diff --git a/app/export-runner.js b/app/export-runner.js new file mode 100644 index 0000000..17f65b8 --- /dev/null +++ b/app/export-runner.js @@ -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 }; diff --git a/app/main.js b/app/main.js index b383010..3dc4bbf 100644 --- a/app/main.js +++ b/app/main.js @@ -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 }; }); diff --git a/app/renderer/app.js b/app/renderer/app.js index 96376cd..1692691 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -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() }, diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 532a64e..de99f9d 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -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, diff --git a/app/renderer/editor.js b/app/renderer/editor.js index b6ab0e6..840f252 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -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 }); diff --git a/app/renderer/style.css b/app/renderer/style.css index 06f6675..f0bda60 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -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; diff --git a/app/renderer/util.js b/app/renderer/util.js index c521fe7..dc70189 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -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, '"'); + +/** 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) => `

${escapeHtml(para).replace(/\n/g, '
')}

`) + .join(''); +} diff --git a/core/export-worker.js b/core/export-worker.js new file mode 100644 index 0000000..b19d174 --- /dev/null +++ b/core/export-worker.js @@ -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)); +}); diff --git a/core/pdf.js b/core/pdf.js index 21febb7..04ceefb 100644 --- a/core/pdf.js +++ b/core/pdf.js @@ -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; diff --git a/core/renderast.js b/core/renderast.js index 773f583..fe0dd64 100644 --- a/core/renderast.js +++ b/core/renderast.js @@ -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, }; diff --git a/core/schema.js b/core/schema.js index 84cd151..88e2b51 100644 --- a/core/schema.js +++ b/core/schema.js @@ -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; } diff --git a/core/templates.js b/core/templates.js index 1422383..225c884 100644 --- a/core/templates.js +++ b/core/templates.js @@ -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 }; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ea0578..095560c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,6 +81,7 @@ guide.json + step.json + settings ▼ hidden/skipped, focused-view geometry) Render AST ──► exporters/json.js .json + steps-/ 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) diff --git a/exporters/confluence.js b/exporters/confluence.js index 2f00a76..675ef15 100644 --- a/exporters/confluence.js +++ b/exporters/confluence.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> diff --git a/exporters/document-layout.js b/exporters/document-layout.js new file mode 100644 index 0000000..1cec7f9 --- /dev/null +++ b/exporters/document-layout.js @@ -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, +}; diff --git a/exporters/docx.js b/exporters/docx.js index d79bf6e..bf845ea 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -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 }; diff --git a/exporters/html.js b/exporters/html.js index 852973e..d0aaeb0 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -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)} - -

${escapeHtml(ast.guide.title)}

-${ast.guide.descriptionHtml ? `
${ast.guide.descriptionHtml}
` : ''} + +
+${renderCover(ast, tpl)} +${toc} ${stepsHtml} - + +
`; @@ -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 + ? ` + ` + : ''; - const tocHtml = ast.steps.map((step) => - `
  • ${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
  • ` - ).join('\n'); - - const stepsHtml = ast.steps.map((step) => ` -
    -

    - - ${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')} -

    - ${stepBodyHtml(step, ast, images, tpl)} -
    `).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 = {}) { ${escapeHtml(ast.guide.title)} - + - -
    -
    -

    ${escapeHtml(ast.guide.title)}

    -${ast.guide.descriptionHtml ? `
    ${ast.guide.descriptionHtml}
    ` : ''} -
    +${renderCover(ast, tpl)} +
    +
    +
    +
    ${stepsHtml} -
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
    +
    diff --git a/exporters/image-bundle.js b/exporters/image-bundle.js index 6842112..815118d 100644 --- a/exporters/image-bundle.js +++ b/exporters/image-bundle.js @@ -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, diff --git a/exporters/index.js b/exporters/index.js index 92c5687..e2892c3 100644 --- a/exporters/index.js +++ b/exporters/index.js @@ -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, diff --git a/exporters/json.js b/exporters/json.js index 8e9d789..f199520 100644 --- a/exporters/json.js +++ b/exporters/json.js @@ -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, diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js new file mode 100644 index 0000000..10e2a67 --- /dev/null +++ b/exporters/markdown-guide.js @@ -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( + `
    `, + `
    ${escapeHtml(title)}
    `, + ); + if (body) lines.push(`
    ${tb.descriptionHtml || ''}
    `); + lines.push('
    ', ''); + 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('
    ', ''); + 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(``, ''); + lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); + if (step.skipped) lines.push('*(skipped)*', ''); + + const { before, rest } = stepContentGroups(step); + for (const tb of before) emitBlock(lines, tb, { alertStyle }); + + if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); + + const img = images.get(step.stepId); + if (img) { + if (tpl.azureWiki && tpl.imageMaxWidth > 0) { + lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, ''); + } else { + lines.push(`![Step ${step.number}](${img.relPath})`, ''); + } + } + + for (const block of rest) { + if (block.kind === 'text') { + emitBlock(lines, block, { alertStyle }); + } else if (block.kind === 'code') { + lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', ''); + } else if (block.kind === 'table') { + if (!block.rows || !block.rows.length) continue; + const width = Math.max(...block.rows.map((r) => r.length)); + const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; }; + lines.push(`| ${pad(block.rows[0]).join(' | ')} |`); + lines.push(`|${' --- |'.repeat(width)}`); + for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`); + lines.push(''); + } + } + } + + const file = path.join(outDir, `${guideSlug(ast)}${fileExt}`); + fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n'); + return { file, imageCount: images.size }; +} + +module.exports = { + DEFAULT_TEMPLATE, + anchorFor, + renderMarkdownGuide, +}; diff --git a/exporters/markdown.js b/exporters/markdown.js index 5d6be64..dc71773 100644 --- a/exporters/markdown.js +++ b/exporters/markdown.js @@ -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 .md plus a steps-/ 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(``, ''); - lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); - if (step.skipped) lines.push('*(skipped)*', ''); - - const { before, rest } = stepContentGroups(step); - for (const tb of before) emitBlock(lines, tb); - - if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); - - const img = images.get(step.stepId); - if (img) { - if (tpl.azureWiki && tpl.imageMaxWidth > 0) { - lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, ''); - } else { - lines.push(`![Step ${step.number}](${img.relPath})`, ''); - } - } - - for (const block of rest) { - if (block.kind === 'text') { - emitBlock(lines, block); - } else if (block.kind === 'code') { - lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', ''); - } else if (block.kind === 'table') { - if (!block.rows || !block.rows.length) continue; - const width = Math.max(...block.rows.map((r) => r.length)); - const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; }; - lines.push(`| ${pad(block.rows[0]).join(' | ')} |`); - lines.push(`|${' --- |'.repeat(width)}`); - for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`); - lines.push(''); - } - } - } - - const file = path.join(outDir, `${guideSlug(ast)}.md`); - fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n'); - return { file, imageCount: images.size }; -} - -function emitBlock(lines, tb) { - const label = LEVEL_LABEL[tb.level] || 'Note'; - // GitHub-Flavored Markdown alert syntax — renders with a colored, - // icon-labeled box on GitHub/Azure DevOps wikis and several other - // viewers; degrades to a plain blockquote elsewhere. - lines.push(`> [!${label.toUpperCase()}]`); - if (tb.title) lines.push(`> **${tb.title}**`); - const body = htmlToMarkdown(tb.descriptionHtml); - if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`); - lines.push(''); + return renderMarkdownGuide(ast, outDir, template, { + defaults: DEFAULT_TEMPLATE, + alertStyle: 'html', + tocTitle: 'Contents', + fileExt: '.md', + }); } module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; diff --git a/exporters/pdf.js b/exporters/pdf.js index e54d913..34c997c 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -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) { diff --git a/exporters/pptx.js b/exporters/pptx.js index 89b2286..6f40541 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -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) { `${runsXml}`; } +function rectShape(x, y, w, h, fill) { + return `` + + `` + + ``; +} + function para(text, { size = 1800, bold = false, color = '111827' } = {}) { return `${escapeXml(text)}`; } @@ -39,6 +64,49 @@ function picture(relId, x, y, w, h) { ``; } +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 ` `; } +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 = ` @@ -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 }); } diff --git a/exporters/wikijs.js b/exporters/wikijs.js new file mode 100644 index 0000000..456407b --- /dev/null +++ b/exporters/wikijs.js @@ -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 }; diff --git a/scripts/build-release.sh b/scripts/build-release.sh index d3827fe..366b49b 100644 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -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 diff --git a/scripts/make-sample-guide.js b/scripts/make-sample-guide.js index 13bb440..bffb313 100644 --- a/scripts/make-sample-guide.js +++ b/scripts/make-sample-guide.js @@ -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); diff --git a/tests/checks/test_workflow_sample_artifacts.sh b/tests/checks/test_workflow_sample_artifacts.sh index c34ad02..7b543da 100644 --- a/tests/checks/test_workflow_sample_artifacts.sh +++ b/tests/checks/test_workflow_sample_artifacts.sh @@ -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" diff --git a/tests/unit/export-worker.test.js b/tests/unit/export-worker.test.js new file mode 100644 index 0000000..90bad8a --- /dev/null +++ b/tests/unit/export-worker.test.js @@ -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: {}, + }), + ); +}); diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index df45d3f..a82659c 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -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) => `

    ${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}

    `; + store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '

    Short content A.

    ' }); + store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '

    Short content B.

    ' }); + // 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: '

    Short content D.

    ' }); + 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) => `

    ${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}

    `; + store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '

    Short content A.

    ' }); + store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '

    Short content B.

    ' }); + // 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: '

    Short content D.

    ' }); + 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(/ 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'))); }); diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index 119749a..295cea9 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -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('
    Admins only.

    ')); +}); + +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(/
  • m[1]); + const tocAnchors = [...html.matchAll(/
  • \s* m[1]); const sectionIds = [...html.matchAll(/
    m[1]); assert.deepEqual(tocAnchors, sectionIds); assert.equal(sectionIds.length, 3); diff --git a/tests/unit/store.test.js b/tests/unit/store.test.js index aa9e672..8752077 100644 --- a/tests/unit/store.test.js +++ b/tests/unit/store.test.js @@ -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/); +});