@uniweb/unipress 0.4.27 → 0.4.29

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.
@@ -0,0 +1,152 @@
1
+ # Size and layout
2
+
3
+ The physical shape of your book — its **trim size**, margins, and typography — is
4
+ set in `document.yml` under `book:`. Pick it once; every build uses it. This page
5
+ explains the choices and how to make them.
6
+
7
+ > Full field reference: [`book` template](../templates/book.md). This page is the
8
+ > *why* behind the settings.
9
+
10
+ ## Trim size
11
+
12
+ The trim size is the finished page dimensions after the printer cuts the sheet.
13
+ It's the single most defining physical choice — it sets how your book feels in the
14
+ hand and constrains everything else. Set it with `book.trim`:
15
+
16
+ ```yaml
17
+ book:
18
+ trim: trade-6x9
19
+ ```
20
+
21
+ ### Built-in presets
22
+
23
+ | Preset | Size | Good for |
24
+ |---|---|---|
25
+ | `trade-6x9` *(default)* | 6 × 9 in | The safe default. Most trade fiction and nonfiction. |
26
+ | `trade-7x10` | 7 × 10 in | Technical books, workbooks, anything with code or wide images. |
27
+ | `crown-octavo` | 7.44 × 9.69 in (189 × 246 mm) | Common UK trade hardcover. |
28
+ | `royal-octavo` | 6.14 × 9.21 in (156 × 234 mm) | Academic and scholarly hardcover. |
29
+ | `a5` | 148 × 210 mm | Compact European paperback; pocket guides. |
30
+
31
+ Two more sizes exist for the **article** genre — full-size paper for documents and
32
+ proofing copies rather than bound books:
33
+
34
+ | Preset | Size | Good for |
35
+ |---|---|---|
36
+ | `a4` | 210 × 297 mm | The world's standard paper. A roomy proofing copy. |
37
+ | `letter` | 8.5 × 11 in | US/Canada paper. A roomy proofing copy. |
38
+
39
+ ### A custom size
40
+
41
+ Any trim your printer supports. Give a width and height (and, if you like,
42
+ margins) instead of a preset name:
43
+
44
+ ```yaml
45
+ book:
46
+ trim:
47
+ width: 5in # a common "mass-market"-ish trade size
48
+ height: 8in
49
+ margins: { inside: 0.75in, outside: 0.5in, top: 0.75in, bottom: 0.75in }
50
+ ```
51
+
52
+ You can also start from a preset and nudge one value:
53
+
54
+ ```yaml
55
+ book:
56
+ trim:
57
+ preset: trade-6x9
58
+ margins: { inside: 0.9in } # more gutter for a thick book
59
+ ```
60
+
61
+ **Pick a size your print-on-demand service offers.** Each service lists its
62
+ supported trim sizes; matching one avoids a custom-size surcharge. Check
63
+ [KDP's list](https://kdp.amazon.com/help/topic/G201834180) or
64
+ [IngramSpark's](https://help.ingramspark.com/hc/en-us/articles/360010301911).
65
+
66
+ ## Margins and the gutter
67
+
68
+ A bound book needs an **asymmetric** margin: the **inside** (gutter) edge — where
69
+ the pages meet the spine — must be wider than the **outside**, or text disappears
70
+ into the binding. The presets already do this. The four margins:
71
+
72
+ ```yaml
73
+ book:
74
+ trim:
75
+ preset: trade-6x9
76
+ margins: { inside: 0.75in, outside: 0.5in, top: 0.75in, bottom: 0.75in }
77
+ ```
78
+
79
+ **The more pages, the more gutter you need** — a thick book's spine "steals" more
80
+ of the inside margin. Print-on-demand services publish a minimum inside margin by
81
+ page count; a ~300-page 6×9 usually wants at least 0.75–0.875 in inside. Confirm
82
+ against your service's table before you finalize, and widen `inside` if needed.
83
+
84
+ ## Bleed
85
+
86
+ **Bleed** is art that runs off the edge of the page. Because printers can't cut
87
+ perfectly, anything meant to reach the edge must extend ~0.125 in *past* the trim,
88
+ so the cut never leaves a white sliver.
89
+
90
+ - **Text interiors need no bleed.** Your words sit inside the margins; unipress
91
+ emits pages at the exact trim size, which is exactly what print-on-demand wants
92
+ for a text interior. This is the common case — you don't have to think about it.
93
+ - **Full-bleed interior *images*** (a photo running to the paper edge) are the
94
+ exception. unipress emits trim-size pages, so it isn't set up for edge-to-edge
95
+ interior art. Keep interior images within the text block, or ask your printer how
96
+ they want full-bleed interior pages supplied.
97
+ - **Your cover is where bleed really matters**, and that's handled separately by
98
+ your print service's cover template — see [covers-and-images.md](./covers-and-images.md#covers).
99
+
100
+ ## Front matter
101
+
102
+ The pages before Chapter 1 — title, copyright, contents. Toggle them under
103
+ `book.structure`:
104
+
105
+ ```yaml
106
+ book:
107
+ structure:
108
+ titlePage: true
109
+ copyrightPage: true
110
+ toc: true
111
+ tocDepth: 2 # how many heading levels the contents lists
112
+ frontMatterNumbering: roman # none | roman | arabic
113
+ ```
114
+
115
+ - **`titlePage`** — the title, subtitle, and author, typeset. If your cover art
116
+ already carries the title (many do), you may want this **off** to avoid repeating
117
+ it: `titlePage: false`.
118
+ - **`copyrightPage`** — the copyright/imprint page. Pulls from `book.rights`,
119
+ `book.publisher`, `book.isbn` (see [isbn-and-metadata.md](./isbn-and-metadata.md)).
120
+ - **`toc`** — a generated table of contents from your chapter titles.
121
+ - **`frontMatterNumbering: roman`** is the traditional convention: front matter is
122
+ numbered i, ii, iii…; Chapter 1 resets to page 1. Use `none` to leave front
123
+ matter unnumbered, or `arabic` to number straight through.
124
+
125
+ ## Typography
126
+
127
+ The defaults produce a clean, readable book — you can ship without touching them.
128
+ When you want control, set it under `book.typography`:
129
+
130
+ ```yaml
131
+ book:
132
+ typography:
133
+ bodySize: 11pt
134
+ leading: 0.72em # line spacing
135
+ firstLineIndent: 1.4em # paragraph indent
136
+ bodyFont: ["EB Garamond", "Georgia"] # first available wins
137
+ headingFont: ["EB Garamond", "Georgia"]
138
+ ```
139
+
140
+ - **Fonts are tried in order**; the first one installed on the machine running the
141
+ compile is used, and unipress embeds it in the PDF. Leave them unset to use the
142
+ compiler's clean built-in defaults. If you name a font, make sure it's installed
143
+ where you build.
144
+ - **Body size and leading** are the levers for page count and comfort. 10.5–11 pt
145
+ with roomy leading reads well in a 6×9.
146
+ - For a classical look with these choices preconfigured, scaffold the **monograph**
147
+ template instead of tuning by hand: `unipress create my-book --template monograph`.
148
+
149
+ ## Next
150
+
151
+ - Add your [covers and images](./covers-and-images.md).
152
+ - Produce your [ebook](./ebooks.md) or go to [print](./print.md).
@@ -53,7 +53,7 @@ with `paths: { pages: . }`. It's an ordinary config file; edit it freely.
53
53
  To write one by hand instead:
54
54
 
55
55
  ```yaml
56
- foundation: "@uniweb/book@0.4.1"
56
+ foundation: "@uniweb/book@0.4.2"
57
57
  format: pdf
58
58
  paths:
59
59
  pages: . # only needed when sections live at the root (no content/ folder)
@@ -200,6 +200,23 @@ The config file's default export is a string, number, array, or something else.
200
200
  export default { format: 'pdf' }
201
201
  ```
202
202
 
203
+ ## Cover image (or other config asset) doesn't appear in the output
204
+
205
+ Not an error — a silent drop. The book compiles, but the front/back cover (or a
206
+ banner/logo declared in config) is missing from the PDF/EPUB.
207
+
208
+ Config-declared asset paths — `book.covers.front`, `book.covers.back`, and the like —
209
+ are resolved from the top-level config unipress **reads for content collection**: the
210
+ default `document.yml`, or the file you pass to `--document`. They are *not* scanned from
211
+ a `unipress.config.js` supplied via `--config` (that file layers build settings — `out`,
212
+ `format`, `typst` — over the compile, after content is collected).
213
+
214
+ So if your covers live in `document-book.yml`, build it with `--variant document-book`
215
+ — not `--config document-book.yml`. Confirm with `--verbose`: the `Found N asset references`
216
+ line should count your covers (e.g. 2 more than the body images), and the source-bundle
217
+ `blob:` size should grow by roughly the covers' byte size. Also confirm the files exist at
218
+ the declared path relative to the content directory (e.g. `assets/front.png`).
219
+
203
220
  ## Internal error (exit code 2)
204
221
 
205
222
  Something unexpected threw — typically a library error or a code path unipress didn't wrap. Re-run with `--verbose` for a stack trace, then open an issue with the trace and a description of the content directory + foundation you're compiling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/unipress",
3
- "version": "0.4.27",
3
+ "version": "0.4.29",
4
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
5
  "type": "module",
6
6
  "publishConfig": {
@@ -50,9 +50,9 @@
50
50
  "react": "^19.0.0",
51
51
  "react-dom": "^19.0.0",
52
52
  "@uniweb/runtime": "0.8.20",
53
+ "@uniweb/build": "0.14.21",
53
54
  "@uniweb/core": "0.7.14",
54
55
  "@uniweb/content-reader": "1.1.12",
55
- "@uniweb/build": "0.14.20",
56
56
  "@uniweb/semantic-parser": "1.1.17"
57
57
  },
58
58
  "devDependencies": {
package/src/cli.js CHANGED
@@ -25,6 +25,9 @@ Commands:
25
25
  latex emits a LaTeX source bundle (run latexmk yourself)
26
26
  --foundation <ref> override document.yml's foundation: field
27
27
  --out <path> output file (default: ./<dir>.<ext>)
28
+ --variant <name> build an alternate config <name>.yml in <dir>
29
+ (e.g. --variant document-book) instead of
30
+ document.yml — several cuts of one manuscript
28
31
  --config <path> explicit config file (default: <dir>/unipress.config.js)
29
32
  --typst-binary <p> path to a typst binary (skips managed download)
30
33
  --keep-temp keep the typst temp dir on failure (for debugging)
@@ -86,6 +89,7 @@ async function main(argv) {
86
89
  format: { type: 'string' },
87
90
  out: { type: 'string' },
88
91
  config: { type: 'string' },
92
+ variant: { type: 'string' },
89
93
  title: { type: 'string' },
90
94
  author: { type: 'string' },
91
95
  force: { type: 'boolean' },
@@ -131,6 +135,7 @@ async function main(argv) {
131
135
  foundation: values.foundation ?? null,
132
136
  out: values.out ?? null,
133
137
  config: values.config ?? null,
138
+ variant: values.variant ?? null,
134
139
  typstBinary: values['typst-binary'] ?? null,
135
140
  keepTemp: !!values['keep-temp'],
136
141
  yes: !!values.yes,
@@ -17,10 +17,18 @@ import { detectConfigFile, CONFIG_FILE_NAMES } from '../document-yml.js'
17
17
  import { detectBareContent, materializeDocumentYml } from '../materialize.js'
18
18
  import { findCatalogEntry } from '../catalog.js'
19
19
 
20
+ // `--variant book` → `book.yml`; `--variant print.yaml` → `print.yaml`.
21
+ // The name is used verbatim — no `document-` prefixing — with a `.yml`
22
+ // extension assumed when none is given. Returns null when unset.
23
+ export function variantToConfigFile(variant) {
24
+ if (!variant) return null
25
+ return /\.ya?ml$/i.test(variant) ? variant : `${variant}.yml`
26
+ }
27
+
20
28
  // Bare-folder default: the book foundation's `article` genre — a clean
21
29
  // single-column A4 paper, the natural shape for a loose folder of markdown.
22
30
  const DEFAULT_FOUNDATION =
23
- findCatalogEntry('article')?.foundation?.ref ?? '@uniweb/book@0.4.1'
31
+ findCatalogEntry('article')?.foundation?.ref ?? '@uniweb/book@0.4.2'
24
32
 
25
33
  // "durable-structured-systems" → "Durable Structured Systems"
26
34
  function titleize(slug) {
@@ -98,7 +106,7 @@ async function offerToMaterialize({ sitePath, format, foundation, yes }) {
98
106
  return true
99
107
  }
100
108
 
101
- export async function compileCommand({ dir, format = null, foundation = null, out = null, config = null, typstBinary = null, keepTemp = false, yes = false, verbose = false } = {}) {
109
+ export async function compileCommand({ dir, format = null, foundation = null, out = null, config = null, variant = null, typstBinary = null, keepTemp = false, yes = false, verbose = false } = {}) {
102
110
  if (!dir) {
103
111
  process.stderr.write('error: `compile` requires a directory argument\n')
104
112
  process.stderr.write('usage: unipress compile <dir> [--format <fmt>] [--foundation <ref>] [--out <path>] [--config <path>] [--typst-binary <path>] [--keep-temp] [--yes] [--verbose]\n')
@@ -106,7 +114,9 @@ export async function compileCommand({ dir, format = null, foundation = null, ou
106
114
  }
107
115
 
108
116
  const sitePath = resolve(dir)
109
- if (existsSync(sitePath) && !detectConfigFile(sitePath)) {
117
+ // `--variant <name>` names an explicit config; don't offer to generate
118
+ // one (loadContent throws a clear error if the named file is missing).
119
+ if (!variant && existsSync(sitePath) && !detectConfigFile(sitePath)) {
110
120
  const proceed = await offerToMaterialize({ sitePath, format, foundation, yes })
111
121
  if (!proceed) process.exit(1)
112
122
  }
@@ -121,6 +131,7 @@ export async function compileCommand({ dir, format = null, foundation = null, ou
121
131
  foundationRef: foundation,
122
132
  outPath: out,
123
133
  configPath: config,
134
+ documentConfig: variantToConfigFile(variant),
124
135
  typstBinaryPath: typstBinary,
125
136
  keepTemp,
126
137
  onProgress
package/src/compile.js CHANGED
@@ -52,10 +52,13 @@ export async function compile({
52
52
  typstBinaryPath: cliTypstBinaryPath = null,
53
53
  keepTemp = false,
54
54
  configPath: cliConfigPath = null,
55
+ documentConfig = null,
55
56
  onProgress = () => {}
56
57
  } = {}) {
57
58
  onProgress('loading content...')
58
- const { content, sitePath, configFile } = await loadContent(dir)
59
+ const { content, sitePath, configFile } = await loadContent(dir, {
60
+ configFile: documentConfig
61
+ })
59
62
  onProgress(` ${content.pages.length} page(s) from ${sitePath} (${configFile})`)
60
63
 
61
64
  // A config exists but nothing was collected — almost always the markdown
@@ -8,8 +8,8 @@
8
8
  // image asset-processor (which eagerly loads sharp's native binding and
9
9
  // blows up in a `bun build --compile` binary).
10
10
 
11
- import { existsSync } from 'node:fs'
12
- import { resolve, join } from 'node:path'
11
+ import { existsSync, readdirSync } from 'node:fs'
12
+ import { resolve, join, basename } from 'node:path'
13
13
  import { collectSiteContent, processCollections } from '@uniweb/build/content'
14
14
  import { detectConfigFile, CONFIG_FILE_NAMES } from './document-yml.js'
15
15
  import { ContentDirectoryError, DocumentYmlError } from './errors.js'
@@ -121,6 +121,21 @@ async function resolveLocalCollections(siteContent, sitePath) {
121
121
  }
122
122
  }
123
123
 
124
+ // Discoverability aid: when a named `--variant` config is missing, list the
125
+ // YAML configs that ARE present so a typo (or a forgotten `.yml`) is easy to
126
+ // spot and fix.
127
+ function listConfigsHint(sitePath) {
128
+ try {
129
+ const ymls = readdirSync(sitePath).filter(
130
+ (f) => f.endsWith('.yml') || f.endsWith('.yaml'),
131
+ )
132
+ if (!ymls.length) return ''
133
+ return `\navailable configs here: ${ymls.join(', ')}`
134
+ } catch {
135
+ return ''
136
+ }
137
+ }
138
+
124
139
  export async function loadContent(dir, options = {}) {
125
140
  const sitePath = resolve(dir)
126
141
 
@@ -128,17 +143,39 @@ export async function loadContent(dir, options = {}) {
128
143
  throw new ContentDirectoryError(`content directory does not exist: ${sitePath}`)
129
144
  }
130
145
 
131
- const configFile = detectConfigFile(sitePath)
132
- if (!configFile) {
133
- throw new DocumentYmlError(
134
- `no ${CONFIG_FILE_NAMES.PRIMARY} (or ${CONFIG_FILE_NAMES.FALLBACK}) found in ${sitePath}`
135
- )
146
+ // An explicit config name (from `--variant`) selects an alternate
147
+ // top-level config inside the content dir — e.g. a `document-book.yml`
148
+ // beside the default `document.yml`. It must exist; we don't fall back
149
+ // to auto-detection when the user named one. Otherwise auto-detect
150
+ // `document.yml` / `site.yml`.
151
+ let configFile
152
+ if (options.configFile) {
153
+ configFile = options.configFile
154
+ if (!existsSync(join(sitePath, configFile))) {
155
+ throw new DocumentYmlError(
156
+ `variant config not found: ${join(sitePath, configFile)}${listConfigsHint(sitePath)}`
157
+ )
158
+ }
159
+ } else {
160
+ configFile = detectConfigFile(sitePath)
161
+ if (!configFile) {
162
+ throw new DocumentYmlError(
163
+ `no ${CONFIG_FILE_NAMES.PRIMARY} (or ${CONFIG_FILE_NAMES.FALLBACK}) found in ${sitePath}`
164
+ )
165
+ }
136
166
  }
137
167
 
168
+ // unipress is a document tool: read any config that isn't the `site.yml`
169
+ // dogfood fallback with the document profile (content/ + folder mode +
170
+ // `content:` ordering), regardless of the file's name. So a variant can
171
+ // be named freely (book.yml, print.yml) and still build as a document.
172
+ const profile = basename(configFile).startsWith('site') ? 'site' : 'document'
173
+
138
174
  let content
139
175
  try {
140
176
  content = await collectSiteContent(sitePath, {
141
177
  configFile,
178
+ profile,
142
179
  foundationPath: options.foundationPath
143
180
  })
144
181
  } catch (err) {
@@ -31,27 +31,11 @@
31
31
  // field and this module will verify bytes before caching.
32
32
 
33
33
  import { existsSync } from 'node:fs'
34
- import { mkdir, writeFile, symlink } from 'node:fs/promises'
35
- import { dirname, join, posix, resolve as pathResolve } from 'node:path'
36
- import { createRequire } from 'node:module'
37
- import { fileURLToPath } from 'node:url'
34
+ import { mkdir, writeFile } from 'node:fs/promises'
35
+ import { dirname, join, posix } from 'node:path'
38
36
  import { FoundationFetchError } from './errors.js'
39
37
  import { getCacheDir } from './typst/binary-manager.js'
40
-
41
- const require = createRequire(import.meta.url)
42
-
43
- // Bare specifiers that the foundation expects to resolve externally
44
- // (matches DEFAULT_EXTERNALS in @uniweb/build). At import time, Node
45
- // walks up from the cache dir looking for a node_modules/<name>. The
46
- // cache dir isn't inside any package tree, so we link unipress's own
47
- // installations into a co-located node_modules.
48
- const EXTERNAL_PACKAGES = [
49
- 'react',
50
- 'react-dom',
51
- 'react/jsx-runtime',
52
- 'react/jsx-dev-runtime',
53
- '@uniweb/core',
54
- ]
38
+ import { externalShimPackages } from './runtime-externals.js'
55
39
 
56
40
  // Match `./chunk.js` only in actual ESM import positions — `from './x'`,
57
41
  // `import './x'`, or `import('./x')`. A broad "match any quoted ./x.js"
@@ -100,6 +84,10 @@ export async function fetchFoundationToCache(url, { onProgress = () => {} } = {}
100
84
  const cached = join(cacheDir, name)
101
85
  if (existsSync(cached)) {
102
86
  onProgress(`using cached foundation: ${cached}`)
87
+ // Refresh the external shims even on a cache hit: it's a handful of tiny
88
+ // files, and it self-heals a cache populated by an older unipress (which
89
+ // left an empty node_modules or dead symlinks that no longer resolve).
90
+ await writeExternalShims(cacheDir, onProgress)
103
91
  return cached
104
92
  }
105
93
  }
@@ -138,7 +126,7 @@ export async function fetchFoundationToCache(url, { onProgress = () => {} } = {}
138
126
  if (!fetched.has(discovered)) queue.push(discovered)
139
127
  }
140
128
  }
141
- await linkExternals(cacheDir, onProgress)
129
+ await writeExternalShims(cacheDir, onProgress)
142
130
  const entryPath = join(cacheDir, entryName)
143
131
  onProgress(`foundation cached at ${cacheDir} (${fetched.size} file(s))`)
144
132
  return entryPath
@@ -171,63 +159,32 @@ async function pickRemoteEntry(baseUrl, onProgress) {
171
159
  )
172
160
  }
173
161
 
174
- // Make unipress's own copies of the externalized packages reachable
175
- // from the cache dir. Node's ESM loader walks up from the importing
176
- // file looking for `node_modules/<name>` by placing a node_modules
177
- // directory next to the cached entry with symlinks to each
178
- // external's package directory, bare imports inside the foundation
179
- // resolve to unipress's already-installed copies. This keeps a single
180
- // React instance (unipress's) across host and foundation.
181
- async function linkExternals(cacheDir, onProgress) {
162
+ // Lay shim modules for the foundation's externalized peer deps into a
163
+ // node_modules beside the cached entry, so the dynamically imported foundation
164
+ // resolves react / react-dom / @uniweb/core to unipress's own bundled instances
165
+ // (via the globalThis bridge in runtime-externals.js) rather than hunting for a
166
+ // node_modules that isn't there.
167
+ //
168
+ // Replaces the old symlink-to-local-copies approach, which couldn't locate
169
+ // on-disk copies inside the `bun --compile` binary: the deps are bundled into
170
+ // the binary, and require.resolve resolves relative to the CWD, so it found
171
+ // nothing when unipress ran from a project with no react up its tree. The shims
172
+ // read the bridge instead of resolving a real package, so they work from any
173
+ // directory and in any distribution. See runtime-externals.js for the full
174
+ // rationale and the single-instance guarantee.
175
+ //
176
+ // Idempotent and overwriting: cheap (a handful of tiny files), and self-heals a
177
+ // cache left in a bad state by an older unipress.
178
+ async function writeExternalShims(cacheDir, onProgress) {
182
179
  const nmDir = join(cacheDir, 'node_modules')
183
- await mkdir(nmDir, { recursive: true })
184
- // Collapse subpath specifiers (react/jsx-runtime → react) to the
185
- // package-root specifier we actually link. Deduped.
186
- const rootSpecs = new Set()
187
- for (const spec of EXTERNAL_PACKAGES) {
188
- rootSpecs.add(spec.startsWith('@')
189
- ? spec.split('/').slice(0, 2).join('/') // '@scope/name'
190
- : spec.split('/')[0]) // 'name'
191
- }
192
- for (const name of rootSpecs) {
193
- const dest = name.startsWith('@')
194
- ? join(nmDir, ...name.split('/')) // node_modules/@scope/name
195
- : join(nmDir, name) // node_modules/name
196
- if (existsSync(dest)) continue
197
- try {
198
- const pkgDir = findPackageRoot(name)
199
- if (!pkgDir) {
200
- onProgress(` warn: cannot find '${name}' in unipress deps — foundation may fail to import`)
201
- continue
202
- }
203
- await mkdir(dirname(dest), { recursive: true })
204
- await symlink(pkgDir, dest, 'dir')
205
- onProgress(` linked ${name} → ${pkgDir}`)
206
- } catch (err) {
207
- onProgress(` warn: linking ${name} failed: ${err.message}`)
180
+ for (const pkg of externalShimPackages()) {
181
+ const pkgDir = join(nmDir, ...pkg.dir.split('/'))
182
+ await mkdir(pkgDir, { recursive: true })
183
+ await writeFile(join(pkgDir, 'package.json'), pkg.packageJson)
184
+ for (const [rel, source] of Object.entries(pkg.files)) {
185
+ await writeFile(join(pkgDir, rel), source)
208
186
  }
209
- }
210
- }
211
-
212
- function findPackageRoot(name) {
213
- try {
214
- // Resolve the package's `package.json` to find its root.
215
- const pkgJson = require.resolve(`${name}/package.json`)
216
- return dirname(pkgJson)
217
- } catch {
218
- // Fallback: resolve a default export and strip back.
219
- try {
220
- const entry = require.resolve(name)
221
- let dir = dirname(entry)
222
- while (dir !== dirname(dir)) {
223
- if (existsSync(join(dir, 'package.json'))) {
224
- const pkg = JSON.parse(require('fs').readFileSync(join(dir, 'package.json'), 'utf8'))
225
- if (pkg.name === name || pkg.name === name.split('/')[0]) return dir
226
- }
227
- dir = dirname(dir)
228
- }
229
- } catch {}
230
- return null
187
+ onProgress(` shimmed ${pkg.dir} → unipress's bundled copy`)
231
188
  }
232
189
  }
233
190
 
@@ -72,8 +72,8 @@ function publicUrl(name, version) {
72
72
  }
73
73
 
74
74
  const BOOK_FOUNDATION = {
75
- ref: '@uniweb/book@0.4.1',
76
- source: { url: publicUrl('book', '0.4.1') },
75
+ ref: '@uniweb/book@0.4.2',
76
+ source: { url: publicUrl('book', '0.4.2') },
77
77
  }
78
78
 
79
79
  const DATA_FOUNDATION = {
@@ -21,14 +21,23 @@
21
21
  //
22
22
  // React-instance note (gotcha #2): @uniweb/runtime/ssr (built bundle) imports
23
23
  // React as an external; the foundation does the same. Both must resolve to the
24
- // same React instance, otherwise hooks in foundation components throw "Invalid
25
- // hook call". Inside this monorepo react is hoisted; in a real npm install of
26
- // unipress, both also resolve from unipress's node_modules.
24
+ // SAME React instance, otherwise hooks in foundation components throw "Invalid
25
+ // hook call" and the foundation must see the SAME @uniweb/core unipress built
26
+ // the Website graph with. Importing runtime-externals.js (below) publishes
27
+ // unipress's own bundled react / react-dom / @uniweb/core on a globalThis
28
+ // bridge and drives the cache-side shim modules that the fetched foundation
29
+ // resolves against, guaranteeing one shared instance regardless of how unipress
30
+ // is run (source, npm install, or the bun --compile binary). See
31
+ // runtime-externals.js for the full rationale.
27
32
 
28
33
  import { pathToFileURL } from 'node:url'
29
34
  import { readFile } from 'node:fs/promises'
30
35
  import { initPrerender } from '@uniweb/runtime/ssr'
31
36
  import { FoundationResolutionError, CompileError } from './errors.js'
37
+ // Side-effect import: sets the globalThis bridges before any foundation is
38
+ // dynamically imported. ESM evaluates it once (foundation-fetch.js imports it
39
+ // too, to generate the matching shims).
40
+ import './runtime-externals.js'
32
41
 
33
42
  export async function importFoundation(resolvedPath) {
34
43
  try {