diff --git a/core/renderast.js b/core/renderast.js new file mode 100644 index 0000000..1cdca6d --- /dev/null +++ b/core/renderast.js @@ -0,0 +1,123 @@ +'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 }; diff --git a/exporters/common.js b/exporters/common.js new file mode 100644 index 0000000..143878b --- /dev/null +++ b/exporters/common.js @@ -0,0 +1,55 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { slugify } = require('../core/util'); +const { encodePng } = require('../core/png'); +const { renderStepImage } = require('../core/renderast'); + +/** + * Shared exporter helpers: every image-bearing exporter renders annotated + * step images through the same pipeline so output is consistent. + */ + +function guideSlug(ast) { + return slugify(ast.guide.title, 'guide'); +} + +function imagesDirName(ast) { + return `steps-${guideSlug(ast)}`; +} + +/** + * Render every image step to an annotated PNG inside outDir//. + * Returns Map stepId -> { relPath, width, height }. + */ +function writeStepImages(ast, outDir) { + const dirName = imagesDirName(ast); + const dir = path.join(outDir, dirName); + const result = new Map(); + let n = 0; + for (const step of ast.steps) { + n += 1; + const img = renderStepImage(step); + if (!img) continue; + fs.mkdirSync(dir, { recursive: true }); + const fileName = `${String(n).padStart(3, '0')}-${slugify(step.title || step.stepId, step.stepId)}.png`; + fs.writeFileSync(path.join(dir, fileName), encodePng(img)); + result.set(step.stepId, { relPath: `${dirName}/${fileName}`, width: img.width, height: img.height }); + } + return result; +} + +/** Render step images in-memory (for self-contained HTML, PDF, GIF...). */ +function renderAllImages(ast) { + const result = new Map(); + for (const step of ast.steps) { + const img = renderStepImage(step); + if (img) result.set(step.stepId, img); + } + return result; +} + +const LEVEL_LABEL = { info: 'Note', warn: 'Warning', error: 'Important', success: 'Tip' }; + +module.exports = { guideSlug, imagesDirName, writeStepImages, renderAllImages, LEVEL_LABEL }; diff --git a/exporters/html.js b/exporters/html.js new file mode 100644 index 0000000..6cab834 --- /dev/null +++ b/exporters/html.js @@ -0,0 +1,223 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { escapeHtml } = require('../core/util'); +const { encodePng } = require('../core/png'); +const { guideSlug, renderAllImages, LEVEL_LABEL } = require('./common'); + +/** + * HTML exporters. Both variants are fully self-contained single files: + * screenshots are embedded as data URIs, styles are inline, and there are + * no external (network) references of any kind. + * + * - simple: lightweight, copy-paste friendly markup. + * - rich: floating TOC, per-step checkboxes with progress persisted in the + * browser's localStorage (local only), and a progress bar. + */ + +const DEFAULT_TEMPLATE = { + includeImages: true, + accentColor: '#2563eb', + customCss: '', +}; + +function anchorFor(step) { + return `step-${step.number.replace(/\./g, '-')}`; +} + +function dataUri(img) { + return `data:image/png;base64,${encodePng(img).toString('base64')}`; +} + +function stepLinkRewrite(html, ast) { + // step: hrefs become local anchors when the target step is exported. + return html.replace(/href="step:([^"]+)"/g, (m, id) => { + const target = ast.steps.find((s) => s.stepId === id); + return target ? `href="#${anchorFor(target)}"` : 'data-missing-step-link="true"'; + }); +} + +function blocksHtml(step, position) { + return step.textBlocks + .filter((tb) => tb.position === position) + .map((tb) => `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`) + .join('\n'); +} + +function stepBodyHtml(step, ast, images, tpl) { + const parts = []; + parts.push(blocksHtml(step, 'before-description')); + if (step.descriptionHtml) parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); + const img = images.get(step.stepId); + if (img && tpl.includeImages) { + parts.push(`Step ${escapeHtml(step.number)}`); + } + for (const cb of step.codeBlocks) { + parts.push(`
${escapeHtml(cb.code || '')}
`); + } + for (const tb of step.tableBlocks || []) { + if (!tb.rows || !tb.rows.length) continue; + const [head, ...rest] = tb.rows; + parts.push('' + head.map((c) => ``).join('') + '' + + rest.map((r) => '' + r.map((c) => ``).join('') + '').join('') + + '
${escapeHtml(c)}
${escapeHtml(c)}
'); + } + parts.push(blocksHtml(step, 'after-description')); + parts.push(blocksHtml(step, 'after-image')); + return parts.filter(Boolean).join('\n'); +} + +const BASE_CSS = ` + body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px; + padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; } + h1 { font-size: 1.7em; margin-bottom: .2em; } + h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; } + img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; } + pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; } + table { border-collapse: collapse; margin: .6em 0; } + th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; } + .block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } + .block-warn { border-color: #f59e0b; background: #fffbeb; } + .block-error { border-color: #ef4444; background: #fef2f2; } + .block-success { border-color: #10b981; background: #ecfdf5; } + .skipped { opacity: .55; } + @media (prefers-color-scheme: dark) { + body { background: #111827; color: #e5e7eb; } + h2 { border-color: #374151; } + pre.code, .block { background: #1f2937; } + th, td { border-color: #4b5563; } + } +`; + +function exportHtmlSimple(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + fs.mkdirSync(outDir, { recursive: true }); + const images = tpl.includeImages ? renderAllImages(ast) : new Map(); + + const stepsHtml = ast.steps.map((step) => ` +
+

${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}

+ ${stepBodyHtml(step, ast, images, tpl)} +
`).join('\n'); + + const html = ` + + + + +${escapeHtml(ast.guide.title)} + + + +

${escapeHtml(ast.guide.title)}

+${ast.guide.descriptionHtml ? `
${ast.guide.descriptionHtml}
` : ''} +${stepsHtml} +
Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
+ + +`; + const file = path.join(outDir, `${guideSlug(ast)}.html`); + fs.writeFileSync(file, html); + return { file, imageCount: images.size }; +} + +function exportHtmlRich(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + fs.mkdirSync(outDir, { recursive: true }); + const images = tpl.includeImages ? renderAllImages(ast) : new Map(); + const storageKey = `stepforge-progress-${ast.guide.id}`; + + const tocHtml = ast.steps.map((step) => + `
  • ${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
  • ` + ).join('\n'); + + const stepsHtml = ast.steps.map((step) => ` +
    +

    + + ${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')} +

    + ${stepBodyHtml(step, ast, images, tpl)} +
    `).join('\n'); + + const richCss = ` + .layout { display: flex; gap: 28px; max-width: 1180px; margin: 0 auto; } + nav.toc { position: sticky; top: 16px; align-self: flex-start; min-width: 220px; max-width: 280px; + max-height: calc(100vh - 32px); overflow-y: auto; font-size: .92em; + border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; } + nav.toc ul { list-style: none; margin: 0; padding: 0; } + nav.toc li { margin: .25em 0; } + nav.toc li.d1 { padding-left: 14px; } nav.toc li.d2 { padding-left: 28px; } + nav.toc a { color: inherit; text-decoration: none; } + nav.toc a:hover { color: ${tpl.accentColor}; } + main { flex: 1; min-width: 0; } + .progress { position: sticky; top: 0; background: inherit; padding: 8px 0; z-index: 2; } + .progress .bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; } + .progress .fill { height: 100%; width: 0; background: ${tpl.accentColor}; transition: width .2s; } + label.check { margin-right: 8px; } + section.step.done h2 { text-decoration: line-through; opacity: .6; } + @media (max-width: 900px) { .layout { flex-direction: column; } nav.toc { position: static; max-width: none; } } + @media (prefers-color-scheme: dark) { nav.toc { border-color: #374151; } .progress .bar { background: #374151; } } +`; + + const script = ` +(function () { + var key = ${JSON.stringify(storageKey)}; + var state = {}; + try { state = JSON.parse(localStorage.getItem(key) || '{}'); } catch (e) {} + var boxes = document.querySelectorAll('input.step-done'); + function refresh() { + var done = 0; + boxes.forEach(function (b) { + b.closest('section').classList.toggle('done', b.checked); + if (b.checked) done++; + }); + var fill = document.querySelector('.progress .fill'); + if (fill) fill.style.width = (boxes.length ? (100 * done / boxes.length) : 0) + '%'; + var label = document.querySelector('.progress .label'); + if (label) label.textContent = done + ' / ' + boxes.length + ' steps done'; + } + boxes.forEach(function (b) { + b.checked = !!state[b.dataset.step]; + b.addEventListener('change', function () { + state[b.dataset.step] = b.checked; + try { localStorage.setItem(key, JSON.stringify(state)); } catch (e) {} + refresh(); + }); + }); + refresh(); +})(); +`; + + const html = ` + + + + +${escapeHtml(ast.guide.title)} + + + +
    + +
    +

    ${escapeHtml(ast.guide.title)}

    +${ast.guide.descriptionHtml ? `
    ${ast.guide.descriptionHtml}
    ` : ''} +
    +${stepsHtml} +
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
    +
    +
    + + + +`; + const file = path.join(outDir, `${guideSlug(ast)}-rich.html`); + fs.writeFileSync(file, html); + return { file, imageCount: images.size }; +} + +module.exports = { exportHtmlSimple, exportHtmlRich, DEFAULT_TEMPLATE, anchorFor }; diff --git a/exporters/htmlmd.js b/exporters/htmlmd.js new file mode 100644 index 0000000..fdc224c --- /dev/null +++ b/exporters/htmlmd.js @@ -0,0 +1,82 @@ +'use strict'; + +const { decodeEntities } = require('../core/util'); + +/** + * Convert sanitized description HTML fragments to Markdown. Handles the tags + * the sanitizer allows; anything unexpected degrades to its text content. + */ + +function htmlToMarkdown(html) { + if (!html) return ''; + let out = String(html); + + // tables first (their inner tags would otherwise be consumed) + out = out.replace(/([\s\S]*?)<\/table>/gi, (m, body) => tableToMd(body)); + + out = out + .replace(/
    \s*]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (m, code) => `\n\`\`\`\n${decodeEntities(code)}\n\`\`\`\n`)
    +    .replace(/
    ([\s\S]*?)<\/pre>/gi, (m, code) => `\n\`\`\`\n${decodeEntities(code)}\n\`\`\`\n`)
    +    .replace(/

    ([\s\S]*?)<\/h1>/gi, '\n# $1\n') + .replace(/

    ([\s\S]*?)<\/h2>/gi, '\n## $1\n') + .replace(/

    ([\s\S]*?)<\/h3>/gi, '\n### $1\n') + .replace(/

    ([\s\S]*?)<\/h4>/gi, '\n#### $1\n') + .replace(/
    ([\s\S]*?)<\/blockquote>/gi, (m, q) => `\n> ${stripTags(q).trim().replace(/\n/g, '\n> ')}\n`) + .replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, '**$2**') + .replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, '*$2*') + .replace(/([\s\S]*?)<\/u>/gi, '$1') + .replace(/([\s\S]*?)<\/s>/gi, '~~$1~~') + .replace(/]*>([\s\S]*?)<\/code>/gi, (m, c) => `\`${decodeEntities(c)}\``) + .replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (m, href, label) => { + if (href.startsWith('step:')) return `[${stripTags(label)}](#step-${href.slice(5)})`; + return `[${stripTags(label)}](${href})`; + }) + .replace(/]*>([\s\S]*?)<\/a>/gi, '$1'); + + // lists + out = out.replace(/
      ([\s\S]*?)<\/ol>/gi, (m, body) => listToMd(body, true)); + out = out.replace(/
        ([\s\S]*?)<\/ul>/gi, (m, body) => listToMd(body, false)); + + out = out + .replace(//gi, '\n') + .replace(//gi, '\n---\n') + .replace(/

        /gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, ''); + + return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +function listToMd(body, ordered) { + let i = 0; + const items = []; + for (const m of body.matchAll(/

      • ([\s\S]*?)<\/li>/gi)) { + i += 1; + const text = stripTags(m[1]).trim(); + items.push(ordered ? `${i}. ${text}` : `- ${text}`); + } + return `\n${items.join('\n')}\n`; +} + +function tableToMd(body) { + const rows = []; + for (const rowM of body.matchAll(/

    ([\s\S]*?)<\/tr>/gi)) { + const cells = []; + for (const cellM of rowM[1].matchAll(/<(td|th)[^>]*>([\s\S]*?)<\/\1>/gi)) { + cells.push(stripTags(cellM[2]).trim().replace(/\|/g, '\\|')); + } + rows.push(cells); + } + if (!rows.length) return ''; + const width = Math.max(...rows.map((r) => r.length)); + const pad = (r) => { while (r.length < width) r.push(''); return r; }; + const lines = [`| ${pad(rows[0]).join(' | ')} |`, `|${' --- |'.repeat(width)}`]; + for (const row of rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`); + return `\n${lines.join('\n')}\n`; +} + +function stripTags(html) { + return decodeEntities(String(html).replace(/<[^>]+>/g, '')); +} + +module.exports = { htmlToMarkdown }; diff --git a/exporters/json.js b/exporters/json.js new file mode 100644 index 0000000..7bc8028 --- /dev/null +++ b/exporters/json.js @@ -0,0 +1,56 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { guideSlug, writeStepImages } = require('./common'); + +/** + * JSON exporter: structured guide + steps, annotated screenshots written to + * a sidecar steps-/ folder, image paths relative to the JSON file. + */ + +const DEFAULT_TEMPLATE = { + pretty: true, + includeImages: true, + includeAnnotations: true, +}; + +function exportJson(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + fs.mkdirSync(outDir, { recursive: true }); + const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map(); + + const doc = { + format: 'stepforge-guide', + version: 1, + generatedAt: ast.generatedAt, + guide: { + title: ast.guide.title, + descriptionHtml: ast.guide.descriptionHtml, + createdAt: ast.guide.createdAt, + updatedAt: ast.guide.updatedAt, + }, + steps: ast.steps.map((step) => ({ + number: step.number, + kind: step.kind, + status: step.status, + title: step.title, + descriptionHtml: step.descriptionHtml, + descriptionText: step.descriptionText, + image: images.has(step.stepId) ? images.get(step.stepId) : null, + annotations: tpl.includeAnnotations ? step.annotations : undefined, + textBlocks: step.textBlocks.map((tb) => ({ + position: tb.position, level: tb.level, title: tb.title, descriptionHtml: tb.descriptionHtml, + })), + codeBlocks: step.codeBlocks, + tableBlocks: step.tableBlocks, + links: step.links, + })), + }; + + const file = path.join(outDir, `${guideSlug(ast)}.json`); + fs.writeFileSync(file, JSON.stringify(doc, null, tpl.pretty ? 2 : 0) + '\n'); + return { file, imageCount: images.size }; +} + +module.exports = { exportJson, DEFAULT_TEMPLATE }; diff --git a/exporters/markdown.js b/exporters/markdown.js new file mode 100644 index 0000000..abd9bd0 --- /dev/null +++ b/exporters/markdown.js @@ -0,0 +1,93 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { guideSlug, writeStepImages, LEVEL_LABEL } = require('./common'); +const { htmlToMarkdown } = require('./htmlmd'); + +/** + * Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder. + * azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept. + */ + +const DEFAULT_TEMPLATE = { + toc: true, + includeImages: true, + azureWiki: false, + imageMaxWidth: 0, // 0 = natural size +}; + +function anchorFor(step) { + return `step-${step.number.replace(/\./g, '-')}`; +} + +function exportMarkdown(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + fs.mkdirSync(outDir, { recursive: true }); + const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map(); + const lines = []; + + lines.push(`# ${ast.guide.title}`, ''); + if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), ''); + + if (tpl.toc && ast.steps.length > 1) { + lines.push('## Contents', ''); + for (const step of ast.steps) { + const indent = ' '.repeat(step.depth); + lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`); + } + lines.push(''); + } + + for (const step of ast.steps) { + const heading = step.depth > 0 ? '###' : '##'; + lines.push(`<a id="${anchorFor(step)}"></a>`, ''); + lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); + if (step.skipped) lines.push('*(skipped)*', ''); + + emitBlocks(lines, step, 'before-description'); + + if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); + + const img = images.get(step.stepId); + if (img) { + if (tpl.azureWiki && tpl.imageMaxWidth > 0) { + lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, ''); + } else { + lines.push(`![Step ${step.number}](${img.relPath})`, ''); + } + } + + for (const cb of step.codeBlocks) { + lines.push(`\`\`\`${cb.language || ''}`, cb.code || '', '```', ''); + } + for (const tb of step.tableBlocks || []) { + if (!tb.rows || !tb.rows.length) continue; + const width = Math.max(...tb.rows.map((r) => r.length)); + const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; }; + lines.push(`| ${pad(tb.rows[0]).join(' | ')} |`); + lines.push(`|${' --- |'.repeat(width)}`); + for (const row of tb.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`); + lines.push(''); + } + + emitBlocks(lines, step, 'after-description'); + emitBlocks(lines, step, 'after-image'); + } + + const file = path.join(outDir, `${guideSlug(ast)}.md`); + fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n'); + return { file, imageCount: images.size }; +} + +function emitBlocks(lines, step, position) { + for (const tb of step.textBlocks.filter((b) => b.position === position)) { + const label = LEVEL_LABEL[tb.level] || 'Note'; + lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`); + const body = htmlToMarkdown(tb.descriptionHtml); + if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`); + lines.push(''); + } +} + +module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js new file mode 100644 index 0000000..8d18da5 --- /dev/null +++ b/tests/unit/exporters-text.test.js @@ -0,0 +1,201 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { buildRenderAst, renderStepImage } = require('../../core/renderast'); +const { exportJson } = require('../../exporters/json'); +const { exportMarkdown } = require('../../exporters/markdown'); +const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); +const { htmlToMarkdown } = require('../../exporters/htmlmd'); +const { decodePng } = require('../../core/png'); +const { buildFixtureGuide } = require('./fixture-guide'); +const { makeTmpDir, rmrf } = require('./helpers'); + +test('render AST: numbering, placeholder expansion, hidden/skipped filtering', (t) => { + const root = makeTmpDir('ast'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + + const ast = buildRenderAst(store, guide.guideId, { globals: { Author: 'GlobalAuthor' } }); + + assert.equal(ast.guide.title, 'Configure AcmeSync backups'); + // Guide-level placeholder wins over global. + assert.ok(ast.guide.descriptionHtml.includes('Casey')); + + // Hidden always excluded; skipped excluded by default flag. + const titles = ast.steps.map((s) => s.title); + assert.ok(!titles.includes('Internal-only note')); + assert.ok(!titles.includes('Legacy path')); + + // Hierarchical numbering: 1, 1.1, 2 + assert.deepEqual(ast.steps.map((s) => s.number), ['1', '1.1', '2']); + assert.equal(ast.steps[0].title, 'Open AcmeSync settings'); + assert.equal(ast.steps[1].depth, 1); + + // Step images resolve to real decodable files with annotations burned in. + const img = renderStepImage(ast.steps[0]); + assert.equal(img.width, 320); + // Red rect stroke on the left border (x=0.125*320=40), away from the badge. + const p = (100 * 320 + 40) * 4; + assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]); +}); + +test('JSON export produces a parseable document with real image files', (t) => { + const root = makeTmpDir('expjson'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + const out = path.join(root, 'out'); + + const ast = buildRenderAst(store, guide.guideId); + const { file, imageCount } = exportJson(ast, out); + + const doc = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(doc.guide.title, 'Configure AcmeSync backups'); + assert.equal(doc.steps.length, 3); + assert.equal(imageCount, 2); + assert.deepEqual(doc.steps.map((s) => s.number), ['1', '1.1', '2']); + + // Image paths are relative to the JSON file and decode as PNGs of the + // declared dimensions. + for (const step of doc.steps.filter((s) => s.image)) { + const imgFile = path.join(out, step.image.relPath); + const img = decodePng(fs.readFileSync(imgFile)); + assert.equal(img.width, step.image.width); + assert.equal(img.height, step.image.height); + } + + // Code/table blocks survive structurally. + const s2 = doc.steps.find((s) => s.number === '2'); + assert.equal(s2.codeBlocks[0].language, 'cron'); + assert.equal(s2.tableBlocks[0].rows[1][0], 'Weekdays'); +}); + +test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) => { + const root = makeTmpDir('expmd'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + const out = path.join(root, 'out'); + + const ast = buildRenderAst(store, guide.guideId); + const { file } = exportMarkdown(ast, out); + const md = fs.readFileSync(file, 'utf8'); + + // Every TOC link points at an anchor that exists in the document. + const tocLinks = [...md.matchAll(/\]\(#([a-z0-9-]+)\)/g)].map((m) => m[1]); + assert.equal(tocLinks.length, 3); + for (const anchor of tocLinks) { + assert.ok(md.includes(`<a id="${anchor}"></a>`), `anchor ${anchor} exists`); + } + + // Every image reference resolves to a real PNG on disk. + const imgRefs = [...md.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((m) => m[1]); + assert.equal(imgRefs.length, 2); + for (const rel of imgRefs) { + const img = decodePng(fs.readFileSync(path.join(out, rel))); + assert.equal(img.width, 320); + } + + // Structure: title heading, step headings with numbers, fenced code, table. + const lines = md.split('\n'); + assert.equal(lines[0], '# Configure AcmeSync backups'); + assert.ok(lines.some((l) => l.startsWith('## 1. Open AcmeSync settings'))); + assert.ok(lines.some((l) => l.startsWith('### 1.1. Verify the gear icon'))); + const fenceStart = lines.indexOf('```cron'); + assert.ok(fenceStart > 0, 'code fence present'); + assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup'); + assert.equal(lines[fenceStart + 2], '```'); + assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row'); + // Warning text block became a blockquote with its content. + const warnIdx = lines.findIndex((l) => l.startsWith('> **Warning: Access**')); + assert.ok(warnIdx > 0); + assert.equal(lines[warnIdx + 1], '> Admins only.'); +}); + +test('Simple HTML export is self-contained with valid embedded images', (t) => { + const root = makeTmpDir('exphtml'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + const out = path.join(root, 'out'); + + const ast = buildRenderAst(store, guide.guideId); + const { file } = exportHtmlSimple(ast, out); + const html = fs.readFileSync(file, 'utf8'); + + // No external references: every src/href is data:, #anchor, or https user link. + const refs = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]); + for (const ref of refs) { + assert.ok( + ref.startsWith('data:') || ref.startsWith('#') || ref.startsWith('https://docs.example.com'), + `unexpected external ref: ${ref.slice(0, 60)}` + ); + } + + // Embedded images decode back to the original dimensions. + const uris = [...html.matchAll(/src="data:image\/png;base64,([^"]+)"/g)].map((m) => m[1]); + assert.equal(uris.length, 2); + for (const b64 of uris) { + const img = decodePng(Buffer.from(b64, 'base64')); + assert.equal(img.width, 320); + assert.equal(img.height, 200); + } + + // One section per exported step, with the right ids. + const ids = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(ids, ['step-1', 'step-1-1', 'step-2']); +}); + +test('Rich HTML export: TOC matches sections, checkboxes per step, local-only persistence', (t) => { + const root = makeTmpDir('exprich'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + const out = path.join(root, 'out'); + + const ast = buildRenderAst(store, guide.guideId); + const { file } = exportHtmlRich(ast, out); + const html = fs.readFileSync(file, 'utf8'); + + const tocAnchors = [...html.matchAll(/<li class="d\d"><a href="#([^"]+)"/g)].map((m) => m[1]); + const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(tocAnchors, sectionIds); + assert.equal(sectionIds.length, 3); + + const checkboxes = [...html.matchAll(/<input type="checkbox" class="step-done" data-step="([^"]+)"/g)]; + assert.equal(checkboxes.length, 3); + + // Progress persists via localStorage only — no network APIs in the script. + assert.ok(html.includes('localStorage')); + for (const banned of ['fetch(', 'XMLHttpRequest', 'WebSocket', 'navigator.sendBeacon', 'http://']) { + assert.ok(!html.includes(banned), `must not contain ${banned}`); + } +}); + +test('htmlToMarkdown converts the sanitizer-allowed tag set', () => { + const md = htmlToMarkdown( + '<p>Use <b>bold</b>, <em>italic</em> and <code>cmd --flag</code>.</p>' + + '<ul><li>one</li><li>two</li></ul>' + + '<ol><li>first</li><li>second</li></ol>' + + '<table><tr><th>K</th><th>V</th></tr><tr><td>a</td><td>1</td></tr></table>' + + '<pre><code>line1\nline2</code></pre>' + + '<p><a href="https://x.example">link</a> & entity</p>' + ); + const lines = md.split('\n'); + assert.ok(lines.includes('Use **bold**, *italic* and `cmd --flag`.')); + assert.ok(lines.includes('- one') && lines.includes('- two')); + assert.ok(lines.includes('1. first') && lines.includes('2. second')); + assert.ok(lines.includes('| K | V |') && lines.includes('| a | 1 |')); + const fence = lines.indexOf('```'); + assert.deepEqual(lines.slice(fence, fence + 4), ['```', 'line1', 'line2', '```']); + assert.ok(lines.includes('[link](https://x.example) & entity')); +}); + +test('preview mode limits the AST to the first N steps', (t) => { + const root = makeTmpDir('preview'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + const ast = buildRenderAst(store, guide.guideId, { maxSteps: 2 }); + assert.equal(ast.steps.length, 2); + assert.deepEqual(ast.steps.map((s) => s.number), ['1', '1.1']); +}); diff --git a/tests/unit/fixture-guide.js b/tests/unit/fixture-guide.js new file mode 100644 index 0000000..29cde08 --- /dev/null +++ b/tests/unit/fixture-guide.js @@ -0,0 +1,53 @@ +'use strict'; + +const { GuideStore } = require('../../core/store'); +const raster = require('../../core/raster'); +const { encodePng } = require('../../core/png'); + +/** + * Build a realistic guide used by exporter tests: real PNG screenshots, + * annotations, substeps, text/code/table blocks, placeholders, and a + * hidden + skipped step to exercise filtering. + */ +function buildFixtureGuide(rootDir) { + const store = new GuideStore(rootDir); + const guide = store.createGuide({ + title: 'Configure [[Product]] backups', + descriptionHtml: '<p>Maintained by <strong>[[Author]]</strong>.</p>', + placeholders: { Product: 'AcmeSync', Author: 'Casey' }, + }); + + // screenshot 1: blue window with a light panel + const shot1 = raster.createImage(320, 200, [40, 60, 200, 255]); + raster.fillRect(shot1, 40, 30, 240, 140, [240, 240, 245, 255]); + const s1 = store.addStep(guide.guideId, { + title: 'Open [[Product]] settings', + descriptionHtml: '<p>Click the <b>gear</b> icon, then choose <a href="https://docs.example.com">Settings</a>.</p>', + annotations: [ + { type: 'rect', x: 0.125, y: 0.15, w: 0.75, h: 0.7, style: { stroke: '#FF0000', strokeWidth: 6, fill: 'transparent' } }, + { type: 'number', value: 1, x: 0.02, y: 0.05, w: 0.12, h: 0.2, style: { stroke: '#E5484D' } }, + ], + }, encodePng(shot1), { width: 320, height: 200 }); + + const sub = store.addStep(guide.guideId, { + kind: 'empty', + parentStepId: s1.stepId, + title: 'Verify the gear icon is visible', + textBlocks: [{ position: 'after-description', level: 'warn', title: 'Access', descriptionHtml: '<p>Admins only.</p>' }], + }); + + const shot2 = raster.createImage(320, 200, [20, 140, 90, 255]); + const s2 = store.addStep(guide.guideId, { + title: 'Enable nightly backups', + descriptionHtml: '<p>Use the schedule below.</p>', + codeBlocks: [{ id: 'cb1', language: 'cron', code: '0 2 * * * /usr/local/bin/acmesync --backup' }], + tableBlocks: [{ id: 'tb1', rows: [['Day', 'Window'], ['Weekdays', '02:00-03:00'], ['Weekends', '04:00-05:00']] }], + }, encodePng(shot2), { width: 320, height: 200 }); + + const hidden = store.addStep(guide.guideId, { kind: 'empty', title: 'Internal-only note', hidden: true }); + const skipped = store.addStep(guide.guideId, { kind: 'empty', title: 'Legacy path', skipped: true }); + + return { store, guide: store.getGuide(guide.guideId), s1, sub, s2, hidden, skipped }; +} + +module.exports = { buildFixtureGuide };