@timber-js/app 0.2.0-alpha.146 → 0.2.0-alpha.147

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 (42) hide show
  1. package/dist/_chunks/{cli-schema-sync-DH-M8Poy.js → cli-schema-sync-CBiMEtqa.js} +1 -1
  2. package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-CQaxupHv.js} +47 -54
  3. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +1 -0
  4. package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-Ck4Y45XN.js} +49 -8
  5. package/dist/_chunks/walkers-Ck4Y45XN.js.map +1 -0
  6. package/dist/cli.js +1 -1
  7. package/dist/client/error-boundary.d.ts.map +1 -1
  8. package/dist/client/error-boundary.js +13 -4
  9. package/dist/client/error-boundary.js.map +1 -1
  10. package/dist/client/error-reconstituter.d.ts +8 -1
  11. package/dist/client/error-reconstituter.d.ts.map +1 -1
  12. package/dist/index.js +2 -2
  13. package/dist/routing/codegen-types.d.ts +4 -0
  14. package/dist/routing/codegen-types.d.ts.map +1 -1
  15. package/dist/routing/index.js +2 -2
  16. package/dist/server/deny-boundary.d.ts +27 -2
  17. package/dist/server/deny-boundary.d.ts.map +1 -1
  18. package/dist/server/deny-renderer.d.ts.map +1 -1
  19. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  20. package/dist/server/internal.js +226 -178
  21. package/dist/server/internal.js.map +1 -1
  22. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  23. package/dist/server/status-code-resolver.d.ts +6 -0
  24. package/dist/server/status-code-resolver.d.ts.map +1 -1
  25. package/dist/server/utils/mdx-file.d.ts +15 -0
  26. package/dist/server/utils/mdx-file.d.ts.map +1 -0
  27. package/package.json +2 -2
  28. package/src/client/error-boundary.tsx +14 -0
  29. package/src/client/error-reconstituter.tsx +15 -1
  30. package/src/routing/codegen-types.ts +4 -0
  31. package/src/routing/codegen.ts +63 -6
  32. package/src/routing/scanner.ts +80 -63
  33. package/src/server/deny-boundary.ts +106 -13
  34. package/src/server/deny-renderer.ts +40 -10
  35. package/src/server/error-boundary-wrapper.ts +1 -14
  36. package/src/server/route-matcher.ts +22 -6
  37. package/src/server/rsc-entry/error-renderer.ts +1 -12
  38. package/src/server/status-code-resolver.ts +3 -2
  39. package/src/server/tree-match.ts +15 -5
  40. package/src/server/utils/mdx-file.ts +22 -0
  41. package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
  42. package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"error-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/error-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAGlE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAYvD;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAsHD;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,WAAW,EAAE,EAC/B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,EAAE,qBAAqB,EACtC,WAAW,CAAC,EAAE,eAAe,GAC5B,OAAO,CAAC,QAAQ,CAAC,CAwCnB;AAqID;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,WAAW,EAAE,mBAAmB,EAChC,eAAe,EAAE,OAAO,EACxB,eAAe,EAAE,qBAAqB,GACrC,OAAO,CAAC,QAAQ,CAAC,CAuCnB"}
1
+ {"version":3,"file":"error-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/error-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE/D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAGlE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAavD;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB;AA0GD;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,WAAW,EAAE,EAC/B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,EAAE,qBAAqB,EACtC,WAAW,CAAC,EAAE,eAAe,GAC5B,OAAO,CAAC,QAAQ,CAAC,CAwCnB;AAqID;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,WAAW,EAAE,mBAAmB,EAChC,eAAe,EAAE,OAAO,EACxB,eAAe,EAAE,qBAAqB,GACrC,OAAO,CAAC,QAAQ,CAAC,CAuCnB"}
@@ -58,6 +58,12 @@ export interface SlotDeniedResolution<TFile> {
58
58
  /** How the file was matched. */
59
59
  kind: SlotDeniedKind;
60
60
  }
61
+ /**
62
+ * Maps legacy file convention names to their corresponding HTTP status codes.
63
+ * Only used in the 4xx component fallback chain. Exported so the in-tree
64
+ * deny chain (deny-boundary.ts) uses the same mapping — see TIM-1081.
65
+ */
66
+ export declare const LEGACY_FILE_TO_STATUS: Record<string, number>;
61
67
  /**
62
68
  * Resolve the status-code file to render for a given HTTP status code.
63
69
  *
@@ -1 +1 @@
1
- {"version":3,"file":"status-code-resolver.d.ts","sourceRoot":"","sources":["../../src/server/status-code-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvD,4CAA4C;AAC5C,MAAM,MAAM,cAAc,GACtB,OAAO,GACP,UAAU,GACV,QAAQ,GACR,OAAO,CAAC;AAEZ,yDAAyD;AACzD,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,MAAM,CAAC;AAEpD,kEAAkE;AAClE,MAAM,WAAW,oBAAoB,CAAC,KAAK;IACzC,8BAA8B;IAC9B,IAAI,EAAE,KAAK,CAAC;IACZ,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,IAAI,EAAE,cAAc,CAAC;IACrB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,0CAA0C;AAC1C,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;AAElD,8CAA8C;AAC9C,MAAM,WAAW,oBAAoB,CAAC,KAAK;IACzC,0DAA0D;IAC1D,IAAI,EAAE,KAAK,CAAC;IACZ,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,gCAAgC;IAChC,IAAI,EAAE,cAAc,CAAC;CACtB;AA8DD;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EACrC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,aAAa,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAC3C,MAAM,GAAE,gBAA8B,GACrC,oBAAoB,CAAC,KAAK,CAAC,GAAG,IAAI,CAKpC;AA0FD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EACrC,QAAQ,EAAE,WAAW,CAAC,KAAK,CAAC,GAC3B,oBAAoB,CAAC,KAAK,CAAC,GAAG,IAAI,CAYpC"}
1
+ {"version":3,"file":"status-code-resolver.d.ts","sourceRoot":"","sources":["../../src/server/status-code-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvD,4CAA4C;AAC5C,MAAM,MAAM,cAAc,GACtB,OAAO,GACP,UAAU,GACV,QAAQ,GACR,OAAO,CAAC;AAEZ,yDAAyD;AACzD,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,MAAM,CAAC;AAEpD,kEAAkE;AAClE,MAAM,WAAW,oBAAoB,CAAC,KAAK;IACzC,8BAA8B;IAC9B,IAAI,EAAE,KAAK,CAAC;IACZ,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IACf,gCAAgC;IAChC,IAAI,EAAE,cAAc,CAAC;IACrB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,0CAA0C;AAC1C,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;AAElD,8CAA8C;AAC9C,MAAM,WAAW,oBAAoB,CAAC,KAAK;IACzC,0DAA0D;IAC1D,IAAI,EAAE,KAAK,CAAC;IACZ,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,gCAAgC;IAChC,IAAI,EAAE,cAAc,CAAC;CACtB;AAID;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIxD,CAAC;AAkDF;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EACrC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,aAAa,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAC3C,MAAM,GAAE,gBAA8B,GACrC,oBAAoB,CAAC,KAAK,CAAC,GAAG,IAAI,CAKpC;AA0FD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EACrC,QAAQ,EAAE,WAAW,CAAC,KAAK,CAAC,GAC3B,oBAAoB,CAAC,KAAK,CAAC,GAAG,IAAI,CAYpC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * MDX file-path detection shared by error/deny rendering paths.
3
+ *
4
+ * MDX status and error files are server components — they cannot be passed
5
+ * as function props across the RSC→client boundary (e.g. to
6
+ * TimberErrorBoundary or ErrorReconstituter) and must be pre-rendered as
7
+ * elements with plain props instead.
8
+ *
9
+ * See design/10-error-handling.md §"Status-Code File Variants".
10
+ */
11
+ /**
12
+ * Check if a file path ends with an MDX/markdown extension.
13
+ */
14
+ export declare function isMdxFilePath(filePath: string): boolean;
15
+ //# sourceMappingURL=mdx-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mdx-file.d.ts","sourceRoot":"","sources":["../../../src/server/utils/mdx-file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH;;GAEG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAIvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.146",
3
+ "version": "0.2.0-alpha.147",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -163,4 +163,4 @@
163
163
  "engines": {
164
164
  "node": ">=22.12.0"
165
165
  }
166
- }
166
+ }
@@ -255,7 +255,21 @@ export class TimberErrorBoundary extends Component<
255
255
 
256
256
  // Render the fallback component with the right props shape.
257
257
  if (parsed?.type === 'deny') {
258
+ // Status-code file boundaries (404.tsx, 4xx.tsx, …) get the documented
259
+ // 4xx props. error.tsx boundaries (no status filter) get their
260
+ // documented { error, digest, reset } contract — plus the deny extras
261
+ // for dual-shape implementations. Without the error prop, an error.tsx
262
+ // reading error.message crashes on deny fall-through. See TIM-1081.
263
+ if (this.props.status != null) {
264
+ return createElement(this.props.fallbackComponent as never, {
265
+ status: parsed.status,
266
+ dangerouslyPassData: parsed.data,
267
+ });
268
+ }
258
269
  return createElement(this.props.fallbackComponent as never, {
270
+ error,
271
+ digest: null,
272
+ reset: this.reset,
259
273
  status: parsed.status,
260
274
  dangerouslyPassData: parsed.data,
261
275
  });
@@ -33,6 +33,9 @@ export interface SerializableError {
33
33
  * - digest: plain JSON or null
34
34
  * - reset: undefined (only meaningful on client after boundary catch)
35
35
  * - component: client module reference (RSC Flight serializes as opaque ref)
36
+ * - status / dangerouslyPassData: set only when error.tsx serves a deny()
37
+ * as the last entry in the 4xx fallback chain — forwarded so dual-shape
38
+ * error.tsx implementations can branch on the deny status (TIM-1081)
36
39
  */
37
40
  interface ErrorReconstituterProps {
38
41
  error: SerializableError;
@@ -42,7 +45,11 @@ interface ErrorReconstituterProps {
42
45
  error: Error;
43
46
  digest: { code: string; data: unknown } | null;
44
47
  reset: (() => void) | undefined;
48
+ status?: number;
49
+ dangerouslyPassData?: unknown;
45
50
  }>;
51
+ status?: number;
52
+ dangerouslyPassData?: unknown;
46
53
  }
47
54
 
48
55
  /**
@@ -54,6 +61,8 @@ export function ErrorReconstituter({
54
61
  digest,
55
62
  reset,
56
63
  component,
64
+ status,
65
+ dangerouslyPassData,
57
66
  }: ErrorReconstituterProps): ReactNode {
58
67
  // Reconstitute a real Error so instanceof checks work in user code
59
68
  const error = Object.assign(new Error(serialized.message), {
@@ -61,5 +70,10 @@ export function ErrorReconstituter({
61
70
  ...(serialized.stack != null ? { stack: serialized.stack } : {}),
62
71
  });
63
72
 
64
- return createElement(component, { error, digest, reset });
73
+ return createElement(component, {
74
+ error,
75
+ digest,
76
+ reset,
77
+ ...(status != null ? { status, dangerouslyPassData } : {}),
78
+ });
65
79
  }
@@ -21,6 +21,10 @@ export interface RouteEntry {
21
21
  searchParamsPagePath?: string;
22
22
  /** Whether this is an API route (route.ts) vs page route */
23
23
  isApiRoute: boolean;
24
+ /** Whether this entry lives inside a parallel slot (@name). Slot entries
25
+ * share their parent's urlPath — urlPath-keyed emissions dedupe them,
26
+ * preferring the non-slot entry. segmentPath-keyed emissions keep all. */
27
+ isSlotRoute?: boolean;
24
28
  /** Whether a global schema (app/schema.ts) exists for this app */
25
29
  hasSchema?: boolean;
26
30
  }
@@ -48,7 +48,7 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
48
48
  const hasSchema = options.hasSchema ?? detectSchema(options.appDir);
49
49
 
50
50
  const routes: RouteEntry[] = [];
51
- collectRoutes(tree.root, [], [], '', routes, hasSchema);
51
+ collectRoutes(tree.root, [], [], '', routes, hasSchema, false);
52
52
 
53
53
  // Sort routes alphabetically for deterministic output
54
54
  routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
@@ -82,7 +82,8 @@ function collectRoutes(
82
82
  ancestorParamsFiles: string[],
83
83
  parentTreePath: string,
84
84
  routes: RouteEntry[],
85
- hasSchema: boolean
85
+ hasSchema: boolean,
86
+ insideSlot: boolean
86
87
  ): void {
87
88
  // Build the tree path (includes groups, slots — uniquely identifies this segment).
88
89
  // Root node has empty segmentName, producing treePath = ''.
@@ -156,6 +157,7 @@ function collectRoutes(
156
157
  params: resolvedParams,
157
158
  hasSearchParams: false,
158
159
  isApiRoute,
160
+ isSlotRoute: insideSlot,
159
161
  hasSchema,
160
162
  };
161
163
 
@@ -175,15 +177,63 @@ function collectRoutes(
175
177
 
176
178
  // Recurse into children
177
179
  for (const child of node.children) {
178
- collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema);
180
+ collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema, insideSlot);
179
181
  }
180
182
 
181
183
  // Recurse into slots (they share the parent's URL path, but may have their own pages)
182
184
  for (const slot of Object.values(node.slots)) {
183
- collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema);
185
+ collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema, true);
184
186
  }
185
187
  }
186
188
 
189
+ /**
190
+ * Dedupe route entries by urlPath for urlPath-keyed emissions (the Routes
191
+ * interface, useQueryStates overloads, and typed Link variants).
192
+ *
193
+ * Parallel-slot pages share their parent's urlPath; emitting one entry per
194
+ * slot page produced duplicate interface keys (TS2300/TS2717 under
195
+ * skipLibCheck:false) and duplicate overloads. The non-slot entry is the
196
+ * canonical one; when only slot entries exist (parent has no page), the
197
+ * lexicographically smallest segmentPath wins so output is independent of
198
+ * slot scan order. A slot's searchParams definition is carried onto the
199
+ * merged entry when the canonical entry has none — never silently dropped.
200
+ *
201
+ * segmentPath-keyed emissions (getSegmentParams, useSegmentParams,
202
+ * TimberSegmentParams) must NOT use this — segmentPaths are already unique
203
+ * and slot entries feed those APIs directly.
204
+ */
205
+ function dedupeRoutesByUrlPath(routes: RouteEntry[]): RouteEntry[] {
206
+ const byPath = new Map<string, RouteEntry>();
207
+ for (const route of routes) {
208
+ const existing = byPath.get(route.urlPath);
209
+ if (!existing) {
210
+ byPath.set(route.urlPath, route);
211
+ continue;
212
+ }
213
+
214
+ let winner = existing;
215
+ let loser = route;
216
+ const routeWins =
217
+ (existing.isSlotRoute && !route.isSlotRoute) ||
218
+ (!!existing.isSlotRoute === !!route.isSlotRoute &&
219
+ route.segmentPath.localeCompare(existing.segmentPath) < 0);
220
+ if (routeWins) {
221
+ winner = route;
222
+ loser = existing;
223
+ }
224
+
225
+ if (!winner.hasSearchParams && loser.hasSearchParams) {
226
+ winner = {
227
+ ...winner,
228
+ hasSearchParams: true,
229
+ searchParamsPagePath: loser.searchParamsPagePath,
230
+ };
231
+ }
232
+ byPath.set(route.urlPath, winner);
233
+ }
234
+ return [...byPath.values()];
235
+ }
236
+
187
237
  /**
188
238
  * Determine the TypeScript type for a segment's param.
189
239
  */
@@ -366,10 +416,14 @@ function formatDeclarationFile(
366
416
  lines.push('');
367
417
  }
368
418
 
419
+ // urlPath-keyed emissions use the deduped list (slot pages share their
420
+ // parent's urlPath); segmentPath-keyed emissions below keep all entries.
421
+ const uniqueByUrlPath = dedupeRoutesByUrlPath(routes);
422
+
369
423
  lines.push("declare module '@timber-js/app' {");
370
424
  lines.push(' interface Routes {');
371
425
 
372
- for (const route of routes) {
426
+ for (const route of uniqueByUrlPath) {
373
427
  const paramsType = formatParamsType(route.params, importBase, hasSchema);
374
428
  const searchParamsType = formatSearchParamsType(route, importBase);
375
429
 
@@ -393,7 +447,10 @@ function formatDeclarationFile(
393
447
  lines.push('}');
394
448
  lines.push('');
395
449
 
396
- const pageRoutes = routes.filter((r) => !r.isApiRoute);
450
+ // useQueryStates + typed Link are urlPath-keyed → deduped list.
451
+ const pageRoutes = uniqueByUrlPath.filter((r) => !r.isApiRoute);
452
+ // getSegmentParams / useSegmentParams are segmentPath-keyed → full list,
453
+ // so slot segments keep their own typed overloads.
397
454
  const dynamicRoutes = routes.filter((r) => r.params.length > 0);
398
455
 
399
456
  // Generate @timber-js/app/server augmentation — typed getSegmentParams
@@ -177,44 +177,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
177
177
 
178
178
  // Page-extension conventions (page, layout, error, default, denied)
179
179
  if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {
180
- const file: RouteFile = { filePath: fullPath, extension: ext };
181
- switch (name) {
182
- case 'page':
183
- node.page = file;
184
- break;
185
- case 'layout':
186
- node.layout = file;
187
- break;
188
- case 'error':
189
- node.error = file;
190
- break;
191
- case 'default':
192
- node.default = file;
193
- break;
194
- case 'denied':
195
- node.denied = file;
196
- break;
180
+ const key = name as 'page' | 'layout' | 'error' | 'default' | 'denied';
181
+ const existing = node[key];
182
+ if (existing) {
183
+ throwDuplicateConvention(name, existing.filePath, fullPath);
197
184
  }
185
+ node[key] = { filePath: fullPath, extension: ext };
198
186
  continue;
199
187
  }
200
188
 
201
- // Fixed conventions (middleware, access, route) — always .ts or .tsx
202
- if (FIXED_CONVENTIONS.has(name) && /\.?[jt]sx?$/.test(ext)) {
203
- const file: RouteFile = { filePath: fullPath, extension: ext };
204
- switch (name) {
205
- case 'middleware':
206
- node.middleware = file;
207
- break;
208
- case 'access':
209
- node.access = file;
210
- break;
211
- case 'route':
212
- node.route = file;
213
- break;
214
- case 'params':
215
- node.params = file;
216
- break;
189
+ // Fixed conventions (middleware, access, route, params) — only
190
+ // .ts/.tsx/.js/.jsx, never pageExtensions like .mdx. The regex is
191
+ // anchored: mts/cts/mjs/cjs (and stray tail matches like `.foots`)
192
+ // are NOT recognized. See design/07-routing.md §"Page Extensions".
193
+ if (FIXED_CONVENTIONS.has(name) && /^[jt]sx?$/.test(ext)) {
194
+ const key = name as 'middleware' | 'access' | 'route' | 'params';
195
+ const existing = node[key];
196
+ if (existing) {
197
+ throwDuplicateConvention(name, existing.filePath, fullPath);
217
198
  }
199
+ node[key] = { filePath: fullPath, extension: ext };
218
200
  continue;
219
201
  }
220
202
 
@@ -233,6 +215,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
233
215
  if (!node.statusFiles) {
234
216
  node.statusFiles = {};
235
217
  }
218
+ const existing = node.statusFiles[name];
219
+ if (existing) {
220
+ throwDuplicateConvention(name, existing.filePath, fullPath);
221
+ }
236
222
  node.statusFiles[name] = { filePath: fullPath, extension: ext };
237
223
  continue;
238
224
  }
@@ -242,6 +228,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
242
228
  if (!node.legacyStatusFiles) {
243
229
  node.legacyStatusFiles = {};
244
230
  }
231
+ const existing = node.legacyStatusFiles[name];
232
+ if (existing) {
233
+ throwDuplicateConvention(name, existing.filePath, fullPath);
234
+ }
245
235
  node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };
246
236
  continue;
247
237
  }
@@ -249,6 +239,8 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
249
239
  // Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
250
240
  // Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
251
241
  // When both exist for the same base name, dynamic takes precedence.
242
+ // Two files of the SAME kind (static+static or dynamic+dynamic) are a
243
+ // build error — picking one by readdir order is platform-dependent.
252
244
  // See design/16-metadata.md §"Metadata Routes"
253
245
  const metaInfo = classifyMetadataRoute(entry);
254
246
  if (metaInfo) {
@@ -257,11 +249,13 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
257
249
  }
258
250
  const existing = node.metadataRoutes[name];
259
251
  if (existing) {
260
- // Dynamic > static precedence: only overwrite if the new file is dynamic
261
- // or the existing file is static (dynamic always wins).
262
252
  const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
263
253
  const newIsDynamic = isDynamicMetadataExtension(name, ext);
264
- if (newIsDynamic || !existingIsDynamic) {
254
+ if (existingIsDynamic === newIsDynamic) {
255
+ throwDuplicateConvention(name, existing.filePath, fullPath);
256
+ }
257
+ // Dynamic > static precedence: overwrite only when the new file is dynamic.
258
+ if (newIsDynamic) {
265
259
  node.metadataRoutes[name] = { filePath: fullPath, extension: ext };
266
260
  }
267
261
  } else {
@@ -281,6 +275,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
281
275
  }
282
276
  }
283
277
 
278
+ /**
279
+ * Throw a build error for two files claiming the same convention slot in
280
+ * one segment (e.g. page.tsx + page.jsx). Which file readdir returns first
281
+ * is platform-dependent (APFS sorts entries, ext4 does not) — silently
282
+ * picking one would let macOS dev and Linux CI serve different
283
+ * implementations of the same route.
284
+ */
285
+ function throwDuplicateConvention(
286
+ convention: string,
287
+ firstPath: string,
288
+ secondPath: string
289
+ ): never {
290
+ throw new Error(
291
+ `Build error: duplicate '${convention}' files in the same route segment.\n` +
292
+ ` File 1: ${firstPath}\n` +
293
+ ` File 2: ${secondPath}\n` +
294
+ `Each segment may define '${convention}' at most once. Remove one of the files.`
295
+ );
296
+ }
297
+
284
298
  /**
285
299
  * Recursively scan child directories and build the segment tree.
286
300
  */
@@ -359,57 +373,60 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
359
373
  * Two route groups like (auth)/login/page.tsx and (marketing)/login/page.tsx both claim
360
374
  * /login — the scanner must detect and reject this at build time.
361
375
  *
362
- * Parallel slots are excluded from collision detection — they intentionally coexist at
363
- * the same URL path as their parent (that's the whole point of parallel routes).
376
+ * Each parallel slot is validated as its OWN collision space: a slot's pages
377
+ * intentionally share URL paths with the parent tree (that's the point of
378
+ * parallel routes), but two route groups INSIDE one slot claiming the same
379
+ * URL — e.g. @side/(a)/x/page.tsx + @side/(b)/x/page.tsx — still collide.
364
380
  */
365
381
  function validateRouteGroupCollisions(root: SegmentNode): void {
366
382
  // Map from urlPath → { filePath, source } for the first page/route seen at that path
367
- const seen = new Map<string, { filePath: string; segmentPath: string }>();
368
- collectRoutableLeaves(root, seen, '', false);
383
+ collectRoutableLeaves(root, new Map(), '');
369
384
  }
370
385
 
371
386
  /**
372
387
  * Walk the segment tree and collect all routable leaves (page or route files),
373
- * throwing on collision. Slots are tracked in their own collision space since
374
- * they are parallel routes that intentionally share URL paths with their parent.
388
+ * throwing on collision. Each slot recurses with a FRESH collision map —
389
+ * slots are parallel routes that intentionally share URL paths with their
390
+ * parent tree, but collisions within a single slot are real errors.
375
391
  */
376
392
  function collectRoutableLeaves(
377
393
  node: SegmentNode,
378
394
  seen: Map<string, { filePath: string; segmentPath: string }>,
379
- segmentPath: string,
380
- insideSlot: boolean
395
+ segmentPath: string
381
396
  ): void {
397
+ // Intercepting routes are resolved by the slot resolver, not URL matching,
398
+ // and their computed urlPaths intentionally mirror other routes (e.g. two
399
+ // interceptors in one slot both produce /gallery/[id]). Skip them entirely.
400
+ if (node.segmentType === 'intercepting') return;
401
+
382
402
  const currentPath = segmentPath
383
403
  ? `${segmentPath}/${node.segmentName}`
384
404
  : node.segmentName || '(root)';
385
405
 
386
- // Only check collisions for non-slot pages — slots intentionally share URL paths
387
- if (!insideSlot) {
388
- const routableFile = node.page ?? node.route;
389
- if (routableFile) {
390
- const existing = seen.get(node.urlPath);
391
- if (existing) {
392
- throw new Error(
393
- `Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
394
- ` URL path: ${node.urlPath}\n` +
395
- ` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
396
- ` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
397
- `Each URL path must map to exactly one page or route handler. ` +
398
- `Rename or move one of the conflicting files.`
399
- );
400
- }
401
- seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
406
+ const routableFile = node.page ?? node.route;
407
+ if (routableFile) {
408
+ const existing = seen.get(node.urlPath);
409
+ if (existing) {
410
+ throw new Error(
411
+ `Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
412
+ ` URL path: ${node.urlPath}\n` +
413
+ ` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
414
+ ` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
415
+ `Each URL path must map to exactly one page or route handler. ` +
416
+ `Rename or move one of the conflicting files.`
417
+ );
402
418
  }
419
+ seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
403
420
  }
404
421
 
405
422
  // Recurse into children
406
423
  for (const child of node.children) {
407
- collectRoutableLeaves(child, seen, currentPath, insideSlot);
424
+ collectRoutableLeaves(child, seen, currentPath);
408
425
  }
409
426
 
410
- // Recurse into slots — each slot is its own parallel route space
427
+ // Recurse into slots — each slot gets a fresh map (its own collision space)
411
428
  for (const slotNode of Object.values(node.slots)) {
412
- collectRoutableLeaves(slotNode, seen, currentPath, true);
429
+ collectRoutableLeaves(slotNode, new Map(), currentPath);
413
430
  }
414
431
  }
415
432