From 0b490e2aa98a5f3a33312971b4b4639ec905513a Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 12:54:31 -0500 Subject: [PATCH 01/15] Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users record author/co-authors/organization for a guide via a new "Guide information…" dialog; this metadata renders below the title on the PDF cover (title now sits above the accent rule). PDF export also paginates so each step fits its own page where possible, keeps a step's title/image/lead-in together, and forces the next step onto a fresh page after an oversized step overflows. Exports now run in a forked helper process so large guides no longer freeze the UI. Co-Authored-By: Claude Sonnet 4.6 --- app/export-runner.js | 42 +++++++++++ app/main.js | 11 ++- app/renderer/app.js | 1 + app/renderer/dialogs.js | 39 ++++++++++ app/renderer/editor.js | 12 ++++ core/export-worker.js | 26 +++++++ core/renderast.js | 5 ++ core/schema.js | 7 ++ exporters/pdf.js | 96 +++++++++++++++++++++++-- tests/unit/export-worker.test.js | 73 +++++++++++++++++++ tests/unit/exporters-binary.test.js | 106 ++++++++++++++++++++++++++++ tests/unit/exporters-text.test.js | 27 +++++++ tests/unit/store.test.js | 24 +++++++ 13 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 app/export-runner.js create mode 100644 core/export-worker.js create mode 100644 tests/unit/export-worker.test.js 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/); +}); From 722c5d2eee70838990194f902a9de70032a02a07 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 13:04:35 -0500 Subject: [PATCH 02/15] Add a Description field to the Guide information dialog Reuses the guide's existing descriptionHtml/descriptionText, which already renders on the PDF cover and at the top of every other export format, so it surfaces alongside author/co-authors/organization with no exporter changes needed. Co-Authored-By: Claude Sonnet 4.6 --- app/renderer/dialogs.js | 13 +++++++++++-- app/renderer/editor.js | 8 ++++++-- app/renderer/util.js | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 999235a..b2fcf13 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -640,14 +640,19 @@ 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. + * 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', @@ -657,6 +662,9 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) { 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'), @@ -666,6 +674,7 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) { author: authorInput.value.trim(), coAuthors: coAuthorsInput.value.trim(), organization: organizationInput.value.trim(), + description: descriptionInput.value.trim(), }); close(); resolve(true); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 8d875b4..ee8d14f 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1574,9 +1574,13 @@ class GuideEditor { async openGuideInfo() { if (!this.guide) return; await dialogs.showGuideInfoDialog({ - values: this.guide.metadata || {}, - onSave: async (metadata) => { + 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.'); }, diff --git a/app/renderer/util.js b/app/renderer/util.js index c521fe7..47915cb 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -187,3 +187,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(''); +} From b51a4c46cee59c9a781ee341276c248260f614e2 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 13:22:51 -0500 Subject: [PATCH 03/15] Make PDF Contents entries clickable links to each step's page Adds PdfBuilder.linkRect() for /Subtype /Link annotations with a /Dest pointing at another page's destination. TOC entries record a target placeholder that the per-step loop fills in once it knows which page the step landed on, so clicking a Contents line jumps the reader straight to that step. --- core/pdf.js | 32 +++++++++++++++++++++++++++++ exporters/pdf.js | 20 +++++++++++++++--- tests/unit/exporters-binary.test.js | 28 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) 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/exporters/pdf.js b/exporters/pdf.js index d749e4a..ee80b7c 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -252,13 +252,25 @@ 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 }); + pdf.linkRect(M + indent, y, usableW - indent, lineH, target); + y += lineH; + } } pdf.addPage(); y = M; @@ -283,6 +295,8 @@ function exportPdf(ast, outDir, template = {}) { // 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] }); diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 9b6fa73..007a54b 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -55,6 +55,24 @@ function bookmarkPages(buf) { 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(); @@ -103,6 +121,16 @@ 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 + + assert.deepEqual(tocTargets, bookmarks.map((b) => b.pageIndex)); +}); + 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')); From ab280daf636f6923538a5e051028d8a1bbdeb9bc Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 13:49:31 -0500 Subject: [PATCH 04/15] Style PDF cover and TOC links --- exporters/pdf.js | 5 +++-- tests/unit/exporters-binary.test.js | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/exporters/pdf.js b/exporters/pdf.js index ee80b7c..34c997c 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -232,7 +232,8 @@ function exportPdf(ast, outDir, template = {}) { pdf.addPage(); if (tpl.includeCover) { - y = M + usableH * 0.18; + // 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 }); @@ -267,7 +268,7 @@ function exportPdf(ast, outDir, template = {}) { 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 }); + pdf.text(line, M + indent, y, { size: tocSize, color: tpl.accentColor }); pdf.linkRect(M + indent, y, usableW - indent, lineH, target); y += lineH; } diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 007a54b..7504e39 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -127,8 +127,12 @@ test('PDF Contents: each entry is a clickable link to its step\'s page', (t) => 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) => { @@ -169,6 +173,7 @@ test('PDF cover: title in big text above the accent rule, guide metadata below i 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'); }); From 8cc1ba3532def0ea31ff43028692b6ca035799e4 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 13:59:04 -0500 Subject: [PATCH 05/15] Add Wiki.js markdown export option --- app/main.js | 8 +- core/templates.js | 18 ++- docs/ARCHITECTURE.md | 1 + exporters/index.js | 2 + exporters/markdown-guide.js | 117 ++++++++++++++++++ exporters/markdown.js | 91 ++------------ exporters/wikijs.js | 26 ++++ scripts/build-release.sh | 2 +- scripts/make-sample-guide.js | 2 +- .../checks/test_workflow_sample_artifacts.sh | 4 +- tests/unit/exporters-binary.test.js | 2 +- tests/unit/exporters-text.test.js | 27 ++++ 12 files changed, 207 insertions(+), 93 deletions(-) create mode 100644 exporters/markdown-guide.js create mode 100644 exporters/wikijs.js diff --git a/app/main.js b/app/main.js index d410060..3dc4bbf 100644 --- a/app/main.js +++ b/app/main.js @@ -11,7 +11,7 @@ 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'); @@ -579,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', 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/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/markdown-guide.js b/exporters/markdown-guide.js new file mode 100644 index 0000000..6f0fd02 --- /dev/null +++ b/exporters/markdown-guide.js @@ -0,0 +1,117 @@ +'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 = { + 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', +}; + +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 === '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}`, ''); + if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), ''); + + if (tpl.toc && ast.steps.length > 1) { + lines.push(`## ${tocTitle}`, ''); + for (const step of ast.steps) { + const indent = ' '.repeat(step.depth); + lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`); + } + lines.push(''); + } + + for (const step of ast.steps) { + const heading = step.depth > 0 ? '###' : '##'; + lines.push(`<a id="${anchorFor(step)}"></a>`, ''); + lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); + if (step.skipped) lines.push('*(skipped)*', ''); + + const { before, rest } = stepContentGroups(step); + for (const tb of before) emitBlock(lines, tb, { 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..8eb9aa7 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 <slug>.md plus a steps-<slug>/ image folder. * azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept. */ -const DEFAULT_TEMPLATE = { - toc: true, - includeImages: true, - azureWiki: false, - imageMaxWidth: 0, // 0 = natural size -}; - -function anchorFor(step) { - return `step-${step.number.replace(/\./g, '-')}`; -} - function exportMarkdown(ast, outDir, template = {}) { - const tpl = { ...DEFAULT_TEMPLATE, ...template }; - fs.mkdirSync(outDir, { recursive: true }); - const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map(); - const lines = []; - - lines.push(`# ${ast.guide.title}`, ''); - if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), ''); - - if (tpl.toc && ast.steps.length > 1) { - lines.push('## Contents', ''); - for (const step of ast.steps) { - const indent = ' '.repeat(step.depth); - lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`); - } - lines.push(''); - } - - for (const step of ast.steps) { - const heading = step.depth > 0 ? '###' : '##'; - lines.push(`<a id="${anchorFor(step)}"></a>`, ''); - lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); - if (step.skipped) lines.push('*(skipped)*', ''); - - const { before, rest } = stepContentGroups(step); - for (const tb of before) emitBlock(lines, tb); - - if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); - - const img = images.get(step.stepId); - if (img) { - if (tpl.azureWiki && tpl.imageMaxWidth > 0) { - lines.push(`![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: 'gfm', + tocTitle: 'Contents', + fileExt: '.md', + }); } module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; diff --git a/exporters/wikijs.js b/exporters/wikijs.js new file mode 100644 index 0000000..a6b5bcd --- /dev/null +++ b/exporters/wikijs.js @@ -0,0 +1,26 @@ +'use strict'; + +const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide'); + +/** + * Wiki.js markdown exporter. Same step/body structure as the generic + * Markdown exporter, but omits the manual Contents section by default and + * emits Wiki.js-friendly callout blocks. + */ + +const WIKIJS_TEMPLATE = { + toc: false, + 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/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 7504e39..3035475 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -376,6 +376,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 185a087..c5f5086 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'); @@ -143,6 +144,32 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) assert.equal(lines[warnIdx + 2], '> Admins only.'); }); +test('Wiki.js export: TOC is omitted, 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) => { const root = makeTmpDir('expconf'); t.after(() => rmrf(root)); From b85eaad0cb92c3d73835e97949e82b154eeb72a9 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 14:02:34 -0500 Subject: [PATCH 06/15] Indent nested guide editor steps --- app/renderer/editor.js | 56 ++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index ee8d14f..c0118e9 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) { From b84b7883fe48c2a010486ee22a2235336fe9b112 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 14:03:32 -0500 Subject: [PATCH 07/15] Fix export dialog format handling --- app/renderer/editor.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index c0118e9..840f252 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1666,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 }); From 61e3c812a04c013191a836f49866e53519f3536d Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 14:22:31 -0500 Subject: [PATCH 08/15] Polish exported documents and TOCs --- exporters/confluence.js | 10 +- exporters/document-layout.js | 40 +++ exporters/docx.js | 104 +++++- exporters/html.js | 519 ++++++++++++++++++++++++---- exporters/image-bundle.js | 4 +- exporters/json.js | 3 + exporters/markdown-guide.js | 11 +- exporters/pptx.js | 52 ++- exporters/wikijs.js | 5 +- tests/unit/exporters-binary.test.js | 33 +- tests/unit/exporters-text.test.js | 6 +- 11 files changed, 668 insertions(+), 119 deletions(-) create mode 100644 exporters/document-layout.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..bd7d445 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -6,6 +6,8 @@ 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'); +const raster = require('../core/raster'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one @@ -15,6 +17,7 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex const DEFAULT_TEMPLATE = { includeImages: true, + includeToc: true, imageWidthTwips: 9000, // ~15.9cm inside A4 margins }; @@ -64,6 +67,28 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) { `</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`; } +function calloutIcon(level) { + const img = raster.createImage(24, 24, [0, 0, 0, 0]); + const fill = { + info: [37, 99, 235, 255], + success: [16, 185, 129, 255], + warn: [245, 158, 11, 255], + error: [239, 68, 68, 255], + }[level] || [37, 99, 235, 255]; + raster.fillOval(img, 1, 1, 22, 22, fill); + const glyph = level === 'success' ? 'v' : level === 'warn' ? '!' : level === 'error' ? 'x' : 'i'; + raster.drawTextCentered(img, 12, 13, glyph, 12, [255, 255, 255, 255]); + return img; +} + +function pageBreak() { + return p('<w:r><w:br w:type="page"/></w:r>'); +} + +function headingStyleForDepth(depth) { + return `Heading${Math.min(3, depth + 1)}`; +} + 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>`; @@ -87,29 +112,71 @@ function exportDocx(ast, outDir, template = {}) { const media = []; // { name, data } const rels = []; // relationship XML strings - let relCounter = 0; + let relCounter = 1; // rId1 reserved for settings.xml + let stepImageCount = 0; + + const iconRelIds = {}; + for (const level of ['info', 'success', 'warn', 'error']) { + const icon = calloutIcon(level); + const relId = ++relCounter; + iconRelIds[level] = relId; + const name = `callout-${level}.png`; + media.push({ name, data: encodePng(icon) }); + rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`); + } 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(p( + `<w:fldSimple w:instr="TOC \\o "1-3" \\h \\z \\u"><w:r><w:rPr><w:i/></w:rPr><w:t xml:space="preserve">Update contents in Word</w:t></w:r></w:fldSimple>` + )); + body.push(pageBreak()); + } + + const emitTextBlock = (tb) => { + const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; + const iconRelId = iconRelIds[tb.level] || iconRelIds.info; + const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + body.push(p( + `${drawing(iconRelId, 16, 16, 240)}${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 }), + `${step.forceNewPage ? '<w:pageBreakBefore/>' : ''}<w:pStyle w:val="${headingStyleForDepth(step.depth)}"/>` + )); 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 +191,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 +215,7 @@ ${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/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/> </Types>`, }, { @@ -166,16 +230,18 @@ ${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 }, ...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} -
Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
+
Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}
+
`; @@ -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
    +
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}
    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/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 index 6f0fd02..a10fcfc 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); 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, @@ -57,13 +58,17 @@ function renderMarkdownGuide(ast, outDir, template = {}, { 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 step of ast.steps) { - const indent = ' '.repeat(step.depth); - lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`); + for (const entry of tocEntries(ast)) { + const indent = ' '.repeat(entry.depth); + lines.push(`${indent}- [${entry.number}. ${entry.title}](#${entry.anchor})`); } lines.push(''); } diff --git a/exporters/pptx.js b/exporters/pptx.js index 89b2286..f822a1f 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -6,6 +6,7 @@ const { zipSync } = require('../core/zip'); const { escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages } = require('./common'); +const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** * PPTX exporter: a title slide plus one 16:9 slide per step (title bar + @@ -15,6 +16,7 @@ const { guideSlug, renderAllImages } = require('./common'); const DEFAULT_TEMPLATE = { includeImages: true, titleSlide: true, + includeToc: true, }; const SLIDE_W = 12192000; // EMU, 16:9 @@ -29,6 +31,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)}`; } @@ -85,22 +93,46 @@ 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(914400, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })); + titleContent += rectShape(914400, 3300000, 2200000, 14000, '2563EB'); + titleContent += textBox(914400, 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) { + let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); + tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000, para('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' })); + toc.forEach((entry, index) => { + const x = 914400 + (entry.depth * 220000); + const y = 2300000 + (index * 255000); + tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB'); + tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, 220000, + para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 })); + }); + slides.push({ + xml: slideXml(tocContent), 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 })); + let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); + content += textBox(457200, 420000, SLIDE_W - 914400, 620000, + para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); + content += rectShape(457200, 1120000, 2400000, 12000, '2563EB'); const rels = []; const media = []; @@ -112,14 +144,14 @@ 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, maxH = SLIDE_H - 1870000 - (step.descriptionText ? 650000 : 260000); 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), 1500000, w, h); } if (step.descriptionText) { - content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000, + content += textBox(457200, SLIDE_H - 720000, SLIDE_W - 914400, 600000, para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); } slides.push({ xml: slideXml(content), rels, media }); diff --git a/exporters/wikijs.js b/exporters/wikijs.js index a6b5bcd..456407b 100644 --- a/exporters/wikijs.js +++ b/exporters/wikijs.js @@ -4,12 +4,11 @@ const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide'); /** * Wiki.js markdown exporter. Same step/body structure as the generic - * Markdown exporter, but omits the manual Contents section by default and - * emits Wiki.js-friendly callout blocks. + * Markdown exporter, but uses Wiki.js-friendly callout blocks. */ const WIKIJS_TEMPLATE = { - toc: false, + toc: true, includeImages: true, imageMaxWidth: 0, }; diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 3035475..b8d97e0 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -285,25 +285,45 @@ 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']) { 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'); // 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, 7); + assert.ok(relTargets.includes('settings.xml')); + + const mediaTargets = relTargets.filter((target) => target.startsWith('media/')); + assert.equal(mediaTargets.length, 6); + const iconTargets = mediaTargets.filter((target) => target.includes('callout-')); + const imageTargets = mediaTargets.filter((target) => target.includes('image')); + assert.equal(iconTargets.length, 4); + 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('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"')); // unzip CLI also accepts the package (it is a plain zip). assert.ok(entries.size >= 6); @@ -313,7 +333,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 [ @@ -331,6 +351,7 @@ 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(/ Admins only.'); }); -test('Wiki.js export: TOC is omitted, wiki callouts render, images exist', (t) => { +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')); @@ -156,7 +156,7 @@ test('Wiki.js export: TOC is omitted, wiki callouts render, images exist', (t) = 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 === '## 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.')); @@ -238,7 +238,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); From f79bbfed9f51c857c97324dd5d26fa0a53752474 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:32:14 -0500 Subject: [PATCH 09/15] Style markdown callouts with HTML --- exporters/markdown-guide.js | 20 ++++++++++++++++++++ exporters/markdown.js | 2 +- tests/unit/exporters-text.test.js | 10 +++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js index a10fcfc..297641e 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -2,6 +2,7 @@ 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'); @@ -20,6 +21,13 @@ const WIKIJS_CALLOUT_CLASS = { error: 'is-danger', }; +const HTML_CALLOUT_THEME = { + info: { label: 'Note', border: '#2563eb', bg: '#eff6ff', fg: '#1d4ed8', kind: 'note' }, + success: { label: 'Tip', border: '#10b981', bg: '#ecfdf5', fg: '#047857', kind: 'tip' }, + warn: { label: 'Warning', border: '#f59e0b', bg: '#fffbeb', fg: '#b45309', kind: 'warning' }, + error: { label: 'Important', border: '#ef4444', bg: '#fef2f2', fg: '#b91c1c', kind: 'important' }, +}; + function anchorFor(step) { return `step-${step.number.replace(/\./g, '-')}`; } @@ -30,6 +38,18 @@ function quoteBody(text) { 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; + 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'; diff --git a/exporters/markdown.js b/exporters/markdown.js index 8eb9aa7..dc71773 100644 --- a/exporters/markdown.js +++ b/exporters/markdown.js @@ -10,7 +10,7 @@ const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown function exportMarkdown(ast, outDir, template = {}) { return renderMarkdownGuide(ast, outDir, template, { defaults: DEFAULT_TEMPLATE, - alertStyle: 'gfm', + alertStyle: 'html', tocTitle: 'Contents', fileExt: '.md', }); diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index e919ce5..2a6b978 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -137,11 +137,11 @@ 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) => { From d67afd2bc94af4aa5e8f520181329b40268b6c8f Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:37:02 -0500 Subject: [PATCH 10/15] docx headings for toc --- exporters/docx.js | 75 ++++++++++++++++++++++++++++- tests/unit/exporters-binary.test.js | 13 ++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/exporters/docx.js b/exporters/docx.js index bd7d445..c21469e 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -89,6 +89,18 @@ 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(''); + parts.push(``); + parts.push(``); + return parts.join(''); +} + function table(rows) { const cols = Math.max(...rows.map((r) => r.length)); const grid = `${''.repeat(cols)}`; @@ -106,15 +118,72 @@ function table(rows) { return `${borders}${grid}${body}`; } +function stylesXml() { + const headingStyle = (styleId, name, outlineLvl, size, color) => ` + + + + + + + + + + + + + + + + + + + + `; + + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + ${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')} + ${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')} + ${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')} +`; +} + 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 = 1; // rId1 reserved for settings.xml + let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml let stepImageCount = 0; + rels.push(``); + const iconRelIds = {}; for (const level of ['info', 'success', 'warn', 'error']) { const icon = calloutIcon(level); @@ -162,7 +231,7 @@ function exportDocx(ast, outDir, template = {}) { const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22; body.push(p( run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), - `${step.forceNewPage ? '' : ''}` + headingParagraphProps(step.depth, step.forceNewPage) )); const { before, rest } = stepContentGroups(step); @@ -215,6 +284,7 @@ ${body.join('\n')} + `, }, @@ -235,6 +305,7 @@ ${rels.join('\n')} `, }, { 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 })), ]; diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index b8d97e0..80f69e6 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -285,18 +285,20 @@ 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', 'word/settings.xml']) { + 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, 7); + assert.equal(relTargets.length, 8); assert.ok(relTargets.includes('settings.xml')); + assert.ok(relTargets.includes('styles.xml')); const mediaTargets = relTargets.filter((target) => target.startsWith('media/')); assert.equal(mediaTargets.length, 6); @@ -324,6 +326,13 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', ( 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); From ad3348f721980e5eaa7af1c7983cf95c1817933c Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:40:16 -0500 Subject: [PATCH 11/15] paginate pptx toc slides --- exporters/pptx.js | 55 +++++++++++++++++++++-------- tests/unit/exporters-binary.test.js | 22 ++++++++++++ 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/exporters/pptx.js b/exporters/pptx.js index f822a1f..2ddcd85 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -22,6 +22,14 @@ const DEFAULT_TEMPLATE = { const SLIDE_W = 12192000; // EMU, 16:9 const SLIDE_H = 6858000; const EMU_PER_PX = 9525; +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 @@ -58,6 +66,33 @@ ${content} `; } +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 = ` @@ -110,20 +145,12 @@ function exportPptx(ast, outDir, template = {}) { } if (toc.length) { - let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); - tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000, para('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' })); - toc.forEach((entry, index) => { - const x = 914400 + (entry.depth * 220000); - const y = 2300000 + (index * 255000); - tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB'); - tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, 220000, - para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 })); - }); - slides.push({ - xml: slideXml(tocContent), - rels: [], media: [], + const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE); + tocPages.forEach((page, index) => { + slides.push({ + xml: tocSlideXml(ast, page, { continued: index > 0 }), + rels: [], media: [], + }); }); } diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 80f69e6..9c2acea 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -370,6 +370,28 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve', } }); +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) => { const root = makeTmpDir('tpl'); t.after(() => rmrf(root)); From 7da39831ab71680277f49957c37c9e2974be965c Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:41:55 -0500 Subject: [PATCH 12/15] remove markdown callout fill --- exporters/markdown-guide.js | 11 ++++++----- tests/unit/exporters-text.test.js | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js index 297641e..7abfc8a 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -22,10 +22,10 @@ const WIKIJS_CALLOUT_CLASS = { }; const HTML_CALLOUT_THEME = { - info: { label: 'Note', border: '#2563eb', bg: '#eff6ff', fg: '#1d4ed8', kind: 'note' }, - success: { label: 'Tip', border: '#10b981', bg: '#ecfdf5', fg: '#047857', kind: 'tip' }, - warn: { label: 'Warning', border: '#f59e0b', bg: '#fffbeb', fg: '#b45309', kind: 'warning' }, - error: { label: 'Important', border: '#ef4444', bg: '#fef2f2', fg: '#b91c1c', kind: 'important' }, + 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) { @@ -42,8 +42,9 @@ function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) { 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 || ''}
    `); diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index 2a6b978..b823d98 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -140,6 +140,7 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) // Warning text block became a styled HTML callout with its content. assert.ok(md.includes('
    Admins only.

    ')); }); From 4f74b03324be229c7f42e3194f8d8f4235953e2e Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:46:39 -0500 Subject: [PATCH 13/15] fix docx toc and callouts --- exporters/docx.js | 42 +++++++++-------------------- tests/unit/exporters-binary.test.js | 9 ++++--- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/exporters/docx.js b/exporters/docx.js index c21469e..bf845ea 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -7,7 +7,6 @@ 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'); -const raster = require('../core/raster'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one @@ -67,24 +66,20 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) { ``; } -function calloutIcon(level) { - const img = raster.createImage(24, 24, [0, 0, 0, 0]); - const fill = { - info: [37, 99, 235, 255], - success: [16, 185, 129, 255], - warn: [245, 158, 11, 255], - error: [239, 68, 68, 255], - }[level] || [37, 99, 235, 255]; - raster.fillOval(img, 1, 1, 22, 22, fill); - const glyph = level === 'success' ? 'v' : level === 'warn' ? '!' : level === 'error' ? 'x' : 'i'; - raster.drawTextCentered(img, 12, 13, glyph, 12, [255, 255, 255, 255]); - return img; -} - function pageBreak() { return p(''); } +function tocFieldParagraph() { + return p([ + '', + ' TOC \\o "1-3" \\h \\z \\u ', + '', + 'Update contents in Word', + '', + ].join('')); +} + function headingStyleForDepth(depth) { return `Heading${Math.min(3, depth + 1)}`; } @@ -184,16 +179,6 @@ function exportDocx(ast, outDir, template = {}) { rels.push(``); - const iconRelIds = {}; - for (const level of ['info', 'success', 'warn', 'error']) { - const icon = calloutIcon(level); - const relId = ++relCounter; - iconRelIds[level] = relId; - const name = `callout-${level}.png`; - media.push({ name, data: encodePng(icon) }); - rels.push(``); - } - const body = []; body.push(p( run(ast.guide.title, { bold: true, size: 48 }), @@ -210,18 +195,15 @@ function exportDocx(ast, outDir, template = {}) { run('Contents', { bold: true, size: 28 }), '' )); - body.push(p( - `Update contents in Word` - )); + body.push(tocFieldParagraph()); body.push(pageBreak()); } const emitTextBlock = (tb) => { const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; - const iconRelId = iconRelIds[tb.level] || iconRelIds.info; const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; body.push(p( - `${drawing(iconRelId, 16, 16, 240)}${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`, + `${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`, `` )); }; diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 9c2acea..3c9896c 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -296,15 +296,15 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', ( // 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, 8); + 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, 6); + 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, 4); + assert.equal(iconTargets.length, 0); assert.equal(imageTargets.length, 2); for (const target of iconTargets) { @@ -323,7 +323,8 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', ( for (const id of embeds) { assert.ok(relIds.includes(id), `missing relationship for ${id}`); } - assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u')); + 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"')); From 3de8ec978b4f2ff916fe4f4b912ee7e545f3dcbd Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 14:58:16 -0500 Subject: [PATCH 14/15] Render PPTX callouts and normalize markdown text --- exporters/markdown-guide.js | 2 +- exporters/pptx.js | 94 ++++++++++++++++++++++++++--- tests/unit/exporters-binary.test.js | 6 ++ tests/unit/exporters-text.test.js | 1 + 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js index 7abfc8a..10e2a67 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -47,7 +47,7 @@ function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) { `
    `, `
    ${escapeHtml(title)}
    `, ); - if (body) lines.push(`
    ${tb.descriptionHtml || ''}
    `); + if (body) lines.push(`
    ${tb.descriptionHtml || ''}
    `); lines.push('
    ', ''); return; } diff --git a/exporters/pptx.js b/exporters/pptx.js index 2ddcd85..6f40541 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -5,7 +5,7 @@ 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'); /** @@ -22,6 +22,15 @@ const DEFAULT_TEMPLATE = { 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; @@ -55,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 ` para(line, { size: 1500, color: '6B7280' }))].join('')); slides.push({ @@ -156,10 +208,24 @@ function exportPptx(ast, outDir, template = {}) { let mediaCounter = 0; for (const step of ast.steps) { + 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, 420000, SLIDE_W - 914400, 620000, + content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H, para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); - content += rectShape(457200, 1120000, 2400000, 12000, '2563EB'); + 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 = []; @@ -171,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 - 1870000 - (step.descriptionText ? 650000 : 260000); + 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), 1500000, 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 - 720000, SLIDE_W - 914400, 600000, + 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/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 3c9896c..a82659c 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -362,6 +362,9 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve', 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) => { diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index b823d98..295cea9 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -141,6 +141,7 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) assert.ok(md.includes('
    Admins only.

    ')); }); From 5b54305b9fdc891b482cfba10f0378d89f7f8a23 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Mon, 15 Jun 2026 17:07:35 -0500 Subject: [PATCH 15/15] Show a loading spinner on the Export/Preview buttons while exporting The export dialog's Export and Preview buttons now disable and show a spinner with status text while the corresponding async operation is in flight, so the user knows the (sometimes slow) export is running and the app isn't stuck. Co-Authored-By: Claude Sonnet 4.6 --- app/renderer/dialogs.js | 53 ++++++++++++++++++++++++++--------------- app/renderer/style.css | 19 +++++++++++++++ app/renderer/util.js | 21 ++++++++++++++++ 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index b2fcf13..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), }); 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 47915cb..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) => {