17 Commits
Author SHA1 Message Date
Tyler a201b05c9e 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
2026-06-15 22:10:32 +00:00
TylerandClaude Sonnet 4.6 5b54305b9f Show a loading spinner on the Export/Preview buttons while exporting
The export dialog's Export and Preview buttons now disable and show a
spinner with status text while the corresponding async operation is
in flight, so the user knows the (sometimes slow) export is running
and the app isn't stuck.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:07:35 -05:00
Tyler 3de8ec978b Render PPTX callouts and normalize markdown text 2026-06-15 14:58:16 -05:00
Tyler 4f74b03324 fix docx toc and callouts 2026-06-15 14:46:39 -05:00
Tyler 7da39831ab remove markdown callout fill 2026-06-15 14:41:55 -05:00
Tyler ad3348f721 paginate pptx toc slides 2026-06-15 14:40:16 -05:00
Tyler d67afd2bc9 docx headings for toc 2026-06-15 14:37:02 -05:00
Tyler f79bbfed9f Style markdown callouts with HTML 2026-06-15 14:32:14 -05:00
Tyler 61e3c812a0 Polish exported documents and TOCs 2026-06-15 14:22:31 -05:00
Tyler b84b7883fe Fix export dialog format handling 2026-06-15 14:03:32 -05:00
Tyler b85eaad0cb Indent nested guide editor steps 2026-06-15 14:02:34 -05:00
Tyler 8cc1ba3532 Add Wiki.js markdown export option 2026-06-15 13:59:04 -05:00
Tyler ab280daf63 Style PDF cover and TOC links 2026-06-15 13:49:31 -05:00
Tyler b51a4c46ce Make PDF Contents entries clickable links to each step's page
Adds PdfBuilder.linkRect() for /Subtype /Link annotations with a
/Dest pointing at another page's destination. TOC entries record a
target placeholder that the per-step loop fills in once it knows
which page the step landed on, so clicking a Contents line jumps the
reader straight to that step.
2026-06-15 13:22:51 -05:00
Tyler 7229b154c9 Merge branch 'main' into export_changes 2026-06-15 13:11:35 -05:00
TylerandClaude Sonnet 4.6 722c5d2eee Add a Description field to the Guide information dialog
Reuses the guide's existing descriptionHtml/descriptionText, which
already renders on the PDF cover and at the top of every other export
format, so it surfaces alongside author/co-authors/organization with
no exporter changes needed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 13:04:35 -05:00
TylerandClaude Sonnet 4.6 0b490e2aa9 Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process
Lets users record author/co-authors/organization for a guide via a new
"Guide information…" dialog; this metadata renders below the title on
the PDF cover (title now sits above the accent rule). PDF export also
paginates so each step fits its own page where possible, keeps a
step's title/image/lead-in together, and forces the next step onto a
fresh page after an oversized step overflows. Exports now run in a
forked helper process so large guides no longer freeze the UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 12:54:31 -05: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/);
});