Files
autodoc/core/renderast.js
T
Iisyourdad f88ff0259e
Template tests / tests (push) Waiting to run
Template tests / tests (pull_request) Waiting to run
Fix guide editor issues 4-10
2026-06-12 11:07:57 -05:00

141 lines
4.9 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');
const { orderedBlocks, blockText } = require('./blocks');
/**
* 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 || []).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,
};
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 };