@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/CHANGELOG.md +137 -0
- package/LICENSE +201 -0
- package/README.md +213 -0
- package/RELEASING.md +101 -0
- package/docs/parity-report.md +153 -0
- package/docs/templates/book.md +55 -0
- package/docs/templates/data-report.md +85 -0
- package/docs/templates/directory.md +73 -0
- package/docs/templates/monograph.md +50 -0
- package/docs/templates/report.md +55 -0
- package/docs/troubleshooting.md +159 -0
- package/package.json +62 -0
- package/src/catalog.js +23 -0
- package/src/cli.js +193 -0
- package/src/commands/compile.js +37 -0
- package/src/commands/create.js +165 -0
- package/src/commands/inspect.js +182 -0
- package/src/compile.js +165 -0
- package/src/config.js +128 -0
- package/src/content-loader.js +56 -0
- package/src/document-yml.js +20 -0
- package/src/errors.js +78 -0
- package/src/foundation-fetch.js +202 -0
- package/src/foundation-loader.js +229 -0
- package/src/foundations-data.js +122 -0
- package/src/index.js +8 -0
- package/src/orchestrator.js +150 -0
- package/src/scaffold.js +66 -0
- package/src/sinks/blob.js +25 -0
- package/src/sinks/typst.js +101 -0
- package/src/templates-data.js +63 -0
- package/src/typst/binary-manager.js +228 -0
- package/src/typst/versions.js +65 -0
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
|
+
}
|