@timber-js/app 0.2.0-alpha.169 → 0.2.0-alpha.170
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/LICENSE +8 -0
- package/dist/_chunks/{canonicalize-P41GR6tY.js → canonicalize-Du3o_ptW.js} +30 -12
- package/dist/_chunks/canonicalize-Du3o_ptW.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-B73L6pMq.js → cli-schema-sync-B5FDplGI.js} +55 -7
- package/dist/_chunks/cli-schema-sync-B5FDplGI.js.map +1 -0
- package/dist/_chunks/{segment-classify-CDDRVKs7.js → segment-classify-Byy425ng.js} +57 -18
- package/dist/_chunks/segment-classify-Byy425ng.js.map +1 -0
- package/dist/_chunks/{walkers-CoOC8Hga.js → walkers-DCoE-LJf.js} +23 -9
- package/dist/_chunks/walkers-DCoE-LJf.js.map +1 -0
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/index.js +5 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/index.js +26 -4
- package/dist/index.js.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/schema-validation.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +8 -2
- package/dist/routing/segment-classify.d.ts.map +1 -1
- package/dist/routing/types.d.ts +4 -0
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/server/internal.js +13 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +24 -4
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts.map +1 -1
- package/docs/more/01-advanced-routing.mdx +35 -1
- package/docs/more/04-metadata-and-fonts.mdx +2 -2
- package/package.json +7 -8
- package/src/cli-schema-sync.ts +15 -2
- package/src/cli.ts +0 -0
- package/src/client/link.tsx +4 -1
- package/src/routing/codegen.ts +13 -1
- package/src/routing/link-codegen.ts +15 -12
- package/src/routing/manifest-codegen.ts +6 -0
- package/src/routing/scanner.ts +85 -3
- package/src/routing/schema-validation.ts +7 -1
- package/src/routing/segment-classify.ts +49 -14
- package/src/routing/types.ts +4 -0
- package/src/server/metadata-routes.ts +71 -10
- package/src/server/pipeline-interception.ts +14 -1
- package/src/server/pipeline-phases.ts +7 -1
- package/src/server/route-element-builder.ts +12 -3
- package/src/server/route-matcher.ts +16 -2
- package/src/server/sitemap-generator.ts +5 -4
- package/src/server/slot-resolver.ts +7 -1
- package/src/server/tree-match.ts +46 -4
- package/dist/_chunks/canonicalize-P41GR6tY.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +0 -1
- package/dist/_chunks/segment-classify-CDDRVKs7.js.map +0 -1
- package/dist/_chunks/walkers-CoOC8Hga.js.map +0 -1
|
@@ -50,8 +50,18 @@ export const METADATA_ROUTE_CONVENTIONS: Record<
|
|
|
50
50
|
nestable: boolean;
|
|
51
51
|
staticExtensions: string[];
|
|
52
52
|
dynamicExtensions: string[];
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The URL path basename this file serves at (relative to segment).
|
|
55
|
+
* For image routes, the full serve path includes an extension via
|
|
56
|
+
* `resolveServePathForFile()`.
|
|
57
|
+
*/
|
|
54
58
|
servePath: string;
|
|
59
|
+
/**
|
|
60
|
+
* When set, image routes append `.{serveExtension}` to the serve path.
|
|
61
|
+
* Dynamic handlers (`.ts`/`.tsx`) use this as the default. Static files
|
|
62
|
+
* use their own extension instead. Non-image routes leave this undefined.
|
|
63
|
+
*/
|
|
64
|
+
serveExtension?: string;
|
|
55
65
|
}
|
|
56
66
|
> = {
|
|
57
67
|
'sitemap': {
|
|
@@ -93,6 +103,7 @@ export const METADATA_ROUTE_CONVENTIONS: Record<
|
|
|
93
103
|
staticExtensions: ['png', 'jpg', 'svg'],
|
|
94
104
|
dynamicExtensions: ['ts', 'tsx'],
|
|
95
105
|
servePath: 'icon',
|
|
106
|
+
serveExtension: 'png',
|
|
96
107
|
},
|
|
97
108
|
'opengraph-image': {
|
|
98
109
|
type: 'opengraph-image',
|
|
@@ -101,6 +112,7 @@ export const METADATA_ROUTE_CONVENTIONS: Record<
|
|
|
101
112
|
staticExtensions: ['png', 'jpg'],
|
|
102
113
|
dynamicExtensions: ['ts', 'tsx'],
|
|
103
114
|
servePath: 'opengraph-image',
|
|
115
|
+
serveExtension: 'png',
|
|
104
116
|
},
|
|
105
117
|
|
|
106
118
|
'apple-icon': {
|
|
@@ -110,6 +122,7 @@ export const METADATA_ROUTE_CONVENTIONS: Record<
|
|
|
110
122
|
staticExtensions: ['png'],
|
|
111
123
|
dynamicExtensions: ['ts', 'tsx'],
|
|
112
124
|
servePath: 'apple-icon',
|
|
125
|
+
serveExtension: 'png',
|
|
113
126
|
},
|
|
114
127
|
};
|
|
115
128
|
|
|
@@ -205,25 +218,73 @@ export function classifyMetadataRoute(fileName: string): MetadataRouteInfo | nul
|
|
|
205
218
|
}
|
|
206
219
|
|
|
207
220
|
/**
|
|
208
|
-
*
|
|
221
|
+
* Resolve the serve path for a metadata route file.
|
|
209
222
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
223
|
+
* For image routes (icon, opengraph-image, apple-icon), the serve path includes
|
|
224
|
+
* a file extension so CDNs cache correctly:
|
|
225
|
+
* - Dynamic handlers (.ts/.tsx) use the convention's `serveExtension` (default: .png)
|
|
226
|
+
* - Static files use their own extension (e.g., icon.svg → icon.svg)
|
|
227
|
+
*
|
|
228
|
+
* Non-image routes return the convention's `servePath` as-is (already includes
|
|
229
|
+
* extension: sitemap.xml, robots.txt, etc.).
|
|
230
|
+
*/
|
|
231
|
+
export function resolveServePathForFile(baseName: string, filePath: string): string {
|
|
232
|
+
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
233
|
+
if (!convention) return baseName;
|
|
234
|
+
|
|
235
|
+
if (!convention.serveExtension) return convention.servePath;
|
|
236
|
+
|
|
237
|
+
const ext = filePath.slice(filePath.lastIndexOf('.') + 1);
|
|
238
|
+
if (convention.staticExtensions.includes(ext)) {
|
|
239
|
+
return `${convention.servePath}.${ext}`;
|
|
240
|
+
}
|
|
241
|
+
return `${convention.servePath}.${convention.serveExtension}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get the default serve path for a metadata route type (using default extension
|
|
246
|
+
* for image routes). Used for auto-link generation when only the type is known.
|
|
212
247
|
*/
|
|
213
248
|
export function getMetadataRouteServePath(type: MetadataRouteType): string {
|
|
214
249
|
for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {
|
|
215
|
-
if (convention.type === type)
|
|
250
|
+
if (convention.type === type) {
|
|
251
|
+
if (convention.serveExtension) {
|
|
252
|
+
return `${convention.servePath}.${convention.serveExtension}`;
|
|
253
|
+
}
|
|
254
|
+
return convention.servePath;
|
|
255
|
+
}
|
|
216
256
|
}
|
|
217
257
|
throw new Error(`[timber] Unknown metadata route type: ${type}`);
|
|
218
258
|
}
|
|
219
259
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
)
|
|
260
|
+
/**
|
|
261
|
+
* All possible serve path segments for metadata routes (includes extension
|
|
262
|
+
* variants for image routes).
|
|
263
|
+
*/
|
|
264
|
+
const METADATA_SERVE_PATHS = new Set<string>();
|
|
265
|
+
for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {
|
|
266
|
+
if (convention.serveExtension) {
|
|
267
|
+
for (const ext of [...convention.staticExtensions, convention.serveExtension]) {
|
|
268
|
+
METADATA_SERVE_PATHS.add(`${convention.servePath}.${ext}`);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
METADATA_SERVE_PATHS.add(convention.servePath);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
223
274
|
|
|
224
275
|
export function isMetadataRouteServePath(pathname: string): boolean {
|
|
225
|
-
|
|
226
|
-
|
|
276
|
+
let lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
|
|
277
|
+
const qIdx = lastSegment.indexOf('?');
|
|
278
|
+
let query = '';
|
|
279
|
+
if (qIdx !== -1) {
|
|
280
|
+
query = lastSegment.slice(qIdx + 1);
|
|
281
|
+
lastSegment = lastSegment.slice(0, qIdx);
|
|
282
|
+
}
|
|
283
|
+
if (!METADATA_SERVE_PATHS.has(lastSegment)) return false;
|
|
284
|
+
// Vite module requests (e.g., /src/icon.svg?import) use special query params.
|
|
285
|
+
// These are source assets, not metadata routes.
|
|
286
|
+
if (/(?:^|&)(?:import|url|raw|worker|inline)(?:&|$)/.test(query)) return false;
|
|
287
|
+
return true;
|
|
227
288
|
}
|
|
228
289
|
|
|
229
290
|
/** A <link> auto-link tag. */
|
|
@@ -75,10 +75,23 @@ export function pathnameMatchesPattern(pathname: string, pattern: string): boole
|
|
|
75
75
|
return pi < pathParts.length;
|
|
76
76
|
case 'optional-catch-all':
|
|
77
77
|
return true;
|
|
78
|
-
case 'dynamic':
|
|
78
|
+
case 'dynamic': {
|
|
79
79
|
if (pi >= pathParts.length) return false;
|
|
80
|
+
const prefix = seg.prefix ?? '';
|
|
81
|
+
const suffix = seg.suffix ?? '';
|
|
82
|
+
const pathPart = pathParts[pi];
|
|
83
|
+
if (prefix || suffix) {
|
|
84
|
+
if (
|
|
85
|
+
pathPart.length <= prefix.length + suffix.length ||
|
|
86
|
+
(prefix && !pathPart.startsWith(prefix)) ||
|
|
87
|
+
(suffix && !pathPart.endsWith(suffix))
|
|
88
|
+
) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
80
92
|
pi++;
|
|
81
93
|
continue;
|
|
94
|
+
}
|
|
82
95
|
case 'static':
|
|
83
96
|
if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
|
|
84
97
|
pi++;
|
|
@@ -453,7 +453,13 @@ export async function handleRequest(
|
|
|
453
453
|
//
|
|
454
454
|
// Skip on RSC requests (client-side navigations) — the browser already
|
|
455
455
|
// has CSS, fonts, and JS chunks from the initial full-page load.
|
|
456
|
-
|
|
456
|
+
// Skip on API routes (route.ts without page.tsx) — they return data
|
|
457
|
+
// (JSON, images, etc.), not HTML that needs CSS/JS/font hints.
|
|
458
|
+
const leafSegment = match.segments[match.segments.length - 1] as
|
|
459
|
+
| { route?: unknown; page?: unknown }
|
|
460
|
+
| undefined;
|
|
461
|
+
const isApiRoute = leafSegment?.route && !leafSegment?.page;
|
|
462
|
+
if (config.earlyHints && !isRscRequest && !isApiRoute) {
|
|
457
463
|
try {
|
|
458
464
|
await config.earlyHints(match, req, responseHeaders);
|
|
459
465
|
} catch (err) {
|
|
@@ -23,7 +23,11 @@ import type { RouteMatch } from './pipeline.js';
|
|
|
23
23
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
24
24
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
25
25
|
import type { Metadata } from './types.js';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
METADATA_ROUTE_CONVENTIONS,
|
|
28
|
+
getMetadataRouteAutoLink,
|
|
29
|
+
resolveServePathForFile,
|
|
30
|
+
} from './metadata-routes.js';
|
|
27
31
|
|
|
28
32
|
// In dev mode, use a per-startup nonce for metadata route cache busting
|
|
29
33
|
// instead of per-file content hashes (avoids rehashing on every request).
|
|
@@ -438,10 +442,15 @@ export async function buildRouteElement(
|
|
|
438
442
|
? ''
|
|
439
443
|
: requestPathname
|
|
440
444
|
: '';
|
|
441
|
-
|
|
445
|
+
const metaFile = segment.metadataRoutes[baseName];
|
|
446
|
+
const fileServePath = metaFile?.filePath
|
|
447
|
+
? resolveServePathForFile(baseName, metaFile.filePath)
|
|
448
|
+
: convention.serveExtension
|
|
449
|
+
? `${convention.servePath}.${convention.serveExtension}`
|
|
450
|
+
: convention.servePath;
|
|
451
|
+
let href = `${resolvedPrefix}/${fileServePath}`;
|
|
442
452
|
// Append cache-bust query param for image metadata routes
|
|
443
453
|
if (convention.type === 'opengraph-image') {
|
|
444
|
-
const metaFile = segment.metadataRoutes[baseName];
|
|
445
454
|
const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
|
|
446
455
|
const cacheBust = fileHash ?? getDevNonce();
|
|
447
456
|
href = `${href}?${cacheBust}`;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
METADATA_ROUTE_CONVENTIONS,
|
|
16
16
|
isStaticMetadataExtension,
|
|
17
17
|
resolveStaticContentType,
|
|
18
|
+
resolveServePathForFile,
|
|
18
19
|
type MetadataRouteType,
|
|
19
20
|
} from './metadata-routes.js';
|
|
20
21
|
import { matchUrlParts } from './tree-match.js';
|
|
@@ -125,10 +126,16 @@ export interface MetadataRouteMatch {
|
|
|
125
126
|
segmentParams: Record<string, string | string[]>;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
// Reverse lookup: serve path → convention base name
|
|
129
|
+
// Reverse lookup: serve path (with extension) → convention base name
|
|
129
130
|
const SERVE_PATH_TO_CONVENTION = new Map<string, string>();
|
|
130
131
|
for (const [baseName, convention] of Object.entries(METADATA_ROUTE_CONVENTIONS)) {
|
|
131
|
-
|
|
132
|
+
if (convention.serveExtension) {
|
|
133
|
+
for (const ext of [...convention.staticExtensions, convention.serveExtension]) {
|
|
134
|
+
SERVE_PATH_TO_CONVENTION.set(`${convention.servePath}.${ext}`, baseName);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
SERVE_PATH_TO_CONVENTION.set(convention.servePath, baseName);
|
|
138
|
+
}
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
/**
|
|
@@ -188,6 +195,13 @@ function matchMetadataPathname(
|
|
|
188
195
|
const file = findMetadataRouteInSegment(segment, baseName, convention.nestable);
|
|
189
196
|
if (!file) return null;
|
|
190
197
|
|
|
198
|
+
// Validate the requested URL extension matches the file's resolved serve path.
|
|
199
|
+
// Prevents /icon.svg matching when only icon.png exists.
|
|
200
|
+
if (convention.serveExtension) {
|
|
201
|
+
const resolvedServePath = resolveServePathForFile(baseName, file.filePath);
|
|
202
|
+
if (lastPart !== resolvedServePath) return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
191
205
|
const ext = file.filePath.slice(file.filePath.lastIndexOf('.') + 1);
|
|
192
206
|
const isStatic = isStaticMetadataExtension(baseName, ext);
|
|
193
207
|
const contentType = isStatic
|
|
@@ -257,16 +257,17 @@ export function resolvePathWithParams(
|
|
|
257
257
|
return value;
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
// Single dynamic: [param]
|
|
260
|
+
// Single dynamic: [param] (possibly with affixes like img-[id].png)
|
|
261
|
+
// Match optional prefix before [ and optional suffix after ]
|
|
261
262
|
let missingRequired = false;
|
|
262
|
-
resolved = resolved.replace(
|
|
263
|
+
resolved = resolved.replace(/([^/[]*)\[([^\]]+)\]([^/[]*)/g, (match, prefix, name, suffix) => {
|
|
263
264
|
const value = params[name];
|
|
264
265
|
if (value === undefined) {
|
|
265
266
|
missingRequired = true;
|
|
266
267
|
return match; // keep original to avoid malformed URL
|
|
267
268
|
}
|
|
268
|
-
|
|
269
|
-
return
|
|
269
|
+
const v = Array.isArray(value) ? (value[0] ?? '') : value;
|
|
270
|
+
return prefix + v + suffix;
|
|
270
271
|
});
|
|
271
272
|
|
|
272
273
|
if (missingRequired) return null;
|
|
@@ -375,7 +375,13 @@ function extractUrlParts(
|
|
|
375
375
|
if (Array.isArray(value)) {
|
|
376
376
|
parts.push(...value.map(String));
|
|
377
377
|
} else if (value !== undefined) {
|
|
378
|
-
|
|
378
|
+
// Reconstruct the original URL part for affixed segments.
|
|
379
|
+
// The param value is the stripped middle (e.g. "123" for [id].png
|
|
380
|
+
// matching "123.png"), so we re-attach prefix/suffix so that slot
|
|
381
|
+
// matching through matchUrlParts sees the original part.
|
|
382
|
+
const prefix = seg.paramPrefix ?? '';
|
|
383
|
+
const suffix = seg.paramSuffix ?? '';
|
|
384
|
+
parts.push(prefix + String(value) + suffix);
|
|
379
385
|
}
|
|
380
386
|
}
|
|
381
387
|
}
|
package/src/server/tree-match.ts
CHANGED
|
@@ -107,6 +107,11 @@ function isTerminal<TFile>(node: SegmentNode<TFile>): boolean {
|
|
|
107
107
|
return !!(node.page || node.route);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/** Combined affix length for specificity ordering. */
|
|
111
|
+
function affixLength<TFile>(node: SegmentNode<TFile>): number {
|
|
112
|
+
return (node.paramPrefix?.length ?? 0) + (node.paramSuffix?.length ?? 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
/**
|
|
111
116
|
* Recursive depth-first matcher with backtracking.
|
|
112
117
|
*
|
|
@@ -168,9 +173,46 @@ function matchNode<TFile>(
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
// 2.
|
|
176
|
+
// 2. Affixed dynamic segments (e.g. img-[id].png) — more specific than bare dynamic.
|
|
177
|
+
// Sorted by specificity: longer combined prefix+suffix wins.
|
|
178
|
+
const affixed = effective.filter(
|
|
179
|
+
({ child }) =>
|
|
180
|
+
child.segmentType === 'dynamic' && child.paramName && (child.paramPrefix || child.paramSuffix)
|
|
181
|
+
);
|
|
182
|
+
affixed.sort((a, b) => affixLength(b.child) - affixLength(a.child));
|
|
183
|
+
for (const { child, groupChain } of affixed) {
|
|
184
|
+
const prefix = child.paramPrefix ?? '';
|
|
185
|
+
const suffix = child.paramSuffix ?? '';
|
|
186
|
+
if (
|
|
187
|
+
part.length > prefix.length + suffix.length &&
|
|
188
|
+
(!prefix || part.startsWith(prefix)) &&
|
|
189
|
+
(!suffix || part.endsWith(suffix))
|
|
190
|
+
) {
|
|
191
|
+
const value = part.slice(prefix.length, part.length - (suffix.length || 0));
|
|
192
|
+
chain.push(...groupChain);
|
|
193
|
+
const prev = params[child.paramName!];
|
|
194
|
+
params[child.paramName!] = value;
|
|
195
|
+
if (matchNode(child, parts, index + 1, chain, params, requireTerminal)) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// Backtrack
|
|
199
|
+
chain.length -= groupChain.length;
|
|
200
|
+
if (prev !== undefined) {
|
|
201
|
+
params[child.paramName!] = prev;
|
|
202
|
+
} else {
|
|
203
|
+
delete params[child.paramName!];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. Bare dynamic segments ([param])
|
|
172
209
|
for (const { child, groupChain } of effective) {
|
|
173
|
-
if (
|
|
210
|
+
if (
|
|
211
|
+
child.segmentType === 'dynamic' &&
|
|
212
|
+
child.paramName &&
|
|
213
|
+
!child.paramPrefix &&
|
|
214
|
+
!child.paramSuffix
|
|
215
|
+
) {
|
|
174
216
|
chain.push(...groupChain);
|
|
175
217
|
const prev = params[child.paramName];
|
|
176
218
|
params[child.paramName] = part;
|
|
@@ -187,7 +229,7 @@ function matchNode<TFile>(
|
|
|
187
229
|
}
|
|
188
230
|
}
|
|
189
231
|
|
|
190
|
-
//
|
|
232
|
+
// 4. Catch-all segments ([...param]) — must be terminal unless the
|
|
191
233
|
// caller passed requireTerminal:false (metadata route matching needs to
|
|
192
234
|
// find the owning segment even when it has no page).
|
|
193
235
|
for (const { child, groupChain } of effective) {
|
|
@@ -202,7 +244,7 @@ function matchNode<TFile>(
|
|
|
202
244
|
}
|
|
203
245
|
}
|
|
204
246
|
|
|
205
|
-
//
|
|
247
|
+
// 5. Optional catch-all segments ([[...param]]) — same terminal rule
|
|
206
248
|
for (const { child, groupChain } of effective) {
|
|
207
249
|
if (
|
|
208
250
|
child.segmentType === 'optional-catch-all' &&
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"canonicalize-P41GR6tY.js","names":[],"sources":["../../src/server/metadata-routes.ts","../../src/server/canonicalize.ts"],"sourcesContent":["/**\n * Metadata route classification for timber.js.\n *\n * Metadata routes are file-based endpoints that generate well-known URLs for\n * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).\n *\n * These routes run through proxy.ts but NOT through middleware.ts or access.ts —\n * they are public endpoints by nature.\n *\n * See design/16-metadata.md §\"Metadata Routes\"\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Classification of a metadata route file. */\nexport interface MetadataRouteInfo {\n /** The metadata route type. */\n type: MetadataRouteType;\n /** The content type to serve this route with. */\n contentType: string;\n /** Whether this route can appear in nested segments (not just app root). */\n nestable: boolean;\n}\n\nexport type MetadataRouteType =\n | 'sitemap'\n | 'robots'\n | 'manifest'\n | 'favicon'\n | 'icon'\n | 'opengraph-image'\n | 'apple-icon';\n\n// ─── Convention Table ────────────────────────────────────────────────────────\n\n/**\n * All recognized metadata route file conventions.\n *\n * Each entry maps a base file name (without extension) to its route info.\n * The extensions determine whether the file is static or dynamic.\n *\n * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg\n * Dynamic extensions: .ts, .tsx\n */\nexport const METADATA_ROUTE_CONVENTIONS: Record<\n string,\n {\n type: MetadataRouteType;\n contentType: string;\n nestable: boolean;\n staticExtensions: string[];\n dynamicExtensions: string[];\n /** The URL path this file serves at (relative to segment). */\n servePath: string;\n }\n> = {\n 'sitemap': {\n type: 'sitemap',\n contentType: 'application/xml',\n nestable: true,\n staticExtensions: ['xml'],\n dynamicExtensions: ['ts'],\n servePath: 'sitemap.xml',\n },\n 'robots': {\n type: 'robots',\n contentType: 'text/plain',\n nestable: false,\n staticExtensions: ['txt'],\n dynamicExtensions: ['ts'],\n servePath: 'robots.txt',\n },\n 'manifest': {\n type: 'manifest',\n contentType: 'application/manifest+json',\n nestable: false,\n staticExtensions: ['json'],\n dynamicExtensions: ['ts'],\n servePath: 'manifest.webmanifest',\n },\n 'favicon': {\n type: 'favicon',\n contentType: 'image/x-icon',\n nestable: false,\n staticExtensions: ['ico'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'favicon.ico',\n },\n 'icon': {\n type: 'icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg', 'svg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'icon',\n },\n 'opengraph-image': {\n type: 'opengraph-image',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png', 'jpg'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'opengraph-image',\n },\n\n 'apple-icon': {\n type: 'apple-icon',\n contentType: 'image/*',\n nestable: true,\n staticExtensions: ['png'],\n dynamicExtensions: ['ts', 'tsx'],\n servePath: 'apple-icon',\n },\n};\n\n// ─── MIME Type Resolution ─────────────────────────────────────────────────────\n\n/**\n * Map of file extensions to MIME types for static metadata route files.\n * Used to resolve the generic `image/*` content type for static image files.\n */\nconst EXTENSION_MIME_TYPES: Record<string, string> = {\n xml: 'application/xml',\n txt: 'text/plain',\n json: 'application/json',\n ico: 'image/x-icon',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n svg: 'image/svg+xml',\n webp: 'image/webp',\n};\n\n/**\n * Resolve the concrete MIME type for a static metadata route file.\n *\n * For generic content types like `image/*`, this resolves to the actual\n * MIME type based on the file extension (e.g. `image/png` for `.png`).\n *\n * @param conventionContentType - The content type from the convention table (may be generic like `image/*`)\n * @param extension - The file extension without leading dot (e.g. \"png\", \"xml\")\n * @returns The resolved MIME type\n */\nexport function resolveStaticContentType(conventionContentType: string, extension: string): string {\n if (conventionContentType.includes('*')) {\n return EXTENSION_MIME_TYPES[extension] ?? 'application/octet-stream';\n }\n return conventionContentType;\n}\n\n/**\n * Check if a file extension represents a static (non-code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"xml\", \"png\", \"ts\")\n * @returns true if this is a static file, false if dynamic or unrecognized\n */\nexport function isStaticMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.staticExtensions.includes(extension);\n}\n\n/**\n * Check if a file extension represents a dynamic (code) metadata route file.\n *\n * @param baseName - The base file name without extension (e.g. \"sitemap\", \"icon\")\n * @param extension - The file extension without leading dot (e.g. \"ts\", \"tsx\")\n * @returns true if this is a dynamic file, false if static or unrecognized\n */\nexport function isDynamicMetadataExtension(baseName: string, extension: string): boolean {\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return false;\n return convention.dynamicExtensions.includes(extension);\n}\n\n// ─── Classification ──────────────────────────────────────────────────────────\n\n/**\n * Classify a file name as a metadata route, or return null if it's not one.\n *\n * @param fileName - The full file name including extension (e.g. \"sitemap.xml\", \"icon.tsx\")\n * @returns Classification info, or null if not a metadata route\n */\nexport function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {\n const dotIndex = fileName.lastIndexOf('.');\n if (dotIndex === -1) return null;\n\n const baseName = fileName.slice(0, dotIndex);\n const ext = fileName.slice(dotIndex + 1);\n\n const convention = METADATA_ROUTE_CONVENTIONS[baseName];\n if (!convention) return null;\n\n const isStatic = convention.staticExtensions.includes(ext);\n const isDynamic = convention.dynamicExtensions.includes(ext);\n\n if (!isStatic && !isDynamic) return null;\n\n return {\n type: convention.type,\n contentType: convention.contentType,\n nestable: convention.nestable,\n };\n}\n\n/**\n * Get the serve path for a metadata route type.\n *\n * @param type - The metadata route type\n * @returns The URL path fragment this route serves at\n */\nexport function getMetadataRouteServePath(type: MetadataRouteType): string {\n for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {\n if (convention.type === type) return convention.servePath;\n }\n throw new Error(`[timber] Unknown metadata route type: ${type}`);\n}\n\nconst METADATA_SERVE_PATHS = new Set(\n Object.values(METADATA_ROUTE_CONVENTIONS).map((c) => c.servePath)\n);\n\nexport function isMetadataRouteServePath(pathname: string): boolean {\n const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);\n return METADATA_SERVE_PATHS.has(lastSegment);\n}\n\n/** A <link> auto-link tag. */\nexport interface AutoLinkLink {\n tag: 'link';\n rel: string;\n href: string;\n type?: string;\n}\n\n/** A <meta> auto-link tag. */\nexport interface AutoLinkMeta {\n tag: 'meta';\n property?: string;\n name?: string;\n content: string;\n}\n\nexport type AutoLinkTag = AutoLinkLink | AutoLinkMeta;\n\n/**\n * Get the auto-link tags to inject into <head> for metadata route files\n * discovered in a segment.\n *\n * Returns link tags for icon/apple-icon/manifest, and meta tags for\n * opengraph-image (emits both og:image and twitter:image). Returns null\n * for types that don't auto-link (favicon, sitemap, robots).\n *\n * @param type - The metadata route type\n * @param href - The resolved URL path to the metadata route\n * @returns Tag descriptor(s) for the <head>, or null if no auto-link\n */\nexport function getMetadataRouteAutoLink(type: MetadataRouteType, href: string): AutoLinkTag[] {\n switch (type) {\n case 'icon':\n return [{ tag: 'link', rel: 'icon', href }];\n case 'apple-icon':\n return [{ tag: 'link', rel: 'apple-touch-icon', href }];\n case 'manifest':\n return [{ tag: 'link', rel: 'manifest', href }];\n case 'opengraph-image':\n return [\n { tag: 'meta', property: 'og:image', content: href },\n { tag: 'meta', name: 'twitter:image', content: href },\n ];\n default:\n return [];\n }\n}\n","/**\n * URL canonicalization — runs once at the request boundary.\n *\n * Every layer (proxy.ts, middleware.ts, access.ts, components) sees the same\n * canonical path. No re-decoding occurs at any later stage.\n *\n * See design/07-routing.md §\"URL Canonicalization & Security\"\n */\n\n/** Result of canonicalization — either a clean path or a rejection. */\nexport type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; status: 400 };\n\n/**\n * Encoded separators that produce a 400 rejection.\n * %2f (/) and %5c (\\) cause path-confusion attacks.\n *\n * Shared between the runtime canonicalizer and the build-time route scanner\n * to ensure both enforce identical security rules. See design/13-security.md.\n */\nexport const ENCODED_SEPARATOR_RE = /%2f|%5c/i;\n\n/** Null byte — rejected. Shared with the route scanner. */\nexport const NULL_BYTE_RE = /%00/i;\n\n/**\n * Canonicalize a URL pathname.\n *\n * 1. Reject encoded separators (%2f, %5c) and null bytes (%00)\n * 2. Single percent-decode\n * 3. Collapse // → /\n * 4. Resolve .. segments (reject if escaping root)\n * 5. Strip trailing slash (except root \"/\")\n *\n * @param rawPathname - The raw pathname from the request URL (percent-encoded)\n * @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.\n */\nexport function canonicalize(rawPathname: string, stripTrailingSlash = true): CanonicalizeResult {\n // Step 1: Reject dangerous encoded sequences BEFORE decoding.\n // This must happen on the raw input so %252f doesn't bypass after a single decode.\n if (ENCODED_SEPARATOR_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n if (NULL_BYTE_RE.test(rawPathname)) {\n return { ok: false, status: 400 };\n }\n\n // Step 2: Single percent-decode.\n // Double-encoded input (%2561 → %61) stays as %61 — not decoded again.\n let decoded: string;\n try {\n decoded = decodeURIComponent(rawPathname);\n } catch {\n // Malformed percent-encoding → 400\n return { ok: false, status: 400 };\n }\n\n // Reject null bytes that appeared after decoding (from valid %00-like sequences\n // that weren't caught above — belt and suspenders).\n if (decoded.includes('\\0')) {\n return { ok: false, status: 400 };\n }\n\n // Backslash is NOT a path separator — keep as literal character.\n // But reject if it would create // after normalization (e.g., /\\evil.com).\n // We do NOT convert \\ to / — it stays as a literal.\n\n // Step 3: Collapse consecutive slashes.\n let pathname = decoded.replace(/\\/\\/+/g, '/');\n\n // Step 4: Resolve .. and . segments.\n const segments = pathname.split('/');\n const resolved: string[] = [];\n for (const seg of segments) {\n if (seg === '..') {\n if (resolved.length <= 1) {\n // Trying to escape root — 400\n return { ok: false, status: 400 };\n }\n resolved.pop();\n } else if (seg !== '.') {\n resolved.push(seg);\n }\n }\n\n pathname = resolved.join('/') || '/';\n\n // Step 5: Strip trailing slash (except root \"/\").\n if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n return { ok: true, pathname };\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cli-schema-sync-B73L6pMq.js","names":[],"sources":["../../src/routing/scanner.ts","../../src/cli-schema-sync.ts"],"sourcesContent":["/**\n * Route discovery scanner.\n *\n * Pure function: (appDir, config) → RouteTree\n *\n * Scans the app/ directory and builds a segment tree recognizing all\n * timber.js file conventions. Does NOT handle request matching — this\n * is discovery only.\n */\n\nimport { readdirSync, statSync } from 'node:fs';\nimport { join, extname, basename } from 'node:path';\nimport type {\n RouteTree,\n SegmentNode,\n SegmentType,\n RouteFile,\n ScannerConfig,\n InterceptionMarker,\n} from './types.js';\nimport { classifySegment } from './segment-classify.js';\nimport { DEFAULT_PAGE_EXTENSIONS } from './types.js';\nimport { classifyMetadataRoute, isDynamicMetadataExtension } from '../server/metadata-routes.js';\nimport { swallow } from '../server/logger.js';\nimport { ENCODED_SEPARATOR_RE, NULL_BYTE_RE } from '../server/canonicalize.js';\n\n/**\n * File convention names that use pageExtensions (can be .tsx, .ts, .jsx, .js, .mdx, etc.)\n */\nconst PAGE_EXT_CONVENTIONS = new Set(['page', 'layout', 'error', 'default', 'denied']);\n\n/**\n * Legacy compat status-code files.\n * Maps legacy file name → HTTP status code for the fallback chain.\n * See design/10-error-handling.md §\"Fallback Chain\".\n */\nconst LEGACY_STATUS_FILES: Record<string, number> = {\n 'not-found': 404,\n 'forbidden': 403,\n 'unauthorized': 401,\n};\n\n/**\n * File convention names that are always .ts/.tsx (never .mdx etc.)\n */\nconst FIXED_CONVENTIONS = new Set(['middleware', 'access', 'route', 'params']);\n\n/**\n * Status-code file patterns:\n * - Exact 3-digit codes: 401.tsx, 429.tsx, 503.tsx\n * - Category catch-alls: 4xx.tsx, 5xx.tsx\n */\nconst STATUS_CODE_PATTERN = /^(\\d{3}|[45]xx)$/;\n\n/**\n * Scan the app/ directory and build the route tree.\n *\n * @param appDir - Absolute path to the app/ directory\n * @param config - Scanner configuration\n * @returns The complete route tree\n */\nexport function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTree {\n const pageExtensions = config.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS;\n const extSet = new Set(pageExtensions);\n\n const tree: RouteTree = {\n root: createSegmentNode('', 'static', '/'),\n };\n\n // Check for proxy.ts at app root\n const proxyFile = findFixedFile(appDir, 'proxy');\n if (proxyFile) {\n tree.proxy = proxyFile;\n }\n\n // Check for global-error.{tsx,ts,jsx,js} at app root.\n // Tier 2 error page — renders standalone (no layouts) when no segment-level\n // error file is found. See design/10-error-handling.md §\"Tier 2\".\n const globalErrorFile = findPageExtFile(appDir, 'global-error', extSet);\n if (globalErrorFile) {\n tree.globalError = globalErrorFile;\n }\n\n // Scan the root directory's files\n scanSegmentFiles(appDir, tree.root, extSet);\n\n // Scan children recursively\n scanChildren(appDir, tree.root, extSet);\n\n // Validate: detect route group collisions (different groups producing pages at the same URL)\n validateRouteGroupCollisions(tree.root);\n\n // Validate: detect duplicate param names in nested dynamic segments\n // e.g., /[id]/items/[id] — same param name in ancestor and descendant\n validateDuplicateParamNames(tree.root);\n\n // Validate: detect same param name used with different bracket forms\n // e.g., [id] in one route and [...id] in another\n validateUniqueBracketForms(tree.root);\n\n return tree;\n}\n\n/**\n * Create an empty segment node.\n */\nfunction createSegmentNode(\n segmentName: string,\n segmentType: SegmentType,\n urlPath: string,\n paramName?: string,\n interceptionMarker?: InterceptionMarker,\n interceptedSegmentName?: string\n): SegmentNode {\n return {\n segmentName,\n segmentType,\n urlPath,\n paramName,\n interceptionMarker,\n interceptedSegmentName,\n children: [],\n slots: {},\n };\n}\n\n/**\n * Compute the URL path for a child segment given its parent's URL path.\n * Route groups, slots, and intercepting routes do NOT add URL depth.\n */\nfunction computeUrlPath(parentUrlPath: string, dirName: string, segmentType: SegmentType): string {\n // Groups, slots, and intercepting routes don't add to URL path\n if (segmentType === 'group' || segmentType === 'slot' || segmentType === 'intercepting') {\n return parentUrlPath;\n }\n\n const parentPath = parentUrlPath === '/' ? '' : parentUrlPath;\n return `${parentPath}/${dirName}`;\n}\n\n/**\n * Scan a directory for file conventions and populate the segment node.\n */\nfunction scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string>): void {\n let entries: string[];\n try {\n entries = readdirSync(dirPath);\n } catch (err) {\n swallow(err, `scanSegmentFiles: unreadable directory ${dirPath}`, { level: 'warn' });\n return;\n }\n\n for (const entry of entries) {\n const fullPath = join(dirPath, entry);\n\n // Skip directories — handled by scanChildren\n try {\n if (statSync(fullPath).isDirectory()) continue;\n } catch {\n continue;\n }\n\n const ext = extname(entry).slice(1); // remove leading dot\n const name = basename(entry, `.${ext}`);\n\n // Page-extension conventions (page, layout, error, default, denied)\n if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {\n const key = name as 'page' | 'layout' | 'error' | 'default' | 'denied';\n const existing = node[key];\n if (existing) {\n throwDuplicateConvention(name, existing.filePath, fullPath);\n }\n node[key] = { filePath: fullPath, extension: ext };\n continue;\n }\n\n // Fixed conventions (middleware, access, route, params) — only\n // .ts/.tsx/.js/.jsx, never pageExtensions like .mdx. The regex is\n // anchored: mts/cts/mjs/cjs (and stray tail matches like `.foots`)\n // are NOT recognized. See design/07-routing.md §\"Page Extensions\".\n if (FIXED_CONVENTIONS.has(name) && /^[jt]sx?$/.test(ext)) {\n const key = name as 'middleware' | 'access' | 'route' | 'params';\n const existing = node[key];\n if (existing) {\n throwDuplicateConvention(name, existing.filePath, fullPath);\n }\n node[key] = { filePath: fullPath, extension: ext };\n continue;\n }\n\n // JSON status-code files (401.json, 4xx.json, 503.json, 5xx.json)\n // Recognized regardless of pageExtensions — .json is a data format, not a page extension.\n if (STATUS_CODE_PATTERN.test(name) && ext === 'json') {\n if (!node.jsonStatusFiles) {\n node.jsonStatusFiles = {};\n }\n node.jsonStatusFiles[name] = { filePath: fullPath, extension: ext };\n continue;\n }\n\n // Status-code files (401.tsx, 4xx.tsx, 503.tsx, 5xx.tsx)\n if (STATUS_CODE_PATTERN.test(name) && extSet.has(ext)) {\n if (!node.statusFiles) {\n node.statusFiles = {};\n }\n const existing = node.statusFiles[name];\n if (existing) {\n throwDuplicateConvention(name, existing.filePath, fullPath);\n }\n node.statusFiles[name] = { filePath: fullPath, extension: ext };\n continue;\n }\n\n // Legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)\n if (name in LEGACY_STATUS_FILES && extSet.has(ext)) {\n if (!node.legacyStatusFiles) {\n node.legacyStatusFiles = {};\n }\n const existing = node.legacyStatusFiles[name];\n if (existing) {\n throwDuplicateConvention(name, existing.filePath, fullPath);\n }\n node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };\n continue;\n }\n\n // Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)\n // Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.\n // When both exist for the same base name, dynamic takes precedence.\n // Two files of the SAME kind (static+static or dynamic+dynamic) are a\n // build error — picking one by readdir order is platform-dependent.\n // See design/16-metadata.md §\"Metadata Routes\"\n const metaInfo = classifyMetadataRoute(entry);\n if (metaInfo) {\n if (!metaInfo.nestable && node.segmentName !== '') {\n throw new Error(\n `Build error: '${name}' is a root-only metadata convention and must be in the app root directory.\\n` +\n ` File: ${fullPath}\\n` +\n ` Move this file to the app root (not a route group or nested segment).`\n );\n }\n if (!node.metadataRoutes) {\n node.metadataRoutes = {};\n }\n const existing = node.metadataRoutes[name];\n if (existing) {\n const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);\n const newIsDynamic = isDynamicMetadataExtension(name, ext);\n if (existingIsDynamic === newIsDynamic) {\n throwDuplicateConvention(name, existing.filePath, fullPath);\n }\n // Dynamic > static precedence: overwrite only when the new file is dynamic.\n if (newIsDynamic) {\n node.metadataRoutes[name] = { filePath: fullPath, extension: ext };\n }\n } else {\n node.metadataRoutes[name] = { filePath: fullPath, extension: ext };\n }\n }\n }\n\n // Validate: route.ts + page.* is a hard build error\n if (node.route && node.page) {\n throw new Error(\n `Build error: route.ts and page.* cannot coexist in the same segment.\\n` +\n ` route.ts: ${node.route.filePath}\\n` +\n ` page: ${node.page.filePath}\\n` +\n `A URL is either an API endpoint or a rendered page, not both.`\n );\n }\n}\n\n/**\n * Throw a build error for two files claiming the same convention slot in\n * one segment (e.g. page.tsx + page.jsx). Which file readdir returns first\n * is platform-dependent (APFS sorts entries, ext4 does not) — silently\n * picking one would let macOS dev and Linux CI serve different\n * implementations of the same route.\n */\nfunction throwDuplicateConvention(\n convention: string,\n firstPath: string,\n secondPath: string\n): never {\n throw new Error(\n `Build error: duplicate '${convention}' files in the same route segment.\\n` +\n ` File 1: ${firstPath}\\n` +\n ` File 2: ${secondPath}\\n` +\n `Each segment may define '${convention}' at most once. Remove one of the files.`\n );\n}\n\n/**\n * Recursively scan child directories and build the segment tree.\n */\nfunction scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<string>): void {\n let entries: string[];\n try {\n entries = readdirSync(dirPath);\n } catch (err) {\n swallow(err, `scanChildren: unreadable directory ${dirPath}`, { level: 'warn' });\n return;\n }\n\n for (const entry of entries) {\n const fullPath = join(dirPath, entry);\n\n try {\n if (!statSync(fullPath).isDirectory()) continue;\n } catch {\n continue;\n }\n\n // Reject directories with encoded path delimiters or null bytes.\n // These can cause route collisions when decoded at the URL boundary.\n // See design/13-security.md §\"Encoded separators rejected\" and §\"Null bytes rejected\".\n if (ENCODED_SEPARATOR_RE.test(entry)) {\n throw new Error(\n `Build error: directory name contains an encoded path delimiter (%2F or %5C).\\n` +\n ` Directory: ${fullPath}\\n` +\n `Encoded separators in directory names cause route collisions when decoded. ` +\n `Rename the directory to remove the encoded delimiter.`\n );\n }\n if (NULL_BYTE_RE.test(entry)) {\n throw new Error(\n `Build error: directory name contains an encoded null byte (%00).\\n` +\n ` Directory: ${fullPath}\\n` +\n `Encoded null bytes in directory names are not allowed. ` +\n `Rename the directory to remove the null byte encoding.`\n );\n }\n\n const { type, paramName, interceptionMarker, interceptedSegmentName } = classifySegment(entry);\n\n // Skip private folders — underscore-prefixed dirs are excluded from routing\n if (type === 'private') continue;\n\n const urlPath = computeUrlPath(parentNode.urlPath, entry, type);\n const childNode = createSegmentNode(\n entry,\n type,\n urlPath,\n paramName,\n interceptionMarker,\n interceptedSegmentName\n );\n\n // Scan this segment's files\n scanSegmentFiles(fullPath, childNode, extSet);\n\n // Recurse into subdirectories\n scanChildren(fullPath, childNode, extSet);\n\n // Attach to parent: slots go into slots record, everything else is a child\n if (type === 'slot') {\n const slotName = entry.slice(1); // remove @\n parentNode.slots[slotName] = childNode;\n } else {\n parentNode.children.push(childNode);\n }\n }\n}\n\n/**\n * Validate that route groups don't produce conflicting pages/routes at the same URL path.\n *\n * Two route groups like (auth)/login/page.tsx and (marketing)/login/page.tsx both claim\n * /login — the scanner must detect and reject this at build time.\n *\n * Each parallel slot is validated as its OWN collision space: a slot's pages\n * intentionally share URL paths with the parent tree (that's the point of\n * parallel routes), but two route groups INSIDE one slot claiming the same\n * URL — e.g. @side/(a)/x/page.tsx + @side/(b)/x/page.tsx — still collide.\n */\nfunction validateRouteGroupCollisions(root: SegmentNode): void {\n // Map from urlPath → { filePath, source } for the first page/route seen at that path\n collectRoutableLeaves(root, new Map(), '');\n}\n\n/**\n * Walk the segment tree and collect all routable leaves (page or route files),\n * throwing on collision. Each slot recurses with a FRESH collision map —\n * slots are parallel routes that intentionally share URL paths with their\n * parent tree, but collisions within a single slot are real errors.\n */\nfunction collectRoutableLeaves(\n node: SegmentNode,\n seen: Map<string, { filePath: string; segmentPath: string }>,\n segmentPath: string\n): void {\n // Intercepting routes are resolved by the slot resolver, not URL matching,\n // and their computed urlPaths intentionally mirror other routes (e.g. two\n // interceptors in one slot both produce /gallery/[id]). Skip them entirely.\n if (node.segmentType === 'intercepting') return;\n\n const currentPath = segmentPath\n ? `${segmentPath}/${node.segmentName}`\n : node.segmentName || '(root)';\n\n const routableFile = node.page ?? node.route;\n if (routableFile) {\n const existing = seen.get(node.urlPath);\n if (existing) {\n throw new Error(\n `Build error: route collision — multiple route groups produce a page/route at the same URL path.\\n` +\n ` URL path: ${node.urlPath}\\n` +\n ` File 1: ${existing.filePath} (via ${existing.segmentPath})\\n` +\n ` File 2: ${routableFile.filePath} (via ${currentPath})\\n` +\n `Each URL path must map to exactly one page or route handler. ` +\n `Rename or move one of the conflicting files.`\n );\n }\n seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });\n }\n\n // Recurse into children\n for (const child of node.children) {\n collectRoutableLeaves(child, seen, currentPath);\n }\n\n // Recurse into slots — each slot gets a fresh map (its own collision space)\n for (const slotNode of Object.values(node.slots)) {\n collectRoutableLeaves(slotNode, new Map(), currentPath);\n }\n}\n\n/**\n * Validate that no route chain contains duplicate dynamic param names.\n *\n * Example violation:\n * app/[id]/items/[id]/page.tsx — 'id' appears twice in the ancestor chain.\n *\n * Route groups are transparent — params accumulate through them.\n * Slots are independent — duplicate detection does NOT cross slot boundaries.\n *\n * See design/07-routing.md §\"Duplicate Param Name Detection\"\n */\nfunction validateDuplicateParamNames(root: SegmentNode): void {\n walkForDuplicateParams(root, new Map());\n}\n\n/**\n * Recursively walk the segment tree, tracking seen param names → segment paths.\n * Throws on the first duplicate found.\n */\nfunction walkForDuplicateParams(node: SegmentNode, seen: Map<string, string>): void {\n // If this node introduces a param name, check for duplicates\n if (node.paramName) {\n const existing = seen.get(node.paramName);\n if (existing) {\n throw new Error(\n `[timber] Duplicate param name '${node.paramName}' in route chain.\\n` +\n ` First defined at: ${existing}\\n` +\n ` Duplicate at: ${node.urlPath || '/'}\\n` +\n ` Rename one of the segments to avoid ambiguity.`\n );\n }\n // Add to seen for descendants (use a new Map to avoid polluting siblings)\n seen = new Map(seen);\n seen.set(node.paramName, node.urlPath || '/');\n }\n\n // Recurse into children (they inherit the accumulated params)\n for (const child of node.children) {\n walkForDuplicateParams(child, seen);\n }\n\n // Slots are independent parallel routes — start fresh param tracking\n // (a slot's params don't conflict with the main route's params)\n for (const slotNode of Object.values(node.slots)) {\n walkForDuplicateParams(slotNode, new Map(seen));\n }\n}\n\n/**\n * Validate that each param name uses a single bracket form across the entire route tree.\n *\n * Different bracket forms produce different types:\n * [id] → string\n * [...id] → string[]\n * [[...id]] → string[] | undefined\n *\n * If the same param name appears with multiple forms, the no-argument\n * getSegmentParams() / useSegmentParams() return type becomes ambiguous.\n *\n * Same name + same form across different routes is fine and expected.\n */\nfunction validateUniqueBracketForms(root: SegmentNode): void {\n const forms = new Map<string, { segmentType: SegmentType; urlPath: string }>();\n collectBracketForms(root, forms);\n}\n\nfunction collectBracketForms(\n node: SegmentNode,\n forms: Map<string, { segmentType: SegmentType; urlPath: string }>\n): void {\n if (node.paramName && node.segmentType !== 'static' && node.segmentType !== 'group') {\n const existing = forms.get(node.paramName);\n if (existing && existing.segmentType !== node.segmentType) {\n const bracketExamples: Record<string, string> = {\n 'dynamic': `[${node.paramName}]`,\n 'catch-all': `[...${node.paramName}]`,\n 'optional-catch-all': `[[...${node.paramName}]]`,\n };\n throw new Error(\n `[timber] Param name '${node.paramName}' uses multiple bracket forms.\\n` +\n ` ${bracketExamples[existing.segmentType] ?? existing.segmentType} at: ${existing.urlPath}\\n` +\n ` ${bracketExamples[node.segmentType] ?? node.segmentType} at: ${node.urlPath || '/'}\\n` +\n ` Each param name must use a single bracket form across all routes.\\n` +\n ` Rename one of the segments to fix this (e.g., [${node.paramName}] → [${node.paramName}Id]).`\n );\n }\n if (!existing) {\n forms.set(node.paramName, {\n segmentType: node.segmentType,\n urlPath: node.urlPath || '/',\n });\n }\n }\n\n for (const child of node.children) {\n collectBracketForms(child, forms);\n }\n for (const slotNode of Object.values(node.slots)) {\n collectBracketForms(slotNode, forms);\n }\n}\n\n/**\n * Find a fixed-extension file (proxy.ts) in a directory.\n */\nfunction findFixedFile(dirPath: string, name: string): RouteFile | undefined {\n for (const ext of ['ts', 'tsx']) {\n const fullPath = join(dirPath, `${name}.${ext}`);\n try {\n if (statSync(fullPath).isFile()) {\n return { filePath: fullPath, extension: ext };\n }\n } catch {\n // File doesn't exist\n }\n }\n return undefined;\n}\n\n/**\n * Find a file using the configured page extensions (tsx, ts, jsx, js, mdx, etc.).\n * Used for app-root conventions like global-error that aren't per-segment.\n */\nfunction findPageExtFile(\n dirPath: string,\n name: string,\n extSet: Set<string>\n): RouteFile | undefined {\n for (const ext of extSet) {\n const fullPath = join(dirPath, `${name}.${ext}`);\n try {\n if (statSync(fullPath).isFile()) {\n return { filePath: fullPath, extension: ext };\n }\n } catch {\n // File doesn't exist\n }\n }\n return undefined;\n}\n","/**\n * `timber schema sync` — scan filesystem for dynamic segments, append\n * missing entries with `codec.string` defaults, and remove stale entries\n * whose segments no longer exist in the filesystem.\n *\n * Design doc: design/41-global-params.md §Auto-Scaffolding\n */\n\nimport { readFileSync, writeFileSync, existsSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { scanRoutes } from './routing/scanner.js';\nimport type { SegmentNode } from './routing/types.js';\nimport { resolveAppDir } from './plugin-context.js';\n\n/**\n * Collect all unique bracket-keyed dynamic segment names from the route tree.\n * Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.\n */\nexport function collectDynamicSegments(appDir: string): string[] {\n const tree = scanRoutes(appDir);\n const segments = new Set<string>();\n walkSegments(tree.root, segments);\n return [...segments].sort();\n}\n\nfunction walkSegments(node: SegmentNode, segments: Set<string>): void {\n if (node.paramName) {\n // Reconstruct bracket key from segmentName (already has brackets)\n segments.add(node.segmentName);\n }\n for (const child of node.children) {\n walkSegments(child, segments);\n }\n for (const slot of Object.values(node.slots)) {\n walkSegments(slot, segments);\n }\n}\n\n/**\n * Replace comment bodies (and optionally string contents) with spaces,\n * preserving length and newlines so every index maps 1:1 to the original.\n * Lets the structural scanning below ignore `segmentParams` mentions and\n * braces inside comments/strings without a TS parser (fine pre-alpha,\n * see TIM-1066).\n *\n * With `maskStrings`, string contents are blanked too — except literal\n * 'segmentParams' strings, kept so a quoted property key stays findable.\n */\nfunction maskSource(content: string, maskStrings: boolean): string {\n const out = content.split('');\n const n = content.length;\n let i = 0;\n\n const blank = (from: number, to: number): void => {\n for (let j = from; j < to; j++) {\n if (content[j] !== '\\n') out[j] = ' ';\n }\n };\n\n while (i < n) {\n const ch = content[i];\n const next = content[i + 1];\n if (ch === '/' && next === '/') {\n const start = i;\n while (i < n && content[i] !== '\\n') i++;\n blank(start, i);\n } else if (ch === '/' && next === '*') {\n const start = i;\n i += 2;\n while (i < n && !(content[i] === '*' && content[i + 1] === '/')) i++;\n i = Math.min(i + 2, n);\n blank(start, i);\n } else if (ch === '\"' || ch === \"'\" || ch === '`') {\n const start = i;\n i++;\n while (i < n && content[i] !== ch) {\n if (content[i] === '\\\\') i++;\n i++;\n }\n const end = i; // at closing quote (or n if unterminated)\n if (maskStrings && content.slice(start + 1, end) !== 'segmentParams') {\n blank(start + 1, end);\n }\n i++;\n } else {\n i++;\n }\n }\n return out.join('');\n}\n\n/**\n * Locate the `segmentParams: { ... }` block in a schema file.\n * Returns the indices of the opening and matching closing brace, or null\n * if no segmentParams property is found. Comments and strings are masked\n * first, so a JSDoc example mentioning `segmentParams: { ... }` above the\n * real defineSchema call is not mistaken for the block, and braces inside\n * comments/strings don't skew the depth count. The search is anchored to\n * the `defineSchema(` call when present, so helper objects with their own\n * segmentParams property earlier in the file are skipped.\n */\nexport function findSegmentParamsBlock(\n content: string\n): { openBrace: number; closeBrace: number } | null {\n const masked = maskSource(content, true);\n\n const defineCall = /\\bdefineSchema\\s*\\(/.exec(masked);\n const keyRe = /(['\"]?)segmentParams\\1\\s*:\\s*\\{/g;\n keyRe.lastIndex = defineCall ? defineCall.index + defineCall[0].length : 0;\n const keyMatch = keyRe.exec(masked);\n if (!keyMatch) return null;\n\n const openBrace = keyMatch.index + keyMatch[0].length - 1;\n let depth = 1;\n let i = openBrace + 1;\n while (i < masked.length && depth > 0) {\n if (masked[i] === '{') depth++;\n else if (masked[i] === '}') depth--;\n i++;\n }\n if (depth > 0) return null;\n // i now points to just after the closing brace\n return { openBrace, closeBrace: i - 1 };\n}\n\n/**\n * Parse existing bracket keys from an app/schema.ts file.\n * Scoped to the segmentParams: { ... } block — bracket keys elsewhere in\n * the file (unrelated objects, comments above defineSchema) are ignored.\n * Comments inside the block are masked, so a commented-out entry does not\n * count as registered.\n *\n * The key charset matches the route classifier (segment-classify.ts):\n * any character except `]`, so hyphenated params like '[my-param]' parse.\n */\nexport function parseExistingSchemaKeys(content: string): string[] {\n const block = findSegmentParamsBlock(content);\n if (!block) return [];\n\n const blockContent = maskSource(content, false).slice(block.openBrace, block.closeBrace);\n const keys: string[] = [];\n // Match quoted bracket keys like '[artistSlug]', '[...slug]', '[[...topic]]'\n const re = /['\"](\\[{1,2}(?:\\.{3})?[^\\]]+\\]{1,2})['\"]\\s*:/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(blockContent)) !== null) {\n keys.push(m[1]);\n }\n return keys;\n}\n\n/** Schema file extensions to check. */\nconst SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];\n\n/**\n * Find the schema file path if it exists.\n */\nexport function findSchemaFile(appDir: string): string | undefined {\n for (const ext of SCHEMA_EXTENSIONS) {\n const p = join(appDir, `schema${ext}`);\n if (existsSync(p)) return p;\n }\n return undefined;\n}\n\nexport function defaultCodecForSegment(seg: string): string {\n if (seg.startsWith('[[...')) return 'codec.optionalCatchAll(codec.string)';\n if (seg.startsWith('[...')) return 'codec.stringArray';\n return 'codec.string';\n}\n\n/**\n * Generate the content for a new app/schema.ts with the given segment keys.\n */\nfunction generateNewSchema(segments: string[]): string {\n const lines: string[] = [];\n lines.push(\"import { defineSchema } from '@timber-js/app/params';\");\n lines.push(\"import { codec } from '@timber-js/app/codec';\");\n lines.push('');\n lines.push('export default defineSchema({');\n lines.push(' segmentParams: {');\n for (const seg of segments) {\n lines.push(` '${seg}': ${defaultCodecForSegment(seg)},`);\n }\n lines.push(' },');\n lines.push('});');\n lines.push('');\n return lines.join('\\n');\n}\n\n/**\n * Append missing segment entries to an existing schema file.\n * Inserts new entries before the closing `}` of segmentParams.\n */\nconst CODEC_IMPORT = \"import { codec } from '@timber-js/app/codec';\";\n\nfunction hasCodecImport(content: string): boolean {\n return /import\\s+\\{[^}]*\\bcodec\\b[^}]*\\}\\s+from\\s+['\"]@timber-js\\/app\\/codec['\"]/.test(content);\n}\n\nfunction ensureCodecImport(content: string): string {\n if (hasCodecImport(content)) return content;\n\n // Insert after the last existing import statement\n const lines = content.split('\\n');\n let lastImportIndex = -1;\n for (let i = 0; i < lines.length; i++) {\n if (/^\\s*import\\s/.test(lines[i])) lastImportIndex = i;\n }\n\n if (lastImportIndex === -1) {\n return CODEC_IMPORT + '\\n' + content;\n }\n\n lines.splice(lastImportIndex + 1, 0, CODEC_IMPORT);\n return lines.join('\\n');\n}\n\nfunction appendToSchema(content: string, newSegments: string[]): string {\n content = ensureCodecImport(content);\n\n const block = findSegmentParamsBlock(content);\n if (!block) {\n throw new Error(\n '[timber] Could not find segmentParams in schema file. ' +\n 'Expected a defineSchema({ segmentParams: { ... } }) call.'\n );\n }\n const { openBrace, closeBrace } = block;\n\n // Detect indentation from existing entries\n const existingLine = content.slice(openBrace, closeBrace).match(/^(\\s+)['\"]\\[/m);\n const indent = existingLine ? existingLine[1] : ' ';\n\n // Build insertion text\n const insertion = newSegments\n .map((seg) => `${indent}'${seg}': ${defaultCodecForSegment(seg)},`)\n .join('\\n');\n\n // Insert before the closing brace\n const before = content.slice(0, closeBrace);\n const after = content.slice(closeBrace);\n\n // Add newline if the content before doesn't end with one\n const separator = before.trimEnd().endsWith(',') ? '\\n' : '\\n';\n\n return before + separator + insertion + '\\n' + after;\n}\n\n/**\n * Remove entries from the segmentParams block whose keys match `staleSegments`.\n * Removes the entire entry including trailing comma/whitespace. Scoped to\n * the segmentParams block — identical keys in unrelated objects elsewhere\n * in the file are left untouched.\n */\nfunction removeFromSchema(content: string, staleSegments: string[]): string {\n const block = findSegmentParamsBlock(content);\n if (!block) return content;\n\n let blockContent = content.slice(block.openBrace, block.closeBrace);\n // Key matching and the entry walk run on a comment-masked copy (same\n // length, indices align) so commented-out entries are never removed.\n // Both copies are spliced in sync to keep indices valid.\n let blockMasked = maskSource(blockContent, false);\n\n for (const seg of staleSegments) {\n const escaped = seg.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const keyRe = new RegExp(`^[ \\t]*['\"]${escaped}['\"][ \\t]*:`, 'gm');\n let m: RegExpExecArray | null;\n while ((m = keyRe.exec(blockMasked)) !== null) {\n const start = m.index;\n let i = m.index + m[0].length;\n let depth = 0;\n let inString: string | null = null;\n\n while (i < blockMasked.length) {\n const ch = blockMasked[i];\n if (inString) {\n if (ch === '\\\\') {\n i++;\n } else if (ch === inString) {\n inString = null;\n }\n } else if (ch === '\"' || ch === \"'\" || ch === '`') {\n inString = ch;\n } else if (ch === '(' || ch === '{' || ch === '[') {\n depth++;\n } else if (ch === ')' || ch === '}' || ch === ']') {\n depth--;\n } else if (depth === 0 && ch === ',') {\n i++;\n break;\n } else if (depth === 0 && (ch === '\\n' || ch === '}')) {\n break;\n }\n i++;\n }\n\n // Consume trailing newline\n if (i < blockMasked.length && blockMasked[i] === '\\n') i++;\n\n blockContent = blockContent.slice(0, start) + blockContent.slice(i);\n blockMasked = blockMasked.slice(0, start) + blockMasked.slice(i);\n keyRe.lastIndex = start;\n }\n }\n\n return content.slice(0, block.openBrace) + blockContent + content.slice(block.closeBrace);\n}\n\nexport interface SchemaSyncResult {\n /** Segments found in the filesystem. */\n filesystemSegments: string[];\n /** Segments already defined in app/schema.ts. */\n existingSegments: string[];\n /** Segments that were added. */\n addedSegments: string[];\n /** Segments that were removed (no longer in filesystem). */\n removedSegments: string[];\n /** Whether a new schema file was created. */\n created: boolean;\n}\n\n/**\n * Run the schema sync: scan filesystem, diff against schema, append missing.\n */\nexport function runSchemaSync(projectRoot: string): SchemaSyncResult {\n const appDir = resolveAppDir(projectRoot);\n\n const filesystemSegments = collectDynamicSegments(appDir);\n\n const schemaPath = findSchemaFile(appDir);\n\n if (filesystemSegments.length === 0 && !schemaPath) {\n return {\n filesystemSegments: [],\n existingSegments: [],\n addedSegments: [],\n removedSegments: [],\n created: false,\n };\n }\n\n if (!schemaPath) {\n // Create new schema file\n const newPath = join(appDir, 'schema.ts');\n writeFileSync(newPath, generateNewSchema(filesystemSegments), 'utf-8');\n return {\n filesystemSegments,\n existingSegments: [],\n addedSegments: filesystemSegments,\n removedSegments: [],\n created: true,\n };\n }\n\n // Read existing schema and diff\n const content = readFileSync(schemaPath, 'utf-8');\n const existingSegments = parseExistingSchemaKeys(content);\n const missing = filesystemSegments.filter((seg) => !existingSegments.includes(seg));\n const stale = existingSegments.filter((seg) => !filesystemSegments.includes(seg));\n\n if (missing.length === 0 && stale.length === 0) {\n return {\n filesystemSegments,\n existingSegments,\n addedSegments: [],\n removedSegments: [],\n created: false,\n };\n }\n\n let updated = content;\n if (stale.length > 0) {\n updated = removeFromSchema(updated, stale);\n }\n if (missing.length > 0) {\n updated = appendToSchema(updated, missing);\n }\n writeFileSync(schemaPath, updated, 'utf-8');\n\n return {\n filesystemSegments,\n existingSegments,\n addedSegments: missing,\n removedSegments: stale,\n created: false,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"segment-classify-CDDRVKs7.js","names":[],"sources":["../../src/routing/types.ts","../../src/routing/segment-classify.ts"],"sourcesContent":["/**\n * Route tree types for timber.js file-system routing.\n *\n * The route tree is built by scanning the app/ directory and recognizing\n * file conventions (page.*, layout.*, middleware.ts, access.ts, route.ts, etc.).\n *\n * **Single shape, two specializations** (TIM-848):\n *\n * `SegmentNode<TFile>` is the one canonical in-memory shape for the\n * timber route tree. The same interface is used at build time (with\n * `TFile = RouteFile`) and at request time (with `TFile = ManifestFile`,\n * see `server/route-matcher.ts`). Walkers parameterized over `TFile`\n * work on either, eliminating the previous duplication between\n * `SegmentNode` (Map-based) and `ManifestSegmentNode` (object-based).\n *\n * Keyed groups (`slots`, `statusFiles`, `jsonStatusFiles`,\n * `legacyStatusFiles`, `metadataRoutes`) are plain `Record<string, …>`\n * objects rather than `Map`s so that the build-time tree can be\n * serialized into the virtual route manifest with no shape transform.\n *\n * See design/07-routing.md §\"Route Tree Shape\" and design/18-build-system.md\n * §\"Route Manifest Shape\".\n */\n\n/** Segment type classification */\nexport type SegmentType =\n | 'static' // e.g. \"dashboard\"\n | 'dynamic' // e.g. \"[id]\"\n | 'catch-all' // e.g. \"[...slug]\"\n | 'optional-catch-all' // e.g. \"[[...slug]]\"\n | 'group' // e.g. \"(marketing)\"\n | 'slot' // e.g. \"@sidebar\"\n | 'intercepting' // e.g. \"(.)photo\", \"(..)photo\", \"(...)photo\"\n | 'private'; // e.g. \"_components\", \"_lib\" — excluded from routing\n\n/**\n * Intercepting route marker — indicates how many levels up to resolve the\n * intercepted route from the intercepting route's location.\n *\n * See design/07-routing.md §\"Intercepting Routes\"\n */\nexport type InterceptionMarker = '(.)' | '(..)' | '(...)' | '(..)(..)';\n\n/** All recognized interception markers, ordered longest-first for parsing. */\nexport const INTERCEPTION_MARKERS: InterceptionMarker[] = ['(..)(..)', '(.)', '(..)', '(...)'];\n\n/**\n * A single file discovered in a route segment at build time.\n *\n * The runtime equivalent (`ManifestFile`, defined in\n * `server/route-matcher.ts`) replaces `extension` with a lazy `load`\n * function. Walkers that only need `filePath` are parameterized over\n * `TFile` and accept either.\n */\nexport interface RouteFile {\n /** Absolute path to the file */\n filePath: string;\n /** File extension without leading dot (e.g. \"tsx\", \"ts\", \"mdx\") */\n extension: string;\n}\n\n/**\n * A node in the segment tree.\n *\n * Generic over `TFile` so the same interface describes both the\n * build-time tree (`SegmentNode<RouteFile>`, the default) and the\n * runtime manifest tree (`SegmentNode<ManifestFile>`, aliased as\n * `ManifestSegmentNode`). All keyed groups use `Record` (not `Map`)\n * so the build-time tree serializes to the virtual route manifest\n * with no shape transform.\n */\nexport interface SegmentNode<TFile = RouteFile> {\n /** The raw directory name (e.g. \"dashboard\", \"[id]\", \"(auth)\", \"@sidebar\") */\n segmentName: string;\n /** Classified segment type */\n segmentType: SegmentType;\n /** The dynamic param name, if dynamic (e.g. \"id\" for \"[id]\", \"slug\" for \"[...slug]\") */\n paramName?: string;\n /** The URL path prefix at this segment level (e.g. \"/dashboard\") */\n urlPath: string;\n /** For intercepting segments: the marker used, e.g. \"(.)\". */\n interceptionMarker?: InterceptionMarker;\n /**\n * For intercepting segments: the segment name after stripping the marker.\n * E.g., for \"(.)photo\" this is \"photo\".\n */\n interceptedSegmentName?: string;\n\n // --- File conventions ---\n page?: TFile;\n layout?: TFile;\n middleware?: TFile;\n access?: TFile;\n route?: TFile;\n /**\n * params.ts — isomorphic convention file exporting segmentParams and/or searchParams.\n * Discovered by the scanner like middleware.ts and access.ts.\n * See design/07-routing.md §\"params.ts Convention File\"\n */\n params?: TFile;\n error?: TFile;\n default?: TFile;\n /** Status-code files: 4xx.tsx, 5xx.tsx, {status}.tsx (component format) */\n statusFiles?: Record<string, TFile>;\n /** JSON status-code files: 4xx.json, 5xx.json, {status}.json */\n jsonStatusFiles?: Record<string, TFile>;\n /** denied.tsx — slot-only denial rendering */\n denied?: TFile;\n /** Legacy compat: not-found.tsx (maps to 404), forbidden.tsx (403), unauthorized.tsx (401) */\n legacyStatusFiles?: Record<string, TFile>;\n\n /** Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.) keyed by base name */\n metadataRoutes?: Record<string, TFile>;\n\n // --- Children ---\n children: SegmentNode<TFile>[];\n /** Parallel route slots (keyed by slot name without @) */\n slots: Record<string, SegmentNode<TFile>>;\n}\n\n/**\n * The full route tree output from the scanner (or the root of the\n * runtime route manifest, when `TFile = ManifestFile`).\n *\n * Generic so the same wrapper carries app-root metadata for both\n * shapes. The runtime manifest extends this with `viteRoot` (see\n * `ManifestRoot` in `server/route-matcher.ts`).\n */\nexport interface RouteTree<TFile = RouteFile> {\n /** The root segment node (representing app/) */\n root: SegmentNode<TFile>;\n /** All discovered proxy.ts files (should be at most one, in app/) */\n proxy?: TFile;\n /**\n * Global error page: app/global-error.{tsx,ts,jsx,js}\n *\n * Rendered as a standalone full-page replacement (no layout wrapping)\n * when no segment-level error file is found. SSR-only render path.\n * Must provide its own <html> and <body>.\n *\n * See design/10-error-handling.md §\"Tier 2 — Global Error Page\"\n */\n globalError?: TFile;\n}\n\n/** Configuration passed to the scanner */\nexport interface ScannerConfig {\n /** Recognized page/layout extensions (without dots). Default: ['tsx', 'ts', 'jsx', 'js'] */\n pageExtensions?: string[];\n}\n\n/** Default page extensions */\nexport const DEFAULT_PAGE_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js'];\n","/**\n * Shared segment classifier — both URL tokens and filesystem directory names.\n *\n * `classifyUrlSegment(token)` is a pure single-pass character parser that\n * classifies a route segment token (e.g. \"dashboard\", \"[id]\", \"[...slug]\",\n * \"[[...path]]\") into a typed discriminated union. NO regex, NO Node.js-only\n * APIs — safe to import from browser code (used by `Link` interpolation).\n *\n * `classifySegment(dirName)` is the build-time directory-name classifier\n * used by the scanner. It recognizes timber-only conventions (private\n * `_*`, parallel `@*`, route groups `(name)`, intercepting routes\n * `(.)`/`(..)`/`(...)`/`(..)(..)`) and delegates bracket syntax to\n * `classifyUrlSegment`. It is the **single source of truth** for what\n * counts as a routing segment — there is no separate copy in the\n * scanner. (TIM-848.)\n *\n * Malformed input falls through to `{ kind: 'static' }` — the safe default.\n *\n * If you change the bracket syntax, update ONLY this file. Every\n * consumer imports from here.\n *\n * See design/07-routing.md §\"Route Segments\"\n */\n\nimport type { InterceptionMarker, SegmentType } from './types.js';\nimport { INTERCEPTION_MARKERS } from './types.js';\n\nexport type UrlSegment =\n | { kind: 'static'; value: string }\n | { kind: 'dynamic'; name: string }\n | { kind: 'catch-all'; name: string }\n | { kind: 'optional-catch-all'; name: string };\n\n/**\n * Classify a URL path segment token.\n *\n * Walks the string left-to-right in one pass:\n * 1. If it doesn't start with '[', it's static.\n * 2. Count opening brackets (1 or 2) to detect optional.\n * 3. Check for '...' to detect catch-all.\n * 4. Read the param name up to the closing bracket.\n * 5. Validate the expected closing sequence (']' or ']]').\n * 6. Reject if there are leftover characters after the close.\n *\n * Any structural violation → static (safe default).\n */\nexport function classifyUrlSegment(token: string): UrlSegment {\n const len = token.length;\n\n // Must start with '[' to be dynamic\n if (len === 0 || token[0] !== '[') {\n return { kind: 'static', value: token };\n }\n\n let i = 1;\n\n // Check for optional: '[[...'\n const optional = token[i] === '[';\n if (optional) i++;\n\n // Check for catch-all: '...'\n const catchAll = i + 2 < len && token[i] === '.' && token[i + 1] === '.' && token[i + 2] === '.';\n if (catchAll) i += 3;\n\n // Read param name — everything up to ']'\n const nameStart = i;\n while (i < len && token[i] !== ']') i++;\n\n // Must have found a ']' and name must be non-empty\n if (i >= len || i === nameStart) {\n return { kind: 'static', value: token };\n }\n\n const name = token.slice(nameStart, i);\n i++; // skip first ']'\n\n // Optional requires a second ']'\n if (optional) {\n if (i >= len || token[i] !== ']') {\n return { kind: 'static', value: token };\n }\n i++;\n }\n\n // Must be at end of string — no trailing characters\n if (i !== len) {\n return { kind: 'static', value: token };\n }\n\n if (optional && catchAll) return { kind: 'optional-catch-all', name };\n if (catchAll) return { kind: 'catch-all', name };\n if (optional) {\n // '[[name]]' without '...' is malformed — not a valid segment syntax\n return { kind: 'static', value: token };\n }\n return { kind: 'dynamic', name };\n}\n\n// ─── Directory-name classifier (build-time scanner) ─────────────────────────\n\n/** Result of classifying a filesystem directory name. */\nexport interface SegmentClassification {\n type: SegmentType;\n paramName?: string;\n interceptionMarker?: InterceptionMarker;\n interceptedSegmentName?: string;\n}\n\n/**\n * Classify a directory name into its segment type.\n *\n * Recognizes all timber file-system conventions in priority order:\n * 1. Private folders: `_name` (excluded from routing)\n * 2. Parallel route slots: `@name`\n * 3. Intercepting routes: `(.)name`, `(..)name`, `(...)name`, `(..)(..)name`\n * 4. Route groups: `(name)`\n * 5. Bracket syntax: `[id]`, `[...slug]`, `[[...path]]` (delegated to\n * `classifyUrlSegment`)\n * 6. Static: anything else\n *\n * If you change the bracket syntax, update only `classifyUrlSegment`.\n * If you change the directory-prefix conventions, update this function.\n */\nexport function classifySegment(dirName: string): SegmentClassification {\n // Private folder: _name (excluded from routing)\n if (dirName.startsWith('_')) {\n return { type: 'private' };\n }\n\n // Parallel route slot: @name\n if (dirName.startsWith('@')) {\n return { type: 'slot' };\n }\n\n // Intercepting routes: (.)name, (..)name, (...)name, (..)(..)name\n // Check before route groups since intercepting markers also start with (\n const interception = parseInterceptionMarker(dirName);\n if (interception) {\n return {\n type: 'intercepting',\n interceptionMarker: interception.marker,\n interceptedSegmentName: interception.segmentName,\n };\n }\n\n // Route group: (name)\n if (dirName.startsWith('(') && dirName.endsWith(')')) {\n return { type: 'group' };\n }\n\n // Bracket-syntax segments: [param], [...param], [[...param]]\n const urlSeg = classifyUrlSegment(dirName);\n if (urlSeg.kind !== 'static') {\n return { type: urlSeg.kind, paramName: urlSeg.name };\n }\n\n return { type: 'static' };\n}\n\n/**\n * Parse an interception marker from a directory name.\n *\n * Returns the marker and the remaining segment name, or null if not an\n * intercepting route. Markers are checked longest-first to avoid `(..)`\n * matching before `(..)(..)`.\n *\n * Examples:\n * \"(.)photo\" → { marker: \"(.)\", segmentName: \"photo\" }\n * \"(..)feed\" → { marker: \"(..)\", segmentName: \"feed\" }\n * \"(...)photos\" → { marker: \"(...)\", segmentName: \"photos\" }\n * \"(..)(..)admin\" → { marker: \"(..)(..)\", segmentName: \"admin\" }\n * \"(marketing)\" → null (route group, not interception)\n */\nfunction parseInterceptionMarker(\n dirName: string\n): { marker: InterceptionMarker; segmentName: string } | null {\n for (const marker of INTERCEPTION_MARKERS) {\n if (dirName.startsWith(marker)) {\n const rest = dirName.slice(marker.length);\n // Must have a segment name after the marker, and the rest must not\n // be empty or end with ) (which would be a route group like \"(auth)\")\n if (rest.length > 0 && !rest.endsWith(')')) {\n return { marker, segmentName: rest };\n }\n }\n }\n return null;\n}\n"],"mappings":";;AA4CA,IAAa,uBAA6C;CAAC;CAAY;CAAO;CAAQ;AAAO;;AA4G7F,IAAa,0BAA0B;CAAC;CAAO;CAAM;CAAO;AAAI;;;;;;;;;;;;;;;;AC1GhE,SAAgB,mBAAmB,OAA2B;CAC5D,MAAM,MAAM,MAAM;CAGlB,IAAI,QAAQ,KAAK,MAAM,OAAO,KAC5B,OAAO;EAAE,MAAM;EAAU,OAAO;CAAM;CAGxC,IAAI,IAAI;CAGR,MAAM,WAAW,MAAM,OAAO;CAC9B,IAAI,UAAU;CAGd,MAAM,WAAW,IAAI,IAAI,OAAO,MAAM,OAAO,OAAO,MAAM,IAAI,OAAO,OAAO,MAAM,IAAI,OAAO;CAC7F,IAAI,UAAU,KAAK;CAGnB,MAAM,YAAY;CAClB,OAAO,IAAI,OAAO,MAAM,OAAO,KAAK;CAGpC,IAAI,KAAK,OAAO,MAAM,WACpB,OAAO;EAAE,MAAM;EAAU,OAAO;CAAM;CAGxC,MAAM,OAAO,MAAM,MAAM,WAAW,CAAC;CACrC;CAGA,IAAI,UAAU;EACZ,IAAI,KAAK,OAAO,MAAM,OAAO,KAC3B,OAAO;GAAE,MAAM;GAAU,OAAO;EAAM;EAExC;CACF;CAGA,IAAI,MAAM,KACR,OAAO;EAAE,MAAM;EAAU,OAAO;CAAM;CAGxC,IAAI,YAAY,UAAU,OAAO;EAAE,MAAM;EAAsB;CAAK;CACpE,IAAI,UAAU,OAAO;EAAE,MAAM;EAAa;CAAK;CAC/C,IAAI,UAEF,OAAO;EAAE,MAAM;EAAU,OAAO;CAAM;CAExC,OAAO;EAAE,MAAM;EAAW;CAAK;AACjC;;;;;;;;;;;;;;;;AA2BA,SAAgB,gBAAgB,SAAwC;CAEtE,IAAI,QAAQ,WAAW,GAAG,GACxB,OAAO,EAAE,MAAM,UAAU;CAI3B,IAAI,QAAQ,WAAW,GAAG,GACxB,OAAO,EAAE,MAAM,OAAO;CAKxB,MAAM,eAAe,wBAAwB,OAAO;CACpD,IAAI,cACF,OAAO;EACL,MAAM;EACN,oBAAoB,aAAa;EACjC,wBAAwB,aAAa;CACvC;CAIF,IAAI,QAAQ,WAAW,GAAG,KAAK,QAAQ,SAAS,GAAG,GACjD,OAAO,EAAE,MAAM,QAAQ;CAIzB,MAAM,SAAS,mBAAmB,OAAO;CACzC,IAAI,OAAO,SAAS,UAClB,OAAO;EAAE,MAAM,OAAO;EAAM,WAAW,OAAO;CAAK;CAGrD,OAAO,EAAE,MAAM,SAAS;AAC1B;;;;;;;;;;;;;;;AAgBA,SAAS,wBACP,SAC4D;CAC5D,KAAK,MAAM,UAAU,sBACnB,IAAI,QAAQ,WAAW,MAAM,GAAG;EAC9B,MAAM,OAAO,QAAQ,MAAM,OAAO,MAAM;EAGxC,IAAI,KAAK,SAAS,KAAK,CAAC,KAAK,SAAS,GAAG,GACvC,OAAO;GAAE;GAAQ,aAAa;EAAK;CAEvC;CAEF,OAAO;AACT"}
|