@opentf/web-cli 1.2.0 → 1.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentf/web-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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"
@@ -20,7 +20,7 @@
20
20
  "bun": ">=1.0.0"
21
21
  },
22
22
  "dependencies": {
23
- "@opentf/web-compiler": "0.2.0",
23
+ "@opentf/web-compiler": "0.3.0",
24
24
  "@tailwindcss/node": "4.2.2",
25
25
  "@tailwindcss/oxide": "4.2.2",
26
26
  "rolldown": "1.0.0-rc.17",
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,
@@ -76,6 +82,7 @@ export async function runBuild(options = {}) {
76
82
  console.error(`✗ no page.jsx files found under ${appDir}`);
77
83
  process.exit(1);
78
84
  }
85
+ assertNoRouteConflicts(appDir, exclude);
79
86
 
80
87
  // Build the client for hydration when there will be server markup to adopt — the
81
88
  // SSR server (`runBuild({ hydrate: true })`) and `--ssg` pre-rendered pages. A
@@ -94,7 +101,9 @@ export async function runBuild(options = {}) {
94
101
  const tmp = join(root, ".otfw");
95
102
  mkdirSync(tmp, { recursive: true });
96
103
  const entry = join(tmp, "entry.js");
97
- 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));
98
107
 
99
108
  console.log("\n OTF Web — production build\n");
100
109
 
@@ -161,6 +170,24 @@ export async function runBuild(options = {}) {
161
170
  const chunks = result.output.filter((o) => o.type === "chunk").length;
162
171
  buildStep.done(`Compiled ${pages.length} routes · bundled ${chunks} chunks`);
163
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
+
164
191
  // SSG: pre-render each route into static HTML using the shell we just composed
165
192
  // (so per-route files carry the same bundle + stylesheet links).
166
193
  let ssg = null;
@@ -181,6 +208,7 @@ export async function runBuild(options = {}) {
181
208
  docsPlugins,
182
209
  lastUpdated,
183
210
  i18n: config?.i18n,
211
+ loaders,
184
212
  onCompile: (id) => ssgStep.update(`compiling ${basename(id)} (${++ssgCompiled})`),
185
213
  onRender: (done, total) => ssgStep.update(`rendering ${done}/${total}`),
186
214
  });
@@ -190,6 +218,12 @@ export async function runBuild(options = {}) {
190
218
  );
191
219
  }
192
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
+
193
227
  // Copy the public/ directory (static assets served at the root), if present.
194
228
  const publicDir = join(root, "public");
195
229
  if (existsSync(publicDir)) cpSync(publicDir, outDir, { recursive: true });
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
@@ -7,10 +7,12 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
 
9
9
  import {
10
+ DATA_FILE,
10
11
  buildServerBundle,
11
12
  injectHead,
12
13
  injectHydrationData,
13
14
  injectMarkup,
15
+ injectRouteData,
14
16
  withHtmlLang,
15
17
  } from "./shared.js";
16
18
 
@@ -81,7 +83,7 @@ function escapeXml(s) {
81
83
  * Pre-render the app to static HTML files under `outDir`. Returns
82
84
  * `{ count, skipped, failed }`.
83
85
  */
84
- 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 }) {
85
87
  const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n, onCompile });
86
88
 
87
89
  // i18n (docs/I18N.md §6): pre-render each route once per locale. The default
@@ -101,10 +103,20 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
101
103
  for (const locale of locales) {
102
104
  const urlPath = localizeFor(path, locale, defaultLocale);
103
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
+
104
116
  // Passing the localized URL pins router.locale (resolveLocale) and still
105
117
  // matches the locale-agnostic route table, so `t()` renders in `locale`.
106
118
  const { html, metadata, hydration } =
107
- (await mod.renderRoute(urlPath, params)) ?? { html: "", metadata: {}, hydration: "" };
119
+ (await mod.renderRoute(urlPath, params, "", { data })) ?? { html: "", metadata: {}, hydration: "" };
108
120
  const meta = i18nOn
109
121
  ? { ...metadata, links: [...(metadata.links || []), ...alternatesFor(path, locales, defaultLocale)] }
110
122
  : metadata;
@@ -117,15 +129,28 @@ export async function runPrerender({ root, pages, webEntry, otfwc, shellHtml, ou
117
129
  mkdirSync(dirname(file), { recursive: true });
118
130
  writeFileSync(
119
131
  file,
120
- injectHydrationData(
121
- injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
122
- hydration,
132
+ injectRouteData(
133
+ injectHydrationData(
134
+ injectMarkup(injectHead(withHtmlLang(shellHtml, locale), head), html),
135
+ hydration,
136
+ ),
137
+ dataJson,
123
138
  ),
124
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
+ }
125
149
  rendered.push(urlPath);
126
150
  } catch (e) {
127
151
  failed.push(urlPath);
128
- 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}`);
129
154
  }
130
155
  }
131
156
  }
package/src/serve.js CHANGED
@@ -16,25 +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,
36
38
  injectHydrationData,
37
39
  injectMarkup,
40
+ injectRouteData,
38
41
  loadConfig,
39
42
  loadDocsPlugins,
40
43
  loadProject,
@@ -150,19 +153,37 @@ export async function runServe() {
150
153
 
151
154
  const { mod, cleanup } = await buildServerBundle({ root, pages, webEntry, otfwc, docsPlugins, i18n });
152
155
 
153
- 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 = () => {
154
176
  try {
155
177
  cleanup();
156
178
  } catch {}
179
+ };
180
+ const shutdown = () => {
181
+ cleanupAll();
157
182
  process.exit(0);
158
183
  };
159
184
  process.once("SIGINT", shutdown);
160
185
  process.once("SIGTERM", shutdown);
161
- process.once("exit", () => {
162
- try {
163
- cleanup();
164
- } catch {}
165
- });
186
+ process.once("exit", cleanupAll);
166
187
 
167
188
  // Serve a built asset from dist/ (only regular files; a directory path falls
168
189
  // through to SSR). `dist/` already contains hashed assets, copied public/ files,
@@ -177,22 +198,45 @@ export async function runServe() {
177
198
  });
178
199
  }
179
200
 
180
- // SSR one navigation: render the route's markup + <head>, inject into the shell.
181
- async function render(url) {
182
- const result = await mod.renderRoute(url.pathname, null, url.search);
183
- if (!result) {
184
- const notFound = join(distDir, "404.html");
185
- const body = existsSync(notFound) ? readFileSync(notFound) : "<h1>404 — Not Found</h1>";
186
- 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
+ }
187
229
  }
230
+ const result = await mod.renderRoute(url.pathname, null, url.search, { data });
231
+ if (!result) return renderNotFound();
188
232
  const meta = i18nOn
189
233
  ? { ...result.metadata, links: [...(result.metadata.links || []), ...alternatesFor(stripLocale(url.pathname))] }
190
234
  : result.metadata;
191
235
  const head = mod.renderHead(meta, { path: url.pathname, baseUrl });
192
236
  const localizedShell = i18nOn ? withHtmlLang(shell, localeOf(url.pathname)) : shell;
193
- const html = injectHydrationData(
194
- injectMarkup(injectHead(localizedShell, head), result.html),
195
- result.hydration,
237
+ const html = injectRouteData(
238
+ injectHydrationData(injectMarkup(injectHead(localizedShell, head), result.html), result.hydration),
239
+ dataJson,
196
240
  );
197
241
  // 200 for a real route; 404 when the path fell back to the registered 404 page.
198
242
  return new Response(html, {
@@ -204,6 +248,22 @@ export async function runServe() {
204
248
  const server = serve(startPort, explicitPort, {
205
249
  async fetch(req) {
206
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
+ }
207
267
  // A path with a file extension is an asset request; serve it from dist/. A
208
268
  // miss on a real asset is a 404 (don't fall through to the SSR shell).
209
269
  if (/\.[a-z0-9]+$/i.test(url.pathname) && url.pathname !== "/") {
@@ -221,7 +281,7 @@ export async function runServe() {
221
281
  }
222
282
  }
223
283
  try {
224
- return await render(url);
284
+ return await render(url, req);
225
285
  } catch (e) {
226
286
  console.error(`✗ SSR failed for ${url.pathname}: ${e?.message ?? e}`);
227
287
  return new Response(`<pre>SSR error: ${e?.message ?? e}</pre>`, {
@@ -233,6 +293,8 @@ export async function runServe() {
233
293
  });
234
294
 
235
295
  console.log(`\n OTF Web SSR server`);
236
- 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})`);
237
299
  console.log(` ✓ ready in ${Date.now() - bootStart}ms\n`);
238
300
  }
package/src/shared.js CHANGED
@@ -73,6 +73,23 @@ export function injectHydrationData(shellHtml, json) {
73
73
  return injectBeforeBody(shellHtml, `<script type="application/json" id="__otfw_h">${json}</script>`);
74
74
  }
75
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
+
76
93
  /**
77
94
  * Stamp the `data-otfw-hydrate` sentinel onto the shell's `#app` container, telling
78
95
  * the client to *adopt* the server-rendered DOM on first paint instead of rebuilding
@@ -413,6 +430,325 @@ export function discoverPages(dir, exclude) {
413
430
  return out;
414
431
  }
415
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
+
416
752
  /** The optional `app/routeGuard.{js,ts}` path, or null. */
417
753
  export function findGuard(appDir) {
418
754
  return [join(appDir, "routeGuard.js"), join(appDir, "routeGuard.ts")].find(
@@ -428,7 +764,7 @@ export function findGuard(appDir) {
428
764
  * The production build imports the file directly (Rolldown code-splits it); the dev
429
765
  * server passes a `/__route/…` URL so the route compiles on first navigation.
430
766
  */
431
- 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 = []) {
432
768
  const map = pages
433
769
  .map((p) => ` [${JSON.stringify(p)}]: () => import(${JSON.stringify(loaderUrl(p))}),`)
434
770
  .join("\n");
@@ -442,11 +778,14 @@ export function entrySource(pages, appDir, loaderUrl = (p) => p, i18n = null, na
442
778
  // Navigation mode (otfw.config `nav`): "mpa" disables client-side link interception
443
779
  // (docs/HYDRATION.md §7). Only emitted when explicitly "mpa"; "spa" is the default.
444
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)},` : "";
445
784
  return (
446
785
  `import { mountApp } from "@opentf/web";\n` +
447
786
  (guard ? `import guard from ${JSON.stringify(guard)};\n` : "") +
448
787
  `mountApp({\n pages: {\n${map}\n },\n` +
449
- ` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}\n});\n`
788
+ ` target: document.getElementById("app"),${guard ? "\n guard," : ""}${i18nOpt}${navOpt}${loadersOpt}\n});\n`
450
789
  );
451
790
  }
452
791