@opentf/web-cli 1.0.0 → 1.1.0

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/src/shared.js CHANGED
@@ -5,13 +5,15 @@
5
5
  // `otfwc` IR compiler as a Rolldown `transform` plugin, and let Rolldown link the
6
6
  // module graph. This module holds everything they have in common.
7
7
 
8
- import { existsSync, readdirSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
- import { fileURLToPath } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+
12
+ import { build } from "rolldown";
11
13
 
12
14
  import { otfwcPath } from "@opentf/web-compiler";
13
15
 
14
- export const EXTENSIONS = [".jsx", ".tsx", ".js", ".ts"];
16
+ export const EXTENSIONS = [".jsx", ".tsx", ".js", ".ts", ".mdx", ".md"];
15
17
 
16
18
  export const MIME = {
17
19
  css: "text/css",
@@ -24,6 +26,74 @@ export const MIME = {
24
26
  woff2: "font/woff2",
25
27
  };
26
28
 
29
+ /** Fallback HTML shell used when a project has no `index.html`. */
30
+ export const DEFAULT_HTML_SHELL =
31
+ `<!doctype html><html lang="en"><head>\n` +
32
+ `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">\n` +
33
+ `<title>OTF Web</title></head><body><div id="app"></div></body></html>`;
34
+
35
+ // The project's own module entry <script> is stripped so the toolchain injects
36
+ // its own bundle in its place.
37
+ const MODULE_ENTRY_RE = /<script\s+type=["']module["'][^>]*src=[^>]*>\s*<\/script>\s*/gi;
38
+
39
+ /**
40
+ * The project's `index.html` shell (or {@link DEFAULT_HTML_SHELL}) with the app's
41
+ * module entry script removed — the common starting point for `dev` and `build`.
42
+ */
43
+ export function readHtmlShell(root) {
44
+ const indexPath = join(root, "index.html");
45
+ const html = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : DEFAULT_HTML_SHELL;
46
+ return html.replace(MODULE_ENTRY_RE, "");
47
+ }
48
+
49
+ /** Inject `snippet` just before `</body>` (or append when there is none). */
50
+ export function injectBeforeBody(html, snippet) {
51
+ // Function replacer so `$` in `snippet` is literal (String.replace treats `$1`,
52
+ // `$&`, … in a string replacement as back-references — e.g. a "$189.00" price).
53
+ return html.includes("</body>") ? html.replace("</body>", () => `${snippet}</body>`) : html + snippet;
54
+ }
55
+
56
+ /** Inject pre-rendered markup into the shell's empty `#app` container. */
57
+ export function injectMarkup(shellHtml, markup) {
58
+ // Function replacer: `markup` may contain `$` (currency, etc.) — keep it literal.
59
+ return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, (_m, open, close) => `${open}${markup}${close}`);
60
+ }
61
+
62
+ /**
63
+ * Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
64
+ * the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
65
+ * it (`runtime/router.js` `mountApp`). Used when the client bundle was built for the
66
+ * hydrate target and there is server markup to adopt (SSR, and SSG pre-render).
67
+ * Idempotent — a no-op if the sentinel is already present.
68
+ */
69
+ export function stampHydrateSentinel(shellHtml) {
70
+ return shellHtml.replace(/<div id="app"([^>]*)>/, (m, attrs) =>
71
+ /\bdata-otfw-hydrate\b/.test(attrs) ? m : `<div id="app"${attrs} data-otfw-hydrate>`,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Inject per-route `<head>` tags before `</head>`, dropping the shell's default
77
+ * `<title>` when the route supplies its own (so each page gets a unique,
78
+ * non-duplicated title). Shared by SSG pre-render and the SSR server.
79
+ */
80
+ export function injectHead(shellHtml, headHtml) {
81
+ if (!headHtml) return shellHtml;
82
+ let out = shellHtml;
83
+ if (/<title[\s>]/i.test(headHtml)) out = out.replace(/<title>[\s\S]*?<\/title>\s*/i, "");
84
+ // Function replacer so `$` in `headHtml` (e.g. a price in a meta description) is literal.
85
+ return out.replace(/<\/head>/i, () => `${headHtml}\n</head>`);
86
+ }
87
+
88
+ /** Set/replace the shell's `<html lang>` for a localized page (i18n; docs/I18N.md §6). */
89
+ export function withHtmlLang(shellHtml, locale) {
90
+ if (!locale) return shellHtml;
91
+ if (/<html[^>]*\slang=/i.test(shellHtml)) {
92
+ return shellHtml.replace(/(<html[^>]*\slang=)(["'])[^"']*\2/i, `$1$2${locale}$2`);
93
+ }
94
+ return shellHtml.replace(/<html\b/i, `<html lang="${locale}"`);
95
+ }
96
+
27
97
  /** Nearest ancestor directory of `from` (inclusive) that contains `name`. */
28
98
  export function findUp(name, from) {
29
99
  let dir = from;
@@ -79,10 +149,10 @@ export function loadProject() {
79
149
  }
80
150
  }
81
151
 
82
- // forms-demo depends on @opentf/web-form, which is not yet ported to the new
83
- // runtime (it lands with the Project Graph). Override with EXCLUDE_ROUTES.
152
+ // Route directories to skip during discovery comma-separated names in
153
+ // EXCLUDE_ROUTES. None are excluded by default.
84
154
  const exclude = new Set(
85
- (process.env.EXCLUDE_ROUTES ?? "forms-demo").split(",").filter(Boolean),
155
+ (process.env.EXCLUDE_ROUTES ?? "").split(",").filter(Boolean),
86
156
  );
87
157
 
88
158
  return { root, appDir, webEntry, otfwc, workspace, exclude };
@@ -100,6 +170,148 @@ function ensureCompiler(otfwc, workspace) {
100
170
  if (b.exitCode !== 0) process.exit(b.exitCode);
101
171
  }
102
172
 
173
+ /**
174
+ * Load the optional project config (`otfw.config.{json,js,mjs}`) as a plain object.
175
+ * Read by the build for the site URL (SEO) and the `docs` block (docs generator).
176
+ */
177
+ export async function loadConfig(root) {
178
+ const json = join(root, "otfw.config.json");
179
+ if (existsSync(json)) {
180
+ try {
181
+ return JSON.parse(readFileSync(json, "utf8")) ?? {};
182
+ } catch (e) {
183
+ console.warn(`⚠ could not parse otfw.config.json: ${e?.message ?? e}`);
184
+ }
185
+ }
186
+ for (const name of ["otfw.config.js", "otfw.config.mjs"]) {
187
+ const p = join(root, name);
188
+ if (existsSync(p)) {
189
+ try {
190
+ return (await import(pathToFileURL(p).href)).default ?? {};
191
+ } catch (e) {
192
+ console.warn(`⚠ could not load ${name}: ${e?.message ?? e}`);
193
+ }
194
+ }
195
+ }
196
+ return {};
197
+ }
198
+
199
+ /**
200
+ * The docs navigation Rolldown plugin, when the project opts into the docs
201
+ * generator (a `docs` block in otfw.config). Resolved from `@opentf/web-docs`
202
+ * (the app's own dependency); returns null when docs aren't configured or the
203
+ * package isn't installed, so the core toolchain stays untouched for normal apps.
204
+ */
205
+ export async function loadDocsPlugins(root, appDir, config, exclude = new Set()) {
206
+ const docs = config?.docs;
207
+ const blog = config?.blog;
208
+ if (!docs && !blog) return [];
209
+ try {
210
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
211
+ const { docsNavPlugin, blogPostsPlugin, lastUpdatedPlugin } = await import(
212
+ pathToFileURL(entry).href
213
+ );
214
+ const plugins = [];
215
+ // Resolves `@opentf/web-docs/nav` to a section map — one generated tree per top-level
216
+ // folder under app/. Any folder with a DocsLayout becomes a section automatically.
217
+ if (docs) plugins.push(docsNavPlugin({ appDir, exclude }));
218
+ // Resolves `@opentf/web-docs/posts` to the generated post list.
219
+ if (blog) plugins.push(blogPostsPlugin({ appDir, contentDir: blog.dir ?? "blog", exclude }));
220
+ // Resolves `@opentf/web-docs/updated` to the per-page last-updated map (every route),
221
+ // when last-updated is turned on anywhere.
222
+ if (wantsLastUpdated(config)) plugins.push(lastUpdatedPlugin({ appDir, exclude }));
223
+ return plugins;
224
+ } catch (e) {
225
+ console.warn(
226
+ `⚠ docs/blog config present but @opentf/web-docs could not be loaded: ${e?.message ?? e}`,
227
+ );
228
+ return [];
229
+ }
230
+ }
231
+
232
+ /** Whether "last updated" tracking is enabled anywhere (`docs`/`blog` `lastUpdated`). */
233
+ export function wantsLastUpdated(config) {
234
+ return Boolean(config?.docs?.lastUpdated || config?.blog?.lastUpdated);
235
+ }
236
+
237
+ /**
238
+ * Build the `{ [routePath]: ISO }` last-updated map for every page (the same map the
239
+ * `@opentf/web-docs/updated` virtual module exposes). Used by the SSG step to emit
240
+ * `article:modified_time`. Returns `{}` when last-updated is off or the package can't
241
+ * be loaded.
242
+ */
243
+ export async function runLastUpdated(root, appDir, config, exclude = new Set()) {
244
+ if (!wantsLastUpdated(config)) return {};
245
+ try {
246
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
247
+ const { loadLastUpdated } = await import(pathToFileURL(entry).href);
248
+ return loadLastUpdated({ appDir, exclude });
249
+ } catch (e) {
250
+ console.warn(`⚠ last-updated map skipped: ${e?.message ?? e}`);
251
+ return {};
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Run Pagefind over the built site when the docs config opts into it
257
+ * (`docs.search.provider === "pagefind"`). Indexes the pre-rendered HTML in `siteDir`
258
+ * and writes `<siteDir>/pagefind/`. No-op (returns null) otherwise. Resolved from the
259
+ * app's `@opentf/web-docs` so the hook ships with the docs package.
260
+ */
261
+ export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
262
+ if (config?.docs?.search?.provider !== "pagefind") return null;
263
+ try {
264
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
265
+ const { indexWithPagefind } = await import(pathToFileURL(entry).href);
266
+ return await indexWithPagefind({ siteDir, onProgress });
267
+ } catch (e) {
268
+ console.warn(`⚠ Pagefind indexing skipped: ${e?.message ?? e}`);
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Generate the blog RSS feed when the project has a `blog` config and a base URL.
275
+ * Scans the post folders, renders RSS 2.0, and writes `<siteDir>/<blogDir>/rss.xml`.
276
+ * A project-supplied `public/<blogDir>/rss.xml` takes precedence (it overwrites ours
277
+ * on the public/ copy). Returns `{ path, url, count }` or null (no blog / no base URL
278
+ * / package missing). Resolved from the app's `@opentf/web-docs`.
279
+ */
280
+ export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
281
+ const blog = config?.blog;
282
+ if (!blog) return null;
283
+ if (!baseUrl) {
284
+ console.warn("⚠ blog RSS feed skipped: no site URL (pass --base-url or set otfw.config)");
285
+ return null;
286
+ }
287
+ const contentDir = blog.dir ?? "blog";
288
+ if (existsSync(join(root, "public", contentDir, "rss.xml"))) return null; // honor override
289
+ try {
290
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
291
+ const { loadPosts, renderBlogFeed } = await import(pathToFileURL(entry).href);
292
+ const posts = loadPosts({ appDir, contentDir, exclude });
293
+ const feedPath = `/${contentDir}/rss.xml`;
294
+ const title = blog.title || (config?.docs?.title ? `${config.docs.title} Blog` : "Blog");
295
+ const xml = renderBlogFeed({
296
+ posts,
297
+ baseUrl,
298
+ feedPath,
299
+ channel: {
300
+ title,
301
+ description: blog.description || title,
302
+ link: `/${contentDir}`,
303
+ },
304
+ });
305
+ const out = join(siteDir, contentDir, "rss.xml");
306
+ mkdirSync(dirname(out), { recursive: true });
307
+ writeFileSync(out, xml);
308
+ return { path: feedPath, url: baseUrl.replace(/\/+$/, "") + feedPath, count: posts.length };
309
+ } catch (e) {
310
+ console.warn(`⚠ blog RSS feed skipped: ${e?.message ?? e}`);
311
+ return null;
312
+ }
313
+ }
314
+
103
315
  /** Discover file-based routes under `app/`: every page/layout and the 404. */
104
316
  export function discoverPages(dir, exclude) {
105
317
  const out = [];
@@ -107,7 +319,7 @@ export function discoverPages(dir, exclude) {
107
319
  if (entry.isDirectory() && exclude.has(entry.name)) continue;
108
320
  const full = `${dir}/${entry.name}`;
109
321
  if (entry.isDirectory()) out.push(...discoverPages(full, exclude));
110
- else if (/^(page|layout|404)\.[jt]sx$/.test(entry.name)) out.push(full);
322
+ else if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(entry.name)) out.push(full);
111
323
  }
112
324
  return out;
113
325
  }
@@ -122,20 +334,192 @@ export function findGuard(appDir) {
122
334
  /**
123
335
  * The app entry source: hand `mountApp` a route map of lazy `() => import()`
124
336
  * loaders (so each route code-splits into its own chunk) plus the optional guard.
337
+ *
338
+ * `loaderUrl(filePath)` maps each route file to the specifier its loader imports.
339
+ * The production build imports the file directly (Rolldown code-splits it); the dev
340
+ * server passes a `/__route/…` URL so the route compiles on first navigation.
125
341
  */
126
- export function entrySource(pages, appDir) {
342
+ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
127
343
  const map = pages
128
- .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(p)}),`)
344
+ .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
129
345
  .join("\n");
130
346
  const guard = findGuard(appDir);
347
+ // Thread the i18n config (otfw.config) into `mountApp` so the client router knows
348
+ // the locales and `router.locale` resolves from the URL prefix (docs/I18N.md §6).
349
+ const i18nOpt =
350
+ i18n && Array.isArray(i18n.locales) && i18n.locales.length
351
+ ? `\n i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })},`
352
+ : "";
353
+ // Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
354
+ // (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
355
+ const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
131
356
  return (
132
357
  `import { mountApp } from "@opentf/web";\n` +
133
358
  (guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
134
359
  `mountApp({\n pages: {\n${map}\n },\n` +
135
- ` target: document.getElementById("app"),${guard ? "\n guard," : ""}\n});\n`
360
+ ` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
136
361
  );
137
362
  }
138
363
 
364
+ /**
365
+ * Crawl the module graph via `otfwc graph` and return a queryable handle. Used by
366
+ * the dev server to invalidate precisely: `affected(file)` is every module that
367
+ * transitively imports `file` (so editing it rebuilds exactly those route chunks).
368
+ * `roots` are the entries to crawl from (the route/layout files + the runtime).
369
+ */
370
+ export async function moduleGraph(otfwc, webEntry, roots) {
371
+ const proc = Bun.spawn([otfwc, "graph", `--web=${webEntry}`, ...roots], {
372
+ stdout: "pipe",
373
+ stderr: "pipe",
374
+ });
375
+ proc.unref(); // a short read-only crawl — never let it hold up shutdown
376
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
377
+ if (exitCode !== 0) {
378
+ console.error(`✗ otfwc graph failed:\n${await new Response(proc.stderr).text()}`);
379
+ return { affected: () => new Set(), has: () => false };
380
+ }
381
+ let modules = [];
382
+ try {
383
+ modules = JSON.parse(stdout).modules;
384
+ } catch {
385
+ return { affected: () => new Set(), has: () => false };
386
+ }
387
+ // Reverse adjacency: target → importers, for transitive-dependents queries.
388
+ const importers = new Map();
389
+ for (const m of modules) {
390
+ for (const dep of m.deps) {
391
+ if (dep.external) continue;
392
+ if (!importers.has(dep.target)) importers.set(dep.target, []);
393
+ importers.get(dep.target).push(m.id);
394
+ }
395
+ }
396
+ const ids = new Set(modules.map((m) => m.id));
397
+ return {
398
+ has: (id) => ids.has(id),
399
+ affected(file) {
400
+ const out = new Set();
401
+ const queue = [file];
402
+ while (queue.length) {
403
+ const id = queue.shift();
404
+ if (out.has(id)) continue;
405
+ out.add(id);
406
+ for (const importer of importers.get(id) || []) {
407
+ if (!out.has(importer)) queue.push(importer);
408
+ }
409
+ }
410
+ return out;
411
+ },
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Start a long-lived `otfwc serve` process and talk to it over a framed
417
+ * stdin/stdout protocol (see crates/otfw_cli/src/main.rs `serve`). One process
418
+ * compiles every module, so the toolchain pays the binary-startup cost once
419
+ * instead of spawning a subprocess per file — the dominant dev-server cost.
420
+ *
421
+ * `compile(id, source, component, target)` resolves to the emitted JS or rejects
422
+ * with the compiler diagnostic (`target` is `"csr"` | `"ssg"` | `"hydrate"`).
423
+ * Requests are serialized through a FIFO queue: the server
424
+ * is single-threaded, replies arrive in request order, so the head of the queue
425
+ * always pairs with the next frame. The child is killed when this process exits.
426
+ */
427
+ export function startCompilerServer(otfwc) {
428
+ const proc = Bun.spawn([otfwc, "serve"], {
429
+ stdin: "pipe",
430
+ stdout: "pipe",
431
+ stderr: "inherit",
432
+ });
433
+ // Don't let the long-lived child keep the event loop open: a one-shot `otfw build`
434
+ // must exit once the bundle is written (the dev server stays alive on its own via
435
+ // `Bun.serve`). The `exit` handler below still tears the child down, and the child
436
+ // also sees EOF on its stdin pipe when we go.
437
+ proc.unref();
438
+ const reader = proc.stdout.getReader();
439
+ const enc = new TextEncoder();
440
+ const dec = new TextDecoder();
441
+ const queue = []; // { resolve, reject } in request order
442
+ let buf = new Uint8Array(0);
443
+ let pumping = false;
444
+ let dead = false;
445
+
446
+ const append = (a, b) => {
447
+ const out = new Uint8Array(a.length + b.length);
448
+ out.set(a);
449
+ out.set(b, a.length);
450
+ return out;
451
+ };
452
+ const die = (err) => {
453
+ dead = true;
454
+ while (queue.length) queue.shift().reject(err);
455
+ };
456
+
457
+ // Drain reply frames as they arrive, resolving queued requests in order. A frame
458
+ // is `<status> <byteLen>\n` followed by exactly `byteLen` bytes of payload.
459
+ async function pump() {
460
+ if (pumping) return;
461
+ pumping = true;
462
+ try {
463
+ while (queue.length) {
464
+ let nl = buf.indexOf(10);
465
+ while (nl === -1) {
466
+ const { value, done } = await reader.read();
467
+ if (done) return die(new Error("otfwc serve exited"));
468
+ buf = append(buf, value);
469
+ nl = buf.indexOf(10);
470
+ }
471
+ const [status, lenStr] = dec.decode(buf.subarray(0, nl)).split(" ");
472
+ const len = Number(lenStr);
473
+ while (buf.length < nl + 1 + len) {
474
+ const { value, done } = await reader.read();
475
+ if (done) return die(new Error("otfwc serve exited"));
476
+ buf = append(buf, value);
477
+ }
478
+ const payload = dec.decode(buf.subarray(nl + 1, nl + 1 + len));
479
+ buf = buf.slice(nl + 1 + len);
480
+ const job = queue.shift();
481
+ if (status === "OK") job.resolve(payload);
482
+ else job.reject(new Error(payload));
483
+ }
484
+ } finally {
485
+ pumping = false;
486
+ }
487
+ }
488
+
489
+ function compile(id, source, component, target = "csr") {
490
+ if (dead) return Promise.reject(new Error("otfwc serve is not running"));
491
+ return new Promise((resolve, reject) => {
492
+ queue.push({ resolve, reject });
493
+ const idB = enc.encode(id);
494
+ const srcB = enc.encode(source);
495
+ proc.stdin.write(enc.encode(`${idB.length} ${srcB.length} ${component ? 1 : 0} ${target}\n`));
496
+ proc.stdin.write(idB);
497
+ proc.stdin.write(srcB);
498
+ proc.stdin.flush();
499
+ pump();
500
+ });
501
+ }
502
+
503
+ const close = () => {
504
+ if (dead) return;
505
+ dead = true;
506
+ try {
507
+ proc.stdin.end();
508
+ } catch {}
509
+ try {
510
+ proc.kill();
511
+ } catch {}
512
+ };
513
+ // Clean up the child whenever this process exits — for any reason and at any exit
514
+ // code. We deliberately don't trap SIGINT/SIGTERM here: a helper shouldn't dictate
515
+ // the whole process's exit code (that's the command's call), and the child also
516
+ // receives the terminal's group signal directly. The `exit` hook is enough to avoid
517
+ // leaking it. (One-shot builds exit when done; the dev server until it's stopped.)
518
+ process.once("exit", close);
519
+
520
+ return { compile, close };
521
+ }
522
+
139
523
  /**
140
524
  * Rolldown plugin: compile `.jsx`/`.tsx` through the `otfwc` IR compiler. Page /
141
525
  * layout / 404 modules become factories; everything else a Custom Element. On a
@@ -143,23 +527,27 @@ export function entrySource(pages, appDir) {
143
527
  * build) unless `failOnError` is set (production builds should fail loudly).
144
528
  * `onResult(id, errorMessageOrNull)` is called per module so the dev server can
145
529
  * push compile diagnostics to the error overlay and clear them once fixed.
530
+ *
531
+ * Compilation runs through one persistent `otfwc serve` process per plugin instance
532
+ * (see `startCompilerServer`). `target` picks the codegen backend: `"csr"` (the live
533
+ * DOM build), `"ssg"` (HTML-string renderers), or `"hydrate"` (the dual module — a
534
+ * CSR build factory plus an adopt factory for first-paint hydration).
146
535
  */
147
536
  export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr" } = {}) {
537
+ const server = startCompilerServer(otfwc);
148
538
  return {
149
539
  name: "otfw",
150
- transform(code, id) {
151
- if (!/\.[jt]sx$/.test(id)) return null;
152
- const base = id.split("/").pop().replace(/\.[jt]sx$/, "");
540
+ async transform(code, id) {
541
+ if (!/\.(mdx|md|[jt]sx)$/.test(id)) return null;
542
+ const base = id.split("/").pop().replace(/\.(mdx|md|[jt]sx)$/, "");
153
543
  const isPage = base === "page" || base === "layout" || base === "404";
154
- const args = ["build"];
155
- if (!isPage) args.push("--component");
156
- if (target === "ssg") args.push("--target=ssg");
157
- args.push("--stdin", id);
158
- const proc = Bun.spawnSync([otfwc, ...args], {
159
- stdin: new TextEncoder().encode(code),
160
- });
161
- if (proc.exitCode !== 0) {
162
- const msg = proc.stderr.toString();
544
+ try {
545
+ const out = await server.compile(id, code, !isPage, target);
546
+ onResult?.(id, null);
547
+ // Side effects (e.g. customElements.define) must survive bundling.
548
+ return { code: out, moduleSideEffects: true };
549
+ } catch (e) {
550
+ const msg = e?.message ?? String(e);
163
551
  onResult?.(id, msg);
164
552
  if (failOnError) {
165
553
  this.error(`otfwc failed for ${id}:\n${msg}`);
@@ -172,9 +560,6 @@ export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr
172
560
  ` return pre; }`;
173
561
  return { code: stub, moduleSideEffects: true };
174
562
  }
175
- onResult?.(id, null);
176
- // Side effects (e.g. customElements.define) must survive bundling.
177
- return { code: proc.stdout.toString(), moduleSideEffects: true };
178
563
  },
179
564
  };
180
565
  }
@@ -200,6 +585,78 @@ export function cssPlugin() {
200
585
  };
201
586
  }
202
587
 
588
+ // Generated server entry: eager-import every page module (so `registerRoutes`
589
+ // sees real namespaces, enabling `getStaticPaths`) and re-export the render API.
590
+ // Shared by the SSG pre-render and the SSR server — both render through the same
591
+ // SSG-compiled bundle (ARCHITECTURE.md §6: "SSR … shares the SSG path").
592
+ export function serverEntrySource(pages, i18n = null) {
593
+ const imports = pages.map((p, i) => `import * as p${i} from ${JSON.stringify(p)};`).join("\n");
594
+ const map = pages.map((p, i) => ` [${JSON.stringify(p)}]: p${i},`).join("\n");
595
+ // i18n: the server-side router needs the locales too — so renderRoute can strip
596
+ // a `/fr/...` prefix to match the route table and set router.locale for `t()`
597
+ // (docs/I18N.md §6). Without this the prefix wouldn't match and pages would
598
+ // render in the default locale (or 404).
599
+ const i18nOn = i18n && Array.isArray(i18n.locales) && i18n.locales.length;
600
+ const named = i18nOn ? "registerRoutes, configureI18n" : "registerRoutes";
601
+ const i18nCall = i18nOn
602
+ ? `configureI18n(${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })});\n`
603
+ : "";
604
+ return (
605
+ `${imports}\n` +
606
+ `import { ${named} } from "@opentf/web";\n` +
607
+ `export { renderRoute, renderHead, collectRoutePaths } from "@opentf/web/server";\n` +
608
+ i18nCall +
609
+ `registerRoutes({\n${map}\n});\n`
610
+ );
611
+ }
612
+
613
+ /**
614
+ * Build the server render bundle (the compiler's SSG backend — HTML-string
615
+ * renderers) and import it. Returns `{ mod, cleanup }`, where `mod` exposes
616
+ * `renderRoute` / `renderHead` / `collectRoutePaths` and `cleanup()` removes the
617
+ * temp build dir. The SSG pre-render calls `cleanup()` immediately after rendering
618
+ * every route; the SSR server keeps the module live and cleans up on shutdown.
619
+ */
620
+ export async function buildServerBundle({
621
+ root,
622
+ pages,
623
+ webEntry,
624
+ otfwc,
625
+ docsPlugins = [],
626
+ i18n = null,
627
+ onCompile,
628
+ tmpName = ".otfw-ssg",
629
+ }) {
630
+ const tmp = join(root, tmpName);
631
+ mkdirSync(tmp, { recursive: true });
632
+ const entry = join(tmp, "ssg-entry.js");
633
+ writeFileSync(entry, serverEntrySource(pages, i18n));
634
+
635
+ const serverApi = join(dirname(webEntry), "server", "index.js");
636
+ await build({
637
+ input: entry,
638
+ resolve: {
639
+ alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
640
+ extensions: EXTENSIONS,
641
+ },
642
+ plugins: [
643
+ ...docsPlugins,
644
+ otfwPlugin(otfwc, { failOnError: true, target: "ssg", onResult: (id) => onCompile?.(id) }),
645
+ cssPlugin(),
646
+ ],
647
+ output: { dir: join(tmp, "out"), format: "esm", entryFileNames: "server.js" },
648
+ checks: { pluginTimings: false },
649
+ });
650
+
651
+ // The runtime defines `class … extends HTMLElement` at load (for CSR custom
652
+ // elements). Server render never instantiates them, but the base class must
653
+ // exist so the class definitions evaluate. A bare stub suffices — no DOM
654
+ // (customElements stays undefined, so elements self-register only in the browser).
655
+ globalThis.HTMLElement ??= class {};
656
+ const mod = await import(pathToFileURL(join(tmp, "out", "server.js")).href);
657
+ return { mod, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
658
+ }
659
+
203
660
  function fail(msg) {
204
661
  console.error(`✗ ${msg}`);
205
662
  process.exit(1);