Fix guide editor issues 4-10
Template tests / tests (pull_request) Has been cancelled
Template tests / tests (push) Has been cancelled

This commit is contained in:
Iisyourdad
2026-06-12 11:07:57 -05:00
parent d966ac762d
commit f88ff0259e
22 changed files with 598 additions and 174 deletions
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
function blockText(block) {
if (!block || typeof block !== 'object') return '';
for (const key of ['code', 'text', 'body', 'value', 'content']) {
const value = block[key];
if (value != null && value !== '') return String(value);
}
return '';
}
function orderedBlocks(step) {
const blocks = [];
for (const tb of step.textBlocks || []) {
blocks.push({ kind: 'text', ...tb });
}
for (const cb of step.codeBlocks || []) {
blocks.push({ kind: 'code', ...cb, code: blockText(cb) });
}
for (const tbl of step.tableBlocks || []) {
blocks.push({ kind: 'table', ...tbl });
}
return blocks.sort((a, b) => (
(Number.isFinite(a.order) ? a.order : 0) - (Number.isFinite(b.order) ? b.order : 0)
|| (BLOCK_KIND_ORDER[a.kind] - BLOCK_KIND_ORDER[b.kind])
|| String(a.id || '').localeCompare(String(b.id || ''))
));
}
function nextBlockOrder(step) {
return orderedBlocks(step).reduce((max, block) => Math.max(max, Number.isFinite(block.order) ? block.order : 0), 0) + 1;
}
module.exports = {
BLOCK_KIND_ORDER,
blockText,
orderedBlocks,
nextBlockOrder,
};
+19 -2
View File
@@ -7,6 +7,7 @@ const { htmlToText, deepClone } = require('./util');
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
const { decodePng } = require('./png');
const { renderAnnotations, applyFocusedView } = require('./raster');
const { orderedBlocks, blockText } = require('./blocks');
/**
* The Render AST is the single normalized document model every exporter
@@ -75,8 +76,24 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
})),
codeBlocks: step.codeBlocks || [],
tableBlocks: step.tableBlocks || [],
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
tableBlocks: (step.tableBlocks || []).map((tb) => ({
...tb,
rows: Array.isArray(tb.rows) ? tb.rows.map((row) => [...row]) : [],
})),
blocks: orderedBlocks(step).map((block) => {
if (block.kind === 'text') {
return {
...block,
title: expand(block.title || ''),
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
};
}
if (block.kind === 'code') return { ...block };
if (block.kind === 'table') return { ...block };
return { ...block };
}),
links: step.links || [],
image: null,
};
+34 -5
View File
@@ -1,7 +1,8 @@
'use strict';
const { newId, nowIso, deepClone } = require('./util');
const { newId, nowIso } = require('./util');
const { sanitizeHtml } = require('./sanitize');
const { blockText } = require('./blocks');
const SCHEMA_VERSION = 1;
@@ -49,6 +50,12 @@ function createGuide(fields = {}) {
}
function createStep(fields = {}) {
let nextOrder = 1;
const takeOrder = (block) => {
const order = Number.isFinite(block && block.order) ? block.order : nextOrder;
nextOrder = Math.max(nextOrder, order + 1);
return order;
};
return {
stepId: fields.stepId || newId('step'),
parentStepId: fields.parentStepId || null,
@@ -69,9 +76,9 @@ function createStep(fields = {}) {
image: fields.image || null, // { originalPath, workingPath, size:{width,height} }
extraImages: fields.extraImages || [], // multi-image steps
annotations: (fields.annotations || []).map(normalizeAnnotation),
textBlocks: (fields.textBlocks || []).map(normalizeTextBlock),
codeBlocks: fields.codeBlocks || [], // { id, language, code }
tableBlocks: fields.tableBlocks || [], // { id, rows: [[cellText,..],..], headerRow }
textBlocks: (fields.textBlocks || []).map((tb) => normalizeTextBlock(tb, takeOrder(tb))),
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
links: fields.links || [], // { id, label, targetStepId }
};
}
@@ -94,16 +101,36 @@ function normalizeAnnotation(a) {
return ann;
}
function normalizeTextBlock(tb) {
function normalizeTextBlock(tb, order = null) {
return {
id: tb.id || newId('tb'),
position: TEXTBLOCK_POSITIONS.includes(tb.position) ? tb.position : 'after-description',
level: TEXTBLOCK_LEVELS.includes(tb.level) ? tb.level : 'info',
order: Number.isFinite(tb.order) ? tb.order : order,
title: tb.title || '',
descriptionHtml: sanitizeHtml(tb.descriptionHtml || ''),
};
}
function normalizeCodeBlock(cb, order = null) {
return {
id: cb.id || newId('cb'),
order: Number.isFinite(cb.order) ? cb.order : order,
language: typeof cb.language === 'string' ? cb.language : '',
code: blockText(cb),
};
}
function normalizeTableBlock(tb, order = null) {
return {
id: tb.id || newId('tbl'),
order: Number.isFinite(tb.order) ? tb.order : order,
rows: Array.isArray(tb.rows)
? tb.rows.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '')) : []))
: [],
};
}
function num(v, fallback) {
return Number.isFinite(v) ? v : fallback;
}
@@ -173,6 +200,8 @@ module.exports = {
createStep,
normalizeAnnotation,
normalizeTextBlock,
normalizeCodeBlock,
normalizeTableBlock,
validateGuide,
validateStep,
normalizeGuide,
+2 -1
View File
@@ -2,6 +2,7 @@
const path = require('node:path');
const { writeJsonSync, readJsonIfExists, htmlToText } = require('./util');
const { blockText } = require('./blocks');
/**
* Local full-text search over guide titles, descriptions, step titles/
@@ -57,7 +58,7 @@ class SearchIndex {
const parts = [
htmlToText(step.descriptionHtml),
...(step.textBlocks || []).map((tb) => `${tb.title} ${htmlToText(tb.descriptionHtml)}`),
...(step.codeBlocks || []).map((cb) => cb.code || ''),
...(step.codeBlocks || []).map((cb) => blockText(cb)),
...(step.annotations || []).map((a) => a.text || ''),
];
this.docs[`s:${guide.guideId}:${step.stepId}`] = {
+3 -4
View File
@@ -221,9 +221,8 @@ class GuideStore {
}
saveStep(guideId, step) {
const stored = deepClone(step);
const stored = normalizeStep(deepClone(step));
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
for (const tb of stored.textBlocks || []) tb.descriptionHtml = sanitizeHtml(tb.descriptionHtml);
validateStep(stored);
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
const guide = this.getGuide(guideId);
@@ -263,8 +262,8 @@ class GuideStore {
}
/** Replace the working image (crop result). The original is never touched. */
setWorkingImage(guideId, stepId, pngBuffer, size) {
const step = this.getStep(guideId, stepId);
setWorkingImage(guideId, stepId, pngBuffer, size, stepPatch = null) {
const step = stepPatch ? deepClone(stepPatch) : this.getStep(guideId, stepId);
if (!step.image) throw new Error('step has no image');
atomicWriteFileSync(path.join(this.stepDir(guideId, stepId), step.image.workingPath), pngBuffer);
step.image.size = size;
+1 -1
View File
@@ -11,7 +11,7 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
* defaults, shareable as .sfglt zip files.
*/
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
class TemplateManager {
constructor(templatesDir) {