@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/src/compile.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Programmatic entry point for `unipress compile`.
|
|
2
|
+
//
|
|
3
|
+
// Two-step pipeline after content + foundation setup:
|
|
4
|
+
//
|
|
5
|
+
// 1. Load content, resolve the foundation, run initPrerender to
|
|
6
|
+
// populate the Website graph. (loadContent + resolveFoundation +
|
|
7
|
+
// loadAndInit — unchanged from M4.)
|
|
8
|
+
// 2. Call foundation.compileDocument(website, { format, foundation,
|
|
9
|
+
// ...hostHints }) — the foundation looks up outputs[format],
|
|
10
|
+
// assembles adapter options, gathers blocks, and routes through
|
|
11
|
+
// the right Press adapter. Returns a Blob.
|
|
12
|
+
// 3. Sink the Blob. Pdf output runs the typst binary on the source
|
|
13
|
+
// bundle; everything else writes bytes directly.
|
|
14
|
+
//
|
|
15
|
+
// Block gathering, tree building, and adapter-options assembly used to
|
|
16
|
+
// live here (M4–M8a). As of M8a they live inside compileDocument on the
|
|
17
|
+
// Press side, keyed off the foundation's `outputs:` declaration. This
|
|
18
|
+
// module used to maintain PRESS_FORMAT_BY_OUTPUT and EXT_BY_FORMAT
|
|
19
|
+
// tables for the `pdf` → `typst` aliasing and the default filename
|
|
20
|
+
// extension — both are gone. Foundations declare their own aliasing via
|
|
21
|
+
// `outputs[format].via` and default extensions via
|
|
22
|
+
// `outputs[format].extension`.
|
|
23
|
+
//
|
|
24
|
+
// Progress is fanned out through a single `onProgress` callback so
|
|
25
|
+
// verbose output is consistent across the pipeline (load, resolve,
|
|
26
|
+
// init, compile, sink).
|
|
27
|
+
|
|
28
|
+
import { basename, resolve } from 'node:path'
|
|
29
|
+
import { loadContent } from './content-loader.js'
|
|
30
|
+
import { resolveFoundation } from './foundation-loader.js'
|
|
31
|
+
import { loadAndInit, compileDocumentWithFoundation, getFoundationOutputs } from './orchestrator.js'
|
|
32
|
+
import { loadUnipressConfig } from './config.js'
|
|
33
|
+
import { writeBlobToFile } from './sinks/blob.js'
|
|
34
|
+
import { writePdfViaTypst } from './sinks/typst.js'
|
|
35
|
+
import { DocumentYmlError, CompileError } from './errors.js'
|
|
36
|
+
|
|
37
|
+
// Sink selection. `pdf` is special-cased because the Press output (via
|
|
38
|
+
// the typst adapter) is a source-bundle zip, not a PDF — unipress
|
|
39
|
+
// finishes the job by running the typst binary. Every other format is a
|
|
40
|
+
// direct byte write.
|
|
41
|
+
function pickSink(format) {
|
|
42
|
+
if (format === 'pdf') return 'typst'
|
|
43
|
+
return 'blob'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function compile({
|
|
47
|
+
dir,
|
|
48
|
+
format: cliFormat = null,
|
|
49
|
+
foundationRef: cliFoundationRef = null,
|
|
50
|
+
outPath: cliOutPath = null,
|
|
51
|
+
typstBinaryPath: cliTypstBinaryPath = null,
|
|
52
|
+
keepTemp = false,
|
|
53
|
+
configPath: cliConfigPath = null,
|
|
54
|
+
onProgress = () => {}
|
|
55
|
+
} = {}) {
|
|
56
|
+
onProgress('loading content...')
|
|
57
|
+
const { content, sitePath, configFile } = await loadContent(dir)
|
|
58
|
+
onProgress(` ${content.pages.length} page(s) from ${sitePath} (${configFile})`)
|
|
59
|
+
|
|
60
|
+
// Load unipress.config.js — from --config, then <dir>/unipress.config.js,
|
|
61
|
+
// else empty. Relative paths inside the config are resolved against the
|
|
62
|
+
// config file's own directory (see src/config.js).
|
|
63
|
+
const { config, configPath } = await loadUnipressConfig({
|
|
64
|
+
contentDir: sitePath,
|
|
65
|
+
explicitPath: cliConfigPath
|
|
66
|
+
})
|
|
67
|
+
if (configPath) onProgress(` config: ${configPath}`)
|
|
68
|
+
|
|
69
|
+
// Precedence chain: CLI > unipress.config.js > document.yml > defaults.
|
|
70
|
+
const format = cliFormat ?? config.format ?? content.config?.format ?? null
|
|
71
|
+
if (!format) {
|
|
72
|
+
const hints = ['pass --format <fmt>']
|
|
73
|
+
if (configPath) hints.push(`set format: in ${configPath}`)
|
|
74
|
+
hints.push(`set format: in ${configFile}`)
|
|
75
|
+
throw new DocumentYmlError(
|
|
76
|
+
`no format specified — ${hints.join(' or ')}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const foundationRef = cliFoundationRef ?? config.foundation ?? null
|
|
81
|
+
const typstBinaryPath = cliTypstBinaryPath ?? config.typst?.binary ?? null
|
|
82
|
+
const typstVersion = config.typst?.version ?? null
|
|
83
|
+
|
|
84
|
+
onProgress('resolving foundation...')
|
|
85
|
+
const foundationInfo = await resolveFoundation({
|
|
86
|
+
// Already merged CLI + config.foundation above; document.yml foundation
|
|
87
|
+
// is the fallback.
|
|
88
|
+
cliRef: foundationRef,
|
|
89
|
+
configRef: content.config?.foundation,
|
|
90
|
+
anchorDir: sitePath
|
|
91
|
+
})
|
|
92
|
+
onProgress(` ${foundationInfo.ref} → ${foundationInfo.resolvedPath}`)
|
|
93
|
+
|
|
94
|
+
onProgress('initializing foundation...')
|
|
95
|
+
const { foundation, uniweb } = await loadAndInit({
|
|
96
|
+
content,
|
|
97
|
+
resolvedPath: foundationInfo.resolvedPath,
|
|
98
|
+
onProgress: (msg) => onProgress(` ${msg}`)
|
|
99
|
+
})
|
|
100
|
+
const website = uniweb.activeWebsite
|
|
101
|
+
|
|
102
|
+
// Validate the format early so users get a helpful "declared outputs:
|
|
103
|
+
// …" message before anything downstream fails. Press's compileDocument
|
|
104
|
+
// would throw an equivalent error — we front-load it so the error
|
|
105
|
+
// class is the one unipress uses for CLI output.
|
|
106
|
+
const outputs = getFoundationOutputs(foundation)
|
|
107
|
+
if (!outputs) {
|
|
108
|
+
throw new CompileError(
|
|
109
|
+
`foundation declares no outputs — cannot compile.\n` +
|
|
110
|
+
`hint: foundation's default export must include an outputs: { … } map. ` +
|
|
111
|
+
`See framework/docs/reference/foundation-config.md#document-outputs.`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
const outputSpec = outputs[format]
|
|
115
|
+
if (!outputSpec) {
|
|
116
|
+
const declared = Object.keys(outputs).join(', ') || '(none)'
|
|
117
|
+
throw new CompileError(
|
|
118
|
+
`foundation does not declare 'outputs.${format}' — cannot compile.\n` +
|
|
119
|
+
`available formats: ${declared}`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Default output filename: `./<dir-basename>.<ext>`. The foundation
|
|
124
|
+
// declares the extension; fall back to the format name if it didn't.
|
|
125
|
+
// Precedence: CLI --out > config.out > default.
|
|
126
|
+
const ext = outputSpec.extension ?? format
|
|
127
|
+
const outPath = cliOutPath ?? config.out ?? null
|
|
128
|
+
const finalOutPath = outPath
|
|
129
|
+
? resolve(outPath)
|
|
130
|
+
: resolve(`./${basename(sitePath)}.${ext}`)
|
|
131
|
+
|
|
132
|
+
// Host hints for the foundation's getOptions. For pdf, pass
|
|
133
|
+
// mode: 'sources' so the foundation returns a typst source bundle the
|
|
134
|
+
// typst sink can then compile locally. Other formats receive no
|
|
135
|
+
// extra hints for now — foundations decide their own defaults.
|
|
136
|
+
const hostHints = format === 'pdf' ? { mode: 'sources' } : {}
|
|
137
|
+
|
|
138
|
+
onProgress(`compiling to ${format}${outputSpec.via ? ` (via ${outputSpec.via})` : ''}...`)
|
|
139
|
+
const blob = await compileDocumentWithFoundation(foundation, website, {
|
|
140
|
+
format,
|
|
141
|
+
...hostHints
|
|
142
|
+
})
|
|
143
|
+
onProgress(` blob: ${blob.size} bytes, type=${blob.type || '(none)'}`)
|
|
144
|
+
|
|
145
|
+
onProgress(`writing ${finalOutPath}...`)
|
|
146
|
+
const sink = pickSink(format)
|
|
147
|
+
const result = sink === 'typst'
|
|
148
|
+
? await writePdfViaTypst(blob, finalOutPath, {
|
|
149
|
+
typstBinaryPath,
|
|
150
|
+
typstVersion,
|
|
151
|
+
keepTemp,
|
|
152
|
+
onProgress: (msg) => onProgress(` ${msg}`)
|
|
153
|
+
})
|
|
154
|
+
: await writeBlobToFile(blob, finalOutPath)
|
|
155
|
+
onProgress(` wrote ${result.bytes} bytes`)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
outPath: result.outPath,
|
|
159
|
+
bytes: result.bytes,
|
|
160
|
+
format,
|
|
161
|
+
pressFormat: outputSpec.via ?? format,
|
|
162
|
+
pageCount: website.pages.length,
|
|
163
|
+
foundation: foundationInfo
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Load an optional `unipress.config.js` from the content directory (or
|
|
2
|
+
// an explicit --config path). The config is an ordinary ES module that
|
|
3
|
+
// default-exports a settings object — typically written with the
|
|
4
|
+
// `defineUnipressConfig` identity helper for editor autocomplete:
|
|
5
|
+
//
|
|
6
|
+
// // unipress.config.js
|
|
7
|
+
// import { defineUnipressConfig } from '@uniweb/unipress'
|
|
8
|
+
// export default defineUnipressConfig({
|
|
9
|
+
// out: './dist/book.pdf',
|
|
10
|
+
// typst: { version: '0.14.2' },
|
|
11
|
+
// })
|
|
12
|
+
//
|
|
13
|
+
// The precedence chain (plan §9.2):
|
|
14
|
+
//
|
|
15
|
+
// CLI flags > unipress.config.js > document.yml > foundation defaults
|
|
16
|
+
//
|
|
17
|
+
// This module only loads + validates the config file. Merging into the
|
|
18
|
+
// compile pipeline happens in src/compile.js, which owns the precedence
|
|
19
|
+
// semantics for each field.
|
|
20
|
+
//
|
|
21
|
+
// Path resolution convention (matches Vite / Astro): relative paths in
|
|
22
|
+
// the config object — `out`, `foundation`, `typst.binary` — are
|
|
23
|
+
// resolved against the directory containing the config file, not the
|
|
24
|
+
// cwd or the content directory. Absolute paths are used as-is.
|
|
25
|
+
|
|
26
|
+
import { existsSync, statSync } from 'node:fs'
|
|
27
|
+
import { resolve, dirname, isAbsolute } from 'node:path'
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
29
|
+
import { ConfigValidationError } from './errors.js'
|
|
30
|
+
|
|
31
|
+
const DEFAULT_CONFIG_NAME = 'unipress.config.js'
|
|
32
|
+
const PATHY_FIELDS = ['out', 'foundation']
|
|
33
|
+
const PATHY_NESTED_FIELDS = [['typst', 'binary']]
|
|
34
|
+
|
|
35
|
+
export async function loadUnipressConfig({ contentDir, explicitPath = null } = {}) {
|
|
36
|
+
const configPath = pickConfigPath({ contentDir, explicitPath })
|
|
37
|
+
if (!configPath) {
|
|
38
|
+
return { config: {}, configPath: null, source: 'default' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let mod
|
|
42
|
+
try {
|
|
43
|
+
mod = await import(pathToFileURL(configPath).href)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new ConfigValidationError(
|
|
46
|
+
`failed to load config file ${configPath}\n` +
|
|
47
|
+
`cause: ${err.message}\n` +
|
|
48
|
+
`hint: syntax errors, missing dependencies, and thrown errors surface here`
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const raw = mod.default ?? mod.config
|
|
53
|
+
if (raw == null) {
|
|
54
|
+
throw new ConfigValidationError(
|
|
55
|
+
`config file ${configPath} has no default export\n` +
|
|
56
|
+
`hint: export default { ... } (or wrap with defineUnipressConfig)`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
60
|
+
throw new ConfigValidationError(
|
|
61
|
+
`config file ${configPath} must default-export an object (got ${describe(raw)})`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config = normalizePaths(raw, dirname(configPath))
|
|
66
|
+
return { config, configPath, source: explicitPath ? 'flag' : 'autodetect' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pick between an explicit --config path and auto-discovery in the
|
|
70
|
+
// content directory. Explicit paths must exist; auto-discovery skips
|
|
71
|
+
// silently when no config file is present.
|
|
72
|
+
function pickConfigPath({ contentDir, explicitPath }) {
|
|
73
|
+
if (explicitPath) {
|
|
74
|
+
const abs = isAbsolute(explicitPath)
|
|
75
|
+
? explicitPath
|
|
76
|
+
: resolve(process.cwd(), explicitPath)
|
|
77
|
+
if (!existsSync(abs)) {
|
|
78
|
+
throw new ConfigValidationError(
|
|
79
|
+
`--config path does not exist: ${abs}`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
if (!statSync(abs).isFile()) {
|
|
83
|
+
throw new ConfigValidationError(
|
|
84
|
+
`--config path is not a file: ${abs}`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
return abs
|
|
88
|
+
}
|
|
89
|
+
if (!contentDir) return null
|
|
90
|
+
const candidate = resolve(contentDir, DEFAULT_CONFIG_NAME)
|
|
91
|
+
return existsSync(candidate) ? candidate : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve relative paths declared in the config against the config
|
|
95
|
+
// file's own directory. The fields treated as paths are listed
|
|
96
|
+
// explicitly so this doesn't catch arbitrary user strings.
|
|
97
|
+
function normalizePaths(config, configDir) {
|
|
98
|
+
const out = { ...config }
|
|
99
|
+
for (const key of PATHY_FIELDS) {
|
|
100
|
+
if (typeof out[key] === 'string' && !isAbsolute(out[key]) && !looksUrlOrPackage(out[key])) {
|
|
101
|
+
out[key] = resolve(configDir, out[key])
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const [key, subkey] of PATHY_NESTED_FIELDS) {
|
|
105
|
+
const sub = out[key]
|
|
106
|
+
if (sub && typeof sub === 'object' && typeof sub[subkey] === 'string' && !isAbsolute(sub[subkey])) {
|
|
107
|
+
out[key] = { ...sub, [subkey]: resolve(configDir, sub[subkey]) }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A value like 'my-foundation' or '@scope/foo' is a package name; a
|
|
114
|
+
// value like 'https://...' is a URL. Don't try to filesystem-resolve
|
|
115
|
+
// those. Everything else with a leading dot / absolute is treated as a
|
|
116
|
+
// path and resolved above.
|
|
117
|
+
function looksUrlOrPackage(value) {
|
|
118
|
+
if (/^https?:\/\//.test(value)) return true
|
|
119
|
+
// A path starts with ./, ../, /, or a drive letter.
|
|
120
|
+
const pathy = /^(\.\.?[/\\]|[/\\]|[A-Za-z]:[/\\])/
|
|
121
|
+
return !pathy.test(value)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function describe(value) {
|
|
125
|
+
if (value === null) return 'null'
|
|
126
|
+
if (Array.isArray(value)) return 'array'
|
|
127
|
+
return typeof value
|
|
128
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Load a unipress content directory into a Uniweb content object.
|
|
2
|
+
//
|
|
3
|
+
// Wraps `collectSiteContent`, but tells it which top-level config file to
|
|
4
|
+
// read (document.yml or site.yml).
|
|
5
|
+
//
|
|
6
|
+
// Imports from `@uniweb/build/content` — the sharp/Vite/React-free entry
|
|
7
|
+
// designed for Bun-compiled binaries. `@uniweb/build/site` pulls in the
|
|
8
|
+
// image asset-processor (which eagerly loads sharp's native binding and
|
|
9
|
+
// blows up in a `bun build --compile` binary).
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { resolve, join } from 'node:path'
|
|
13
|
+
import { collectSiteContent } from '@uniweb/build/content'
|
|
14
|
+
import { detectConfigFile, CONFIG_FILE_NAMES } from './document-yml.js'
|
|
15
|
+
import { ContentDirectoryError, DocumentYmlError } from './errors.js'
|
|
16
|
+
|
|
17
|
+
export async function loadContent(dir, options = {}) {
|
|
18
|
+
const sitePath = resolve(dir)
|
|
19
|
+
|
|
20
|
+
if (!existsSync(sitePath)) {
|
|
21
|
+
throw new ContentDirectoryError(`content directory does not exist: ${sitePath}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const configFile = detectConfigFile(sitePath)
|
|
25
|
+
if (!configFile) {
|
|
26
|
+
throw new DocumentYmlError(
|
|
27
|
+
`no ${CONFIG_FILE_NAMES.PRIMARY} (or ${CONFIG_FILE_NAMES.FALLBACK}) found in ${sitePath}`
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let content
|
|
32
|
+
try {
|
|
33
|
+
content = await collectSiteContent(sitePath, {
|
|
34
|
+
configFile,
|
|
35
|
+
foundationPath: options.foundationPath
|
|
36
|
+
})
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// js-yaml throws YAMLException with .mark.line/.column. Wrap it in
|
|
39
|
+
// DocumentYmlError so the CLI reports it with a location hint
|
|
40
|
+
// instead of a bare stack trace.
|
|
41
|
+
if (err?.name === 'YAMLException') {
|
|
42
|
+
const configPath = join(sitePath, configFile)
|
|
43
|
+
const line = err?.mark?.line != null ? err.mark.line + 1 : null
|
|
44
|
+
const col = err?.mark?.column != null ? err.mark.column + 1 : null
|
|
45
|
+
const loc = line != null ? `${configPath}:${line}${col != null ? `:${col}` : ''}` : configPath
|
|
46
|
+
throw new DocumentYmlError(
|
|
47
|
+
`malformed YAML in ${configFile}\n` +
|
|
48
|
+
`at ${loc}\n` +
|
|
49
|
+
`cause: ${err.reason || err.message}`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { content, configFile, sitePath }
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Detect which top-level config file a content directory uses.
|
|
2
|
+
//
|
|
3
|
+
// unipress projects use `document.yml`. To smooth migration and to support
|
|
4
|
+
// dogfooding inside uniweb workspaces, `site.yml` is accepted as a fallback
|
|
5
|
+
// and gets the same treatment — `collectSiteContent` reads whichever name
|
|
6
|
+
// we pass via its `configFile` option.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const PRIMARY = 'document.yml'
|
|
12
|
+
const FALLBACK = 'site.yml'
|
|
13
|
+
|
|
14
|
+
export function detectConfigFile(sitePath) {
|
|
15
|
+
if (existsSync(join(sitePath, PRIMARY))) return PRIMARY
|
|
16
|
+
if (existsSync(join(sitePath, FALLBACK))) return FALLBACK
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CONFIG_FILE_NAMES = { PRIMARY, FALLBACK }
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Named error classes used across unipress commands.
|
|
2
|
+
//
|
|
3
|
+
// Per the brief (Section 14), every error names what was attempted, what
|
|
4
|
+
// failed, and how to recover. The CLI's top-level handler converts these
|
|
5
|
+
// into `error: ...` lines (and stack traces under --verbose).
|
|
6
|
+
//
|
|
7
|
+
// Error shape:
|
|
8
|
+
// new <Class>(summary)
|
|
9
|
+
// new <Class>(summary + '\nhint: ...\ncause: ...')
|
|
10
|
+
//
|
|
11
|
+
// Convention: a single summary line, optionally followed by lines prefixed
|
|
12
|
+
// with `hint:` or `cause:`. `format()` turns that into
|
|
13
|
+
//
|
|
14
|
+
// error: <summary>
|
|
15
|
+
// hint: <...>
|
|
16
|
+
// cause: <...>
|
|
17
|
+
//
|
|
18
|
+
// Keeping the structured data as part of the string (rather than separate
|
|
19
|
+
// fields) lets call sites stay one-argument and still get consistent
|
|
20
|
+
// output.
|
|
21
|
+
|
|
22
|
+
// Known "structured" prefixes inside a multi-line message. When format()
|
|
23
|
+
// sees these at the start of a continuation line, it preserves them;
|
|
24
|
+
// everything else on continuation lines is indented as-is (for the
|
|
25
|
+
// `at file:line` form and arbitrary continuation context).
|
|
26
|
+
const STRUCTURED_PREFIXES = /^(hint|cause|at)\s*:/
|
|
27
|
+
|
|
28
|
+
export class UnipressError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = this.constructor.name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Formatted multi-line rendering. The top-level CLI handler calls this
|
|
35
|
+
// for every UnipressError so output stays consistent regardless of
|
|
36
|
+
// which pipeline stage threw.
|
|
37
|
+
format() {
|
|
38
|
+
const [first, ...rest] = (this.message ?? '').split('\n')
|
|
39
|
+
const out = [`error: ${first}`]
|
|
40
|
+
for (const line of rest) {
|
|
41
|
+
if (!line) continue
|
|
42
|
+
// Normalize prefixed continuation lines (hint/cause/at) with a
|
|
43
|
+
// 4-space indent, a 6-character prefix column, and the remainder.
|
|
44
|
+
// Example: 'hint: rebuild the foundation' -> ' hint: rebuild the foundation'.
|
|
45
|
+
// Non-prefixed lines get the same 4-space indent (they're
|
|
46
|
+
// continuation context).
|
|
47
|
+
out.push(STRUCTURED_PREFIXES.test(line) ? ` ${line}` : ` ${line}`)
|
|
48
|
+
}
|
|
49
|
+
return out.join('\n')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Pipeline stages (present in code today) ---------------------------
|
|
54
|
+
|
|
55
|
+
export class ContentDirectoryError extends UnipressError {}
|
|
56
|
+
export class DocumentYmlError extends UnipressError {}
|
|
57
|
+
export class ConfigValidationError extends UnipressError {}
|
|
58
|
+
export class FoundationResolutionError extends UnipressError {}
|
|
59
|
+
export class CompileError extends UnipressError {}
|
|
60
|
+
export class OutputWriteError extends UnipressError {}
|
|
61
|
+
export class TypstBinaryError extends UnipressError {}
|
|
62
|
+
|
|
63
|
+
// --- Pipeline stages (catalog-complete; call sites land with their milestone) ---
|
|
64
|
+
|
|
65
|
+
// M9-URL will use this for registry fetches.
|
|
66
|
+
export class FoundationFetchError extends UnipressError {}
|
|
67
|
+
|
|
68
|
+
// Reserved for markdown / frontmatter / collections-JSON parse failures.
|
|
69
|
+
// Today, js-yaml's YAMLException + ENOENT surface through DocumentYmlError
|
|
70
|
+
// (content-loader.js catches them); ContentParseError is the dedicated
|
|
71
|
+
// class when per-file parse errors start needing distinct exit codes or
|
|
72
|
+
// handling (e.g. "build succeeded with 1 page skipped — parse error in
|
|
73
|
+
// pages/foo.md").
|
|
74
|
+
export class ContentParseError extends UnipressError {}
|
|
75
|
+
|
|
76
|
+
// M9 (unipress create) uses this for missing / malformed template
|
|
77
|
+
// packages.
|
|
78
|
+
export class TemplateError extends UnipressError {}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// URL-based foundation resolution: fetch + cache a built foundation's
|
|
2
|
+
// full dist tree so Node can import it.
|
|
3
|
+
//
|
|
4
|
+
// Cache layout:
|
|
5
|
+
// <unipress cache>/foundations/<host>/<pathish>/foundation.js
|
|
6
|
+
// <unipress cache>/foundations/<host>/<pathish>/<chunk>.js
|
|
7
|
+
// ... etc
|
|
8
|
+
//
|
|
9
|
+
// where <pathish> is the URL's path with leading slashes stripped and
|
|
10
|
+
// the trailing /foundation.js stripped — the same URL always maps to
|
|
11
|
+
// the same cache path.
|
|
12
|
+
//
|
|
13
|
+
// A built foundation is a small entry (`foundation.js`) plus a set of
|
|
14
|
+
// sibling chunks referenced by relative imports (and further chunks
|
|
15
|
+
// dynamically imported from those). `fetchFoundationToCache` walks the
|
|
16
|
+
// import graph: it downloads foundation.js, scans for `./*.js` imports
|
|
17
|
+
// (static and dynamic), fetches each, and recurses. That's enough to
|
|
18
|
+
// cover @uniweb/build's output — all sibling chunks live next to
|
|
19
|
+
// foundation.js. Imports NOT starting with './' are bare specifiers
|
|
20
|
+
// (external like 'react') or absolute URLs and are not part of the
|
|
21
|
+
// fetch graph.
|
|
22
|
+
//
|
|
23
|
+
// Integrity: the fetch is unverified today. Once the Uniweb registry
|
|
24
|
+
// publishes integrity data, the catalog entry can carry an `integrity:`
|
|
25
|
+
// field and this module will verify bytes before caching.
|
|
26
|
+
|
|
27
|
+
import { existsSync } from 'node:fs'
|
|
28
|
+
import { mkdir, writeFile, symlink } from 'node:fs/promises'
|
|
29
|
+
import { dirname, join, posix, resolve as pathResolve } from 'node:path'
|
|
30
|
+
import { createRequire } from 'node:module'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { FoundationFetchError } from './errors.js'
|
|
33
|
+
import { getCacheDir } from './typst/binary-manager.js'
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url)
|
|
36
|
+
|
|
37
|
+
// Bare specifiers that the foundation expects to resolve externally
|
|
38
|
+
// (matches DEFAULT_EXTERNALS in @uniweb/build). At import time, Node
|
|
39
|
+
// walks up from the cache dir looking for a node_modules/<name>. The
|
|
40
|
+
// cache dir isn't inside any package tree, so we link unipress's own
|
|
41
|
+
// installations into a co-located node_modules.
|
|
42
|
+
const EXTERNAL_PACKAGES = [
|
|
43
|
+
'react',
|
|
44
|
+
'react-dom',
|
|
45
|
+
'react/jsx-runtime',
|
|
46
|
+
'react/jsx-dev-runtime',
|
|
47
|
+
'@uniweb/core',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
// Match `./chunk.js` only in actual ESM import positions — `from './x'`,
|
|
51
|
+
// `import './x'`, or `import('./x')`. A broad "match any quoted ./x.js"
|
|
52
|
+
// picks up Node-core strings embedded in bundled commonjs shims.
|
|
53
|
+
const RELATIVE_JS_IMPORT = /(?:from\s*|\bimport\s*\(?\s*)['"](\.\/[^'"]+\.(?:js|mjs|cjs))['"]/g
|
|
54
|
+
|
|
55
|
+
export function getFoundationCacheDir(url) {
|
|
56
|
+
let parsed
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(url)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new FoundationFetchError(
|
|
61
|
+
`foundation URL is not a valid URL: ${url}\n` +
|
|
62
|
+
`cause: ${err.message}`
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
const host = parsed.host.replace(/:/g, '_')
|
|
66
|
+
let p = parsed.pathname.replace(/^\/+/, '').replace(/\/foundation\.js$/i, '')
|
|
67
|
+
return join(getCacheDir(), 'foundations', host, p)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getFoundationCachePath(url) {
|
|
71
|
+
return join(getFoundationCacheDir(url), 'foundation.js')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchFoundationToCache(url, { onProgress = () => {} } = {}) {
|
|
75
|
+
const cacheDir = getFoundationCacheDir(url)
|
|
76
|
+
const entryPath = join(cacheDir, 'foundation.js')
|
|
77
|
+
if (existsSync(entryPath)) {
|
|
78
|
+
onProgress(`using cached foundation: ${entryPath}`)
|
|
79
|
+
return entryPath
|
|
80
|
+
}
|
|
81
|
+
onProgress(`downloading foundation graph from ${url}`)
|
|
82
|
+
await mkdir(cacheDir, { recursive: true })
|
|
83
|
+
|
|
84
|
+
// Directory URL the individual chunk files live under. Works whether
|
|
85
|
+
// the input URL ends in /foundation.js or at the containing directory.
|
|
86
|
+
const urlObj = new URL(url)
|
|
87
|
+
const pathname = urlObj.pathname.replace(/\/foundation\.js$/i, '/')
|
|
88
|
+
urlObj.pathname = pathname.endsWith('/') ? pathname : pathname + '/'
|
|
89
|
+
const baseUrl = urlObj.toString()
|
|
90
|
+
|
|
91
|
+
const fetched = new Set()
|
|
92
|
+
const queue = ['foundation.js']
|
|
93
|
+
while (queue.length > 0) {
|
|
94
|
+
const rel = queue.shift()
|
|
95
|
+
if (fetched.has(rel)) continue
|
|
96
|
+
fetched.add(rel)
|
|
97
|
+
|
|
98
|
+
const fullUrl = new URL(rel, baseUrl).toString()
|
|
99
|
+
const buf = await fetchOne(fullUrl)
|
|
100
|
+
const localPath = join(cacheDir, rel)
|
|
101
|
+
await mkdir(dirname(localPath), { recursive: true })
|
|
102
|
+
await writeFile(localPath, buf)
|
|
103
|
+
onProgress(` ${buf.length} bytes: ${rel}`)
|
|
104
|
+
|
|
105
|
+
// Scan text for relative imports and enqueue.
|
|
106
|
+
const text = buf.toString('utf8')
|
|
107
|
+
for (const match of text.matchAll(RELATIVE_JS_IMPORT)) {
|
|
108
|
+
const discovered = normaliseRelative(rel, match[1])
|
|
109
|
+
if (!fetched.has(discovered)) queue.push(discovered)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
await linkExternals(cacheDir, onProgress)
|
|
113
|
+
onProgress(`foundation cached at ${cacheDir} (${fetched.size} file(s))`)
|
|
114
|
+
return entryPath
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Make unipress's own copies of the externalized packages reachable
|
|
118
|
+
// from the cache dir. Node's ESM loader walks up from the importing
|
|
119
|
+
// file looking for `node_modules/<name>` — by placing a node_modules
|
|
120
|
+
// directory next to the cached foundation.js with symlinks to each
|
|
121
|
+
// external's package directory, bare imports inside the foundation
|
|
122
|
+
// resolve to unipress's already-installed copies. This keeps a single
|
|
123
|
+
// React instance (unipress's) across host and foundation.
|
|
124
|
+
async function linkExternals(cacheDir, onProgress) {
|
|
125
|
+
const nmDir = join(cacheDir, 'node_modules')
|
|
126
|
+
await mkdir(nmDir, { recursive: true })
|
|
127
|
+
// Collapse subpath specifiers (react/jsx-runtime → react) to the
|
|
128
|
+
// package-root specifier we actually link. Deduped.
|
|
129
|
+
const rootSpecs = new Set()
|
|
130
|
+
for (const spec of EXTERNAL_PACKAGES) {
|
|
131
|
+
rootSpecs.add(spec.startsWith('@')
|
|
132
|
+
? spec.split('/').slice(0, 2).join('/') // '@scope/name'
|
|
133
|
+
: spec.split('/')[0]) // 'name'
|
|
134
|
+
}
|
|
135
|
+
for (const name of rootSpecs) {
|
|
136
|
+
const dest = name.startsWith('@')
|
|
137
|
+
? join(nmDir, ...name.split('/')) // node_modules/@scope/name
|
|
138
|
+
: join(nmDir, name) // node_modules/name
|
|
139
|
+
if (existsSync(dest)) continue
|
|
140
|
+
try {
|
|
141
|
+
const pkgDir = findPackageRoot(name)
|
|
142
|
+
if (!pkgDir) {
|
|
143
|
+
onProgress(` warn: cannot find '${name}' in unipress deps — foundation may fail to import`)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
147
|
+
await symlink(pkgDir, dest, 'dir')
|
|
148
|
+
onProgress(` linked ${name} → ${pkgDir}`)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
onProgress(` warn: linking ${name} failed: ${err.message}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function findPackageRoot(name) {
|
|
156
|
+
try {
|
|
157
|
+
// Resolve the package's `package.json` to find its root.
|
|
158
|
+
const pkgJson = require.resolve(`${name}/package.json`)
|
|
159
|
+
return dirname(pkgJson)
|
|
160
|
+
} catch {
|
|
161
|
+
// Fallback: resolve a default export and strip back.
|
|
162
|
+
try {
|
|
163
|
+
const entry = require.resolve(name)
|
|
164
|
+
let dir = dirname(entry)
|
|
165
|
+
while (dir !== dirname(dir)) {
|
|
166
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
167
|
+
const pkg = JSON.parse(require('fs').readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
168
|
+
if (pkg.name === name || pkg.name === name.split('/')[0]) return dir
|
|
169
|
+
}
|
|
170
|
+
dir = dirname(dir)
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normaliseRelative(fromRel, toRel) {
|
|
178
|
+
// Resolve `toRel` against `fromRel`'s directory using POSIX semantics
|
|
179
|
+
// (URLs and ESM paths are POSIX-style regardless of host OS).
|
|
180
|
+
const fromDir = posix.dirname(fromRel)
|
|
181
|
+
return posix.normalize(posix.join(fromDir, toRel)).replace(/^\.\//, '')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchOne(url) {
|
|
185
|
+
let res
|
|
186
|
+
try {
|
|
187
|
+
res = await fetch(url)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new FoundationFetchError(
|
|
190
|
+
`could not fetch ${url}\n` +
|
|
191
|
+
`cause: ${err.message}\n` +
|
|
192
|
+
`hint: is the registry reachable? (for localhost URLs, is unicloud running?)`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
throw new FoundationFetchError(
|
|
197
|
+
`fetch failed (${res.status} ${res.statusText}): ${url}\n` +
|
|
198
|
+
`hint: check the foundation is published at that version`
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return Buffer.from(await res.arrayBuffer())
|
|
202
|
+
}
|