@uniweb/unipress 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/LICENSE +201 -0
- package/README.md +213 -0
- package/RELEASING.md +101 -0
- package/docs/parity-report.md +153 -0
- package/docs/templates/book.md +55 -0
- package/docs/templates/data-report.md +85 -0
- package/docs/templates/directory.md +73 -0
- package/docs/templates/monograph.md +50 -0
- package/docs/templates/report.md +55 -0
- package/docs/troubleshooting.md +159 -0
- package/package.json +62 -0
- package/src/catalog.js +23 -0
- package/src/cli.js +193 -0
- package/src/commands/compile.js +37 -0
- package/src/commands/create.js +165 -0
- package/src/commands/inspect.js +182 -0
- package/src/compile.js +165 -0
- package/src/config.js +128 -0
- package/src/content-loader.js +56 -0
- package/src/document-yml.js +20 -0
- package/src/errors.js +78 -0
- package/src/foundation-fetch.js +202 -0
- package/src/foundation-loader.js +229 -0
- package/src/foundations-data.js +122 -0
- package/src/index.js +8 -0
- package/src/orchestrator.js +150 -0
- package/src/scaffold.js +66 -0
- package/src/sinks/blob.js +25 -0
- package/src/sinks/typst.js +101 -0
- package/src/templates-data.js +63 -0
- package/src/typst/binary-manager.js +228 -0
- package/src/typst/versions.js +65 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Resolve a foundation reference to an absolute filesystem path.
|
|
2
|
+
//
|
|
3
|
+
// Returns a path string. Does NOT import or evaluate the foundation —
|
|
4
|
+
// that's the orchestrator's job (M3b). This separation lets the resolver
|
|
5
|
+
// be tested without going through React, and lets the orchestrator be
|
|
6
|
+
// tested with a hand-supplied path.
|
|
7
|
+
//
|
|
8
|
+
// Five accepted ref forms:
|
|
9
|
+
//
|
|
10
|
+
// 1. URL ............ https://.../foundation.js — downloaded + cached
|
|
11
|
+
// 2. Local path ..... ./foo, ../foo, /abs/foo, ./foo/dist/foundation.js
|
|
12
|
+
// 3. Registry ref ... @<namespace>/<name>@<version> — constructed into a
|
|
13
|
+
// URL via the registry base (UNIWEB_REGISTRY_URL or
|
|
14
|
+
// the production default), then resolved as URL
|
|
15
|
+
// 4. Catalog id ..... academic-metrics, book, … — looked up in
|
|
16
|
+
// foundations-data.js, then resolved as URL
|
|
17
|
+
// 5. Package name ... my-foundation, @org/foo (no @version suffix)
|
|
18
|
+
//
|
|
19
|
+
// Resolution order for a bare name: registry-ref first (if it matches the
|
|
20
|
+
// scoped @ns/name@ver pattern), then catalog, then package. The registry
|
|
21
|
+
// ref is the authoritative form scaffolded documents pin — it works for
|
|
22
|
+
// any user of the foundation regardless of whether they have the catalog
|
|
23
|
+
// id resolved locally.
|
|
24
|
+
//
|
|
25
|
+
// The chosen entry for a package is its `exports['./dist']` map, NOT the
|
|
26
|
+
// default `.` entry — the default points at source (_entry.generated.js)
|
|
27
|
+
// and is only valid inside a Vite build. `dist/foundation.js` is the
|
|
28
|
+
// built artifact (the federated module) that Node can import.
|
|
29
|
+
|
|
30
|
+
import { existsSync, statSync, readFileSync } from 'node:fs'
|
|
31
|
+
import { readFile } from 'node:fs/promises'
|
|
32
|
+
import { resolve, isAbsolute, join, dirname } from 'node:path'
|
|
33
|
+
import { FoundationResolutionError } from './errors.js'
|
|
34
|
+
import { findCatalogEntry } from './catalog.js'
|
|
35
|
+
import { fetchFoundationToCache } from './foundation-fetch.js'
|
|
36
|
+
|
|
37
|
+
const URL_PATTERN = /^https?:\/\//
|
|
38
|
+
const PATH_PATTERN = /^(\.\.?[\/\\]|[\/\\]|[A-Za-z]:[\/\\])/
|
|
39
|
+
// Registry ref: @<namespace>/<name>@<version>. Namespace + name follow npm's
|
|
40
|
+
// scoped-package allowed character set (lowercase alphanumerics, _, -, .).
|
|
41
|
+
// Version is anything semver-shaped — exact match isn't enforced here; the
|
|
42
|
+
// registry decides what's valid.
|
|
43
|
+
const REGISTRY_REF_PATTERN = /^@([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)@([0-9a-z][0-9a-z.\-+]*)$/i
|
|
44
|
+
const DEFAULT_REGISTRY_BASE = 'https://site-router.uniweb-edge.workers.dev'
|
|
45
|
+
const DEFAULT_BUILT_ENTRY = 'dist/foundation.js'
|
|
46
|
+
|
|
47
|
+
function getRegistryBase() {
|
|
48
|
+
const raw = process.env.UNIWEB_REGISTRY_URL || DEFAULT_REGISTRY_BASE
|
|
49
|
+
return raw.replace(/\/$/, '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildRegistryUrl(namespace, name, version) {
|
|
53
|
+
return `${getRegistryBase()}/registry/packages/${namespace}/${name}/${version}/foundation.js`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function resolveFoundationRef(ref, { anchorDir, onProgress = () => {} } = {}) {
|
|
57
|
+
if (!ref || typeof ref !== 'string') {
|
|
58
|
+
throw new FoundationResolutionError(
|
|
59
|
+
`foundation ref is required (got ${ref === undefined ? 'undefined' : JSON.stringify(ref)})`
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (URL_PATTERN.test(ref)) {
|
|
64
|
+
return resolveUrlRef(ref, { onProgress })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (PATH_PATTERN.test(ref)) {
|
|
68
|
+
return resolvePathRef(ref, anchorDir)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const registryMatch = ref.match(REGISTRY_REF_PATTERN)
|
|
72
|
+
if (registryMatch) {
|
|
73
|
+
return resolveRegistryRef(ref, registryMatch, { onProgress })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const catalogEntry = findCatalogEntry(ref)
|
|
77
|
+
if (catalogEntry) {
|
|
78
|
+
return resolveCatalogRef(ref, catalogEntry, { onProgress })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return resolvePackageRef(ref, anchorDir)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveRegistryRef(ref, match, { onProgress }) {
|
|
85
|
+
const [, namespace, name, version] = match
|
|
86
|
+
const url = buildRegistryUrl(namespace, name, version)
|
|
87
|
+
const resolvedPath = await fetchFoundationToCache(url, { onProgress })
|
|
88
|
+
return { ref, source: 'registry', resolvedPath, registryUrl: url }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveUrlRef(url, { onProgress }) {
|
|
92
|
+
const resolvedPath = await fetchFoundationToCache(url, { onProgress })
|
|
93
|
+
return { ref: url, source: 'url', resolvedPath }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function resolveCatalogRef(id, entry, { onProgress }) {
|
|
97
|
+
// Prefer the registry ref under the new shape (entry.foundation.ref);
|
|
98
|
+
// fall back to legacy shape (entry.source.url) for any entries still
|
|
99
|
+
// on the v0.1 layout.
|
|
100
|
+
const ref = entry?.foundation?.ref
|
|
101
|
+
if (ref) {
|
|
102
|
+
const m = ref.match(REGISTRY_REF_PATTERN)
|
|
103
|
+
if (m) {
|
|
104
|
+
const result = await resolveRegistryRef(ref, m, { onProgress })
|
|
105
|
+
return { ...result, ref: id, source: 'catalog', catalogEntry: entry }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const url = entry?.foundation?.source?.url ?? entry?.source?.url
|
|
109
|
+
if (!url) {
|
|
110
|
+
throw new FoundationResolutionError(
|
|
111
|
+
`catalog entry '${id}' has no foundation.ref or source.url\n` +
|
|
112
|
+
`hint: pass --foundation <path-or-url> to bypass the catalog`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const resolvedPath = await fetchFoundationToCache(url, { onProgress })
|
|
116
|
+
return { ref: id, source: 'catalog', resolvedPath, catalogEntry: entry }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolvePathRef(ref, anchorDir) {
|
|
120
|
+
if (!anchorDir) {
|
|
121
|
+
throw new FoundationResolutionError(
|
|
122
|
+
`cannot resolve relative foundation path '${ref}' without an anchor directory`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const absolute = isAbsolute(ref) ? ref : resolve(anchorDir, ref)
|
|
127
|
+
|
|
128
|
+
if (!existsSync(absolute)) {
|
|
129
|
+
throw new FoundationResolutionError(
|
|
130
|
+
`foundation path does not exist: ${absolute}\n` +
|
|
131
|
+
`hint: did you build the foundation? (pnpm --filter <foundation> build)`
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const stat = statSync(absolute)
|
|
136
|
+
const file = stat.isDirectory() ? join(absolute, DEFAULT_BUILT_ENTRY) : absolute
|
|
137
|
+
|
|
138
|
+
if (!existsSync(file)) {
|
|
139
|
+
throw new FoundationResolutionError(
|
|
140
|
+
`expected built foundation at ${file}\n` +
|
|
141
|
+
`hint: did you build the foundation? (pnpm --filter <foundation> build)`
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { ref, source: 'path', resolvedPath: file }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function resolvePackageRef(name, anchorDir) {
|
|
149
|
+
if (!anchorDir) {
|
|
150
|
+
throw new FoundationResolutionError(
|
|
151
|
+
`cannot resolve foundation package '${name}' without an anchor directory`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Walk up from anchorDir looking for node_modules/<name>/. Bypasses
|
|
156
|
+
// Node's exports-map enforcement (which blocks `<pkg>/package.json`
|
|
157
|
+
// access unless the package explicitly whitelists it). pnpm's hoisting
|
|
158
|
+
// and workspace symlinks land where this expects.
|
|
159
|
+
const pkgRoot = findPackageDir(name, anchorDir)
|
|
160
|
+
if (!pkgRoot) {
|
|
161
|
+
throw new FoundationResolutionError(
|
|
162
|
+
`cannot find foundation package '${name}' from ${anchorDir}\n` +
|
|
163
|
+
`hint: is the package installed? (pnpm install / pnpm add ${name})\n` +
|
|
164
|
+
`hint: if it's not on npm, pass a local path: --foundation ../path/to/foundation`
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const pkgJsonPath = join(pkgRoot, 'package.json')
|
|
169
|
+
const pkg = existsSync(pkgJsonPath)
|
|
170
|
+
? JSON.parse(await readFile(pkgJsonPath, 'utf8'))
|
|
171
|
+
: null
|
|
172
|
+
|
|
173
|
+
// Prefer the explicit `./dist` exports subpath if declared; otherwise
|
|
174
|
+
// fall back to the convention `dist/foundation.js`.
|
|
175
|
+
const distExport = pickBuiltEntry(pkg?.exports)
|
|
176
|
+
const file = distExport
|
|
177
|
+
? resolve(pkgRoot, distExport)
|
|
178
|
+
: join(pkgRoot, DEFAULT_BUILT_ENTRY)
|
|
179
|
+
|
|
180
|
+
if (!existsSync(file)) {
|
|
181
|
+
const cause = distExport
|
|
182
|
+
? `package '${name}' declares exports['./dist'] = '${distExport}' but file is missing`
|
|
183
|
+
: `package '${name}' has no exports['./dist'] entry, and ${DEFAULT_BUILT_ENTRY} is missing`
|
|
184
|
+
throw new FoundationResolutionError(
|
|
185
|
+
`${cause}\n` +
|
|
186
|
+
`hint: build the foundation (pnpm --filter ${name} build)`
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ref: name, source: 'package', resolvedPath: file, packageRoot: pkgRoot, packageVersion: pkg?.version }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function findPackageDir(name, fromDir) {
|
|
194
|
+
let dir = fromDir
|
|
195
|
+
while (true) {
|
|
196
|
+
const candidate = join(dir, 'node_modules', name)
|
|
197
|
+
if (existsSync(candidate)) return candidate
|
|
198
|
+
const parent = dirname(dir)
|
|
199
|
+
if (parent === dir) return null
|
|
200
|
+
dir = parent
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A foundation's `exports['./dist']` entry points at the built `foundation.js`.
|
|
205
|
+
// The value can be a string or a conditional-exports object; we accept both.
|
|
206
|
+
function pickBuiltEntry(exports) {
|
|
207
|
+
if (!exports || typeof exports !== 'object') return null
|
|
208
|
+
const dist = exports['./dist']
|
|
209
|
+
if (!dist) return null
|
|
210
|
+
if (typeof dist === 'string') return dist
|
|
211
|
+
if (typeof dist === 'object') {
|
|
212
|
+
return dist.default || dist.import || dist.node || null
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Resolve the foundation ref for a unipress run. CLI flag overrides
|
|
218
|
+
// `document.yml`'s `foundation:` field; otherwise we use what the
|
|
219
|
+
// content config declares.
|
|
220
|
+
export async function resolveFoundation({ cliRef, configRef, anchorDir, onProgress = () => {} }) {
|
|
221
|
+
const ref = cliRef ?? configRef
|
|
222
|
+
if (!ref) {
|
|
223
|
+
throw new FoundationResolutionError(
|
|
224
|
+
`no foundation specified\n` +
|
|
225
|
+
`hint: set 'foundation:' in document.yml, or pass --foundation <ref>`
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
return resolveFoundationRef(ref, { anchorDir, onProgress })
|
|
229
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* unipress template catalog.
|
|
3
|
+
*
|
|
4
|
+
* Each entry is one starter pattern users pick at `unipress create` time.
|
|
5
|
+
* A template pins a foundation (the runtime artifact unipress loads at
|
|
6
|
+
* compile) and ships starter content that exercises that foundation.
|
|
7
|
+
*
|
|
8
|
+
* Multiple templates can pin the same foundation — `book`, `monograph`,
|
|
9
|
+
* and `report` all use `@uniweb/book` with different starter content,
|
|
10
|
+
* different `book.structure:` defaults, and different sample chapters.
|
|
11
|
+
*
|
|
12
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* Resolution path
|
|
14
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* `unipress create my-doc --template book`:
|
|
17
|
+
* 1. catalog lookup (this file) → entry.foundation.ref + entry.scaffold.
|
|
18
|
+
* 2. scaffold copies templates-data.js[entry.scaffold] into my-doc/. The
|
|
19
|
+
* bundled document.yml has `foundation: <entry.foundation.ref>` (the
|
|
20
|
+
* registry-ref form, e.g., '@uniweb/book@0.1.0' — rewritten from the
|
|
21
|
+
* committed dev path-ref by scripts/generate-templates-data.js).
|
|
22
|
+
*
|
|
23
|
+
* `unipress compile my-doc`:
|
|
24
|
+
* 1. document.yml `foundation: '@uniweb/book@0.1.0'` is parsed as a
|
|
25
|
+
* registry ref by foundation-loader.js.
|
|
26
|
+
* 2. The loader constructs a URL from the registry base
|
|
27
|
+
* (UNIWEB_REGISTRY_URL or the production default at
|
|
28
|
+
* site-router.uniweb-edge.workers.dev) → fetches + caches.
|
|
29
|
+
*
|
|
30
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
31
|
+
* source.url
|
|
32
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* Each entry's `foundation.source.url` is the human-meaningful "where this
|
|
35
|
+
* foundation lives" pointer shown in `list-templates` output and post-create
|
|
36
|
+
* messages. It does NOT drive resolution under the registry-ref-in-document.yml
|
|
37
|
+
* model — foundation-loader builds its own URL from the ref + base. The
|
|
38
|
+
* URL is included for transparency.
|
|
39
|
+
*
|
|
40
|
+
* v0.2 entries pin local-registry URLs (http://localhost:4001/...) so
|
|
41
|
+
* foundation devs running `uniweb publish --local` can verify resolution
|
|
42
|
+
* end-to-end. TF8 of the unipress-foundations-and-templates plan switches
|
|
43
|
+
* these to the production registry once the foundations publish there.
|
|
44
|
+
*
|
|
45
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
46
|
+
* Storage format
|
|
47
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
48
|
+
*
|
|
49
|
+
* Stored as a plain JS module (not YAML) so `bun build --compile` can
|
|
50
|
+
* inline it at bundle time. The compiled binary has no filesystem for
|
|
51
|
+
* runtime reads.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const LOCAL_REGISTRY_BASE = 'http://localhost:4001/registry/packages'
|
|
55
|
+
|
|
56
|
+
function localUrl(namespace, name, version) {
|
|
57
|
+
return `${LOCAL_REGISTRY_BASE}/${namespace}/${name}/${version}/foundation.js`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BOOK_FOUNDATION = {
|
|
61
|
+
ref: '@uniweb/book@0.1.0',
|
|
62
|
+
source: { url: localUrl('uniweb', 'book', '0.1.0') },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DATA_FOUNDATION = {
|
|
66
|
+
ref: '@uniweb/data@0.1.0',
|
|
67
|
+
source: { url: localUrl('uniweb', 'data', '0.1.0') },
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const FOUNDATIONS = [
|
|
71
|
+
{
|
|
72
|
+
id: 'book',
|
|
73
|
+
name: 'Book',
|
|
74
|
+
description:
|
|
75
|
+
'Long-form prose with chapters. Title page, copyright, TOC, ' +
|
|
76
|
+
'and trade-6x9 trim by default. Compiles to PDF (Typst), Typst ' +
|
|
77
|
+
'source bundle, Paged.js HTML, or EPUB.',
|
|
78
|
+
outputs: ['pdf', 'typst', 'pagedjs', 'epub'],
|
|
79
|
+
foundation: BOOK_FOUNDATION,
|
|
80
|
+
scaffold: 'book',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'monograph',
|
|
84
|
+
name: 'Monograph',
|
|
85
|
+
description:
|
|
86
|
+
'Scholarly long-form work. Same foundation as `book` with ' +
|
|
87
|
+
'footnote-heavy starter content and academic-typography defaults.',
|
|
88
|
+
outputs: ['pdf', 'typst', 'pagedjs', 'epub'],
|
|
89
|
+
foundation: BOOK_FOUNDATION,
|
|
90
|
+
scaffold: 'monograph',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'report',
|
|
94
|
+
name: 'Report',
|
|
95
|
+
description:
|
|
96
|
+
'Long-form report with mixed prose and tables. Same foundation as ' +
|
|
97
|
+
'`book`, configured for technical writing rather than narrative.',
|
|
98
|
+
outputs: ['pdf', 'typst', 'pagedjs', 'epub'],
|
|
99
|
+
foundation: BOOK_FOUNDATION,
|
|
100
|
+
scaffold: 'report',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'data-report',
|
|
104
|
+
name: 'Data Report',
|
|
105
|
+
description:
|
|
106
|
+
'Aggregate metrics across a set of records — publications, funding, ' +
|
|
107
|
+
'supervisions. Emits both a downloadable Excel workbook and a Word report.',
|
|
108
|
+
outputs: ['xlsx', 'docx'],
|
|
109
|
+
foundation: DATA_FOUNDATION,
|
|
110
|
+
scaffold: 'data-report',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'directory',
|
|
114
|
+
name: 'Directory',
|
|
115
|
+
description:
|
|
116
|
+
'Listing of records (people, organizations, items) with filterable ' +
|
|
117
|
+
'fields and a queryable surface. Emits Excel + Word output.',
|
|
118
|
+
outputs: ['xlsx', 'docx'],
|
|
119
|
+
foundation: DATA_FOUNDATION,
|
|
120
|
+
scaffold: 'directory',
|
|
121
|
+
},
|
|
122
|
+
]
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Public programmatic surface for `unipress`.
|
|
2
|
+
//
|
|
3
|
+
// v0 is CLI-first. This file exists so `import '@uniweb/unipress'` doesn't fail and
|
|
4
|
+
// so future API exports (defineUnipressConfig, etc.) have a stable home.
|
|
5
|
+
|
|
6
|
+
export function defineUnipressConfig(config) {
|
|
7
|
+
return config
|
|
8
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Import a built foundation and wire it to a content object.
|
|
2
|
+
//
|
|
3
|
+
// `initPrerender` from @uniweb/runtime/ssr does the actual work: it builds
|
|
4
|
+
// the Website graph (Pages, Blocks), installs the SSR childBlockRenderer,
|
|
5
|
+
// installs SSR-safe routing shims, and propagates base path. We hand it
|
|
6
|
+
// the loaded foundation module and the content object; we get back a
|
|
7
|
+
// configured `uniweb` instance whose `activeWebsite` is fully populated.
|
|
8
|
+
//
|
|
9
|
+
// Compile-time dispatch goes through `foundation.compileDocument` (and its
|
|
10
|
+
// lower-level sibling `compileSubtree`) — re-exports the foundation's build
|
|
11
|
+
// adds when the foundation itself imports @uniweb/press. This keeps Press a
|
|
12
|
+
// single instance: the foundation has its own bundled copy, and unipress
|
|
13
|
+
// reaches it via the foundation rather than importing its own (which would
|
|
14
|
+
// create a dual-React-context trap that drops every registration).
|
|
15
|
+
//
|
|
16
|
+
// compileDocument is the high-level "compile this website through this
|
|
17
|
+
// foundation" entry point — it looks up `foundation.outputs[format]`,
|
|
18
|
+
// calls the foundation's getOptions to assemble adapter options, and
|
|
19
|
+
// dispatches. unipress hands it the Website + format + host hints;
|
|
20
|
+
// compileDocument does the rest.
|
|
21
|
+
//
|
|
22
|
+
// React-instance note (gotcha #2): @uniweb/runtime/ssr (built bundle) imports
|
|
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.
|
|
27
|
+
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
29
|
+
import { readFile } from 'node:fs/promises'
|
|
30
|
+
import { initPrerender } from '@uniweb/runtime/ssr'
|
|
31
|
+
import { FoundationResolutionError, CompileError } from './errors.js'
|
|
32
|
+
|
|
33
|
+
export async function importFoundation(resolvedPath) {
|
|
34
|
+
try {
|
|
35
|
+
return await import(pathToFileURL(resolvedPath).href)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new FoundationResolutionError(
|
|
38
|
+
`failed to import foundation at ${resolvedPath}\n` +
|
|
39
|
+
`cause: ${err.message}\n` +
|
|
40
|
+
`hint: confirm the foundation was built with @uniweb/build (dist/foundation.js)`
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function initOrchestrator({ content, foundation, extensions = [], onProgress } = {}) {
|
|
46
|
+
return initPrerender(content, foundation, extensions, { onProgress })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Convenience: import + init in one step. Returns the uniweb instance,
|
|
50
|
+
// or throws (the caller decides whether to surface as fatal or attached
|
|
51
|
+
// to the inspect dump).
|
|
52
|
+
export async function loadAndInit({ content, resolvedPath, extensions = [], onProgress } = {}) {
|
|
53
|
+
const foundation = await importFoundation(resolvedPath)
|
|
54
|
+
const uniweb = initOrchestrator({ content, foundation, extensions, onProgress })
|
|
55
|
+
return { foundation, uniweb }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Node-side loadAsset for the foundation's compile pipeline.
|
|
59
|
+
//
|
|
60
|
+
// Press's compileDocument threads a `loadAsset(src)` helper into each
|
|
61
|
+
// foundation's getOptions. In a browser host it defaults to fetch; in
|
|
62
|
+
// unipress (Node) we supply an fs-based implementation so config-level
|
|
63
|
+
// assets (cover images, banners, logos in document.yml) load without
|
|
64
|
+
// the foundation needing to branch on environment.
|
|
65
|
+
//
|
|
66
|
+
// Lookup order:
|
|
67
|
+
// 1. data: URL → decode in place.
|
|
68
|
+
// 2. Asset manifest hit (`website.assets[src]`) — the resolved entry
|
|
69
|
+
// carries an absolute filesystem path the framework's content
|
|
70
|
+
// collector populated. Read bytes via fs.
|
|
71
|
+
// 3. Absolute filesystem path on disk → read directly.
|
|
72
|
+
// 4. Otherwise: fail loudly. We don't fall back to fetch because
|
|
73
|
+
// unipress runs offline-by-default and a missing manifest entry
|
|
74
|
+
// almost always means a misspelled path in document.yml.
|
|
75
|
+
function createNodeLoadAsset(website) {
|
|
76
|
+
return async function loadAsset(src) {
|
|
77
|
+
if (!src || typeof src !== 'string') return null
|
|
78
|
+
|
|
79
|
+
if (src.startsWith('data:')) {
|
|
80
|
+
const comma = src.indexOf(',')
|
|
81
|
+
if (comma === -1) return null
|
|
82
|
+
const meta = src.slice(5, comma)
|
|
83
|
+
const data = src.slice(comma + 1)
|
|
84
|
+
if (meta.includes(';base64')) {
|
|
85
|
+
const bin = Buffer.from(data, 'base64')
|
|
86
|
+
return new Uint8Array(bin.buffer, bin.byteOffset, bin.byteLength)
|
|
87
|
+
}
|
|
88
|
+
return new TextEncoder().encode(decodeURIComponent(data))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entry = website?.assets?.[src]
|
|
92
|
+
const path = entry?.resolved || (src.startsWith('/') ? src : null)
|
|
93
|
+
if (!path) {
|
|
94
|
+
throw new CompileError(
|
|
95
|
+
`loadAsset: cannot resolve '${src}' in Node compile context\n` +
|
|
96
|
+
`hint: config-level asset paths must be discoverable by the framework's ` +
|
|
97
|
+
`content collector. Confirm the file exists relative to the document directory ` +
|
|
98
|
+
`(e.g. assets/${src.split('/').pop()}) and that document.yml references it ` +
|
|
99
|
+
`using the same path.`
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const buf = await readFile(path)
|
|
105
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new CompileError(
|
|
108
|
+
`loadAsset: failed to read '${src}' from ${path}\n` +
|
|
109
|
+
`cause: ${err.message}`
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Compile a populated Website into a Blob, using the foundation's
|
|
116
|
+
// bundled Press via the host-shareable compileDocument re-export.
|
|
117
|
+
// Throws CompileError if compileDocument isn't present — that means
|
|
118
|
+
// the foundation either doesn't depend on @uniweb/press, or was built
|
|
119
|
+
// with a pre-M8a @uniweb/build that only re-exported compileSubtree.
|
|
120
|
+
export async function compileDocumentWithFoundation(foundation, website, options = {}) {
|
|
121
|
+
if (typeof foundation?.compileDocument !== 'function') {
|
|
122
|
+
throw new CompileError(
|
|
123
|
+
`foundation does not expose compileDocument — cannot compile to '${options.format}'\n` +
|
|
124
|
+
`hint: foundation must import @uniweb/press and be rebuilt with a current @uniweb/build ` +
|
|
125
|
+
`(@uniweb/build@57498ef or later re-exports compileDocument alongside compileSubtree)`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const loadAsset = createNodeLoadAsset(website)
|
|
130
|
+
return await foundation.compileDocument(website, { ...options, foundation, loadAsset })
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Wrap Press's errors in CompileError so the CLI's top-level handler
|
|
133
|
+
// surfaces them with consistent formatting.
|
|
134
|
+
if (err instanceof CompileError) throw err
|
|
135
|
+
throw new CompileError(err.message || String(err))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read the foundation's outputs declaration from either the built or
|
|
140
|
+
// source shape. Used by compile.js for pre-compile validation and for
|
|
141
|
+
// reading the default extension per format.
|
|
142
|
+
export function getFoundationOutputs(foundation) {
|
|
143
|
+
if (!foundation) return null
|
|
144
|
+
return (
|
|
145
|
+
foundation.default?.capabilities?.outputs ??
|
|
146
|
+
foundation.default?.outputs ??
|
|
147
|
+
foundation.outputs ??
|
|
148
|
+
null
|
|
149
|
+
)
|
|
150
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Scaffold a content directory from a unipress template.
|
|
2
|
+
//
|
|
3
|
+
// Templates live on disk under `framework/unipress/documents/<name>/` and
|
|
4
|
+
// are embedded into the bundle via `src/templates-data.js` (generated by
|
|
5
|
+
// scripts/generate-templates-data.js). This module reads from the embedded
|
|
6
|
+
// map — so scaffolding works identically in Node dev and inside the
|
|
7
|
+
// bun-compiled binary (which has no access to the documents/ path).
|
|
8
|
+
//
|
|
9
|
+
// File entries come in two shapes:
|
|
10
|
+
// - text files: a plain string. Files with a `.hbs` suffix get
|
|
11
|
+
// Handlebars-processed (and the suffix stripped); other text files
|
|
12
|
+
// are copied verbatim.
|
|
13
|
+
// - binary files (PNG, PDF, fonts, archives, etc.): an object
|
|
14
|
+
// `{ encoding: 'base64', content: <base64> }`. The generator detects
|
|
15
|
+
// these by extension. Scaffold decodes and writes the bytes verbatim;
|
|
16
|
+
// no Handlebars pass.
|
|
17
|
+
//
|
|
18
|
+
// No package.json is written — unipress projects are content-only.
|
|
19
|
+
//
|
|
20
|
+
// Handlebars variables available in `.hbs` files:
|
|
21
|
+
// title, author, year, date, foundation
|
|
22
|
+
|
|
23
|
+
import { readdir, mkdir, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import Handlebars from 'handlebars'
|
|
27
|
+
import { TemplateError } from './errors.js'
|
|
28
|
+
import { TEMPLATES } from './templates-data.js'
|
|
29
|
+
|
|
30
|
+
export async function scaffold({ templateName, targetDir, vars, force = false, onProgress = () => {} }) {
|
|
31
|
+
const files = TEMPLATES[templateName]
|
|
32
|
+
if (!files) {
|
|
33
|
+
throw new TemplateError(
|
|
34
|
+
`template not found: '${templateName}'\n` +
|
|
35
|
+
`hint: run 'unipress list-templates' to see available templates`
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
if (existsSync(targetDir) && (await readdir(targetDir)).length > 0 && !force) {
|
|
39
|
+
throw new TemplateError(
|
|
40
|
+
`target directory is not empty: ${targetDir}\n` +
|
|
41
|
+
`hint: pick a different directory, or pass --force to overwrite`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
await mkdir(targetDir, { recursive: true })
|
|
45
|
+
|
|
46
|
+
for (const [relPath, entry] of Object.entries(files)) {
|
|
47
|
+
let dstRel = relPath
|
|
48
|
+
let dstContent
|
|
49
|
+
let label
|
|
50
|
+
if (entry !== null && typeof entry === 'object' && entry.encoding === 'base64') {
|
|
51
|
+
dstContent = Buffer.from(entry.content, 'base64')
|
|
52
|
+
label = 'extracted'
|
|
53
|
+
} else if (relPath.endsWith('.hbs')) {
|
|
54
|
+
dstRel = relPath.slice(0, -'.hbs'.length)
|
|
55
|
+
dstContent = Handlebars.compile(entry, { noEscape: true })(vars)
|
|
56
|
+
label = 'rendered'
|
|
57
|
+
} else {
|
|
58
|
+
dstContent = entry
|
|
59
|
+
label = 'copied'
|
|
60
|
+
}
|
|
61
|
+
const dstPath = join(targetDir, dstRel)
|
|
62
|
+
await mkdir(dirname(dstPath), { recursive: true })
|
|
63
|
+
await writeFile(dstPath, dstContent)
|
|
64
|
+
onProgress(`${label} ${dstRel}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Write a compileSubtree Blob to the local filesystem.
|
|
2
|
+
//
|
|
3
|
+
// Today all Press format adapters (docx, xlsx, typst source bundle) return
|
|
4
|
+
// a single Blob. Writing it to disk is one line of glue — the sink lives
|
|
5
|
+
// in its own module because the four-step compile pattern (gather → tree
|
|
6
|
+
// → compile → sink) keeps the sink decoupled, and future hosts (HTTP
|
|
7
|
+
// response, blob storage) slot in here as siblings.
|
|
8
|
+
|
|
9
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { dirname } from 'node:path'
|
|
11
|
+
import { OutputWriteError } from '../errors.js'
|
|
12
|
+
|
|
13
|
+
export async function writeBlobToFile(blob, outPath) {
|
|
14
|
+
const buf = Buffer.from(await blob.arrayBuffer())
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(dirname(outPath), { recursive: true })
|
|
17
|
+
await writeFile(outPath, buf)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new OutputWriteError(
|
|
20
|
+
`failed to write output to ${outPath}\n` +
|
|
21
|
+
`cause: ${err.message}`
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
return { outPath, bytes: buf.length }
|
|
25
|
+
}
|