'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 };