@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,114 @@
1
+ /**
2
+ * Lint status files and error.tsx for missing 'use client' directive.
3
+ *
4
+ * Status files (error.tsx, 404.tsx, 4xx.tsx, 5xx.tsx, NNN.tsx) and legacy
5
+ * compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx) are passed
6
+ * as fallbackComponent to TimberErrorBoundary — a 'use client' component.
7
+ * RSC forbids passing server component functions as props to client
8
+ * components, causing a hard-to-debug runtime error.
9
+ *
10
+ * This module provides a build/dev-time check that warns when these files
11
+ * are missing the 'use client' directive.
12
+ *
13
+ * See design/10-error-handling.md §"Status-Code Files".
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import type { RouteTree, SegmentNode } from './types.js';
18
+ import { detectFileDirective } from '#/utils/directive-parser.js';
19
+
20
+ /** Extensions that require 'use client' (component files, not MDX/JSON). */
21
+ const CLIENT_REQUIRED_EXTENSIONS = new Set(['tsx', 'jsx', 'ts', 'js']);
22
+
23
+ export interface StatusFileLintWarning {
24
+ filePath: string;
25
+ fileType: string;
26
+ }
27
+
28
+ /**
29
+ * Walk the route tree and check all status files and error files for
30
+ * the 'use client' directive. Returns an array of warnings for files
31
+ * that are missing it.
32
+ *
33
+ * MDX and JSON status files are excluded — MDX files are server components
34
+ * by design, and JSON files are data, not components.
35
+ */
36
+ export function lintStatusFileDirectives(tree: RouteTree): StatusFileLintWarning[] {
37
+ const warnings: StatusFileLintWarning[] = [];
38
+ walkNode(tree.root, warnings);
39
+ return warnings;
40
+ }
41
+
42
+ function walkNode(node: SegmentNode, warnings: StatusFileLintWarning[]): void {
43
+ // Check error.tsx
44
+ if (node.error) {
45
+ checkFile(node.error.filePath, node.error.extension, 'error', warnings);
46
+ }
47
+
48
+ // Check status-code files (404.tsx, 4xx.tsx, 5xx.tsx, etc.)
49
+ if (node.statusFiles) {
50
+ for (const [code, file] of node.statusFiles) {
51
+ checkFile(file.filePath, file.extension, code, warnings);
52
+ }
53
+ }
54
+
55
+ // Check legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)
56
+ if (node.legacyStatusFiles) {
57
+ for (const [name, file] of node.legacyStatusFiles) {
58
+ checkFile(file.filePath, file.extension, name, warnings);
59
+ }
60
+ }
61
+
62
+ // Recurse into children and slots
63
+ for (const child of node.children) {
64
+ walkNode(child, warnings);
65
+ }
66
+ for (const [, slotNode] of node.slots) {
67
+ walkNode(slotNode, warnings);
68
+ }
69
+ }
70
+
71
+ function checkFile(
72
+ filePath: string,
73
+ extension: string,
74
+ fileType: string,
75
+ warnings: StatusFileLintWarning[]
76
+ ): void {
77
+ if (!CLIENT_REQUIRED_EXTENSIONS.has(extension)) return;
78
+
79
+ let code: string;
80
+ try {
81
+ code = readFileSync(filePath, 'utf-8');
82
+ } catch {
83
+ return; // File unreadable — skip silently
84
+ }
85
+
86
+ const directive = detectFileDirective(code, ['use client']);
87
+ if (!directive) {
88
+ warnings.push({ filePath, fileType });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Format warnings into human-readable console output.
94
+ */
95
+ export function formatStatusFileLintWarnings(warnings: StatusFileLintWarning[]): string {
96
+ const lines = [
97
+ `[timber] ${warnings.length} status/error file${warnings.length > 1 ? 's' : ''} missing 'use client' directive:`,
98
+ '',
99
+ ];
100
+
101
+ for (const w of warnings) {
102
+ lines.push(` ${w.filePath}`);
103
+ }
104
+
105
+ lines.push('');
106
+ lines.push(
107
+ " Status files and error.tsx are rendered inside TimberErrorBoundary (a 'use client' component)."
108
+ );
109
+ lines.push(
110
+ " Add 'use client' as the first line of each file to avoid a runtime error."
111
+ );
112
+
113
+ return lines.join('\n');
114
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Route tree types for timber.js file-system routing.
3
+ *
4
+ * The route tree is built by scanning the app/ directory and recognizing
5
+ * file conventions (page.*, layout.*, middleware.ts, access.ts, route.ts, etc.).
6
+ */
7
+
8
+ /** Segment type classification */
9
+ export type SegmentType =
10
+ | 'static' // e.g. "dashboard"
11
+ | 'dynamic' // e.g. "[id]"
12
+ | 'catch-all' // e.g. "[...slug]"
13
+ | 'optional-catch-all' // e.g. "[[...slug]]"
14
+ | 'group' // e.g. "(marketing)"
15
+ | 'slot' // e.g. "@sidebar"
16
+ | 'intercepting' // e.g. "(.)photo", "(..)photo", "(...)photo"
17
+ | 'private'; // e.g. "_components", "_lib" — excluded from routing
18
+
19
+ /**
20
+ * Intercepting route marker — indicates how many levels up to resolve the
21
+ * intercepted route from the intercepting route's location.
22
+ *
23
+ * See design/07-routing.md §"Intercepting Routes"
24
+ */
25
+ export type InterceptionMarker = '(.)' | '(..)' | '(...)' | '(..)(..)';
26
+
27
+ /** All recognized interception markers, ordered longest-first for parsing. */
28
+ export const INTERCEPTION_MARKERS: InterceptionMarker[] = ['(..)(..)', '(.)', '(..)', '(...)'];
29
+
30
+ /** A single file discovered in a route segment */
31
+ export interface RouteFile {
32
+ /** Absolute path to the file */
33
+ filePath: string;
34
+ /** File extension without leading dot (e.g. "tsx", "ts", "mdx") */
35
+ extension: string;
36
+ }
37
+
38
+ /** A node in the segment tree */
39
+ export interface SegmentNode {
40
+ /** The raw directory name (e.g. "dashboard", "[id]", "(auth)", "@sidebar") */
41
+ segmentName: string;
42
+ /** Classified segment type */
43
+ segmentType: SegmentType;
44
+ /** The dynamic param name, if dynamic (e.g. "id" for "[id]", "slug" for "[...slug]") */
45
+ paramName?: string;
46
+ /** The URL path prefix at this segment level (e.g. "/dashboard") */
47
+ urlPath: string;
48
+ /** For intercepting segments: the marker used, e.g. "(.)". */
49
+ interceptionMarker?: InterceptionMarker;
50
+ /**
51
+ * For intercepting segments: the segment name after stripping the marker.
52
+ * E.g., for "(.)photo" this is "photo".
53
+ */
54
+ interceptedSegmentName?: string;
55
+
56
+ // --- File conventions ---
57
+ page?: RouteFile;
58
+ layout?: RouteFile;
59
+ middleware?: RouteFile;
60
+ access?: RouteFile;
61
+ route?: RouteFile;
62
+ error?: RouteFile;
63
+ default?: RouteFile;
64
+ /** Status-code files: 4xx.tsx, 5xx.tsx, {status}.tsx (component format) */
65
+ statusFiles?: Map<string, RouteFile>;
66
+ /** JSON status-code files: 4xx.json, 5xx.json, {status}.json */
67
+ jsonStatusFiles?: Map<string, RouteFile>;
68
+ /** denied.tsx — slot-only denial rendering */
69
+ denied?: RouteFile;
70
+ /** Legacy compat: not-found.tsx (maps to 404), forbidden.tsx (403), unauthorized.tsx (401) */
71
+ legacyStatusFiles?: Map<string, RouteFile>;
72
+ /** prerender.ts — signals build-time pre-rendering for this segment's shell */
73
+ prerender?: RouteFile;
74
+ /** search-params.ts — typed search params definition for this route */
75
+ searchParams?: RouteFile;
76
+ /** Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.) keyed by base name */
77
+ metadataRoutes?: Map<string, RouteFile>;
78
+
79
+ // --- Children ---
80
+ children: SegmentNode[];
81
+ /** Parallel route slots (keyed by slot name without @) */
82
+ slots: Map<string, SegmentNode>;
83
+ }
84
+
85
+ /** The full route tree output from the scanner */
86
+ export interface RouteTree {
87
+ /** The root segment node (representing app/) */
88
+ root: SegmentNode;
89
+ /** All discovered proxy.ts files (should be at most one, in app/) */
90
+ proxy?: RouteFile;
91
+ }
92
+
93
+ /** Configuration passed to the scanner */
94
+ export interface ScannerConfig {
95
+ /** Recognized page/layout extensions (without dots). Default: ['tsx', 'ts', 'jsx', 'js'] */
96
+ pageExtensions?: string[];
97
+ }
98
+
99
+ /** Default page extensions */
100
+ export const DEFAULT_PAGE_EXTENSIONS = ['tsx', 'ts', 'jsx', 'js'];
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Static analyzability checker for search-params.ts files.
3
+ *
4
+ * Validates that a search-params.ts file's default export is statically
5
+ * analyzable — a createSearchParams() call or a chain of .extend()/.pick()
6
+ * calls on a SearchParamsDefinition.
7
+ *
8
+ * Non-analyzable files produce a hard build error with a diagnostic.
9
+ *
10
+ * Design doc: design/09-typescript.md §"Static Analyzability"
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Result of analyzing a search-params.ts file. */
18
+ export interface AnalyzeResult {
19
+ /** Whether the file is statically analyzable. */
20
+ valid: boolean;
21
+ /** Error details when valid is false. */
22
+ error?: AnalyzeError;
23
+ }
24
+
25
+ /** Diagnostic error for non-analyzable search-params.ts. */
26
+ export interface AnalyzeError {
27
+ /** Absolute file path. */
28
+ filePath: string;
29
+ /** Description of the non-analyzable expression. */
30
+ expression: string;
31
+ /** Suggested fix. */
32
+ suggestion: string;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // AST-free source analysis
37
+ //
38
+ // We use a lightweight regex-based approach to validate the structure of the
39
+ // default export. This avoids requiring a TypeScript compiler instance at
40
+ // build time for the initial validation pass. The full type extraction
41
+ // (reading T from SearchParamsDefinition<T>) still happens via the TypeScript
42
+ // compiler in the codegen step — this module just validates the *shape*.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Patterns that indicate a valid default export:
47
+ *
48
+ * 1. `export default createSearchParams(...)`
49
+ * 2. `export default someVar.extend(...)`
50
+ * 3. `export default someVar.pick(...)`
51
+ * 4. `export default someVar.extend(...).extend(...)` (chained)
52
+ * 5. `export default someVar.extend(...).pick(...)` (chained)
53
+ * 6. `export default createSearchParams(...).extend(...)`
54
+ *
55
+ * Invalid patterns:
56
+ * - `export default someFunction(...)` (arbitrary factory)
57
+ * - `export default condition ? a : b` (runtime conditional)
58
+ * - `export default variable` (opaque reference without call)
59
+ */
60
+
61
+ /**
62
+ * Analyze a search-params.ts file source for static analyzability.
63
+ *
64
+ * @param source - The file content as a string
65
+ * @param filePath - Absolute path to the file (for diagnostics)
66
+ */
67
+ export function analyzeSearchParams(source: string, filePath: string): AnalyzeResult {
68
+ // Strip comments to avoid false matches
69
+ const stripped = stripComments(source);
70
+
71
+ // Find the default export
72
+ const defaultExport = extractDefaultExport(stripped);
73
+
74
+ if (!defaultExport) {
75
+ return {
76
+ valid: false,
77
+ error: {
78
+ filePath,
79
+ expression: '(no default export found)',
80
+ suggestion:
81
+ 'search-params.ts must have a default export. Use: export default createSearchParams({ ... })',
82
+ },
83
+ };
84
+ }
85
+
86
+ // Validate the expression
87
+ if (isValidExpression(defaultExport.trim())) {
88
+ return { valid: true };
89
+ }
90
+
91
+ return {
92
+ valid: false,
93
+ error: {
94
+ filePath,
95
+ expression: defaultExport.trim(),
96
+ suggestion:
97
+ 'The default export must be a createSearchParams() call, or a chain of ' +
98
+ '.extend() / .pick() calls on a SearchParamsDefinition. Arbitrary factory ' +
99
+ 'functions and runtime conditionals are not supported.',
100
+ },
101
+ };
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Internal helpers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /** Strip single-line and multi-line comments from source. */
109
+ function stripComments(source: string): string {
110
+ // Remove multi-line comments
111
+ let result = source.replace(/\/\*[\s\S]*?\*\//g, '');
112
+ // Remove single-line comments
113
+ result = result.replace(/\/\/.*$/gm, '');
114
+ return result;
115
+ }
116
+
117
+ /**
118
+ * Extract the expression from `export default <expr>`.
119
+ *
120
+ * Handles both:
121
+ * export default createSearchParams(...)
122
+ * export default expr\n (terminated by newline or semicolon before next statement)
123
+ */
124
+ function extractDefaultExport(source: string): string | undefined {
125
+ // Match `export default` followed by the expression
126
+ const match = source.match(
127
+ /export\s+default\s+([\s\S]+?)(?:;|\n(?=export|import|const|let|var|function|class|type|interface|declare))/
128
+ );
129
+ if (match) {
130
+ return match[1];
131
+ }
132
+
133
+ // Fallback: match everything after `export default` to end of file
134
+ const fallback = source.match(/export\s+default\s+([\s\S]+)$/);
135
+ if (fallback) {
136
+ return fallback[1].replace(/;\s*$/, '');
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
142
+ /**
143
+ * Check if an expression is a valid statically-analyzable pattern.
144
+ *
145
+ * Valid patterns:
146
+ * - Starts with `createSearchParams(`
147
+ * - Contains `.extend(` or `.pick(` chains (possibly starting with createSearchParams or a variable)
148
+ * - A variable identifier followed by chaining
149
+ */
150
+ function isValidExpression(expr: string): boolean {
151
+ // Normalize whitespace
152
+ const normalized = expr.replace(/\s+/g, ' ').trim();
153
+
154
+ // Pattern 1: starts with createSearchParams(
155
+ if (normalized.startsWith('createSearchParams(')) {
156
+ return true;
157
+ }
158
+
159
+ // Pattern 2: chain ending with .extend(...) or .pick(...)
160
+ // This covers: someVar.extend(...), createSearchParams(...).extend(...).pick(...), etc.
161
+ if (/\.(extend|pick)\s*\(/.test(normalized)) {
162
+ // Reject ternaries and other conditional patterns
163
+ if (/\?/.test(normalized) && /:/.test(normalized)) {
164
+ return false;
165
+ }
166
+ // Reject function declarations/expressions
167
+ if (/^\s*(function|=>|\()/.test(normalized)) {
168
+ return false;
169
+ }
170
+ return true;
171
+ }
172
+
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * Format an AnalyzeError into a human-readable build error message.
178
+ */
179
+ export function formatAnalyzeError(error: AnalyzeError): string {
180
+ return [
181
+ `[timber] Non-analyzable search-params.ts`,
182
+ ``,
183
+ ` File: ${error.filePath}`,
184
+ ` Expression: ${error.expression}`,
185
+ ``,
186
+ ` ${error.suggestion}`,
187
+ ``,
188
+ ` The framework must be able to statically extract the type from your`,
189
+ ` search-params.ts at build time. Dynamic values, conditionals, and`,
190
+ ` arbitrary factory functions prevent this analysis.`,
191
+ ].join('\n');
192
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Built-in codecs and the fromSchema bridge for Standard Schema-compatible
3
+ * validation libraries (Zod, Valibot, ArkType).
4
+ *
5
+ * Design doc: design/09-typescript.md §"The SearchParamCodec Protocol"
6
+ */
7
+
8
+ import type { SearchParamCodec } from './create.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Standard Schema interface (subset)
12
+ //
13
+ // Standard Schema (https://github.com/standard-schema/standard-schema) defines
14
+ // a minimal interface that Zod ≥3.24, Valibot ≥1.0, and ArkType all implement.
15
+ // We depend only on `~standard.validate` to avoid coupling to any specific lib.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface StandardSchemaV1<Output = unknown> {
19
+ '~standard': {
20
+ validate(value: unknown): StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
21
+ };
22
+ }
23
+
24
+ type StandardSchemaResult<Output> =
25
+ | { value: Output; issues?: undefined }
26
+ | { value?: undefined; issues: ReadonlyArray<{ message: string }> };
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Sync validate helper
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Zod v4's ~standard.validate() signature includes Promise in the return union
34
+ * to satisfy the Standard Schema spec, but in practice Zod always validates
35
+ * synchronously for the schema types we use. We assert the result is sync and
36
+ * throw if it isn't — search params parsing must be synchronous.
37
+ */
38
+ function validateSync<Output>(
39
+ schema: StandardSchemaV1<Output>,
40
+ value: unknown
41
+ ): StandardSchemaResult<Output> {
42
+ const result = schema['~standard'].validate(value);
43
+ if (result instanceof Promise) {
44
+ throw new Error(
45
+ '[timber] fromSchema: schema returned a Promise — only sync schemas are supported for search params.'
46
+ );
47
+ }
48
+ return result;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // fromSchema — bridge from Standard Schema to SearchParamCodec
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Bridge a Standard Schema-compatible schema (Zod, Valibot, ArkType) to a
57
+ * SearchParamCodec.
58
+ *
59
+ * Parse: coerces the raw URL string through the schema. On validation failure,
60
+ * parses `undefined` to get the schema's default value (the schema should have
61
+ * a `.default()` call). If that also fails, returns `undefined`.
62
+ *
63
+ * Serialize: uses `String()` for primitives, `null` for null/undefined.
64
+ *
65
+ * ```ts
66
+ * import { fromSchema } from '@timber/app/search-params'
67
+ * import { z } from 'zod/v4'
68
+ *
69
+ * const pageCodec = fromSchema(z.coerce.number().int().min(1).default(1))
70
+ * ```
71
+ */
72
+ export function fromSchema<T>(schema: StandardSchemaV1<T>): SearchParamCodec<T> {
73
+ return {
74
+ parse(value: string | string[] | undefined): T {
75
+ // For array inputs, take the last value (consistent with URLSearchParams.get())
76
+ const input = Array.isArray(value) ? value[value.length - 1] : value;
77
+
78
+ // Try parsing the raw value
79
+ const result = validateSync(schema, input);
80
+ if (!result.issues) {
81
+ return result.value;
82
+ }
83
+
84
+ // On failure, try parsing undefined to get the default
85
+ const defaultResult = validateSync(schema, undefined);
86
+ if (!defaultResult.issues) {
87
+ return defaultResult.value;
88
+ }
89
+
90
+ // No default available — return undefined (codec design choice)
91
+ return undefined as T;
92
+ },
93
+
94
+ serialize(value: T): string | null {
95
+ if (value === null || value === undefined) {
96
+ return null;
97
+ }
98
+ return String(value);
99
+ },
100
+ };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // fromArraySchema — bridge for array-valued search params
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Bridge a Standard Schema for array values. Handles both single strings
109
+ * and repeated query keys (`?tag=a&tag=b`).
110
+ *
111
+ * ```ts
112
+ * import { fromArraySchema } from '@timber/app/search-params'
113
+ * import { z } from 'zod/v4'
114
+ *
115
+ * const tagsCodec = fromArraySchema(z.array(z.string()).default([]))
116
+ * ```
117
+ */
118
+ export function fromArraySchema<T>(schema: StandardSchemaV1<T>): SearchParamCodec<T> {
119
+ return {
120
+ parse(value: string | string[] | undefined): T {
121
+ // Coerce single string to array for array schemas
122
+ let input: unknown = value;
123
+ if (typeof value === 'string') {
124
+ input = [value];
125
+ } else if (value === undefined) {
126
+ input = undefined;
127
+ }
128
+
129
+ const result = validateSync(schema, input);
130
+ if (!result.issues) {
131
+ return result.value;
132
+ }
133
+
134
+ // On failure, try undefined for default
135
+ const defaultResult = validateSync(schema, undefined);
136
+ if (!defaultResult.issues) {
137
+ return defaultResult.value;
138
+ }
139
+
140
+ return undefined as T;
141
+ },
142
+
143
+ serialize(value: T): string | null {
144
+ if (value === null || value === undefined) {
145
+ return null;
146
+ }
147
+ if (Array.isArray(value)) {
148
+ return value.length === 0 ? null : value.join(',');
149
+ }
150
+ return String(value);
151
+ },
152
+ };
153
+ }