@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/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
+ }