@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.
- package/README.md +39 -2
- package/docs/publishing/README.md +135 -0
- package/docs/publishing/covers-and-images.md +118 -0
- package/docs/publishing/ebooks.md +93 -0
- package/docs/publishing/isbn-and-metadata.md +90 -0
- package/docs/publishing/print.md +93 -0
- package/docs/publishing/size-and-layout.md +152 -0
- package/docs/troubleshooting.md +18 -1
- package/package.json +2 -2
- package/src/cli.js +5 -0
- package/src/commands/compile.js +14 -3
- package/src/compile.js +4 -1
- package/src/content-loader.js +44 -7
- package/src/foundation-fetch.js +32 -75
- package/src/foundations-data.js +2 -2
- package/src/orchestrator.js +12 -3
- package/src/runtime-externals.js +138 -0
- package/src/templates-data.js +6 -6
|
@@ -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).
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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,
|
package/src/commands/compile.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
package/src/content-loader.js
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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) {
|
package/src/foundation-fetch.js
CHANGED
|
@@ -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
|
|
35
|
-
import { dirname, join, posix
|
|
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
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
package/src/foundations-data.js
CHANGED
|
@@ -72,8 +72,8 @@ function publicUrl(name, version) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const BOOK_FOUNDATION = {
|
|
75
|
-
ref: '@uniweb/book@0.4.
|
|
76
|
-
source: { url: publicUrl('book', '0.4.
|
|
75
|
+
ref: '@uniweb/book@0.4.2',
|
|
76
|
+
source: { url: publicUrl('book', '0.4.2') },
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
const DATA_FOUNDATION = {
|
package/src/orchestrator.js
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
// hook call"
|
|
26
|
-
//
|
|
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 {
|