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

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,14 @@
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 {
15
+ buildCodecChainType,
16
+ emitResolveSegmentFieldHelper,
17
+ formatSearchParamsType,
18
+ } from './codegen-shared.js';
19
+ import { formatLinkCatchAllOverloads, formatTypedLinkOverloads } from './link-codegen.js';
35
20
 
36
21
  /** Options for route map generation. */
37
22
  export interface CodegenOptions {
@@ -53,7 +38,7 @@ export interface CodegenOptions {
53
38
  */
54
39
  export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}): string {
55
40
  const routes: RouteEntry[] = [];
56
- collectRoutes(tree.root, [], routes);
41
+ collectRoutes(tree.root, [], [], routes);
57
42
 
58
43
  // Sort routes alphabetically for deterministic output
59
44
  routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
@@ -73,28 +58,73 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
73
58
  function collectRoutes(
74
59
  node: SegmentNode,
75
60
  ancestorParams: ParamEntry[],
61
+ ancestorParamsFiles: string[],
76
62
  routes: RouteEntry[]
77
63
  ): void {
78
- // Accumulate params from this segment
64
+ // TIM-834: Identify this segment's own params.ts (if it has a
65
+ // segmentParams export). The full chain of params.ts files in the
66
+ // route ancestry is threaded down via `ancestorParamsFiles`; codec
67
+ // resolution for each ParamEntry is deferred until leaf time so that
68
+ // descendant params.ts files can override ancestor codecs (closest-
69
+ // to-leaf wins, matching the runtime semantics of
70
+ // coerceSegmentParams which walks segments top-down and overwrites
71
+ // earlier coercions).
72
+ const ownParamsFile =
73
+ node.params && fileHasExport(node.params.filePath, 'segmentParams')
74
+ ? node.params.filePath
75
+ : undefined;
76
+
77
+ // Accumulate params from this segment. We attach `codecFilePaths`
78
+ // later (at leaf time) using the FULL chain so descendant overrides
79
+ // are visible. The legacy layout/page fallback is recorded now
80
+ // because it is per-segment (and does not participate in inheritance).
79
81
  const params = [...ancestorParams];
80
82
  if (node.paramName) {
81
- // Check if layout or page exports a params codec for this segment
82
- const codecFile = findParamsExport(node);
83
+ const legacyFallback = ownParamsFile ? undefined : findLegacyParamsExport(node);
83
84
  params.push({
84
85
  name: node.paramName,
85
86
  type: paramTypeForSegment(node.segmentType),
86
- codecFilePath: codecFile,
87
+ // Codec chain populated at leaf time. We carry the per-segment
88
+ // legacy fallback (if any) so leaf-time resolution can fall back
89
+ // to it when no params.ts in the chain declares this key.
90
+ legacyCodecFilePath: legacyFallback,
87
91
  });
88
92
  }
89
93
 
94
+ // Extend the chain for descendants of this segment.
95
+ const nextAncestorFiles = ownParamsFile
96
+ ? [...ancestorParamsFiles, ownParamsFile]
97
+ : ancestorParamsFiles;
98
+
90
99
  // Check if this segment is a leaf route (has page or route file)
91
100
  const isPage = !!node.page;
92
101
  const isApiRoute = !!node.route;
93
102
 
94
103
  if (isPage || isApiRoute) {
104
+ // TIM-834 P1 fix: at LEAF time, the full chain of params.ts files
105
+ // (root-to-leaf) is known. Resolve every ParamEntry's
106
+ // `codecFilePaths` to the chain in LEAF-FIRST order so the
107
+ // closest-to-leaf entry is checked first — matching runtime
108
+ // closest-wins semantics. The chain is shared by all params in the
109
+ // route, so we compute it once.
110
+ const leafFirstChain = nextAncestorFiles.length > 0 ? [...nextAncestorFiles].reverse() : [];
111
+ const resolvedParams: ParamEntry[] = params.map((p) => {
112
+ const codecFilePaths =
113
+ leafFirstChain.length > 0
114
+ ? leafFirstChain
115
+ : p.legacyCodecFilePath
116
+ ? [p.legacyCodecFilePath]
117
+ : undefined;
118
+ return {
119
+ name: p.name,
120
+ type: p.type,
121
+ codecFilePaths,
122
+ };
123
+ });
124
+
95
125
  const entry: RouteEntry = {
96
126
  urlPath: node.urlPath,
97
- params: [...params],
127
+ params: resolvedParams,
98
128
  hasSearchParams: false,
99
129
  isApiRoute,
100
130
  };
@@ -115,12 +145,12 @@ function collectRoutes(
115
145
 
116
146
  // Recurse into children
117
147
  for (const child of node.children) {
118
- collectRoutes(child, params, routes);
148
+ collectRoutes(child, params, nextAncestorFiles, routes);
119
149
  }
120
150
 
121
151
  // Recurse into slots (they share the parent's URL path, but may have their own pages)
122
152
  for (const [, slot] of node.slots) {
123
- collectRoutes(slot, params, routes);
153
+ collectRoutes(slot, params, nextAncestorFiles, routes);
124
154
  }
125
155
  }
126
156
 
@@ -159,18 +189,13 @@ function fileHasExport(filePath: string, exportName: string): boolean {
159
189
  }
160
190
 
161
191
  /**
162
- * Find which file exports `segmentParams` for a dynamic segment.
192
+ * Find a legacy `segmentParams` export on layout.tsx or page.tsx.
163
193
  *
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.
194
+ * Backward-compat shim: TIM-508 made params.ts the canonical location
195
+ * for `segmentParams`. Layout/page exports are still accepted for the
196
+ * OWN segment only (not inherited by descendants — see TIM-834).
167
197
  */
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)
198
+ function findLegacyParamsExport(node: SegmentNode): string | undefined {
174
199
  if (node.layout && fileHasExport(node.layout.filePath, 'segmentParams')) {
175
200
  return node.layout.filePath;
176
201
  }
@@ -195,6 +220,12 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
195
220
  // (removing exports like bindUseQueryStates that aren't listed here).
196
221
  lines.push('export {};');
197
222
  lines.push('');
223
+
224
+ // TIM-834 P2: emit the shared codec-resolution helper type ONCE so the
225
+ // per-param chain conditionals reference it instead of inlining the
226
+ // fallback in both branches (which grows O(2^N) in chain depth).
227
+ lines.push(emitResolveSegmentFieldHelper());
228
+ lines.push('');
198
229
  lines.push("declare module '@timber-js/app' {");
199
230
  lines.push(' interface Routes {');
200
231
 
@@ -290,79 +321,12 @@ function formatParamsType(params: ParamEntry[], importBase?: string): string {
290
321
  }
291
322
 
292
323
  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}`;
307
- });
308
- return `{ ${fields.join('; ')} }`;
309
- }
310
-
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}`;
324
+ const codecType = buildCodecChainType(p, importBase, p.type);
325
+ return `${p.name}: ${codecType}`;
339
326
  });
340
327
  return `{ ${fields.join('; ')} }`;
341
328
  }
342
329
 
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
330
  /**
367
331
  * Generate useQueryStates overloads.
368
332
  *
@@ -392,173 +356,8 @@ function formatUseQueryStatesOverloads(routes: RouteEntry[], importBase?: string
392
356
  return lines;
393
357
  }
394
358
 
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
- }
359
+ // Link overload formatters and helpers (`formatTypedLinkOverloads`,
360
+ // `formatLinkCatchAllOverloads`, `formatLinkParamsType`,
361
+ // `buildResolvedPattern`, `LINK_BASE_PROPS_TYPE`) were extracted to
362
+ // `./link-codegen.ts` (TIM-835) to keep this file under the 500-line
363
+ // cap. They are imported at the top of this file.