@timber-js/app 0.2.0-alpha.90 → 0.2.0-alpha.91

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.
@@ -9,29 +9,10 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync } from 'node:fs';
12
- import { relative, posix } from 'node:path';
13
12
  import type { RouteTree, SegmentNode } from './types.js';
14
-
15
- /** A single route entry extracted from the segment tree. */
16
- interface RouteEntry {
17
- /** URL path pattern (e.g. "/products/[id]") */
18
- urlPath: string;
19
- /** Accumulated params from all ancestor dynamic segments */
20
- params: ParamEntry[];
21
- /** Whether the page.tsx exports searchParams */
22
- hasSearchParams: boolean;
23
- /** Absolute path to the page file that exports searchParams (for import paths) */
24
- searchParamsPagePath?: string;
25
- /** Whether this is an API route (route.ts) vs page route */
26
- isApiRoute: boolean;
27
- }
28
-
29
- interface ParamEntry {
30
- name: string;
31
- type: 'string' | 'string[]' | 'string[] | undefined';
32
- /** When a layout/page exports defineParams, the absolute path to that file. */
33
- codecFilePath?: string;
34
- }
13
+ import type { ParamEntry, RouteEntry } from './codegen-types.js';
14
+ import { buildCodecChainType, formatSearchParamsType } from './codegen-shared.js';
15
+ import { formatLinkCatchAllOverloads, formatTypedLinkOverloads } from './link-codegen.js';
35
16
 
36
17
  /** Options for route map generation. */
37
18
  export interface CodegenOptions {
@@ -53,7 +34,7 @@ export interface CodegenOptions {
53
34
  */
54
35
  export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}): string {
55
36
  const routes: RouteEntry[] = [];
56
- collectRoutes(tree.root, [], routes);
37
+ collectRoutes(tree.root, [], [], routes);
57
38
 
58
39
  // Sort routes alphabetically for deterministic output
59
40
  routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
@@ -73,20 +54,47 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
73
54
  function collectRoutes(
74
55
  node: SegmentNode,
75
56
  ancestorParams: ParamEntry[],
57
+ ancestorParamsFiles: string[],
76
58
  routes: RouteEntry[]
77
59
  ): void {
60
+ // TIM-834: Identify this segment's own params.ts (if it has a
61
+ // segmentParams export). Ancestor params.ts files are inherited by
62
+ // descendant dynamic segments — closest-wins.
63
+ const ownParamsFile =
64
+ node.params && fileHasExport(node.params.filePath, 'segmentParams')
65
+ ? node.params.filePath
66
+ : undefined;
67
+
78
68
  // Accumulate params from this segment
79
69
  const params = [...ancestorParams];
80
70
  if (node.paramName) {
81
- // Check if layout or page exports a params codec for this segment
82
- const codecFile = findParamsExport(node);
71
+ // Build the codec lookup chain in priority order: own segment first,
72
+ // then ancestor params.ts files closest-to-this-segment first. The
73
+ // generated type emits a nested conditional that picks the first
74
+ // entry in the chain whose `segmentParams` declares this key.
75
+ const codecFilePaths: string[] = [];
76
+ if (ownParamsFile) codecFilePaths.push(ownParamsFile);
77
+ for (let i = ancestorParamsFiles.length - 1; i >= 0; i--) {
78
+ codecFilePaths.push(ancestorParamsFiles[i]);
79
+ }
80
+ // Legacy fallback: layout/page export — only for the OWN segment.
81
+ // Inheritance does not extend to legacy layout/page exports.
82
+ if (codecFilePaths.length === 0) {
83
+ const legacy = findLegacyParamsExport(node);
84
+ if (legacy) codecFilePaths.push(legacy);
85
+ }
83
86
  params.push({
84
87
  name: node.paramName,
85
88
  type: paramTypeForSegment(node.segmentType),
86
- codecFilePath: codecFile,
89
+ codecFilePaths: codecFilePaths.length > 0 ? codecFilePaths : undefined,
87
90
  });
88
91
  }
89
92
 
93
+ // Extend the ancestor chain for descendants of this segment.
94
+ const nextAncestorFiles = ownParamsFile
95
+ ? [...ancestorParamsFiles, ownParamsFile]
96
+ : ancestorParamsFiles;
97
+
90
98
  // Check if this segment is a leaf route (has page or route file)
91
99
  const isPage = !!node.page;
92
100
  const isApiRoute = !!node.route;
@@ -115,12 +123,12 @@ function collectRoutes(
115
123
 
116
124
  // Recurse into children
117
125
  for (const child of node.children) {
118
- collectRoutes(child, params, routes);
126
+ collectRoutes(child, params, nextAncestorFiles, routes);
119
127
  }
120
128
 
121
129
  // Recurse into slots (they share the parent's URL path, but may have their own pages)
122
130
  for (const [, slot] of node.slots) {
123
- collectRoutes(slot, params, routes);
131
+ collectRoutes(slot, params, nextAncestorFiles, routes);
124
132
  }
125
133
  }
126
134
 
@@ -159,18 +167,13 @@ function fileHasExport(filePath: string, exportName: string): boolean {
159
167
  }
160
168
 
161
169
  /**
162
- * Find which file exports `segmentParams` for a dynamic segment.
170
+ * Find a legacy `segmentParams` export on layout.tsx or page.tsx.
163
171
  *
164
- * Priority: params.ts (convention file) > layout > page.
165
- * params.ts is the canonical location (TIM-508). Layout/page fallback
166
- * exists for backward compat during migration.
172
+ * Backward-compat shim: TIM-508 made params.ts the canonical location
173
+ * for `segmentParams`. Layout/page exports are still accepted for the
174
+ * OWN segment only (not inherited by descendants — see TIM-834).
167
175
  */
168
- function findParamsExport(node: SegmentNode): string | undefined {
169
- // params.ts convention file — primary location
170
- if (node.params && fileHasExport(node.params.filePath, 'segmentParams')) {
171
- return node.params.filePath;
172
- }
173
- // Fallback: layout or page export (legacy, will be removed)
176
+ function findLegacyParamsExport(node: SegmentNode): string | undefined {
174
177
  if (node.layout && fileHasExport(node.layout.filePath, 'segmentParams')) {
175
178
  return node.layout.filePath;
176
179
  }
@@ -290,79 +293,12 @@ function formatParamsType(params: ParamEntry[], importBase?: string): string {
290
293
  }
291
294
 
292
295
  const fields = params.map((p) => {
293
- if (p.codecFilePath) {
294
- // Use the codec's inferred type from the layout/page file
295
- const absPath = p.codecFilePath.replace(/\.(ts|tsx)$/, '');
296
- let importPath: string;
297
- if (importBase) {
298
- importPath = './' + relative(importBase, absPath).replace(/\\/g, '/');
299
- } else {
300
- importPath = './' + posix.basename(absPath);
301
- }
302
- // Extract T from the 'segmentParams' named export's ParamsDefinition<T>
303
- const codecType = `(typeof import('${importPath}'))['segmentParams'] extends import('@timber-js/app/segment-params').ParamsDefinition<infer T> ? T[${JSON.stringify(p.name)}] : ${p.type}`;
304
- return `${p.name}: ${codecType}`;
305
- }
306
- return `${p.name}: ${p.type}`;
296
+ const codecType = buildCodecChainType(p, importBase, p.type);
297
+ return `${p.name}: ${codecType}`;
307
298
  });
308
299
  return `{ ${fields.join('; ')} }`;
309
300
  }
310
301
 
311
- /**
312
- * Format the params type for Link overloads.
313
- *
314
- * Link params accept `string | number` for single dynamic segments
315
- * (convenience — values are stringified at runtime). Catch-all and
316
- * optional catch-all remain `string[]` / `string[] | undefined`.
317
- *
318
- * Uses DIRECT types (not conditional) to preserve excess property checking.
319
- */
320
- function formatLinkParamsType(params: ParamEntry[], importBase?: string): string {
321
- if (params.length === 0) {
322
- return '{}';
323
- }
324
-
325
- const fields = params.map((p) => {
326
- if (p.codecFilePath) {
327
- const absPath = p.codecFilePath.replace(/\.(ts|tsx)$/, '');
328
- let importPath: string;
329
- if (importBase) {
330
- importPath = './' + relative(importBase, absPath).replace(/\\/g, '/');
331
- } else {
332
- importPath = './' + posix.basename(absPath);
333
- }
334
- const codecType = `(typeof import('${importPath}'))['segmentParams'] extends import('@timber-js/app/segment-params').ParamsDefinition<infer T> ? T[${JSON.stringify(p.name)}] : ${p.type}`;
335
- return `${p.name}: ${codecType}`;
336
- }
337
- const type = p.type === 'string' ? 'string | number' : p.type;
338
- return `${p.name}: ${type}`;
339
- });
340
- return `{ ${fields.join('; ')} }`;
341
- }
342
-
343
- /**
344
- * Format the searchParams type for a route entry.
345
- *
346
- * When a page.tsx exports searchParams, we reference its inferred type via an import type.
347
- * The import path is relative to `importBase` (the directory where the .d.ts will be
348
- * written). When importBase is undefined, falls back to a bare relative path.
349
- */
350
- function formatSearchParamsType(route: RouteEntry, importBase?: string): string {
351
- if (route.hasSearchParams && route.searchParamsPagePath) {
352
- const absPath = route.searchParamsPagePath.replace(/\.(ts|tsx)$/, '');
353
- let importPath: string;
354
- if (importBase) {
355
- // Make the path relative to the output directory, converted to posix separators
356
- importPath = './' + relative(importBase, absPath).replace(/\\/g, '/');
357
- } else {
358
- importPath = './' + posix.basename(absPath);
359
- }
360
- // Extract the type from the named 'searchParams' export of the page module.
361
- return `(typeof import('${importPath}'))['searchParams'] extends import('@timber-js/app/search-params').SearchParamsDefinition<infer T> ? T : never`;
362
- }
363
- return '{}';
364
- }
365
-
366
302
  /**
367
303
  * Generate useQueryStates overloads.
368
304
  *
@@ -392,173 +328,8 @@ function formatUseQueryStatesOverloads(routes: RouteEntry[], importBase?: string
392
328
  return lines;
393
329
  }
394
330
 
395
- /**
396
- * Build a TypeScript template literal pattern for a dynamic route.
397
- * e.g. '/products/[id]' → '/products/${string}'
398
- * '/blog/[...slug]' → '/blog/${string}'
399
- * '/docs/[[...path]]' → '/docs/${string}' (also matches /docs)
400
- * '/[org]/[repo]' → '/${string}/${string}'
401
- */
402
- function buildResolvedPattern(route: RouteEntry): string | null {
403
- const parts = route.urlPath.split('/');
404
- const templateParts = parts.map((part) => {
405
- if (part.startsWith('[[...') && part.endsWith(']]')) {
406
- // Optional catch-all — matches any trailing path
407
- return '${string}';
408
- }
409
- if (part.startsWith('[...') && part.endsWith(']')) {
410
- // Catch-all — matches one or more segments
411
- return '${string}';
412
- }
413
- if (part.startsWith('[') && part.endsWith(']')) {
414
- // Dynamic segment
415
- return '${string}';
416
- }
417
- return part;
418
- });
419
- return templateParts.join('/');
420
- }
421
-
422
- /** Shared Link base-props type literal used in every emitted call signature. */
423
- const LINK_BASE_PROPS_TYPE =
424
- "Omit<import('react').AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & { prefetch?: boolean; scroll?: boolean; preserveSearchParams?: true | string[]; onNavigate?: import('./client/link.js').OnNavigateHandler; children?: import('react').ReactNode }";
425
-
426
- /**
427
- * TIM-832: Catch-all call signatures for `<Link>` — external hrefs and
428
- * computed `string` variables. Emitted from codegen in a SEPARATE
429
- * `declare module` block (declared AFTER the per-route block) so the TS
430
- * "later overload set ordered first" rule places these catch-all
431
- * signatures ahead of per-route in resolution order, leaving per-route
432
- * as the final overload whose error message is reported on failure.
433
- *
434
- * The conditional `string extends H ? ... : never` protection preserves
435
- * TIM-624's guarantee that unknown internal path literals don't match
436
- * the catch-all — typos like `<Link href="/typo" />` still error.
437
- */
438
- function formatLinkCatchAllOverloads(): string[] {
439
- const lines: string[] = [];
440
- const baseProps = LINK_BASE_PROPS_TYPE;
441
-
442
- // TIM-830: the catch-all signatures accept EITHER the legacy
443
- // `{ definition, values }` wrapper OR a flat `Record<string, unknown>`
444
- // values object. External/computed hrefs can't be looked up in the
445
- // runtime registry, so the wrapped form is still the reliable path;
446
- // the flat form is kept permissive for callers migrating from typed-
447
- // route hrefs to computed strings. `resolveHref` discriminates at
448
- // runtime via the presence of a `definition` key.
449
- const catchAllSearchParams =
450
- '{ definition: SearchParamsDefinition<Record<string, unknown>>; values: Record<string, unknown> } | Record<string, unknown>';
451
-
452
- // ExternalHref inlined here rather than referenced as an exported
453
- // alias so the generated .d.ts stands alone without source imports.
454
- const externalHref =
455
- '`http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `ftp://${string}` | `//${string}` | `#${string}` | `?${string}`';
456
-
457
- lines.push(' // Typed Link overloads — catch-all (block 2 / emitted second)');
458
- lines.push(' interface LinkFunction {');
459
-
460
- // (1) External/literal-protocol hrefs.
461
- lines.push(` (props: ${baseProps} & {`);
462
- lines.push(` href: ${externalHref}`);
463
- lines.push(` segmentParams?: never`);
464
- lines.push(` searchParams?: ${catchAllSearchParams}`);
465
- lines.push(` }): import('react').JSX.Element`);
466
-
467
- // (2) Computed/variable href — non-literal `string` only.
468
- // `string extends H` is true only when H is the wide `string` type,
469
- // not a specific literal. Literal internal paths (typos) that don't
470
- // match any per-route signature collapse to `never` here, but since
471
- // this block is NOT the "last overload" at resolution time, TS
472
- // instead reports the error from the per-route block — which now
473
- // says `Type '"/typo"' is not assignable to type '"/products/[id]"'`
474
- // or similar per-route-specific guidance.
475
- lines.push(` <H extends string>(`);
476
- lines.push(` props: string extends H`);
477
- lines.push(` ? ${baseProps} & {`);
478
- lines.push(` href: H`);
479
- lines.push(` segmentParams?: Record<string, string | number | string[]>`);
480
- lines.push(` searchParams?: ${catchAllSearchParams}`);
481
- lines.push(` }`);
482
- lines.push(` : never`);
483
- lines.push(` ): import('react').JSX.Element`);
484
-
485
- lines.push(' }');
486
- return lines;
487
- }
488
-
489
- /**
490
- * Generate typed per-route Link call signatures via LinkFunction
491
- * interface merging.
492
- *
493
- * Each call signature uses DIRECT types (not conditional types) for
494
- * segmentParams, preserving TypeScript's excess property checking.
495
- * Interface merging is the only reliable way to add "overloads" via
496
- * module augmentation — function overloads can't be augmented.
497
- *
498
- * TIM-832: this function emits ONLY the per-route block. The catch-all
499
- * block is emitted separately by `formatLinkCatchAllOverloads` inside a
500
- * second `declare module` so TS overload ordering reports per-route
501
- * errors (see `formatDeclarationFile`).
502
- */
503
- function formatTypedLinkOverloads(routes: RouteEntry[], importBase?: string): string[] {
504
- const lines: string[] = [];
505
- const baseProps = LINK_BASE_PROPS_TYPE;
506
-
507
- lines.push(' interface LinkFunction {');
508
- for (const route of routes) {
509
- const hasDynamicParams = route.params.length > 0;
510
- const paramsProp = hasDynamicParams
511
- ? `segmentParams: ${formatLinkParamsType(route.params, importBase)}`
512
- : 'segmentParams?: never';
513
-
514
- const searchParamsType = route.hasSearchParams
515
- ? formatSearchParamsType(route, importBase)
516
- : null;
517
- // TIM-830: the per-route pattern signature (href: '/products/[id]')
518
- // uses a FLAT `Partial<T>` shape — the registry is keyed by the
519
- // un-interpolated pattern, which matches `href` here exactly.
520
- const patternSearchParamsProp = searchParamsType
521
- ? `searchParams?: Partial<${searchParamsType}>`
522
- : 'searchParams?: never';
523
-
524
- // TIM-832: For dynamic routes, emit the resolved-template signature
525
- // FIRST and the pattern signature SECOND. The TS overload resolution
526
- // order is top-to-bottom, and TS reports the error from the LAST
527
- // overload tried on a failed call. For a user who passes the literal
528
- // pattern href (`href="/products/[id]"`) and mistyped segmentParams,
529
- // we want the PATTERN signature's error (which names the specific
530
- // `segmentParams` shape like `{ id: string | number }`) to be the one
531
- // TS reports — so the pattern must come LAST.
532
- //
533
- // TIM-830: the resolved-template signature keeps the legacy WRAPPED
534
- // `{ definition, values }` shape. The runtime registry is keyed by
535
- // the un-interpolated pattern (e.g. '/products/[id]'), so a flat
536
- // lookup for an already-interpolated href like '/products/42' would
537
- // fail. Flat values only work on the pattern signature below where
538
- // `href` is the pattern itself. Callers using a resolved-template
539
- // href must pass the definition inline, same as the external/
540
- // computed catch-all signature.
541
- if (hasDynamicParams) {
542
- const templatePattern = buildResolvedPattern(route);
543
- if (templatePattern) {
544
- const resolvedSearchParamsProp = searchParamsType
545
- ? `searchParams?: { definition: SearchParamsDefinition<${searchParamsType}>; values: Partial<${searchParamsType}> }`
546
- : 'searchParams?: never';
547
- lines.push(` (props: ${baseProps} & {`);
548
- lines.push(` href: \`${templatePattern}\``);
549
- lines.push(` segmentParams?: never`);
550
- lines.push(` ${resolvedSearchParamsProp}`);
551
- lines.push(` }): import('react').JSX.Element`);
552
- }
553
- }
554
-
555
- lines.push(` (props: ${baseProps} & {`);
556
- lines.push(` href: '${route.urlPath}'`);
557
- lines.push(` ${paramsProp}`);
558
- lines.push(` ${patternSearchParamsProp}`);
559
- lines.push(` }): import('react').JSX.Element`);
560
- }
561
- lines.push(' }');
562
-
563
- return lines;
564
- }
331
+ // Link overload formatters and helpers (`formatTypedLinkOverloads`,
332
+ // `formatLinkCatchAllOverloads`, `formatLinkParamsType`,
333
+ // `buildResolvedPattern`, `LINK_BASE_PROPS_TYPE`) were extracted to
334
+ // `./link-codegen.ts` (TIM-835) to keep this file under the 500-line
335
+ // cap. They are imported at the top of this file.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Typed `<Link>` codegen — interface augmentation generation.
3
+ *
4
+ * Extracted from `codegen.ts` to keep that file under the project's
5
+ * 500-line cap. This module owns everything that emits the
6
+ * `interface LinkFunction { ... }` augmentation blocks in the generated
7
+ * `.timber/timber-routes.d.ts`.
8
+ *
9
+ * Two augmentation blocks are emitted:
10
+ *
11
+ * 1. **Per-route discriminated union** (`formatTypedLinkOverloads`) —
12
+ * one call signature whose props is a union keyed on `href`. TS
13
+ * narrows by literal href and reports prop errors against the
14
+ * matched variant. See TIM-835 and `design/09-typescript.md`.
15
+ *
16
+ * 2. **Catch-all overloads** (`formatLinkCatchAllOverloads`) — external
17
+ * href literals (`http://`, `mailto:`, etc.) and a computed-string
18
+ * `<H extends string>` signature for runtime-computed paths.
19
+ *
20
+ * Block ordering is critical for error UX: per-route is emitted FIRST
21
+ * so that, after TS's "later overload set ordered first" merge rule,
22
+ * the discriminated union ends up LAST in resolution order — the
23
+ * overload TS reports against on failure.
24
+ */
25
+
26
+ import type { ParamEntry, RouteEntry } from './codegen-types.js';
27
+ import { buildCodecChainType, formatSearchParamsType } from './codegen-shared.js';
28
+
29
+ /** Shared Link base-props type literal used in every emitted call signature. */
30
+ export const LINK_BASE_PROPS_TYPE =
31
+ "Omit<import('react').AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & { prefetch?: boolean; scroll?: boolean; preserveSearchParams?: true | string[]; onNavigate?: import('./client/link.js').OnNavigateHandler; children?: import('react').ReactNode }";
32
+
33
+ /**
34
+ * Build a TypeScript template literal pattern for a dynamic route.
35
+ * e.g. '/products/[id]' → '/products/${string}'
36
+ * '/blog/[...slug]' → '/blog/${string}'
37
+ * '/docs/[[...path]]' → '/docs/${string}' (also matches /docs)
38
+ * '/[org]/[repo]' → '/${string}/${string}'
39
+ */
40
+ export function buildResolvedPattern(route: RouteEntry): string | null {
41
+ const parts = route.urlPath.split('/');
42
+ const templateParts = parts.map((part) => {
43
+ if (part.startsWith('[[...') && part.endsWith(']]')) {
44
+ // Optional catch-all — matches any trailing path
45
+ return '${string}';
46
+ }
47
+ if (part.startsWith('[...') && part.endsWith(']')) {
48
+ // Catch-all — matches one or more segments
49
+ return '${string}';
50
+ }
51
+ if (part.startsWith('[') && part.endsWith(']')) {
52
+ // Dynamic segment
53
+ return '${string}';
54
+ }
55
+ return part;
56
+ });
57
+ return templateParts.join('/');
58
+ }
59
+
60
+ /**
61
+ * Format the segmentParams type for Link overloads.
62
+ *
63
+ * Link params accept `string | number` for single dynamic segments
64
+ * (convenience — values are stringified at runtime). Catch-all and
65
+ * optional catch-all remain `string[]` / `string[] | undefined`.
66
+ *
67
+ * When the segment's params chain (TIM-834) declares a typed codec, the
68
+ * inferred type from the codec wins via a nested conditional.
69
+ */
70
+ export function formatLinkParamsType(params: ParamEntry[], importBase?: string): string {
71
+ if (params.length === 0) {
72
+ return '{}';
73
+ }
74
+
75
+ const fields = params.map((p) => {
76
+ const fallback = p.type === 'string' ? 'string | number' : p.type;
77
+ const codecType = buildCodecChainType(p, importBase, fallback);
78
+ return `${p.name}: ${codecType}`;
79
+ });
80
+ return `{ ${fields.join('; ')} }`;
81
+ }
82
+
83
+ /**
84
+ * Catch-all call signatures for `<Link>` — external hrefs and computed
85
+ * `string` variables. Emitted from codegen in a SEPARATE
86
+ * `declare module` block (declared AFTER the per-route block) so the TS
87
+ * "later overload set ordered first" rule places these catch-all
88
+ * signatures ahead of per-route in resolution order, leaving per-route
89
+ * as the final overload whose error message is reported on failure.
90
+ *
91
+ * The conditional `string extends H ? ... : never` protection preserves
92
+ * TIM-624's guarantee that unknown internal path literals don't match
93
+ * the catch-all — typos like `<Link href="/typo" />` still error.
94
+ */
95
+ export function formatLinkCatchAllOverloads(): string[] {
96
+ const lines: string[] = [];
97
+ const baseProps = LINK_BASE_PROPS_TYPE;
98
+
99
+ // TIM-830: the catch-all signatures accept EITHER the legacy
100
+ // `{ definition, values }` wrapper OR a flat `Record<string, unknown>`
101
+ // values object. External/computed hrefs can't be looked up in the
102
+ // runtime registry, so the wrapped form is still the reliable path;
103
+ // the flat form is kept permissive for callers migrating from typed-
104
+ // route hrefs to computed strings. `resolveHref` discriminates at
105
+ // runtime via the presence of a `definition` key.
106
+ const catchAllSearchParams =
107
+ '{ definition: SearchParamsDefinition<Record<string, unknown>>; values: Record<string, unknown> } | Record<string, unknown>';
108
+
109
+ // ExternalHref inlined here rather than referenced as an exported
110
+ // alias so the generated .d.ts stands alone without source imports.
111
+ const externalHref =
112
+ '`http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `ftp://${string}` | `//${string}` | `#${string}` | `?${string}`';
113
+
114
+ lines.push(' // Typed Link overloads — catch-all (block 2 / emitted second)');
115
+ lines.push(' interface LinkFunction {');
116
+
117
+ // (1) External/literal-protocol hrefs.
118
+ lines.push(` (props: ${baseProps} & {`);
119
+ lines.push(` href: ${externalHref}`);
120
+ lines.push(` segmentParams?: never`);
121
+ lines.push(` searchParams?: ${catchAllSearchParams}`);
122
+ lines.push(` }): import('react').JSX.Element`);
123
+
124
+ // (2) Computed/variable href — non-literal `string` only.
125
+ // `string extends H` is true only when H is the wide `string` type,
126
+ // not a specific literal. Literal internal paths (typos) that don't
127
+ // match any per-route signature collapse to `never` here, but since
128
+ // this block is NOT the "last overload" at resolution time, TS
129
+ // instead reports the error from the per-route block — which now
130
+ // says `Type '"/typo"' is not assignable to type '"/products/[id]"'`
131
+ // or similar per-route-specific guidance.
132
+ lines.push(` <H extends string>(`);
133
+ lines.push(` props: string extends H`);
134
+ lines.push(` ? ${baseProps} & {`);
135
+ lines.push(` href: H`);
136
+ lines.push(` segmentParams?: Record<string, string | number | string[]>`);
137
+ lines.push(` searchParams?: ${catchAllSearchParams}`);
138
+ lines.push(` }`);
139
+ lines.push(` : never`);
140
+ lines.push(` ): import('react').JSX.Element`);
141
+
142
+ lines.push(' }');
143
+ return lines;
144
+ }
145
+
146
+ /**
147
+ * Generate typed per-route Link call signatures via LinkFunction
148
+ * interface merging.
149
+ *
150
+ * TIM-835: This emits a SINGLE call signature whose props is a
151
+ * discriminated union keyed on `href`. TypeScript narrows the union by
152
+ * the literal `href` at the call site, then checks the rest of the
153
+ * props against the matched variant. When `segmentParams` or
154
+ * `searchParams` is wrong, TS reports the error against the matched
155
+ * variant — naming the user's actual `href` and the offending field.
156
+ *
157
+ * Before TIM-835, this function emitted N separate per-route overloads
158
+ * (one per route, sometimes two for dynamic routes). When ALL overloads
159
+ * failed, TS would pick an arbitrary failed overload (heuristically the
160
+ * "last tried") to render the diagnostic, often pointing at a route
161
+ * completely unrelated to the one the user wrote. The discriminated
162
+ * union sidesteps overload-resolution heuristics entirely.
163
+ *
164
+ * Open property shapes (`Record<string, unknown>` instead of `never`)
165
+ * for `segmentParams` on routes that don't declare them keep generic
166
+ * `LinkProps`-spreading wrappers compiling. (Aligned with TIM-833.)
167
+ *
168
+ * TIM-832: this function still emits the per-route block FIRST and the
169
+ * catch-all block follows, so per-route remains the LAST overload set in
170
+ * resolution order — the one TS reports against on failure. With a
171
+ * discriminated union there is only one call signature in this block,
172
+ * so the resolved-template-vs-pattern ordering reduces to placing the
173
+ * pattern variant before the resolved-template variant inside the union.
174
+ */
175
+ export function formatTypedLinkOverloads(routes: RouteEntry[], importBase?: string): string[] {
176
+ const lines: string[] = [];
177
+ const baseProps = LINK_BASE_PROPS_TYPE;
178
+
179
+ // Build the union variants. Each route contributes one variant for the
180
+ // pattern href (e.g. '/products/[id]') and, for dynamic routes, an
181
+ // additional variant for the resolved-template href (e.g.
182
+ // `/products/${string}`).
183
+ //
184
+ // The PATTERN variant must be listed BEFORE the resolved-template
185
+ // variant for the same route so that when the user writes the literal
186
+ // pattern href (`<Link href="/products/[id]" />`), TS narrows to the
187
+ // pattern variant (which carries the typed `segmentParams` shape)
188
+ // rather than the looser resolved-template variant. Without this
189
+ // ordering, TS would silently match the resolved-template variant
190
+ // first — swallowing typed-segmentParams type errors.
191
+ const variants: string[] = [];
192
+ for (const route of routes) {
193
+ const hasDynamicParams = route.params.length > 0;
194
+ // For routes with no dynamic params, accept an absent OR open-shape
195
+ // segmentParams. `Record<string, unknown>` keeps generic spreads
196
+ // compiling and gives a readable error if a non-object is passed.
197
+ const paramsProp = hasDynamicParams
198
+ ? `segmentParams: ${formatLinkParamsType(route.params, importBase)}`
199
+ : 'segmentParams?: Record<string, unknown>';
200
+
201
+ const searchParamsType = route.hasSearchParams
202
+ ? formatSearchParamsType(route, importBase)
203
+ : null;
204
+ // TIM-830: pattern href uses the FLAT `Partial<T>` shape — the
205
+ // runtime registry is keyed by the un-interpolated pattern.
206
+ //
207
+ // TIM-835: when the route has NO searchParams definition, use
208
+ // `Record<string, never>` (forbids any keys) instead of
209
+ // `Record<string, unknown>` so passing unexpected query params is a
210
+ // type error. Generic-spread compatibility is preserved by the
211
+ // matching open shape on `segmentParams` (which is the prop users
212
+ // typically forward through wrapper components).
213
+ const patternSearchParamsProp = searchParamsType
214
+ ? `searchParams?: Partial<${searchParamsType}>`
215
+ : 'searchParams?: Record<string, never>';
216
+
217
+ // Pattern variant FIRST (more specific).
218
+ variants.push(
219
+ `${baseProps} & { href: '${route.urlPath}'; ${paramsProp}; ${patternSearchParamsProp} }`
220
+ );
221
+
222
+ // Resolved-template variant SECOND (looser, matches interpolated hrefs).
223
+ if (hasDynamicParams) {
224
+ const templatePattern = buildResolvedPattern(route);
225
+ if (templatePattern) {
226
+ // TIM-830: resolved-template href keeps the WRAPPED
227
+ // `{ definition, values }` shape — the registry can't be looked
228
+ // up by an already-interpolated href.
229
+ const resolvedSearchParamsProp = searchParamsType
230
+ ? `searchParams?: { definition: SearchParamsDefinition<${searchParamsType}>; values: Partial<${searchParamsType}> }`
231
+ : 'searchParams?: Record<string, never>';
232
+ // TIM-835: `Record<string, never>` for segmentParams forbids ANY
233
+ // provided keys on the resolved-template variant. Without this,
234
+ // the resolved-template would shadow the pattern variant for
235
+ // literal pattern hrefs (`<Link href="/products/[id]" segmentParams={...} />`)
236
+ // because both variants accept the literal `'/products/[id]'`
237
+ // and the looser variant would silently swallow segmentParams
238
+ // type errors.
239
+ variants.push(
240
+ `${baseProps} & { href: \`${templatePattern}\`; segmentParams?: Record<string, never>; ${resolvedSearchParamsProp} }`
241
+ );
242
+ }
243
+ }
244
+ }
245
+
246
+ lines.push(' interface LinkFunction {');
247
+ if (variants.length === 0) {
248
+ // No page routes — emit nothing. The catch-all block in the second
249
+ // augmentation still provides external/computed-string signatures.
250
+ lines.push(' }');
251
+ return lines;
252
+ }
253
+ lines.push(' (');
254
+ lines.push(' props:');
255
+ for (const variant of variants) {
256
+ lines.push(` | (${variant})`);
257
+ }
258
+ lines.push(` ): import('react').JSX.Element`);
259
+ lines.push(' }');
260
+
261
+ return lines;
262
+ }