@uniflowed/vite 0.0.0-alpha.12 → 0.0.0-alpha.14

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.
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  /** The file names the router reserves inside the router root. */
21
21
  export const RESERVED = Object.freeze({
22
22
  layout: "_uf.layout",
23
+ template: "_uf.template",
23
24
  page: "_uf.page",
24
25
  middleware: "_uf.middleware",
25
26
  notFound: "_uf.not-found",
@@ -28,6 +29,31 @@ export const RESERVED = Object.freeze({
28
29
  route: "_uf.route",
29
30
  });
30
31
 
32
+ /**
33
+ * Directory names uf reserves inside the router root without serving them.
34
+ *
35
+ * One spelling each, and they are here so `crates/uf_router/tests/
36
+ * reserved_names.rs` can hold this router and `uf_router::RouteSegment` to the
37
+ * same list — the way it already holds the two to the same `_uf.*` roles. A
38
+ * spelling one router refuses and the other serves as a URL is exactly the
39
+ * disagreement that made this necessary.
40
+ *
41
+ * `@team` is Next.js's parallel-route slot and `(.)photo` its intercepting
42
+ * route. uf has neither, and until #267 both fell through to "a literal URL
43
+ * segment": `@team` became `/@team`, `(.)photo` became `/(.)photo` — the test
44
+ * for a `(group)` is that the segment *ends* in `)` — and the generated
45
+ * `RoutePath` union contained them, so `route("/@team", …)` type checked. A
46
+ * convention served as nonsense is worse than one that is refused, because the
47
+ * project looks like it works.
48
+ */
49
+ export const UNSUPPORTED_SEGMENTS = Object.freeze([
50
+ "@team",
51
+ "(.)photo",
52
+ "(..)photo",
53
+ "(...)photo",
54
+ "(..)(..)photo",
55
+ ]);
56
+
31
57
  /** Extensions a page or layout may use; `.mdx` is a page written as content. */
32
58
  const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
33
59
  const MODULE_EXTENSIONS = [".js", ".jsx"];
@@ -47,6 +73,8 @@ const MAX_DEPTH = 32;
47
73
  * @property {ReadonlyArray<{above: number, module: string}>} loading the
48
74
  * `<Suspense>` boundaries in scope, root first; `above` is how many of
49
75
  * `layouts` are outside each one
76
+ * @property {ReadonlyArray<{above: number, module: string}>} templates the
77
+ * `_uf.template.js` wrappers in scope, root first, with the same `above`
50
78
  * @property {boolean} mdx whether the page is MDX content
51
79
  */
52
80
 
@@ -86,7 +114,9 @@ const MAX_DEPTH = 32;
86
114
  *
87
115
  * @typedef {object} NotFoundBoundary
88
116
  * @property {string} path route path of the directory that declares it
89
- * @property {string} page absolute path of the page module
117
+ * @property {?string} page absolute path of the page module, or `null` for the
118
+ * record the scan synthesises at the router root when a project declares
119
+ * none — see `scanRoutes`
90
120
  * @property {ReadonlyArray<string>} layouts absolute paths, root first
91
121
  * @property {boolean} mdx whether the page is MDX content
92
122
  */
@@ -103,7 +133,8 @@ const MAX_DEPTH = 32;
103
133
  *
104
134
  * @typedef {object} ErrorBoundary
105
135
  * @property {string} path route path of the directory that declares it
106
- * @property {string} module absolute path of the error module
136
+ * @property {?string} module absolute path of the error module, or `null` for
137
+ * the synthesised root record
107
138
  * @property {ReadonlyArray<string>} layouts absolute paths, root first
108
139
  */
109
140
 
@@ -136,6 +167,10 @@ const MAX_DEPTH = 32;
136
167
  * Directories that do not exist yield an empty table rather than an error: a
137
168
  * library project has no router root, and that is not a mistake.
138
169
  *
170
+ * Throws for a directory named the way a parallel route or an intercepting
171
+ * route is spelled: uf has neither, and both used to become literal URL
172
+ * segments. See {@link UNSUPPORTED_SEGMENTS}.
173
+ *
139
174
  * @param {string} appRoot absolute path of the router root (`app/`)
140
175
  * @returns {{
141
176
  * routes: Route[],
@@ -153,7 +188,11 @@ export function scanRoutes(appRoot) {
153
188
  const errors = [];
154
189
  if (!isDirectory(appRoot)) return { routes, handlers, middleware, notFound, errors };
155
190
 
156
- const walk = (directory, segments, layouts, loading, depth) => {
191
+ // The layouts in scope at the router root, kept because the two synthesised
192
+ // records below are made of them. See the note beside them.
193
+ let rootLayouts = [];
194
+
195
+ const walk = (directory, segments, layouts, loading, templates, depth) => {
157
196
  if (depth > MAX_DEPTH) return;
158
197
  const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
159
198
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
@@ -161,6 +200,9 @@ export function scanRoutes(appRoot) {
161
200
 
162
201
  const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
163
202
  const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
203
+ if (depth === 0) {
204
+ rootLayouts = nextLayouts;
205
+ }
164
206
 
165
207
  // Inside this directory's own layout, which is where Next.js puts it and
166
208
  // the only placement that makes sense: the fallback is what shows *within*
@@ -173,6 +215,17 @@ export function scanRoutes(appRoot) {
173
215
  ? [...loading, { above: nextLayouts.length, module: ownLoading }]
174
216
  : loading;
175
217
 
218
+ // A template accumulates the way a layout does, and is placed the way a
219
+ // loading file is: inside its own segment's layout and outside everything
220
+ // below, so `nextLayouts.length` is taken after the own layout is added.
221
+ // Every template above a route is on that route, one inside the next, for
222
+ // the reason every layout is — the difference between the two is a `key`,
223
+ // not a shape.
224
+ const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS);
225
+ const nextTemplates = ownTemplate
226
+ ? [...templates, { above: nextLayouts.length, module: ownTemplate }]
227
+ : templates;
228
+
176
229
  // A middleware guards this directory and everything below it, whether or
177
230
  // not this directory is itself a route: `app/dashboard/_uf.middleware.js`
178
231
  // with no `_uf.page.js` beside it still guards `/dashboard/settings`.
@@ -191,6 +244,7 @@ export function scanRoutes(appRoot) {
191
244
  page,
192
245
  layouts: nextLayouts,
193
246
  loading: nextLoading,
247
+ templates: nextTemplates,
194
248
  mdx: page.endsWith(".mdx"),
195
249
  });
196
250
  }
@@ -233,17 +287,49 @@ export function scanRoutes(appRoot) {
233
287
  // A leading dot or underscore is private to the author: `_components/`
234
288
  // beside a page is a place to put things, not a route.
235
289
  if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
290
+ // Checked before descending, and after the private-directory test for
291
+ // the same reason `uf_router` prunes them: `app/_drafts/@team/` is not a
292
+ // route uf would have served, so it is not one to refuse.
293
+ const refused = unsupportedSegmentReason(entry.name);
294
+ if (refused != null) {
295
+ throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
296
+ }
236
297
  walk(
237
298
  path.join(directory, entry.name),
238
299
  [...segments, entry.name],
239
300
  nextLayouts,
240
301
  nextLoading,
302
+ nextTemplates,
241
303
  depth + 1,
242
304
  );
243
305
  }
244
306
  };
245
307
 
246
- walk(appRoot, [], [], [], 0);
308
+ walk(appRoot, [], [], [], [], 0);
309
+
310
+ // A boundary at the router root for a project that declared none, carrying
311
+ // the root's layouts and no module of its own.
312
+ //
313
+ // Without it the router had no record to answer an unmatched URL with, so it
314
+ // answered with the framework's page and `layouts: []` — and a site whose
315
+ // root layout owns the masthead, the stylesheet and often `<html>` itself
316
+ // replied to a stale link with a white page saying 404, with no way to leave
317
+ // it. That was never the nearest-ancestor rule failing: the rule had nothing
318
+ // to find. `uf create` scaffolds neither boundary, so this is the state every
319
+ // new project is in until it writes one. See ubugeeei-prod/uf#351.
320
+ //
321
+ // Only when nothing is at `/` already. A `(group)` directory is not a URL
322
+ // segment, so `app/(marketing)/_uf.not-found.js` is a boundary at `/` too and
323
+ // adding a second one there would put a second answer at a path the URL
324
+ // cannot choose between.
325
+ const atRoot = (boundaries) => boundaries.some((boundary) => boundary.path === "/");
326
+ if (!atRoot(notFound)) {
327
+ notFound.push({ path: "/", page: null, layouts: rootLayouts, mdx: false });
328
+ }
329
+ if (!atRoot(errors)) {
330
+ errors.push({ path: "/", module: null, layouts: rootLayouts });
331
+ }
332
+
247
333
  const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
248
334
  routes.sort(byPath);
249
335
  handlers.sort(byPath);
@@ -287,27 +373,112 @@ function findModule(directory, stem, extensions) {
287
373
  return null;
288
374
  }
289
375
 
376
+ /**
377
+ * What one directory name means to the route path.
378
+ *
379
+ * Mirrors `uf_router::classify_route_segment`, which is the same six answers
380
+ * in the same order. The order is load-bearing in one place: an interception
381
+ * marker is a `(…)` *prefix* with a route after it, and a `(group)` is a
382
+ * segment that ends in `)`, so the interception test has to come first or
383
+ * every group would be read as one.
384
+ *
385
+ * @param {string} segment one directory name
386
+ * @returns {{kind: "group"}
387
+ * | {kind: "param", name: string}
388
+ * | {kind: "catchAll", name: string}
389
+ * | {kind: "literal", name: string}
390
+ * | {kind: "slot", name: string}
391
+ * | {kind: "interception", marker: string, route: string}}
392
+ */
393
+ export function classifyRouteSegment(segment) {
394
+ if (segment.startsWith("@")) return { kind: "slot", name: segment.slice(1) };
395
+ const intercepted = interceptionMarker(segment);
396
+ if (intercepted != null) return { kind: "interception", ...intercepted };
397
+ if (segment.startsWith("(") && segment.endsWith(")")) return { kind: "group" };
398
+ if (segment.startsWith("[...") && segment.endsWith("]")) {
399
+ return { kind: "catchAll", name: segment.slice(4, -1) };
400
+ }
401
+ if (segment.startsWith("[") && segment.endsWith("]")) {
402
+ return { kind: "param", name: segment.slice(1, -1) };
403
+ }
404
+ return { kind: "literal", name: segment };
405
+ }
406
+
407
+ /**
408
+ * The `(.)`-style prefix of `segment` and the route after it, or `null`.
409
+ *
410
+ * One or more of `(.)`, `(..)` and `(...)` — every marker Next.js defines;
411
+ * `(..)(..)` is two of them rather than a fourth — followed by something for
412
+ * them to intercept. A marker with nothing after it names no route and is the
413
+ * `(group)` it has always been.
414
+ */
415
+ function interceptionMarker(segment) {
416
+ let consumed = 0;
417
+ while (segment[consumed] === "(") {
418
+ const close = segment.indexOf(")", consumed);
419
+ if (close === -1) break;
420
+ const inner = segment.slice(consumed + 1, close);
421
+ if (inner.length === 0 || inner.length > 3 || /[^.]/.test(inner)) break;
422
+ consumed = close + 1;
423
+ }
424
+ if (consumed === 0 || consumed === segment.length) return null;
425
+ return { marker: segment.slice(0, consumed), route: segment.slice(consumed) };
426
+ }
427
+
428
+ /**
429
+ * Why uf refuses a directory named `segment`, or `null` when it serves it.
430
+ *
431
+ * The message is this router's own rather than `uf_router`'s, because the two
432
+ * are reached differently: the Rust one fails `uf build` and `uf dev` through
433
+ * the route manifest, and this one fails a project driving Vite itself. Both
434
+ * say the same two things — which feature the spelling belongs to, and that it
435
+ * is refused rather than served as a URL.
436
+ */
437
+ export function unsupportedSegmentReason(segment) {
438
+ const classified = classifyRouteSegment(segment);
439
+ if (classified.kind === "slot") {
440
+ return (
441
+ `\`${segment}\` is a parallel-route slot, and uf does not have parallel routes — a route ` +
442
+ "here renders in one place, so there is nothing for a slot to render into. It is refused " +
443
+ `rather than served as the URL segment \`/${segment}\`, which is what it used to become. ` +
444
+ "Rename the directory; a URL segment that really starts with `@` has no spelling in this " +
445
+ "grammar, so capture it with a `[param]`. https://github.com/ubugeeei-prod/uf/issues/267"
446
+ );
447
+ }
448
+ if (classified.kind === "interception") {
449
+ return (
450
+ `\`${segment}\` is an intercepting route, and uf does not have interception — a navigation ` +
451
+ "carries where it is going and not where it came from, so nothing here could match " +
452
+ `\`${classified.route}\`. It is refused rather than served as the URL segment ` +
453
+ `\`/${segment}\`, which is what it used to become. Move the route to the path it belongs ` +
454
+ "at, or rename the directory. https://github.com/ubugeeei-prod/uf/issues/267"
455
+ );
456
+ }
457
+ return null;
458
+ }
459
+
290
460
  /**
291
461
  * Turn directory segments into a route path and its parameters.
292
462
  *
293
463
  * `(group)` segments organise files without appearing in the URL, `[name]`
294
- * captures one segment, and `[...name]` captures the rest of the path.
464
+ * captures one segment, and `[...name]` captures the rest of the path. A slot
465
+ * or an interception never reaches here: {@link scanRoutes} refuses the
466
+ * directory before it walks into it.
295
467
  */
296
468
  export function routeFromSegments(segments) {
297
469
  const params = [];
298
470
  const out = [];
299
471
  for (const segment of segments) {
300
- if (segment.startsWith("(") && segment.endsWith(")")) continue;
301
- if (segment.startsWith("[...") && segment.endsWith("]")) {
302
- const name = segment.slice(4, -1);
303
- params.push({ name, catchAll: true });
304
- out.push(`:${name}*`);
472
+ const classified = classifyRouteSegment(segment);
473
+ if (classified.kind === "group") continue;
474
+ if (classified.kind === "catchAll") {
475
+ params.push({ name: classified.name, catchAll: true });
476
+ out.push(`:${classified.name}*`);
305
477
  continue;
306
478
  }
307
- if (segment.startsWith("[") && segment.endsWith("]")) {
308
- const name = segment.slice(1, -1);
309
- params.push({ name, catchAll: false });
310
- out.push(`:${name}`);
479
+ if (classified.kind === "param") {
480
+ params.push({ name: classified.name, catchAll: false });
481
+ out.push(`:${classified.name}`);
311
482
  continue;
312
483
  }
313
484
  out.push(segment);
@@ -316,11 +487,21 @@ export function routeFromSegments(segments) {
316
487
  return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
317
488
  }
318
489
 
319
- /** Virtual module ids the router plugin serves. */
490
+ /**
491
+ * Virtual module ids the router plugin serves.
492
+ *
493
+ * `actions` is the one that does not come from this file's directory scan: it
494
+ * is generated from the RSC manifest by `internal/rsc.js`, because which
495
+ * `"use server"` exports are callable endpoints is an answer about the module
496
+ * graph and not about the filesystem. It is here because it is a virtual
497
+ * module id and this is where they are named, and because
498
+ * `serverModuleSource` below is the only thing that imports it.
499
+ */
320
500
  export const VIRTUAL = Object.freeze({
321
501
  routes: "virtual:uf/routes",
322
502
  client: "virtual:uf/client",
323
503
  server: "virtual:uf/server",
504
+ actions: "virtual:uf/actions",
324
505
  });
325
506
 
326
507
  /**
@@ -366,6 +547,25 @@ export const VIRTUAL = Object.freeze({
366
547
  * `virtual:uf/server` is generated with the default and always will be: the
367
548
  * server renders every route, so its table is the complete one.
368
549
  *
550
+ * # `relativeTo`, and the one string in this table a browser can read
551
+ *
552
+ * Every `import()` here is a specifier Vite resolves and rewrites to a chunk
553
+ * URL, so no absolute path survives the build — except `file`, which is a
554
+ * string. It is the route's source path, kept for diagnostics: the middleware
555
+ * table's is what names a module in an error, and `router.js`'s generated
556
+ * types are about the same files.
557
+ *
558
+ * The server's table can hold an absolute path; it is read on the machine that
559
+ * has those files. The browser's cannot, because that table is downloaded:
560
+ * uf's own manual shipped `/home/<user>/…/docs/app/guide/cache/_uf.page.mdx`
561
+ * for each of thirty-four routes to every visitor, which publishes the build
562
+ * machine's layout and its user's name for nothing — the browser has no
563
+ * filesystem to resolve them against and reads them only in a message.
564
+ *
565
+ * So the client call passes the project root and every `file` here is emitted
566
+ * relative to it. Diagnostics keep a path a person can act on — a shorter one
567
+ * — and a deploy stops describing the machine it was built on.
568
+ *
369
569
  * @param {{
370
570
  * routes: Route[],
371
571
  * handlers?: Handler[],
@@ -373,10 +573,29 @@ export const VIRTUAL = Object.freeze({
373
573
  * notFound?: NotFoundBoundary[],
374
574
  * errors?: ErrorBoundary[],
375
575
  * }} table
376
- * @param {{shipsPage?: (route: Route) => boolean}} [options]
576
+ * @param {{
577
+ * shipsPage?: (route: Route) => boolean,
578
+ * relativeTo?: string,
579
+ * }} [options]
377
580
  */
378
581
  export function routesModuleSource(table, options = {}) {
379
582
  const shipsPage = options.shipsPage ?? (() => true);
583
+ const relativeTo = options.relativeTo ?? null;
584
+ /**
585
+ * A `file` as this table should state it.
586
+ *
587
+ * Relative even when that means leading `..` segments — a module outside the
588
+ * project root is rare and a `../` path still says where it is without
589
+ * saying where the machine is, which is the whole property. Separators are
590
+ * POSIX because this string is read wherever the bundle is opened rather
591
+ * than where it was written.
592
+ */
593
+ const displayFile = (file) => {
594
+ if (relativeTo == null || file == null) {
595
+ return file;
596
+ }
597
+ return path.relative(relativeTo, file).split(path.sep).join("/");
598
+ };
380
599
  const layoutIds = new Map();
381
600
  const layoutImports = [];
382
601
  const layoutId = (file) => {
@@ -412,32 +631,63 @@ export function routesModuleSource(table, options = {}) {
412
631
  return id;
413
632
  };
414
633
 
634
+ // Templates are deduplicated for the reason layouts are — one
635
+ // `app/_uf.template.js` wraps every route under it — and are lazy for the
636
+ // reason layouts are too: a template is part of the route's own tree rather
637
+ // than a fallback React has to have in hand at the moment something goes
638
+ // wrong, so it is awaited with the layouts before the first render.
639
+ const templateIds = new Map();
640
+ const templateImports = [];
641
+ const templateId = (file) => {
642
+ let id = templateIds.get(file);
643
+ if (id === undefined) {
644
+ id = `template${templateIds.size}`;
645
+ templateIds.set(file, id);
646
+ templateImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
647
+ }
648
+ return id;
649
+ };
650
+
415
651
  const entries = table.routes.map((route) => {
416
652
  if (!shipsPage(route)) {
417
653
  return ` {
418
654
  path: ${JSON.stringify(route.path)},
419
655
  params: ${JSON.stringify(route.params)},
420
656
  mdx: ${route.mdx},
421
- file: ${JSON.stringify(route.page)},
657
+ file: ${JSON.stringify(displayFile(route.page))},
422
658
  layouts: [],
423
659
  loading: [],
660
+ templates: [],
424
661
  }`;
425
662
  }
426
663
  const layouts = route.layouts.map(layoutId);
427
664
  const loading = (route.loading ?? []).map(
428
665
  (boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`,
429
666
  );
667
+ const templates = (route.templates ?? []).map(
668
+ (entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`,
669
+ );
430
670
  return ` {
431
671
  path: ${JSON.stringify(route.path)},
432
672
  params: ${JSON.stringify(route.params)},
433
673
  mdx: ${route.mdx},
434
- file: ${JSON.stringify(route.page)},
674
+ file: ${JSON.stringify(displayFile(route.page))},
435
675
  page: () => import(${JSON.stringify(route.page)}),
436
676
  layouts: [${layouts.join(", ")}],
437
677
  loading: [${loading.join(", ")}],
678
+ templates: [${templates.join(", ")}],
438
679
  }`;
439
680
  });
440
681
 
682
+ // A boundary the scan synthesised has no module to import — the framework's
683
+ // own page renders in its place — so it emits `null` where a declared one
684
+ // emits a loader, and a name for `file` rather than a path nothing wrote.
685
+ // See the note in `scanRoutes` and ubugeeei-prod/uf#351.
686
+ const SYNTHESISED = JSON.stringify("@uniflowed/router");
687
+ const boundaryModule = (file) =>
688
+ file == null ? "null" : `() => import(${JSON.stringify(file)})`;
689
+ const boundaryFile = (file) => (file == null ? SYNTHESISED : JSON.stringify(displayFile(file)));
690
+
441
691
  // A list, because a not-found is a segment file: every directory may declare
442
692
  // one and the router takes the nearest above the path. `layoutId` is the
443
693
  // same table the routes use, so a boundary that shares a layout with a page
@@ -446,8 +696,8 @@ export function routesModuleSource(table, options = {}) {
446
696
  (boundary) => ` {
447
697
  path: ${JSON.stringify(boundary.path)},
448
698
  mdx: ${boundary.mdx},
449
- file: ${JSON.stringify(boundary.page)},
450
- page: () => import(${JSON.stringify(boundary.page)}),
699
+ file: ${boundaryFile(boundary.page)},
700
+ page: ${boundaryModule(boundary.page)},
451
701
  layouts: [${boundary.layouts.map(layoutId).join(", ")}],
452
702
  }`,
453
703
  );
@@ -459,8 +709,8 @@ export function routesModuleSource(table, options = {}) {
459
709
  const errorEntries = (table.errors ?? []).map(
460
710
  (boundary) => ` {
461
711
  path: ${JSON.stringify(boundary.path)},
462
- file: ${JSON.stringify(boundary.module)},
463
- module: () => import(${JSON.stringify(boundary.module)}),
712
+ file: ${boundaryFile(boundary.module)},
713
+ module: ${boundaryModule(boundary.module)},
464
714
  layouts: [${boundary.layouts.map(layoutId).join(", ")}],
465
715
  }`,
466
716
  );
@@ -472,7 +722,7 @@ export function routesModuleSource(table, options = {}) {
472
722
  (handler) => ` {
473
723
  path: ${JSON.stringify(handler.path)},
474
724
  params: ${JSON.stringify(handler.params)},
475
- file: ${JSON.stringify(handler.module)},
725
+ file: ${JSON.stringify(displayFile(handler.module))},
476
726
  load: () => import(${JSON.stringify(handler.module)}),
477
727
  }`,
478
728
  );
@@ -485,7 +735,7 @@ export function routesModuleSource(table, options = {}) {
485
735
  const middlewareEntries = (table.middleware ?? []).map(
486
736
  (entry) => ` {
487
737
  path: ${JSON.stringify(entry.path)},
488
- file: ${JSON.stringify(entry.module)},
738
+ file: ${JSON.stringify(displayFile(entry.module))},
489
739
  load: () => import(${JSON.stringify(entry.module)}),
490
740
  }`,
491
741
  );
@@ -493,13 +743,18 @@ export function routesModuleSource(table, options = {}) {
493
743
  // Last, because it is defined by what everything above did *not* import: a
494
744
  // layout a kept route also uses is already in the graph as a lazy chunk, and
495
745
  // importing it here as well would pull it into the entry chunk instead.
496
- const carried = new Set([...layoutIds.keys(), ...loadingIds.keys()]);
746
+ const carried = new Set([...layoutIds.keys(), ...loadingIds.keys(), ...templateIds.keys()]);
497
747
  const styleOnlyImports = [];
498
748
  for (const route of table.routes) {
499
749
  if (shipsPage(route)) {
500
750
  continue;
501
751
  }
502
- const files = [route.page, ...route.layouts, ...(route.loading ?? []).map((it) => it.module)];
752
+ const files = [
753
+ route.page,
754
+ ...route.layouts,
755
+ ...(route.loading ?? []).map((it) => it.module),
756
+ ...(route.templates ?? []).map((it) => it.module),
757
+ ];
503
758
  for (const file of files) {
504
759
  if (carried.has(file)) {
505
760
  continue;
@@ -509,7 +764,7 @@ export function routesModuleSource(table, options = {}) {
509
764
  }
510
765
  }
511
766
 
512
- return `${[...styleOnlyImports, ...layoutImports, ...loadingImports].join("\n")}
767
+ return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports].join("\n")}
513
768
  export const routes = [
514
769
  ${entries.join(",\n")}
515
770
  ];
@@ -558,6 +813,16 @@ hydrate({ App, routes, notFound, errors });
558
813
  * request — one decides whether the router is reached at all, the others
559
814
  * decide what the router renders when it is.
560
815
  *
816
+ * `callAction` goes between the two, and its position is the same argument
817
+ * made twice. Below `runMiddleware`, because an action call is a request to a
818
+ * path and the guard on that path is owed the same say over it as over the
819
+ * page — which is why the call is a `POST` to the page's own URL rather than
820
+ * to a reserved one. Above `dispatch`, because a request that names an action
821
+ * has named it: letting it fall through to a route handler that happens to sit
822
+ * at the same path would answer somebody's action with somebody else's
823
+ * function. It declines every request that carries no action id, so a project
824
+ * with no actions pays one `headers.get` per request and nothing else.
825
+ *
561
826
  * `internal/serve.js` and `driver.js` call them in that order, and
562
827
  * `packages/vite/index.js` does the same for a project driving Vite itself.
563
828
  *
@@ -583,11 +848,13 @@ hydrate({ App, routes, notFound, errors });
583
848
  */
584
849
  export function serverModuleSource(appEntry) {
585
850
  return `import {
851
+ createActionDispatcher,
586
852
  createDispatcher,
587
853
  createMiddlewareRunner,
588
854
  createRenderer,
589
855
  } from "@uniflowed/router/server";
590
856
  import { routes, handlers, middleware, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
857
+ import { actions } from ${JSON.stringify(VIRTUAL.actions)};
591
858
  import App from ${JSON.stringify(appEntry)};
592
859
  export { routes, handlers, middleware, notFound, errors };
593
860
  export { beginRequest } from "@uniflowed/router/server";
@@ -595,6 +862,7 @@ const renderer = createRenderer({ App, routes, notFound, errors });
595
862
  export const render = renderer.render;
596
863
  export const prerender = renderer.prerender;
597
864
  export const dispatch = createDispatcher({ handlers });
865
+ export const callAction = createActionDispatcher({ actions });
598
866
  export const runMiddleware = createMiddlewareRunner({ middleware });
599
867
  `;
600
868
  }