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..d410060 100644 --- a/app/main.js +++ b/app/main.js @@ -14,6 +14,7 @@ const { SearchIndex } = require('../core/search'); const { TemplateManager, FORMATS } = 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'); @@ -607,8 +608,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 582ca1f..c3cebee 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..999235a 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -639,6 +639,44 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} }); } +/** + * Optional document metadata (author, co-authors, organization) shown at the + * top of the guide, below the title, and surfaced on the PDF cover page. + */ +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 { 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), + ), + 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(), + }); + close(); + resolve(true); + }, + }, 'Save'), + ], + onClose: () => resolve(false), + }); + }); +} + const SHORTCUTS = [ ['Capture & steps', [ ['Ctrl+S', 'Save (writes linked archive when guide is linked)'], @@ -727,6 +765,7 @@ window.StepForgeDialogs = { showInfoDialog, showBackupsDialog, showPlaceholdersDialog, + showGuideInfoDialog, showShortcutsDialog, showTemplateManager, showRecordingReminder, diff --git a/app/renderer/editor.js b/app/renderer/editor.js index b6ab0e6..8d875b4 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1571,6 +1571,18 @@ class GuideEditor { }); } + async openGuideInfo() { + if (!this.guide) return; + await dialogs.showGuideInfoDialog({ + values: this.guide.metadata || {}, + onSave: async (metadata) => { + this.guide.metadata = metadata; + await api.guide.save({ guide: this.guide }); + this.onToast('Guide information saved.'); + }, + }); + } + openShortcutsHelp() { dialogs.showShortcutsDialog(); } 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/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/exporters/pdf.js b/exporters/pdf.js index e54d913..d749e4a 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,79 @@ 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; + 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] }); @@ -197,10 +265,23 @@ function exportPdf(ast, outDir, template = {}) { } 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 headSize = step.depth > 0 ? 12 : 14; writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); @@ -259,6 +340,7 @@ function exportPdf(ast, outDir, template = {}) { } y += 10; + forcedFresh = totalHeight > usableH; } function emitBlock(tb) { 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..9b6fa73 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,34 @@ 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; +} + /** Tiny XML well-formedness check: balanced tags, single root. */ function assertWellFormedXml(xml, label) { const body = xml.replace(/<\?xml[^?]*\?>/, '').trim(); @@ -80,6 +110,82 @@ 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 > 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 }); diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index 119749a..185a087 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -44,6 +44,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)); 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/); +});