Merge pull request 'Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process' (#19) from export_changes into main
Template tests / tests (push) Successful in 1m40s

Reviewed-on: Tyler/autodoc#19
This commit was merged in pull request #19.
This commit is contained in:
2026-06-15 22:10:32 +00:00
32 changed files with 1776 additions and 267 deletions
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const path = require('node:path');
const { fork } = require('node:child_process');
const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js');
/**
* Run an export job in a forked helper process, so building the render AST
* and rendering the output (e.g. a multi-step PDF) never blocks the main
* process — and therefore never freezes the editor window — no matter how
* large the guide is.
*/
function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps }) {
return new Promise((resolve, reject) => {
const worker = fork(WORKER_PATH, [], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
worker.removeAllListeners();
worker.kill();
fn(value);
};
worker.once('message', (msg) => {
if (msg && msg.ok) finish(resolve, msg.result);
else finish(reject, new Error((msg && msg.error) || 'Export worker failed.'));
});
worker.once('error', (err) => finish(reject, err));
worker.once('exit', (code) => {
finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`));
});
worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps });
});
}
module.exports = { runExportInWorker };
+15 -4
View File
@@ -11,9 +11,10 @@ const {
const { GuideStore } = require('../core/store'); const { GuideStore } = require('../core/store');
const { Settings } = require('../core/settings'); const { Settings } = require('../core/settings');
const { SearchIndex } = require('../core/search'); const { SearchIndex } = require('../core/search');
const { TemplateManager, FORMATS } = require('../core/templates'); const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast'); const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters'); const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive'); const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks'); const { readLock } = require('../core/locks');
@@ -578,13 +579,17 @@ function setupIpc() {
}); });
// export + preview // export + preview
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f])); h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]).map((format) => ({
id: format,
label: FORMAT_LABELS[format] || format,
})));
h('export:defaults', ({ format }) => { h('export:defaults', ({ format }) => {
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable // Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
// options from it (booleans -> checkbox, numbers -> number, strings -> text). // options from it (booleans -> checkbox, numbers -> number, strings -> text).
const mod = { const mod = {
json: '../exporters/json', json: '../exporters/json',
markdown: '../exporters/markdown', markdown: '../exporters/markdown',
wikijs: '../exporters/wikijs',
'html-simple': '../exporters/html', 'html-simple': '../exporters/html',
'html-rich': '../exporters/html', 'html-rich': '../exporters/html',
confluence: '../exporters/confluence', confluence: '../exporters/confluence',
@@ -607,8 +612,14 @@ function setupIpc() {
dir = res.filePaths[0]; dir = res.filePaths[0];
} }
settings.set(`exports.lastOutputDirs.${format}`, dir); settings.set(`exports.lastOutputDirs.${format}`, dir);
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() }); const result = await runExportInWorker({
const result = runExport(format, ast, dir, options || {}); dataDir: store.root,
guideId,
format,
options: options || {},
outDir: dir,
globals: settings.getGlobalPlaceholders(),
});
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file); if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
return { ok: true, ...result }; return { ok: true, ...result };
}); });
+1
View File
@@ -317,6 +317,7 @@ class StepForgeApp {
const rect = e.target.getBoundingClientRect(); const rect = e.target.getBoundingClientRect();
contextMenu(rect.left, rect.bottom + 4, [ contextMenu(rect.left, rect.bottom + 4, [
{ label: 'Rename guide…', action: () => this.renameGuide() }, { label: 'Rename guide…', action: () => this.renameGuide() },
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() }, { label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() }, { label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() }, { label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
+73 -10
View File
@@ -413,28 +413,43 @@ function showExportDialog({
outDir: outDirInput.value.trim() || null, outDir: outDirInput.value.trim() || null,
}); });
const { close } = openModal({ const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel');
title: 'Export', const previewBtn = el('button', {
body,
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button', {
onClick: async () => { onClick: async () => {
if (typeof onPreview !== 'function') return; if (typeof onPreview !== 'function') return;
setButtonLoading(previewBtn, true, 'Preview…');
try {
await onPreview(payload()); // keep dialog open so settings can be tweaked await onPreview(payload()); // keep dialog open so settings can be tweaked
} finally {
setButtonLoading(previewBtn, false);
}
}, },
}, 'Preview'), }, 'Preview');
el('button.primary', { const exportBtn = el('button.primary', {
onClick: async () => { onClick: async () => {
if (typeof onExport !== 'function') return; if (typeof onExport !== 'function') return;
cancelBtn.disabled = true;
previewBtn.disabled = true;
setButtonLoading(exportBtn, true, 'Exporting…');
try {
const ok = await onExport(payload()); const ok = await onExport(payload());
if (ok !== false) { if (ok !== false) {
close(); close();
resolve(true); resolve(true);
return;
}
} finally {
cancelBtn.disabled = false;
previewBtn.disabled = false;
setButtonLoading(exportBtn, false);
} }
}, },
}, 'Export'), }, 'Export');
],
const { close } = openModal({
title: 'Export',
body,
footer: [cancelBtn, previewBtn, exportBtn],
wide: true, wide: true,
onClose: () => resolve(false), onClose: () => resolve(false),
}); });
@@ -639,6 +654,53 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
}); });
} }
/**
* Optional document metadata (author, co-authors, organization, description)
* shown at the top of the guide, below the title, and surfaced on the PDF
* cover page and the top of other export formats.
*/
function showGuideInfoDialog({ values = {}, onSave } = {}) {
return new Promise((resolve) => {
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
const descriptionInput = el('textarea', {
rows: 4,
placeholder: 'A short summary of this guide.',
}, values.description || '');
const { close } = openModal({
title: 'Guide information',
body: el('div', {},
el('div.muted', { style: { marginBottom: '10px' } },
'All fields are optional. They appear below the title on the guide and on the PDF cover page.'),
labeledRow('Author', authorInput),
labeledRow('Co-authors', coAuthorsInput),
labeledRow('Organization', organizationInput),
labeledRow('Description', descriptionInput, { stacked: true }),
el('div.muted', { style: { marginTop: '-4px' } },
'Shown on the first page of the PDF and at the top of other export formats.'),
),
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button.primary', {
onClick: async () => {
await onSave?.({
author: authorInput.value.trim(),
coAuthors: coAuthorsInput.value.trim(),
organization: organizationInput.value.trim(),
description: descriptionInput.value.trim(),
});
close();
resolve(true);
},
}, 'Save'),
],
onClose: () => resolve(false),
});
});
}
const SHORTCUTS = [ const SHORTCUTS = [
['Capture & steps', [ ['Capture & steps', [
['Ctrl+S', 'Save (writes linked archive when guide is linked)'], ['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
@@ -727,6 +789,7 @@ window.StepForgeDialogs = {
showInfoDialog, showInfoDialog,
showBackupsDialog, showBackupsDialog,
showPlaceholdersDialog, showPlaceholdersDialog,
showGuideInfoDialog,
showShortcutsDialog, showShortcutsDialog,
showTemplateManager, showTemplateManager,
showRecordingReminder, showRecordingReminder,
+38 -10
View File
@@ -87,6 +87,16 @@ function stepNumberMap(steps) {
return numbers; return numbers;
} }
function stepDepth(step, stepMap) {
let depth = 0;
let parent = step.parentStepId;
while (parent && stepMap.has(parent)) {
depth += 1;
parent = stepMap.get(parent).parentStepId;
}
return depth;
}
function isEditableTarget(target) { function isEditableTarget(target) {
return target && ( return target && (
target.tagName === 'INPUT' || target.tagName === 'INPUT' ||
@@ -761,15 +771,10 @@ class GuideEditor {
this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : ''; this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : '';
for (const step of this.steps) { for (const step of this.steps) {
const number = numbers.get(step.stepId) || ''; const number = numbers.get(step.stepId) || '';
let depth = 0; const depth = stepDepth(step, this.stepMap);
let parent = step.parentStepId;
while (parent && this.stepMap.has(parent)) {
depth += 1;
parent = this.stepMap.get(parent).parentStepId;
}
const selected = current && current.stepId === step.stepId; const selected = current && current.stepId === step.stepId;
const checked = this.selectedSteps.has(step.stepId); const checked = this.selectedSteps.has(step.stepId);
const item = el('div.step-item', { const itemProps = {
className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`, className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`,
dataset: { stepId: step.stepId }, dataset: { stepId: step.stepId },
onClick: () => { onClick: () => {
@@ -802,7 +807,9 @@ class GuideEditor {
{ label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() }, { label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() },
]); ]);
}, },
}, };
if (depth) itemProps.style = { marginLeft: `${depth * 18}px` };
const item = el('div.step-item', itemProps,
this.stepSelectMode this.stepSelectMode
? el('input', { ? el('input', {
type: 'checkbox', type: 'checkbox',
@@ -818,7 +825,8 @@ class GuideEditor {
step.parentStepId ? 'sub' : '', step.parentStepId ? 'sub' : '',
step.hidden ? 'hidden' : '', step.hidden ? 'hidden' : '',
step.skipped ? 'skipped' : '', step.skipped ? 'skipped' : '',
].filter(Boolean).join(' · '))); ].filter(Boolean).join(' · ')),
);
this.dom.stepsList.append(item); this.dom.stepsList.append(item);
} }
if (!this.steps.length) { if (!this.steps.length) {
@@ -1571,6 +1579,22 @@ class GuideEditor {
}); });
} }
async openGuideInfo() {
if (!this.guide) return;
await dialogs.showGuideInfoDialog({
values: {
...this.guide.metadata,
description: htmlToPlainText(this.guide.descriptionHtml || ''),
},
onSave: async ({ description, ...metadata }) => {
this.guide.metadata = metadata;
this.guide.descriptionHtml = textToHtml(description);
await api.guide.save({ guide: this.guide });
this.onToast('Guide information saved.');
},
});
}
openShortcutsHelp() { openShortcutsHelp() {
dialogs.showShortcutsDialog(); dialogs.showShortcutsDialog();
} }
@@ -1642,7 +1666,11 @@ class GuideEditor {
} }
async openExportDialog() { async openExportDialog() {
const formats = (await api.export.formats()).map((id) => ({ id, label: id.replace(/-/g, ' ') })); const formats = (await api.export.formats()).map((format) => (
typeof format === 'string'
? { id: format, label: format.replace(/-/g, ' ') }
: format
));
const templatesByFormat = {}; const templatesByFormat = {};
for (const format of formats) { for (const format of formats) {
templatesByFormat[format.id] = await api.templates.list({ format: format.id }); templatesByFormat[format.id] = await api.templates.list({ format: format.id });
+19
View File
@@ -90,6 +90,25 @@ button.tool.active {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent-fg); color: var(--accent-fg);
} }
button:disabled { opacity: 0.6; cursor: default; }
button.loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.spinner {
display: inline-block;
width: 13px;
height: 13px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
opacity: 0.85;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
input, select, textarea { input, select, textarea {
font: inherit; font: inherit;
+39
View File
@@ -34,6 +34,27 @@ function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild); while (node.firstChild) node.removeChild(node.firstChild);
} }
/**
* Toggle a button into/out of a busy state: disables it and swaps its label
* for a spinner + `label` (e.g. "Exporting…"), restoring the original label
* afterwards. Used for actions that block on a slow main-process call.
*/
function setButtonLoading(btn, loading, label) {
if (loading) {
if (btn.dataset.origLabel === undefined) btn.dataset.origLabel = btn.textContent;
btn.disabled = true;
btn.classList.add('loading');
clearNode(btn);
btn.append(el('span.spinner'), label || btn.dataset.origLabel);
} else {
btn.disabled = false;
btn.classList.remove('loading');
clearNode(btn);
btn.append(btn.dataset.origLabel ?? '');
delete btn.dataset.origLabel;
}
}
function debounce(fn, ms) { function debounce(fn, ms) {
let t = null; let t = null;
const wrapped = (...args) => { const wrapped = (...args) => {
@@ -187,3 +208,21 @@ function fmtDate(iso) {
const escapeHtml = (s) => String(s ?? '') const escapeHtml = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */
function htmlToPlainText(html) {
return String(html || '')
.replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, '\'').replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
}
/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */
function textToHtml(text) {
const trimmed = String(text || '').trim();
if (!trimmed) return '';
return trimmed.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const { GuideStore } = require('./store');
const { buildRenderAst } = require('./renderast');
const { runExport } = require('../exporters');
/**
* Entry point for the export helper process (forked via
* app/export-runner.js). Runs one export job building the render AST and
* invoking the exporter off the main process so a large guide's PDF/DOCX/
* etc. rendering never blocks the UI.
*/
process.on('message', (job) => {
let payload;
try {
const { dataDir, guideId, format, options, outDir, globals, maxSteps } = job;
const store = new GuideStore(dataDir);
const ast = buildRenderAst(store, guideId, { globals, maxSteps });
const result = runExport(format, ast, outDir, options || {});
payload = { ok: true, result };
} catch (err) {
payload = { ok: false, error: err && err.message ? err.message : String(err) };
}
process.send(payload, () => process.exit(payload.ok ? 0 : 1));
});
+32
View File
@@ -60,6 +60,7 @@ class PdfBuilder {
this.images = []; // { name, width, height, data (deflated RGB), smask? } this.images = []; // { name, width, height, data (deflated RGB), smask? }
this.imageCache = new Map(); this.imageCache = new Map();
this.bookmarks = []; // { title, pageIndex, y } this.bookmarks = []; // { title, pageIndex, y }
this.links = []; // { pageIndex, x, yTop, w, h, target }
} }
addPage() { addPage() {
@@ -173,6 +174,16 @@ class PdfBuilder {
this.bookmarks.push({ title: toLatin1(title), pageIndex }); this.bookmarks.push({ title: toLatin1(title), pageIndex });
} }
/**
* Add a clickable region that jumps to another page (e.g. a Contents
* entry linking to a step). `target` is either a page index, or an
* object whose `pageIndex` is filled in later once the destination
* page is known and read when `build()` runs.
*/
linkRect(x, yTop, w, h, target, pageIndex = this.pages.length - 1) {
this.links.push({ pageIndex, x, yTop, w, h, target });
}
build() { build() {
if (!this.pages.length) this.addPage(); if (!this.pages.length) this.addPage();
const objects = []; // 1-based; objects[i] = body string|Buffer after header const objects = []; // 1-based; objects[i] = body string|Buffer after header
@@ -212,6 +223,27 @@ class PdfBuilder {
)); ));
} }
// Link annotations (e.g. clickable Contents entries that jump to a
// step's page). Targets resolved late, after every step has claimed a
// page, so `target` may be an object whose `pageIndex` was only just set.
const pageAnnots = new Map(); // page index -> [annotation object id, ...]
for (const link of this.links) {
const targetIndex = typeof link.target === 'number' ? link.target : link.target?.pageIndex;
if (targetIndex == null || !pageIds[targetIndex]) continue;
const y1 = this.pageHeight - link.yTop - link.h;
const y2 = this.pageHeight - link.yTop;
const annotId = addObj(
`<< /Type /Annot /Subtype /Link /Rect [${link.x.toFixed(2)} ${y1.toFixed(2)} ${(link.x + link.w).toFixed(2)} ${y2.toFixed(2)}] ` +
`/Border [0 0 0] /Dest [${pageIds[targetIndex]} 0 R /Fit] >>`
);
if (!pageAnnots.has(link.pageIndex)) pageAnnots.set(link.pageIndex, []);
pageAnnots.get(link.pageIndex).push(annotId);
}
for (const [pageIndex, annotIds] of pageAnnots) {
const id = pageIds[pageIndex];
objects[id - 1] = objects[id - 1].replace(/ >>$/, ` /Annots [${annotIds.map((a) => `${a} 0 R`).join(' ')}] >>`);
}
let outlinesRef = ''; let outlinesRef = '';
if (this.bookmarks.length) { if (this.bookmarks.length) {
const outlineRootId = objects.length + 1; const outlineRootId = objects.length + 1;
+5
View File
@@ -125,6 +125,11 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
createdAt: guide.createdAt, createdAt: guide.createdAt,
updatedAt: guide.updatedAt, updatedAt: guide.updatedAt,
flags: guide.flags, flags: guide.flags,
metadata: {
author: expand(guide.metadata?.author || ''),
coAuthors: expand(guide.metadata?.coAuthors || ''),
organization: expand(guide.metadata?.organization || ''),
},
}, },
steps: limited, steps: limited,
}; };
+7
View File
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
title: fields.title || 'Untitled guide', title: fields.title || 'Untitled guide',
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''), descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
placeholders: { ...(fields.placeholders || {}) }, placeholders: { ...(fields.placeholders || {}) },
metadata: {
author: '',
coAuthors: '',
organization: '',
...(fields.metadata || {}),
},
flags: { flags: {
focusedViewDefault: false, focusedViewDefault: false,
hideSkippedStepsInExports: true, hideSkippedStepsInExports: true,
@@ -149,6 +155,7 @@ function validateGuide(guide) {
if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array'); if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array');
else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates'); else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates');
if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object'); if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object');
if (guide.metadata && typeof guide.metadata !== 'object') errors.push('metadata must be an object');
if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`); if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`);
return guide; return guide;
} }
+16 -2
View File
@@ -11,7 +11,21 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
* defaults, shareable as .sfglt zip files. * defaults, shareable as .sfglt zip files.
*/ */
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; const FORMATS = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const FORMAT_LABELS = {
json: 'JSON',
markdown: 'Markdown',
wikijs: 'Wiki.js',
'html-simple': 'HTML (simple)',
'html-rich': 'HTML (rich)',
confluence: 'Confluence',
pdf: 'PDF',
gif: 'GIF',
'image-bundle': 'Image bundle',
docx: 'DOCX',
pptx: 'PPTX',
};
class TemplateManager { class TemplateManager {
constructor(templatesDir) { constructor(templatesDir) {
@@ -96,4 +110,4 @@ class TemplateManager {
} }
} }
module.exports = { TemplateManager, FORMATS }; module.exports = { TemplateManager, FORMATS, FORMAT_LABELS };
+1
View File
@@ -81,6 +81,7 @@ guide.json + step.json + settings
▼ hidden/skipped, focused-view geometry) ▼ hidden/skipped, focused-view geometry)
Render AST ──► exporters/json.js .json + steps-<title>/ images Render AST ──► exporters/json.js .json + steps-<title>/ images
──► exporters/markdown.js .md + steps-<title>/ images ──► exporters/markdown.js .md + steps-<title>/ images
──► exporters/wikijs.js .md + steps-<title>/ images
──► exporters/html-simple.js single self-contained .html ──► exporters/html-simple.js single self-contained .html
──► exporters/html-rich.js checkboxes + floating TOC ──► exporters/html-rich.js checkboxes + floating TOC
──► exporters/pdf.js native PDF writer (core/pdf.js) ──► exporters/pdf.js native PDF writer (core/pdf.js)
+6 -4
View File
@@ -5,6 +5,7 @@ const path = require('node:path');
const { slugify, escapeXml } = require('../core/util'); const { slugify, escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common');
const { anchorFor, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* Confluence storage-format export. Writes a single XHTML document plus a * Confluence storage-format export. Writes a single XHTML document plus a
@@ -14,6 +15,7 @@ const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
toc: true,
}; };
const MACRO_FOR_LEVEL = { const MACRO_FOR_LEVEL = {
@@ -23,10 +25,6 @@ const MACRO_FOR_LEVEL = {
success: 'tip', success: 'tip',
}; };
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function stepLinkRewrite(html, ast) { function stepLinkRewrite(html, ast) {
return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => { return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => {
const target = ast.steps.find((s) => s.stepId === id); const target = ast.steps.find((s) => s.stepId === id);
@@ -112,7 +110,11 @@ function exportConfluence(ast, outDir, template = {}) {
</head> </head>
<body> <body>
<h1>${escapeXml(ast.guide.title)}</h1> <h1>${escapeXml(ast.guide.title)}</h1>
<div style="border-bottom: 4px solid #2563eb; margin: 14px 0 18px;"></div>
${guideMetaLines(ast).map((line) => `<p><strong>${escapeXml(line.split(': ')[0])}:</strong> ${escapeXml(line.slice(line.indexOf(': ') + 2))}</p>`).join('\n')}
<p><em>${escapeXml(guideSummary(ast))}</em></p>
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''} ${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
${tpl.toc && ast.steps.length > 1 ? '<ac:structured-macro ac:name="toc"></ac:structured-macro>' : ''}
${stepXml} ${stepXml}
</body> </body>
</html> </html>
+40
View File
@@ -0,0 +1,40 @@
'use strict';
function anchorFor(stepOrNumber) {
const number = typeof stepOrNumber === 'string'
? stepOrNumber
: stepOrNumber && stepOrNumber.number;
return `step-${String(number || '').replace(/\./g, '-')}`;
}
function tocEntries(ast, { maxDepth = Infinity } = {}) {
return (ast.steps || []).map((step) => ({
step,
anchor: anchorFor(step),
number: step.number,
title: step.title || 'Untitled step',
depth: Math.min(Number.isFinite(step.depth) ? step.depth : 0, maxDepth),
}));
}
function guideMetaLines(ast) {
const meta = ast?.guide?.metadata || {};
return [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
}
function guideSummary(ast) {
const count = ast?.steps?.length || 0;
const generated = String(ast?.generatedAt || '').slice(0, 10);
return `${count} step${count === 1 ? '' : 's'} · generated ${generated}`;
}
module.exports = {
anchorFor,
tocEntries,
guideMetaLines,
guideSummary,
};
+138 -19
View File
@@ -6,6 +6,7 @@ const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util'); const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* DOCX exporter: WordprocessingML built directly (no dependency), one * DOCX exporter: WordprocessingML built directly (no dependency), one
@@ -15,6 +16,7 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
includeToc: true,
imageWidthTwips: 9000, // ~15.9cm inside A4 margins imageWidthTwips: 9000, // ~15.9cm inside A4 margins
}; };
@@ -64,6 +66,36 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) {
`</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`; `</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
} }
function pageBreak() {
return p('<w:r><w:br w:type="page"/></w:r>');
}
function tocFieldParagraph() {
return p([
'<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>',
'<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>',
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>',
'<w:r><w:rPr><w:i/></w:rPr><w:t xml:space="preserve">Update contents in Word</w:t></w:r>',
'<w:r><w:fldChar w:fldCharType="end"/></w:r>',
].join(''));
}
function headingStyleForDepth(depth) {
return `Heading${Math.min(3, depth + 1)}`;
}
function headingOutlineLevelForDepth(depth) {
return Math.min(2, Math.max(0, depth));
}
function headingParagraphProps(depth, forceNewPage = false) {
const parts = [];
if (forceNewPage) parts.push('<w:pageBreakBefore/>');
parts.push(`<w:pStyle w:val="${headingStyleForDepth(depth)}"/>`);
parts.push(`<w:outlineLvl w:val="${headingOutlineLevelForDepth(depth)}"/>`);
return parts.join('');
}
function table(rows) { function table(rows) {
const cols = Math.max(...rows.map((r) => r.length)); const cols = Math.max(...rows.map((r) => r.length));
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`; const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
@@ -81,35 +113,121 @@ function table(rows) {
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`; return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`;
} }
function stylesXml() {
const headingStyle = (styleId, name, outlineLvl, size, color) => `
<w:style w:type="paragraph" w:styleId="${styleId}">
<w:name w:val="${name}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:uiPriority w:val="${9 + outlineLvl}"/>
<w:qFormat/>
<w:unhideWhenUsed/>
<w:pPr>
<w:keepNext/>
<w:keepLines/>
<w:spacing w:before="${outlineLvl === 0 ? 360 : outlineLvl === 1 ? 240 : 180}" w:after="120"/>
<w:outlineLvl w:val="${outlineLvl}"/>
</w:pPr>
<w:rPr>
<w:b/>
<w:sz w:val="${size}"/>
<w:szCs w:val="${size}"/>
<w:color w:val="${color}"/>
</w:rPr>
</w:style>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:rPrDefault>
<w:pPrDefault>
<w:pPr>
<w:spacing w:after="160"/>
</w:pPr>
</w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
<w:uiPriority w:val="1"/>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:style>
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
</w:styles>`;
}
function exportDocx(ast, outDir, template = {}) { function exportDocx(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template }; const tpl = { ...DEFAULT_TEMPLATE, ...template };
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const media = []; // { name, data } const media = []; // { name, data }
const rels = []; // relationship XML strings const rels = []; // relationship XML strings
let relCounter = 0; let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
let stepImageCount = 0;
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
const body = []; const body = [];
body.push(p(run(ast.guide.title, { bold: true, size: 48 }))); body.push(p(
run(ast.guide.title, { bold: true, size: 48 }),
'<w:pBdr><w:bottom w:val="single" w:sz="24" w:space="12" w:color="2563EB"/></w:pBdr>'
));
if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' }))); if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' })));
body.push(p(run(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 18, color: '888888' }))); for (const line of guideMetaLines(ast)) body.push(p(run(line, { size: 20, color: '6B7280' })));
body.push(p(run(guideSummary(ast), { size: 18, color: '888888' })));
body.push(pageBreak());
if (tpl.includeToc && ast.steps.length > 1) {
body.push(p(
run('Contents', { bold: true, size: 28 }),
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
));
body.push(tocFieldParagraph());
body.push(pageBreak());
}
const emitTextBlock = (tb) => {
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
body.push(p(
`${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`,
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
));
};
for (const step of ast.steps) { for (const step of ast.steps) {
const headSize = step.depth > 0 ? 26 : 30; const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
step.forceNewPage ? '<w:pageBreakBefore/>' : '')); body.push(p(
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
headingParagraphProps(step.depth, step.forceNewPage)
));
const { before, rest } = stepContentGroups(step); const { before, rest } = stepContentGroups(step);
for (const tb of before) emitTextBlock(tb); for (const tb of before) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText))); if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
relCounter += 1; const relId = ++relCounter;
const name = `image${relCounter}.png`; const name = `image${relCounter}.png`;
media.push({ name, data: encodePng(img) }); media.push({ name, data: encodePng(img) });
rels.push(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`); rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips))); body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips)));
stepImageCount += 1;
} }
for (const block of rest) { for (const block of rest) {
@@ -124,14 +242,10 @@ function exportDocx(ast, outDir, template = {}) {
} }
} }
function emitTextBlock(tb) { const settingsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; <w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; <w:updateFields w:val="true"/>
body.push(p( </w:settings>`;
run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
));
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -152,6 +266,8 @@ ${body.join('\n')}
<Default Extension="xml" ContentType="application/xml"/> <Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/> <Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`, </Types>`,
}, },
{ {
@@ -166,16 +282,19 @@ ${body.join('\n')}
name: 'word/_rels/document.xml.rels', name: 'word/_rels/document.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
${rels.join('\n')} ${rels.join('\n')}
</Relationships>`, </Relationships>`,
}, },
{ name: 'word/settings.xml', data: settingsXml },
{ name: 'word/styles.xml', data: stylesXml() },
...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })), ...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })),
]; ];
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.docx`); const file = path.join(outDir, `${guideSlug(ast)}.docx`);
fs.writeFileSync(file, zipSync(entries)); fs.writeFileSync(file, zipSync(entries));
return { file, imageCount: media.length }; return { file, imageCount: stepImageCount };
} }
module.exports = { exportDocx, DEFAULT_TEMPLATE }; module.exports = { exportDocx, DEFAULT_TEMPLATE };
+449 -70
View File
@@ -5,6 +5,7 @@ const path = require('node:path');
const { escapeHtml } = require('../core/util'); const { escapeHtml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { anchorFor, tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* HTML exporters. Both variants are fully self-contained single files: * HTML exporters. Both variants are fully self-contained single files:
@@ -18,14 +19,11 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
toc: true,
accentColor: '#2563eb', accentColor: '#2563eb',
customCss: '', customCss: '',
}; };
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function dataUri(img) { function dataUri(img) {
return `data:image/png;base64,${encodePng(img).toString('base64')}`; return `data:image/png;base64,${encodePng(img).toString('base64')}`;
} }
@@ -42,6 +40,83 @@ function blockHtml(tb) {
return `<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>`; return `<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>`;
} }
function renderMetaChips(ast) {
return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
`<span class="chip muted">${escapeHtml(guideSummary(ast))}</span>`,
].join('');
}
function renderTocList(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function renderCover(ast, tpl) {
return `
<section class="cover">
<div class="eyebrow">StepForge export</div>
<h1>${escapeHtml(ast.guide.title)}</h1>
<div class="rule" style="background:${tpl.accentColor}"></div>
${ast.guide.descriptionHtml ? `<div class="desc">${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
<div class="meta">${renderMetaChips(ast)}</div>
</section>`;
}
function renderStepCard(step, ast, images, tpl, { rich = false, selected = false } = {}) {
const title = `${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}`;
const statusText = step.skipped
? 'Skipped'
: step.status === 'in-progress'
? 'In progress'
: step.status === 'done'
? 'Done'
: 'Todo';
const head = rich ? `
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
<span class="step-num">${escapeHtml(step.number)}</span>
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`;
return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${head}
<div class="step-body">
${stepBodyHtml(step, ast, images, tpl)}
</div>
</section>`;
}
function renderRichToc(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!m) return [37, 99, 235];
const n = Number.parseInt(m[1], 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
function bodyStyle(tpl) {
const [r, g, b] = hexToRgb(tpl.accentColor);
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
}
function stepBodyHtml(step, ast, images, tpl) { function stepBodyHtml(step, ast, images, tpl) {
const parts = []; const parts = [];
const { before, rest } = stepContentGroups(step); const { before, rest } = stepContentGroups(step);
@@ -68,28 +143,346 @@ function stepBodyHtml(step, ast, images, tpl) {
} }
const BASE_CSS = ` const BASE_CSS = `
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px; :root {
padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; } --bg: #f4f7fb;
h1 { font-size: 1.7em; margin-bottom: .2em; } --bg-2: #eef3f9;
h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; } --panel: rgba(255, 255, 255, 0.92);
img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; } --panel-strong: #ffffff;
pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; } --panel-soft: #f8fbff;
table { border-collapse: collapse; margin: .6em 0; } --text: #152033;
th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; } --muted: #637084;
.block { border-left: 4px solid #3b82f6; background: #eff6ff; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } --border: rgba(119, 134, 156, 0.22);
.block strong { color: #1d4ed8; } --shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.16), transparent 26%),
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.11), transparent 22%),
linear-gradient(180deg, var(--bg), var(--bg-2));
line-height: 1.6;
font-family: "Aptos", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img.shot {
max-width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: 18px;
margin: 16px 0;
box-shadow: var(--shadow);
}
pre.code {
background: #0f172a;
color: #e2e8f0;
padding: 16px 18px;
border-radius: 18px;
overflow-x: auto;
box-shadow: var(--shadow);
}
pre.code code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--shadow);
}
th, td {
border-bottom: 1px solid rgba(119, 134, 156, 0.16);
padding: 10px 12px;
text-align: left;
}
thead th {
background: var(--panel-soft);
color: var(--text);
font-size: .88rem;
letter-spacing: .02em;
text-transform: uppercase;
}
tbody tr:last-child td { border-bottom: 0; }
.doc { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; }
.cover, .toc-card, .step-card, .progress-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.cover {
padding: 34px 36px 28px;
margin-bottom: 22px;
}
.cover .eyebrow {
color: var(--accent);
text-transform: uppercase;
letter-spacing: .16em;
font-size: .74rem;
font-weight: 800;
margin-bottom: 14px;
}
.cover h1 {
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1.02;
margin: 0;
letter-spacing: -0.04em;
}
.cover .rule {
width: 132px;
height: 6px;
margin: 18px 0 16px;
border-radius: 999px;
box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.22);
}
.cover .desc {
max-width: 76ch;
color: var(--muted);
font-size: 1.02rem;
}
.cover .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.10);
color: var(--accent);
border: 1px solid rgba(var(--accent-rgb), 0.14);
font-size: .86rem;
font-weight: 650;
}
.chip.muted {
background: rgba(255, 255, 255, 0.55);
color: var(--muted);
border-color: rgba(119, 134, 156, 0.16);
}
.toc-card {
padding: 20px 22px;
margin-bottom: 24px;
}
.toc-card h2 {
margin: 0 0 16px;
font-size: 1.08rem;
letter-spacing: .02em;
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li { margin: 0; }
.toc-list a {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 9px 12px;
border-radius: 14px;
color: inherit;
}
.toc-list a:hover {
background: rgba(var(--accent-rgb), 0.08);
text-decoration: none;
}
.toc-list .num {
min-width: 44px;
color: var(--accent);
font-weight: 800;
}
.toc-list .label { flex: 1; }
.toc-list li.d1 a { padding-left: 24px; }
.toc-list li.d2 a { padding-left: 44px; }
.toc-list li.d3 a { padding-left: 64px; }
.step-card {
margin: 20px 0;
padding: 22px 24px 18px;
border-left: 6px solid var(--accent);
}
.step-card h2 {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 0 16px;
font-size: 1.28rem;
line-height: 1.15;
}
.step-card h2 .step-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
height: 2.1rem;
padding: 0 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.11);
color: var(--accent);
font-size: .9rem;
font-weight: 800;
letter-spacing: .02em;
flex: none;
}
.step-card h2 .step-title { flex: 1; }
.status-chip {
display: inline-flex;
align-items: center;
height: 1.65rem;
padding: 0 9px;
border-radius: 999px;
font-size: .72rem;
letter-spacing: .08em;
text-transform: uppercase;
font-weight: 800;
color: #334155;
background: var(--panel-soft);
border: 1px solid rgba(119, 134, 156, 0.18);
}
.status-chip.status-done {
color: #047857;
background: #ecfdf5;
border-color: rgba(16, 185, 129, 0.22);
}
.status-chip.status-in-progress {
color: #b45309;
background: #fffbeb;
border-color: rgba(245, 158, 11, 0.22);
}
.status-chip.status-todo {
color: #475569;
}
.status-chip.skipped {
color: #92400e;
background: #fffbeb;
border-color: rgba(245, 158, 11, 0.24);
}
.step-card.skipped { opacity: .76; }
.step-body > .desc {
color: #243044;
}
.block {
position: relative;
border-left: 4px solid var(--accent);
background: rgba(var(--accent-rgb), 0.08);
padding: 14px 16px 14px 54px;
margin: 14px 0;
border-radius: 18px;
overflow: hidden;
}
.block::before {
content: 'i';
position: absolute;
left: 16px;
top: 14px;
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--accent-rgb), 0.15);
color: var(--accent);
font-weight: 900;
font-size: 13px;
}
.block strong { color: var(--text); }
.block-warn { border-color: #f59e0b; background: #fffbeb; } .block-warn { border-color: #f59e0b; background: #fffbeb; }
.block-warn strong { color: #b45309; } .block-warn::before { content: '!'; background: rgba(245, 158, 11, 0.16); color: #b45309; }
.block-warn strong { color: #92400e; }
.block-error { border-color: #ef4444; background: #fef2f2; } .block-error { border-color: #ef4444; background: #fef2f2; }
.block-error::before { content: '!'; background: rgba(239, 68, 68, 0.14); color: #b91c1c; }
.block-error strong { color: #b91c1c; } .block-error strong { color: #b91c1c; }
.block-success { border-color: #10b981; background: #ecfdf5; } .block-success { border-color: #10b981; background: #ecfdf5; }
.block-success::before { content: '✓'; background: rgba(16, 185, 129, 0.14); color: #047857; }
.block-success strong { color: #047857; } .block-success strong { color: #047857; }
.footer-note {
margin-top: 20px;
color: var(--muted);
font-size: .84rem;
}
.skipped { opacity: .55; } .skipped { opacity: .55; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { background: #111827; color: #e5e7eb; } :root {
h2 { border-color: #374151; } --bg: #0b1220;
pre.code, .block { background: #1f2937; } --bg-2: #0f172a;
th, td { border-color: #4b5563; } --panel: rgba(17, 24, 39, 0.9);
--panel-strong: #111827;
--panel-soft: #162033;
--text: #e5e7eb;
--muted: #98a2b3;
--border: rgba(148, 163, 184, 0.18);
--shadow: 0 16px 40px rgba(0, 0, 0, 0.25);
}
.status-chip { color: #d1d5db; }
.status-chip.skipped { color: #fbbf24; background: rgba(245, 158, 11, 0.12); }
.cover .desc, .step-body > .desc { color: #cbd5e1; }
.chip.muted { background: rgba(15, 23, 42, 0.75); color: #cbd5e1; }
.block { background: rgba(37, 99, 235, 0.12); }
.block-warn { background: rgba(245, 158, 11, 0.12); }
.block-error { background: rgba(239, 68, 68, 0.12); }
.block-success { background: rgba(16, 185, 129, 0.12); }
table { background: var(--panel-strong); }
th, td { border-bottom-color: rgba(148, 163, 184, 0.12); }
}
`;
const RICH_CSS = `
.layout-rich {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.toc-panel {
position: sticky;
top: 20px;
align-self: start;
}
.toc-card {
margin: 0;
max-height: calc(100vh - 40px);
overflow: auto;
}
.progress-card {
margin-bottom: 22px;
padding: 14px 18px;
}
.progress {
position: sticky;
top: 0;
z-index: 2;
background: transparent;
}
.progress .label {
font-size: .86rem;
font-weight: 700;
color: var(--muted);
margin-bottom: 8px;
}
.progress .bar {
height: 8px;
background: rgba(148, 163, 184, 0.18);
border-radius: 999px;
overflow: hidden;
}
.progress .fill {
height: 100%;
width: 0;
background: var(--accent);
transition: width .2s ease;
}
label.check { margin-right: 0; }
label.check input { transform: translateY(1px); }
.step-card h2 .step-title { min-width: 0; }
@media (max-width: 960px) {
.layout-rich { grid-template-columns: 1fr; }
.toc-panel { position: static; }
.toc-card { max-height: none; }
} }
`; `;
@@ -97,12 +490,16 @@ function exportHtmlSimple(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template }; const tpl = { ...DEFAULT_TEMPLATE, ...template };
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const toc = tpl.toc && ast.steps.length > 1
const stepsHtml = ast.steps.map((step) => ` ? `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}"> <section class="toc-card">
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2> <h2>Contents</h2>
${stepBodyHtml(step, ast, images, tpl)} <ul class="toc-list">
</section>`).join('\n'); ${renderTocList(ast)}
</ul>
</section>`
: '';
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n');
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -112,11 +509,13 @@ function exportHtmlSimple(ast, outDir, template = {}) {
<title>${escapeHtml(ast.guide.title)}</title> <title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${tpl.customCss}</style> <style>${BASE_CSS}${tpl.customCss}</style>
</head> </head>
<body> <body style="${bodyStyle(tpl)}">
<h1>${escapeHtml(ast.guide.title)}</h1> <div class="doc doc-simple">
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''} ${renderCover(ast, tpl)}
${toc}
${stepsHtml} ${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer> <footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</div>
</body> </body>
</html> </html>
`; `;
@@ -130,39 +529,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const storageKey = `stepforge-progress-${ast.guide.id}`; const storageKey = `stepforge-progress-${ast.guide.id}`;
const tocHtml = tpl.toc && ast.steps.length > 1
? `
<aside class="toc-panel">
<section class="toc-card">
<h2>Contents</h2>
<ul class="toc-list">
${renderRichToc(ast)}
</ul>
</section>
</aside>`
: '';
const tocHtml = ast.steps.map((step) => const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n');
`<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 = ` const script = `
(function () { (function () {
@@ -199,19 +578,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(ast.guide.title)}</title> <title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${richCss}${tpl.customCss}</style> <style>${BASE_CSS}${RICH_CSS}${tpl.customCss}</style>
</head> </head>
<body> <body style="${bodyStyle(tpl)}">
<div class="layout"> <div class="doc layout-rich">
<nav class="toc"><strong>Contents</strong><ul>
${tocHtml} ${tocHtml}
</ul></nav>
<main> <main>
<h1>${escapeHtml(ast.guide.title)}</h1> ${renderCover(ast, tpl)}
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''} <div class="progress progress-card">
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div> <div class="label"></div>
<div class="bar"><div class="fill"></div></div>
</div>
${stepsHtml} ${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer> <footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</main> </main>
</div> </div>
<script>${script}</script> <script>${script}</script>
+3 -1
View File
@@ -3,6 +3,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { guideSlug, writeStepImages } = require('./common'); const { guideSlug, writeStepImages } = require('./common');
const { tocEntries, guideSummary } = require('./document-layout');
const raster = require('../core/raster'); const raster = require('../core/raster');
const { decodePng, encodePng } = require('../core/png'); const { decodePng, encodePng } = require('../core/png');
@@ -39,7 +40,8 @@ function exportImageBundle(ast, outDir, template = {}) {
const meta = { const meta = {
format: 'stepforge-image-bundle', format: 'stepforge-image-bundle',
version: 1, version: 1,
guide: { title: ast.guide.title, generatedAt: ast.generatedAt }, guide: { title: ast.guide.title, generatedAt: ast.generatedAt, summary: guideSummary(ast) },
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
steps: ast.steps.map((step) => ({ steps: ast.steps.map((step) => ({
number: step.number, number: step.number,
title: step.title, title: step.title,
+2
View File
@@ -2,6 +2,7 @@
const { exportJson } = require('./json'); const { exportJson } = require('./json');
const { exportMarkdown } = require('./markdown'); const { exportMarkdown } = require('./markdown');
const { exportWikiJs } = require('./wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('./html'); const { exportHtmlSimple, exportHtmlRich } = require('./html');
const { exportConfluence } = require('./confluence'); const { exportConfluence } = require('./confluence');
const { exportPdf } = require('./pdf'); const { exportPdf } = require('./pdf');
@@ -14,6 +15,7 @@ const { exportPptx } = require('./pptx');
const EXPORTERS = { const EXPORTERS = {
json: exportJson, json: exportJson,
markdown: exportMarkdown, markdown: exportMarkdown,
wikijs: exportWikiJs,
'html-simple': exportHtmlSimple, 'html-simple': exportHtmlSimple,
'html-rich': exportHtmlRich, 'html-rich': exportHtmlRich,
confluence: exportConfluence, confluence: exportConfluence,
+3
View File
@@ -3,6 +3,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common');
const { tocEntries, guideSummary } = require('./document-layout');
/** /**
* JSON exporter: structured guide + steps, annotated screenshots written to * JSON exporter: structured guide + steps, annotated screenshots written to
@@ -29,7 +30,9 @@ function exportJson(ast, outDir, template = {}) {
descriptionHtml: ast.guide.descriptionHtml, descriptionHtml: ast.guide.descriptionHtml,
createdAt: ast.guide.createdAt, createdAt: ast.guide.createdAt,
updatedAt: ast.guide.updatedAt, updatedAt: ast.guide.updatedAt,
summary: guideSummary(ast),
}, },
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
steps: ast.steps.map((step) => ({ steps: ast.steps.map((step) => ({
number: step.number, number: step.number,
kind: step.kind, kind: step.kind,
+143
View File
@@ -0,0 +1,143 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { escapeHtml } = require('../core/util');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd');
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
const DEFAULT_TEMPLATE = {
toc: true,
includeImages: true,
azureWiki: false,
imageMaxWidth: 0, // 0 = natural size
};
const WIKIJS_CALLOUT_CLASS = {
info: 'is-info',
success: 'is-success',
warn: 'is-warning',
error: 'is-danger',
};
const HTML_CALLOUT_THEME = {
info: { label: 'Note', border: '#2563eb', fg: '#1d4ed8', kind: 'note' },
success: { label: 'Tip', border: '#10b981', fg: '#047857', kind: 'tip' },
warn: { label: 'Warning', border: '#f59e0b', fg: '#b45309', kind: 'warning' },
error: { label: 'Important', border: '#ef4444', fg: '#b91c1c', kind: 'important' },
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function quoteBody(text) {
return text ? text.replace(/\n/g, '\n> ') : '';
}
function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) {
const body = htmlToMarkdown(tb.descriptionHtml);
if (alertStyle === 'html') {
const theme = HTML_CALLOUT_THEME[tb.level] || HTML_CALLOUT_THEME.info;
const label = theme.label;
const title = tb.title ? `${label}: ${tb.title}` : label;
const style = `border-left: 4px solid ${theme.border}; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;`;
lines.push(
`<div class="sf-callout sf-callout-${theme.kind}" style="${style}">`,
`<div style="font-weight: 700; color: ${theme.fg}; margin-bottom: ${body ? '6px' : '0'};">${escapeHtml(title)}</div>`,
);
if (body) lines.push(`<div style="color: inherit;">${tb.descriptionHtml || ''}</div>`);
lines.push('</div>', '');
return;
}
if (alertStyle === 'wikijs') {
const label = tb.title || LEVEL_LABEL[tb.level] || 'Note';
const className = WIKIJS_CALLOUT_CLASS[tb.level] || 'is-info';
lines.push(`> **${label}**`);
if (body) lines.push(`> ${quoteBody(body)}`);
lines.push(`{.${className}}`, '');
return;
}
const label = LEVEL_LABEL[tb.level] || 'Note';
lines.push(`> [!${label.toUpperCase()}]`);
if (tb.title) lines.push(`> **${tb.title}**`);
if (body) lines.push(`> ${quoteBody(body)}`);
lines.push('');
}
function renderMarkdownGuide(ast, outDir, template = {}, {
defaults = DEFAULT_TEMPLATE,
alertStyle = 'gfm',
tocTitle = 'Contents',
fileExt = '.md',
} = {}) {
const tpl = { ...defaults, ...template };
fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
const lines = [];
lines.push(`# ${ast.guide.title}`, '');
lines.push('<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>', '');
const metaLines = guideMetaLines(ast);
if (metaLines.length) lines.push(metaLines.join(' · '), '');
lines.push(`*${guideSummary(ast)}*`, '');
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
if (tpl.toc && ast.steps.length > 1) {
lines.push(`## ${tocTitle}`, '');
for (const entry of tocEntries(ast)) {
const indent = ' '.repeat(entry.depth);
lines.push(`${indent}- [${entry.number}. ${entry.title}](#${entry.anchor})`);
}
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)*', '');
const { before, rest } = stepContentGroups(step);
for (const tb of before) emitBlock(lines, tb, { alertStyle });
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 block of rest) {
if (block.kind === 'text') {
emitBlock(lines, block, { alertStyle });
} else if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const width = Math.max(...block.rows.map((r) => r.length));
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
}
const file = path.join(outDir, `${guideSlug(ast)}${fileExt}`);
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
return { file, imageCount: images.size };
}
module.exports = {
DEFAULT_TEMPLATE,
anchorFor,
renderMarkdownGuide,
};
+7 -84
View File
@@ -1,96 +1,19 @@
'use strict'; 'use strict';
const fs = require('node:fs'); const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown-guide');
const path = require('node:path');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd');
/** /**
* Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder. * Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder.
* azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept. * 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 = {}) { function exportMarkdown(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template }; return renderMarkdownGuide(ast, outDir, template, {
fs.mkdirSync(outDir, { recursive: true }); defaults: DEFAULT_TEMPLATE,
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map(); alertStyle: 'html',
const lines = []; tocTitle: 'Contents',
fileExt: '.md',
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)*', '');
const { before, rest } = stepContentGroups(step);
for (const tb of before) emitBlock(lines, tb);
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 block of rest) {
if (block.kind === 'text') {
emitBlock(lines, block);
} else if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const width = Math.max(...block.rows.map((r) => r.length));
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
}
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 emitBlock(lines, tb) {
const label = LEVEL_LABEL[tb.level] || 'Note';
// GitHub-Flavored Markdown alert syntax — renders with a colored,
// icon-labeled box on GitHub/Azure DevOps wikis and several other
// viewers; degrades to a plain blockquote elsewhere.
lines.push(`> [!${label.toUpperCase()}]`);
if (tb.title) lines.push(`> **${tb.title}**`);
const body = htmlToMarkdown(tb.descriptionHtml);
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
lines.push('');
} }
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
+108 -11
View File
@@ -130,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
let y = M; let y = M;
// Never start a page break when already at the top of a page — an item
// taller than a full page (e.g. a step's combined head height) must
// still render and overflow naturally rather than push out a blank page.
const ensure = (needed) => { const ensure = (needed) => {
if (y + needed > size.height - M) { if (y > M && y + needed > size.height - M) {
pdf.addPage(); pdf.addPage();
y = M; y = M;
} }
@@ -169,14 +172,80 @@ function exportPdf(ast, outDir, template = {}) {
} }
}; };
/** Height a callout block (emitBlock) will occupy, including its trailing gap. */
const measureBlock = (tb) => {
const { height: bodyH } = tb.descriptionHtml
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { height: 0 };
return 16 + bodyH + 6;
};
/** Height an image will occupy on the page, including its trailing gap. */
const measureImage = (stepId) => {
const img = images.get(stepId);
if (!img) return 0;
const maxH = usableH * tpl.imageMaxHeightRatio;
let h = (img.height / img.width) * usableW;
if (h > maxH) h = maxH;
return h + 10;
};
const measureCode = (block) => {
const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3;
return lines.length * lineH + 16;
};
const measureTable = (block) => {
if (!block.rows || !block.rows.length) return 0;
return block.rows.length * 16 + 8;
};
/**
* Compute the vertical space a step will need: `head` is the title, the
* accent rule, any before-description blocks, the description, and the
* image kept together on one page and `total` adds the remaining
* blocks plus the trailing gap, used to decide whether the whole step
* fits on a fresh page.
*/
const measureStep = (step) => {
const headSize = step.depth > 0 ? 12 : 14;
const titleText = `${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`;
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
const { before, rest } = stepContentGroups(step);
for (const tb of before) head += measureBlock(tb);
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
head += measureImage(step.stepId);
let restHeight = 0;
for (const block of rest) {
if (block.kind === 'text') restHeight += measureBlock(block);
else if (block.kind === 'code') restHeight += measureCode(block);
else if (block.kind === 'table') restHeight += measureTable(block);
}
return { head, total: head + restHeight + 10 };
};
pdf.addPage(); pdf.addPage();
if (tpl.includeCover) { if (tpl.includeCover) {
y = M + usableH * 0.18; // Keep the cover title near the top edge instead of vertically centering it.
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor }); y = M;
y += 6; writeLines(ast.guide.title, { size: 28, font: 'F2' });
writeLines(ast.guide.title, { size: 26, font: 'F2' }); y += 10;
y += 8; pdf.rect(M, y, usableW, 3, { fill: tpl.accentColor });
y += 16;
const meta = ast.guide.metadata || {};
const metaLines = [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
for (const line of metaLines) writeLines(line, { size: 11, color: [90, 90, 90] });
if (metaLines.length) y += 8;
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] }); if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
y += 14; y += 14;
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
@@ -184,24 +253,51 @@ function exportPdf(ast, outDir, template = {}) {
y = M; y = M;
} }
// Filled in below as each step claims its page, so the Contents entries
// above (already laid out before pagination starts) can link to it.
const tocTargets = new Map(); // stepId -> { pageIndex }
if (tpl.includeToc && ast.steps.length > 1) { if (tpl.includeToc && ast.steps.length > 1) {
writeLines('Contents', { size: 16, font: 'F2' }); writeLines('Contents', { size: 16, font: 'F2' });
y += 4; y += 4;
const tocSize = 10.5;
const lineH = tocSize * 1.35;
for (const step of ast.steps) { for (const step of ast.steps) {
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, { const indent = 14 * step.depth;
size: 10.5, indent: 14 * step.depth, const target = {};
}); tocTargets.set(step.stepId, target);
for (const line of pdf.wrapText(`${step.number}. ${step.title || 'Untitled step'}`, tocSize, usableW - indent, 'F1')) {
ensure(lineH);
pdf.text(line, M + indent, y, { size: tocSize, color: tpl.accentColor });
pdf.linkRect(M + indent, y, usableW - indent, lineH, target);
y += lineH;
}
} }
pdf.addPage(); pdf.addPage();
y = M; y = M;
} }
let first = true; let first = true;
let forcedFresh = false;
const pageBottom = size.height - M;
for (const step of ast.steps) { for (const step of ast.steps) {
if (step.forceNewPage && !first) { pdf.addPage(); y = M; } const { head: headHeight, total: totalHeight } = measureStep(step);
// Start a fresh page when: the step's "New page" toggle is set; the
// previous step overflowed past one page (so this step shouldn't share
// the spillover page); or this step doesn't fit in what's left of the
// current page. The last check is skipped when already at the top of a
// page, so an overlong step doesn't push out a blank page first.
const needsFreshPage = !first && (
step.forceNewPage || forcedFresh || (y > M && y + totalHeight > pageBottom)
);
if (needsFreshPage) { pdf.addPage(); y = M; }
first = false; first = false;
ensure(40); // Keep the title, accent rule, lead-in blocks, description, and image
// together — never split across a page boundary.
ensure(headHeight);
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
const tocTarget = tocTargets.get(step.stepId);
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
@@ -259,6 +355,7 @@ function exportPdf(ast, outDir, template = {}) {
} }
y += 10; y += 10;
forcedFresh = totalHeight > usableH;
} }
function emitBlock(tb) { function emitBlock(tb) {
+146 -11
View File
@@ -5,7 +5,8 @@ const path = require('node:path');
const { zipSync } = require('../core/zip'); const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util'); const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('./common');
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar + * PPTX exporter: a title slide plus one 16:9 slide per step (title bar +
@@ -15,11 +16,29 @@ const { guideSlug, renderAllImages } = require('./common');
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
includeImages: true, includeImages: true,
titleSlide: true, titleSlide: true,
includeToc: true,
}; };
const SLIDE_W = 12192000; // EMU, 16:9 const SLIDE_W = 12192000; // EMU, 16:9
const SLIDE_H = 6858000; const SLIDE_H = 6858000;
const EMU_PER_PX = 9525; const EMU_PER_PX = 9525;
const SLIDE_MARGIN = 914400;
const TITLE_Y = 420000;
const TITLE_H = 620000;
const TITLE_RULE_Y = 1120000;
const CONTENT_Y = 1500000;
const CONTENT_FOOTER_Y = 720000;
const CALL_OUT_HEIGHT = 620000;
const CALL_OUT_GAP = 90000;
const CALL_OUT_BAR_W = 24000;
const TOC_ENTRY_START_Y = 2300000;
const TOC_ENTRY_SPACING = 255000;
const TOC_ENTRY_HEIGHT = 220000;
const TOC_BOTTOM_MARGIN = 500000;
const TOC_MAX_ENTRIES_PER_SLIDE = Math.max(
1,
Math.floor((SLIDE_H - TOC_BOTTOM_MARGIN - TOC_ENTRY_START_Y - TOC_ENTRY_HEIGHT) / TOC_ENTRY_SPACING) + 1,
);
let shapeIdCounter = 10; // reset per export for deterministic output let shapeIdCounter = 10; // reset per export for deterministic output
@@ -29,6 +48,12 @@ function textBox(x, y, w, h, runsXml) {
`<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`; `<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`;
} }
function rectShape(x, y, w, h, fill) {
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeIdCounter++}" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>` +
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="${fill}"/></a:solidFill><a:ln><a:noFill/></a:ln></p:spPr></p:sp>`;
}
function para(text, { size = 1800, bold = false, color = '111827' } = {}) { function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`; return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`;
} }
@@ -39,6 +64,49 @@ function picture(relId, x, y, w, h) {
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`; `<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
} }
const CALLOUT_STYLE = {
info: { fill: 'EFF6FF', accent: '2563EB', label: 'Note', color: '1D4ED8' },
success: { fill: 'ECFDF5', accent: '10B981', label: 'Tip', color: '047857' },
warn: { fill: 'FFFBEB', accent: 'F59E0B', label: 'Warning', color: 'B45309' },
error: { fill: 'FEF2F2', accent: 'EF4444', label: 'Important', color: 'B91C1C' },
};
function estimateWrappedLines(text, charsPerLine) {
const raw = String(text || '').trim();
if (!raw) return 0;
return raw.split(/\n+/).reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
}
function calloutHeight(tb) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const lines = estimateWrappedLines(label, 46) + estimateWrappedLines(tb.descriptionText, 72);
return Math.max(CALL_OUT_HEIGHT, 360000 + (lines * 150000));
}
function calloutXml(tb, x, y, w) {
const style = CALLOUT_STYLE[tb.level] || CALLOUT_STYLE.info;
const height = calloutHeight(tb);
const label = `${style.label}${tb.title ? `: ${tb.title}` : ''}`;
const titlePara = para(label, { size: 1400, bold: true, color: style.color });
const bodyPara = tb.descriptionText ? para(tb.descriptionText.slice(0, 400), { size: 1250, color: '374151' }) : '';
const innerX = x + CALL_OUT_BAR_W + 24000;
const innerW = Math.max(0, w - CALL_OUT_BAR_W - 72000);
return {
height,
xml: [
rectShape(x, y, w, height, style.fill),
rectShape(x, y, CALL_OUT_BAR_W, height, style.accent),
textBox(innerX, y + 12000, innerW, Math.max(0, height - 24000), `${titlePara}${bodyPara}`),
].join(''),
};
}
function descriptionHeight(text) {
const lines = estimateWrappedLines(text, 95);
if (!lines) return 0;
return Math.max(360000, 260000 + (lines * 130000));
}
function slideXml(content) { function slideXml(content) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" <p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
@@ -50,6 +118,33 @@ ${content}
</p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`; </p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
} }
function chunkArray(items, size) {
const chunks = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
function tocSlideXml(ast, entries, { continued = false } = {}) {
let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000,
para(continued ? 'Contents (continued)' : 'Contents', { size: 3000, bold: true }));
tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB');
tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000,
para(guideSummary(ast), { size: 1500, color: '6B7280' }));
entries.forEach((entry, index) => {
const x = 914400 + (entry.depth * 220000);
const y = TOC_ENTRY_START_Y + (index * TOC_ENTRY_SPACING);
tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB');
tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, TOC_ENTRY_HEIGHT,
para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 }));
});
return slideXml(tocContent);
}
const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge"> <a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
<a:themeElements> <a:themeElements>
@@ -85,22 +180,52 @@ function exportPptx(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] } const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] }
const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : [];
if (tpl.titleSlide) { if (tpl.titleSlide) {
const metaLines = guideMetaLines(ast);
let titleContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
titleContent += textBox(SLIDE_MARGIN, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true }));
titleContent += rectShape(SLIDE_MARGIN, 3300000, 2200000, 14000, '2563EB');
titleContent += textBox(SLIDE_MARGIN, 3500000, SLIDE_W - 1828800, 1100000,
[para(guideSummary(ast), { size: 1800, color: '6B7280' }),
...metaLines.map((line) => para(line, { size: 1500, color: '6B7280' }))].join(''));
slides.push({ slides.push({
xml: slideXml( xml: slideXml(titleContent),
textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) +
textBox(914400, 3600000, SLIDE_W - 1828800, 800000,
para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' }))
),
rels: [], media: [], rels: [], media: [],
}); });
} }
if (toc.length) {
const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE);
tocPages.forEach((page, index) => {
slides.push({
xml: tocSlideXml(ast, page, { continued: index > 0 }),
rels: [], media: [],
});
});
}
let mediaCounter = 0; let mediaCounter = 0;
for (const step of ast.steps) { for (const step of ast.steps) {
let content = textBox(457200, 274638, SLIDE_W - 914400, 700000, const { before, rest } = stepContentGroups(step);
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true })); const beforeBlocks = before.filter((block) => block.kind === 'text');
const restBlocks = rest.filter((block) => block.kind === 'text');
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H,
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB');
let y = CONTENT_Y;
for (const tb of beforeBlocks) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
const descReserve = step.descriptionText ? descriptionHeight(step.descriptionText) + 120000 : 0;
const restReserve = restBlocks.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const rels = []; const rels = [];
const media = []; const media = [];
@@ -112,15 +237,25 @@ function exportPptx(ast, outDir, template = {}) {
const relId = 2; // rId1 = layout, rId2 = image const relId = 2; // rId1 = layout, rId2 = image
rels.push({ id: relId, name }); rels.push({ id: relId, name });
// Fit image into a centered region below the title. // Fit image into a centered region below the title.
const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000); const maxW = SLIDE_W - 1219200;
const maxH = Math.max(0, SLIDE_H - y - descReserve - restReserve - 100000);
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
const scale = Math.min(maxW / w, maxH / h, 1); const scale = Math.min(maxW / w, maxH / h, 1);
w = Math.round(w * scale); h = Math.round(h * scale); w = Math.round(w * scale); h = Math.round(h * scale);
content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h); content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
y += h + 100000;
} }
if (step.descriptionText) { if (step.descriptionText) {
content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000, const descH = Math.max(360000, descReserve || 420000);
content += textBox(457200, Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH), SLIDE_W - 914400, descH,
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y = Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH) + descH + 90000;
}
for (const tb of restBlocks) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
} }
slides.push({ xml: slideXml(content), rels, media }); slides.push({ xml: slideXml(content), rels, media });
} }
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide');
/**
* Wiki.js markdown exporter. Same step/body structure as the generic
* Markdown exporter, but uses Wiki.js-friendly callout blocks.
*/
const WIKIJS_TEMPLATE = {
toc: true,
includeImages: true,
imageMaxWidth: 0,
};
function exportWikiJs(ast, outDir, template = {}) {
return renderMarkdownGuide(ast, outDir, template, {
defaults: WIKIJS_TEMPLATE,
alertStyle: 'wikijs',
tocTitle: 'Contents',
fileExt: '.md',
});
}
module.exports = { exportWikiJs, DEFAULT_TEMPLATE: WIKIJS_TEMPLATE };
+1 -1
View File
@@ -97,7 +97,7 @@ Host: ${process.platform} ${process.arch} (node ${process.version})
- Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'} - Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'}
- Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'} - Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'}
- Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'} - Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'}
- Sample exports (9 formats): see examples/sample-exports/ - Sample exports (10 formats): see examples/sample-exports/
- Full artifact list with sha256 checksums: artifacts_manifest.json - Full artifact list with sha256 checksums: artifacts_manifest.json
## Packaging tool availability ## Packaging tool availability
+1 -1
View File
@@ -223,7 +223,7 @@ function createGuide(store) {
function exportOutputs(store, guideId, root, manifest) { function exportOutputs(store, guideId, root, manifest) {
const ast = buildRenderAst(store, guideId); const ast = buildRenderAst(store, guideId);
const formats = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; const formats = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const outputs = {}; const outputs = {};
for (const format of formats) { for (const format of formats) {
const outDir = path.join(root, 'sample-exports', format); const outDir = path.join(root, 'sample-exports', format);
@@ -21,7 +21,7 @@ for f in sample-manifest.json sample-guide.sfgz; do
done done
for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \ for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \
sample-exports/html-rich sample-exports/pdf sample-exports/gif \ sample-exports/wikijs sample-exports/html-rich sample-exports/pdf sample-exports/gif \
sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do
if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then
echo "Sample export directory is empty: $dir" >&2 echo "Sample export directory is empty: $dir" >&2
@@ -34,7 +34,7 @@ const fs = require('node:fs');
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8')); const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8'));
if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format'); if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format');
if (!manifest.guideId) throw new Error('missing guideId'); if (!manifest.guideId) throw new Error('missing guideId');
if (!manifest.exports || Object.keys(manifest.exports).length < 9) throw new Error('missing sample exports'); if (!manifest.exports || Object.keys(manifest.exports).length < 10) throw new Error('missing sample exports');
NODE NODE
echo "sample artifacts OK" echo "sample artifacts OK"
+73
View File
@@ -0,0 +1,73 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runExportInWorker } = require('../../app/export-runner');
const { buildRenderAst } = require('../../core/renderast');
const { runExport } = require('../../exporters');
const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers');
test('export helper process produces the same result as an in-process export', async (t) => {
const root = makeTmpDir('exportworker');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
const expected = runExport('json', buildRenderAst(store, guide.guideId), path.join(root, 'inproc'));
const result = await runExportInWorker({
dataDir: store.root,
guideId: guide.guideId,
format: 'json',
options: {},
outDir: path.join(root, 'worker'),
globals: {},
});
assert.equal(result.imageCount, expected.imageCount);
assert.ok(fs.existsSync(result.file));
const fromWorker = JSON.parse(fs.readFileSync(result.file, 'utf8'));
const fromInProcess = JSON.parse(fs.readFileSync(expected.file, 'utf8'));
// Each build stamps its own generatedAt; everything else must match exactly.
delete fromWorker.generatedAt;
delete fromInProcess.generatedAt;
assert.deepEqual(fromWorker, fromInProcess);
});
test('export helper process rejects on an unknown format', async (t) => {
const root = makeTmpDir('exportworkerbadfmt');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
await assert.rejects(
runExportInWorker({
dataDir: store.root,
guideId: guide.guideId,
format: 'exe',
options: {},
outDir: path.join(root, 'out'),
globals: {},
}),
/unknown export format/,
);
});
test('export helper process rejects on an unknown guide id', async (t) => {
const root = makeTmpDir('exportworkerbadguide');
t.after(() => rmrf(root));
const { store } = buildFixtureGuide(path.join(root, 'data'));
await assert.rejects(
runExportInWorker({
dataDir: store.root,
guideId: 'guide_does_not_exist',
format: 'json',
options: {},
outDir: path.join(root, 'out'),
globals: {},
}),
);
});
+205 -7
View File
@@ -4,8 +4,10 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const zlib = require('node:zlib');
const { execFileSync } = require('node:child_process'); const { execFileSync } = require('node:child_process');
const { GuideStore } = require('../../core/store');
const { buildRenderAst } = require('../../core/renderast'); const { buildRenderAst } = require('../../core/renderast');
const { exportPdf } = require('../../exporters/pdf'); const { exportPdf } = require('../../exporters/pdf');
const { exportGifGuide } = require('../../exporters/gif'); const { exportGifGuide } = require('../../exporters/gif');
@@ -25,6 +27,52 @@ function hasTool(cmd) {
try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; } try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; }
} }
/** Inflate each page's content stream, in page order, for op-level assertions. */
function pageContents(buf) {
const text = buf.toString('latin1');
const out = [];
const re = /\d+ 0 obj\n<< \/Filter \/FlateDecode \/Length (\d+) >>\nstream\n/g;
let m;
while ((m = re.exec(text)) !== null) {
const len = Number(m[1]);
const start = m.index + m[0].length;
out.push(zlib.inflateSync(buf.subarray(start, start + len)).toString('latin1'));
}
return out;
}
/** Map each step bookmark's title to the 0-based page index it lands on. */
function bookmarkPages(buf) {
const text = buf.toString('latin1');
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
.map((m) => Number(m[1]));
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
const out = [];
for (const m of text.matchAll(/\/Title \(([^)]*)\)([^>]*)/g)) {
const dest = /\/Dest \[(\d+) 0 R/.exec(m[2]);
if (dest) out.push({ title: m[1], pageIndex: pageIndexOf.get(Number(dest[1])) });
}
return out;
}
/** Resolve each Link annotation's `/Dest` on the page at `pageIndex` to a 0-based page index. */
function tocLinkTargets(buf, pageIndex) {
const text = buf.toString('latin1');
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
.map((m) => Number(m[1]));
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
const objBody = (id) => new RegExp(`(?:^|\\n)${id} 0 obj\\n([\\s\\S]*?)\\nendobj`).exec(text)[1];
const annots = /\/Annots \[([^\]]+)\]/.exec(objBody(kids[pageIndex]));
if (!annots) return [];
return [...annots[1].matchAll(/(\d+) 0 R/g)].map((m) => {
const body = objBody(Number(m[1]));
assert.match(body, /\/Subtype \/Link/);
const dest = /\/Dest \[(\d+) 0 R/.exec(body);
return pageIndexOf.get(Number(dest[1]));
});
}
/** Tiny XML well-formedness check: balanced tags, single root. */ /** Tiny XML well-formedness check: balanced tags, single root. */
function assertWellFormedXml(xml, label) { function assertWellFormedXml(xml, label) {
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim(); const body = xml.replace(/<\?xml[^?]*\?>/, '').trim();
@@ -73,6 +121,20 @@ test('PDF export: valid document, bookmarks per step, images embedded', (t) => {
assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2); assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2);
}); });
test('PDF Contents: each entry is a clickable link to its step\'s page', (t) => {
const { ast, root } = fixtureAst(t, 'pdftoc');
const buf = fs.readFileSync(exportPdf(ast, path.join(root, 'out')).file);
const bookmarks = bookmarkPages(buf); // one per step, in document order
const tocTargets = tocLinkTargets(buf, 1); // page 0 = cover, page 1 = Contents
const tocPage = pageContents(buf)[1];
const blueTocLines = [...tocPage.matchAll(/BT \/F1 10\.5 Tf 0\.145 0\.388 0\.922 rg 1 0 0 1 [\d.]+ [\d.]+ Tm \(([^)]*)\) Tj ET/g)]
.map((m) => m[1]);
assert.deepEqual(tocTargets, bookmarks.map((b) => b.pageIndex));
assert.deepEqual(blueTocLines, ast.steps.map((step) => `${step.number}. ${step.title || 'Untitled step'}`));
});
test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => { test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => {
const { ast, root } = fixtureAst(t, 'pdfgs'); const { ast, root } = fixtureAst(t, 'pdfgs');
const { file, pageCount } = exportPdf(ast, path.join(root, 'out')); const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));
@@ -80,6 +142,83 @@ test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) =
assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`)); assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`));
}); });
test('PDF cover: title in big text above the accent rule, guide metadata below it', (t) => {
const root = makeTmpDir('pdfcover');
t.after(() => rmrf(root));
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
// No metadata set: cover has no Author/Co-authors/Organization lines.
const astNoMeta = buildRenderAst(store, bare.guideId);
const { file: fileNoMeta } = exportPdf(astNoMeta, path.join(root, 'out1'));
const coverNoMeta = pageContents(fs.readFileSync(fileNoMeta))[0];
assert.ok(!coverNoMeta.includes('Author:'));
assert.ok(!coverNoMeta.includes('Co-authors:'));
assert.ok(!coverNoMeta.includes('Organization:'));
// Set guide metadata, then re-export.
const guide = store.getGuide(bare.guideId);
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee', organization: 'Acme Corp' };
store.saveGuide(guide);
const ast = buildRenderAst(store, guide.guideId);
const { file } = exportPdf(ast, path.join(root, 'out2'));
const cover = pageContents(fs.readFileSync(file))[0];
assert.ok(cover.includes('Author: Jane Doe'));
assert.ok(cover.includes('Co-authors: Alex Lee'));
assert.ok(cover.includes('Organization: Acme Corp'));
// Title (28pt, F2) sits above the accent rule (a 3pt-tall filled rect),
// which sits above the metadata lines (11pt, F1). PDF y increases
// upward, so items higher on the page have larger y values.
const titleY = Number(/\/F2 28 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Configure AcmeSync backups\) Tj/.exec(cover)[1]);
const ruleY = Number(/[\d.]+ [\d.]+ [\d.]+ rg ([\d.]+) ([\d.]+) [\d.]+ 3\.00 re f/.exec(cover)[2]);
const authorY = Number(/\/F1 11 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Author: Jane Doe\) Tj/.exec(cover)[1]);
assert.ok(titleY > 740, 'title starts near the top edge');
assert.ok(titleY > ruleY, 'title sits above the accent rule');
assert.ok(ruleY > authorY, 'metadata sits below the accent rule');
});
test('PDF pagination: short steps pack onto a page; a step that does not fit moves to a fresh page', (t) => {
const root = makeTmpDir('pdfpage');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Pagination test' });
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
// Doesn't fit in what's left of page 1, but fits comfortably on its own
// page — with enough room left for step D to pack onto that same page.
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(95) });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
const ast = buildRenderAst(store, guide.guideId);
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
const pages = bookmarkPages(fs.readFileSync(file));
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 1]);
assert.equal(pageCount, 2);
});
test('PDF pagination: a step longer than one page starts fresh and overflows; the next step starts on a new page', (t) => {
const root = makeTmpDir('pdfpage2');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Pagination overflow test' });
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
// Longer than a full page: starts on its own page and overflows onto the next.
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(120) });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
const ast = buildRenderAst(store, guide.guideId);
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
const pages = bookmarkPages(fs.readFileSync(file));
// A and B pack onto page 0; C starts fresh on page 1 and overflows onto
// page 2; D is forced onto a fresh page 3 rather than sharing C's spillover.
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 3]);
assert.equal(pageCount, 4);
});
test('GIF export: title card + one frame per image step, valid animation', (t) => { test('GIF export: title card + one frame per image step, valid animation', (t) => {
const { ast, root } = fixtureAst(t, 'gifx'); const { ast, root } = fixtureAst(t, 'gifx');
const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 }); const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 });
@@ -146,25 +285,55 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (
assert.equal(imageCount, 2); assert.equal(imageCount, 2);
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels']) { for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels', 'word/settings.xml', 'word/styles.xml']) {
assert.ok(entries.has(required), `missing ${required}`); assert.ok(entries.has(required), `missing ${required}`);
} }
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml'); assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types'); assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types');
assertWellFormedXml(entries.get('word/settings.xml').toString('utf8'), 'settings.xml');
assertWellFormedXml(entries.get('word/styles.xml').toString('utf8'), 'styles.xml');
// Every relationship target exists in the package, every embed has a rel. // Every relationship target exists in the package, every embed has a rel.
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8'); const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]); const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]);
assert.equal(relTargets.length, 2); assert.equal(relTargets.length, 4);
for (const target of relTargets) { assert.ok(relTargets.includes('settings.xml'));
assert.ok(relTargets.includes('styles.xml'));
const mediaTargets = relTargets.filter((target) => target.startsWith('media/'));
assert.equal(mediaTargets.length, 2);
const iconTargets = mediaTargets.filter((target) => target.includes('callout-'));
const imageTargets = mediaTargets.filter((target) => target.includes('image'));
assert.equal(iconTargets.length, 0);
assert.equal(imageTargets.length, 2);
for (const target of iconTargets) {
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
const img = decodePng(entries.get(`word/${target}`));
assert.equal(img.width, 24);
}
for (const target of imageTargets) {
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`); assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
const img = decodePng(entries.get(`word/${target}`)); const img = decodePng(entries.get(`word/${target}`));
assert.equal(img.width, 320); assert.equal(img.width, 320);
} }
const docXml = entries.get('word/document.xml').toString('utf8'); const docXml = entries.get('word/document.xml').toString('utf8');
const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]); const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]);
const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]); const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]).filter((id) => id !== 'rId1');
assert.deepEqual(embeds.sort(), relIds.sort()); for (const id of embeds) {
assert.ok(relIds.includes(id), `missing relationship for ${id}`);
}
assert.ok(docXml.includes('w:fldChar w:fldCharType="begin" w:dirty="true"'));
assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u'));
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
assert.ok(docXml.includes('w:outlineLvl w:val="0"'));
assert.ok(docXml.includes('w:outlineLvl w:val="1"'));
const stylesXml = entries.get('word/styles.xml').toString('utf8');
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading1"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading2"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading3"'));
// unzip CLI also accepts the package (it is a plain zip). // unzip CLI also accepts the package (it is a plain zip).
assert.ok(entries.size >= 6); assert.ok(entries.size >= 6);
@@ -174,7 +343,7 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
const { ast, root } = fixtureAst(t, 'pptx'); const { ast, root } = fixtureAst(t, 'pptx');
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out')); const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));
assert.equal(slideCount, 4, 'title slide + 3 steps'); assert.equal(slideCount, 5, 'title slide + contents slide + 3 steps');
assert.equal(imageCount, 2); assert.equal(imageCount, 2);
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
for (const required of [ for (const required of [
@@ -192,6 +361,10 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
// presentation.xml references each slide and the count matches. // presentation.xml references each slide and the count matches.
const pres = entries.get('ppt/presentation.xml').toString('utf8'); const pres = entries.get('ppt/presentation.xml').toString('utf8');
assert.equal((pres.match(/<p:sldId /g) || []).length, slideCount); assert.equal((pres.match(/<p:sldId /g) || []).length, slideCount);
assert.ok(entries.get('ppt/slides/slide2.xml').toString('utf8').includes('Contents'));
const slide4 = entries.get('ppt/slides/slide4.xml').toString('utf8');
assert.ok(slide4.includes('Warning: Access'));
assert.ok(slide4.includes('Admins only.'));
// image rels on slides resolve to media files. // image rels on slides resolve to media files.
for (let i = 1; i <= slideCount; i++) { for (let i = 1; i <= slideCount; i++) {
const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8'); const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8');
@@ -199,6 +372,31 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`); assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`);
} }
} }
const mediaTargets = [...entries.keys()].filter((name) => name.startsWith('ppt/media/'));
assert.equal(mediaTargets.length, 2);
assert.ok(!mediaTargets.some((name) => name.includes('callout-')));
});
test('PPTX export: TOC paginates onto additional slides before it would overflow', (t) => {
const root = makeTmpDir('pptxtoc');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Large TOC test' });
for (let i = 1; i <= 40; i++) {
store.addStep(guide.guideId, { kind: 'empty', title: `Step ${i}` });
}
const ast = buildRenderAst(store, guide.guideId);
const { file, slideCount } = exportPptx(ast, path.join(root, 'out'));
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
const slideXmls = Array.from({ length: slideCount }, (_, i) => entries.get(`ppt/slides/slide${i + 1}.xml`).toString('utf8'));
const tocSlides = slideXmls.filter((xml) => xml.includes('Contents'));
assert.ok(tocSlides.length >= 2, 'large TOC should span multiple slides');
assert.ok(tocSlides[0].includes('1. Step 1'));
assert.ok(!tocSlides[0].includes('40. Step 40'));
assert.ok(tocSlides.at(-1).includes('40. Step 40'));
}); });
test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => { test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => {
@@ -237,6 +435,6 @@ test('a saved template changes exporter behavior through runExport', (t) => {
const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover')); const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover'));
assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages'); assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages');
assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired'); assert.equal(Object.keys(EXPORTERS).length, 11, 'all eleven formats wired');
assert.throws(() => runExport('exe', ast, path.join(root, 'out3'))); assert.throws(() => runExport('exe', ast, path.join(root, 'out3')));
}); });
+62 -6
View File
@@ -8,6 +8,7 @@ const path = require('node:path');
const { buildRenderAst, renderStepImage } = require('../../core/renderast'); const { buildRenderAst, renderStepImage } = require('../../core/renderast');
const { exportJson } = require('../../exporters/json'); const { exportJson } = require('../../exporters/json');
const { exportMarkdown } = require('../../exporters/markdown'); const { exportMarkdown } = require('../../exporters/markdown');
const { exportWikiJs } = require('../../exporters/wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
const { exportConfluence } = require('../../exporters/confluence'); const { exportConfluence } = require('../../exporters/confluence');
const { htmlToMarkdown } = require('../../exporters/htmlmd'); const { htmlToMarkdown } = require('../../exporters/htmlmd');
@@ -44,6 +45,33 @@ test('render AST: numbering, placeholder expansion, hidden/skipped filtering', (
assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]); assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]);
}); });
test('render AST: guide metadata defaults to empty strings and expands placeholders', (t) => {
const root = makeTmpDir('astmeta');
t.after(() => rmrf(root));
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
// Fixture guide has no metadata set: all fields default to ''.
const noMeta = buildRenderAst(store, bare.guideId);
assert.deepEqual(noMeta.guide.metadata, { author: '', coAuthors: '', organization: '' });
// Set metadata with placeholders and re-check expansion against guide + global scope.
const guide = store.getGuide(bare.guideId);
guide.metadata = {
author: '[[Author]]',
coAuthors: 'Alex Lee, [[CoAuthor]]',
organization: '[[Org]]',
};
store.saveGuide(guide);
const ast = buildRenderAst(store, guide.guideId, { globals: { CoAuthor: 'Sam Patel', Org: 'GlobalOrg' } });
// Guide-level placeholder (Author -> Casey) wins over global; CoAuthor/Org fall back to globals.
assert.deepEqual(ast.guide.metadata, {
author: 'Casey',
coAuthors: 'Alex Lee, Sam Patel',
organization: 'GlobalOrg',
});
});
test('JSON export produces a parseable document with real image files', (t) => { test('JSON export produces a parseable document with real image files', (t) => {
const root = makeTmpDir('expjson'); const root = makeTmpDir('expjson');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
@@ -109,11 +137,39 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup'); assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup');
assert.equal(lines[fenceStart + 2], '```'); assert.equal(lines[fenceStart + 2], '```');
assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row'); assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row');
// Warning text block became a GFM alert blockquote with its content. // Warning text block became a styled HTML callout with its content.
const warnIdx = lines.findIndex((l) => l.startsWith('> [!WARNING]')); assert.ok(md.includes('<div class="sf-callout sf-callout-warning"'));
assert.ok(warnIdx > 0); assert.ok(md.includes('border-left: 4px solid #f59e0b'));
assert.equal(lines[warnIdx + 1], '> **Access**'); assert.ok(!md.includes('background: #fffbeb'));
assert.equal(lines[warnIdx + 2], '> Admins only.'); assert.ok(md.includes('color: inherit;'));
assert.ok(md.includes('Warning: Access'));
assert.ok(md.includes('<p>Admins only.</p>'));
});
test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => {
const root = makeTmpDir('expwikijs');
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 } = exportWikiJs(ast, out);
const md = fs.readFileSync(file, 'utf8');
const lines = md.split('\n');
assert.equal(lines[0], '# Configure AcmeSync backups');
assert.ok(lines.some((l) => l === '## Contents'));
assert.ok(lines.some((l) => l.startsWith('## 1. Open AcmeSync settings')));
assert.ok(lines.some((l) => l.startsWith('> **Access**')));
assert.ok(lines.includes('> Admins only.'));
assert.ok(lines.includes('{.is-warning}'));
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);
}
}); });
test('Confluence export writes storage-format XML and image attachments', (t) => { test('Confluence export writes storage-format XML and image attachments', (t) => {
@@ -184,7 +240,7 @@ test('Rich HTML export: TOC matches sections, checkboxes per step, local-only pe
const { file } = exportHtmlRich(ast, out); const { file } = exportHtmlRich(ast, out);
const html = fs.readFileSync(file, 'utf8'); const html = fs.readFileSync(file, 'utf8');
const tocAnchors = [...html.matchAll(/<li class="d\d"><a href="#([^"]+)"/g)].map((m) => m[1]); const tocAnchors = [...html.matchAll(/<li class="d\d">\s*<a href="#([^"]+)"/g)].map((m) => m[1]);
const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]); const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(tocAnchors, sectionIds); assert.deepEqual(tocAnchors, sectionIds);
assert.equal(sectionIds.length, 3); assert.equal(sectionIds.length, 3);
+24
View File
@@ -213,3 +213,27 @@ test('guide ids are validated against path traversal', (t) => {
assert.throws(() => store.guideDir('a/b')); assert.throws(() => store.guideDir('a/b'));
assert.throws(() => store.stepDir('ok', '..')); assert.throws(() => store.stepDir('ok', '..'));
}); });
test('guide metadata: optional fields default to empty strings and round-trip via save/load', (t) => {
const root = makeTmpDir('metadata');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Metadata test' });
assert.deepEqual(guide.metadata, { author: '', coAuthors: '', organization: '' });
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' };
store.saveGuide(guide);
const fresh = new GuideStore(root);
const loaded = fresh.getGuide(guide.guideId);
assert.deepEqual(loaded.metadata, { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' });
// A guide saved before metadata existed normalizes to the same defaults.
const legacy = fresh.getGuide(guide.guideId);
delete legacy.metadata;
store.saveGuide(legacy);
assert.deepEqual(fresh.getGuide(guide.guideId).metadata, { author: '', coAuthors: '', organization: '' });
loaded.metadata = 'not an object';
assert.throws(() => store.saveGuide(loaded), /metadata must be an object/);
});