@timber-js/app 0.1.1 → 0.1.3

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.
Files changed (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * timber-static-build — Vite sub-plugin for static output mode.
3
+ *
4
+ * When `output: 'static'` is set in timber.config.ts, this plugin:
5
+ * 1. Validates that no dynamic APIs (cookies(), headers()) are used
6
+ * 2. When client JavaScript is disabled, rejects 'use client' and 'use server' directives
7
+ * 3. Coordinates build-time rendering of all pages
8
+ *
9
+ * Design doc: design/15-future-prerendering.md
10
+ */
11
+
12
+ import type { Plugin } from 'vite';
13
+ import type { PluginContext } from '#/index.js';
14
+ import { detectFileDirective } from '#/utils/directive-parser.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface StaticValidationError {
21
+ type: 'dynamic-api' | 'nojs-directive';
22
+ file: string;
23
+ message: string;
24
+ line?: number;
25
+ }
26
+
27
+ export interface StaticOptions {
28
+ clientJavascriptDisabled: boolean;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Detection: dynamic APIs (cookies, headers)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Patterns that indicate dynamic per-request API usage.
37
+ * These are build errors in static mode because there is no request at build time.
38
+ *
39
+ * We detect both import-level and call-level usage.
40
+ */
41
+ const DYNAMIC_API_PATTERNS: Array<{ pattern: RegExp; name: string }> = [
42
+ { pattern: /\bcookies\s*\(/, name: 'cookies()' },
43
+ { pattern: /\bheaders\s*\(/, name: 'headers()' },
44
+ ];
45
+
46
+ /**
47
+ * Detect usage of dynamic per-request APIs (cookies(), headers())
48
+ * that cannot work at build time in static mode.
49
+ *
50
+ * Returns an array of validation errors.
51
+ */
52
+ export function detectDynamicApis(code: string, fileId: string): StaticValidationError[] {
53
+ const errors: StaticValidationError[] = [];
54
+
55
+ for (const { pattern, name } of DYNAMIC_API_PATTERNS) {
56
+ if (pattern.test(code)) {
57
+ // Find the line number of the first match
58
+ const lines = code.split('\n');
59
+ let line: number | undefined;
60
+ for (let i = 0; i < lines.length; i++) {
61
+ if (pattern.test(lines[i])) {
62
+ line = i + 1;
63
+ break;
64
+ }
65
+ }
66
+
67
+ errors.push({
68
+ type: 'dynamic-api',
69
+ file: fileId,
70
+ message:
71
+ `${name} cannot be used in static mode — there is no request at build time. ` +
72
+ `Remove the ${name} call or switch to output: 'server'.`,
73
+ line,
74
+ });
75
+ }
76
+ }
77
+
78
+ return errors;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Detection: 'use client' / 'use server' directives (clientJavascript disabled)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Detect 'use client' and 'use server' directives using AST-based parsing.
87
+ * When client JavaScript is disabled, both are hard build errors — no React
88
+ * runtime or server actions are allowed in the output.
89
+ *
90
+ * When client JavaScript is enabled, these are allowed (client components
91
+ * hydrate, server actions get extracted to API endpoints).
92
+ */
93
+ export function detectDirectives(
94
+ code: string,
95
+ fileId: string,
96
+ options: StaticOptions
97
+ ): StaticValidationError[] {
98
+ if (!options.clientJavascriptDisabled) return [];
99
+
100
+ const errors: StaticValidationError[] = [];
101
+
102
+ const clientDirective = detectFileDirective(code, ['use client']);
103
+ if (clientDirective) {
104
+ errors.push({
105
+ type: 'nojs-directive',
106
+ file: fileId,
107
+ message:
108
+ `'use client' is not allowed when client JavaScript is disabled (clientJavascript: false). ` +
109
+ `This mode produces zero JavaScript — client components cannot exist.`,
110
+ line: clientDirective.line,
111
+ });
112
+ }
113
+
114
+ const serverDirective = detectFileDirective(code, ['use server']);
115
+ if (serverDirective) {
116
+ errors.push({
117
+ type: 'nojs-directive',
118
+ file: fileId,
119
+ message:
120
+ `'use server' is not allowed when client JavaScript is disabled (clientJavascript: false). ` +
121
+ `This mode produces zero JavaScript — server actions cannot exist.`,
122
+ line: serverDirective.line,
123
+ });
124
+ }
125
+
126
+ return errors;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Combined validation
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Run all static mode validations on a source file.
135
+ *
136
+ * Combines:
137
+ * - Dynamic API detection (cookies, headers) — always in static mode
138
+ * - Directive detection ('use client', 'use server') — only when client JS is disabled
139
+ */
140
+ export function validateStaticMode(
141
+ code: string,
142
+ fileId: string,
143
+ options: StaticOptions
144
+ ): StaticValidationError[] {
145
+ const errors: StaticValidationError[] = [];
146
+
147
+ errors.push(...detectDynamicApis(code, fileId));
148
+ errors.push(...detectDirectives(code, fileId, options));
149
+
150
+ return errors;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Vite Plugin
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Create the timber-static-build Vite plugin.
159
+ *
160
+ * Only active when output: 'static' is configured.
161
+ *
162
+ * Hooks:
163
+ * - transform: Validates source files for static mode violations
164
+ */
165
+ export function timberStaticBuild(ctx: PluginContext): Plugin {
166
+ const isStatic = ctx.config.output === 'static';
167
+ const clientJavascriptDisabled = ctx.clientJavascript.disabled;
168
+
169
+ return {
170
+ name: 'timber-static-build',
171
+
172
+ /**
173
+ * Validate source files during transform.
174
+ *
175
+ * In static mode, we check every app/ file for:
176
+ * - Dynamic API usage (cookies(), headers()) → build error
177
+ * - When client JS disabled: 'use client' / 'use server' directives → build error
178
+ */
179
+ transform(code: string, id: string) {
180
+ // Only active in static mode
181
+ if (!isStatic) return null;
182
+
183
+ // Skip node_modules
184
+ if (id.includes('node_modules')) return null;
185
+
186
+ // Only check files in the app directory
187
+ if (!id.includes('/app/') && !id.startsWith('app/')) return null;
188
+
189
+ // Only check JS/TS files
190
+ if (!/\.[jt]sx?$/.test(id)) return null;
191
+
192
+ const errors = validateStaticMode(code, id, { clientJavascriptDisabled });
193
+
194
+ if (errors.length > 0) {
195
+ // Format all errors into a single build error message
196
+ const messages = errors.map(
197
+ (e) =>
198
+ `[timber] Static mode error in ${e.file}${e.line ? `:${e.line}` : ''}: ${e.message}`
199
+ );
200
+
201
+ this.error(messages.join('\n\n'));
202
+ }
203
+
204
+ return null;
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Route map codegen.
3
+ *
4
+ * Walks the scanned RouteTree and generates a TypeScript declaration file
5
+ * mapping every route to its params and searchParams shapes.
6
+ *
7
+ * This runs at build time and in dev (regenerated on file changes).
8
+ * No runtime overhead — purely static type generation.
9
+ */
10
+
11
+ import { existsSync } from 'node:fs';
12
+ import { join, relative, posix } from 'node:path';
13
+ 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 this route has a co-located search-params.ts */
22
+ hasSearchParams: boolean;
23
+ /** Absolute path to search-params.ts (for computing relative import paths) */
24
+ searchParamsAbsPath?: 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
+ }
33
+
34
+ /** Options for route map generation. */
35
+ export interface CodegenOptions {
36
+ /** Absolute path to the app/ directory. Required for search-params.ts detection. */
37
+ appDir?: string;
38
+ /**
39
+ * Absolute path to the directory where the .d.ts file will be written.
40
+ * Used to compute correct relative import paths for search-params.ts files.
41
+ * Defaults to appDir when not provided (preserves backward compat for tests).
42
+ */
43
+ outputDir?: string;
44
+ }
45
+
46
+ /**
47
+ * Generate a TypeScript declaration file string from a scanned route tree.
48
+ *
49
+ * The output is a `declare module '@timber/app'` block containing the Routes
50
+ * interface that maps every route path to its params and searchParams shape.
51
+ */
52
+ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}): string {
53
+ const routes: RouteEntry[] = [];
54
+ collectRoutes(tree.root, [], options.appDir, routes);
55
+
56
+ // Sort routes alphabetically for deterministic output
57
+ routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
58
+
59
+ // When outputDir differs from appDir, import paths must be relative to outputDir
60
+ const importBase = options.outputDir ?? options.appDir;
61
+
62
+ return formatDeclarationFile(routes, importBase);
63
+ }
64
+
65
+ /**
66
+ * Recursively walk the segment tree and collect route entries.
67
+ *
68
+ * A route entry is created for any segment that has a `page` or `route` file.
69
+ * Params accumulate from ancestor dynamic segments.
70
+ */
71
+ function collectRoutes(
72
+ node: SegmentNode,
73
+ ancestorParams: ParamEntry[],
74
+ appDir: string | undefined,
75
+ routes: RouteEntry[]
76
+ ): void {
77
+ // Accumulate params from this segment
78
+ const params = [...ancestorParams];
79
+ if (node.paramName) {
80
+ params.push({
81
+ name: node.paramName,
82
+ type: paramTypeForSegment(node.segmentType),
83
+ });
84
+ }
85
+
86
+ // Check if this segment is a leaf route (has page or route file)
87
+ const isPage = !!node.page;
88
+ const isApiRoute = !!node.route;
89
+
90
+ if (isPage || isApiRoute) {
91
+ const entry: RouteEntry = {
92
+ urlPath: node.urlPath,
93
+ params: [...params],
94
+ hasSearchParams: false,
95
+ isApiRoute,
96
+ };
97
+
98
+ // Detect co-located search-params.ts
99
+ if (appDir && isPage) {
100
+ const segmentDir = resolveSegmentDir(appDir, node);
101
+ const searchParamsFile = findSearchParamsFile(segmentDir);
102
+ if (searchParamsFile) {
103
+ entry.hasSearchParams = true;
104
+ entry.searchParamsAbsPath = searchParamsFile;
105
+ }
106
+ }
107
+
108
+ routes.push(entry);
109
+ }
110
+
111
+ // Recurse into children
112
+ for (const child of node.children) {
113
+ collectRoutes(child, params, appDir, routes);
114
+ }
115
+
116
+ // Recurse into slots (they share the parent's URL path, but may have their own pages)
117
+ for (const [, slot] of node.slots) {
118
+ collectRoutes(slot, params, appDir, routes);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Determine the TypeScript type for a segment's param.
124
+ */
125
+ function paramTypeForSegment(segmentType: string): ParamEntry['type'] {
126
+ switch (segmentType) {
127
+ case 'catch-all':
128
+ return 'string[]';
129
+ case 'optional-catch-all':
130
+ return 'string[] | undefined';
131
+ default:
132
+ return 'string';
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Resolve the absolute directory path for a segment node.
138
+ *
139
+ * Reconstructs the filesystem path by walking from appDir through
140
+ * the segment names encoded in the urlPath, accounting for groups and slots.
141
+ */
142
+ function resolveSegmentDir(appDir: string, node: SegmentNode): string {
143
+ // The node's page/route file path gives us the actual directory
144
+ const file = node.page ?? node.route;
145
+ if (file) {
146
+ // The file is in the segment directory — go up one level
147
+ const parts = file.filePath.split('/');
148
+ parts.pop(); // remove filename
149
+ return parts.join('/');
150
+ }
151
+ // Fallback: construct from urlPath (imprecise for groups, but acceptable)
152
+ return appDir;
153
+ }
154
+
155
+ /**
156
+ * Find a search-params.ts file in a directory.
157
+ */
158
+ function findSearchParamsFile(dirPath: string): string | undefined {
159
+ for (const ext of ['ts', 'tsx']) {
160
+ const candidate = join(dirPath, `search-params.${ext}`);
161
+ if (existsSync(candidate)) {
162
+ return candidate;
163
+ }
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ /**
169
+ * Format the collected routes into a TypeScript declaration file.
170
+ */
171
+ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): string {
172
+ const lines: string[] = [];
173
+
174
+ lines.push('// This file is auto-generated by timber.js route map codegen.');
175
+ lines.push('// Do not edit manually. Regenerated on build and in dev mode.');
176
+ lines.push('');
177
+ // export {} makes this file a module, so all declare module blocks are
178
+ // augmentations rather than ambient replacements. Without this, the
179
+ // declare module blocks would replace the original module types entirely
180
+ // (removing exports like bindUseQueryStates that aren't listed here).
181
+ lines.push('export {};');
182
+ lines.push('');
183
+ lines.push("declare module '@timber/app' {");
184
+ lines.push(' interface Routes {');
185
+
186
+ for (const route of routes) {
187
+ const paramsType = formatParamsType(route.params);
188
+ const searchParamsType = formatSearchParamsType(route, importBase);
189
+
190
+ lines.push(` '${route.urlPath}': {`);
191
+ lines.push(` params: ${paramsType}`);
192
+ lines.push(` searchParams: ${searchParamsType}`);
193
+ lines.push(` }`);
194
+ }
195
+
196
+ lines.push(' }');
197
+ lines.push('}');
198
+ lines.push('');
199
+
200
+ // Generate @timber/app/server augmentation — typed searchParams() generic
201
+ const pageRoutes = routes.filter((r) => !r.isApiRoute);
202
+
203
+ if (pageRoutes.length > 0) {
204
+ lines.push("declare module '@timber/app/server' {");
205
+ lines.push(" import type { Routes } from '@timber/app'");
206
+ lines.push(
207
+ " export function searchParams<R extends keyof Routes>(): Promise<Routes[R]['searchParams']>"
208
+ );
209
+ lines.push('}');
210
+ lines.push('');
211
+ }
212
+
213
+ // Generate overloads for @timber/app/client
214
+ const dynamicRoutes = routes.filter((r) => r.params.length > 0);
215
+
216
+ if (dynamicRoutes.length > 0 || pageRoutes.length > 0) {
217
+ lines.push("declare module '@timber/app/client' {");
218
+ lines.push(
219
+ " import type { SearchParamsDefinition, SetParams, QueryStatesOptions, SearchParamCodec } from '@timber/app/search-params'"
220
+ );
221
+ lines.push('');
222
+
223
+ // useParams overloads
224
+ if (dynamicRoutes.length > 0) {
225
+ for (const route of dynamicRoutes) {
226
+ const paramsType = formatParamsType(route.params);
227
+ lines.push(` export function useParams(route: '${route.urlPath}'): ${paramsType}`);
228
+ }
229
+ lines.push(' export function useParams(): Record<string, string | string[]>');
230
+ lines.push('');
231
+ }
232
+
233
+ // useQueryStates overloads
234
+ if (pageRoutes.length > 0) {
235
+ lines.push(...formatUseQueryStatesOverloads(pageRoutes, importBase));
236
+ lines.push('');
237
+ }
238
+
239
+ // Typed Link overloads
240
+ if (pageRoutes.length > 0) {
241
+ lines.push(' // Typed Link props per route');
242
+ lines.push(...formatTypedLinkOverloads(pageRoutes, importBase));
243
+ }
244
+
245
+ lines.push('}');
246
+ lines.push('');
247
+ }
248
+
249
+ return lines.join('\n');
250
+ }
251
+
252
+ /**
253
+ * Format the params type for a route entry.
254
+ */
255
+ function formatParamsType(params: ParamEntry[]): string {
256
+ if (params.length === 0) {
257
+ return '{}';
258
+ }
259
+
260
+ const fields = params.map((p) => `${p.name}: ${p.type}`);
261
+ return `{ ${fields.join('; ')} }`;
262
+ }
263
+
264
+ /**
265
+ * Format the params type for Link props.
266
+ *
267
+ * Link params accept `string | number` for single dynamic segments
268
+ * (convenience — values are stringified at runtime). Catch-all and
269
+ * optional catch-all remain `string[]` / `string[] | undefined`.
270
+ *
271
+ * See design/07-routing.md §"Typed params and searchParams on <Link>"
272
+ */
273
+ function formatLinkParamsType(params: ParamEntry[]): string {
274
+ if (params.length === 0) {
275
+ return '{}';
276
+ }
277
+
278
+ const fields = params.map((p) => {
279
+ // Single dynamic segments accept string | number for convenience
280
+ const type = p.type === 'string' ? 'string | number' : p.type;
281
+ return `${p.name}: ${type}`;
282
+ });
283
+ return `{ ${fields.join('; ')} }`;
284
+ }
285
+
286
+ /**
287
+ * Format the searchParams type for a route entry.
288
+ *
289
+ * When a search-params.ts exists, we reference its inferred type via an import type.
290
+ * The import path is relative to `importBase` (the directory where the .d.ts will be
291
+ * written). When importBase is undefined, falls back to a bare relative path.
292
+ */
293
+ function formatSearchParamsType(route: RouteEntry, importBase?: string): string {
294
+ if (route.hasSearchParams && route.searchParamsAbsPath) {
295
+ const absPath = route.searchParamsAbsPath.replace(/\.(ts|tsx)$/, '');
296
+ let importPath: string;
297
+ if (importBase) {
298
+ // Make the path relative to the output directory, converted to posix separators
299
+ importPath = './' + relative(importBase, absPath).replace(/\\/g, '/');
300
+ } else {
301
+ importPath = './' + posix.basename(absPath);
302
+ }
303
+ // Use (typeof import('...'))[' default'] instead of import('...').default
304
+ // because with moduleResolution:"bundler", import('...').default is treated as
305
+ // a namespace member access which doesn't work for default exports.
306
+ return `(typeof import('${importPath}'))['default'] extends import('@timber/app/search-params').SearchParamsDefinition<infer T> ? T : never`;
307
+ }
308
+ return '{}';
309
+ }
310
+
311
+ /**
312
+ * Generate useQueryStates overloads.
313
+ *
314
+ * For each page route:
315
+ * - Routes with search-params.ts get a typed overload returning the inferred T
316
+ * - Routes without search-params.ts get an overload returning [{}, SetParams<{}>]
317
+ *
318
+ * A fallback overload for standalone codecs (existing API) is emitted last.
319
+ */
320
+ function formatUseQueryStatesOverloads(routes: RouteEntry[], importBase?: string): string[] {
321
+ const lines: string[] = [];
322
+
323
+ for (const route of routes) {
324
+ const searchParamsType = route.hasSearchParams
325
+ ? formatSearchParamsType(route, importBase)
326
+ : '{}';
327
+ lines.push(
328
+ ` export function useQueryStates<R extends '${route.urlPath}'>(route: R, options?: QueryStatesOptions): [${searchParamsType}, SetParams<${searchParamsType}>]`
329
+ );
330
+ }
331
+
332
+ // Fallback: standalone codecs (existing API)
333
+ lines.push(
334
+ ' export function useQueryStates<T extends Record<string, unknown>>(codecs: { [K in keyof T]: SearchParamCodec<T[K]> }, options?: QueryStatesOptions): [T, SetParams<T>]'
335
+ );
336
+
337
+ return lines;
338
+ }
339
+
340
+ /**
341
+ * Generate typed Link overloads.
342
+ *
343
+ * For each page route, we generate a Link function overload that:
344
+ * - Constrains href to the route pattern
345
+ * - Types the params prop based on dynamic segments
346
+ * - Types the searchParams prop based on search-params.ts (if present)
347
+ *
348
+ * Routes without dynamic segments accept href as a literal string with no params.
349
+ * Routes with dynamic segments require a params prop.
350
+ */
351
+ function formatTypedLinkOverloads(routes: RouteEntry[], importBase?: string): string[] {
352
+ const lines: string[] = [];
353
+
354
+ for (const route of routes) {
355
+ const hasDynamicParams = route.params.length > 0;
356
+ const paramsType = formatLinkParamsType(route.params);
357
+ const searchParamsType = route.hasSearchParams
358
+ ? formatSearchParamsType(route, importBase)
359
+ : null;
360
+
361
+ if (hasDynamicParams) {
362
+ // Route with dynamic segments — params prop required
363
+ const spProp = searchParamsType
364
+ ? `searchParams?: { definition: SearchParamsDefinition<${searchParamsType}>; values: Partial<${searchParamsType}> }`
365
+ : `searchParams?: never`;
366
+ lines.push(
367
+ ` export function Link(props: Omit<import('react').AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {`
368
+ );
369
+ lines.push(` href: '${route.urlPath}'`);
370
+ lines.push(` params: ${paramsType}`);
371
+ lines.push(` ${spProp}`);
372
+ lines.push(` prefetch?: boolean; scroll?: boolean; children?: import('react').ReactNode`);
373
+ lines.push(` }): import('react').JSX.Element`);
374
+ } else {
375
+ // Static route — no params needed
376
+ const spProp = searchParamsType
377
+ ? `searchParams?: { definition: SearchParamsDefinition<${searchParamsType}>; values: Partial<${searchParamsType}> }`
378
+ : `searchParams?: never`;
379
+ lines.push(
380
+ ` export function Link(props: Omit<import('react').AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {`
381
+ );
382
+ lines.push(` href: '${route.urlPath}'`);
383
+ lines.push(` params?: never`);
384
+ lines.push(` ${spProp}`);
385
+ lines.push(` prefetch?: boolean; scroll?: boolean; children?: import('react').ReactNode`);
386
+ lines.push(` }): import('react').JSX.Element`);
387
+ }
388
+ }
389
+
390
+ // Fallback overload for arbitrary string hrefs (escape hatch)
391
+ lines.push(
392
+ ` export function Link(props: import('./client/link.js').LinkProps): import('react').JSX.Element`
393
+ );
394
+
395
+ return lines;
396
+ }
@@ -0,0 +1,14 @@
1
+ export { scanRoutes, classifySegment } from './scanner.js';
2
+ export { generateRouteMap } from './codegen.js';
3
+ export type { CodegenOptions } from './codegen.js';
4
+ export type {
5
+ RouteTree,
6
+ SegmentNode,
7
+ SegmentType,
8
+ RouteFile,
9
+ ScannerConfig,
10
+ InterceptionMarker,
11
+ } from './types.js';
12
+ export { DEFAULT_PAGE_EXTENSIONS, INTERCEPTION_MARKERS } from './types.js';
13
+ export { collectInterceptionRewrites } from './interception.js';
14
+ export type { InterceptionRewrite } from './interception.js';