@opentf/web-cli 1.1.0 → 1.3.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/README.md CHANGED
@@ -39,7 +39,10 @@ otfw serve # build, then server-render each route per request (SSR)
39
39
  Picks port 3000 (scanning upward), or `--port` for an explicit one. Phase 1: the page
40
40
  becomes interactive via the client bundle (CSR mount); hydration is a later phase.
41
41
 
42
- `OTFWC_BIN` overrides the compiler binary location (used for compiler development).
42
+ `OTFWC_BIN` overrides the compiler binary location. Published installs under
43
+ `node_modules` use the packaged compiler, even when the app lives inside a Cargo
44
+ workspace; this repository's own source checkout may build and use
45
+ `target/debug/otfwc` for compiler development.
43
46
 
44
47
  ## License
45
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentf/web-cli",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "OTF Web dev toolchain — the Rolldown-driven CSR dev server, production build, and SSG.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "test": "bun test tests/",
14
- "test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/hydrate-browser.mjs"
14
+ "test:e2e": "bun tests/e2e/serve.mjs && bun tests/e2e/build-data.mjs && bun tests/e2e/hydrate-browser.mjs"
15
15
  },
16
16
  "files": [
17
17
  "src"
package/src/build.js CHANGED
@@ -14,13 +14,19 @@ import {
14
14
  writeFileSync,
15
15
  } from "node:fs";
16
16
  import { basename, join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
17
18
 
18
19
  import { compileCss, usesTailwind } from "./tailwind.js";
19
20
  import {
20
21
  EXTENSIONS,
22
+ assertNoRouteConflicts,
21
23
  cssPlugin,
24
+ discoverLoaders,
22
25
  discoverPages,
26
+ emitApiBundle,
27
+ emitLoaderBundle,
23
28
  entrySource,
29
+ loaderRoutePath,
24
30
  injectBeforeBody,
25
31
  loadConfig,
26
32
  loadDocsPlugins,
@@ -29,6 +35,7 @@ import {
29
35
  readHtmlShell,
30
36
  runBlogFeed,
31
37
  runDocsSearchIndex,
38
+ runLlmsFiles,
32
39
  runLastUpdated,
33
40
  stampHydrateSentinel,
34
41
  } from "./shared.js";
@@ -36,17 +43,37 @@ import { fmtMs, step } from "./reporter.js";
36
43
 
37
44
  const hash = (s) => Bun.hash(s).toString(16).padStart(16, "0").slice(0, 8);
38
45
 
39
- // Site origin for absolute canonical / sitemap URLs. Priority: `--base-url=` flag,
40
- // then `otfw.config` (`{ site: { url } }`), else "" (relative canonicals, sitemap
41
- // skipped with a warning).
42
- function resolveBaseUrl(config) {
43
- const flag = process.argv.find((a) => a.startsWith("--base-url="));
46
+ // Site origin for absolute canonical / sitemap/feed URLs. Priority: `--base-url=`
47
+ // flag, then `otfw.config` (`{ site: { url } }`).
48
+ export function resolveBaseUrl(config, argv = process.argv) {
49
+ const flag = argv.find((a) => a.startsWith("--base-url="));
44
50
  if (flag) return flag.slice("--base-url=".length).replace(/\/+$/, "");
45
51
  if (config?.site?.url) return String(config.site.url).replace(/\/+$/, "");
46
52
  return "";
47
53
  }
48
54
 
55
+ export function buildRequiresBaseUrl(config, argv = process.argv) {
56
+ return argv.includes("--ssg") || !!config?.docs || !!config?.blog;
57
+ }
58
+
59
+ function requireBaseUrl(config, argv = process.argv) {
60
+ const baseUrl = resolveBaseUrl(config, argv);
61
+ if (baseUrl || !buildRequiresBaseUrl(config, argv)) return baseUrl;
62
+ console.error(
63
+ `✗ site.url is required for this production build.\n` +
64
+ ` Add it to otfw.config.js:\n\n` +
65
+ ` export default defineDocsConfig({\n` +
66
+ ` site: { url: "https://example.com" }\n` +
67
+ ` })\n\n` +
68
+ ` Or pass --base-url=https://example.com`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+
49
73
  export async function runBuild(options = {}) {
74
+ const projectRoot = process.cwd();
75
+ const config = await loadConfig(projectRoot);
76
+ const baseUrl = requireBaseUrl(config);
50
77
  const { root, appDir, webEntry, otfwc, exclude } = loadProject();
51
78
  const t0 = performance.now();
52
79
 
@@ -55,6 +82,7 @@ export async function runBuild(options = {}) {
55
82
  console.error(`✗ no page.jsx files found under ${appDir}`);
56
83
  process.exit(1);
57
84
  }
85
+ assertNoRouteConflicts(appDir, exclude);
58
86
 
59
87
  // Build the client for hydration when there will be server markup to adopt — the
60
88
  // SSR server (`runBuild({ hydrate: true })`) and `--ssg` pre-rendered pages. A
@@ -63,7 +91,6 @@ export async function runBuild(options = {}) {
63
91
 
64
92
  // Docs generator: resolve `@opentf/web-docs/nav` to the build-time nav tree when
65
93
  // the project has a `docs` config block.
66
- const config = await loadConfig(root);
67
94
  const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
68
95
 
69
96
  const outDir = join(root, "dist");
@@ -74,7 +101,9 @@ export async function runBuild(options = {}) {
74
101
  const tmp = join(root, ".otfw");
75
102
  mkdirSync(tmp, { recursive: true });
76
103
  const entry = join(tmp, "entry.js");
77
- writeFileSync(entry, entrySource(pages, appDir, undefined, config?.i18n, config?.nav));
104
+ const loaderFiles = discoverLoaders(appDir, exclude);
105
+ const loaderRoutes = loaderFiles.map((f) => loaderRoutePath(f, appDir));
106
+ writeFileSync(entry, entrySource(pages, appDir, undefined, config?.i18n, config?.nav, loaderRoutes));
78
107
 
79
108
  console.log("\n OTF Web — production build\n");
80
109
 
@@ -141,11 +170,28 @@ export async function runBuild(options = {}) {
141
170
  const chunks = result.output.filter((o) => o.type === "chunk").length;
142
171
  buildStep.done(`Compiled ${pages.length} routes · bundled ${chunks} chunks`);
143
172
 
173
+ // Route loaders (docs/DATA.md): emit the server bundle to dist/server/loaders.js
174
+ // — `otfw serve` imports it per request, and the SSG pre-render below runs it at
175
+ // build time (so this must precede the SSG step).
176
+ let loaders = null;
177
+ if (loaderFiles.length > 0) {
178
+ const loaderStep = step("Bundling route loaders");
179
+ await emitLoaderBundle({
180
+ root,
181
+ appDir,
182
+ webEntry,
183
+ exclude,
184
+ i18n: config?.i18n,
185
+ outDir: join(outDir, "server"),
186
+ });
187
+ loaders = (await import(pathToFileURL(join(outDir, "server", "loaders.js")).href)).loaders;
188
+ loaderStep.done(`Route loaders — ${loaderFiles.length} → dist/server/loaders.js`);
189
+ }
190
+
144
191
  // SSG: pre-render each route into static HTML using the shell we just composed
145
192
  // (so per-route files carry the same bundle + stylesheet links).
146
193
  let ssg = null;
147
194
  if (process.argv.includes("--ssg")) {
148
- const baseUrl = resolveBaseUrl(config);
149
195
  const { runPrerender } = await import("./prerender.js");
150
196
  // Per-page last-updated map (git/frontmatter) for the article:modified_time tag.
151
197
  const lastUpdated = await runLastUpdated(root, appDir, config, exclude);
@@ -162,6 +208,7 @@ export async function runBuild(options = {}) {
162
208
  docsPlugins,
163
209
  lastUpdated,
164
210
  i18n: config?.i18n,
211
+ loaders,
165
212
  onCompile: (id) => ssgStep.update(`compiling ${basename(id)} (${++ssgCompiled})`),
166
213
  onRender: (done, total) => ssgStep.update(`rendering ${done}/${total}`),
167
214
  });
@@ -171,6 +218,12 @@ export async function runBuild(options = {}) {
171
218
  );
172
219
  }
173
220
 
221
+ // API routes (SPEC §11/§13): emit the server handler bundle to dist/server/api.js
222
+ // so a deploy adapter (Bun/Node/CF Workers) can serve /api/* in production.
223
+ const apiStep = step("Bundling API routes");
224
+ const api = await emitApiBundle({ root, appDir, webEntry, exclude, outDir: join(outDir, "server") });
225
+ apiStep.done(api ? `API routes — ${api.routes.length} → dist/server/api.js` : "API routes — none");
226
+
174
227
  // Copy the public/ directory (static assets served at the root), if present.
175
228
  const publicDir = join(root, "public");
176
229
  if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
@@ -185,13 +238,20 @@ export async function runBuild(options = {}) {
185
238
  searchStep.done(`Search index — ${search?.pages ?? 0} page(s)`);
186
239
  }
187
240
 
188
- // Blog RSS feed (when a `blog` block + site URL are configured). Written after the
189
- // public/ copy so a project-supplied feed override isn't clobbered.
241
+ // Blog RSS + Atom feeds (when a `blog` block is configured). Written
242
+ // after the public/ copy so project-supplied feed overrides aren't clobbered.
190
243
  if (config?.blog) {
191
- const feedStep = step("Generating RSS feed");
192
- const feed = await runBlogFeed(root, appDir, config, outDir, resolveBaseUrl(config), exclude);
193
- if (feed) feedStep.done(`RSS feed — ${feed.count} post(s) → ${feed.path}`);
194
- else feedStep.done("RSS feed — skipped");
244
+ const feedStep = step("Generating blog feeds");
245
+ const feed = await runBlogFeed(root, appDir, config, outDir, baseUrl, exclude);
246
+ if (feed) feedStep.done(`Blog feeds — ${feed.count} post(s) → ${feed.paths.join(", ")}`);
247
+ else feedStep.done("Blog feeds — skipped");
248
+ }
249
+
250
+ if (config?.docs || config?.blog) {
251
+ const llmsStep = step("Generating LLM context");
252
+ const llms = await runLlmsFiles(root, appDir, pages, config, outDir, baseUrl);
253
+ if (llms) llmsStep.done(`LLM context — ${llms.paths.join(", ")}`);
254
+ else llmsStep.done("LLM context — skipped");
195
255
  }
196
256
 
197
257
  console.log(`\n → dist/ ready in ${fmtMs(performance.now() - t0)}\n`);
package/src/dev.js CHANGED
@@ -18,16 +18,23 @@
18
18
 
19
19
  import { rolldown } from "rolldown";
20
20
  import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
21
- import { join } from "node:path";
21
+ import { dirname, join } from "node:path";
22
+ import { pathToFileURL } from "node:url";
22
23
 
23
24
  import { compileCss, usesTailwind } from "./tailwind.js";
24
25
  import { overlayClient } from "./overlay.js";
25
26
  import {
26
27
  EXTENSIONS,
27
28
  MIME,
29
+ assertNoRouteConflicts,
30
+ buildApiBundle,
31
+ buildLoaderBundle,
28
32
  cssPlugin,
33
+ discoverApiRoutes,
34
+ discoverLoaders,
29
35
  discoverPages,
30
36
  entrySource,
37
+ loaderRoutePath,
31
38
  injectBeforeBody,
32
39
  loadConfig,
33
40
  loadDocsPlugins,
@@ -97,6 +104,7 @@ export async function runDev() {
97
104
  console.error(`✗ no page.jsx files found under ${appDir}`);
98
105
  process.exit(1);
99
106
  }
107
+ assertNoRouteConflicts(appDir, exclude);
100
108
 
101
109
  const config = await loadConfig(root);
102
110
  const docsPlugins = await loadDocsPlugins(root, appDir, config, exclude);
@@ -104,7 +112,10 @@ export async function runDev() {
104
112
  const devDir = join(root, ".dev");
105
113
  mkdirSync(devDir, { recursive: true });
106
114
  const entryFile = join(devDir, "entry.js");
107
- writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav));
115
+ const loaderRoutes = () => discoverLoaders(appDir, exclude).map((f) => loaderRoutePath(f, appDir));
116
+ const writeEntry = () =>
117
+ writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav, loaderRoutes()));
118
+ writeEntry();
108
119
 
109
120
  let server;
110
121
  const publish = (msg) => server?.publish("hmr", JSON.stringify(msg));
@@ -184,6 +195,88 @@ export async function runDev() {
184
195
  return routeCache.get(file);
185
196
  }
186
197
 
198
+ // API routes (SPEC §11) are plain server modules under app/api/. In dev they're
199
+ // built lazily on the first /api/* request and rebuilt after an edit (the watcher
200
+ // clears the cache). `bust` busts the ESM import cache so edits take effect.
201
+ let apiBundle = null;
202
+ let apiBuilt = false;
203
+ let apiVersion = 0;
204
+ async function getApi() {
205
+ if (!apiBuilt) {
206
+ try {
207
+ apiBundle = await buildApiBundle({ root, appDir, webEntry, exclude, bust: ++apiVersion });
208
+ } catch (e) {
209
+ console.error(`✗ API build failed: ${e?.message ?? e}`);
210
+ apiBundle = await brokenApiStub(e);
211
+ }
212
+ apiBuilt = true;
213
+ }
214
+ return apiBundle;
215
+ }
216
+ // A failed API build must not silently serve the SPA shell for endpoint URLs:
217
+ // stand in with a handler that 500s exactly the discovered endpoints (real
218
+ // matching semantics via createApiHandler) until the next successful rebuild.
219
+ async function brokenApiStub(err) {
220
+ try {
221
+ const serverApi = pathToFileURL(join(dirname(webEntry), "server", "index.js")).href;
222
+ const { createApiHandler } = await import(serverApi);
223
+ // Strip ANSI color codes — the bundler's terminal diagnostic goes into JSON here.
224
+ const msg = String(err?.message ?? err).replace(/\u001b\[[0-9;]*m/g, "");
225
+ const fail = () => Response.json({ error: `API routes failed to build: ${msg}` }, { status: 500 });
226
+ const stub = Object.fromEntries(
227
+ ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].map((m) => [m, fail]),
228
+ );
229
+ const { routes } = discoverApiRoutes(appDir, exclude);
230
+ return {
231
+ handler: createApiHandler(Object.fromEntries(routes.map((f) => [f, stub])), {}, { appDir }),
232
+ routes,
233
+ cleanup: () => {},
234
+ };
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+ function invalidateApi() {
240
+ try {
241
+ apiBundle?.cleanup();
242
+ } catch {}
243
+ apiBundle = null;
244
+ apiBuilt = false;
245
+ }
246
+
247
+ // Route loaders (docs/DATA.md) are plain server modules like API routes, and get
248
+ // the same treatment: built lazily on the first `__data.json` request, rebuilt
249
+ // after an edit (the watcher clears the cache), ESM cache busted per rebuild.
250
+ let loaderBundle = null;
251
+ let loadersBuilt = false;
252
+ let loaderVersion = 0;
253
+ async function getLoaders() {
254
+ if (!loadersBuilt) {
255
+ try {
256
+ loaderBundle = await buildLoaderBundle({
257
+ root,
258
+ appDir,
259
+ webEntry,
260
+ exclude,
261
+ i18n: config?.i18n,
262
+ bust: ++loaderVersion,
263
+ });
264
+ } catch (e) {
265
+ console.error(`✗ loader build failed: ${e?.message ?? e}`);
266
+ loaderBundle = null;
267
+ }
268
+ loadersBuilt = true;
269
+ }
270
+ return loaderBundle;
271
+ }
272
+ function invalidateLoaders() {
273
+ try {
274
+ loaderBundle?.cleanup();
275
+ } catch {}
276
+ loaderBundle = null;
277
+ loadersBuilt = false;
278
+ }
279
+
187
280
  // The module graph powers precise invalidation; build it in the background so it
188
281
  // never blocks startup. Until it's ready, a change clears every cache (safe).
189
282
  let graph = { has: () => false, affected: () => new Set() };
@@ -217,12 +310,30 @@ export async function runDev() {
217
310
  const watcher = watch(appDir, { recursive: true }, (_evt, name) => {
218
311
  if (!name) return;
219
312
  const file = join(appDir, name);
313
+ // An API endpoint/middleware edit (any `route.*` / `_middleware.*` file) rebuilds
314
+ // the API bundle on the next request and triggers a reload.
315
+ if (/^(route|_middleware)\.(jsx?|tsx?)$/.test(name.split("/").pop())) {
316
+ invalidateApi();
317
+ publish({ type: "reload" });
318
+ return;
319
+ }
320
+ // A loader edit rebuilds the loader bundle on the next data request; a new or
321
+ // deleted loader.* also changes the route set baked into the entry, so the
322
+ // entry is rewritten and its cached chunk dropped (a stale set would make SPA
323
+ // nav skip the fetch — or fetch a 404).
324
+ if (/^loader\.(js|ts)$/.test(name.split("/").pop())) {
325
+ invalidateLoaders();
326
+ writeEntry();
327
+ entryCode = null;
328
+ publish({ type: "reload" });
329
+ return;
330
+ }
220
331
  if (/\.(mdx|md|[jt]sx|css)$/.test(name)) {
221
332
  if (/^(page|layout|404)\.(mdx|md|[jt]sx)$/.test(name.split("/").pop()) && !pages.includes(file)) {
222
333
  const fresh = discoverPages(appDir, exclude);
223
334
  pages.length = 0;
224
335
  pages.push(...fresh);
225
- writeFileSync(entryFile, entrySource(pages, appDir, toRouteUrl, config?.i18n, config?.nav));
336
+ writeEntry();
226
337
  }
227
338
  onChange(file);
228
339
  }
@@ -234,6 +345,8 @@ export async function runDev() {
234
345
  try {
235
346
  watcher.close();
236
347
  } catch {}
348
+ invalidateApi();
349
+ invalidateLoaders();
237
350
  process.exit(0);
238
351
  };
239
352
  process.once("SIGINT", shutdown);
@@ -242,6 +355,8 @@ export async function runDev() {
242
355
  try {
243
356
  watcher.close();
244
357
  } catch {}
358
+ invalidateApi();
359
+ invalidateLoaders();
245
360
  });
246
361
 
247
362
  // Import map (so `@opentf/web` resolves to the shared runtime chunk) + entry + the
@@ -303,6 +418,29 @@ export async function runDev() {
303
418
  if (pathname.startsWith(ROUTE_PREFIX) && pathname.endsWith(".js")) {
304
419
  return js(await serveRoute(fromRouteUrl(pathname)));
305
420
  }
421
+ // API endpoints (route.* files, at any path — SPEC §11): a matched handler's
422
+ // Response wins; a miss falls through to static assets / the SPA shell, so pages
423
+ // and endpoints coexist. `getApi()` is O(1) after the first build (or a no-op
424
+ // when the app has no route.* files).
425
+ {
426
+ const api = await getApi();
427
+ const res = api ? await api.handler(req) : null;
428
+ if (res) return res;
429
+ }
430
+ // The route-loader data endpoint (docs/DATA.md): `<path>/__data.json` is a
431
+ // reserved suffix — answered here (SPA navigation fetches it) and never
432
+ // allowed to fall through to assets or the SPA shell, so a miss is a 404.
433
+ if (pathname === "/__data.json" || pathname.endsWith("/__data.json")) {
434
+ try {
435
+ const bundle = await getLoaders();
436
+ const res = bundle ? await bundle.loaders.handle(req) : null;
437
+ if (res) return res;
438
+ } catch (e) {
439
+ console.error(`✗ loader failed for ${pathname}: ${e?.message ?? e}`);
440
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
441
+ }
442
+ return Response.json(null, { status: 404 });
443
+ }
306
444
  if (pathname !== "/") {
307
445
  const asset = await serveStatic(pathname);
308
446
  if (asset) return asset;
package/src/prerender.js CHANGED
@@ -6,7 +6,15 @@
6
6
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
 
9
- import { buildServerBundle, injectHead, injectMarkup, withHtmlLang } from "./shared.js";
9
+ import {
10
+ DATA_FILE,
11
+ buildServerBundle,
12
+ injectHead,
13
+ injectHydrationData,
14
+ injectMarkup,
15
+ injectRouteData,
16
+ withHtmlLang,
17
+ } from "./shared.js";
10
18
 
11
19
  // "/" → dist/index.html, "/post/1" → dist/post/1/index.html, "/fr/about" → dist/fr/about/index.html.
12
20
  function htmlPathFor(outDir, route) {
@@ -75,7 +83,7 @@ function escapeXml(s) {
75
83
  * Pre-render the app to static HTML files under `outDir`. Returns
76
84
  * `{ count, skipped, failed }`.
77
85
  */
78
- export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, onCompile, onRender }) {
86
+ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, outDir, baseUrl = "", docsPlugins = [], lastUpdated = {}, i18n = null, loaders = null, onCompile, onRender }) {
79
87
  const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n, onCompile });
80
88
 
81
89
  // i18n (docs/I18N.md §6): pre-render each route once per locale. The default
@@ -95,9 +103,20 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
95
103
  for (const locale of locales) {
96
104
  const urlPath = localizeFor(path, locale, defaultLocale);
97
105
  try {
106
+ // Route loader (docs/DATA.md): run it at build time with no `request` and
107
+ // an empty query (a query-dependent loader needs `otfw serve`). The result
108
+ // is threaded into the render (`router.data`), inlined into the HTML, and
109
+ // written as a sibling `__data.json` — per locale, since a localized
110
+ // loader can return locale-dependent data.
111
+ const m = loaders?.match(urlPath);
112
+ let data;
113
+ let dataJson = "";
114
+ if (m) ({ data, json: dataJson } = await loaders.loadSerialized(m, { query: {} }));
115
+
98
116
  // Passing the localized URL pins router.locale (resolveLocale) and still
99
117
  // matches the locale-agnostic route table, so `t()` renders in `locale`.
100
- const { html, metadata } = (await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {} };
118
+ const { html, metadata, hydration } =
119
+ (await mod.renderRoute(urlPath, params, "", { data })) ?? { html: "", metadata: {}, hydration: "" };
101
120
  const meta = i18nOn
102
121
  ? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
103
122
  : metadata;
@@ -108,11 +127,30 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
108
127
  if (iso) head += `\n<meta property="article:modified_time" content="${escapeXml(iso)}">`;
109
128
  const file = htmlPathFor(outDir, urlPath);
110
129
  mkdirSync(dirname(file), { recursive: true });
111
- writeFileSync(file, injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html));
130
+ writeFileSync(
131
+ file,
132
+ injectRouteData(
133
+ injectHydrationData(
134
+ injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
135
+ hydration,
136
+ ),
137
+ dataJson,
138
+ ),
139
+ );
140
+ if (m) {
141
+ // The static data file SPA navigation fetches on a plain static host —
142
+ // byte-identical to the inline payload ("null" when the loader returned
143
+ // undefined, so a 200 still parses as JSON).
144
+ const dataFile =
145
+ urlPath === "/" ? join(outDir, DATA_FILE) : join(outDir, urlPath, DATA_FILE);
146
+ mkdirSync(dirname(dataFile), { recursive: true });
147
+ writeFileSync(dataFile, dataJson || "null");
148
+ }
112
149
  rendered.push(urlPath);
113
150
  } catch (e) {
114
151
  failed.push(urlPath);
115
- console.error(`✗ pre-render failed for ${urlPath}: ${e?.message ?? e}`);
152
+ const why = e?.otfwNotFound ? "loader called notFound()" : (e?.message ?? e);
153
+ console.error(`✗ pre-render failed for ${urlPath}: ${why}`);
116
154
  }
117
155
  }
118
156
  }
@@ -123,7 +161,10 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
123
161
  const result = await mod.renderRoute("/__otfw_404__");
124
162
  if (result) {
125
163
  const head = mod.renderHead({ robots: "noindex", ...result.metadata }, { baseUrl });
126
- writeFileSync(join(outDir, "404.html"), injectMarkup(injectHead(shellHtml, head), result.html));
164
+ writeFileSync(
165
+ join(outDir, "404.html"),
166
+ injectHydrationData(injectMarkup(injectHead(shellHtml, head), result.html), result.hydration),
167
+ );
127
168
  }
128
169
  } catch {
129
170
  /* no 404 page */
package/src/serve.js CHANGED
@@ -16,24 +16,28 @@
16
16
  // • anything else → SSR: render the route's markup + <head>, inject into the
17
17
  // shell, return text/html.
18
18
  //
19
- // Hydration (Phase 2.0): the client build is the hydrate target and the shell's
20
- // `#app` carries the `data-otfw-hydrate` sentinel, so the client *adopts* the
21
- // server-rendered DOM on first paint (no rebuild/flash) for leaf routes that the
22
- // hydrate backend can emit an adopt factory for; anything it can't (layout chains,
23
- // child components, lists/conditionals see docs/HYDRATION.md §4) falls back to a
24
- // clean CSR mount. The per-request HTML is real either way (first paint, dynamic
25
- // content, SEO).
19
+ // Hydration (Phase 2, complete — docs/HYDRATION.md §4): the client build is the
20
+ // hydrate target and the shell's `#app` carries the `data-otfw-hydrate` sentinel,
21
+ // so the client *adopts* the server-rendered DOM on first paint (no rebuild/flash)
22
+ // pages, layout chains, keyed lists, conditionals, and component islands with
23
+ // rich props (2.0/2.5/2.1a–d). A route the adopt walk can't handle falls back to
24
+ // a clean CSR mount per component. The per-request HTML is real either way (first
25
+ // paint, dynamic content, SEO).
26
26
 
27
27
  import { existsSync, readFileSync, statSync } from "node:fs";
28
28
  import { join } from "node:path";
29
+ import { pathToFileURL } from "node:url";
29
30
 
30
31
  import { runBuild } from "./build.js";
31
32
  import {
32
33
  MIME,
33
34
  buildServerBundle,
35
+ discoverApiRoutes,
34
36
  discoverPages,
35
37
  injectHead,
38
+ injectHydrationData,
36
39
  injectMarkup,
40
+ injectRouteData,
37
41
  loadConfig,
38
42
  loadDocsPlugins,
39
43
  loadProject,
@@ -149,19 +153,37 @@ export async function runServe() {
149
153
 
150
154
  const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n });
151
155
 
152
- const shutdown = () => {
156
+ // API routes (SPEC §11): the client build already emitted the handler bundle to
157
+ // dist/server/api.js (build.js `emitApiBundle`). Import it and hold it live;
158
+ // `apiHandler(req)` returns a Response or null (no route matched).
159
+ const apiFile = join(distDir, "server", "api.js");
160
+ let api = null;
161
+ if (existsSync(apiFile)) {
162
+ const apiMod = await import(pathToFileURL(apiFile).href);
163
+ api = { handler: apiMod.apiHandler, routes: discoverApiRoutes(appDir, exclude).routes };
164
+ }
165
+
166
+ // Route loaders (docs/DATA.md): the client build emitted the registry bundle to
167
+ // dist/server/loaders.js (build.js `emitLoaderBundle`). Import it and hold it
168
+ // live; per request it runs the matched page's loader (threaded into the render
169
+ // as `router.data`) and answers the `<path>/__data.json` endpoint.
170
+ const loadersFile = join(distDir, "server", "loaders.js");
171
+ const loaders = existsSync(loadersFile)
172
+ ? (await import(pathToFileURL(loadersFile).href)).loaders
173
+ : null;
174
+
175
+ const cleanupAll = () => {
153
176
  try {
154
177
  cleanup();
155
178
  } catch {}
179
+ };
180
+ const shutdown = () => {
181
+ cleanupAll();
156
182
  process.exit(0);
157
183
  };
158
184
  process.once("SIGINT", shutdown);
159
185
  process.once("SIGTERM", shutdown);
160
- process.once("exit", () => {
161
- try {
162
- cleanup();
163
- } catch {}
164
- });
186
+ process.once("exit", cleanupAll);
165
187
 
166
188
  // Serve a built asset from dist/ (only regular files; a directory path falls
167
189
  // through to SSR). `dist/` already contains hashed assets, copied public/ files,
@@ -176,20 +198,46 @@ export async function runServe() {
176
198
  });
177
199
  }
178
200
 
179
- // SSR one navigation: render the route's markup + <head>, inject into the shell.
180
- async function render(url) {
181
- const result = await mod.renderRoute(url.pathname, null, url.search);
182
- if (!result) {
183
- const notFound = join(distDir, "404.html");
184
- const body = existsSync(notFound) ? readFileSync(notFound) : "<h1>404 — Not Found</h1>";
185
- return new Response(body, { status: 404, headers: { "content-type": "text/html" } });
201
+ // The 404 response: the pre-built dist/404.html when present, else a bare body.
202
+ function renderNotFound() {
203
+ const notFound = join(distDir, "404.html");
204
+ const body = existsSync(notFound) ? readFileSync(notFound) : "<h1>404 — Not Found</h1>";
205
+ return new Response(body, { status: 404, headers: { "content-type": "text/html" } });
206
+ }
207
+
208
+ // SSR one navigation: run the route's loader (if any), render the markup +
209
+ // <head>, inject into the shell.
210
+ async function render(url, req) {
211
+ // Loader first — `notFound()` must resolve before anything renders. The check
212
+ // is a property (`otfwNotFound`), not instanceof: the loader bundle is its own
213
+ // module graph. On notFound, re-render as the unmatched sentinel path so the
214
+ // registered 404 page is served with HTTP 404 (same as a route miss). Any
215
+ // other loader throw propagates to the 500 branch in `fetch`.
216
+ let data;
217
+ let dataJson = "";
218
+ const m = loaders?.match(url.pathname);
219
+ if (m) {
220
+ try {
221
+ ({ data, json: dataJson } = await loaders.loadSerialized(m, {
222
+ request: req,
223
+ query: Object.fromEntries(url.searchParams),
224
+ }));
225
+ } catch (e) {
226
+ if (e?.otfwNotFound === true) return render(new URL("/__otfw_404__", url), req);
227
+ throw e;
228
+ }
186
229
  }
230
+ const result = await mod.renderRoute(url.pathname, null, url.search, { data });
231
+ if (!result) return renderNotFound();
187
232
  const meta = i18nOn
188
233
  ? { ...result.metadata, links: [...(result.metadata.links || []), ...alternatesFor(stripLocale(url.pathname))] }
189
234
  : result.metadata;
190
235
  const head = mod.renderHead(meta, { path: url.pathname, baseUrl });
191
236
  const localizedShell = i18nOn ? withHtmlLang(shell, localeOf(url.pathname)) : shell;
192
- const html = injectMarkup(injectHead(localizedShell, head), result.html);
237
+ const html = injectRouteData(
238
+ injectHydrationData(injectMarkup(injectHead(localizedShell, head), result.html), result.hydration),
239
+ dataJson,
240
+ );
193
241
  // 200 for a real route; 404 when the path fell back to the registered 404 page.
194
242
  return new Response(html, {
195
243
  status: result.status ?? 200,
@@ -200,6 +248,22 @@ export async function runServe() {
200
248
  const server = serve(startPort, explicitPort, {
201
249
  async fetch(req) {
202
250
  const url = new URL(req.url);
251
+ // API endpoints (route.* files, at any path — SPEC §11) are checked first —
252
+ // before the asset branch, matching `otfw dev` — so an endpoint path with a
253
+ // dotted segment (`/api/v1.0`) still resolves. A matched handler's Response
254
+ // wins; a miss falls through to assets / SSR, so pages and endpoints coexist.
255
+ if (api) {
256
+ const res = await api.handler(req);
257
+ if (res) return res;
258
+ }
259
+ // The route-loader data endpoint (docs/DATA.md): `<path>/__data.json` is a
260
+ // reserved suffix, answered before the asset branch below would swallow it
261
+ // (it has a file extension) and never falling through to SSR (a catch-all
262
+ // page would happily match the suffix as a path segment) — a miss is a 404.
263
+ if (url.pathname === "/__data.json" || url.pathname.endsWith("/__data.json")) {
264
+ const res = loaders ? await loaders.handle(req) : null;
265
+ return res ?? Response.json(null, { status: 404 });
266
+ }
203
267
  // A path with a file extension is an asset request; serve it from dist/. A
204
268
  // miss on a real asset is a 404 (don't fall through to the SSR shell).
205
269
  if (/\.[a-z0-9]+$/i.test(url.pathname) && url.pathname !== "/") {
@@ -217,7 +281,7 @@ export async function runServe() {
217
281
  }
218
282
  }
219
283
  try {
220
- return await render(url);
284
+ return await render(url, req);
221
285
  } catch (e) {
222
286
  console.error(`✗ SSR failed for ${url.pathname}: ${e?.message ?? e}`);
223
287
  return new Response(`<pre>SSR error: ${e?.message ?? e}</pre>`, {
@@ -229,6 +293,8 @@ export async function runServe() {
229
293
  });
230
294
 
231
295
  console.log(`\n OTF Web SSR server`);
232
- console.log(` → http://localhost:${server.port} (${pages.length} routes, server-rendered)`);
296
+ const apiNote = api ? `, ${api.routes.length} API routes` : "";
297
+ const loaderNote = loaders ? `, ${loaders.routes.length} loaders` : "";
298
+ console.log(` → http://localhost:${server.port} (${pages.length} routes, server-rendered${apiNote}${loaderNote})`);
233
299
  console.log(` ✓ ready in ${Date.now() - bootStart}ms\n`);
234
300
  }
package/src/shared.js CHANGED
@@ -59,6 +59,37 @@ export function injectMarkup(shellHtml, markup) {
59
59
  return shellHtml.replace(/(<div id="app"[^>]*>)\s*(<\/div>)/, (_m, open, close) => `${open}${markup}${close}`);
60
60
  }
61
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
+ /** The reserved per-route data filename/URL suffix (mirrors the runtime's
77
+ * `DATA_FILE` in `@opentf/web` runtime/route-data.js). */
78
+ export const DATA_FILE = "__data.json";
79
+
80
+ /**
81
+ * Embed a route loader's data (docs/DATA.md) as a `<script type="application/json"
82
+ * id="__otfw_data">` the client router reads on first paint (instead of fetching
83
+ * `<path>/__data.json`). A separate script from the island payload above — the two
84
+ * channels have independent shapes and readers. `json` comes `<`-escaped from
85
+ * `serializeRouteData`; no-op for an empty payload (route without a loader, or an
86
+ * undefined loader result).
87
+ */
88
+ export function injectRouteData(shellHtml, json) {
89
+ if (!json) return shellHtml;
90
+ return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_data">${json}</script>`);
91
+ }
92
+
62
93
  /**
63
94
  * Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
64
95
  * the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
@@ -105,10 +136,45 @@ export function findUp(name, from) {
105
136
  }
106
137
  }
107
138
 
139
+ function hasLocalCompilerWorkspace(workspace) {
140
+ return !!workspace && existsSync(join(workspace, "crates", "otfw_cli", "Cargo.toml"));
141
+ }
142
+
143
+ export function resolveCompiler({
144
+ cliDir = dirname(fileURLToPath(import.meta.url)),
145
+ env = process.env,
146
+ resolvePackagedCompiler = otfwcPath,
147
+ findWorkspace = findUp,
148
+ ensure = ensureCompiler,
149
+ } = {}) {
150
+ if (env.OTFWC_BIN) return { otfwc: env.OTFWC_BIN, workspace: null };
151
+
152
+ let packagedError = null;
153
+ try {
154
+ return { otfwc: resolvePackagedCompiler(), workspace: null };
155
+ } catch (e) {
156
+ packagedError = e;
157
+ }
158
+
159
+ const installedPackage = cliDir.split(/[\\/]/).includes("node_modules");
160
+ const workspace = installedPackage ? null : findWorkspace("Cargo.toml", cliDir);
161
+ if (hasLocalCompilerWorkspace(workspace)) {
162
+ const otfwc = join(workspace, "target", "debug", "otfwc");
163
+ ensure(otfwc, workspace);
164
+ return { otfwc, workspace };
165
+ }
166
+
167
+ fail(
168
+ `${packagedError?.message ?? "cannot resolve @opentf/web-compiler prebuilt binary"}\n` +
169
+ ` No local otfwc compiler workspace was found to build from source.`,
170
+ );
171
+ }
172
+
108
173
  /**
109
174
  * Resolve the project and toolchain: the app being built (cwd), the runtime
110
175
  * package, the `otfwc` compiler, and the excluded routes. Exits with a clear
111
- * message on any hard failure. Builds the compiler on demand from the workspace.
176
+ * message on any hard failure. Source checkouts can build the compiler on demand
177
+ * from this repo's Cargo workspace; published installs use @opentf/web-compiler.
112
178
  */
113
179
  export function loadProject() {
114
180
  const root = process.cwd();
@@ -129,25 +195,9 @@ export function loadProject() {
129
195
  fail(`cannot resolve "@opentf/web" from ${root}\n add it to your dependencies.`);
130
196
  }
131
197
 
132
- // Locate the `otfwc` compiler. Published: the prebuilt binary from `@opentf/web-compiler`
133
- // (a dependency of this CLI). In this repo's own dev (a Cargo workspace is found
134
- // above the CLI): the cargo `target/debug` build, rebuilt on demand. `OTFWC_BIN`
135
- // overrides both.
136
- const cliDir = dirname(fileURLToPath(import.meta.url));
137
- const workspace = findUp("Cargo.toml", cliDir);
138
- let otfwc;
139
- if (process.env.OTFWC_BIN) {
140
- otfwc = process.env.OTFWC_BIN;
141
- } else if (workspace) {
142
- otfwc = join(workspace, "target", "debug", "otfwc");
143
- ensureCompiler(otfwc, workspace);
144
- } else {
145
- try {
146
- otfwc = otfwcPath();
147
- } catch (e) {
148
- fail(e.message);
149
- }
150
- }
198
+ // Locate the `otfwc` compiler. Explicit overrides win, then the packaged
199
+ // compiler binary, then this repo's local Rust workspace as a source fallback.
200
+ const { otfwc, workspace } = resolveCompiler();
151
201
 
152
202
  // Route directories to skip during discovery — comma-separated names in
153
203
  // EXCLUDE_ROUTES. None are excluded by default.
@@ -162,6 +212,19 @@ function ensureCompiler(otfwc, workspace) {
162
212
  if (existsSync(otfwc)) return;
163
213
  if (!workspace) fail(`otfwc compiler not found at ${otfwc}`);
164
214
  console.log("building compiler (cargo build -p otfw_cli)…");
215
+ const cargo = Bun.spawnSync(["cargo", "--version"], {
216
+ cwd: workspace,
217
+ stdout: "ignore",
218
+ stderr: "pipe",
219
+ });
220
+ if (cargo.error || cargo.exitCode !== 0) {
221
+ fail(
222
+ `cannot build otfwc because cargo is not available.\n` +
223
+ ` This source checkout needs Rust/Cargo to build ${otfwc}.\n` +
224
+ ` Install Rust in the build environment, set OTFWC_BIN to an existing otfwc binary,\n` +
225
+ ` or build with the published @opentf/web-cli package so @opentf/web-compiler can use its prebuilt binary.`,
226
+ );
227
+ }
165
228
  const b = Bun.spawnSync(["cargo", "build", "-p", "otfw_cli"], {
166
229
  cwd: workspace,
167
230
  stdout: "inherit",
@@ -271,43 +334,86 @@ export async function runDocsSearchIndex(root, config, siteDir, onProgress) {
271
334
  }
272
335
 
273
336
  /**
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`.
337
+ * Generate blog feeds when the project has a `blog` config and a base URL. Scans the
338
+ * post folders, renders RSS 2.0 + Atom 1.0, and writes them under
339
+ * `<siteDir>/<blogDir>/`. Project-supplied `public/<blogDir>/{rss,atom}.xml` files
340
+ * take precedence independently. Returns `{ paths, urls, count }` or null (no blog /
341
+ * no base URL / all feeds overridden / package missing). Resolved from the app's
342
+ * `@opentf/web-docs`.
279
343
  */
280
344
  export async function runBlogFeed(root, appDir, config, siteDir, baseUrl, exclude = new Set()) {
281
345
  const blog = config?.blog;
282
346
  if (!blog) return null;
283
347
  if (!baseUrl) {
284
- console.warn("⚠ blog RSS feed skipped: no site URL (pass --base-url or set otfw.config)");
348
+ console.warn("⚠ blog feeds skipped: no site URL (pass --base-url or set otfw.config)");
285
349
  return null;
286
350
  }
287
351
  const contentDir = blog.dir ?? "blog";
288
- if (existsSync(join(root, "public", contentDir, "rss.xml"))) return null; // honor override
289
352
  try {
290
353
  const entry = Bun.resolveSync("@opentf/web-docs/build", root);
291
- const { loadPosts, renderBlogFeed } = await import(pathToFileURL(entry).href);
354
+ const { loadPosts, renderAtomFeed, renderBlogFeed } = await import(pathToFileURL(entry).href);
292
355
  const posts = loadPosts({ appDir, contentDir, exclude });
293
- const feedPath = `/${contentDir}/rss.xml`;
294
356
  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}`,
357
+ const channel = {
358
+ title,
359
+ description: blog.description || title,
360
+ link: `/${contentDir}`,
361
+ };
362
+ const feeds = [
363
+ {
364
+ file: "rss.xml",
365
+ path: `/${contentDir}/rss.xml`,
366
+ render: renderBlogFeed,
303
367
  },
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 };
368
+ {
369
+ file: "atom.xml",
370
+ path: `/${contentDir}/atom.xml`,
371
+ render: renderAtomFeed,
372
+ },
373
+ ];
374
+ const written = [];
375
+ for (const feed of feeds) {
376
+ if (existsSync(join(root, "public", contentDir, feed.file))) continue; // honor override
377
+ const out = join(siteDir, contentDir, feed.file);
378
+ mkdirSync(dirname(out), { recursive: true });
379
+ writeFileSync(out, feed.render({ posts, baseUrl, feedPath: feed.path, channel }));
380
+ written.push(feed.path);
381
+ }
382
+ if (!written.length) return null;
383
+ const origin = baseUrl.replace(/\/+$/, "");
384
+ return { paths: written, urls: written.map((path) => origin + path), count: posts.length };
385
+ } catch (e) {
386
+ console.warn(`⚠ blog feeds skipped: ${e?.message ?? e}`);
387
+ return null;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Generate `/llms.txt` and `/llms-full.txt` for docs/blog sites from the same
393
+ * filesystem route list used by the app build. Project-supplied public files override
394
+ * each output independently.
395
+ */
396
+ export async function runLlmsFiles(root, appDir, pages, config, siteDir, baseUrl) {
397
+ if (!config?.docs && !config?.blog) return null;
398
+ const outputs = [
399
+ { file: "llms.txt", render: "renderLlmsTxt" },
400
+ { file: "llms-full.txt", render: "renderLlmsFullTxt" },
401
+ ].filter((out) => !existsSync(join(root, "public", out.file)));
402
+ if (!outputs.length) return null;
403
+
404
+ try {
405
+ const entry = Bun.resolveSync("@opentf/web-docs/build", root);
406
+ const mod = await import(pathToFileURL(entry).href);
407
+ const written = [];
408
+ for (const out of outputs) {
409
+ const render = mod[out.render];
410
+ if (typeof render !== "function") continue;
411
+ writeFileSync(join(siteDir, out.file), render({ appDir, pages, baseUrl, config }));
412
+ written.push(`/${out.file}`);
413
+ }
414
+ return written.length ? { paths: written } : null;
309
415
  } catch (e) {
310
- console.warn(`⚠ blog RSS feed skipped: ${e?.message ?? e}`);
416
+ console.warn(`⚠ llms.txt skipped: ${e?.message ?? e}`);
311
417
  return null;
312
418
  }
313
419
  }
@@ -324,6 +430,325 @@ export function discoverPages(dir, exclude) {
324
430
  return out;
325
431
  }
326
432
 
433
+ /**
434
+ * Discover file-based API routes anywhere under `app/` (SPEC §11). An endpoint is a
435
+ * `route.{js,ts}` file (the API analogue of `page.{jsx,tsx}`); its folder is the URL.
436
+ * Returns `{ routes, middleware }` — absolute paths to `route.*` handler modules and
437
+ * to `_middleware.{js,ts}` files (folder middleware) respectively.
438
+ */
439
+ export function discoverApiRoutes(appDir, exclude = new Set()) {
440
+ const routes = [];
441
+ const middleware = [];
442
+ const walk = (dir) => {
443
+ if (!existsSync(dir)) return;
444
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
445
+ if (entry.isDirectory()) {
446
+ if (!exclude.has(entry.name)) walk(join(dir, entry.name));
447
+ continue;
448
+ }
449
+ const name = entry.name;
450
+ const full = join(dir, name);
451
+ if (/^route\.(jsx?|tsx?)$/.test(name)) routes.push(full);
452
+ else if (/^_middleware\.(jsx?|tsx?)$/.test(name)) middleware.push(full);
453
+ }
454
+ };
455
+ walk(appDir);
456
+ return { routes, middleware };
457
+ }
458
+
459
+ /**
460
+ * Strip the app-directory prefix from a route file path. With `appDir` the exact
461
+ * prefix is removed (unambiguous even for `app/app/...`); without it, fall back to
462
+ * the last complete `/app` path segment — the lookahead keeps folders that merely
463
+ * start with "app" (`/appointments`) intact.
464
+ */
465
+ function stripAppPrefix(filePath, appDir) {
466
+ if (appDir) {
467
+ const base = appDir.replace(/\/+$/, "");
468
+ if (filePath.startsWith(base + "/")) return filePath.slice(base.length);
469
+ }
470
+ return filePath.replace(/^.*\/app(?=\/)/, "");
471
+ }
472
+
473
+ /** The page URL for a `.../app/<...>/page.{mdx,md,jsx,tsx}` file (folder = URL). */
474
+ export function pageRouteFromPath(filePath, appDir) {
475
+ const r = stripAppPrefix(filePath, appDir).replace(/\/page\.(mdx|md|jsx|tsx)$/, "");
476
+ return r === "" ? "/" : r;
477
+ }
478
+
479
+ /** The route URL for a `.../app/<...>/route.{js,ts}` file (folder = URL). */
480
+ function apiRoutePath(filePath, appDir) {
481
+ const r = stripAppPrefix(filePath, appDir).replace(/\/route\.(jsx?|tsx?)$/, "");
482
+ return r === "" ? "/" : r;
483
+ }
484
+
485
+ /**
486
+ * Discover route loaders anywhere under `app/` (docs/DATA.md). A loader is a
487
+ * `loader.{js,ts}` file sibling to a `page.*` — the data analogue of a `route.*`
488
+ * endpoint. Strictly `js|ts` (no `x` variants): loaders are plain server modules,
489
+ * never JSX. Returns absolute file paths.
490
+ */
491
+ export function discoverLoaders(appDir, exclude = new Set()) {
492
+ const out = [];
493
+ const walk = (dir) => {
494
+ if (!existsSync(dir)) return;
495
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
496
+ if (entry.isDirectory()) {
497
+ if (!exclude.has(entry.name)) walk(join(dir, entry.name));
498
+ } else if (/^loader\.(js|ts)$/.test(entry.name)) out.push(join(dir, entry.name));
499
+ }
500
+ };
501
+ walk(appDir);
502
+ return out;
503
+ }
504
+
505
+ /** The page route a `.../app/<...>/loader.{js,ts}` file feeds (folder = URL). */
506
+ export function loaderRoutePath(filePath, appDir) {
507
+ const r = stripAppPrefix(filePath, appDir).replace(/\/loader\.(js|ts)$/, "");
508
+ return r === "" ? "/" : r;
509
+ }
510
+
511
+ /**
512
+ * Detect folders that hold both a `page.*` and a `route.*` — they'd resolve to the
513
+ * same URL. Returns the conflicting `{ path, page, route }` entries (empty = none).
514
+ * A page and an endpoint cannot own the same path (as in Next.js's App Router).
515
+ */
516
+ export function detectRouteConflicts(appDir, exclude = new Set()) {
517
+ const pageByRoute = new Map();
518
+ for (const p of discoverPages(appDir, exclude)) {
519
+ if (/\/page\.(mdx|md|jsx|tsx)$/.test(p)) pageByRoute.set(pageRouteFromPath(p, appDir), p);
520
+ }
521
+ const conflicts = [];
522
+ for (const r of discoverApiRoutes(appDir, exclude).routes) {
523
+ const path = apiRoutePath(r, appDir);
524
+ if (pageByRoute.has(path)) conflicts.push({ path, page: pageByRoute.get(path), route: r });
525
+ }
526
+ return conflicts;
527
+ }
528
+
529
+ /**
530
+ * Detect misplaced `loader.*` files (docs/DATA.md): a loader feeds the page in
531
+ * its folder, so one without a sibling `page.*` — including one placed next to a
532
+ * `route.*` endpoint — has nothing to feed. Returns `{ loader, route, reason }`.
533
+ */
534
+ export function detectLoaderConflicts(appDir, exclude = new Set()) {
535
+ const conflicts = [];
536
+ for (const loader of discoverLoaders(appDir, exclude)) {
537
+ const dir = dirname(loader);
538
+ const hasPage = ["page.jsx", "page.tsx", "page.mdx", "page.md"].some((f) => existsSync(join(dir, f)));
539
+ if (hasPage) continue;
540
+ const hasRoute = ["route.js", "route.ts"].some((f) => existsSync(join(dir, f)));
541
+ conflicts.push({
542
+ loader,
543
+ route: loaderRoutePath(loader, appDir),
544
+ reason: hasRoute
545
+ ? "sits next to a route.* API endpoint (endpoints take a Request; loaders feed pages)"
546
+ : "has no sibling page.* to feed",
547
+ });
548
+ }
549
+ return conflicts;
550
+ }
551
+
552
+ /** Print the route/page/loader conflicts and exit — shared by build, dev, and serve. */
553
+ export function assertNoRouteConflicts(appDir, exclude = new Set()) {
554
+ const conflicts = detectRouteConflicts(appDir, exclude);
555
+ if (conflicts.length > 0) {
556
+ const lines = conflicts.map((c) => ` ${c.path}\n page: ${c.page}\n route: ${c.route}`).join("\n");
557
+ fail(`a page and an API route cannot resolve to the same path:\n${lines}\n Move one to a different folder.`);
558
+ }
559
+ const loaderConflicts = detectLoaderConflicts(appDir, exclude);
560
+ if (loaderConflicts.length > 0) {
561
+ const lines = loaderConflicts.map((c) => ` ${c.route}\n loader: ${c.loader}\n ${c.reason}`).join("\n");
562
+ fail(`a loader.* file must sit next to the page.* it feeds:\n${lines}`);
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Entry source for the API handler bundle: statically import every route +
568
+ * middleware module (keyed by absolute path so `createApiHandler` can derive the
569
+ * route from the path), then export the composed `Request → Response | null`
570
+ * handler. Mirrors `serverEntrySource` for the page/SSG bundle.
571
+ */
572
+ export function apiEntrySource({ routes, middleware }, appDir) {
573
+ const rImports = routes.map((p, i) => `import * as r${i} from ${JSON.stringify(p)};`).join("\n");
574
+ const mImports = middleware.map((p, i) => `import * as m${i} from ${JSON.stringify(p)};`).join("\n");
575
+ const rMap = routes.map((p, i) => ` [${JSON.stringify(p)}]: r${i},`).join("\n");
576
+ const mMap = middleware.map((p, i) => ` [${JSON.stringify(p)}]: m${i},`).join("\n");
577
+ // `appDir` pins the exact prefix to strip from the keys — route derivation stays
578
+ // correct for folders whose name starts with "app" and for nested app/app dirs.
579
+ const opts = appDir ? `\n{ appDir: ${JSON.stringify(appDir)} },\n` : "\n";
580
+ return (
581
+ `import { createApiHandler } from "@opentf/web/server";\n` +
582
+ `${rImports}\n${mImports}\n` +
583
+ `export const apiHandler = createApiHandler(\n{\n${rMap}\n},\n{\n${mMap}\n},${opts});\n`
584
+ );
585
+ }
586
+
587
+ /**
588
+ * Build the API handler bundle and import it. Returns `{ handler, cleanup }`
589
+ * where `handler(request)` resolves to a `Response` or `null` (no API route
590
+ * matched). Route modules are *plain server code* — bundled as ESM without the
591
+ * `otfwc` DOM transform. Bare (npm / node builtin) imports stay external and are
592
+ * resolved at runtime from the project's node_modules, so server deps and native
593
+ * modules aren't dragged into the bundle. Returns `null` when there are no routes.
594
+ */
595
+ export async function buildApiBundle({ root, appDir, webEntry, exclude, tmpName = ".otfw-api", bust }) {
596
+ const discovered = discoverApiRoutes(appDir, exclude);
597
+ if (discovered.routes.length === 0) return null;
598
+
599
+ const tmp = join(root, tmpName);
600
+ mkdirSync(tmp, { recursive: true });
601
+ const entry = join(tmp, "api-entry.js");
602
+ writeFileSync(entry, apiEntrySource(discovered, appDir));
603
+
604
+ const serverApi = join(dirname(webEntry), "server", "index.js");
605
+ // `bust` versions the emitted *filename* so `otfw dev` picks up handler edits on
606
+ // rebuild — Bun's ESM cache is keyed by file path and ignores a `?v=` query, so
607
+ // only a genuinely new path re-evaluates the module.
608
+ const outName = bust ? `api.${bust}.js` : "api.js";
609
+ await build({
610
+ input: entry,
611
+ platform: "node",
612
+ resolve: {
613
+ alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
614
+ extensions: EXTENSIONS,
615
+ },
616
+ // Keep npm/node builtins external — server handlers resolve them at runtime.
617
+ external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
618
+ output: { dir: join(tmp, "out"), format: "esm", entryFileNames: outName },
619
+ checks: { pluginTimings: false },
620
+ });
621
+
622
+ const mod = await import(pathToFileURL(join(tmp, "out", outName)).href);
623
+ return {
624
+ handler: mod.apiHandler,
625
+ routes: discovered.routes,
626
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
627
+ };
628
+ }
629
+
630
+ /**
631
+ * Emit the API handler bundle to `outDir/api.js` for production/deploy (SPEC §13,
632
+ * `dist/server/`). Unlike `buildApiBundle` (which bundles to a temp dir and imports
633
+ * it for `dev`/`serve`), this writes a persistent, self-contained ESM module that
634
+ * exports `apiHandler` — the runtime dispatcher (`createApiHandler`) is bundled in;
635
+ * npm/node deps stay external and resolve at the deploy target. A deploy adapter
636
+ * (`server/adapters/`) imports this file and hands requests to `apiHandler`.
637
+ * Returns `{ routes }`, or `null` when the project has no API routes.
638
+ */
639
+ export async function emitApiBundle({ root, appDir, webEntry, exclude, outDir }) {
640
+ const discovered = discoverApiRoutes(appDir, exclude);
641
+ if (discovered.routes.length === 0) return null;
642
+
643
+ const tmp = join(root, ".otfw-api-build");
644
+ mkdirSync(tmp, { recursive: true });
645
+ const entry = join(tmp, "api-entry.js");
646
+ writeFileSync(entry, apiEntrySource(discovered, appDir));
647
+
648
+ const serverApi = join(dirname(webEntry), "server", "index.js");
649
+ mkdirSync(outDir, { recursive: true });
650
+ await build({
651
+ input: entry,
652
+ platform: "node",
653
+ resolve: { alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry }, extensions: EXTENSIONS },
654
+ external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
655
+ output: { dir: outDir, format: "esm", entryFileNames: "api.js" },
656
+ checks: { pluginTimings: false },
657
+ });
658
+ rmSync(tmp, { recursive: true, force: true });
659
+ return { routes: discovered.routes };
660
+ }
661
+
662
+ /**
663
+ * Entry source for the loader bundle (docs/DATA.md): statically import every
664
+ * `loader.{js,ts}` module (keyed by absolute path so the registry derives each
665
+ * route from it) and export the composed registry. Mirrors `apiEntrySource`.
666
+ */
667
+ export function loaderEntrySource(loaderFiles, appDir, i18n = null) {
668
+ const imports = loaderFiles.map((p, i) => `import * as l${i} from ${JSON.stringify(p)};`).join("\n");
669
+ const map = loaderFiles.map((p, i) => ` [${JSON.stringify(p)}]: l${i},`).join("\n");
670
+ const i18nOpt =
671
+ i18n && Array.isArray(i18n.locales) && i18n.locales.length
672
+ ? `, i18n: ${JSON.stringify({ locales: i18n.locales, defaultLocale: i18n.defaultLocale })}`
673
+ : "";
674
+ const opts = `\n{ appDir: ${JSON.stringify(appDir ?? "")}${i18nOpt} },\n`;
675
+ return (
676
+ `import { createLoaderRegistry } from "@opentf/web/server";\n` +
677
+ `${imports}\n` +
678
+ `export const loaders = createLoaderRegistry(\n{\n${map}\n},${opts});\n`
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Build the loader bundle and import it. Returns `{ loaders, files, cleanup }`
684
+ * where `loaders` is the registry (`match`/`load`/`loadSerialized`/`handle` —
685
+ * see `createLoaderRegistry`), or `null` when the app has no loader files.
686
+ * Loaders are *plain server code*, bundled exactly like the API handler bundle:
687
+ * no `otfwc` DOM transform, npm/node builtins external (DB drivers and native
688
+ * modules resolve at runtime from the project's node_modules).
689
+ */
690
+ export async function buildLoaderBundle({ root, appDir, webEntry, exclude, i18n, tmpName = ".otfw-loaders", bust }) {
691
+ const files = discoverLoaders(appDir, exclude);
692
+ if (files.length === 0) return null;
693
+
694
+ const tmp = join(root, tmpName);
695
+ mkdirSync(tmp, { recursive: true });
696
+ const entry = join(tmp, "loaders-entry.js");
697
+ writeFileSync(entry, loaderEntrySource(files, appDir, i18n));
698
+
699
+ const serverApi = join(dirname(webEntry), "server", "index.js");
700
+ // Versioned filename, not a `?v=` query — Bun's ESM cache ignores the query for
701
+ // file URLs, so only a new path re-evaluates the rebuilt module (see buildApiBundle).
702
+ const outName = bust ? `loaders.${bust}.js` : "loaders.js";
703
+ await build({
704
+ input: entry,
705
+ platform: "node",
706
+ resolve: {
707
+ alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry },
708
+ extensions: EXTENSIONS,
709
+ },
710
+ external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
711
+ output: { dir: join(tmp, "out"), format: "esm", entryFileNames: outName },
712
+ checks: { pluginTimings: false },
713
+ });
714
+
715
+ const mod = await import(pathToFileURL(join(tmp, "out", outName)).href);
716
+ return {
717
+ loaders: mod.loaders,
718
+ files,
719
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Emit the loader bundle to `outDir/loaders.js` for production (`dist/server/`,
725
+ * next to `api.js`) — `otfw serve` imports it and runs loaders per request /
726
+ * serves the `<path>/__data.json` endpoint. Returns `{ files }`, or `null` when
727
+ * the app has no loader files.
728
+ */
729
+ export async function emitLoaderBundle({ root, appDir, webEntry, exclude, i18n, outDir }) {
730
+ const files = discoverLoaders(appDir, exclude);
731
+ if (files.length === 0) return null;
732
+
733
+ const tmp = join(root, ".otfw-loaders-build");
734
+ mkdirSync(tmp, { recursive: true });
735
+ const entry = join(tmp, "loaders-entry.js");
736
+ writeFileSync(entry, loaderEntrySource(files, appDir, i18n));
737
+
738
+ const serverApi = join(dirname(webEntry), "server", "index.js");
739
+ mkdirSync(outDir, { recursive: true });
740
+ await build({
741
+ input: entry,
742
+ platform: "node",
743
+ resolve: { alias: { "@opentf/web/server": serverApi, "@opentf/web": webEntry }, extensions: EXTENSIONS },
744
+ external: (id) => !id.startsWith(".") && !id.startsWith("/") && !id.startsWith("@opentf/web"),
745
+ output: { dir: outDir, format: "esm", entryFileNames: "loaders.js" },
746
+ checks: { pluginTimings: false },
747
+ });
748
+ rmSync(tmp, { recursive: true, force: true });
749
+ return { files };
750
+ }
751
+
327
752
  /** The optional `app/routeGuard.{js,ts}` path, or null. */
328
753
  export function findGuard(appDir) {
329
754
  return [join(appDir, "routeGuard.js"), join(appDir, "routeGuard.ts")].find(
@@ -339,7 +764,7 @@ export function findGuard(appDir) {
339
764
  * The production build imports the file directly (Rolldown code-splits it); the dev
340
765
  * server passes a `/__route/…` URL so the route compiles on first navigation.
341
766
  */
342
- export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null) {
767
+ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, nav = null, loaderRoutes = []) {
343
768
  const map = pages
344
769
  .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
345
770
  .join("\n");
@@ -353,11 +778,14 @@ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, na
353
778
  // Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
354
779
  // (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
355
780
  const navOpt = nav === "mpa" ? `\n nav: "mpa",` : "";
781
+ // Route patterns with a server loader (docs/DATA.md) — the router fetches
782
+ // `<path>/__data.json` for these on navigation.
783
+ const loadersOpt = loaderRoutes.length ? `\n loaders: ${JSON.stringify(loaderRoutes)},` : "";
356
784
  return (
357
785
  `import { mountApp } from "@opentf/web";\n` +
358
786
  (guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
359
787
  `mountApp({\n pages: {\n${map}\n },\n` +
360
- ` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
788
+ ` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}${loadersOpt}\n});\n`
361
789
  );
362
790
  }
363
791