Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19
@@ -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
@@ -11,9 +11,10 @@ const {
|
||||
const { GuideStore } = require('../core/store');
|
||||
const { Settings } = require('../core/settings');
|
||||
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 { runExport, EXPORTERS } = require('../exporters');
|
||||
const { runExportInWorker } = require('./export-runner');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { readLock } = require('../core/locks');
|
||||
@@ -578,13 +579,17 @@ function setupIpc() {
|
||||
});
|
||||
|
||||
// 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 }) => {
|
||||
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
|
||||
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
|
||||
const mod = {
|
||||
json: '../exporters/json',
|
||||
markdown: '../exporters/markdown',
|
||||
wikijs: '../exporters/wikijs',
|
||||
'html-simple': '../exporters/html',
|
||||
'html-rich': '../exporters/html',
|
||||
confluence: '../exporters/confluence',
|
||||
@@ -607,8 +612,14 @@ function setupIpc() {
|
||||
dir = res.filePaths[0];
|
||||
}
|
||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
|
||||
const result = runExport(format, ast, dir, options || {});
|
||||
const result = await runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId,
|
||||
format,
|
||||
options: options || {},
|
||||
outDir: dir,
|
||||
globals: settings.getGlobalPlaceholders(),
|
||||
});
|
||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||
return { ok: true, ...result };
|
||||
});
|
||||
|
||||
@@ -317,6 +317,7 @@ class StepForgeApp {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
contextMenu(rect.left, rect.bottom + 4, [
|
||||
{ label: 'Rename guide…', action: () => this.renameGuide() },
|
||||
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
||||
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
||||
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||
|
||||
+82
-19
@@ -413,28 +413,43 @@ function showExportDialog({
|
||||
outDir: outDirInput.value.trim() || null,
|
||||
});
|
||||
|
||||
const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel');
|
||||
const previewBtn = el('button', {
|
||||
onClick: async () => {
|
||||
if (typeof onPreview !== 'function') return;
|
||||
setButtonLoading(previewBtn, true, 'Preview…');
|
||||
try {
|
||||
await onPreview(payload()); // keep dialog open so settings can be tweaked
|
||||
} finally {
|
||||
setButtonLoading(previewBtn, false);
|
||||
}
|
||||
},
|
||||
}, 'Preview');
|
||||
const exportBtn = el('button.primary', {
|
||||
onClick: async () => {
|
||||
if (typeof onExport !== 'function') return;
|
||||
cancelBtn.disabled = true;
|
||||
previewBtn.disabled = true;
|
||||
setButtonLoading(exportBtn, true, 'Exporting…');
|
||||
try {
|
||||
const ok = await onExport(payload());
|
||||
if (ok !== false) {
|
||||
close();
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
cancelBtn.disabled = false;
|
||||
previewBtn.disabled = false;
|
||||
setButtonLoading(exportBtn, false);
|
||||
}
|
||||
},
|
||||
}, 'Export');
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Export',
|
||||
body,
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button', {
|
||||
onClick: async () => {
|
||||
if (typeof onPreview !== 'function') return;
|
||||
await onPreview(payload()); // keep dialog open so settings can be tweaked
|
||||
},
|
||||
}, 'Preview'),
|
||||
el('button.primary', {
|
||||
onClick: async () => {
|
||||
if (typeof onExport !== 'function') return;
|
||||
const ok = await onExport(payload());
|
||||
if (ok !== false) {
|
||||
close();
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
}, 'Export'),
|
||||
],
|
||||
footer: [cancelBtn, previewBtn, exportBtn],
|
||||
wide: true,
|
||||
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 = [
|
||||
['Capture & steps', [
|
||||
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
|
||||
@@ -727,6 +789,7 @@ window.StepForgeDialogs = {
|
||||
showInfoDialog,
|
||||
showBackupsDialog,
|
||||
showPlaceholdersDialog,
|
||||
showGuideInfoDialog,
|
||||
showShortcutsDialog,
|
||||
showTemplateManager,
|
||||
showRecordingReminder,
|
||||
|
||||
+53
-25
@@ -87,6 +87,16 @@ function stepNumberMap(steps) {
|
||||
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) {
|
||||
return target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
@@ -761,15 +771,10 @@ class GuideEditor {
|
||||
this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : '';
|
||||
for (const step of this.steps) {
|
||||
const number = numbers.get(step.stepId) || '';
|
||||
let depth = 0;
|
||||
let parent = step.parentStepId;
|
||||
while (parent && this.stepMap.has(parent)) {
|
||||
depth += 1;
|
||||
parent = this.stepMap.get(parent).parentStepId;
|
||||
}
|
||||
const depth = stepDepth(step, this.stepMap);
|
||||
const selected = current && current.stepId === 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' : ''}`,
|
||||
dataset: { stepId: step.stepId },
|
||||
onClick: () => {
|
||||
@@ -802,23 +807,26 @@ class GuideEditor {
|
||||
{ label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() },
|
||||
]);
|
||||
},
|
||||
},
|
||||
this.stepSelectMode
|
||||
? el('input', {
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
onChange: () => this.toggleStepSelection(step.stepId),
|
||||
})
|
||||
: null,
|
||||
el('span.status-dot', { className: `status-dot status-${step.status}` }),
|
||||
el('span.num', {}, number || '•'),
|
||||
el('span.t', {}, step.title || 'Untitled step'),
|
||||
el('span.flags', {}, [
|
||||
step.parentStepId ? 'sub' : '',
|
||||
step.hidden ? 'hidden' : '',
|
||||
step.skipped ? 'skipped' : '',
|
||||
].filter(Boolean).join(' · ')));
|
||||
};
|
||||
if (depth) itemProps.style = { marginLeft: `${depth * 18}px` };
|
||||
const item = el('div.step-item', itemProps,
|
||||
this.stepSelectMode
|
||||
? el('input', {
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
onChange: () => this.toggleStepSelection(step.stepId),
|
||||
})
|
||||
: null,
|
||||
el('span.status-dot', { className: `status-dot status-${step.status}` }),
|
||||
el('span.num', {}, number || '•'),
|
||||
el('span.t', {}, step.title || 'Untitled step'),
|
||||
el('span.flags', {}, [
|
||||
step.parentStepId ? 'sub' : '',
|
||||
step.hidden ? 'hidden' : '',
|
||||
step.skipped ? 'skipped' : '',
|
||||
].filter(Boolean).join(' · ')),
|
||||
);
|
||||
this.dom.stepsList.append(item);
|
||||
}
|
||||
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() {
|
||||
dialogs.showShortcutsDialog();
|
||||
}
|
||||
@@ -1642,7 +1666,11 @@ class GuideEditor {
|
||||
}
|
||||
|
||||
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 = {};
|
||||
for (const format of formats) {
|
||||
templatesByFormat[format.id] = await api.templates.list({ format: format.id });
|
||||
|
||||
@@ -90,6 +90,25 @@ button.tool.active {
|
||||
border-color: var(--accent);
|
||||
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 {
|
||||
font: inherit;
|
||||
|
||||
@@ -34,6 +34,27 @@ function clearNode(node) {
|
||||
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) {
|
||||
let t = null;
|
||||
const wrapped = (...args) => {
|
||||
@@ -187,3 +208,21 @@ function fmtDate(iso) {
|
||||
|
||||
const escapeHtml = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/** 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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\'').replace(/&/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('');
|
||||
}
|
||||
|
||||
@@ -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
@@ -60,6 +60,7 @@ class PdfBuilder {
|
||||
this.images = []; // { name, width, height, data (deflated RGB), smask? }
|
||||
this.imageCache = new Map();
|
||||
this.bookmarks = []; // { title, pageIndex, y }
|
||||
this.links = []; // { pageIndex, x, yTop, w, h, target }
|
||||
}
|
||||
|
||||
addPage() {
|
||||
@@ -173,6 +174,16 @@ class PdfBuilder {
|
||||
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() {
|
||||
if (!this.pages.length) this.addPage();
|
||||
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 = '';
|
||||
if (this.bookmarks.length) {
|
||||
const outlineRootId = objects.length + 1;
|
||||
|
||||
@@ -125,6 +125,11 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
createdAt: guide.createdAt,
|
||||
updatedAt: guide.updatedAt,
|
||||
flags: guide.flags,
|
||||
metadata: {
|
||||
author: expand(guide.metadata?.author || ''),
|
||||
coAuthors: expand(guide.metadata?.coAuthors || ''),
|
||||
organization: expand(guide.metadata?.organization || ''),
|
||||
},
|
||||
},
|
||||
steps: limited,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
|
||||
title: fields.title || 'Untitled guide',
|
||||
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
|
||||
placeholders: { ...(fields.placeholders || {}) },
|
||||
metadata: {
|
||||
author: '',
|
||||
coAuthors: '',
|
||||
organization: '',
|
||||
...(fields.metadata || {}),
|
||||
},
|
||||
flags: {
|
||||
focusedViewDefault: false,
|
||||
hideSkippedStepsInExports: true,
|
||||
@@ -149,6 +155,7 @@ function validateGuide(guide) {
|
||||
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');
|
||||
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('; ')}`);
|
||||
return guide;
|
||||
}
|
||||
|
||||
+16
-2
@@ -11,7 +11,21 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
|
||||
* 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 {
|
||||
constructor(templatesDir) {
|
||||
@@ -96,4 +110,4 @@ class TemplateManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TemplateManager, FORMATS };
|
||||
module.exports = { TemplateManager, FORMATS, FORMAT_LABELS };
|
||||
|
||||
@@ -81,6 +81,7 @@ guide.json + step.json + settings
|
||||
▼ hidden/skipped, focused-view geometry)
|
||||
Render AST ──► exporters/json.js .json + 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-rich.js checkboxes + floating TOC
|
||||
──► exporters/pdf.js native PDF writer (core/pdf.js)
|
||||
|
||||
@@ -5,6 +5,7 @@ const path = require('node:path');
|
||||
const { slugify, escapeXml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
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
|
||||
@@ -14,6 +15,7 @@ const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
toc: true,
|
||||
};
|
||||
|
||||
const MACRO_FOR_LEVEL = {
|
||||
@@ -23,10 +25,6 @@ const MACRO_FOR_LEVEL = {
|
||||
success: 'tip',
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function stepLinkRewrite(html, ast) {
|
||||
return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => {
|
||||
const target = ast.steps.find((s) => s.stepId === id);
|
||||
@@ -112,7 +110,11 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
</head>
|
||||
<body>
|
||||
<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>` : ''}
|
||||
${tpl.toc && ast.steps.length > 1 ? '<ac:structured-macro ac:name="toc"></ac:structured-macro>' : ''}
|
||||
${stepXml}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
@@ -6,6 +6,7 @@ const { zipSync } = require('../core/zip');
|
||||
const { escapeXml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { guideMetaLines, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
||||
@@ -15,6 +16,7 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
includeToc: true,
|
||||
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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
const cols = Math.max(...rows.map((r) => r.length));
|
||||
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>`;
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
const media = []; // { name, data }
|
||||
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 = [];
|
||||
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' })));
|
||||
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) {
|
||||
const headSize = step.depth > 0 ? 26 : 30;
|
||||
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
|
||||
step.forceNewPage ? '<w:pageBreakBefore/>' : ''));
|
||||
const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
|
||||
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
||||
body.push(p(
|
||||
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
|
||||
headingParagraphProps(step.depth, step.forceNewPage)
|
||||
));
|
||||
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
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);
|
||||
if (img) {
|
||||
relCounter += 1;
|
||||
const relId = ++relCounter;
|
||||
const name = `image${relCounter}.png`;
|
||||
media.push({ name, data: encodePng(img) });
|
||||
rels.push(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
|
||||
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
|
||||
rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
|
||||
body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips)));
|
||||
stepImageCount += 1;
|
||||
}
|
||||
|
||||
for (const block of rest) {
|
||||
@@ -124,14 +242,10 @@ function exportDocx(ast, outDir, template = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitTextBlock(tb) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
|
||||
body.push(p(
|
||||
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 settingsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:updateFields w:val="true"/>
|
||||
</w:settings>`;
|
||||
|
||||
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<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="png" ContentType="image/png"/>
|
||||
<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>`,
|
||||
},
|
||||
{
|
||||
@@ -166,16 +282,19 @@ ${body.join('\n')}
|
||||
name: 'word/_rels/document.xml.rels',
|
||||
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<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')}
|
||||
</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 })),
|
||||
];
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const file = path.join(outDir, `${guideSlug(ast)}.docx`);
|
||||
fs.writeFileSync(file, zipSync(entries));
|
||||
return { file, imageCount: media.length };
|
||||
return { file, imageCount: stepImageCount };
|
||||
}
|
||||
|
||||
module.exports = { exportDocx, DEFAULT_TEMPLATE };
|
||||
|
||||
+449
-70
@@ -5,6 +5,7 @@ const path = require('node:path');
|
||||
const { escapeHtml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
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:
|
||||
@@ -18,14 +19,11 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
toc: true,
|
||||
accentColor: '#2563eb',
|
||||
customCss: '',
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function dataUri(img) {
|
||||
return `data:image/png;base64,${encodePng(img).toString('base64')}`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
const parts = [];
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
@@ -68,28 +143,346 @@ function stepBodyHtml(step, ast, images, tpl) {
|
||||
}
|
||||
|
||||
const BASE_CSS = `
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px;
|
||||
padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; }
|
||||
h1 { font-size: 1.7em; margin-bottom: .2em; }
|
||||
h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; }
|
||||
img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; }
|
||||
pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||
table { border-collapse: collapse; margin: .6em 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; }
|
||||
.block { border-left: 4px solid #3b82f6; background: #eff6ff; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; }
|
||||
.block strong { color: #1d4ed8; }
|
||||
:root {
|
||||
--bg: #f4f7fb;
|
||||
--bg-2: #eef3f9;
|
||||
--panel: rgba(255, 255, 255, 0.92);
|
||||
--panel-strong: #ffffff;
|
||||
--panel-soft: #f8fbff;
|
||||
--text: #152033;
|
||||
--muted: #637084;
|
||||
--border: rgba(119, 134, 156, 0.22);
|
||||
--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 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::before { content: '!'; background: rgba(239, 68, 68, 0.14); color: #b91c1c; }
|
||||
.block-error strong { color: #b91c1c; }
|
||||
.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; }
|
||||
.footer-note {
|
||||
margin-top: 20px;
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.skipped { opacity: .55; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #111827; color: #e5e7eb; }
|
||||
h2 { border-color: #374151; }
|
||||
pre.code, .block { background: #1f2937; }
|
||||
th, td { border-color: #4b5563; }
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--bg-2: #0f172a;
|
||||
--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 };
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
const stepsHtml = ast.steps.map((step) => `
|
||||
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
|
||||
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2>
|
||||
${stepBodyHtml(step, ast, images, tpl)}
|
||||
</section>`).join('\n');
|
||||
const toc = tpl.toc && ast.steps.length > 1
|
||||
? `
|
||||
<section class="toc-card">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
${renderTocList(ast)}
|
||||
</ul>
|
||||
</section>`
|
||||
: '';
|
||||
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -112,11 +509,13 @@ function exportHtmlSimple(ast, outDir, template = {}) {
|
||||
<title>${escapeHtml(ast.guide.title)}</title>
|
||||
<style>${BASE_CSS}${tpl.customCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
|
||||
<body style="${bodyStyle(tpl)}">
|
||||
<div class="doc doc-simple">
|
||||
${renderCover(ast, tpl)}
|
||||
${toc}
|
||||
${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>
|
||||
</html>
|
||||
`;
|
||||
@@ -130,39 +529,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
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) =>
|
||||
`<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 stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n');
|
||||
|
||||
const script = `
|
||||
(function () {
|
||||
@@ -199,19 +578,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(ast.guide.title)}</title>
|
||||
<style>${BASE_CSS}${richCss}${tpl.customCss}</style>
|
||||
<style>${BASE_CSS}${RICH_CSS}${tpl.customCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="toc"><strong>Contents</strong><ul>
|
||||
<body style="${bodyStyle(tpl)}">
|
||||
<div class="doc layout-rich">
|
||||
${tocHtml}
|
||||
</ul></nav>
|
||||
<main>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
|
||||
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div>
|
||||
${renderCover(ast, tpl)}
|
||||
<div class="progress progress-card">
|
||||
<div class="label"></div>
|
||||
<div class="bar"><div class="fill"></div></div>
|
||||
</div>
|
||||
${stepsHtml}
|
||||
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps</small></footer>
|
||||
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script>${script}</script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages } = require('./common');
|
||||
const { tocEntries, guideSummary } = require('./document-layout');
|
||||
const raster = require('../core/raster');
|
||||
const { decodePng, encodePng } = require('../core/png');
|
||||
|
||||
@@ -39,7 +40,8 @@ function exportImageBundle(ast, outDir, template = {}) {
|
||||
const meta = {
|
||||
format: 'stepforge-image-bundle',
|
||||
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) => ({
|
||||
number: step.number,
|
||||
title: step.title,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { exportJson } = require('./json');
|
||||
const { exportMarkdown } = require('./markdown');
|
||||
const { exportWikiJs } = require('./wikijs');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('./html');
|
||||
const { exportConfluence } = require('./confluence');
|
||||
const { exportPdf } = require('./pdf');
|
||||
@@ -14,6 +15,7 @@ const { exportPptx } = require('./pptx');
|
||||
const EXPORTERS = {
|
||||
json: exportJson,
|
||||
markdown: exportMarkdown,
|
||||
wikijs: exportWikiJs,
|
||||
'html-simple': exportHtmlSimple,
|
||||
'html-rich': exportHtmlRich,
|
||||
confluence: exportConfluence,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common');
|
||||
const { tocEntries, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* JSON exporter: structured guide + steps, annotated screenshots written to
|
||||
@@ -29,7 +30,9 @@ function exportJson(ast, outDir, template = {}) {
|
||||
descriptionHtml: ast.guide.descriptionHtml,
|
||||
createdAt: ast.guide.createdAt,
|
||||
updatedAt: ast.guide.updatedAt,
|
||||
summary: guideSummary(ast),
|
||||
},
|
||||
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
|
||||
steps: ast.steps.map((step) => ({
|
||||
number: step.number,
|
||||
kind: step.kind,
|
||||
|
||||
@@ -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(``, '');
|
||||
} else {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -1,96 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { htmlToMarkdown } = require('./htmlmd');
|
||||
const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown-guide');
|
||||
|
||||
/**
|
||||
* Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder.
|
||||
* azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
toc: true,
|
||||
includeImages: true,
|
||||
azureWiki: false,
|
||||
imageMaxWidth: 0, // 0 = natural size
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function exportMarkdown(ast, outDir, template = {}) {
|
||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
|
||||
const lines = [];
|
||||
|
||||
lines.push(`# ${ast.guide.title}`, '');
|
||||
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
|
||||
|
||||
if (tpl.toc && ast.steps.length > 1) {
|
||||
lines.push('## Contents', '');
|
||||
for (const step of ast.steps) {
|
||||
const indent = ' '.repeat(step.depth);
|
||||
lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const step of ast.steps) {
|
||||
const heading = step.depth > 0 ? '###' : '##';
|
||||
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
|
||||
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
|
||||
if (step.skipped) lines.push('*(skipped)*', '');
|
||||
|
||||
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(``, '');
|
||||
} else {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
|
||||
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('');
|
||||
return renderMarkdownGuide(ast, outDir, template, {
|
||||
defaults: DEFAULT_TEMPLATE,
|
||||
alertStyle: 'html',
|
||||
tocTitle: 'Contents',
|
||||
fileExt: '.md',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
|
||||
|
||||
+108
-11
@@ -130,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
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) => {
|
||||
if (y + needed > size.height - M) {
|
||||
if (y > M && y + needed > size.height - M) {
|
||||
pdf.addPage();
|
||||
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();
|
||||
|
||||
if (tpl.includeCover) {
|
||||
y = M + usableH * 0.18;
|
||||
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor });
|
||||
y += 6;
|
||||
writeLines(ast.guide.title, { size: 26, font: 'F2' });
|
||||
y += 8;
|
||||
// Keep the cover title near the top edge instead of vertically centering it.
|
||||
y = M;
|
||||
writeLines(ast.guide.title, { size: 28, font: 'F2' });
|
||||
y += 10;
|
||||
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] });
|
||||
y += 14;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
writeLines('Contents', { size: 16, font: 'F2' });
|
||||
y += 4;
|
||||
const tocSize = 10.5;
|
||||
const lineH = tocSize * 1.35;
|
||||
for (const step of ast.steps) {
|
||||
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, {
|
||||
size: 10.5, indent: 14 * step.depth,
|
||||
});
|
||||
const 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();
|
||||
y = M;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
let forcedFresh = false;
|
||||
const pageBottom = size.height - M;
|
||||
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;
|
||||
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'}`);
|
||||
const tocTarget = tocTargets.get(step.stepId);
|
||||
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
|
||||
const headSize = step.depth > 0 ? 12 : 14;
|
||||
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] });
|
||||
@@ -259,6 +355,7 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
}
|
||||
|
||||
y += 10;
|
||||
forcedFresh = totalHeight > usableH;
|
||||
}
|
||||
|
||||
function emitBlock(tb) {
|
||||
|
||||
+146
-11
@@ -5,7 +5,8 @@ const path = require('node:path');
|
||||
const { zipSync } = require('../core/zip');
|
||||
const { escapeXml } = require('../core/util');
|
||||
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 +
|
||||
@@ -15,11 +16,29 @@ const { guideSlug, renderAllImages } = require('./common');
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
titleSlide: true,
|
||||
includeToc: true,
|
||||
};
|
||||
|
||||
const SLIDE_W = 12192000; // EMU, 16:9
|
||||
const SLIDE_H = 6858000;
|
||||
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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
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' } = {}) {
|
||||
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>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<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>`;
|
||||
}
|
||||
|
||||
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"?>
|
||||
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
|
||||
<a:themeElements>
|
||||
@@ -85,22 +180,52 @@ function exportPptx(ast, outDir, template = {}) {
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] }
|
||||
const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : [];
|
||||
|
||||
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({
|
||||
xml: slideXml(
|
||||
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' }))
|
||||
),
|
||||
xml: slideXml(titleContent),
|
||||
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;
|
||||
for (const step of ast.steps) {
|
||||
let content = textBox(457200, 274638, SLIDE_W - 914400, 700000,
|
||||
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true }));
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
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 media = [];
|
||||
|
||||
@@ -112,15 +237,25 @@ function exportPptx(ast, outDir, template = {}) {
|
||||
const relId = 2; // rId1 = layout, rId2 = image
|
||||
rels.push({ id: relId, name });
|
||||
// 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;
|
||||
const scale = Math.min(maxW / w, maxH / h, 1);
|
||||
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) {
|
||||
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' }));
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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'}
|
||||
- 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 exports (9 formats): see examples/sample-exports/
|
||||
- Sample exports (10 formats): see examples/sample-exports/
|
||||
- Full artifact list with sha256 checksums: artifacts_manifest.json
|
||||
|
||||
## Packaging tool availability
|
||||
|
||||
@@ -223,7 +223,7 @@ function createGuide(store) {
|
||||
|
||||
function exportOutputs(store, guideId, root, manifest) {
|
||||
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 = {};
|
||||
for (const format of formats) {
|
||||
const outDir = path.join(root, 'sample-exports', format);
|
||||
|
||||
@@ -21,7 +21,7 @@ for f in sample-manifest.json sample-guide.sfgz; do
|
||||
done
|
||||
|
||||
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
|
||||
if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then
|
||||
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'));
|
||||
if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format');
|
||||
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
|
||||
|
||||
echo "sample artifacts OK"
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,10 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { GuideStore } = require('../../core/store');
|
||||
const { buildRenderAst } = require('../../core/renderast');
|
||||
const { exportPdf } = require('../../exporters/pdf');
|
||||
const { exportGifGuide } = require('../../exporters/gif');
|
||||
@@ -25,6 +27,52 @@ function hasTool(cmd) {
|
||||
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. */
|
||||
function assertWellFormedXml(xml, label) {
|
||||
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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const { ast, root } = fixtureAst(t, 'pdfgs');
|
||||
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}`));
|
||||
});
|
||||
|
||||
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) => {
|
||||
const { ast, root } = fixtureAst(t, 'gifx');
|
||||
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);
|
||||
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}`);
|
||||
}
|
||||
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
|
||||
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.
|
||||
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
|
||||
const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.equal(relTargets.length, 2);
|
||||
for (const target of relTargets) {
|
||||
assert.equal(relTargets.length, 4);
|
||||
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`);
|
||||
const img = decodePng(entries.get(`word/${target}`));
|
||||
assert.equal(img.width, 320);
|
||||
}
|
||||
const docXml = entries.get('word/document.xml').toString('utf8');
|
||||
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]);
|
||||
assert.deepEqual(embeds.sort(), relIds.sort());
|
||||
const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]).filter((id) => id !== 'rId1');
|
||||
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).
|
||||
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 { 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);
|
||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
||||
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.
|
||||
const pres = entries.get('ppt/presentation.xml').toString('utf8');
|
||||
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.
|
||||
for (let i = 1; i <= slideCount; i++) {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
@@ -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'));
|
||||
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')));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const path = require('node:path');
|
||||
const { buildRenderAst, renderStepImage } = require('../../core/renderast');
|
||||
const { exportJson } = require('../../exporters/json');
|
||||
const { exportMarkdown } = require('../../exporters/markdown');
|
||||
const { exportWikiJs } = require('../../exporters/wikijs');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
|
||||
const { exportConfluence } = require('../../exporters/confluence');
|
||||
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]);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const root = makeTmpDir('expjson');
|
||||
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 + 2], '```');
|
||||
assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row');
|
||||
// Warning text block became a GFM alert blockquote with its content.
|
||||
const warnIdx = lines.findIndex((l) => l.startsWith('> [!WARNING]'));
|
||||
assert.ok(warnIdx > 0);
|
||||
assert.equal(lines[warnIdx + 1], '> **Access**');
|
||||
assert.equal(lines[warnIdx + 2], '> Admins only.');
|
||||
// Warning text block became a styled HTML callout with its content.
|
||||
assert.ok(md.includes('<div class="sf-callout sf-callout-warning"'));
|
||||
assert.ok(md.includes('border-left: 4px solid #f59e0b'));
|
||||
assert.ok(!md.includes('background: #fffbeb'));
|
||||
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) => {
|
||||
@@ -184,7 +240,7 @@ test('Rich HTML export: TOC matches sections, checkboxes per step, local-only pe
|
||||
const { file } = exportHtmlRich(ast, out);
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
|
||||
const tocAnchors = [...html.matchAll(/<li class="d\d"><a href="#([^"]+)"/g)].map((m) => m[1]);
|
||||
const 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]);
|
||||
assert.deepEqual(tocAnchors, sectionIds);
|
||||
assert.equal(sectionIds.length, 3);
|
||||
|
||||
@@ -213,3 +213,27 @@ test('guide ids are validated against path traversal', (t) => {
|
||||
assert.throws(() => store.guideDir('a/b'));
|
||||
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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user