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