Add Render AST and text exporters: JSON, Markdown, HTML simple/rich
- 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>
This commit is contained in:
@@ -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 };
|
||||
@@ -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/<steps-slug>/.
|
||||
* 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 };
|
||||
@@ -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:<id> 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) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function stepBodyHtml(step, ast, images, tpl) {
|
||||
const parts = [];
|
||||
parts.push(blocksHtml(step, 'before-description'));
|
||||
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
|
||||
const img = images.get(step.stepId);
|
||||
if (img && tpl.includeImages) {
|
||||
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
|
||||
}
|
||||
for (const cb of step.codeBlocks) {
|
||||
parts.push(`<pre class="code"><code>${escapeHtml(cb.code || '')}</code></pre>`);
|
||||
}
|
||||
for (const tb of step.tableBlocks || []) {
|
||||
if (!tb.rows || !tb.rows.length) continue;
|
||||
const [head, ...rest] = tb.rows;
|
||||
parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>'
|
||||
+ rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
|
||||
+ '</tbody></table>');
|
||||
}
|
||||
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) => `
|
||||
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
|
||||
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2>
|
||||
${stepBodyHtml(step, ast, images, tpl)}
|
||||
</section>`).join('\n');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(ast.guide.title)}</title>
|
||||
<style>${BASE_CSS}${tpl.customCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
|
||||
${stepsHtml}
|
||||
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps</small></footer>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
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) =>
|
||||
`<li class="d${step.depth}"><a href="#${anchorFor(step)}">${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</a></li>`
|
||||
).join('\n');
|
||||
|
||||
const stepsHtml = ast.steps.map((step) => `
|
||||
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
|
||||
<h2>
|
||||
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
|
||||
${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
|
||||
</h2>
|
||||
${stepBodyHtml(step, ast, images, tpl)}
|
||||
</section>`).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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(ast.guide.title)}</title>
|
||||
<style>${BASE_CSS}${richCss}${tpl.customCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="toc"><strong>Contents</strong><ul>
|
||||
${tocHtml}
|
||||
</ul></nav>
|
||||
<main>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
|
||||
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div>
|
||||
${stepsHtml}
|
||||
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps</small></footer>
|
||||
</main>
|
||||
</div>
|
||||
<script>${script}</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
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 };
|
||||
@@ -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(/<table>([\s\S]*?)<\/table>/gi, (m, body) => tableToMd(body));
|
||||
|
||||
out = out
|
||||
.replace(/<pre>\s*<code[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi, (m, code) => `\n\`\`\`\n${decodeEntities(code)}\n\`\`\`\n`)
|
||||
.replace(/<pre>([\s\S]*?)<\/pre>/gi, (m, code) => `\n\`\`\`\n${decodeEntities(code)}\n\`\`\`\n`)
|
||||
.replace(/<h1>([\s\S]*?)<\/h1>/gi, '\n# $1\n')
|
||||
.replace(/<h2>([\s\S]*?)<\/h2>/gi, '\n## $1\n')
|
||||
.replace(/<h3>([\s\S]*?)<\/h3>/gi, '\n### $1\n')
|
||||
.replace(/<h4>([\s\S]*?)<\/h4>/gi, '\n#### $1\n')
|
||||
.replace(/<blockquote>([\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(/<u>([\s\S]*?)<\/u>/gi, '$1')
|
||||
.replace(/<s>([\s\S]*?)<\/s>/gi, '~~$1~~')
|
||||
.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (m, c) => `\`${decodeEntities(c)}\``)
|
||||
.replace(/<a\s+[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (m, href, label) => {
|
||||
if (href.startsWith('step:')) return `[${stripTags(label)}](#step-${href.slice(5)})`;
|
||||
return `[${stripTags(label)}](${href})`;
|
||||
})
|
||||
.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
||||
|
||||
// lists
|
||||
out = out.replace(/<ol>([\s\S]*?)<\/ol>/gi, (m, body) => listToMd(body, true));
|
||||
out = out.replace(/<ul>([\s\S]*?)<\/ul>/gi, (m, body) => listToMd(body, false));
|
||||
|
||||
out = out
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<hr\s*\/?>/gi, '\n---\n')
|
||||
.replace(/<p>/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(/<li>([\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(/<tr>([\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 };
|
||||
@@ -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-<title>/ 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 };
|
||||
@@ -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(``, '');
|
||||
} else {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user