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