@uniweb/unipress 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@uniweb/unipress",
3
+ "version": "0.2.2",
4
+ "description": "Compile a content directory into a document (PDF, EPUB, Paged.js HTML, Typst source bundle, DOCX, XLSX) using a Uniweb foundation. Five built-in templates: book, monograph, report, data-report, directory.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "unipress": "src/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "docs",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "RELEASING.md"
21
+ ],
22
+ "keywords": [
23
+ "uniweb",
24
+ "unipress",
25
+ "cli",
26
+ "document",
27
+ "docx",
28
+ "xlsx",
29
+ "pdf",
30
+ "typst",
31
+ "foundation"
32
+ ],
33
+ "author": "Proximify",
34
+ "license": "Apache-2.0",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/uniweb/unipress.git"
38
+ },
39
+ "homepage": "https://github.com/uniweb/unipress#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/uniweb/unipress/issues"
42
+ },
43
+ "engines": {
44
+ "node": ">=20.19"
45
+ },
46
+ "dependencies": {
47
+ "handlebars": "^4.7.8",
48
+ "js-yaml": "^4.1.0",
49
+ "prompts": "^2.4.2",
50
+ "react": "^18.0.0 || ^19.0.0",
51
+ "react-dom": "^18.0.0 || ^19.0.0",
52
+ "@uniweb/content-reader": "1.1.5",
53
+ "@uniweb/semantic-parser": "1.1.11",
54
+ "@uniweb/runtime": "0.8.4",
55
+ "@uniweb/core": "0.7.3",
56
+ "@uniweb/build": "0.11.2"
57
+ },
58
+ "scripts": {
59
+ "test": "echo \"no tests yet\" && exit 0",
60
+ "lint": "echo \"no lint yet\" && exit 0"
61
+ }
62
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,23 @@
1
+ // Foundation catalog loader.
2
+ //
3
+ // The catalog (`src/foundations-data.js`) enumerates the Press-based
4
+ // foundations unipress knows how to drive — each entry pairs a foundation
5
+ // source URL with a content scaffold. The `create` command uses it to
6
+ // present choices at scaffold time; the foundation-loader uses it to
7
+ // resolve a catalog id to a URL when document.yml's `foundation:` names a
8
+ // catalog entry.
9
+ //
10
+ // Stored as a JS module (not YAML) so `bun build --compile` can inline the
11
+ // data at bundle time — see foundations-data.js for the rationale.
12
+
13
+ import { FOUNDATIONS } from './foundations-data.js'
14
+
15
+ const byId = new Map(FOUNDATIONS.map((e) => [e.id, e]))
16
+
17
+ export function findCatalogEntry(id) {
18
+ return byId.get(id) || null
19
+ }
20
+
21
+ export function listCatalog() {
22
+ return FOUNDATIONS
23
+ }
package/src/cli.js ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from 'node:util'
4
+ import { inspect } from './commands/inspect.js'
5
+ import { compileCommand } from './commands/compile.js'
6
+ import { createCommand, listTemplatesCommand } from './commands/create.js'
7
+ import { UnipressError } from './errors.js'
8
+ // Import assertion inlines package.json at bundle time. Required for
9
+ // `bun build --compile`: reading the file at runtime fails because the
10
+ // compiled binary's virtual filesystem (/$bunfs) doesn't hold it.
11
+ import pkg from '../package.json' with { type: 'json' }
12
+
13
+ const HELP = `unipress ${pkg.version}
14
+
15
+ Compile a content directory into a document using a Uniweb foundation.
16
+
17
+ Usage:
18
+ unipress <command> [options]
19
+
20
+ Commands:
21
+ compile <dir> Compile a content directory into a document
22
+ --format <fmt> output format (pdf | typst | docx | xlsx | epub)
23
+ overrides format: in document.yml
24
+ pdf compiles via typst source bundle
25
+ --foundation <ref> override document.yml's foundation: field
26
+ --out <path> output file (default: ./<dir>.<ext>)
27
+ --config <path> explicit config file (default: <dir>/unipress.config.js)
28
+ --typst-binary <p> path to a typst binary (skips managed download)
29
+ --keep-temp keep the typst temp dir on failure (for debugging)
30
+ create <dir> Scaffold a new unipress project from a template
31
+ --template <id> template to use (interactive picker if omitted)
32
+ --title <str> document title (prompts if omitted)
33
+ --author <str> document author (prompts if omitted)
34
+ --force overwrite non-empty <dir>
35
+ --yes skip prompts (requires --template)
36
+ inspect <dir> Dump the parsed content as JSON
37
+ --full include web-only fields (assets, icons, ...)
38
+ --summary replace pages[] with route strings only
39
+ --page <route> keep only the page matching <route>
40
+ --depth <n> truncate nested values beyond depth n
41
+ --foundation <ref> override the foundation in document.yml (a name or URL)
42
+ --no-orchestrate skip running the foundation; show only the parsed content
43
+ list-templates List the templates available
44
+
45
+ Options:
46
+ -h, --help Show this help
47
+ -v, --version Show version
48
+ --verbose Include stack traces in error output
49
+ `
50
+
51
+ function printHelp() {
52
+ process.stdout.write(HELP)
53
+ }
54
+
55
+ function printVersion() {
56
+ process.stdout.write(`${pkg.version}\n`)
57
+ }
58
+
59
+ function unknownCommand(name) {
60
+ process.stderr.write(`error: unknown command \`${name}\`\n`)
61
+ process.stderr.write(`run \`unipress --help\` to see available commands\n`)
62
+ process.exit(1)
63
+ }
64
+
65
+ function notImplemented(name) {
66
+ process.stderr.write(`error: \`${name}\` is not implemented yet (M1 — package scaffold)\n`)
67
+ process.exit(2)
68
+ }
69
+
70
+ async function main(argv) {
71
+ const { values, positionals } = parseArgs({
72
+ args: argv,
73
+ options: {
74
+ help: { type: 'boolean', short: 'h' },
75
+ version: { type: 'boolean', short: 'v' },
76
+ full: { type: 'boolean' },
77
+ summary: { type: 'boolean' },
78
+ page: { type: 'string' },
79
+ depth: { type: 'string' },
80
+ foundation: { type: 'string' },
81
+ template: { type: 'string' },
82
+ format: { type: 'string' },
83
+ out: { type: 'string' },
84
+ config: { type: 'string' },
85
+ title: { type: 'string' },
86
+ author: { type: 'string' },
87
+ force: { type: 'boolean' },
88
+ yes: { type: 'boolean' },
89
+ 'typst-binary': { type: 'string' },
90
+ 'keep-temp': { type: 'boolean' },
91
+ 'no-orchestrate': { type: 'boolean' },
92
+ verbose: { type: 'boolean' }
93
+ },
94
+ allowPositionals: true,
95
+ strict: false
96
+ })
97
+
98
+ if (values.version) {
99
+ printVersion()
100
+ process.exit(0)
101
+ }
102
+
103
+ const [command, ...rest] = positionals
104
+
105
+ if (!command || values.help) {
106
+ printHelp()
107
+ process.exit(0)
108
+ }
109
+
110
+ try {
111
+ switch (command) {
112
+ case 'inspect':
113
+ await inspect({
114
+ dir: rest[0],
115
+ full: values.full,
116
+ summary: values.summary,
117
+ page: values.page ?? null,
118
+ depth: values.depth != null ? Number(values.depth) : null,
119
+ foundation: values.foundation ?? null,
120
+ orchestrate: !values['no-orchestrate']
121
+ })
122
+ break
123
+ case 'compile':
124
+ await compileCommand({
125
+ dir: rest[0],
126
+ format: values.format ?? null,
127
+ foundation: values.foundation ?? null,
128
+ out: values.out ?? null,
129
+ config: values.config ?? null,
130
+ typstBinary: values['typst-binary'] ?? null,
131
+ keepTemp: !!values['keep-temp'],
132
+ verbose: !!values.verbose
133
+ })
134
+ break
135
+ case 'create':
136
+ if (values.foundation != null) {
137
+ process.stderr.write(`error: --foundation is not valid for 'create'; use --template instead\n`)
138
+ process.exit(1)
139
+ }
140
+ await createCommand({
141
+ dir: rest[0],
142
+ template: values.template ?? null,
143
+ title: values.title ?? null,
144
+ author: values.author ?? null,
145
+ force: !!values.force,
146
+ yes: !!values.yes,
147
+ })
148
+ break
149
+ case 'list-templates':
150
+ await listTemplatesCommand()
151
+ break
152
+ case 'list-foundations':
153
+ process.stderr.write(`error: 'list-foundations' has been renamed to 'list-templates'\n`)
154
+ process.exit(1)
155
+ break
156
+ default:
157
+ unknownCommand(command)
158
+ }
159
+ } catch (err) {
160
+ // UnipressError = user-addressable problem (bad args, missing file,
161
+ // misconfigured foundation). Exit 1. .format() renders the structured
162
+ // `error:` header + indented `hint:` / `cause:` lines.
163
+ //
164
+ // Anything else = internal bug (TypeError, unhandled rejection from
165
+ // a library we didn't wrap). Exit 2. Always print a `(re-run with
166
+ // --verbose for a stack trace)` pointer unless verbose is already on.
167
+ const verbose = !!values.verbose
168
+ if (err instanceof UnipressError) {
169
+ process.stderr.write(err.format() + '\n')
170
+ if (verbose && err.stack) process.stderr.write(err.stack + '\n')
171
+ process.exit(1)
172
+ }
173
+ process.stderr.write(`internal error: ${err?.message ?? err}\n`)
174
+ if (verbose && err?.stack) {
175
+ process.stderr.write(err.stack + '\n')
176
+ } else {
177
+ process.stderr.write(` hint: re-run with --verbose for a stack trace\n`)
178
+ }
179
+ process.exit(2)
180
+ }
181
+
182
+ // Defensive force-exit. Originally added because importing a built
183
+ // foundation kept a MessagePort alive (react-dom/server got bundled
184
+ // into a foundation chunk and ran React's scheduler at module-eval).
185
+ // Fixed at the source in @uniweb/build by externalizing react-dom/*
186
+ // properly, but kept here as a backstop: foundations published before
187
+ // the fix still leak, and any future module that creates a long-lived
188
+ // handle at import-time would do the same. CLIs shouldn't wait for
189
+ // non-essential handles regardless.
190
+ process.exit(0)
191
+ }
192
+
193
+ main(process.argv.slice(2))
@@ -0,0 +1,37 @@
1
+ // `unipress compile <dir>` — CLI adapter for src/compile.js.
2
+ //
3
+ // Writes verbose progress to stderr so stdout stays clean for the
4
+ // single success summary line.
5
+
6
+ import { compile } from '../compile.js'
7
+
8
+ export async function compileCommand({ dir, format = null, foundation = null, out = null, config = null, typstBinary = null, keepTemp = false, verbose = false } = {}) {
9
+ if (!dir) {
10
+ process.stderr.write('error: `compile` requires a directory argument\n')
11
+ process.stderr.write('usage: unipress compile <dir> [--format <fmt>] [--foundation <ref>] [--out <path>] [--config <path>] [--typst-binary <path>] [--keep-temp] [--verbose]\n')
12
+ process.exit(1)
13
+ }
14
+
15
+ const onProgress = verbose
16
+ ? (msg) => process.stderr.write(`[compile] ${msg}\n`)
17
+ : () => {}
18
+
19
+ const result = await compile({
20
+ dir,
21
+ format,
22
+ foundationRef: foundation,
23
+ outPath: out,
24
+ configPath: config,
25
+ typstBinaryPath: typstBinary,
26
+ keepTemp,
27
+ onProgress
28
+ })
29
+
30
+ const pressFormatNote =
31
+ result.pressFormat && result.pressFormat !== result.format
32
+ ? ` via ${result.pressFormat}`
33
+ : ''
34
+ process.stdout.write(
35
+ `wrote ${result.outPath} (${result.bytes} bytes, ${result.format}${pressFormatNote}, ${result.pageCount} pages)\n`
36
+ )
37
+ }
@@ -0,0 +1,165 @@
1
+ // `unipress create <dir>` — scaffold a content directory from a catalog
2
+ // template. A template pins a foundation and ships starter content; the
3
+ // foundation it pins is the runtime artifact unipress loads at compile.
4
+ //
5
+ // Flow:
6
+ // 1. If --template is not given and not --yes, interactively pick from
7
+ // the catalog. If --yes without --template, fail.
8
+ // 2. Look up the catalog entry — resolves to a scaffold name.
9
+ // 3. Collect scaffold vars (title, author, year) via prompts unless
10
+ // --yes is set (then defaults are used; flags override).
11
+ // 4. Copy templates/<scaffold>/ to <dir> with Handlebars substitution.
12
+ // 5. Print next steps.
13
+ //
14
+ // Result is a content-only folder — no package.json, no node_modules. On
15
+ // first compile, unipress downloads the foundation from the catalog URL
16
+ // into the shared cache and imports it.
17
+
18
+ import { resolve } from 'node:path'
19
+ import prompts from 'prompts'
20
+ import { listCatalog, findCatalogEntry } from '../catalog.js'
21
+ import { scaffold } from '../scaffold.js'
22
+ import { TemplateError } from '../errors.js'
23
+
24
+ function log(line) {
25
+ process.stdout.write(line + '\n')
26
+ }
27
+ function err(line) {
28
+ process.stderr.write(line + '\n')
29
+ }
30
+
31
+ async function pickTemplate() {
32
+ const entries = listCatalog()
33
+ if (entries.length === 0) {
34
+ throw new TemplateError(
35
+ `catalog is empty — no templates available for scaffolding\n` +
36
+ `hint: edit foundations.yml to add a template entry`
37
+ )
38
+ }
39
+ const response = await prompts({
40
+ type: 'select',
41
+ name: 'id',
42
+ message: 'Pick a template',
43
+ choices: entries.map((e) => ({
44
+ title: e.name || e.id,
45
+ description: e.description ? String(e.description).trim().replace(/\s+/g, ' ') : undefined,
46
+ value: e.id,
47
+ })),
48
+ })
49
+ if (!response.id) {
50
+ throw new TemplateError('no template selected — aborting')
51
+ }
52
+ return response.id
53
+ }
54
+
55
+ async function collectVars({ flagTitle, flagAuthor, interactive }) {
56
+ const now = new Date()
57
+ const defaults = {
58
+ title: flagTitle || 'Untitled document',
59
+ author: flagAuthor || '',
60
+ year: String(now.getFullYear()),
61
+ date: now.toISOString().slice(0, 10),
62
+ }
63
+ if (!interactive) return defaults
64
+ const answers = await prompts([
65
+ { type: flagTitle ? null : 'text', name: 'title', message: 'Title', initial: defaults.title },
66
+ { type: flagAuthor ? null : 'text', name: 'author', message: 'Author', initial: defaults.author },
67
+ ])
68
+ return {
69
+ title: flagTitle || answers.title || defaults.title,
70
+ author: flagAuthor || answers.author || defaults.author,
71
+ year: defaults.year,
72
+ date: defaults.date,
73
+ }
74
+ }
75
+
76
+ export async function createCommand({ dir, template, title, author, force = false, yes = false }) {
77
+ if (!dir) {
78
+ throw new TemplateError(
79
+ `usage: unipress create <dir> [--template <id>] [--title <t>] [--author <a>]`
80
+ )
81
+ }
82
+ const targetDir = resolve(dir)
83
+
84
+ let templateId = template
85
+ if (!templateId) {
86
+ if (yes) {
87
+ throw new TemplateError(
88
+ `--yes requires --template (no interactive selection when --yes is set)`
89
+ )
90
+ }
91
+ templateId = await pickTemplate()
92
+ }
93
+
94
+ const entry = findCatalogEntry(templateId)
95
+ if (!entry) {
96
+ throw new TemplateError(
97
+ `template '${templateId}' is not in the catalog\n` +
98
+ `hint: run 'unipress list-templates' to see available entries`
99
+ )
100
+ }
101
+
102
+ const scaffoldName = entry.scaffold
103
+ if (!scaffoldName) {
104
+ throw new TemplateError(
105
+ `catalog entry '${templateId}' has no scaffold declared\n` +
106
+ `hint: add 'scaffold: <name>' to the entry in foundations.yml`
107
+ )
108
+ }
109
+
110
+ const vars = await collectVars({ flagTitle: title, flagAuthor: author, interactive: !yes })
111
+ vars.foundation = templateId
112
+
113
+ log(`scaffolding ${targetDir}`)
114
+ log(` template: ${entry.name || entry.id} (id: ${entry.id})`)
115
+ log(` scaffold: ${scaffoldName}`)
116
+
117
+ await scaffold({
118
+ templateName: scaffoldName,
119
+ targetDir,
120
+ vars,
121
+ force,
122
+ onProgress: (msg) => log(` ${msg}`),
123
+ })
124
+
125
+ log('')
126
+ log(`✓ ready`)
127
+ log(` next: cd ${dir}`)
128
+ const formats = Array.isArray(entry.outputs) && entry.outputs.length > 0
129
+ ? entry.outputs.join(' | ')
130
+ : 'pdf'
131
+ log(` unipress compile --format <${formats}>`)
132
+ log('')
133
+ const foundationUrl = entry.foundation?.source?.url ?? entry.source?.url
134
+ const foundationRef = entry.foundation?.ref
135
+ if (foundationRef) {
136
+ log(`note: the document.yml pins ${foundationRef}`)
137
+ }
138
+ if (foundationUrl) {
139
+ log(` it will be fetched from ${foundationUrl}`)
140
+ log(` and cached on the first compile.`)
141
+ }
142
+ }
143
+
144
+ export async function listTemplatesCommand() {
145
+ const entries = listCatalog()
146
+ if (entries.length === 0) {
147
+ err('catalog is empty')
148
+ return
149
+ }
150
+ for (const e of entries) {
151
+ log(`${e.id}`)
152
+ if (e.name) log(` ${e.name}`)
153
+ if (e.description) {
154
+ const desc = String(e.description).trim().replace(/\s+/g, ' ')
155
+ log(` ${desc}`)
156
+ }
157
+ if (Array.isArray(e.outputs) && e.outputs.length > 0) {
158
+ log(` outputs: ${e.outputs.join(', ')}`)
159
+ }
160
+ if (e.foundation?.ref) log(` pins: ${e.foundation.ref}`)
161
+ const url = e.foundation?.source?.url ?? e.source?.url
162
+ if (url) log(` source: ${url}`)
163
+ log('')
164
+ }
165
+ }
@@ -0,0 +1,182 @@
1
+ // `unipress inspect <dir>` — load a content directory and dump the resolved
2
+ // object as JSON. Used as a debugging aid and as the visible output of M2.
3
+ //
4
+ // Filtering knobs:
5
+ // --full include the web-only fields stripped by default
6
+ // (assets, icons, hasExplicitPoster/Preview)
7
+ // --summary replace pages[] with route strings only
8
+ // --page <route> keep only the page matching <route>; errors if no match
9
+ // --depth <n> truncate nested values beyond depth n
10
+ // --foundation <ref> override document.yml's foundation: field
11
+ //
12
+ // As of M3a, inspect also resolves the foundation reference and reports
13
+ // the resolved path in __unipress.foundation. It does NOT import the
14
+ // foundation; M3b does that.
15
+
16
+ import { loadContent } from '../content-loader.js'
17
+ import { resolveFoundation } from '../foundation-loader.js'
18
+ import { loadAndInit } from '../orchestrator.js'
19
+
20
+ const SKIP_KEYS = new Set([
21
+ 'assets',
22
+ 'hasExplicitPoster',
23
+ 'hasExplicitPreview',
24
+ 'icons'
25
+ ])
26
+
27
+ const TRUNCATED = '__truncated__'
28
+
29
+ export async function inspect({ dir, full = false, summary = false, page = null, depth = null, foundation: foundationCliRef = null, orchestrate = true } = {}) {
30
+ if (!dir) {
31
+ process.stderr.write('error: `inspect` requires a directory argument\n')
32
+ process.stderr.write('usage: unipress inspect <dir> [--full] [--summary] [--page <route>] [--depth <n>] [--foundation <ref>] [--no-orchestrate]\n')
33
+ process.exit(1)
34
+ }
35
+
36
+ if (depth !== null && (!Number.isInteger(depth) || depth < 0)) {
37
+ process.stderr.write(`error: --depth must be a non-negative integer (got ${depth})\n`)
38
+ process.exit(1)
39
+ }
40
+
41
+ const { content, configFile, sitePath } = await loadContent(dir)
42
+
43
+ // Resolve foundation reference. Failure to resolve is reported in
44
+ // __unipress.foundation as { ref, error } so the rest of inspect still
45
+ // works — it's a debugging tool, the user wants to see what loaded even
46
+ // if one piece broke.
47
+ let foundationInfo
48
+ try {
49
+ foundationInfo = await resolveFoundation({
50
+ cliRef: foundationCliRef,
51
+ configRef: content.config?.foundation,
52
+ anchorDir: sitePath
53
+ })
54
+ } catch (err) {
55
+ foundationInfo = { ref: foundationCliRef ?? content.config?.foundation ?? null, error: err.message }
56
+ }
57
+
58
+ // Orchestrate (M3b): import the foundation and run initPrerender to
59
+ // populate the Website graph. Surface a summary, not the raw graph
60
+ // (which has back-references and isn't JSON-friendly). Orchestration
61
+ // failures are non-fatal for the same reason resolution failures are.
62
+ let websiteInfo = null
63
+ if (orchestrate && foundationInfo.resolvedPath) {
64
+ try {
65
+ const { uniweb } = await loadAndInit({
66
+ content,
67
+ resolvedPath: foundationInfo.resolvedPath
68
+ })
69
+ websiteInfo = summarizeWebsite(uniweb, { route: page })
70
+ } catch (err) {
71
+ websiteInfo = { error: err.message }
72
+ }
73
+ }
74
+
75
+ let view = full ? { ...content } : trimSkipKeys(content)
76
+
77
+ if (page) {
78
+ view = filterToPage(view, page)
79
+ } else if (summary) {
80
+ view = summarizePages(view)
81
+ }
82
+
83
+ view.__unipress = { sitePath, configFile, foundation: foundationInfo, website: websiteInfo }
84
+
85
+ const output = depth !== null ? truncate(view, depth) : view
86
+ process.stdout.write(JSON.stringify(output, replacer, 2) + '\n')
87
+ }
88
+
89
+ // Summarize the populated Website graph for inspect output. The full
90
+ // graph has cycles (page.website ↔ website.pages, block.page ↔ page)
91
+ // and won't survive JSON.stringify; this returns a tree with no
92
+ // back-refs and the per-block info that matters for "did the graph
93
+ // build correctly" debugging.
94
+ //
95
+ // Page exposes blocks via `bodyBlocks` (and `getPageBlocks()` which
96
+ // merges header/body/footer from the layout). We report bodyBlocks
97
+ // here — header/footer come from layout areas, not page content.
98
+ function summarizeWebsite(uniweb, { route = null } = {}) {
99
+ const w = uniweb?.activeWebsite
100
+ if (!w) return { error: 'uniweb.activeWebsite is null' }
101
+
102
+ const allPages = w.pages ?? []
103
+ const pages = (route ? allPages.filter(p => p.route === route) : allPages).map(p => {
104
+ const blocks = p.bodyBlocks ?? []
105
+ return {
106
+ route: p.route,
107
+ blockCount: blocks.length,
108
+ blocks: blocks.map(b => ({
109
+ type: b.type ?? null,
110
+ hasContent: b.parsedContent != null,
111
+ childBlockCount: (b.childBlocks ?? []).length,
112
+ insetCount: (b.insets ?? []).length
113
+ }))
114
+ }
115
+ })
116
+
117
+ return {
118
+ pageCount: allPages.length,
119
+ activePage: w.activePage?.route ?? null,
120
+ basePath: w.basePath ?? '/',
121
+ pages
122
+ }
123
+ }
124
+
125
+ function trimSkipKeys(content) {
126
+ const out = {}
127
+ for (const [key, value] of Object.entries(content)) {
128
+ if (SKIP_KEYS.has(key)) continue
129
+ out[key] = value
130
+ }
131
+ return out
132
+ }
133
+
134
+ function filterToPage(view, route) {
135
+ if (!Array.isArray(view.pages)) {
136
+ process.stderr.write(`error: content has no pages[] to filter\n`)
137
+ process.exit(1)
138
+ }
139
+ const match = view.pages.find(p => p.route === route)
140
+ if (!match) {
141
+ const available = view.pages.map(p => p.route).join(', ') || '(none)'
142
+ process.stderr.write(`error: no page with route '${route}'\n`)
143
+ process.stderr.write(`available routes: ${available}\n`)
144
+ process.exit(1)
145
+ }
146
+ return { ...view, pages: [match] }
147
+ }
148
+
149
+ function summarizePages(view) {
150
+ if (!Array.isArray(view.pages)) return view
151
+ return {
152
+ ...view,
153
+ pages: view.pages.map(p => p.route)
154
+ }
155
+ }
156
+
157
+ // Walk the value, replacing anything beyond `maxDepth` with TRUNCATED.
158
+ // Depth counts from the root: depth 0 truncates everything inside the root.
159
+ // Arrays preserve length so the user can still see "this had 12 items."
160
+ function truncate(value, maxDepth, currentDepth = 0) {
161
+ if (value === null || typeof value !== 'object') return value
162
+ if (currentDepth >= maxDepth) {
163
+ if (Array.isArray(value)) return value.length === 0 ? [] : [TRUNCATED, `(${value.length} items)`]
164
+ return TRUNCATED
165
+ }
166
+ if (Array.isArray(value)) {
167
+ return value.map(item => truncate(item, maxDepth, currentDepth + 1))
168
+ }
169
+ const out = {}
170
+ for (const [key, child] of Object.entries(value)) {
171
+ out[key] = truncate(child, maxDepth, currentDepth + 1)
172
+ }
173
+ return out
174
+ }
175
+
176
+ // JSON.stringify can't serialize Sets / Maps. The content object has a few
177
+ // of these; convert them to plain values so output is readable.
178
+ function replacer(_key, value) {
179
+ if (value instanceof Set) return [...value]
180
+ if (value instanceof Map) return Object.fromEntries(value)
181
+ return value
182
+ }