@opentf/web-cli 1.0.0 → 1.2.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,88 @@ 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
+ * Embed the island hydration payload (`renderRoute().hydration`) as a
64
+ * `<script type="application/json" id="__otfw_h">` the client reads at upgrade so
65
+ * components resume from rich JS props (compiler-driven data hydration). Placed before
66
+ * `</body>`; a deferred module bundle runs after parse, so the payload is always in the
67
+ * DOM first. `json` is already `<`-escaped by the server collector, so it can't break out
68
+ * of the script. No-op for an empty payload. Uses a function replacer so `$` in the JSON
69
+ * stays literal.
70
+ */
71
+ export function injectHydrationData(shellHtml, json) {
72
+ if (!json) return shellHtml;
73
+ return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_h">${json}</script>`);
74
+ }
75
+
76
+ /**
77
+ * Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
78
+ * the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
79
+ * it (`runtime/router.js` `mountApp`). Used when the client bundle was built for the
80
+ * hydrate target and there is server markup to adopt (SSR, and SSG pre-render).
81
+ * Idempotent — a no-op if the sentinel is already present.
82
+ */
83
+ export function stampHydrateSentinel(shellHtml) {
84
+ return shellHtml.replace(/<div id="app"([^>]*)>/, (m, attrs) =>
85
+ /\bdata-otfw-hydrate\b/.test(attrs) ? m : `<div id="app"${attrs} data-otfw-hydrate>`,
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Inject per-route `<head>` tags before `</head>`, dropping the shell's default
91
+ * `<title>` when the route supplies its own (so each page gets a unique,
92
+ * non-duplicated title). Shared by SSG pre-render and the SSR server.
93
+ */
94
+ export function injectHead(shellHtml, headHtml) {
95
+ if (!headHtml) return shellHtml;
96
+ let out = shellHtml;
97
+ if (/<title[\s>]/i.test(headHtml)) out = out.replace(/<title>[\s\S]*?<\/title>\s*/i, "");
98
+ // Function replacer so `$` in `headHtml` (e.g. a price in a meta description) is literal.
99
+ return out.replace(/<\/head>/i, () => `${headHtml}\n</head>`);
100
+ }
101
+
102
+ /** Set/replace the shell's `<html lang>` for a localized page (i18n; docs/I18N.md §6). */
103
+ export function withHtmlLang(shellHtml, locale) {
104
+ if (!locale) return shellHtml;
105
+ if (/<html[^>]*\slang=/i.test(shellHtml)) {
106
+ return shellHtml.replace(/(<html[^>]*\slang=)(["'])[^"']*\2/i, `$1$2${locale}$2`);
107
+ }
108
+ return shellHtml.replace(/<html\b/i, `<html lang="${locale}"`);
109
+ }
110
+
27
111
  /** Nearest ancestor directory of `from` (inclusive) that contains `name`. */
28
112
  export function findUp(name, from) {
29
113
  let dir = from;
@@ -35,10 +119,45 @@ export function findUp(name, from) {
35
119
  }
36
120
  }
37
121
 
122
+ function hasLocalCompilerWorkspace(workspace) {
123
+ return !!workspace && existsSync(join(workspace, "crates", "otfw_cli", "Cargo.toml"));
124
+ }
125
+
126
+ export function resolveCompiler({
127
+ cliDir = dirname(fileURLToPath(import.meta.url)),
128
+ env = process.env,
129
+ resolvePackagedCompiler = otfwcPath,
130
+ findWorkspace = findUp,
131
+ ensure = ensureCompiler,
132
+ } = {}) {
133
+ if (env.OTFWC_BIN) return { otfwc: env.OTFWC_BIN, workspace: null };
134
+
135
+ let packagedError = null;
136
+ try {
137
+ return { otfwc: resolvePackagedCompiler(), workspace: null };
138
+ } catch (e) {
139
+ packagedError = e;
140
+ }
141
+
142
+ const installedPackage = cliDir.split(/[\\/]/).includes("node_modules");
143
+ const workspace = installedPackage ? null : findWorkspace("Cargo.toml", cliDir);
144
+ if (hasLocalCompilerWorkspace(workspace)) {
145
+ const otfwc = join(workspace, "target", "debug", "otfwc");
146
+ ensure(otfwc, workspace);
147
+ return { otfwc, workspace };
148
+ }
149
+
150
+ fail(
151
+ `${packagedError?.message ?? "cannot resolve @opentf/web-compiler prebuilt binary"}\n` +
152
+ ` No local otfwc compiler workspace was found to build from source.`,
153
+ );
154
+ }
155
+
38
156
  /**
39
157
  * Resolve the project and toolchain: the app being built (cwd), the runtime
40
158
  * package, the `otfwc` compiler, and the excluded routes. Exits with a clear
41
- * message on any hard failure. Builds the compiler on demand from the workspace.
159
+ * message on any hard failure. Source checkouts can build the compiler on demand
160
+ * from this repo's Cargo workspace; published installs use @opentf/web-compiler.
42
161
  */
43
162
  export function loadProject() {
44
163
  const root = process.cwd();
@@ -59,30 +178,14 @@ export function loadProject() {
59
178
  fail(`cannot resolve "@opentf/web" from ${root}\n add it to your dependencies.`);
60
179
  }
61
180
 
62
- // Locate the `otfwc` compiler. Published: the prebuilt binary from `@opentf/web-compiler`
63
- // (a dependency of this CLI). In this repo's own dev (a Cargo workspace is found
64
- // above the CLI): the cargo `target/debug` build, rebuilt on demand. `OTFWC_BIN`
65
- // overrides both.
66
- const cliDir = dirname(fileURLToPath(import.meta.url));
67
- const workspace = findUp("Cargo.toml", cliDir);
68
- let otfwc;
69
- if (process.env.OTFWC_BIN) {
70
- otfwc = process.env.OTFWC_BIN;
71
- } else if (workspace) {
72
- otfwc = join(workspace, "target", "debug", "otfwc");
73
- ensureCompiler(otfwc, workspace);
74
- } else {
75
- try {
76
- otfwc = otfwcPath();
77
- } catch (e) {
78
- fail(e.message);
79
- }
80
- }
181
+ // Locate the `otfwc` compiler. Explicit overrides win, then the packaged
182
+ // compiler binary, then this repo's local Rust workspace as a source fallback.
183
+ const { otfwc, workspace } = resolveCompiler();
81
184
 
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.
185
+ // Route directories to skip during discovery comma-separated names in
186
+ // EXCLUDE_ROUTES. None are excluded by default.
84
187
  const exclude = new Set(
85
- (process.env.EXCLUDE_ROUTES ?? "forms-demo").split(",").filter(Boolean),
188
+ (process.env.EXCLUDE_ROUTES ?? "").split(",").filter(Boolean),
86
189
  );
87
190
 
88
191
  return { root, appDir, webEntry, otfwc, workspace, exclude };
@@ -92,6 +195,19 @@ function ensureCompiler(otfwc, workspace) {
92
195
  if (existsSync(otfwc)) return;
93
196
  if (!workspace) fail(`otfwc compiler not found at ${otfwc}`);
94
197
  console.log("building compiler (cargo build -p otfw_cli)…");
198
+ const cargo = Bun.spawnSync(["cargo", "--version"], {
199
+ cwd: workspace,
200
+ stdout: "ignore",
201
+ stderr: "pipe",
202
+ });
203
+ if (cargo.error || cargo.exitCode !== 0) {
204
+ fail(
205
+ `cannot build otfwc because cargo is not available.\n` +
206
+ ` This source checkout needs Rust/Cargo to build ${otfwc}.\n` +
207
+ ` Install Rust in the build environment, set OTFWC_BIN to an existing otfwc binary,\n` +
208
+ ` or build with the published @opentf/web-cli package so @opentf/web-compiler can use its prebuilt binary.`,
209
+ );
210
+ }
95
211
  const b = Bun.spawnSync(["cargo", "build", "-p", "otfw_cli"], {
96
212
  cwd: workspace,
97
213
  stdout: "inherit",
@@ -100,6 +216,191 @@ function ensureCompiler(otfwc, workspace) {
100
216
  if (b.exitCode !== 0) process.exit(b.exitCode);
101
217
  }
102
218
 
219
+ /**
220
+ * Load the optional project config (`otfw.config.{json,js,mjs}`) as a plain object.
221
+ * Read by the build for the site URL (SEO) and the `docs` block (docs generator).
222
+ */
223
+ export async function loadConfig(root) {
224
+ const json = join(root, "otfw.config.json");
225
+ if (existsSync(json)) {
226
+ try {
227
+ return JSON.parse(readFileSync(json, "utf8")) ?? {};
228
+ } catch (e) {
229
+ console.warn(`⚠ could not parse otfw.config.json: ${e?.message ?? e}`);
230
+ }
231
+ }
232
+ for (const name of ["otfw.config.js", "otfw.config.mjs"]) {
233
+ const p = join(root, name);
234
+ if (existsSync(p)) {
235
+ try {
236
+ return (await import(pathToFileURL(p).href)).default ?? {};
237
+ } catch (e) {
238
+ console.warn(`⚠ could not load ${name}: ${e?.message ?? e}`);
239
+ }
240
+ }
241
+ }
242
+ return {};
243
+ }
244
+
245
+ /**
246
+ * The docs navigation Rolldown plugin, when the project opts into the docs
247
+ * generator (a `docs` block in otfw.config). Resolved from `@opentf/web-docs`
248
+ * (the app's own dependency); returns null when docs aren't configured or the
249
+ * package isn't installed, so the core toolchain stays untouched for normal apps.
250
+ */
251
+ export async function loadDocsPlugins(root, appDir, config, exclude = new Set()) {
252
+ const docs = config?.docs;
253
+ const blog = config?.blog;
254
+ if (!docs && !blog) return [];
255
+ try {
256
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
257
+ const { docsNavPlugin, blogPostsPlugin, lastUpdatedPlugin } = await import(
258
+ pathToFileURL(entry).href
259
+ );
260
+ const plugins = [];
261
+ // Resolves `@opentf/web-docs/nav` to a section map — one generated tree per top-level
262
+ // folder under app/. Any folder with a DocsLayout becomes a section automatically.
263
+ if (docs) plugins.push(docsNavPlugin({ appDir, exclude }));
264
+ // Resolves `@opentf/web-docs/posts` to the generated post list.
265
+ if (blog) plugins.push(blogPostsPlugin({ appDir, contentDir: blog.dir ?? "blog", exclude }));
266
+ // Resolves `@opentf/web-docs/updated` to the per-page last-updated map (every route),
267
+ // when last-updated is turned on anywhere.
268
+ if (wantsLastUpdated(config)) plugins.push(lastUpdatedPlugin({ appDir, exclude }));
269
+ return plugins;
270
+ } catch (e) {
271
+ console.warn(
272
+ `⚠ docs/blog config present but @opentf/web-docs could not be loaded: ${e?.message ?? e}`,
273
+ );
274
+ return [];
275
+ }
276
+ }
277
+
278
+ /** Whether "last updated" tracking is enabled anywhere (`docs`/`blog` `lastUpdated`). */
279
+ export function wantsLastUpdated(config) {
280
+ return Boolean(config?.docs?.lastUpdated || config?.blog?.lastUpdated);
281
+ }
282
+
283
+ /**
284
+ * Build the `{ [routePath]: ISO }` last-updated map for every page (the same map the
285
+ * `@opentf/web-docs/updated` virtual module exposes). Used by the SSG step to emit
286
+ * `article:modified_time`. Returns `{}` when last-updated is off or the package can't
287
+ * be loaded.
288
+ */
289
+ export async function runLastUpdated(root, appDir, config, exclude = new Set()) {
290
+ if (!wantsLastUpdated(config)) return {};
291
+ try {
292
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
293
+ const { loadLastUpdated } = await import(pathToFileURL(entry).href);
294
+ return loadLastUpdated({ appDir, exclude });
295
+ } catch (e) {
296
+ console.warn(`⚠ last-updated map skipped: ${e?.message ?? e}`);
297
+ return {};
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Run Pagefind over the built site when the docs config opts into it
303
+ * (`docs.search.provider === "pagefind"`). Indexes the pre-rendered HTML in `siteDir`
304
+ * and writes `<siteDir>/pagefind/`. No-op (returns null) otherwise. Resolved from the
305
+ * app's `@opentf/web-docs` so the hook ships with the docs package.
306
+ */
307
+ export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
308
+ if (config?.docs?.search?.provider !== "pagefind") return null;
309
+ try {
310
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
311
+ const { indexWithPagefind } = await import(pathToFileURL(entry).href);
312
+ return await indexWithPagefind({ siteDir, onProgress });
313
+ } catch (e) {
314
+ console.warn(`⚠ Pagefind indexing skipped: ${e?.message ?? e}`);
315
+ return null;
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Generate blog feeds when the project has a `blog` config and a base URL. Scans the
321
+ * post folders, renders RSS 2.0 + Atom 1.0, and writes them under
322
+ * `<siteDir>/<blogDir>/`. Project-supplied `public/<blogDir>/{rss,atom}.xml` files
323
+ * take precedence independently. Returns `{ paths, urls, count }` or null (no blog /
324
+ * no base URL / all feeds overridden / package missing). Resolved from the app's
325
+ * `@opentf/web-docs`.
326
+ */
327
+ export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
328
+ const blog = config?.blog;
329
+ if (!blog) return null;
330
+ if (!baseUrl) {
331
+ console.warn("⚠ blog feeds skipped: no site URL (pass --base-url or set otfw.config)");
332
+ return null;
333
+ }
334
+ const contentDir = blog.dir ?? "blog";
335
+ try {
336
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
337
+ const { loadPosts, renderAtomFeed, renderBlogFeed } = await import(pathToFileURL(entry).href);
338
+ const posts = loadPosts({ appDir, contentDir, exclude });
339
+ const title = blog.title || (config?.docs?.title ? `${config.docs.title} Blog` : "Blog");
340
+ const channel = {
341
+ title,
342
+ description: blog.description || title,
343
+ link: `/${contentDir}`,
344
+ };
345
+ const feeds = [
346
+ {
347
+ file: "rss.xml",
348
+ path: `/${contentDir}/rss.xml`,
349
+ render: renderBlogFeed,
350
+ },
351
+ {
352
+ file: "atom.xml",
353
+ path: `/${contentDir}/atom.xml`,
354
+ render: renderAtomFeed,
355
+ },
356
+ ];
357
+ const written = [];
358
+ for (const feed of feeds) {
359
+ if (existsSync(join(root, "public", contentDir, feed.file))) continue; // honor override
360
+ const out = join(siteDir, contentDir, feed.file);
361
+ mkdirSync(dirname(out), { recursive: true });
362
+ writeFileSync(out, feed.render({ posts, baseUrl, feedPath: feed.path, channel }));
363
+ written.push(feed.path);
364
+ }
365
+ if (!written.length) return null;
366
+ const origin = baseUrl.replace(/\/+$/, "");
367
+ return { paths: written, urls: written.map((path) => origin + path), count: posts.length };
368
+ } catch (e) {
369
+ console.warn(`⚠ blog feeds skipped: ${e?.message ?? e}`);
370
+ return null;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Generate `/llms.txt` and `/llms-full.txt` for docs/blog sites from the same
376
+ * filesystem route list used by the app build. Project-supplied public files override
377
+ * each output independently.
378
+ */
379
+ export async function runLlmsFiles(root, appDir, pages, config, siteDir, baseUrl) {
380
+ if (!config?.docs && !config?.blog) return null;
381
+ const outputs = [
382
+ { file: "llms.txt", render: "renderLlmsTxt" },
383
+ { file: "llms-full.txt", render: "renderLlmsFullTxt" },
384
+ ].filter((out) => !existsSync(join(root, "public", out.file)));
385
+ if (!outputs.length) return null;
386
+
387
+ try {
388
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
389
+ const mod = await import(pathToFileURL(entry).href);
390
+ const written = [];
391
+ for (const out of outputs) {
392
+ const render = mod[out.render];
393
+ if (typeof render !== "function") continue;
394
+ writeFileSync(join(siteDir, out.file), render({ appDir, pages, baseUrl, config }));
395
+ written.push(`/${out.file}`);
396
+ }
397
+ return written.length ? { paths: written } : null;
398
+ } catch (e) {
399
+ console.warn(`⚠ llms.txt skipped: ${e?.message ?? e}`);
400
+ return null;
401
+ }
402
+ }
403
+
103
404
  /** Discover file-based routes under `app/`: every page/layout and the 404. */
104
405
  export function discoverPages(dir, exclude) {
105
406
  const out = [];
@@ -107,7 +408,7 @@ export function discoverPages(dir, exclude) {
107
408
  if (entry.isDirectory() && exclude.has(entry.name)) continue;
108
409
  const full = `${dir}/${entry.name}`;
109
410
  if (entry.isDirectory()) out.push(...discoverPages(full, exclude));
110
- else if (/^(page|layout|404)\.[jt]sx$/.test(entry.name)) out.push(full);
411
+ else if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(entry.name)) out.push(full);
111
412
  }
112
413
  return out;
113
414
  }
@@ -122,20 +423,192 @@ export function findGuard(appDir) {
122
423
  /**
123
424
  * The app entry source: hand `mountApp` a route map of lazy `() => import()`
124
425
  * loaders (so each route code-splits into its own chunk) plus the optional guard.
426
+ *
427
+ * `loaderUrl(filePath)` maps each route file to the specifier its loader imports.
428
+ * The production build imports the file directly (Rolldown code-splits it); the dev
429
+ * server passes a `/__route/…` URL so the route compiles on first navigation.
125
430
  */
126
- export function entrySource(pages, appDir) {
431
+ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
127
432
  const map = pages
128
- .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(p)}),`)
433
+ .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
129
434
  .join("\n");
130
435
  const guard = findGuard(appDir);
436
+ // Thread the i18n config (otfw.config) into `mountApp` so the client router knows
437
+ // the locales and `router.locale` resolves from the URL prefix (docs/I18N.md §6).
438
+ const i18nOpt =
439
+ i18n && Array.isArray(i18n.locales) && i18n.locales.length
440
+ ? `\n i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })},`
441
+ : "";
442
+ // Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
443
+ // (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
444
+ const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
131
445
  return (
132
446
  `import { mountApp } from "@opentf/web";\n` +
133
447
  (guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
134
448
  `mountApp({\n pages: {\n${map}\n },\n` +
135
- ` target: document.getElementById("app"),${guard ? "\n guard," : ""}\n});\n`
449
+ ` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
136
450
  );
137
451
  }
138
452
 
453
+ /**
454
+ * Crawl the module graph via `otfwc graph` and return a queryable handle. Used by
455
+ * the dev server to invalidate precisely: `affected(file)` is every module that
456
+ * transitively imports `file` (so editing it rebuilds exactly those route chunks).
457
+ * `roots` are the entries to crawl from (the route/layout files + the runtime).
458
+ */
459
+ export async function moduleGraph(otfwc, webEntry, roots) {
460
+ const proc = Bun.spawn([otfwc, "graph", `--web=${webEntry}`, ...roots], {
461
+ stdout: "pipe",
462
+ stderr: "pipe",
463
+ });
464
+ proc.unref(); // a short read-only crawl — never let it hold up shutdown
465
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
466
+ if (exitCode !== 0) {
467
+ console.error(`✗ otfwc graph failed:\n${await new Response(proc.stderr).text()}`);
468
+ return { affected: () => new Set(), has: () => false };
469
+ }
470
+ let modules = [];
471
+ try {
472
+ modules = JSON.parse(stdout).modules;
473
+ } catch {
474
+ return { affected: () => new Set(), has: () => false };
475
+ }
476
+ // Reverse adjacency: target → importers, for transitive-dependents queries.
477
+ const importers = new Map();
478
+ for (const m of modules) {
479
+ for (const dep of m.deps) {
480
+ if (dep.external) continue;
481
+ if (!importers.has(dep.target)) importers.set(dep.target, []);
482
+ importers.get(dep.target).push(m.id);
483
+ }
484
+ }
485
+ const ids = new Set(modules.map((m) => m.id));
486
+ return {
487
+ has: (id) => ids.has(id),
488
+ affected(file) {
489
+ const out = new Set();
490
+ const queue = [file];
491
+ while (queue.length) {
492
+ const id = queue.shift();
493
+ if (out.has(id)) continue;
494
+ out.add(id);
495
+ for (const importer of importers.get(id) || []) {
496
+ if (!out.has(importer)) queue.push(importer);
497
+ }
498
+ }
499
+ return out;
500
+ },
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Start a long-lived `otfwc serve` process and talk to it over a framed
506
+ * stdin/stdout protocol (see crates/otfw_cli/src/main.rs `serve`). One process
507
+ * compiles every module, so the toolchain pays the binary-startup cost once
508
+ * instead of spawning a subprocess per file — the dominant dev-server cost.
509
+ *
510
+ * `compile(id, source, component, target)` resolves to the emitted JS or rejects
511
+ * with the compiler diagnostic (`target` is `"csr"` | `"ssg"` | `"hydrate"`).
512
+ * Requests are serialized through a FIFO queue: the server
513
+ * is single-threaded, replies arrive in request order, so the head of the queue
514
+ * always pairs with the next frame. The child is killed when this process exits.
515
+ */
516
+ export function startCompilerServer(otfwc) {
517
+ const proc = Bun.spawn([otfwc, "serve"], {
518
+ stdin: "pipe",
519
+ stdout: "pipe",
520
+ stderr: "inherit",
521
+ });
522
+ // Don't let the long-lived child keep the event loop open: a one-shot `otfw build`
523
+ // must exit once the bundle is written (the dev server stays alive on its own via
524
+ // `Bun.serve`). The `exit` handler below still tears the child down, and the child
525
+ // also sees EOF on its stdin pipe when we go.
526
+ proc.unref();
527
+ const reader = proc.stdout.getReader();
528
+ const enc = new TextEncoder();
529
+ const dec = new TextDecoder();
530
+ const queue = []; // { resolve, reject } in request order
531
+ let buf = new Uint8Array(0);
532
+ let pumping = false;
533
+ let dead = false;
534
+
535
+ const append = (a, b) => {
536
+ const out = new Uint8Array(a.length + b.length);
537
+ out.set(a);
538
+ out.set(b, a.length);
539
+ return out;
540
+ };
541
+ const die = (err) => {
542
+ dead = true;
543
+ while (queue.length) queue.shift().reject(err);
544
+ };
545
+
546
+ // Drain reply frames as they arrive, resolving queued requests in order. A frame
547
+ // is `<status> <byteLen>\n` followed by exactly `byteLen` bytes of payload.
548
+ async function pump() {
549
+ if (pumping) return;
550
+ pumping = true;
551
+ try {
552
+ while (queue.length) {
553
+ let nl = buf.indexOf(10);
554
+ while (nl === -1) {
555
+ const { value, done } = await reader.read();
556
+ if (done) return die(new Error("otfwc serve exited"));
557
+ buf = append(buf, value);
558
+ nl = buf.indexOf(10);
559
+ }
560
+ const [status, lenStr] = dec.decode(buf.subarray(0, nl)).split(" ");
561
+ const len = Number(lenStr);
562
+ while (buf.length < nl + 1 + len) {
563
+ const { value, done } = await reader.read();
564
+ if (done) return die(new Error("otfwc serve exited"));
565
+ buf = append(buf, value);
566
+ }
567
+ const payload = dec.decode(buf.subarray(nl + 1, nl + 1 + len));
568
+ buf = buf.slice(nl + 1 + len);
569
+ const job = queue.shift();
570
+ if (status === "OK") job.resolve(payload);
571
+ else job.reject(new Error(payload));
572
+ }
573
+ } finally {
574
+ pumping = false;
575
+ }
576
+ }
577
+
578
+ function compile(id, source, component, target = "csr") {
579
+ if (dead) return Promise.reject(new Error("otfwc serve is not running"));
580
+ return new Promise((resolve, reject) => {
581
+ queue.push({ resolve, reject });
582
+ const idB = enc.encode(id);
583
+ const srcB = enc.encode(source);
584
+ proc.stdin.write(enc.encode(`${idB.length} ${srcB.length} ${component ? 1 : 0} ${target}\n`));
585
+ proc.stdin.write(idB);
586
+ proc.stdin.write(srcB);
587
+ proc.stdin.flush();
588
+ pump();
589
+ });
590
+ }
591
+
592
+ const close = () => {
593
+ if (dead) return;
594
+ dead = true;
595
+ try {
596
+ proc.stdin.end();
597
+ } catch {}
598
+ try {
599
+ proc.kill();
600
+ } catch {}
601
+ };
602
+ // Clean up the child whenever this process exits — for any reason and at any exit
603
+ // code. We deliberately don't trap SIGINT/SIGTERM here: a helper shouldn't dictate
604
+ // the whole process's exit code (that's the command's call), and the child also
605
+ // receives the terminal's group signal directly. The `exit` hook is enough to avoid
606
+ // leaking it. (One-shot builds exit when done; the dev server until it's stopped.)
607
+ process.once("exit", close);
608
+
609
+ return { compile, close };
610
+ }
611
+
139
612
  /**
140
613
  * Rolldown plugin: compile `.jsx`/`.tsx` through the `otfwc` IR compiler. Page /
141
614
  * layout / 404 modules become factories; everything else a Custom Element. On a
@@ -143,23 +616,27 @@ export function entrySource(pages, appDir) {
143
616
  * build) unless `failOnError` is set (production builds should fail loudly).
144
617
  * `onResult(id, errorMessageOrNull)` is called per module so the dev server can
145
618
  * push compile diagnostics to the error overlay and clear them once fixed.
619
+ *
620
+ * Compilation runs through one persistent `otfwc serve` process per plugin instance
621
+ * (see `startCompilerServer`). `target` picks the codegen backend: `"csr"` (the live
622
+ * DOM build), `"ssg"` (HTML-string renderers), or `"hydrate"` (the dual module — a
623
+ * CSR build factory plus an adopt factory for first-paint hydration).
146
624
  */
147
625
  export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr" } = {}) {
626
+ const server = startCompilerServer(otfwc);
148
627
  return {
149
628
  name: "otfw",
150
- transform(code, id) {
151
- if (!/\.[jt]sx$/.test(id)) return null;
152
- const base = id.split("/").pop().replace(/\.[jt]sx$/, "");
629
+ async transform(code, id) {
630
+ if (!/\.(mdx|md|[jt]sx)$/.test(id)) return null;
631
+ const base = id.split("/").pop().replace(/\.(mdx|md|[jt]sx)$/, "");
153
632
  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();
633
+ try {
634
+ const out = await server.compile(id, code, !isPage, target);
635
+ onResult?.(id, null);
636
+ // Side effects (e.g. customElements.define) must survive bundling.
637
+ return { code: out, moduleSideEffects: true };
638
+ } catch (e) {
639
+ const msg = e?.message ?? String(e);
163
640
  onResult?.(id, msg);
164
641
  if (failOnError) {
165
642
  this.error(`otfwc failed for ${id}:\n${msg}`);
@@ -172,9 +649,6 @@ export function otfwPlugin(otfwc, { failOnError = false, onResult, target = "csr
172
649
  ` return pre; }`;
173
650
  return { code: stub, moduleSideEffects: true };
174
651
  }
175
- onResult?.(id, null);
176
- // Side effects (e.g. customElements.define) must survive bundling.
177
- return { code: proc.stdout.toString(), moduleSideEffects: true };
178
652
  },
179
653
  };
180
654
  }
@@ -200,6 +674,78 @@ export function cssPlugin() {
200
674
  };
201
675
  }
202
676
 
677
+ // Generated server entry: eager-import every page module (so `registerRoutes`
678
+ // sees real namespaces, enabling `getStaticPaths`) and re-export the render API.
679
+ // Shared by the SSG pre-render and the SSR server — both render through the same
680
+ // SSG-compiled bundle (ARCHITECTURE.md §6: "SSR … shares the SSG path").
681
+ export function serverEntrySource(pages, i18n = null) {
682
+ const imports = pages.map((p, i) => `import * as p${i} from ${JSON.stringify(p)};`).join("\n");
683
+ const map = pages.map((p, i) => ` [${JSON.stringify(p)}]: p${i},`).join("\n");
684
+ // i18n: the server-side router needs the locales too — so renderRoute can strip
685
+ // a `/fr/...` prefix to match the route table and set router.locale for `t()`
686
+ // (docs/I18N.md §6). Without this the prefix wouldn't match and pages would
687
+ // render in the default locale (or 404).
688
+ const i18nOn = i18n && Array.isArray(i18n.locales) && i18n.locales.length;
689
+ const named = i18nOn ? "registerRoutes, configureI18n" : "registerRoutes";
690
+ const i18nCall = i18nOn
691
+ ? `configureI18n(${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })});\n`
692
+ : "";
693
+ return (
694
+ `${imports}\n` +
695
+ `import { ${named} } from "@opentf/web";\n` +
696
+ `export { renderRoute, renderHead, collectRoutePaths } from "@opentf/web/server";\n` +
697
+ i18nCall +
698
+ `registerRoutes({\n${map}\n});\n`
699
+ );
700
+ }
701
+
702
+ /**
703
+ * Build the server render bundle (the compiler's SSG backend — HTML-string
704
+ * renderers) and import it. Returns `{ mod, cleanup }`, where `mod` exposes
705
+ * `renderRoute` / `renderHead` / `collectRoutePaths` and `cleanup()` removes the
706
+ * temp build dir. The SSG pre-render calls `cleanup()` immediately after rendering
707
+ * every route; the SSR server keeps the module live and cleans up on shutdown.
708
+ */
709
+ export async function buildServerBundle({
710
+ root,
711
+ pages,
712
+ webEntry,
713
+ otfwc,
714
+ docsPlugins = [],
715
+ i18n = null,
716
+ onCompile,
717
+ tmpName = ".otfw-ssg",
718
+ }) {
719
+ const tmp = join(root, tmpName);
720
+ mkdirSync(tmp, { recursive: true });
721
+ const entry = join(tmp, "ssg-entry.js");
722
+ writeFileSync(entry, serverEntrySource(pages, i18n));
723
+
724
+ const serverApi = join(dirname(webEntry), "server", "index.js");
725
+ await build({
726
+ input: entry,
727
+ resolve: {
728
+ alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
729
+ extensions: EXTENSIONS,
730
+ },
731
+ plugins: [
732
+ ...docsPlugins,
733
+ otfwPlugin(otfwc, { failOnError: true, target: "ssg", onResult: (id) => onCompile?.(id) }),
734
+ cssPlugin(),
735
+ ],
736
+ output: { dir: join(tmp, "out"), format: "esm", entryFileNames: "server.js" },
737
+ checks: { pluginTimings: false },
738
+ });
739
+
740
+ // The runtime defines `class … extends HTMLElement` at load (for CSR custom
741
+ // elements). Server render never instantiates them, but the base class must
742
+ // exist so the class definitions evaluate. A bare stub suffices — no DOM
743
+ // (customElements stays undefined, so elements self-register only in the browser).
744
+ globalThis.HTMLElement ??= class {};
745
+ const mod = await import(pathToFileURL(join(tmp, "out", "server.js")).href);
746
+ return { mod, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
747
+ }
748
+
203
749
  function fail(msg) {
204
750
  console.error(`✗ ${msg}`);
205
751
  process.exit(1);