@timber-js/app 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,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';
|