@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.
- package/dist/_chunks/{cli-schema-sync-DH-M8Poy.js → cli-schema-sync-CBiMEtqa.js} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-CQaxupHv.js} +47 -54
- package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +1 -0
- package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-Ck4Y45XN.js} +49 -8
- package/dist/_chunks/walkers-Ck4Y45XN.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +13 -4
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/error-reconstituter.d.ts +8 -1
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/routing/codegen-types.d.ts +4 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/deny-boundary.d.ts +27 -2
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/internal.js +226 -178
- package/dist/server/internal.js.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +6 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/utils/mdx-file.d.ts +15 -0
- package/dist/server/utils/mdx-file.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/client/error-boundary.tsx +14 -0
- package/src/client/error-reconstituter.tsx +15 -1
- package/src/routing/codegen-types.ts +4 -0
- package/src/routing/codegen.ts +63 -6
- package/src/routing/scanner.ts +80 -63
- package/src/server/deny-boundary.ts +106 -13
- package/src/server/deny-renderer.ts +40 -10
- package/src/server/error-boundary-wrapper.ts +1 -14
- package/src/server/route-matcher.ts +22 -6
- package/src/server/rsc-entry/error-renderer.ts +1 -12
- package/src/server/status-code-resolver.ts +3 -2
- package/src/server/tree-match.ts +15 -5
- package/src/server/utils/mdx-file.ts +22 -0
- package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
- 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;
|
|
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;
|
|
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.
|
|
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, {
|
|
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
|
}
|
package/src/routing/codegen.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/src/routing/scanner.ts
CHANGED
|
@@ -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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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) —
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 (
|
|
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
|
-
*
|
|
363
|
-
*
|
|
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
|
-
|
|
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.
|
|
374
|
-
*
|
|
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
|
-
|
|
387
|
-
if (
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
`
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
424
|
+
collectRoutableLeaves(child, seen, currentPath);
|
|
408
425
|
}
|
|
409
426
|
|
|
410
|
-
// Recurse into slots — each slot
|
|
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,
|
|
429
|
+
collectRoutableLeaves(slotNode, new Map(), currentPath);
|
|
413
430
|
}
|
|
414
431
|
}
|
|
415
432
|
|