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,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