@uniweb/unipress 0.4.26 → 0.4.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/unipress",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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": {
@@ -49,11 +49,11 @@
49
49
  "prompts": "^2.4.2",
50
50
  "react": "^19.0.0",
51
51
  "react-dom": "^19.0.0",
52
- "@uniweb/core": "0.7.14",
53
- "@uniweb/build": "0.14.19",
54
52
  "@uniweb/content-reader": "1.1.12",
55
- "@uniweb/semantic-parser": "1.1.17",
56
- "@uniweb/runtime": "0.8.20"
53
+ "@uniweb/build": "0.14.20",
54
+ "@uniweb/core": "0.7.14",
55
+ "@uniweb/runtime": "0.8.20",
56
+ "@uniweb/semantic-parser": "1.1.17"
57
57
  },
58
58
  "devDependencies": {
59
59
  "vitest": "^4.1.7"
@@ -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
 
@@ -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 {
@@ -0,0 +1,138 @@
1
+ // Single-instance linkage for dynamically imported foundations.
2
+ //
3
+ // A built foundation externalizes its peer deps — react, react-dom,
4
+ // react-dom/server, react/jsx-runtime, react/jsx-dev-runtime, @uniweb/core
5
+ // (the set in @uniweb/build's foundation/config.js DEFAULT_EXTERNALS). When
6
+ // unipress fetches a foundation to its cache and dynamically imports the built
7
+ // entry.js, those bare imports must resolve to unipress's OWN copies so the
8
+ // foundation shares one React instance with @uniweb/runtime/ssr and one
9
+ // @uniweb/core with the Website graph the orchestrator builds.
10
+ //
11
+ // The old approach symlinked unipress's node_modules copies beside the cached
12
+ // entry. That relies on those copies existing on disk and being locatable via
13
+ // require.resolve — which fails in the `bun --compile` standalone binary: the
14
+ // deps are bundled INTO the binary, and require.resolve does CWD-relative
15
+ // resolution, so it finds nothing when unipress runs from a project (like a
16
+ // loose book folder) that has no react up its directory tree. The symlink step
17
+ // then produced an empty node_modules and the foundation import failed with
18
+ // "Cannot find module 'react/jsx-runtime'".
19
+ //
20
+ // Instead we mirror @uniweb/runtime's worker-isolate shims (its
21
+ // build-worker.js): publish each package on a globalThis bridge here, and write
22
+ // tiny shim modules into the cache's co-located node_modules (see
23
+ // writeExternalShims in foundation-fetch.js) that re-export from the bridge.
24
+ // Bun/Node resolve the foundation's bare imports to the shims (nearest
25
+ // node_modules wins); each shim reads the bridge and yields unipress's exact
26
+ // instance — no on-disk package needed, works identically in the compiled
27
+ // binary and in source/npm mode.
28
+ //
29
+ // Why globalThis rather than a direct import inside the shim: the shim lives on
30
+ // disk beside the foundation; an `import 'react'` from it would re-enter
31
+ // resolution (and in the binary resolve to nothing). Reading a global sidesteps
32
+ // module resolution entirely — the same reason the isolate uses it.
33
+ //
34
+ // Importing this module has the SIDE EFFECT of setting the globalThis bridges,
35
+ // so it must be imported before any foundation is dynamically imported. Both
36
+ // foundation-fetch.js (which writes the shims) and orchestrator.js (which does
37
+ // the import) import it; ESM evaluates it once.
38
+
39
+ import * as React from 'react'
40
+ import * as ReactJsxRuntime from 'react/jsx-runtime'
41
+ import * as ReactJsxDevRuntime from 'react/jsx-dev-runtime'
42
+ import * as ReactDom from 'react-dom'
43
+ import * as ReactDomServer from 'react-dom/server'
44
+ import * as UniwebCore from '@uniweb/core'
45
+
46
+ // globalThis bridge keys. Namespaced to unipress so they never collide with
47
+ // @uniweb/runtime's worker isolate (__PLATFORM_*), which a foundation could
48
+ // conceivably bundle into the same realm.
49
+ const REACT_KEY = '__UNIPRESS_REACT'
50
+ const JSX_RUNTIME_KEY = '__UNIPRESS_JSX_RUNTIME'
51
+ const JSX_DEV_RUNTIME_KEY = '__UNIPRESS_JSX_DEV_RUNTIME'
52
+ const REACT_DOM_KEY = '__UNIPRESS_REACT_DOM'
53
+ const REACT_DOM_SERVER_KEY = '__UNIPRESS_REACT_DOM_SERVER'
54
+ const UNIWEB_CORE_KEY = '__UNIPRESS_UNIWEB_CORE'
55
+
56
+ globalThis[REACT_KEY] = React
57
+ globalThis[JSX_RUNTIME_KEY] = ReactJsxRuntime
58
+ globalThis[JSX_DEV_RUNTIME_KEY] = ReactJsxDevRuntime
59
+ globalThis[REACT_DOM_KEY] = ReactDom
60
+ globalThis[REACT_DOM_SERVER_KEY] = ReactDomServer
61
+ globalThis[UNIWEB_CORE_KEY] = UniwebCore
62
+
63
+ const VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
64
+
65
+ // Generate an ESM shim module that re-exports every named export of `ns` from
66
+ // the globalThis bridge. Names are enumerated from the live namespace (as
67
+ // build-worker.js does) so the shim tracks the actual React / core surface
68
+ // without a hardcoded list. Non-identifier keys (e.g. "module.exports" from CJS
69
+ // interop) are skipped; `default` is re-exported explicitly.
70
+ function shimSource(globalKey, ns) {
71
+ const names = Object.keys(ns).filter(
72
+ (name) => name !== 'default' && VALID_IDENTIFIER.test(name)
73
+ )
74
+ return [
75
+ `// AUTO-GENERATED by unipress — re-exports from globalThis.${globalKey}.`,
76
+ `// unipress publishes its own bundled instance there (see runtime-externals.js),`,
77
+ `// giving this dynamically-imported foundation the SAME instance unipress uses.`,
78
+ `const __ns = globalThis.${globalKey}`,
79
+ `export default __ns?.default ?? __ns`,
80
+ ...names.map((name) => `export const ${name} = __ns.${name}`),
81
+ ''
82
+ ].join('\n')
83
+ }
84
+
85
+ function modulePackageJson(name, exportsMap) {
86
+ return JSON.stringify(
87
+ {
88
+ name,
89
+ type: 'module',
90
+ exports: { ...exportsMap, './package.json': './package.json' }
91
+ },
92
+ null,
93
+ 2
94
+ )
95
+ }
96
+
97
+ // The shim packages to lay into a fetched foundation's co-located node_modules.
98
+ // Each entry: the package directory (relative to node_modules, POSIX-style),
99
+ // its package.json text, and the shim files keyed by relative path. Subpath
100
+ // specifiers (react/jsx-runtime, react-dom/server) are served through the
101
+ // package's `exports` map so Node/Bun resolve them to the right shim file.
102
+ export function externalShimPackages() {
103
+ return [
104
+ {
105
+ dir: 'react',
106
+ packageJson: modulePackageJson('react', {
107
+ '.': './index.js',
108
+ './jsx-runtime': './jsx-runtime.js',
109
+ './jsx-dev-runtime': './jsx-dev-runtime.js'
110
+ }),
111
+ files: {
112
+ 'index.js': shimSource(REACT_KEY, React),
113
+ 'jsx-runtime.js': shimSource(JSX_RUNTIME_KEY, ReactJsxRuntime),
114
+ 'jsx-dev-runtime.js': shimSource(JSX_DEV_RUNTIME_KEY, ReactJsxDevRuntime)
115
+ }
116
+ },
117
+ {
118
+ dir: 'react-dom',
119
+ packageJson: modulePackageJson('react-dom', {
120
+ '.': './index.js',
121
+ './server': './server.js'
122
+ }),
123
+ files: {
124
+ 'index.js': shimSource(REACT_DOM_KEY, ReactDom),
125
+ 'server.js': shimSource(REACT_DOM_SERVER_KEY, ReactDomServer)
126
+ }
127
+ },
128
+ {
129
+ dir: '@uniweb/core',
130
+ packageJson: modulePackageJson('@uniweb/core', {
131
+ '.': './index.js'
132
+ }),
133
+ files: {
134
+ 'index.js': shimSource(UNIWEB_CORE_KEY, UniwebCore)
135
+ }
136
+ }
137
+ ]
138
+ }