Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process

Lets users record author/co-authors/organization for a guide via a new
"Guide information…" dialog; this metadata renders below the title on
the PDF cover (title now sits above the accent rule). PDF export also
paginates so each step fits its own page where possible, keeps a
step's title/image/lead-in together, and forces the next step onto a
fresh page after an oversized step overflows. Exports now run in a
forked helper process so large guides no longer freeze the UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-06-15 12:54:31 -05:00
co-authored by Claude Sonnet 4.6
parent 4732c5daf3
commit 0b490e2aa9
13 changed files with 460 additions and 9 deletions
+73
View File
@@ -0,0 +1,73 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runExportInWorker } = require('../../app/export-runner');
const { buildRenderAst } = require('../../core/renderast');
const { runExport } = require('../../exporters');
const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers');
test('export helper process produces the same result as an in-process export', async (t) => {
const root = makeTmpDir('exportworker');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
const expected = runExport('json', buildRenderAst(store, guide.guideId), path.join(root, 'inproc'));
const result = await runExportInWorker({
dataDir: store.root,
guideId: guide.guideId,
format: 'json',
options: {},
outDir: path.join(root, 'worker'),
globals: {},
});
assert.equal(result.imageCount, expected.imageCount);
assert.ok(fs.existsSync(result.file));
const fromWorker = JSON.parse(fs.readFileSync(result.file, 'utf8'));
const fromInProcess = JSON.parse(fs.readFileSync(expected.file, 'utf8'));
// Each build stamps its own generatedAt; everything else must match exactly.
delete fromWorker.generatedAt;
delete fromInProcess.generatedAt;
assert.deepEqual(fromWorker, fromInProcess);
});
test('export helper process rejects on an unknown format', async (t) => {
const root = makeTmpDir('exportworkerbadfmt');
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
await assert.rejects(
runExportInWorker({
dataDir: store.root,
guideId: guide.guideId,
format: 'exe',
options: {},
outDir: path.join(root, 'out'),
globals: {},
}),
/unknown export format/,
);
});
test('export helper process rejects on an unknown guide id', async (t) => {
const root = makeTmpDir('exportworkerbadguide');
t.after(() => rmrf(root));
const { store } = buildFixtureGuide(path.join(root, 'data'));
await assert.rejects(
runExportInWorker({
dataDir: store.root,
guideId: 'guide_does_not_exist',
format: 'json',
options: {},
outDir: path.join(root, 'out'),
globals: {},
}),
);
});