@timber-js/app 0.2.0-alpha.96 → 0.2.0-alpha.98

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.
Files changed (104) hide show
  1. package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
  2. package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  3. package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
  4. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
  5. package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
  6. package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
  7. package/dist/adapters/nitro.d.ts.map +1 -1
  8. package/dist/adapters/nitro.js +55 -5
  9. package/dist/adapters/nitro.js.map +1 -1
  10. package/dist/client/index.js +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +189 -62
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugins/build-report.d.ts +6 -4
  15. package/dist/plugins/build-report.d.ts.map +1 -1
  16. package/dist/plugins/dev-404-page.d.ts +8 -18
  17. package/dist/plugins/dev-404-page.d.ts.map +1 -1
  18. package/dist/routing/index.d.ts +5 -3
  19. package/dist/routing/index.d.ts.map +1 -1
  20. package/dist/routing/index.js +3 -3
  21. package/dist/routing/link-codegen.d.ts.map +1 -1
  22. package/dist/routing/scanner.d.ts +1 -10
  23. package/dist/routing/scanner.d.ts.map +1 -1
  24. package/dist/routing/segment-classify.d.ts +37 -8
  25. package/dist/routing/segment-classify.d.ts.map +1 -1
  26. package/dist/routing/types.d.ts +63 -23
  27. package/dist/routing/types.d.ts.map +1 -1
  28. package/dist/routing/walkers.d.ts +51 -0
  29. package/dist/routing/walkers.d.ts.map +1 -0
  30. package/dist/server/action-handler.d.ts.map +1 -1
  31. package/dist/server/dev-holding-server.d.ts +4 -2
  32. package/dist/server/dev-holding-server.d.ts.map +1 -1
  33. package/dist/server/html-injector-core.d.ts +212 -0
  34. package/dist/server/html-injector-core.d.ts.map +1 -0
  35. package/dist/server/html-injectors.d.ts +59 -59
  36. package/dist/server/html-injectors.d.ts.map +1 -1
  37. package/dist/server/internal.js +710 -563
  38. package/dist/server/internal.js.map +1 -1
  39. package/dist/server/node-stream-transforms.d.ts +46 -49
  40. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  41. package/dist/server/pipeline-helpers.d.ts +88 -0
  42. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  43. package/dist/server/pipeline-phases.d.ts +97 -0
  44. package/dist/server/pipeline-phases.d.ts.map +1 -0
  45. package/dist/server/pipeline.d.ts +53 -32
  46. package/dist/server/pipeline.d.ts.map +1 -1
  47. package/dist/server/port-resolution.d.ts +117 -0
  48. package/dist/server/port-resolution.d.ts.map +1 -0
  49. package/dist/server/route-matcher.d.ts +20 -47
  50. package/dist/server/route-matcher.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  53. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  54. package/dist/server/status-code-resolver.d.ts +16 -11
  55. package/dist/server/status-code-resolver.d.ts.map +1 -1
  56. package/dist/server/tree-builder.d.ts.map +1 -1
  57. package/dist/utils/directive-parser.d.ts +0 -45
  58. package/dist/utils/directive-parser.d.ts.map +1 -1
  59. package/package.json +7 -6
  60. package/src/adapters/nitro.ts +55 -5
  61. package/src/cli.ts +0 -0
  62. package/src/index.ts +84 -31
  63. package/src/plugins/build-report.ts +13 -22
  64. package/src/plugins/dev-404-page.ts +15 -41
  65. package/src/plugins/routing.ts +14 -12
  66. package/src/routing/codegen.ts +1 -1
  67. package/src/routing/convention-lint.ts +4 -4
  68. package/src/routing/index.ts +5 -3
  69. package/src/routing/interception.ts +1 -1
  70. package/src/routing/link-codegen.ts +25 -13
  71. package/src/routing/scanner.ts +17 -93
  72. package/src/routing/segment-classify.ts +107 -8
  73. package/src/routing/status-file-lint.ts +3 -3
  74. package/src/routing/types.ts +63 -23
  75. package/src/routing/walkers.ts +90 -0
  76. package/src/server/action-handler.ts +6 -0
  77. package/src/server/deny-renderer.ts +5 -5
  78. package/src/server/dev-holding-server.ts +4 -2
  79. package/src/server/fallback-error.ts +1 -1
  80. package/src/server/html-injector-core.ts +403 -0
  81. package/src/server/html-injectors.ts +158 -297
  82. package/src/server/node-stream-transforms.ts +108 -248
  83. package/src/server/pipeline-helpers.ts +180 -0
  84. package/src/server/pipeline-phases.ts +591 -0
  85. package/src/server/pipeline.ts +76 -539
  86. package/src/server/port-resolution.ts +215 -0
  87. package/src/server/route-element-builder.ts +1 -1
  88. package/src/server/route-matcher.ts +28 -60
  89. package/src/server/rsc-entry/api-handler.ts +2 -2
  90. package/src/server/rsc-entry/error-renderer.ts +1 -1
  91. package/src/server/rsc-entry/index.ts +52 -98
  92. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  93. package/src/server/sitemap-generator.ts +1 -1
  94. package/src/server/slot-resolver.ts +1 -1
  95. package/src/server/status-code-resolver.ts +112 -128
  96. package/src/server/tree-builder.ts +6 -4
  97. package/src/utils/directive-parser.ts +0 -392
  98. package/LICENSE +0 -8
  99. package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
  100. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  101. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  102. package/dist/server/manifest-status-resolver.d.ts +0 -58
  103. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  104. package/src/server/manifest-status-resolver.ts +0 -215
@@ -17,6 +17,7 @@ import { gzipSync } from 'node:zlib';
17
17
  import type { Plugin, Logger } from 'vite';
18
18
  import type { PluginContext } from '../plugin-context.js';
19
19
  import type { SegmentNode, RouteTree } from '../routing/types.js';
20
+ import { collectLeafRoutes } from '../routing/walkers.js';
20
21
  import { formatSize } from '../utils/format.js';
21
22
 
22
23
  // ─── Public types ─────────────────────────────────────────────────────────
@@ -83,33 +84,23 @@ interface RouteInfo {
83
84
  /**
84
85
  * Walk the route tree and collect all leaf routes (pages + API endpoints).
85
86
  *
86
- * Parallel slots (`@artists`, `@shows`, etc.) are intentionally skipped —
87
- * they render alongside the parent page at the same URL and are not
88
- * separately URL-addressable. Their JS is captured in shared/layout chunks.
87
+ * Wraps the shared `collectLeafRoutes` walker (TIM-848). Parallel slots
88
+ * (`@artists`, `@shows`, etc.) are intentionally skipped they render
89
+ * alongside the parent page at the same URL and are not separately
90
+ * URL-addressable. Their JS is captured in shared/layout chunks.
89
91
  *
90
92
  * After collection, entries are deduplicated by URL path so that overlapping
91
93
  * route groups (e.g. `(browse)` and `(marketing)` both producing `/`) only
92
- * appear once. The entry with the largest route-specific size wins.
94
+ * appear once. The entry with the longest segment chain (most specific
95
+ * match) wins.
93
96
  */
94
97
  export function collectRoutes(tree: RouteTree): RouteInfo[] {
95
- const routes: RouteInfo[] = [];
96
-
97
- function walk(node: SegmentNode, chain: SegmentNode[]): void {
98
- const currentChain = [...chain, node];
99
- const path = node.urlPath || '/';
100
-
101
- if (node.page) {
102
- routes.push({ path, segments: currentChain, entryFilePath: node.page.filePath });
103
- }
104
- if (node.route) {
105
- routes.push({ path, segments: currentChain, entryFilePath: node.route.filePath });
106
- }
107
-
108
- // Recurse into child segments only — skip parallel slots (node.slots)
109
- for (const child of node.children) walk(child, currentChain);
110
- }
111
-
112
- walk(tree.root, []);
98
+ const leaves = collectLeafRoutes(tree.root);
99
+ const routes: RouteInfo[] = leaves.map((leaf) => ({
100
+ path: leaf.urlPath,
101
+ segments: leaf.segments,
102
+ entryFilePath: leaf.page?.filePath ?? leaf.route?.filePath ?? null,
103
+ }));
113
104
 
114
105
  // Deduplicate entries with the same URL path (e.g. from overlapping route groups).
115
106
  // Keep the entry with the longest segment chain (most specific match).
@@ -13,6 +13,9 @@
13
13
  * Design doc: 21-dev-server.md, 07-routing.md
14
14
  */
15
15
 
16
+ import type { SegmentNode } from '../routing/types.js';
17
+ import { collectLeafRoutes } from '../routing/walkers.js';
18
+
16
19
  // ─── Types ──────────────────────────────────────────────────────────────────
17
20
 
18
21
  interface RouteInfo {
@@ -22,54 +25,25 @@ interface RouteInfo {
22
25
  type: 'page' | 'route';
23
26
  }
24
27
 
25
- /** Minimal segment node shape — matches ManifestSegmentNode. */
26
- interface SegmentNode {
27
- segmentName: string;
28
- segmentType: string;
29
- urlPath: string;
30
- page?: { filePath: string };
31
- route?: { filePath: string };
32
- children: SegmentNode[];
33
- slots: Record<string, SegmentNode> | Map<string, SegmentNode>;
34
- }
35
-
36
28
  // ─── Route Collection ───────────────────────────────────────────────────────
37
29
 
38
30
  /**
39
- * Collect all routable paths from the manifest tree.
31
+ * Collect all routable paths from a route tree (build-time scanner output
32
+ * or runtime route manifest).
40
33
  *
41
- * Walks the segment tree and collects paths for segments that have
42
- * a page or route handler.
34
+ * Wraps the shared `collectLeafRoutes` walker (TIM-848). Includes parallel
35
+ * slots so the dev 404 page can list slot pages too — they aren't
36
+ * separately addressable, but they're still useful to know about when
37
+ * debugging a missing route.
43
38
  */
44
- export function collectRoutes(root: SegmentNode): RouteInfo[] {
39
+ export function collectRoutes<TFile>(root: SegmentNode<TFile>): RouteInfo[] {
40
+ const leaves = collectLeafRoutes(root, { includeSlots: true });
45
41
  const routes: RouteInfo[] = [];
46
- walkRoutes(root, routes);
47
- return routes.sort((a, b) => a.path.localeCompare(b.path));
48
- }
49
-
50
- function walkRoutes(node: SegmentNode, routes: RouteInfo[]): void {
51
- if (node.page) {
52
- routes.push({ path: node.urlPath || '/', type: 'page' });
53
- }
54
- if (node.route) {
55
- routes.push({ path: node.urlPath || '/', type: 'route' });
56
- }
57
-
58
- for (const child of node.children) {
59
- walkRoutes(child, routes);
60
- }
61
-
62
- // Handle both Map and plain object for slots
63
- const slots = node.slots;
64
- if (slots instanceof Map) {
65
- for (const [, slotNode] of slots) {
66
- walkRoutes(slotNode, routes);
67
- }
68
- } else if (slots && typeof slots === 'object') {
69
- for (const key of Object.keys(slots)) {
70
- walkRoutes((slots as Record<string, SegmentNode>)[key]!, routes);
71
- }
42
+ for (const leaf of leaves) {
43
+ if (leaf.page) routes.push({ path: leaf.urlPath, type: 'page' });
44
+ if (leaf.route) routes.push({ path: leaf.urlPath, type: 'route' });
72
45
  }
46
+ return routes;
73
47
  }
74
48
 
75
49
  // ─── String Similarity ──────────────────────────────────────────────────────
@@ -116,7 +116,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
116
116
  segmentType: 'static',
117
117
  urlPath: '/',
118
118
  children: [],
119
- slots: new Map(),
119
+ slots: {},
120
120
  },
121
121
  };
122
122
  return;
@@ -326,7 +326,7 @@ function generateSearchParamsRegistryModule(tree: RouteTree): string {
326
326
  }
327
327
  }
328
328
  for (const child of node.children) walk(child);
329
- for (const [, slot] of node.slots) walk(slot);
329
+ for (const slot of Object.values(node.slots)) walk(slot);
330
330
  }
331
331
  walk(tree.root);
332
332
 
@@ -482,9 +482,9 @@ function generateManifestModule(tree: RouteTree, viteRoot: string): string {
482
482
  // Runtime registration happens in the route loader using the page module.
483
483
 
484
484
  // Status-code files
485
- if (node.statusFiles && node.statusFiles.size > 0) {
485
+ if (node.statusFiles && Object.keys(node.statusFiles).length > 0) {
486
486
  const statusEntries: string[] = [];
487
- for (const [code, file] of node.statusFiles) {
487
+ for (const [code, file] of Object.entries(node.statusFiles)) {
488
488
  const v = addImport(file);
489
489
  statusEntries.push(
490
490
  `${nextIndent} ${JSON.stringify(code)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
@@ -494,9 +494,9 @@ function generateManifestModule(tree: RouteTree, viteRoot: string): string {
494
494
  }
495
495
 
496
496
  // JSON status-code files
497
- if (node.jsonStatusFiles && node.jsonStatusFiles.size > 0) {
497
+ if (node.jsonStatusFiles && Object.keys(node.jsonStatusFiles).length > 0) {
498
498
  const jsonEntries: string[] = [];
499
- for (const [code, file] of node.jsonStatusFiles) {
499
+ for (const [code, file] of Object.entries(node.jsonStatusFiles)) {
500
500
  const v = addImport(file);
501
501
  jsonEntries.push(
502
502
  `${nextIndent} ${JSON.stringify(code)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
@@ -506,9 +506,9 @@ function generateManifestModule(tree: RouteTree, viteRoot: string): string {
506
506
  }
507
507
 
508
508
  // Legacy status files
509
- if (node.legacyStatusFiles && node.legacyStatusFiles.size > 0) {
509
+ if (node.legacyStatusFiles && Object.keys(node.legacyStatusFiles).length > 0) {
510
510
  const legacyEntries: string[] = [];
511
- for (const [name, file] of node.legacyStatusFiles) {
511
+ for (const [name, file] of Object.entries(node.legacyStatusFiles)) {
512
512
  const v = addImport(file);
513
513
  legacyEntries.push(
514
514
  `${nextIndent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
@@ -520,9 +520,9 @@ function generateManifestModule(tree: RouteTree, viteRoot: string): string {
520
520
  }
521
521
 
522
522
  // Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.)
523
- if (node.metadataRoutes && node.metadataRoutes.size > 0) {
523
+ if (node.metadataRoutes && Object.keys(node.metadataRoutes).length > 0) {
524
524
  const metaEntries: string[] = [];
525
- for (const [name, file] of node.metadataRoutes) {
525
+ for (const [name, file] of Object.entries(node.metadataRoutes)) {
526
526
  const v = addImport(file);
527
527
  metaEntries.push(
528
528
  `${nextIndent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
@@ -540,9 +540,11 @@ function generateManifestModule(tree: RouteTree, viteRoot: string): string {
540
540
  }
541
541
 
542
542
  // Parallel slots
543
- if (node.slots.size > 0) {
543
+ const slotKeys = Object.keys(node.slots);
544
+ if (slotKeys.length > 0) {
544
545
  const slotEntries: string[] = [];
545
- for (const [slotName, slotNode] of node.slots) {
546
+ for (const slotName of slotKeys) {
547
+ const slotNode = node.slots[slotName]!;
546
548
  slotEntries.push(
547
549
  `${nextIndent} ${JSON.stringify(slotName)}: ${serializeNode(slotNode, nextIndent + ' ')}`
548
550
  );
@@ -149,7 +149,7 @@ function collectRoutes(
149
149
  }
150
150
 
151
151
  // Recurse into slots (they share the parent's URL path, but may have their own pages)
152
- for (const [, slot] of node.slots) {
152
+ for (const slot of Object.values(node.slots)) {
153
153
  collectRoutes(slot, params, nextAncestorFiles, routes);
154
154
  }
155
155
  }
@@ -89,7 +89,7 @@ function hasAnyRoutable(node: SegmentNode): boolean {
89
89
  for (const child of node.children) {
90
90
  if (hasAnyRoutable(child)) return true;
91
91
  }
92
- for (const [, slot] of node.slots) {
92
+ for (const slot of Object.values(node.slots)) {
93
93
  if (hasAnyRoutable(slot)) return true;
94
94
  }
95
95
  return false;
@@ -105,7 +105,7 @@ function hasAnyPage(node: SegmentNode): boolean {
105
105
  for (const child of node.children) {
106
106
  if (hasAnyPage(child)) return true;
107
107
  }
108
- for (const [, slot] of node.slots) {
108
+ for (const slot of Object.values(node.slots)) {
109
109
  if (hasAnyPage(slot)) return true;
110
110
  }
111
111
  return false;
@@ -173,7 +173,7 @@ function checkRouteExports(node: SegmentNode, warnings: ConventionWarning[]): vo
173
173
  for (const child of node.children) {
174
174
  checkRouteExports(child, warnings);
175
175
  }
176
- for (const [, slot] of node.slots) {
176
+ for (const slot of Object.values(node.slots)) {
177
177
  checkRouteExports(slot, warnings);
178
178
  }
179
179
  }
@@ -249,7 +249,7 @@ function checkDefaultExports(node: SegmentNode, warnings: ConventionWarning[]):
249
249
  for (const child of node.children) {
250
250
  checkDefaultExports(child, warnings);
251
251
  }
252
- for (const [, slot] of node.slots) {
252
+ for (const slot of Object.values(node.slots)) {
253
253
  checkDefaultExports(slot, warnings);
254
254
  }
255
255
  }
@@ -1,4 +1,4 @@
1
- export { scanRoutes, classifySegment } from './scanner.js';
1
+ export { scanRoutes } from './scanner.js';
2
2
  export { generateRouteMap } from './codegen.js';
3
3
  export type { CodegenOptions } from './codegen.js';
4
4
  export type {
@@ -12,5 +12,7 @@ export type {
12
12
  export { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
13
13
  export { collectInterceptionRewrites } from './interception.js';
14
14
  export type { InterceptionRewrite } from './interception.js';
15
- export { classifyUrlSegment } from './segment-classify.js';
16
- export type { UrlSegment } from './segment-classify.js';
15
+ export { classifyUrlSegment, classifySegment } from './segment-classify.js';
16
+ export type { UrlSegment, SegmentClassification } from './segment-classify.js';
17
+ export { collectLeafRoutes } from './walkers.js';
18
+ export type { LeafRoute, CollectLeafRoutesOptions } from './walkers.js';
@@ -70,7 +70,7 @@ function walkForInterceptions(
70
70
  }
71
71
 
72
72
  // Check slots (intercepting routes are typically inside slots like @modal)
73
- for (const [, slot] of node.slots) {
73
+ for (const slot of Object.values(node.slots)) {
74
74
  walkForInterceptions(slot, ancestors, rewrites);
75
75
  }
76
76
  }
@@ -130,21 +130,33 @@ export function formatLinkCatchAllOverloads(): string[] {
130
130
  lines.push(` }): import('react').JSX.Element`);
131
131
 
132
132
  // (2) Computed/variable href — non-literal `string` only.
133
+ //
133
134
  // `string extends H` is true only when H is the wide `string` type,
134
- // not a specific literal. Literal internal paths (typos) that don't
135
- // match any per-route signature collapse to `never` here, but since
136
- // this block is NOT the "last overload" at resolution time, TS
137
- // instead reports the error from the per-route block — which now
138
- // says `Type '"/typo"' is not assignable to type '"/products/[id]"'`
139
- // or similar per-route-specific guidance.
135
+ // not a specific literal. For literal hrefs, this overload must NOT
136
+ // be selectable so the per-route discriminated union (block 1) is
137
+ // the resolution target.
138
+ //
139
+ // TIM-833 follow-up: the previous shape used `string extends H ? {...} : never`
140
+ // on the WHOLE props type. For literal H, that collapsed `props` to
141
+ // `never`, and JSX type inference then read `(never).children` as
142
+ // `never` — producing the misleading
143
+ // `'children' prop expects type 'never' which requires multiple
144
+ // children, but only a single child was provided` (TS2745) error,
145
+ // which buried the actual prop-mismatch message under a noise diagnostic.
146
+ //
147
+ // Fix: move the `: never` from the whole props onto just the `href`
148
+ // field. The overload remains uncallable for literal H (since `"/x"`
149
+ // is not assignable to `never`), but `children` retains its real
150
+ // `ReactNode` type so JSX inference no longer collapses to never.
151
+ // The diagnostic surface becomes: TS reports the per-route block's
152
+ // error chain (the helpful "Type 'number' is not assignable to type
153
+ // 'string'" message) WITHOUT a leading TS2745 noise line.
140
154
  lines.push(` <H extends string>(`);
141
- lines.push(` props: string extends H`);
142
- lines.push(` ? ${baseProps} & {`);
143
- lines.push(` href: H`);
144
- lines.push(` segmentParams?: Record<string, string | number | string[]>`);
145
- lines.push(` searchParams?: ${catchAllSearchParams}`);
146
- lines.push(` }`);
147
- lines.push(` : never`);
155
+ lines.push(` props: ${baseProps} & {`);
156
+ lines.push(` href: string extends H ? H : never`);
157
+ lines.push(` segmentParams?: Record<string, string | number | string[]>`);
158
+ lines.push(` searchParams?: ${catchAllSearchParams}`);
159
+ lines.push(` }`);
148
160
  lines.push(` ): import('react').JSX.Element`);
149
161
 
150
162
  lines.push(' }');
@@ -18,8 +18,8 @@ import type {
18
18
  ScannerConfig,
19
19
  InterceptionMarker,
20
20
  } from './types.js';
21
- import { classifyUrlSegment } from './segment-classify.js';
22
- import { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
21
+ import { classifySegment } from './segment-classify.js';
22
+ import { DEFAULT_PAGE_EXTENSIONS } from './types.js';
23
23
  import { classifyMetadataRoute, isDynamicMetadataExtension } from '../server/metadata-routes.js';
24
24
 
25
25
  /**
@@ -127,86 +127,10 @@ function createSegmentNode(
127
127
  interceptionMarker,
128
128
  interceptedSegmentName,
129
129
  children: [],
130
- slots: new Map(),
130
+ slots: {},
131
131
  };
132
132
  }
133
133
 
134
- /**
135
- * Classify a directory name into its segment type.
136
- */
137
- export function classifySegment(dirName: string): {
138
- type: SegmentType;
139
- paramName?: string;
140
- interceptionMarker?: InterceptionMarker;
141
- interceptedSegmentName?: string;
142
- } {
143
- // Private folder: _name (excluded from routing)
144
- if (dirName.startsWith('_')) {
145
- return { type: 'private' };
146
- }
147
-
148
- // Parallel route slot: @name
149
- if (dirName.startsWith('@')) {
150
- return { type: 'slot' };
151
- }
152
-
153
- // Intercepting routes: (.)name, (..)name, (...)name, (..)(..)name
154
- // Check before route groups since intercepting markers also start with (
155
- const interception = parseInterceptionMarker(dirName);
156
- if (interception) {
157
- return {
158
- type: 'intercepting',
159
- interceptionMarker: interception.marker,
160
- interceptedSegmentName: interception.segmentName,
161
- };
162
- }
163
-
164
- // Route group: (name)
165
- if (dirName.startsWith('(') && dirName.endsWith(')')) {
166
- return { type: 'group' };
167
- }
168
-
169
- // Bracket-syntax segments: [param], [...param], [[...param]]
170
- // Delegated to the shared character-based classifier. If you change
171
- // bracket syntax, update segment-classify.ts — not here.
172
- const urlSeg = classifyUrlSegment(dirName);
173
- if (urlSeg.kind !== 'static') {
174
- return { type: urlSeg.kind, paramName: urlSeg.name };
175
- }
176
-
177
- return { type: 'static' };
178
- }
179
-
180
- /**
181
- * Parse an interception marker from a directory name.
182
- *
183
- * Returns the marker and the remaining segment name, or null if not an
184
- * intercepting route. Markers are checked longest-first to avoid (..)
185
- * matching before (..)(..).
186
- *
187
- * Examples:
188
- * "(.)photo" → { marker: "(.)", segmentName: "photo" }
189
- * "(..)feed" → { marker: "(..)", segmentName: "feed" }
190
- * "(...)photos" → { marker: "(...)", segmentName: "photos" }
191
- * "(..)(..)admin" → { marker: "(..)(..)", segmentName: "admin" }
192
- * "(marketing)" → null (route group, not interception)
193
- */
194
- function parseInterceptionMarker(
195
- dirName: string
196
- ): { marker: InterceptionMarker; segmentName: string } | null {
197
- for (const marker of INTERCEPTION_MARKERS) {
198
- if (dirName.startsWith(marker)) {
199
- const rest = dirName.slice(marker.length);
200
- // Must have a segment name after the marker, and the rest must not
201
- // be empty or end with ) (which would be a route group like "(auth)")
202
- if (rest.length > 0 && !rest.endsWith(')')) {
203
- return { marker, segmentName: rest };
204
- }
205
- }
206
- }
207
- return null;
208
- }
209
-
210
134
  /**
211
135
  * Compute the URL path for a child segment given its parent's URL path.
212
136
  * Route groups, slots, and intercepting routes do NOT add URL depth.
@@ -292,27 +216,27 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
292
216
  // Recognized regardless of pageExtensions — .json is a data format, not a page extension.
293
217
  if (STATUS_CODE_PATTERN.test(name) && ext === 'json') {
294
218
  if (!node.jsonStatusFiles) {
295
- node.jsonStatusFiles = new Map();
219
+ node.jsonStatusFiles = {};
296
220
  }
297
- node.jsonStatusFiles.set(name, { filePath: fullPath, extension: ext });
221
+ node.jsonStatusFiles[name] = { filePath: fullPath, extension: ext };
298
222
  continue;
299
223
  }
300
224
 
301
225
  // Status-code files (401.tsx, 4xx.tsx, 503.tsx, 5xx.tsx)
302
226
  if (STATUS_CODE_PATTERN.test(name) && extSet.has(ext)) {
303
227
  if (!node.statusFiles) {
304
- node.statusFiles = new Map();
228
+ node.statusFiles = {};
305
229
  }
306
- node.statusFiles.set(name, { filePath: fullPath, extension: ext });
230
+ node.statusFiles[name] = { filePath: fullPath, extension: ext };
307
231
  continue;
308
232
  }
309
233
 
310
234
  // Legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)
311
235
  if (name in LEGACY_STATUS_FILES && extSet.has(ext)) {
312
236
  if (!node.legacyStatusFiles) {
313
- node.legacyStatusFiles = new Map();
237
+ node.legacyStatusFiles = {};
314
238
  }
315
- node.legacyStatusFiles.set(name, { filePath: fullPath, extension: ext });
239
+ node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };
316
240
  continue;
317
241
  }
318
242
 
@@ -323,19 +247,19 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
323
247
  const metaInfo = classifyMetadataRoute(entry);
324
248
  if (metaInfo) {
325
249
  if (!node.metadataRoutes) {
326
- node.metadataRoutes = new Map();
250
+ node.metadataRoutes = {};
327
251
  }
328
- const existing = node.metadataRoutes.get(name);
252
+ const existing = node.metadataRoutes[name];
329
253
  if (existing) {
330
254
  // Dynamic > static precedence: only overwrite if the new file is dynamic
331
255
  // or the existing file is static (dynamic always wins).
332
256
  const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
333
257
  const newIsDynamic = isDynamicMetadataExtension(name, ext);
334
258
  if (newIsDynamic || !existingIsDynamic) {
335
- node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
259
+ node.metadataRoutes[name] = { filePath: fullPath, extension: ext };
336
260
  }
337
261
  } else {
338
- node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
262
+ node.metadataRoutes[name] = { filePath: fullPath, extension: ext };
339
263
  }
340
264
  }
341
265
  }
@@ -412,10 +336,10 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
412
336
  // Recurse into subdirectories
413
337
  scanChildren(fullPath, childNode, extSet);
414
338
 
415
- // Attach to parent: slots go into slots map, everything else is a child
339
+ // Attach to parent: slots go into slots record, everything else is a child
416
340
  if (type === 'slot') {
417
341
  const slotName = entry.slice(1); // remove @
418
- parentNode.slots.set(slotName, childNode);
342
+ parentNode.slots[slotName] = childNode;
419
343
  } else {
420
344
  parentNode.children.push(childNode);
421
345
  }
@@ -477,7 +401,7 @@ function collectRoutableLeaves(
477
401
  }
478
402
 
479
403
  // Recurse into slots — each slot is its own parallel route space
480
- for (const [, slotNode] of node.slots) {
404
+ for (const slotNode of Object.values(node.slots)) {
481
405
  collectRoutableLeaves(slotNode, seen, currentPath, true);
482
406
  }
483
407
  }
@@ -525,7 +449,7 @@ function walkForDuplicateParams(node: SegmentNode, seen: Map<string, string>): v
525
449
 
526
450
  // Slots are independent parallel routes — start fresh param tracking
527
451
  // (a slot's params don't conflict with the main route's params)
528
- for (const [, slotNode] of node.slots) {
452
+ for (const slotNode of Object.values(node.slots)) {
529
453
  walkForDuplicateParams(slotNode, new Map(seen));
530
454
  }
531
455
  }
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Shared URL segment classifier.
2
+ * Shared segment classifier — both URL tokens and filesystem directory names.
3
3
  *
4
- * Single-pass character parser that classifies a route segment token
5
- * (e.g. "dashboard", "[id]", "[...slug]", "[[...path]]") into a typed
6
- * discriminated union. Used by both server-side routing and client-side
7
- * Link interpolation.
4
+ * `classifyUrlSegment(token)` is a pure single-pass character parser that
5
+ * classifies a route segment token (e.g. "dashboard", "[id]", "[...slug]",
6
+ * "[[...path]]") into a typed discriminated union. NO regex, NO Node.js-only
7
+ * APIs — safe to import from browser code (used by `Link` interpolation).
8
8
  *
9
- * NO regex. NO Node.js-only APIs. Safe to import from browser code.
9
+ * `classifySegment(dirName)` is the build-time directory-name classifier
10
+ * used by the scanner. It recognizes timber-only conventions (private
11
+ * `_*`, parallel `@*`, route groups `(name)`, intercepting routes
12
+ * `(.)`/`(..)`/`(...)`/`(..)(..)`) and delegates bracket syntax to
13
+ * `classifyUrlSegment`. It is the **single source of truth** for what
14
+ * counts as a routing segment — there is no separate copy in the
15
+ * scanner. (TIM-848.)
10
16
  *
11
- * Malformed input (unclosed brackets, empty names, etc.) falls through
12
- * to { kind: 'static' } — the safe default.
17
+ * Malformed input falls through to `{ kind: 'static' }` — the safe default.
13
18
  *
14
19
  * If you change the bracket syntax, update ONLY this file. Every
15
20
  * consumer imports from here.
@@ -17,6 +22,9 @@
17
22
  * See design/07-routing.md §"Route Segments"
18
23
  */
19
24
 
25
+ import type { InterceptionMarker, SegmentType } from './types.js';
26
+ import { INTERCEPTION_MARKERS } from './types.js';
27
+
20
28
  export type UrlSegment =
21
29
  | { kind: 'static'; value: string }
22
30
  | { kind: 'dynamic'; name: string }
@@ -87,3 +95,94 @@ export function classifyUrlSegment(token: string): UrlSegment {
87
95
  }
88
96
  return { kind: 'dynamic', name };
89
97
  }
98
+
99
+ // ─── Directory-name classifier (build-time scanner) ─────────────────────────
100
+
101
+ /** Result of classifying a filesystem directory name. */
102
+ export interface SegmentClassification {
103
+ type: SegmentType;
104
+ paramName?: string;
105
+ interceptionMarker?: InterceptionMarker;
106
+ interceptedSegmentName?: string;
107
+ }
108
+
109
+ /**
110
+ * Classify a directory name into its segment type.
111
+ *
112
+ * Recognizes all timber file-system conventions in priority order:
113
+ * 1. Private folders: `_name` (excluded from routing)
114
+ * 2. Parallel route slots: `@name`
115
+ * 3. Intercepting routes: `(.)name`, `(..)name`, `(...)name`, `(..)(..)name`
116
+ * 4. Route groups: `(name)`
117
+ * 5. Bracket syntax: `[id]`, `[...slug]`, `[[...path]]` (delegated to
118
+ * `classifyUrlSegment`)
119
+ * 6. Static: anything else
120
+ *
121
+ * If you change the bracket syntax, update only `classifyUrlSegment`.
122
+ * If you change the directory-prefix conventions, update this function.
123
+ */
124
+ export function classifySegment(dirName: string): SegmentClassification {
125
+ // Private folder: _name (excluded from routing)
126
+ if (dirName.startsWith('_')) {
127
+ return { type: 'private' };
128
+ }
129
+
130
+ // Parallel route slot: @name
131
+ if (dirName.startsWith('@')) {
132
+ return { type: 'slot' };
133
+ }
134
+
135
+ // Intercepting routes: (.)name, (..)name, (...)name, (..)(..)name
136
+ // Check before route groups since intercepting markers also start with (
137
+ const interception = parseInterceptionMarker(dirName);
138
+ if (interception) {
139
+ return {
140
+ type: 'intercepting',
141
+ interceptionMarker: interception.marker,
142
+ interceptedSegmentName: interception.segmentName,
143
+ };
144
+ }
145
+
146
+ // Route group: (name)
147
+ if (dirName.startsWith('(') && dirName.endsWith(')')) {
148
+ return { type: 'group' };
149
+ }
150
+
151
+ // Bracket-syntax segments: [param], [...param], [[...param]]
152
+ const urlSeg = classifyUrlSegment(dirName);
153
+ if (urlSeg.kind !== 'static') {
154
+ return { type: urlSeg.kind, paramName: urlSeg.name };
155
+ }
156
+
157
+ return { type: 'static' };
158
+ }
159
+
160
+ /**
161
+ * Parse an interception marker from a directory name.
162
+ *
163
+ * Returns the marker and the remaining segment name, or null if not an
164
+ * intercepting route. Markers are checked longest-first to avoid `(..)`
165
+ * matching before `(..)(..)`.
166
+ *
167
+ * Examples:
168
+ * "(.)photo" → { marker: "(.)", segmentName: "photo" }
169
+ * "(..)feed" → { marker: "(..)", segmentName: "feed" }
170
+ * "(...)photos" → { marker: "(...)", segmentName: "photos" }
171
+ * "(..)(..)admin" → { marker: "(..)(..)", segmentName: "admin" }
172
+ * "(marketing)" → null (route group, not interception)
173
+ */
174
+ function parseInterceptionMarker(
175
+ dirName: string
176
+ ): { marker: InterceptionMarker; segmentName: string } | null {
177
+ for (const marker of INTERCEPTION_MARKERS) {
178
+ if (dirName.startsWith(marker)) {
179
+ const rest = dirName.slice(marker.length);
180
+ // Must have a segment name after the marker, and the rest must not
181
+ // be empty or end with ) (which would be a route group like "(auth)")
182
+ if (rest.length > 0 && !rest.endsWith(')')) {
183
+ return { marker, segmentName: rest };
184
+ }
185
+ }
186
+ }
187
+ return null;
188
+ }