ca73db68e3
- Render AST: placeholder expansion, hierarchical numbering (1, 1.1), hidden/skipped filtering, preview step limit, annotated image rendering - JSON exporter with sidecar annotated PNGs - Markdown exporter: TOC with resolving anchors, text blocks as blockquotes, fenced code, tables, Azure-wiki image sizing option - Self-contained HTML exporters (data-URI images, zero external refs); rich variant adds floating TOC, checkboxes, localStorage progress - HTML->Markdown converter for the sanitizer-allowed tag set - 7 exporter workflow tests (42 total) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
4.1 KiB
JavaScript
124 lines
4.1 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { sanitizeHtml } = require('./sanitize');
|
|
const { htmlToText, deepClone } = require('./util');
|
|
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
|
|
const { decodePng } = require('./png');
|
|
const { renderAnnotations, applyFocusedView } = require('./raster');
|
|
|
|
/**
|
|
* The Render AST is the single normalized document model every exporter
|
|
* consumes. It resolves placeholders, hierarchical numbering, hidden/skipped
|
|
* filtering, and absolute image paths — exporters never read the store.
|
|
*/
|
|
|
|
function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSteps = 0 } = {}) {
|
|
const guide = store.getGuide(guideId);
|
|
const stepsMap = store.listSteps(guideId);
|
|
|
|
const includedIds = guide.stepsOrder.filter((id) => {
|
|
const s = stepsMap.get(id);
|
|
if (!s || s.hidden) return false;
|
|
if (s.skipped && guide.flags.hideSkippedStepsInExports) return false;
|
|
return true;
|
|
});
|
|
|
|
const values = resolveScopes({
|
|
guide,
|
|
globals,
|
|
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
|
|
});
|
|
const expand = (text) => expandPlaceholders(text, values);
|
|
|
|
const steps = [];
|
|
const topCounter = { n: 0 };
|
|
const childCounters = new Map();
|
|
const numberOf = new Map();
|
|
|
|
for (const id of includedIds) {
|
|
const raw = stepsMap.get(id);
|
|
const step = deepClone(raw);
|
|
let number;
|
|
let depth = 0;
|
|
if (step.parentStepId && numberOf.has(step.parentStepId)) {
|
|
const parentNo = numberOf.get(step.parentStepId);
|
|
const c = (childCounters.get(step.parentStepId) || 0) + 1;
|
|
childCounters.set(step.parentStepId, c);
|
|
number = `${parentNo}.${c}`;
|
|
depth = number.split('.').length - 1;
|
|
} else {
|
|
step.parentStepId = null; // orphan substeps render top-level
|
|
topCounter.n += 1;
|
|
number = String(topCounter.n);
|
|
}
|
|
numberOf.set(step.stepId, number);
|
|
|
|
const ast = {
|
|
stepId: step.stepId,
|
|
parentStepId: step.parentStepId,
|
|
number,
|
|
depth,
|
|
kind: step.kind,
|
|
status: step.status,
|
|
skipped: step.skipped,
|
|
forceNewPage: Boolean(step.forceNewPage),
|
|
title: expand(step.title || ''),
|
|
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')),
|
|
descriptionText: htmlToText(expand(step.descriptionHtml || '')),
|
|
focusedView: step.focusedView,
|
|
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
|
|
textBlocks: (step.textBlocks || []).map((tb) => ({
|
|
...tb,
|
|
title: expand(tb.title || ''),
|
|
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
|
|
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
|
|
})),
|
|
codeBlocks: step.codeBlocks || [],
|
|
tableBlocks: step.tableBlocks || [],
|
|
links: step.links || [],
|
|
image: null,
|
|
};
|
|
if (step.image) {
|
|
const absPath = path.join(store.stepDir(guideId, step.stepId), step.image.workingPath);
|
|
if (fs.existsSync(absPath)) {
|
|
ast.image = { absPath, width: step.image.size.width, height: step.image.size.height };
|
|
}
|
|
}
|
|
steps.push(ast);
|
|
}
|
|
|
|
const limited = maxSteps > 0 ? steps.slice(0, maxSteps) : steps;
|
|
|
|
return {
|
|
format: 'stepforge-render-ast',
|
|
version: 1,
|
|
generatedAt: now.toISOString(),
|
|
placeholders: values,
|
|
guide: {
|
|
id: guide.guideId,
|
|
title: expand(guide.title),
|
|
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')),
|
|
descriptionText: htmlToText(expand(guide.descriptionHtml || '')),
|
|
createdAt: guide.createdAt,
|
|
updatedAt: guide.updatedAt,
|
|
flags: guide.flags,
|
|
},
|
|
steps: limited,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decode a step's working image and burn in annotations + focused view.
|
|
* Returns an RGBA raster image, or null for steps without images.
|
|
*/
|
|
function renderStepImage(astStep) {
|
|
if (!astStep.image) return null;
|
|
const base = decodePng(fs.readFileSync(astStep.image.absPath));
|
|
const annotated = renderAnnotations(base, astStep.annotations);
|
|
return applyFocusedView(annotated, astStep.focusedView);
|
|
}
|
|
|
|
module.exports = { buildRenderAst, renderStepImage };
|