@timber-js/app 0.1.1 → 0.1.2
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/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercepting route utilities.
|
|
3
|
+
*
|
|
4
|
+
* Computes rewrite rules from the route tree that enable intercepting routes
|
|
5
|
+
* to conditionally render when navigating via client-side (soft) navigation.
|
|
6
|
+
*
|
|
7
|
+
* The mechanism: at build time, each intercepting route directory generates a
|
|
8
|
+
* conditional rewrite. On soft navigation, the client sends an `X-Timber-URL`
|
|
9
|
+
* header with the current pathname. The server checks if any rewrite's source
|
|
10
|
+
* (the intercepted URL) matches the target pathname AND the header matches
|
|
11
|
+
* the intercepting route's parent URL. If both match, the intercepting route
|
|
12
|
+
* renders instead of the normal route.
|
|
13
|
+
*
|
|
14
|
+
* On hard navigation (no header), no rewrite matches, and the normal route
|
|
15
|
+
* renders.
|
|
16
|
+
*
|
|
17
|
+
* See design/07-routing.md §"Intercepting Routes"
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SegmentNode, InterceptionMarker } from './types.js';
|
|
21
|
+
|
|
22
|
+
/** A conditional rewrite rule generated from an intercepting route. */
|
|
23
|
+
export interface InterceptionRewrite {
|
|
24
|
+
/**
|
|
25
|
+
* The URL pattern that this rewrite intercepts (the target of navigation).
|
|
26
|
+
* E.g., "/photo/[id]" for a (.)photo/[id] interception.
|
|
27
|
+
*/
|
|
28
|
+
interceptedPattern: string;
|
|
29
|
+
/**
|
|
30
|
+
* The URL prefix that the client must be navigating FROM for this rewrite
|
|
31
|
+
* to apply. Matched against the X-Timber-URL header.
|
|
32
|
+
* E.g., "/feed" for a (.)photo/[id] inside /feed/@modal/.
|
|
33
|
+
*/
|
|
34
|
+
interceptingPrefix: string;
|
|
35
|
+
/**
|
|
36
|
+
* Segments chain from root → intercepting leaf. Used to build the element
|
|
37
|
+
* tree when the interception is active.
|
|
38
|
+
*/
|
|
39
|
+
segmentPath: SegmentNode[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Collect all interception rewrite rules from the route tree.
|
|
44
|
+
*
|
|
45
|
+
* Walks the tree recursively. For each intercepting segment, computes the
|
|
46
|
+
* intercepted URL based on the marker and the segment's position.
|
|
47
|
+
*/
|
|
48
|
+
export function collectInterceptionRewrites(root: SegmentNode): InterceptionRewrite[] {
|
|
49
|
+
const rewrites: InterceptionRewrite[] = [];
|
|
50
|
+
walkForInterceptions(root, [root], rewrites);
|
|
51
|
+
return rewrites;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Recursively walk the segment tree to find intercepting routes.
|
|
56
|
+
*/
|
|
57
|
+
function walkForInterceptions(
|
|
58
|
+
node: SegmentNode,
|
|
59
|
+
ancestors: SegmentNode[],
|
|
60
|
+
rewrites: InterceptionRewrite[]
|
|
61
|
+
): void {
|
|
62
|
+
// Check children
|
|
63
|
+
for (const child of node.children) {
|
|
64
|
+
if (child.segmentType === 'intercepting' && child.interceptionMarker) {
|
|
65
|
+
// Found an intercepting route — collect rewrites from its sub-tree
|
|
66
|
+
collectFromInterceptingNode(child, ancestors, rewrites);
|
|
67
|
+
} else {
|
|
68
|
+
walkForInterceptions(child, [...ancestors, child], rewrites);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check slots (intercepting routes are typically inside slots like @modal)
|
|
73
|
+
for (const [, slot] of node.slots) {
|
|
74
|
+
walkForInterceptions(slot, ancestors, rewrites);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* For an intercepting segment, find all leaf pages in its sub-tree and
|
|
80
|
+
* generate rewrite rules for each.
|
|
81
|
+
*/
|
|
82
|
+
function collectFromInterceptingNode(
|
|
83
|
+
interceptingNode: SegmentNode,
|
|
84
|
+
ancestors: SegmentNode[],
|
|
85
|
+
rewrites: InterceptionRewrite[]
|
|
86
|
+
): void {
|
|
87
|
+
const marker = interceptingNode.interceptionMarker!;
|
|
88
|
+
const segmentName = interceptingNode.interceptedSegmentName!;
|
|
89
|
+
|
|
90
|
+
// Compute the intercepted URL base based on the marker
|
|
91
|
+
const parentUrlPath = ancestors[ancestors.length - 1].urlPath;
|
|
92
|
+
const interceptedBase = computeInterceptedBase(parentUrlPath, marker);
|
|
93
|
+
const interceptedUrlBase =
|
|
94
|
+
interceptedBase === '/' ? `/${segmentName}` : `${interceptedBase}/${segmentName}`;
|
|
95
|
+
|
|
96
|
+
// Find all leaf pages in the intercepting sub-tree
|
|
97
|
+
collectLeavesWithRewrites(
|
|
98
|
+
interceptingNode,
|
|
99
|
+
interceptedUrlBase,
|
|
100
|
+
parentUrlPath,
|
|
101
|
+
[...ancestors, interceptingNode],
|
|
102
|
+
rewrites
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Recursively find leaf pages in an intercepting sub-tree and generate
|
|
108
|
+
* rewrite rules for each.
|
|
109
|
+
*/
|
|
110
|
+
function collectLeavesWithRewrites(
|
|
111
|
+
node: SegmentNode,
|
|
112
|
+
interceptedUrlPath: string,
|
|
113
|
+
interceptingPrefix: string,
|
|
114
|
+
segmentPath: SegmentNode[],
|
|
115
|
+
rewrites: InterceptionRewrite[]
|
|
116
|
+
): void {
|
|
117
|
+
if (node.page) {
|
|
118
|
+
rewrites.push({
|
|
119
|
+
interceptedPattern: interceptedUrlPath,
|
|
120
|
+
interceptingPrefix,
|
|
121
|
+
segmentPath: [...segmentPath],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const child of node.children) {
|
|
126
|
+
const childUrl =
|
|
127
|
+
child.segmentType === 'group'
|
|
128
|
+
? interceptedUrlPath
|
|
129
|
+
: `${interceptedUrlPath}/${child.segmentName}`;
|
|
130
|
+
collectLeavesWithRewrites(
|
|
131
|
+
child,
|
|
132
|
+
childUrl,
|
|
133
|
+
interceptingPrefix,
|
|
134
|
+
[...segmentPath, child],
|
|
135
|
+
rewrites
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Compute the base URL that an intercepting route intercepts, given the
|
|
142
|
+
* parent's URL path and the interception marker.
|
|
143
|
+
*
|
|
144
|
+
* - (.) — same level: parent's URL path
|
|
145
|
+
* - (..) — one level up: parent's parent URL path
|
|
146
|
+
* - (...) — root level: /
|
|
147
|
+
* - (..)(..) — two levels up: parent's grandparent URL path
|
|
148
|
+
*
|
|
149
|
+
* Level counting operates on URL path segments, NOT filesystem directories.
|
|
150
|
+
* Route groups and parallel slots are already excluded from urlPath (they
|
|
151
|
+
* don't add URL depth), so (..) correctly climbs visible segments. This
|
|
152
|
+
* avoids the Vinext bug where path.dirname() on filesystem paths would
|
|
153
|
+
* waste climbs on invisible route groups.
|
|
154
|
+
*/
|
|
155
|
+
function computeInterceptedBase(parentUrlPath: string, marker: InterceptionMarker): string {
|
|
156
|
+
switch (marker) {
|
|
157
|
+
case '(.)':
|
|
158
|
+
return parentUrlPath;
|
|
159
|
+
case '(..)': {
|
|
160
|
+
const parts = parentUrlPath.split('/').filter(Boolean);
|
|
161
|
+
parts.pop();
|
|
162
|
+
return parts.length === 0 ? '/' : `/${parts.join('/')}`;
|
|
163
|
+
}
|
|
164
|
+
case '(...)':
|
|
165
|
+
return '/';
|
|
166
|
+
case '(..)(..)': {
|
|
167
|
+
const parts = parentUrlPath.split('/').filter(Boolean);
|
|
168
|
+
parts.pop();
|
|
169
|
+
parts.pop();
|
|
170
|
+
return parts.length === 0 ? '/' : `/${parts.join('/')}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route discovery scanner.
|
|
3
|
+
*
|
|
4
|
+
* Pure function: (appDir, config) → RouteTree
|
|
5
|
+
*
|
|
6
|
+
* Scans the app/ directory and builds a segment tree recognizing all
|
|
7
|
+
* timber.js file conventions. Does NOT handle request matching — this
|
|
8
|
+
* is discovery only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, extname, basename } from 'node:path';
|
|
13
|
+
import type {
|
|
14
|
+
RouteTree,
|
|
15
|
+
SegmentNode,
|
|
16
|
+
SegmentType,
|
|
17
|
+
RouteFile,
|
|
18
|
+
ScannerConfig,
|
|
19
|
+
InterceptionMarker,
|
|
20
|
+
} from './types.js';
|
|
21
|
+
import { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
|
|
22
|
+
import { classifyMetadataRoute } from '#/server/metadata-routes.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pattern matching encoded path delimiters that must be rejected during route discovery.
|
|
26
|
+
* %2F / %2f (forward slash) and %5C / %5c (backslash) can cause route collisions
|
|
27
|
+
* when decoded. See design/13-security.md §"Encoded separators rejected".
|
|
28
|
+
*/
|
|
29
|
+
const ENCODED_SEPARATOR_PATTERN = /%(?:2[fF]|5[cC])/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pattern matching encoded null bytes (%00) that must be rejected.
|
|
33
|
+
* See design/13-security.md §"Null bytes rejected".
|
|
34
|
+
*/
|
|
35
|
+
const ENCODED_NULL_PATTERN = /%00/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* File convention names that use pageExtensions (can be .tsx, .ts, .jsx, .js, .mdx, etc.)
|
|
39
|
+
*/
|
|
40
|
+
const PAGE_EXT_CONVENTIONS = new Set(['page', 'layout', 'error', 'default', 'denied']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Legacy compat status-code files.
|
|
44
|
+
* Maps legacy file name → HTTP status code for the fallback chain.
|
|
45
|
+
* See design/10-error-handling.md §"Fallback Chain".
|
|
46
|
+
*/
|
|
47
|
+
const LEGACY_STATUS_FILES: Record<string, number> = {
|
|
48
|
+
'not-found': 404,
|
|
49
|
+
'forbidden': 403,
|
|
50
|
+
'unauthorized': 401,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* File convention names that are always .ts/.tsx (never .mdx etc.)
|
|
55
|
+
*/
|
|
56
|
+
const FIXED_CONVENTIONS = new Set(['middleware', 'access', 'route', 'prerender', 'search-params']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Status-code file patterns:
|
|
60
|
+
* - Exact 3-digit codes: 401.tsx, 429.tsx, 503.tsx
|
|
61
|
+
* - Category catch-alls: 4xx.tsx, 5xx.tsx
|
|
62
|
+
*/
|
|
63
|
+
const STATUS_CODE_PATTERN = /^(\d{3}|[45]xx)$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scan the app/ directory and build the route tree.
|
|
67
|
+
*
|
|
68
|
+
* @param appDir - Absolute path to the app/ directory
|
|
69
|
+
* @param config - Scanner configuration
|
|
70
|
+
* @returns The complete route tree
|
|
71
|
+
*/
|
|
72
|
+
export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTree {
|
|
73
|
+
const pageExtensions = config.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS;
|
|
74
|
+
const extSet = new Set(pageExtensions);
|
|
75
|
+
|
|
76
|
+
const tree: RouteTree = {
|
|
77
|
+
root: createSegmentNode('', 'static', '/'),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Check for proxy.ts at app root
|
|
81
|
+
const proxyFile = findFixedFile(appDir, 'proxy');
|
|
82
|
+
if (proxyFile) {
|
|
83
|
+
tree.proxy = proxyFile;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Scan the root directory's files
|
|
87
|
+
scanSegmentFiles(appDir, tree.root, extSet);
|
|
88
|
+
|
|
89
|
+
// Scan children recursively
|
|
90
|
+
scanChildren(appDir, tree.root, extSet);
|
|
91
|
+
|
|
92
|
+
// Validate: detect route group collisions (different groups producing pages at the same URL)
|
|
93
|
+
validateRouteGroupCollisions(tree.root);
|
|
94
|
+
|
|
95
|
+
return tree;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create an empty segment node.
|
|
100
|
+
*/
|
|
101
|
+
function createSegmentNode(
|
|
102
|
+
segmentName: string,
|
|
103
|
+
segmentType: SegmentType,
|
|
104
|
+
urlPath: string,
|
|
105
|
+
paramName?: string,
|
|
106
|
+
interceptionMarker?: InterceptionMarker,
|
|
107
|
+
interceptedSegmentName?: string
|
|
108
|
+
): SegmentNode {
|
|
109
|
+
return {
|
|
110
|
+
segmentName,
|
|
111
|
+
segmentType,
|
|
112
|
+
urlPath,
|
|
113
|
+
paramName,
|
|
114
|
+
interceptionMarker,
|
|
115
|
+
interceptedSegmentName,
|
|
116
|
+
children: [],
|
|
117
|
+
slots: new Map(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Classify a directory name into its segment type.
|
|
123
|
+
*/
|
|
124
|
+
export function classifySegment(dirName: string): {
|
|
125
|
+
type: SegmentType;
|
|
126
|
+
paramName?: string;
|
|
127
|
+
interceptionMarker?: InterceptionMarker;
|
|
128
|
+
interceptedSegmentName?: string;
|
|
129
|
+
} {
|
|
130
|
+
// Private folder: _name (excluded from routing)
|
|
131
|
+
if (dirName.startsWith('_')) {
|
|
132
|
+
return { type: 'private' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Parallel route slot: @name
|
|
136
|
+
if (dirName.startsWith('@')) {
|
|
137
|
+
return { type: 'slot' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Intercepting routes: (.)name, (..)name, (...)name, (..)(..)name
|
|
141
|
+
// Check before route groups since intercepting markers also start with (
|
|
142
|
+
const interception = parseInterceptionMarker(dirName);
|
|
143
|
+
if (interception) {
|
|
144
|
+
return {
|
|
145
|
+
type: 'intercepting',
|
|
146
|
+
interceptionMarker: interception.marker,
|
|
147
|
+
interceptedSegmentName: interception.segmentName,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Route group: (name)
|
|
152
|
+
if (dirName.startsWith('(') && dirName.endsWith(')')) {
|
|
153
|
+
return { type: 'group' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Optional catch-all: [[...name]]
|
|
157
|
+
if (dirName.startsWith('[[...') && dirName.endsWith(']]')) {
|
|
158
|
+
const paramName = dirName.slice(5, -2);
|
|
159
|
+
return { type: 'optional-catch-all', paramName };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Catch-all: [...name]
|
|
163
|
+
if (dirName.startsWith('[...') && dirName.endsWith(']')) {
|
|
164
|
+
const paramName = dirName.slice(4, -1);
|
|
165
|
+
return { type: 'catch-all', paramName };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Dynamic: [name]
|
|
169
|
+
if (dirName.startsWith('[') && dirName.endsWith(']')) {
|
|
170
|
+
const paramName = dirName.slice(1, -1);
|
|
171
|
+
return { type: 'dynamic', paramName };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { type: 'static' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse an interception marker from a directory name.
|
|
179
|
+
*
|
|
180
|
+
* Returns the marker and the remaining segment name, or null if not an
|
|
181
|
+
* intercepting route. Markers are checked longest-first to avoid (..)
|
|
182
|
+
* matching before (..)(..).
|
|
183
|
+
*
|
|
184
|
+
* Examples:
|
|
185
|
+
* "(.)photo" → { marker: "(.)", segmentName: "photo" }
|
|
186
|
+
* "(..)feed" → { marker: "(..)", segmentName: "feed" }
|
|
187
|
+
* "(...)photos" → { marker: "(...)", segmentName: "photos" }
|
|
188
|
+
* "(..)(..)admin" → { marker: "(..)(..)", segmentName: "admin" }
|
|
189
|
+
* "(marketing)" → null (route group, not interception)
|
|
190
|
+
*/
|
|
191
|
+
function parseInterceptionMarker(
|
|
192
|
+
dirName: string
|
|
193
|
+
): { marker: InterceptionMarker; segmentName: string } | null {
|
|
194
|
+
for (const marker of INTERCEPTION_MARKERS) {
|
|
195
|
+
if (dirName.startsWith(marker)) {
|
|
196
|
+
const rest = dirName.slice(marker.length);
|
|
197
|
+
// Must have a segment name after the marker, and the rest must not
|
|
198
|
+
// be empty or end with ) (which would be a route group like "(auth)")
|
|
199
|
+
if (rest.length > 0 && !rest.endsWith(')')) {
|
|
200
|
+
return { marker, segmentName: rest };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Compute the URL path for a child segment given its parent's URL path.
|
|
209
|
+
* Route groups, slots, and intercepting routes do NOT add URL depth.
|
|
210
|
+
*/
|
|
211
|
+
function computeUrlPath(parentUrlPath: string, dirName: string, segmentType: SegmentType): string {
|
|
212
|
+
// Groups, slots, and intercepting routes don't add to URL path
|
|
213
|
+
if (segmentType === 'group' || segmentType === 'slot' || segmentType === 'intercepting') {
|
|
214
|
+
return parentUrlPath;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const parentPath = parentUrlPath === '/' ? '' : parentUrlPath;
|
|
218
|
+
return `${parentPath}/${dirName}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Scan a directory for file conventions and populate the segment node.
|
|
223
|
+
*/
|
|
224
|
+
function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string>): void {
|
|
225
|
+
let entries: string[];
|
|
226
|
+
try {
|
|
227
|
+
entries = readdirSync(dirPath);
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
const fullPath = join(dirPath, entry);
|
|
234
|
+
|
|
235
|
+
// Skip directories — handled by scanChildren
|
|
236
|
+
try {
|
|
237
|
+
if (statSync(fullPath).isDirectory()) continue;
|
|
238
|
+
} catch {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const ext = extname(entry).slice(1); // remove leading dot
|
|
243
|
+
const name = basename(entry, `.${ext}`);
|
|
244
|
+
|
|
245
|
+
// Page-extension conventions (page, layout, error, default, denied)
|
|
246
|
+
if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {
|
|
247
|
+
const file: RouteFile = { filePath: fullPath, extension: ext };
|
|
248
|
+
switch (name) {
|
|
249
|
+
case 'page':
|
|
250
|
+
node.page = file;
|
|
251
|
+
break;
|
|
252
|
+
case 'layout':
|
|
253
|
+
node.layout = file;
|
|
254
|
+
break;
|
|
255
|
+
case 'error':
|
|
256
|
+
node.error = file;
|
|
257
|
+
break;
|
|
258
|
+
case 'default':
|
|
259
|
+
node.default = file;
|
|
260
|
+
break;
|
|
261
|
+
case 'denied':
|
|
262
|
+
node.denied = file;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Fixed conventions (middleware, access, route) — always .ts or .tsx
|
|
269
|
+
if (FIXED_CONVENTIONS.has(name) && /\.?[jt]sx?$/.test(ext)) {
|
|
270
|
+
const file: RouteFile = { filePath: fullPath, extension: ext };
|
|
271
|
+
switch (name) {
|
|
272
|
+
case 'middleware':
|
|
273
|
+
node.middleware = file;
|
|
274
|
+
break;
|
|
275
|
+
case 'access':
|
|
276
|
+
node.access = file;
|
|
277
|
+
break;
|
|
278
|
+
case 'route':
|
|
279
|
+
node.route = file;
|
|
280
|
+
break;
|
|
281
|
+
case 'prerender':
|
|
282
|
+
node.prerender = file;
|
|
283
|
+
break;
|
|
284
|
+
case 'search-params':
|
|
285
|
+
node.searchParams = file;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// JSON status-code files (401.json, 4xx.json, 503.json, 5xx.json)
|
|
292
|
+
// Recognized regardless of pageExtensions — .json is a data format, not a page extension.
|
|
293
|
+
if (STATUS_CODE_PATTERN.test(name) && ext === 'json') {
|
|
294
|
+
if (!node.jsonStatusFiles) {
|
|
295
|
+
node.jsonStatusFiles = new Map();
|
|
296
|
+
}
|
|
297
|
+
node.jsonStatusFiles.set(name, { filePath: fullPath, extension: ext });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Status-code files (401.tsx, 4xx.tsx, 503.tsx, 5xx.tsx)
|
|
302
|
+
if (STATUS_CODE_PATTERN.test(name) && extSet.has(ext)) {
|
|
303
|
+
if (!node.statusFiles) {
|
|
304
|
+
node.statusFiles = new Map();
|
|
305
|
+
}
|
|
306
|
+
node.statusFiles.set(name, { filePath: fullPath, extension: ext });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)
|
|
311
|
+
if (name in LEGACY_STATUS_FILES && extSet.has(ext)) {
|
|
312
|
+
if (!node.legacyStatusFiles) {
|
|
313
|
+
node.legacyStatusFiles = new Map();
|
|
314
|
+
}
|
|
315
|
+
node.legacyStatusFiles.set(name, { filePath: fullPath, extension: ext });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
|
|
320
|
+
// See design/16-metadata.md §"Metadata Routes"
|
|
321
|
+
const metaInfo = classifyMetadataRoute(entry);
|
|
322
|
+
if (metaInfo) {
|
|
323
|
+
if (!node.metadataRoutes) {
|
|
324
|
+
node.metadataRoutes = new Map();
|
|
325
|
+
}
|
|
326
|
+
node.metadataRoutes.set(name, { filePath: fullPath, extension: ext });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Validate: route.ts + page.* is a hard build error
|
|
331
|
+
if (node.route && node.page) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Build error: route.ts and page.* cannot coexist in the same segment.\n` +
|
|
334
|
+
` route.ts: ${node.route.filePath}\n` +
|
|
335
|
+
` page: ${node.page.filePath}\n` +
|
|
336
|
+
`A URL is either an API endpoint or a rendered page, not both.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Recursively scan child directories and build the segment tree.
|
|
343
|
+
*/
|
|
344
|
+
function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<string>): void {
|
|
345
|
+
let entries: string[];
|
|
346
|
+
try {
|
|
347
|
+
entries = readdirSync(dirPath);
|
|
348
|
+
} catch {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
const fullPath = join(dirPath, entry);
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
if (!statSync(fullPath).isDirectory()) continue;
|
|
357
|
+
} catch {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Reject directories with encoded path delimiters or null bytes.
|
|
362
|
+
// These can cause route collisions when decoded at the URL boundary.
|
|
363
|
+
// See design/13-security.md §"Encoded separators rejected" and §"Null bytes rejected".
|
|
364
|
+
if (ENCODED_SEPARATOR_PATTERN.test(entry)) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Build error: directory name contains an encoded path delimiter (%%2F or %%5C).\n` +
|
|
367
|
+
` Directory: ${fullPath}\n` +
|
|
368
|
+
`Encoded separators in directory names cause route collisions when decoded. ` +
|
|
369
|
+
`Rename the directory to remove the encoded delimiter.`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (ENCODED_NULL_PATTERN.test(entry)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Build error: directory name contains an encoded null byte (%%00).\n` +
|
|
375
|
+
` Directory: ${fullPath}\n` +
|
|
376
|
+
`Encoded null bytes in directory names are not allowed. ` +
|
|
377
|
+
`Rename the directory to remove the null byte encoding.`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const { type, paramName, interceptionMarker, interceptedSegmentName } = classifySegment(entry);
|
|
382
|
+
|
|
383
|
+
// Skip private folders — underscore-prefixed dirs are excluded from routing
|
|
384
|
+
if (type === 'private') continue;
|
|
385
|
+
|
|
386
|
+
const urlPath = computeUrlPath(parentNode.urlPath, entry, type);
|
|
387
|
+
const childNode = createSegmentNode(
|
|
388
|
+
entry,
|
|
389
|
+
type,
|
|
390
|
+
urlPath,
|
|
391
|
+
paramName,
|
|
392
|
+
interceptionMarker,
|
|
393
|
+
interceptedSegmentName
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Scan this segment's files
|
|
397
|
+
scanSegmentFiles(fullPath, childNode, extSet);
|
|
398
|
+
|
|
399
|
+
// Recurse into subdirectories
|
|
400
|
+
scanChildren(fullPath, childNode, extSet);
|
|
401
|
+
|
|
402
|
+
// Attach to parent: slots go into slots map, everything else is a child
|
|
403
|
+
if (type === 'slot') {
|
|
404
|
+
const slotName = entry.slice(1); // remove @
|
|
405
|
+
parentNode.slots.set(slotName, childNode);
|
|
406
|
+
} else {
|
|
407
|
+
parentNode.children.push(childNode);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Validate that route groups don't produce conflicting pages/routes at the same URL path.
|
|
414
|
+
*
|
|
415
|
+
* Two route groups like (auth)/login/page.tsx and (marketing)/login/page.tsx both claim
|
|
416
|
+
* /login — the scanner must detect and reject this at build time.
|
|
417
|
+
*
|
|
418
|
+
* Parallel slots are excluded from collision detection — they intentionally coexist at
|
|
419
|
+
* the same URL path as their parent (that's the whole point of parallel routes).
|
|
420
|
+
*/
|
|
421
|
+
function validateRouteGroupCollisions(root: SegmentNode): void {
|
|
422
|
+
// Map from urlPath → { filePath, source } for the first page/route seen at that path
|
|
423
|
+
const seen = new Map<string, { filePath: string; segmentPath: string }>();
|
|
424
|
+
collectRoutableLeaves(root, seen, '', false);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Walk the segment tree and collect all routable leaves (page or route files),
|
|
429
|
+
* throwing on collision. Slots are tracked in their own collision space since
|
|
430
|
+
* they are parallel routes that intentionally share URL paths with their parent.
|
|
431
|
+
*/
|
|
432
|
+
function collectRoutableLeaves(
|
|
433
|
+
node: SegmentNode,
|
|
434
|
+
seen: Map<string, { filePath: string; segmentPath: string }>,
|
|
435
|
+
segmentPath: string,
|
|
436
|
+
insideSlot: boolean
|
|
437
|
+
): void {
|
|
438
|
+
const currentPath = segmentPath
|
|
439
|
+
? `${segmentPath}/${node.segmentName}`
|
|
440
|
+
: node.segmentName || '(root)';
|
|
441
|
+
|
|
442
|
+
// Only check collisions for non-slot pages — slots intentionally share URL paths
|
|
443
|
+
if (!insideSlot) {
|
|
444
|
+
const routableFile = node.page ?? node.route;
|
|
445
|
+
if (routableFile) {
|
|
446
|
+
const existing = seen.get(node.urlPath);
|
|
447
|
+
if (existing) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
|
|
450
|
+
` URL path: ${node.urlPath}\n` +
|
|
451
|
+
` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
|
|
452
|
+
` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
|
|
453
|
+
`Each URL path must map to exactly one page or route handler. ` +
|
|
454
|
+
`Rename or move one of the conflicting files.`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Recurse into children
|
|
462
|
+
for (const child of node.children) {
|
|
463
|
+
collectRoutableLeaves(child, seen, currentPath, insideSlot);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Recurse into slots — each slot is its own parallel route space
|
|
467
|
+
for (const [, slotNode] of node.slots) {
|
|
468
|
+
collectRoutableLeaves(slotNode, seen, currentPath, true);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Find a fixed-extension file (proxy.ts) in a directory.
|
|
474
|
+
*/
|
|
475
|
+
function findFixedFile(dirPath: string, name: string): RouteFile | undefined {
|
|
476
|
+
for (const ext of ['ts', 'tsx']) {
|
|
477
|
+
const fullPath = join(dirPath, `${name}.${ext}`);
|
|
478
|
+
try {
|
|
479
|
+
if (statSync(fullPath).isFile()) {
|
|
480
|
+
return { filePath: fullPath, extension: ext };
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
// File doesn't exist
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|