@timber-js/app 0.2.0-alpha.100 → 0.2.0-alpha.101
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/_chunks/{actions-CQ8Z8VGL.js → actions-BsfxK6yu.js} +2 -2
- package/dist/_chunks/{actions-CQ8Z8VGL.js.map → actions-BsfxK6yu.js.map} +1 -1
- package/dist/_chunks/{chunk-BYIpzuS7.js → chunk-Cfpv9whK.js} +1 -8
- package/dist/_chunks/{define-CfBPoJb0.js → define-k32T6NL2.js} +2 -2
- package/dist/_chunks/{define-CfBPoJb0.js.map → define-k32T6NL2.js.map} +1 -1
- package/dist/_chunks/{navigation-root-BCYczjml.js → navigation-root-yx917rG0.js} +2 -2
- package/dist/_chunks/{navigation-root-BCYczjml.js.map → navigation-root-yx917rG0.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-BkpKAQ7D.js → use-segment-params-DlsOX1YS.js} +2 -2
- package/dist/_chunks/{use-segment-params-BkpKAQ7D.js.map → use-segment-params-DlsOX1YS.js.map} +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/internal.d.ts +1 -1
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +3 -3
- package/dist/fonts/ast.d.ts +98 -6
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +1 -1
- package/dist/fonts/transform.d.ts +15 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1481 -11536
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +2 -23
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +1 -1
- package/package.json +2 -1
- package/src/client/internal.ts +1 -1
- package/src/fonts/ast.ts +488 -16
- package/src/fonts/local.ts +1 -1
- package/src/fonts/transform.ts +147 -236
- package/src/index.ts +14 -2
- package/src/plugins/dev-server.ts +8 -9
- package/src/plugins/routing.ts +1 -12
- package/src/plugins/static-build.ts +3 -83
- package/dist/plugins/server-action-exports.d.ts +0 -26
- package/dist/plugins/server-action-exports.d.ts.map +0 -1
- package/dist/routing/status-file-lint.d.ts +0 -34
- package/dist/routing/status-file-lint.d.ts.map +0 -1
- package/dist/utils/directive-parser.d.ts +0 -28
- package/dist/utils/directive-parser.d.ts.map +0 -1
- package/src/plugins/server-action-exports.ts +0 -220
- package/src/routing/status-file-lint.ts +0 -115
- package/src/utils/directive-parser.ts +0 -140
|
@@ -3,31 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* When `output: 'static'` is set in timber.config.ts, this plugin:
|
|
5
5
|
* 1. Validates that no dynamic APIs (getCookies(), getHeaders()) are used
|
|
6
|
-
* 2.
|
|
7
|
-
* 3. Coordinates build-time rendering of all pages
|
|
6
|
+
* 2. Coordinates build-time rendering of all pages
|
|
8
7
|
*
|
|
9
8
|
* Design doc: design/15-future-prerendering.md
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
import type { Plugin } from 'vite';
|
|
13
12
|
import type { PluginContext } from '../plugin-context.js';
|
|
14
|
-
import { detectFileDirective } from '../utils/directive-parser.js';
|
|
15
13
|
|
|
16
14
|
// ---------------------------------------------------------------------------
|
|
17
15
|
// Types
|
|
18
16
|
// ---------------------------------------------------------------------------
|
|
19
17
|
|
|
20
18
|
export interface StaticValidationError {
|
|
21
|
-
type: 'dynamic-api'
|
|
19
|
+
type: 'dynamic-api';
|
|
22
20
|
file: string;
|
|
23
21
|
message: string;
|
|
24
22
|
line?: number;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
export interface StaticOptions {
|
|
28
|
-
clientJavascriptDisabled: boolean;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
25
|
// ---------------------------------------------------------------------------
|
|
32
26
|
// Detection: dynamic APIs (getCookies, getHeaders, and legacy cookies/headers)
|
|
33
27
|
// ---------------------------------------------------------------------------
|
|
@@ -80,78 +74,6 @@ export function detectDynamicApis(code: string, fileId: string): StaticValidatio
|
|
|
80
74
|
return errors;
|
|
81
75
|
}
|
|
82
76
|
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Detection: 'use client' / 'use server' directives (clientJavascript disabled)
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Detect 'use client' and 'use server' directives using AST-based parsing.
|
|
89
|
-
* When client JavaScript is disabled, both are hard build errors — no React
|
|
90
|
-
* runtime or server actions are allowed in the output.
|
|
91
|
-
*
|
|
92
|
-
* When client JavaScript is enabled, these are allowed (client components
|
|
93
|
-
* hydrate, server actions get extracted to API endpoints).
|
|
94
|
-
*/
|
|
95
|
-
export function detectDirectives(
|
|
96
|
-
code: string,
|
|
97
|
-
fileId: string,
|
|
98
|
-
options: StaticOptions
|
|
99
|
-
): StaticValidationError[] {
|
|
100
|
-
if (!options.clientJavascriptDisabled) return [];
|
|
101
|
-
|
|
102
|
-
const errors: StaticValidationError[] = [];
|
|
103
|
-
|
|
104
|
-
const clientDirective = detectFileDirective(code, ['use client']);
|
|
105
|
-
if (clientDirective) {
|
|
106
|
-
errors.push({
|
|
107
|
-
type: 'nojs-directive',
|
|
108
|
-
file: fileId,
|
|
109
|
-
message:
|
|
110
|
-
`'use client' is not allowed when client JavaScript is disabled (clientJavascript: false). ` +
|
|
111
|
-
`This mode produces zero JavaScript — client components cannot exist.`,
|
|
112
|
-
line: clientDirective.line,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const serverDirective = detectFileDirective(code, ['use server']);
|
|
117
|
-
if (serverDirective) {
|
|
118
|
-
errors.push({
|
|
119
|
-
type: 'nojs-directive',
|
|
120
|
-
file: fileId,
|
|
121
|
-
message:
|
|
122
|
-
`'use server' is not allowed when client JavaScript is disabled (clientJavascript: false). ` +
|
|
123
|
-
`This mode produces zero JavaScript — server actions cannot exist.`,
|
|
124
|
-
line: serverDirective.line,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return errors;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Combined validation
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Run all static mode validations on a source file.
|
|
137
|
-
*
|
|
138
|
-
* Combines:
|
|
139
|
-
* - Dynamic API detection (getCookies, getHeaders) — always in static mode
|
|
140
|
-
* - Directive detection ('use client', 'use server') — only when client JS is disabled
|
|
141
|
-
*/
|
|
142
|
-
export function validateStaticMode(
|
|
143
|
-
code: string,
|
|
144
|
-
fileId: string,
|
|
145
|
-
options: StaticOptions
|
|
146
|
-
): StaticValidationError[] {
|
|
147
|
-
const errors: StaticValidationError[] = [];
|
|
148
|
-
|
|
149
|
-
errors.push(...detectDynamicApis(code, fileId));
|
|
150
|
-
errors.push(...detectDirectives(code, fileId, options));
|
|
151
|
-
|
|
152
|
-
return errors;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
77
|
// ---------------------------------------------------------------------------
|
|
156
78
|
// Vite Plugin
|
|
157
79
|
// ---------------------------------------------------------------------------
|
|
@@ -193,9 +115,7 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
|
|
|
193
115
|
// Only check JS/TS files
|
|
194
116
|
if (!/\.[jt]sx?$/.test(id)) return null;
|
|
195
117
|
|
|
196
|
-
const errors =
|
|
197
|
-
clientJavascriptDisabled: ctx.clientJavascript.disabled,
|
|
198
|
-
});
|
|
118
|
+
const errors = detectDynamicApis(code, id);
|
|
199
119
|
|
|
200
120
|
if (errors.length > 0) {
|
|
201
121
|
// Format all errors into a single build error message
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from 'vite';
|
|
2
|
-
/**
|
|
3
|
-
* Rewrite 'use server' module exports to bypass RSC plugin AST validation.
|
|
4
|
-
*
|
|
5
|
-
* The RSC plugin's client/SSR proxy transform (transformProxyExport) requires
|
|
6
|
-
* all `export const` initializers to be `async ArrowFunctionExpression`. But
|
|
7
|
-
* `createActionClient().action()` and `validated()` return async functions via
|
|
8
|
-
* CallExpressions — valid at runtime but rejected by the static AST check.
|
|
9
|
-
*
|
|
10
|
-
* This plugin rewrites non-function-expression exports from:
|
|
11
|
-
*
|
|
12
|
-
* export const foo = someCall();
|
|
13
|
-
*
|
|
14
|
-
* to:
|
|
15
|
-
*
|
|
16
|
-
* const foo = someCall();
|
|
17
|
-
* export { foo };
|
|
18
|
-
*
|
|
19
|
-
* The `export { name }` form bypasses the RSC plugin's validation without
|
|
20
|
-
* changing runtime semantics. Function expression exports (async arrows,
|
|
21
|
-
* async function expressions) are left untouched.
|
|
22
|
-
*
|
|
23
|
-
* See design/08-forms-and-actions.md §"Middleware for Server Actions"
|
|
24
|
-
*/
|
|
25
|
-
export declare function timberServerActionExports(): Plugin;
|
|
26
|
-
//# sourceMappingURL=server-action-exports.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"server-action-exports.d.ts","sourceRoot":"","sources":["../../src/plugins/server-action-exports.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAOnC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,CAelD"}
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
import type { RouteTree } from './types.js';
|
|
16
|
-
export interface StatusFileLintWarning {
|
|
17
|
-
filePath: string;
|
|
18
|
-
fileType: string;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Walk the route tree and check all status files and error files for
|
|
22
|
-
* the 'use client' directive. Returns an array of warnings for files
|
|
23
|
-
* that are missing it.
|
|
24
|
-
*
|
|
25
|
-
* MDX and JSON status files are excluded — MDX files are server components
|
|
26
|
-
* by design (pre-rendered as elements via fallbackElement, see TIM-503),
|
|
27
|
-
* and JSON files are data, not components.
|
|
28
|
-
*/
|
|
29
|
-
export declare function lintStatusFileDirectives(tree: RouteTree): StatusFileLintWarning[];
|
|
30
|
-
/**
|
|
31
|
-
* Format warnings into human-readable console output.
|
|
32
|
-
*/
|
|
33
|
-
export declare function formatStatusFileLintWarnings(warnings: StatusFileLintWarning[]): string;
|
|
34
|
-
//# sourceMappingURL=status-file-lint.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"status-file-lint.d.ts","sourceRoot":"","sources":["../../src/routing/status-file-lint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,YAAY,CAAC;AAOzD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,SAAS,GAAG,qBAAqB,EAAE,CAIjF;AAqDD;;GAEG;AACH,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,qBAAqB,EAAE,GAAG,MAAM,CAiBtF"}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AST-based directive detection for 'use client' and 'use server'.
|
|
3
|
-
*
|
|
4
|
-
* Uses acorn to parse source code and detect directives properly,
|
|
5
|
-
* avoiding false positives from regex matching inside string literals,
|
|
6
|
-
* comments, or template expressions.
|
|
7
|
-
*
|
|
8
|
-
* @module
|
|
9
|
-
*/
|
|
10
|
-
export interface FileDirective {
|
|
11
|
-
/** The directive value, e.g. 'use client', 'use server' */
|
|
12
|
-
directive: string;
|
|
13
|
-
/** 1-based line number where the directive appears */
|
|
14
|
-
line: number;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Detect a file-level directive ('use client', 'use server', etc.).
|
|
18
|
-
*
|
|
19
|
-
* Per the ECMAScript spec, directives are string literal expression
|
|
20
|
-
* statements at the start of a program body (before any non-directive
|
|
21
|
-
* statements). This function checks the AST `Program.body` for
|
|
22
|
-
* `ExpressionStatement` nodes whose expression is a `Literal` string
|
|
23
|
-
* matching a known directive.
|
|
24
|
-
*
|
|
25
|
-
* Returns the first matching directive, or null if none found.
|
|
26
|
-
*/
|
|
27
|
-
export declare function detectFileDirective(code: string, directives?: string[]): FileDirective | null;
|
|
28
|
-
//# sourceMappingURL=directive-parser.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"directive-parser.d.ts","sourceRoot":"","sources":["../../src/utils/directive-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,MAAM,WAAW,aAAa;IAC5B,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;CACd;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,UAAU,GAAE,MAAM,EAAiC,GAClD,aAAa,GAAG,IAAI,CAmCtB"}
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from 'vite';
|
|
2
|
-
import { Parser } from 'acorn';
|
|
3
|
-
import acornJsx from 'acorn-jsx';
|
|
4
|
-
import { detectFileDirective } from '../utils/directive-parser.js';
|
|
5
|
-
|
|
6
|
-
const jsxParser = Parser.extend(acornJsx());
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Rewrite 'use server' module exports to bypass RSC plugin AST validation.
|
|
10
|
-
*
|
|
11
|
-
* The RSC plugin's client/SSR proxy transform (transformProxyExport) requires
|
|
12
|
-
* all `export const` initializers to be `async ArrowFunctionExpression`. But
|
|
13
|
-
* `createActionClient().action()` and `validated()` return async functions via
|
|
14
|
-
* CallExpressions — valid at runtime but rejected by the static AST check.
|
|
15
|
-
*
|
|
16
|
-
* This plugin rewrites non-function-expression exports from:
|
|
17
|
-
*
|
|
18
|
-
* export const foo = someCall();
|
|
19
|
-
*
|
|
20
|
-
* to:
|
|
21
|
-
*
|
|
22
|
-
* const foo = someCall();
|
|
23
|
-
* export { foo };
|
|
24
|
-
*
|
|
25
|
-
* The `export { name }` form bypasses the RSC plugin's validation without
|
|
26
|
-
* changing runtime semantics. Function expression exports (async arrows,
|
|
27
|
-
* async function expressions) are left untouched.
|
|
28
|
-
*
|
|
29
|
-
* See design/08-forms-and-actions.md §"Middleware for Server Actions"
|
|
30
|
-
*/
|
|
31
|
-
export function timberServerActionExports(): Plugin {
|
|
32
|
-
return {
|
|
33
|
-
name: 'timber-server-action-exports',
|
|
34
|
-
transform(code, id) {
|
|
35
|
-
// Skip non-JS/TS files
|
|
36
|
-
if (!/\.[jt]sx?$/.test(id)) return;
|
|
37
|
-
// Quick bail-out
|
|
38
|
-
if (!code.includes('use server')) return;
|
|
39
|
-
// Check for file-level directive
|
|
40
|
-
const directive = detectFileDirective(code, ['use server']);
|
|
41
|
-
if (!directive) return;
|
|
42
|
-
|
|
43
|
-
return rewriteServerActionExports(code);
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
-
type AcornNode = any;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Check if an AST node is an async function expression (arrow or regular).
|
|
53
|
-
* These are handled correctly by the RSC plugin and don't need rewriting.
|
|
54
|
-
*/
|
|
55
|
-
function isAsyncFunctionExpr(node: AcornNode): boolean {
|
|
56
|
-
if (!node) return false;
|
|
57
|
-
return (
|
|
58
|
-
(node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') && node.async
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Extract identifier names from a declarator's id pattern.
|
|
64
|
-
* Handles simple identifiers and destructuring patterns.
|
|
65
|
-
*/
|
|
66
|
-
function extractDeclNames(id: AcornNode): string[] {
|
|
67
|
-
if (id.type === 'Identifier') return [id.name];
|
|
68
|
-
|
|
69
|
-
// ObjectPattern: const { a, b } = ...
|
|
70
|
-
if (id.type === 'ObjectPattern') {
|
|
71
|
-
return id.properties.flatMap((p: AcornNode) => extractDeclNames(p.value ?? p.argument));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ArrayPattern: const [a, b] = ...
|
|
75
|
-
if (id.type === 'ArrayPattern') {
|
|
76
|
-
return id.elements.filter(Boolean).flatMap((e: AcornNode) => extractDeclNames(e));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// RestElement: const [...rest] = ...
|
|
80
|
-
if (id.type === 'RestElement') {
|
|
81
|
-
return extractDeclNames(id.argument);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// AssignmentPattern: const { a = 1 } = ...
|
|
85
|
-
if (id.type === 'AssignmentPattern') {
|
|
86
|
-
return extractDeclNames(id.left);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface RewriteTarget {
|
|
93
|
-
/** Start offset of the `export` keyword */
|
|
94
|
-
exportStart: number;
|
|
95
|
-
/** Start offset of the `const/let/var` keyword (right after `export `) */
|
|
96
|
-
declStart: number;
|
|
97
|
-
/** Names to re-export */
|
|
98
|
-
names: string[];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function rewriteServerActionExports(code: string): { code: string; map: null } | undefined {
|
|
102
|
-
let ast: AcornNode;
|
|
103
|
-
try {
|
|
104
|
-
ast = jsxParser.parse(code, {
|
|
105
|
-
ecmaVersion: 'latest',
|
|
106
|
-
sourceType: 'module',
|
|
107
|
-
});
|
|
108
|
-
} catch {
|
|
109
|
-
// TypeScript that esbuild hasn't stripped yet — use regex fallback
|
|
110
|
-
return rewriteServerActionExportsFallback(code);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const targets: RewriteTarget[] = [];
|
|
114
|
-
|
|
115
|
-
for (const node of ast.body) {
|
|
116
|
-
// Case 1: export const/let/var name = <non-function-expression>
|
|
117
|
-
if (
|
|
118
|
-
node.type === 'ExportNamedDeclaration' &&
|
|
119
|
-
node.declaration?.type === 'VariableDeclaration'
|
|
120
|
-
) {
|
|
121
|
-
const hasNonFunctionInit = node.declaration.declarations.some(
|
|
122
|
-
(d: AcornNode) => d.init && !isAsyncFunctionExpr(d.init)
|
|
123
|
-
);
|
|
124
|
-
if (!hasNonFunctionInit) continue;
|
|
125
|
-
|
|
126
|
-
const names = node.declaration.declarations.flatMap((d: AcornNode) => extractDeclNames(d.id));
|
|
127
|
-
if (names.length === 0) continue;
|
|
128
|
-
|
|
129
|
-
targets.push({
|
|
130
|
-
exportStart: node.start,
|
|
131
|
-
declStart: node.declaration.start,
|
|
132
|
-
names,
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Case 2: export default <non-function-expression>
|
|
137
|
-
if (node.type === 'ExportDefaultDeclaration') {
|
|
138
|
-
const decl = node.declaration;
|
|
139
|
-
if (
|
|
140
|
-
decl.type !== 'FunctionDeclaration' &&
|
|
141
|
-
decl.type !== 'Identifier' &&
|
|
142
|
-
!isAsyncFunctionExpr(decl)
|
|
143
|
-
) {
|
|
144
|
-
// export default someCall() → const $$default = someCall(); export default $$default;
|
|
145
|
-
targets.push({
|
|
146
|
-
exportStart: node.start,
|
|
147
|
-
declStart: -1, // sentinel for default export
|
|
148
|
-
names: ['default'],
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (targets.length === 0) return undefined;
|
|
155
|
-
|
|
156
|
-
// Process from end to start to preserve character positions
|
|
157
|
-
let result = code;
|
|
158
|
-
const reExports: string[] = [];
|
|
159
|
-
|
|
160
|
-
for (let i = targets.length - 1; i >= 0; i--) {
|
|
161
|
-
const target = targets[i];
|
|
162
|
-
|
|
163
|
-
if (target.declStart === -1) {
|
|
164
|
-
// export default <expr> → const $$default = <expr>;\nexport default $$default;
|
|
165
|
-
const node = ast.body.find(
|
|
166
|
-
(n: AcornNode) => n.type === 'ExportDefaultDeclaration' && n.start === target.exportStart
|
|
167
|
-
);
|
|
168
|
-
if (node) {
|
|
169
|
-
const exprCode = code.slice(node.declaration.start, node.declaration.end);
|
|
170
|
-
const replacement = `const $$default = ${exprCode};\nexport default $$default;`;
|
|
171
|
-
result = result.slice(0, node.start) + replacement + result.slice(node.end);
|
|
172
|
-
}
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Remove 'export ' from the declaration
|
|
177
|
-
result = result.slice(0, target.exportStart) + result.slice(target.declStart);
|
|
178
|
-
reExports.push(...target.names);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (reExports.length > 0) {
|
|
182
|
-
result += '\nexport { ' + reExports.join(', ') + ' };\n';
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return { code: result, map: null };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Regex fallback for TypeScript files that acorn cannot parse.
|
|
190
|
-
*
|
|
191
|
-
* Matches `export const/let/var name = <expr>` where the initializer does
|
|
192
|
-
* NOT start with `async function` or `async (` (i.e., not an async
|
|
193
|
-
* function expression or async arrow function).
|
|
194
|
-
*/
|
|
195
|
-
function rewriteServerActionExportsFallback(code: string): { code: string; map: null } | undefined {
|
|
196
|
-
// Match: export const/let/var <name> = <non-async-function-expr>
|
|
197
|
-
const pattern =
|
|
198
|
-
/^(export\s+)((?:const|let|var)\s+(\w+)\s*=\s*)(?!async\s+(?:function[\s(]|\())/gm;
|
|
199
|
-
|
|
200
|
-
const names: string[] = [];
|
|
201
|
-
let result = code;
|
|
202
|
-
let offset = 0;
|
|
203
|
-
let match;
|
|
204
|
-
|
|
205
|
-
while ((match = pattern.exec(code)) !== null) {
|
|
206
|
-
const exportKeyword = match[1]; // 'export ' (with trailing space)
|
|
207
|
-
const name = match[3];
|
|
208
|
-
|
|
209
|
-
// Remove 'export ' at this position (adjusted for prior removals)
|
|
210
|
-
const pos = match.index + offset;
|
|
211
|
-
result = result.slice(0, pos) + result.slice(pos + exportKeyword.length);
|
|
212
|
-
offset -= exportKeyword.length;
|
|
213
|
-
names.push(name);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (names.length === 0) return undefined;
|
|
217
|
-
|
|
218
|
-
result += '\nexport { ' + names.join(', ') + ' };\n';
|
|
219
|
-
return { code: result, map: null };
|
|
220
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
import { swallow } from '../server/logger.js';
|
|
20
|
-
|
|
21
|
-
/** Extensions that require 'use client' (component files, not MDX/JSON). */
|
|
22
|
-
const CLIENT_REQUIRED_EXTENSIONS = new Set(['tsx', 'jsx', 'ts', 'js']);
|
|
23
|
-
|
|
24
|
-
export interface StatusFileLintWarning {
|
|
25
|
-
filePath: string;
|
|
26
|
-
fileType: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Walk the route tree and check all status files and error files for
|
|
31
|
-
* the 'use client' directive. Returns an array of warnings for files
|
|
32
|
-
* that are missing it.
|
|
33
|
-
*
|
|
34
|
-
* MDX and JSON status files are excluded — MDX files are server components
|
|
35
|
-
* by design (pre-rendered as elements via fallbackElement, see TIM-503),
|
|
36
|
-
* and JSON files are data, not components.
|
|
37
|
-
*/
|
|
38
|
-
export function lintStatusFileDirectives(tree: RouteTree): StatusFileLintWarning[] {
|
|
39
|
-
const warnings: StatusFileLintWarning[] = [];
|
|
40
|
-
walkNode(tree.root, warnings);
|
|
41
|
-
return warnings;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function walkNode(node: SegmentNode, warnings: StatusFileLintWarning[]): void {
|
|
45
|
-
// Check error.tsx
|
|
46
|
-
if (node.error) {
|
|
47
|
-
checkFile(node.error.filePath, node.error.extension, 'error', warnings);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Check status-code files (404.tsx, 4xx.tsx, 5xx.tsx, etc.)
|
|
51
|
-
if (node.statusFiles) {
|
|
52
|
-
for (const [code, file] of Object.entries(node.statusFiles)) {
|
|
53
|
-
checkFile(file.filePath, file.extension, code, warnings);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Check legacy compat files (not-found.tsx, forbidden.tsx, unauthorized.tsx)
|
|
58
|
-
if (node.legacyStatusFiles) {
|
|
59
|
-
for (const [name, file] of Object.entries(node.legacyStatusFiles)) {
|
|
60
|
-
checkFile(file.filePath, file.extension, name, warnings);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Recurse into children and slots
|
|
65
|
-
for (const child of node.children) {
|
|
66
|
-
walkNode(child, warnings);
|
|
67
|
-
}
|
|
68
|
-
for (const slotNode of Object.values(node.slots)) {
|
|
69
|
-
walkNode(slotNode, warnings);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function checkFile(
|
|
74
|
-
filePath: string,
|
|
75
|
-
extension: string,
|
|
76
|
-
fileType: string,
|
|
77
|
-
warnings: StatusFileLintWarning[]
|
|
78
|
-
): void {
|
|
79
|
-
if (!CLIENT_REQUIRED_EXTENSIONS.has(extension)) return;
|
|
80
|
-
|
|
81
|
-
let code: string;
|
|
82
|
-
try {
|
|
83
|
-
code = readFileSync(filePath, 'utf-8');
|
|
84
|
-
} catch (err) {
|
|
85
|
-
swallow(err, `status-file-lint: unreadable file ${filePath}`);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const directive = detectFileDirective(code, ['use client']);
|
|
90
|
-
if (!directive) {
|
|
91
|
-
warnings.push({ filePath, fileType });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Format warnings into human-readable console output.
|
|
97
|
-
*/
|
|
98
|
-
export function formatStatusFileLintWarnings(warnings: StatusFileLintWarning[]): string {
|
|
99
|
-
const lines = [
|
|
100
|
-
`[timber] ${warnings.length} status/error file${warnings.length > 1 ? 's' : ''} missing 'use client' directive:`,
|
|
101
|
-
'',
|
|
102
|
-
];
|
|
103
|
-
|
|
104
|
-
for (const w of warnings) {
|
|
105
|
-
lines.push(` ${w.filePath}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
lines.push('');
|
|
109
|
-
lines.push(
|
|
110
|
-
" Status files and error.tsx are rendered inside TimberErrorBoundary (a 'use client' component)."
|
|
111
|
-
);
|
|
112
|
-
lines.push(" Add 'use client' as the first line of each file to avoid a runtime error.");
|
|
113
|
-
|
|
114
|
-
return lines.join('\n');
|
|
115
|
-
}
|