@timber-js/app 0.2.0-alpha.158 → 0.2.0-alpha.159
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-BA5tIpVu.js → actions-CBbP1hHO.js} +3 -3
- package/dist/_chunks/{actions-BA5tIpVu.js.map → actions-CBbP1hHO.js.map} +1 -1
- package/dist/_chunks/{build-output-helper-C8q4Zpf5.js → build-output-helper-DF5uScqS.js} +4 -1
- package/dist/_chunks/{build-output-helper-C8q4Zpf5.js.map → build-output-helper-DF5uScqS.js.map} +1 -1
- package/dist/_chunks/cache-api-DsWEYwVZ.js +1120 -0
- package/dist/_chunks/cache-api-DsWEYwVZ.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js → cli-schema-sync-Bivx4Rn7.js} +4 -3
- package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js.map → cli-schema-sync-Bivx4Rn7.js.map} +1 -1
- package/dist/_chunks/dist-BA3u1z3W.js +423 -0
- package/dist/_chunks/dist-BA3u1z3W.js.map +1 -0
- package/dist/_chunks/{logger-C5v1IMuh.js → logger-CrRmZMlo.js} +81 -56
- package/dist/_chunks/logger-CrRmZMlo.js.map +1 -0
- package/dist/_chunks/{plugin-context-DUl3dnCF.js → plugin-context-DeAxFRMq.js} +3 -36
- package/dist/_chunks/plugin-context-DeAxFRMq.js.map +1 -0
- package/dist/_chunks/sizeof-lXffQ1Vr.js +35 -0
- package/dist/_chunks/sizeof-lXffQ1Vr.js.map +1 -0
- package/dist/_chunks/{walkers-D5jCGPum.js → walkers-DXbJkeCY.js} +217 -19
- package/dist/_chunks/walkers-DXbJkeCY.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare.d.ts +28 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +143 -9
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/cache-api.d.ts +2 -0
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/handler-store.d.ts +2 -0
- package/dist/cache/handler-store.d.ts.map +1 -1
- package/dist/cache/index.d.ts +2 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +3 -186
- package/dist/cache/redis-handler.d.ts +17 -5
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/singleflight.d.ts +1 -1
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cache/store.d.ts +53 -0
- package/dist/cache/store.d.ts.map +1 -0
- package/dist/cache/stores/cloudflare-kv.d.ts +38 -0
- package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -0
- package/dist/cache/stores/cloudflare-kv.js +29 -0
- package/dist/cache/stores/cloudflare-kv.js.map +1 -0
- package/dist/cache/stores/memory.d.ts +42 -0
- package/dist/cache/stores/memory.d.ts.map +1 -0
- package/dist/cache/stores/memory.js +90 -0
- package/dist/cache/stores/memory.js.map +1 -0
- package/dist/cache/stores/redis.d.ts +69 -0
- package/dist/cache/stores/redis.d.ts.map +1 -0
- package/dist/cache/stores/redis.js +35 -0
- package/dist/cache/stores/redis.js.map +1 -0
- package/dist/cache/stores/vercel.d.ts +36 -0
- package/dist/cache/stores/vercel.d.ts.map +1 -0
- package/dist/cache/stores/vercel.js +33 -0
- package/dist/cache/stores/vercel.js.map +1 -0
- package/dist/cache/tag-aware-handler.d.ts +51 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -0
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cdn/cloudflare-purge.d.ts +23 -0
- package/dist/cdn/cloudflare-purge.d.ts.map +1 -0
- package/dist/cdn/fastly-purge.d.ts +25 -0
- package/dist/cdn/fastly-purge.d.ts.map +1 -0
- package/dist/cdn/headers.d.ts +82 -0
- package/dist/cdn/headers.d.ts.map +1 -0
- package/dist/cdn/index.d.ts +5 -0
- package/dist/cdn/index.d.ts.map +1 -0
- package/dist/cdn/purge-store.d.ts +23 -0
- package/dist/cdn/purge-store.d.ts.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/history.d.ts +0 -3
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +0 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +11 -86
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +1 -3
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/router.d.ts +1 -4
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -11
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -3
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/config-types.d.ts +3 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/index.js +15 -11
- package/dist/cookies/index.js.map +1 -1
- package/dist/dev-tools/browser-logs.d.ts +28 -72
- package/dist/dev-tools/browser-logs.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +1 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/logger.d.ts.map +1 -1
- package/dist/dev-tools/terminal.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts +4 -0
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1269 -359
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +36 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/build-report.d.ts +21 -0
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +5 -14
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/callsite-ast.d.ts +95 -0
- package/dist/plugins/callsite-ast.d.ts.map +1 -0
- package/dist/plugins/callsite-jsx.d.ts +35 -0
- package/dist/plugins/callsite-jsx.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/prebuilt-capture.d.ts +60 -0
- package/dist/plugins/prebuilt-capture.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts +82 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -0
- package/dist/plugins/prerender-sugar.d.ts +42 -0
- package/dist/plugins/prerender-sugar.d.ts.map +1 -0
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +56 -0
- package/dist/routing/export-detect.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +0 -3
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/cookie-parsing.d.ts +15 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +1 -0
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.d.ts +6 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +563 -74
- package/dist/server/internal.js.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +58 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -0
- package/dist/server/prebuilt/capture-state.d.ts +57 -0
- package/dist/server/prebuilt/capture-state.d.ts.map +1 -0
- package/dist/server/prebuilt/manifest-tag-index.d.ts +27 -0
- package/dist/server/prebuilt/manifest-tag-index.d.ts.map +1 -0
- package/dist/server/prebuilt/overlay.d.ts +100 -0
- package/dist/server/prebuilt/overlay.d.ts.map +1 -0
- package/dist/server/prebuilt/payload-source.d.ts +75 -0
- package/dist/server/prebuilt/payload-source.d.ts.map +1 -0
- package/dist/server/prebuilt/synthetic-store.d.ts +64 -0
- package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts +110 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -0
- package/dist/server/prebuilt-runtime.d.ts +101 -0
- package/dist/server/prebuilt-runtime.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +10 -8
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +0 -5
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +10 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +3 -4
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/sitemap-generator.d.ts +6 -6
- package/dist/server/sitemap-generator.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +37 -3
- package/dist/server/tracing.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +10 -4
- package/docs/learn/00-introduction.mdx +8 -13
- package/docs/learn/02-pages-and-layouts.mdx +9 -0
- package/docs/learn/06-forms-and-actions.mdx +0 -4
- package/docs/learn/07-typed-routes.mdx +127 -0
- package/docs/learn/09-caching.mdx +12 -1
- package/docs/learn/10-middleware.mdx +1 -0
- package/docs/learn/11-error-handling.mdx +9 -0
- package/docs/learn/13-configuration.mdx +28 -0
- package/docs/learn/14-deploying.mdx +52 -0
- package/docs/more/06-instrumentation.mdx +27 -1
- package/docs/more/07-security.mdx +8 -2
- package/docs/more/08-developer-experience.mdx +134 -0
- package/package.json +31 -2
- package/src/adapters/build-output-helper.ts +10 -0
- package/src/adapters/cloudflare.ts +178 -4
- package/src/cache/cache-api.ts +60 -0
- package/src/cache/handler-store.ts +8 -0
- package/src/cache/index.ts +4 -0
- package/src/cache/redis-handler.ts +25 -13
- package/src/cache/singleflight.ts +11 -11
- package/src/cache/store.ts +71 -0
- package/src/cache/stores/cloudflare-kv.ts +52 -0
- package/src/cache/stores/memory.ts +125 -0
- package/src/cache/stores/redis.ts +97 -0
- package/src/cache/stores/vercel.ts +59 -0
- package/src/cache/tag-aware-handler.ts +233 -0
- package/src/cache/timber-cache.ts +17 -7
- package/src/cdn/cloudflare-purge.ts +50 -0
- package/src/cdn/fastly-purge.ts +63 -0
- package/src/cdn/headers.ts +127 -0
- package/src/cdn/index.ts +16 -0
- package/src/cdn/purge-store.ts +34 -0
- package/src/client/browser-entry/action-dispatch.ts +1 -10
- package/src/client/browser-entry/post-hydration.ts +0 -1
- package/src/client/browser-entry/router-init.ts +0 -5
- package/src/client/history.ts +0 -4
- package/src/client/navigation-api.ts +5 -11
- package/src/client/router.ts +9 -43
- package/src/client/rsc-fetch.ts +5 -27
- package/src/client/segment-cache.ts +1 -4
- package/src/client/use-cookie.ts +18 -12
- package/src/config-types.ts +3 -1
- package/src/config-validation.ts +1 -0
- package/src/dev-tools/browser-logs.ts +37 -273
- package/src/dev-tools/index.ts +2 -2
- package/src/dev-tools/instrumentation.ts +1 -1
- package/src/dev-tools/logger.ts +33 -51
- package/src/dev-tools/terminal.ts +21 -22
- package/src/fonts/transform.ts +4 -43
- package/src/index.ts +16 -3
- package/src/plugin-context.ts +36 -0
- package/src/plugins/build-report.ts +133 -1
- package/src/plugins/cache.ts +32 -131
- package/src/plugins/callsite-ast.ts +167 -0
- package/src/plugins/callsite-jsx.ts +243 -0
- package/src/plugins/entries.ts +23 -4
- package/src/plugins/fonts.ts +16 -2
- package/src/plugins/prebuilt-capture.ts +208 -0
- package/src/plugins/prebuilt.ts +673 -0
- package/src/plugins/prerender-sugar.ts +329 -0
- package/src/plugins/server-bundle.ts +12 -0
- package/src/plugins/shims.ts +9 -0
- package/src/plugins/static-build.ts +0 -1
- package/src/routing/codegen.ts +1 -21
- package/src/routing/convention-lint.ts +85 -49
- package/src/routing/export-detect.ts +304 -0
- package/src/routing/manifest-codegen.ts +2 -11
- package/src/rsc-runtime/rsc.ts +7 -0
- package/src/server/action-handler.ts +11 -27
- package/src/server/actions.ts +3 -4
- package/src/server/als-registry.ts +7 -0
- package/src/server/cookie-parsing.ts +75 -59
- package/src/server/deny-renderer.ts +7 -15
- package/src/server/html-injectors.ts +11 -3
- package/src/server/internal.ts +17 -1
- package/src/server/prebuilt/cache-key.ts +224 -0
- package/src/server/prebuilt/capture-state.ts +89 -0
- package/src/server/prebuilt/manifest-tag-index.ts +46 -0
- package/src/server/prebuilt/overlay.ts +256 -0
- package/src/server/prebuilt/payload-source.ts +192 -0
- package/src/server/prebuilt/synthetic-store.ts +108 -0
- package/src/server/prebuilt-builder.ts +640 -0
- package/src/server/prebuilt-runtime.ts +706 -0
- package/src/server/route-element-builder.ts +40 -12
- package/src/server/rsc-entry/helpers.ts +0 -17
- package/src/server/rsc-entry/index.ts +83 -0
- package/src/server/rsc-entry/render-route.ts +6 -33
- package/src/server/rsc-entry/revalidate-renderer.ts +16 -11
- package/src/server/rsc-entry/rsc-payload.ts +5 -11
- package/src/server/sitemap-generator.ts +16 -10
- package/src/server/tracing.ts +88 -3
- package/dist/_chunks/cache-api-Cbyh3RW4.js +0 -401
- package/dist/_chunks/cache-api-Cbyh3RW4.js.map +0 -1
- package/dist/_chunks/logger-C5v1IMuh.js.map +0 -1
- package/dist/_chunks/plugin-context-DUl3dnCF.js.map +0 -1
- package/dist/_chunks/validation-CnKhGJsp.js +0 -113
- package/dist/_chunks/validation-CnKhGJsp.js.map +0 -1
- package/dist/_chunks/walkers-D5jCGPum.js.map +0 -1
- package/dist/cache/index.js.map +0 -1
- package/dist/client/head.d.ts +0 -16
- package/dist/client/head.d.ts.map +0 -1
- package/dist/client/navigation-api-types.d.ts +0 -90
- package/dist/client/navigation-api-types.d.ts.map +0 -1
- package/dist/server/client-module-map.d.ts +0 -47
- package/dist/server/client-module-map.d.ts.map +0 -1
- package/dist/shared/head-header.d.ts +0 -54
- package/dist/shared/head-header.d.ts.map +0 -1
- package/src/client/head.ts +0 -61
- package/src/client/navigation-api-types.ts +0 -112
- package/src/server/client-module-map.ts +0 -58
- package/src/shared/head-header.ts +0 -69
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-prerender-sugar — Vite plugin that desugars `export const prerender`
|
|
3
|
+
* on page and layout files into `cache.component(...)` wrappers.
|
|
4
|
+
*
|
|
5
|
+
* Design doc: design/45-cache-lifetimes.md §"Route-Level Static" and
|
|
6
|
+
* §"Layout Propagation".
|
|
7
|
+
*
|
|
8
|
+
* `export const prerender = true` on a page is pure sugar: it desugars to
|
|
9
|
+
* wrapping the page's default export in `timber.cache.component(Component,
|
|
10
|
+
* { prerender: true })`. Layout propagation cascades a layout's
|
|
11
|
+
* `export const prerender` to descendant pages via most-specific-wins:
|
|
12
|
+
* walk the segment tree from leaf to root, first explicit value wins.
|
|
13
|
+
*
|
|
14
|
+
* A layout that declares `export const prerender` is ALSO wrapped — the
|
|
15
|
+
* layout's flight payload is its own static shell (`children` is a
|
|
16
|
+
* separate component boundary), so caching it is natural (nav, sidebar,
|
|
17
|
+
* footer benefit from build-time rendering). See design/45 §Layout
|
|
18
|
+
* Propagation.
|
|
19
|
+
*
|
|
20
|
+
* This transform runs BEFORE timber-prebuilt so the generated
|
|
21
|
+
* `cache.component(...)` call is picked up by the callsite rewrite.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import MagicString from 'magic-string';
|
|
25
|
+
import { parseAst } from 'vite';
|
|
26
|
+
import type { Plugin } from 'vite';
|
|
27
|
+
import type { PluginContext } from '../plugin-context.js';
|
|
28
|
+
import type { SegmentNode, RouteFile } from '../routing/types.js';
|
|
29
|
+
import { getPrerenderExport, type PrerenderExportValue } from '../routing/export-detect.js';
|
|
30
|
+
|
|
31
|
+
interface AstNode {
|
|
32
|
+
type: string;
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
name?: string;
|
|
36
|
+
declaration?: AstNode;
|
|
37
|
+
id?: AstNode & { name?: string };
|
|
38
|
+
expression?: AstNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Walk the segment tree and resolve the effective prerender value for
|
|
43
|
+
* every page and layout. Returns a map from file path to resolved value.
|
|
44
|
+
*
|
|
45
|
+
* **Pages:** walk from leaf toward root; first explicit `export const prerender`
|
|
46
|
+
* wins (page > nearest layout > ... > root layout). If none, dynamic.
|
|
47
|
+
*
|
|
48
|
+
* **Layouts:** a layout that declares `export const prerender` is wrapped
|
|
49
|
+
* directly (its own shell is a separate component boundary from `children`).
|
|
50
|
+
* Layouts without their own declaration are NOT wrapped — they don't
|
|
51
|
+
* inherit from ancestors, because wrapping a layout's shell without the
|
|
52
|
+
* author's explicit opt-in would cache content they may expect to be dynamic.
|
|
53
|
+
*/
|
|
54
|
+
export function resolvePagePrerender(root: SegmentNode): Map<string, PrerenderExportValue> {
|
|
55
|
+
const result = new Map<string, PrerenderExportValue>();
|
|
56
|
+
|
|
57
|
+
function walk(node: SegmentNode, ancestorStack: PrerenderExportValue[]) {
|
|
58
|
+
if (node.segmentType === 'intercepting' || node.segmentType === 'private') return;
|
|
59
|
+
|
|
60
|
+
// Check this segment's layout for a prerender export
|
|
61
|
+
const layoutValue = node.layout ? getPrerenderExport(node.layout.filePath) : undefined;
|
|
62
|
+
const stack = layoutValue !== undefined ? [layoutValue, ...ancestorStack] : ancestorStack;
|
|
63
|
+
|
|
64
|
+
// Wrap the layout itself if it declares prerender (truthy)
|
|
65
|
+
if (node.layout && layoutValue !== undefined && layoutValue !== false) {
|
|
66
|
+
result.set(node.layout.filePath, layoutValue);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (node.page) {
|
|
70
|
+
const pageValue = getPrerenderExport(node.page.filePath);
|
|
71
|
+
// Page's own value takes priority; otherwise use nearest ancestor
|
|
72
|
+
const resolved = pageValue !== undefined ? pageValue : stack[0];
|
|
73
|
+
// Only record if the resolved value is truthy (prerender: true or options object)
|
|
74
|
+
// prerender: false explicitly opts out
|
|
75
|
+
if (resolved !== undefined && resolved !== false) {
|
|
76
|
+
result.set(node.page.filePath, resolved);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const child of node.children) walk(child, stack);
|
|
81
|
+
for (const slot of Object.values(node.slots)) walk(slot, stack);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
walk(root, []);
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the `cache.component` options source text from a resolved value.
|
|
90
|
+
*/
|
|
91
|
+
function buildOptionsSource(value: PrerenderExportValue): string {
|
|
92
|
+
if (value === true) return '{ prerender: true }';
|
|
93
|
+
if (typeof value === 'object') {
|
|
94
|
+
const parts = ['prerender: true'];
|
|
95
|
+
if (value.ttl !== undefined) parts.push(`ttl: ${value.ttl}`);
|
|
96
|
+
if (value.tags !== undefined) parts.push(`tags: ${JSON.stringify(value.tags)}`);
|
|
97
|
+
return `{ ${parts.join(', ')} }`;
|
|
98
|
+
}
|
|
99
|
+
return '{ prerender: true }';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find the default export in the AST and return info needed for the rewrite.
|
|
104
|
+
*/
|
|
105
|
+
interface DefaultExportInfo {
|
|
106
|
+
kind: 'declaration' | 'expression' | 'identifier';
|
|
107
|
+
node: AstNode;
|
|
108
|
+
name?: string;
|
|
109
|
+
/** For `export { X as default, Y }` — position of the default specifier. */
|
|
110
|
+
specifierIndex?: number;
|
|
111
|
+
specifierCount?: number;
|
|
112
|
+
specifierStart?: number;
|
|
113
|
+
specifierEnd?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findDefaultExport(program: { body: AstNode[] }, source: string): DefaultExportInfo | null {
|
|
117
|
+
for (const stmt of program.body) {
|
|
118
|
+
if (stmt.type !== 'ExportDefaultDeclaration' || !stmt.declaration) continue;
|
|
119
|
+
const decl = stmt.declaration;
|
|
120
|
+
|
|
121
|
+
// export default function Name(...) { ... }
|
|
122
|
+
// export default class Name { ... }
|
|
123
|
+
if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
|
|
124
|
+
const name = decl.id?.name;
|
|
125
|
+
if (name) return { kind: 'declaration', node: stmt, name };
|
|
126
|
+
// Anonymous function/class — treat as expression
|
|
127
|
+
return { kind: 'expression', node: stmt };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// export default SomeIdentifier
|
|
131
|
+
if (decl.type === 'Identifier') {
|
|
132
|
+
return { kind: 'identifier', node: stmt, name: decl.name };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// export default <expression>
|
|
136
|
+
return { kind: 'expression', node: stmt };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check for `export { X as default }` (not re-exports from other modules)
|
|
140
|
+
for (const stmt of program.body) {
|
|
141
|
+
if (stmt.type !== 'ExportNamedDeclaration') continue;
|
|
142
|
+
// Skip re-exports: `export { X as default } from './other'`
|
|
143
|
+
if ((stmt as any).source) continue;
|
|
144
|
+
const specifiers = (stmt as any).specifiers as
|
|
145
|
+
| Array<{
|
|
146
|
+
type: string;
|
|
147
|
+
start: number;
|
|
148
|
+
end: number;
|
|
149
|
+
local?: { name: string };
|
|
150
|
+
exported?: { name: string };
|
|
151
|
+
}>
|
|
152
|
+
| undefined;
|
|
153
|
+
if (!specifiers) continue;
|
|
154
|
+
for (let i = 0; i < specifiers.length; i++) {
|
|
155
|
+
const spec = specifiers[i];
|
|
156
|
+
if (spec.exported?.name === 'default' && spec.local?.name && spec.local.name !== 'default') {
|
|
157
|
+
return {
|
|
158
|
+
kind: 'identifier' as const,
|
|
159
|
+
node: stmt,
|
|
160
|
+
name: spec.local.name,
|
|
161
|
+
specifierIndex: i,
|
|
162
|
+
specifierCount: specifiers.length,
|
|
163
|
+
specifierStart: spec.start,
|
|
164
|
+
specifierEnd: spec.end,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function timberPrerenderSugar(ctx: PluginContext): Plugin {
|
|
174
|
+
let resolvedPages: Map<string, PrerenderExportValue> | null = null;
|
|
175
|
+
|
|
176
|
+
function ensureResolved() {
|
|
177
|
+
if (resolvedPages) return;
|
|
178
|
+
if (!ctx.routeTree) {
|
|
179
|
+
resolvedPages = new Map();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
resolvedPages = resolvePagePrerender(ctx.routeTree.root);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
name: 'timber-prerender-sugar',
|
|
187
|
+
|
|
188
|
+
buildStart() {
|
|
189
|
+
// Reset on each build (watch mode rebuilds)
|
|
190
|
+
resolvedPages = null;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
transform(code, id) {
|
|
194
|
+
// Only process in RSC environment (same gate as prebuilt.ts)
|
|
195
|
+
if (this?.environment?.name === 'client') return;
|
|
196
|
+
// Skip node_modules
|
|
197
|
+
if (id.includes('node_modules')) return;
|
|
198
|
+
|
|
199
|
+
ensureResolved();
|
|
200
|
+
if (!resolvedPages || resolvedPages.size === 0) return;
|
|
201
|
+
|
|
202
|
+
const value = resolvedPages.get(id);
|
|
203
|
+
if (!value) return;
|
|
204
|
+
|
|
205
|
+
// This page has a resolved prerender value — wrap its default export
|
|
206
|
+
let program: { body: AstNode[] };
|
|
207
|
+
try {
|
|
208
|
+
program = parseAst(code, { lang: 'tsx' }) as unknown as { body: AstNode[] };
|
|
209
|
+
} catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const defaultExport = findDefaultExport(program, code);
|
|
214
|
+
if (!defaultExport) {
|
|
215
|
+
// Warn if the file has a prerender export but no wrappable default export
|
|
216
|
+
// (e.g. `export { default } from './impl'` — a re-export we can't rewrite).
|
|
217
|
+
if (value) {
|
|
218
|
+
const relPath = id.replace(ctx.root + '/', '');
|
|
219
|
+
console.warn(
|
|
220
|
+
`[timber] ${relPath}: export const prerender is declared but the default ` +
|
|
221
|
+
`export cannot be wrapped (re-export or missing). The prerender declaration ` +
|
|
222
|
+
`will be ignored for this file.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Skip if the default export is already a cache.component(...) call —
|
|
229
|
+
// avoid double-wrapping. Only check the default export itself, not the
|
|
230
|
+
// whole file (a page may use cache.component for child components).
|
|
231
|
+
if (defaultExport.kind === 'expression' && defaultExport.node.declaration) {
|
|
232
|
+
const declText = code.slice(
|
|
233
|
+
defaultExport.node.declaration.start,
|
|
234
|
+
defaultExport.node.declaration.end
|
|
235
|
+
);
|
|
236
|
+
if (declText.includes('.component(')) return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Record this page as desugared for downstream attribution
|
|
240
|
+
ctx.prerenderSugarPages ??= new Set();
|
|
241
|
+
ctx.prerenderSugarPages.add(id);
|
|
242
|
+
|
|
243
|
+
const s = new MagicString(code);
|
|
244
|
+
const optionsSource = buildOptionsSource(value);
|
|
245
|
+
|
|
246
|
+
// Add the cache import at the top (after directives)
|
|
247
|
+
const importAlias = '__timber_prerender_cache__';
|
|
248
|
+
let insertOffset = 0;
|
|
249
|
+
for (const stmt of program.body) {
|
|
250
|
+
const isDirective =
|
|
251
|
+
stmt.type === 'ExpressionStatement' && typeof (stmt as any).directive === 'string';
|
|
252
|
+
if (!isDirective) {
|
|
253
|
+
insertOffset = stmt.start;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
s.appendLeft(
|
|
258
|
+
insertOffset,
|
|
259
|
+
`import { cache as ${importAlias} } from '@timber-js/app/cache';\n`
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
switch (defaultExport.kind) {
|
|
263
|
+
case 'declaration': {
|
|
264
|
+
// `export default function Name() { ... }`
|
|
265
|
+
// → `function Name() { ... }` + `export default cache.component(Name, opts)`
|
|
266
|
+
const declStart = defaultExport.node.declaration!.start;
|
|
267
|
+
// Remove `export default ` prefix
|
|
268
|
+
s.overwrite(defaultExport.node.start, declStart, '');
|
|
269
|
+
// Append the wrapped export at the end
|
|
270
|
+
s.append(
|
|
271
|
+
`\nexport default ${importAlias}.component(${defaultExport.name}, ${optionsSource});\n`
|
|
272
|
+
);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case 'identifier': {
|
|
276
|
+
if (defaultExport.specifierCount !== undefined && defaultExport.specifierCount > 1) {
|
|
277
|
+
// Multi-specifier: `export { Page as default, generateStaticSegmentParams }`
|
|
278
|
+
// Remove only the default specifier + surrounding comma, keep others.
|
|
279
|
+
const specStart = defaultExport.specifierStart!;
|
|
280
|
+
const specEnd = defaultExport.specifierEnd!;
|
|
281
|
+
const idx = defaultExport.specifierIndex!;
|
|
282
|
+
// Remove the specifier and a comma (trailing if first, leading otherwise)
|
|
283
|
+
if (idx === 0) {
|
|
284
|
+
// First specifier: remove text up to (but not including) the next specifier start
|
|
285
|
+
const nextSpec = (defaultExport.node as any).specifiers?.[1];
|
|
286
|
+
if (nextSpec) s.remove(specStart, nextSpec.start);
|
|
287
|
+
} else {
|
|
288
|
+
// Not first: remove from end of previous specifier to this specifier's end
|
|
289
|
+
const prevSpec = (defaultExport.node as any).specifiers?.[idx - 1];
|
|
290
|
+
if (prevSpec) s.remove(prevSpec.end, specEnd);
|
|
291
|
+
}
|
|
292
|
+
// Append the wrapped default export separately
|
|
293
|
+
s.appendLeft(
|
|
294
|
+
defaultExport.node.end,
|
|
295
|
+
`\nexport default ${importAlias}.component(${defaultExport.name}, ${optionsSource});`
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
// `export default MyPage` or `export { MyPage as default }` (sole specifier)
|
|
299
|
+
// → `export default cache.component(MyPage, opts)`
|
|
300
|
+
s.overwrite(
|
|
301
|
+
defaultExport.node.start,
|
|
302
|
+
defaultExport.node.end,
|
|
303
|
+
`export default ${importAlias}.component(${defaultExport.name}, ${optionsSource});`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case 'expression': {
|
|
309
|
+
// `export default () => <div/>` or `export default async function() { ... }`
|
|
310
|
+
// → `const __default = ...; export default cache.component(__default, opts)`
|
|
311
|
+
const decl = defaultExport.node.declaration!;
|
|
312
|
+
s.overwrite(defaultExport.node.start, decl.start, 'const __timber_default_page__ = ');
|
|
313
|
+
// Ensure trailing semicolon
|
|
314
|
+
const afterExpr = code.charAt(defaultExport.node.end - 1) === ';' ? '' : ';';
|
|
315
|
+
s.appendLeft(
|
|
316
|
+
defaultExport.node.end,
|
|
317
|
+
`${afterExpr}\nexport default ${importAlias}.component(__timber_default_page__, ${optionsSource});`
|
|
318
|
+
);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
code: s.toString(),
|
|
325
|
+
map: s.generateMap({ hires: 'boundary' }),
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -36,8 +36,18 @@ const NODE_BUILTINS: string[] = builtinModules.flatMap((m) => [m, `node:${m}`]);
|
|
|
36
36
|
// in a server bundle.
|
|
37
37
|
//
|
|
38
38
|
// Mirrored on both rsc and ssr environments. See TIM-988.
|
|
39
|
+
// Cloudflare runtime modules (cloudflare:workers, cloudflare:sockets, etc.)
|
|
40
|
+
// available at runtime on Workers but must not be bundled.
|
|
41
|
+
const CLOUDFLARE_BUILTINS = [
|
|
42
|
+
'cloudflare:workers',
|
|
43
|
+
'cloudflare:sockets',
|
|
44
|
+
'cloudflare:email',
|
|
45
|
+
'cloudflare:workflows',
|
|
46
|
+
];
|
|
47
|
+
|
|
39
48
|
const BUILD_ONLY_EXTERNALS: string[] = [
|
|
40
49
|
...NODE_BUILTINS,
|
|
50
|
+
...CLOUDFLARE_BUILTINS,
|
|
41
51
|
'vite',
|
|
42
52
|
'nitro',
|
|
43
53
|
'rollup',
|
|
@@ -114,10 +124,12 @@ export function timberServerBundle(): Plugin[] {
|
|
|
114
124
|
environments: {
|
|
115
125
|
rsc: {
|
|
116
126
|
resolve: { noExternal: true, external: BUILD_ONLY_EXTERNALS },
|
|
127
|
+
build: { rolldownOptions: { external: [/^cloudflare:/] } },
|
|
117
128
|
define: serverDefine,
|
|
118
129
|
},
|
|
119
130
|
ssr: {
|
|
120
131
|
resolve: { noExternal: true, external: BUILD_ONLY_EXTERNALS },
|
|
132
|
+
build: { rolldownOptions: { external: [/^cloudflare:/] } },
|
|
121
133
|
define: serverDefine,
|
|
122
134
|
},
|
|
123
135
|
},
|
package/src/plugins/shims.ts
CHANGED
|
@@ -57,6 +57,15 @@ export const SUBPATH_SRC_MAP: Record<string, string> = {
|
|
|
57
57
|
'search-params': 'search-params/index.ts',
|
|
58
58
|
'segment-params': 'segment-params/index.ts',
|
|
59
59
|
|
|
60
|
+
'cache/stores/memory': 'cache/stores/memory.ts',
|
|
61
|
+
'cache/stores/redis': 'cache/stores/redis.ts',
|
|
62
|
+
'cache/stores/cloudflare-kv': 'cache/stores/cloudflare-kv.ts',
|
|
63
|
+
'cache/stores/vercel': 'cache/stores/vercel.ts',
|
|
64
|
+
|
|
65
|
+
'cdn': 'cdn/index.ts',
|
|
66
|
+
'cdn/cloudflare': 'cdn/cloudflare-purge.ts',
|
|
67
|
+
'cdn/fastly': 'cdn/fastly-purge.ts',
|
|
68
|
+
|
|
60
69
|
'adapters/cloudflare': 'adapters/cloudflare.ts',
|
|
61
70
|
'adapters/cloudflare/cache': 'adapters/cloudflare-kv-cache.ts',
|
|
62
71
|
'adapters/cloudflare/dev': 'adapters/cloudflare-dev.ts',
|
|
@@ -95,7 +95,6 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
|
|
|
95
95
|
*
|
|
96
96
|
* In static mode, we check every app/ file for:
|
|
97
97
|
* - Dynamic API usage (getCookies(), getHeaders()) → build error
|
|
98
|
-
* - When client JS disabled: 'use client' / 'use server' directives → build error
|
|
99
98
|
*/
|
|
100
99
|
transform(code: string, id: string) {
|
|
101
100
|
// Read ctx.config lazily inside the hook — not at plugin construction
|
package/src/routing/codegen.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
emitResolveSegmentFieldHelper,
|
|
19
19
|
formatSearchParamsType,
|
|
20
20
|
} from './codegen-shared.js';
|
|
21
|
-
import {
|
|
21
|
+
import { fileHasExport } from './export-detect.js';
|
|
22
22
|
import { formatLinkCatchAllOverloads, formatTypedLinkOverloads } from './link-codegen.js';
|
|
23
23
|
|
|
24
24
|
/** Options for route map generation. */
|
|
@@ -249,26 +249,6 @@ function paramTypeForSegment(segmentType: string): ParamEntry['type'] {
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/**
|
|
253
|
-
* Check if a file exports a specific named export.
|
|
254
|
-
*
|
|
255
|
-
* Uses a lightweight regex check — not full AST parsing. The actual type
|
|
256
|
-
* extraction happens via the TypeScript compiler in the generated .d.ts.
|
|
257
|
-
*/
|
|
258
|
-
function fileHasExport(filePath: string, exportName: string): boolean {
|
|
259
|
-
if (!existsSync(filePath)) return false;
|
|
260
|
-
try {
|
|
261
|
-
const content = readFileCached(filePath);
|
|
262
|
-
const e = exportName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
263
|
-
return (
|
|
264
|
-
new RegExp(`export\\s+(const|let|var)\\s+${e}\\b`).test(content) ||
|
|
265
|
-
new RegExp(`export\\s*\\{[^}]*\\b${e}\\b[^}]*\\}`).test(content)
|
|
266
|
-
);
|
|
267
|
-
} catch {
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
252
|
/**
|
|
273
253
|
* Find a legacy `segmentParams` export on layout.tsx or page.tsx.
|
|
274
254
|
*
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
15
|
import type { RouteTree, SegmentNode } from './types.js';
|
|
16
16
|
import { swallow } from '../server/logger.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
fileHasAnyExport,
|
|
19
|
+
fileHasDefaultExport,
|
|
20
|
+
fileHasDirective,
|
|
21
|
+
fileHasStarExport,
|
|
22
|
+
} from './export-detect.js';
|
|
18
23
|
|
|
19
24
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
20
25
|
|
|
@@ -56,6 +61,12 @@ export function lintConventions(tree: RouteTree, appDir: string): ConventionWarn
|
|
|
56
61
|
// Check 5: page.tsx / layout.tsx without default export
|
|
57
62
|
checkDefaultExports(tree.root, warnings);
|
|
58
63
|
|
|
64
|
+
// Check 6: error.tsx / global-error.tsx without 'use client' directive
|
|
65
|
+
checkErrorDirectives(tree.root, warnings);
|
|
66
|
+
if (tree.globalError && isScriptExtension(tree.globalError.extension)) {
|
|
67
|
+
checkFileUseClientDirective(tree.globalError.filePath, 'global-error', warnings);
|
|
68
|
+
}
|
|
69
|
+
|
|
59
70
|
return warnings;
|
|
60
71
|
}
|
|
61
72
|
|
|
@@ -116,37 +127,23 @@ function hasAnyPage(node: SegmentNode): boolean {
|
|
|
116
127
|
// ─── Check: Route Exports ───────────────────────────────────────────────────
|
|
117
128
|
|
|
118
129
|
/** HTTP methods that route.ts can export. */
|
|
119
|
-
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Pattern to detect named exports that look like HTTP method handlers.
|
|
123
|
-
* Matches: export function GET, export const GET, export async function POST, etc.
|
|
124
|
-
* Also matches: export { GET } or re-exports like export { GET } from './handler'.
|
|
125
|
-
*
|
|
126
|
-
* The re-export branch uses word boundaries (\b) around method names to avoid
|
|
127
|
-
* matching substrings (e.g. TARGET should not match GET). See TIM-795.
|
|
128
|
-
*/
|
|
129
|
-
const EXPORT_PATTERN = new RegExp(
|
|
130
|
-
`export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+(${HTTP_METHODS.join('|')})\\b` +
|
|
131
|
-
`|export\\s*\\{[^}]*\\b(${HTTP_METHODS.join('|')})\\b[^}]*\\}`
|
|
132
|
-
);
|
|
130
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const;
|
|
133
131
|
|
|
134
132
|
/**
|
|
135
133
|
* Warn when a route.ts file doesn't appear to export any recognized HTTP methods.
|
|
136
134
|
*
|
|
137
|
-
* Uses
|
|
138
|
-
*
|
|
139
|
-
*
|
|
135
|
+
* Uses AST-based detection via parseAst for precise matching.
|
|
136
|
+
* Falls back silently when the file can't be parsed (syntax errors produce
|
|
137
|
+
* no spurious warning — the TS compiler will catch those).
|
|
140
138
|
*/
|
|
141
139
|
function checkRouteExports(node: SegmentNode, warnings: ConventionWarning[]): void {
|
|
142
140
|
if (node.route) {
|
|
143
141
|
const filePath = node.route.filePath;
|
|
144
142
|
try {
|
|
145
|
-
const
|
|
146
|
-
if (!
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const hint = hasDefaultExport
|
|
143
|
+
const methods = fileHasAnyExport(filePath, HTTP_METHODS);
|
|
144
|
+
if (methods.size === 0 && !fileHasStarExport(filePath)) {
|
|
145
|
+
const hasDefault = fileHasDefaultExport(filePath);
|
|
146
|
+
const hint = hasDefault
|
|
150
147
|
? ' It looks like you have a default export. route.ts uses named exports\n' +
|
|
151
148
|
' for HTTP methods (GET, POST, etc.), not a default export.\n'
|
|
152
149
|
: '';
|
|
@@ -215,18 +212,6 @@ function checkRootLayout(root: SegmentNode, warnings: ConventionWarning[]): void
|
|
|
215
212
|
|
|
216
213
|
// ─── Check: Default Exports ───────────────────────────────────────────
|
|
217
214
|
|
|
218
|
-
/**
|
|
219
|
-
* Pattern to detect a default export in source code.
|
|
220
|
-
* Matches: export default function, export default class, export default
|
|
221
|
-
* Also matches: export { X as default }, export { default } from './Impl'
|
|
222
|
-
*
|
|
223
|
-
* The third alternative catches bare `default` re-exports (TIM-790).
|
|
224
|
-
* The negative lookahead (?!\s+as\b) prevents matching `export { default as X }`
|
|
225
|
-
* which is a named export, not a default export (TIM-806).
|
|
226
|
-
*/
|
|
227
|
-
const DEFAULT_EXPORT_PATTERN =
|
|
228
|
-
/export\s+default\b|export\s*\{[^}]*\bas\s+default\b|export\s*\{[^}]*\bdefault\b(?!\s+as\b)[^}]*\}/;
|
|
229
|
-
|
|
230
215
|
/**
|
|
231
216
|
* Warn when page.tsx or layout.tsx files don't have a default export.
|
|
232
217
|
*
|
|
@@ -234,7 +219,8 @@ const DEFAULT_EXPORT_PATTERN =
|
|
|
234
219
|
* - page.tsx: the page renders nothing (empty content)
|
|
235
220
|
* - layout.tsx: the layout module loads but has no component to render
|
|
236
221
|
*
|
|
237
|
-
* Uses
|
|
222
|
+
* Uses AST-based detection via parseAst for precise matching. Falls back
|
|
223
|
+
* silently when the file can't be parsed (no spurious warning on syntax errors).
|
|
238
224
|
*/
|
|
239
225
|
function checkDefaultExports(node: SegmentNode, warnings: ConventionWarning[]): void {
|
|
240
226
|
// Check page files (tsx/ts/jsx/js only — not mdx, which is default-exported by the mdx compiler)
|
|
@@ -266,8 +252,7 @@ function checkFileDefaultExport(
|
|
|
266
252
|
warnings: ConventionWarning[]
|
|
267
253
|
): void {
|
|
268
254
|
try {
|
|
269
|
-
|
|
270
|
-
if (!DEFAULT_EXPORT_PATTERN.test(source)) {
|
|
255
|
+
if (!fileHasDefaultExport(filePath)) {
|
|
271
256
|
warnings.push({
|
|
272
257
|
id: `NO_DEFAULT_EXPORT:${filePath}`,
|
|
273
258
|
summary: `${fileType}.tsx has no default export: ${filePath}`,
|
|
@@ -287,6 +272,56 @@ function checkFileDefaultExport(
|
|
|
287
272
|
}
|
|
288
273
|
}
|
|
289
274
|
|
|
275
|
+
// ─── Check: Error Directive ─────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Warn when error.tsx or global-error.tsx is missing 'use client'.
|
|
279
|
+
*
|
|
280
|
+
* React's error boundary API requires client components. Without the directive,
|
|
281
|
+
* the error boundary silently fails — the component renders as a server component
|
|
282
|
+
* with no error catching. Users discover this only via cryptic runtime errors.
|
|
283
|
+
*/
|
|
284
|
+
function checkErrorDirectives(node: SegmentNode, warnings: ConventionWarning[]): void {
|
|
285
|
+
if (node.error && isScriptExtension(node.error.extension)) {
|
|
286
|
+
checkFileUseClientDirective(node.error.filePath, 'error', warnings);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const child of node.children) {
|
|
290
|
+
checkErrorDirectives(child, warnings);
|
|
291
|
+
}
|
|
292
|
+
for (const slot of Object.values(node.slots)) {
|
|
293
|
+
checkErrorDirectives(slot, warnings);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function checkFileUseClientDirective(
|
|
298
|
+
filePath: string,
|
|
299
|
+
fileType: string,
|
|
300
|
+
warnings: ConventionWarning[]
|
|
301
|
+
): void {
|
|
302
|
+
try {
|
|
303
|
+
if (!fileHasDirective(filePath, 'use client')) {
|
|
304
|
+
warnings.push({
|
|
305
|
+
id: `ERROR_NO_USE_CLIENT:${filePath}`,
|
|
306
|
+
summary: `${fileType}.tsx is missing 'use client': ${filePath}`,
|
|
307
|
+
details:
|
|
308
|
+
` File: ${filePath}\n\n` +
|
|
309
|
+
` ${fileType}.tsx must start with 'use client' because React error\n` +
|
|
310
|
+
' boundaries require client components. Without it, the error boundary\n' +
|
|
311
|
+
' silently fails and errors are uncaught.\n\n' +
|
|
312
|
+
" To fix: Add 'use client' as the first line of the file:\n\n" +
|
|
313
|
+
" 'use client';\n\n" +
|
|
314
|
+
` export default function ${fileType === 'global-error' ? 'GlobalError' : 'ErrorBoundary'}({ error, reset }) {\n` +
|
|
315
|
+
' return <div><h2>Something went wrong</h2><button onClick={reset}>Try again</button></div>;\n' +
|
|
316
|
+
' }\n',
|
|
317
|
+
level: 'warn',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
swallow(err, `convention-lint: unreadable ${fileType} file ${filePath}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
290
325
|
// ─── Check: App Directory Exists ────────────────────────────────────────────
|
|
291
326
|
|
|
292
327
|
/**
|
|
@@ -312,11 +347,12 @@ export function checkAppDirExists(appDir: string): ConventionWarning | null {
|
|
|
312
347
|
|
|
313
348
|
// ─── Formatting ─────────────────────────────────────────────────────────────
|
|
314
349
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
350
|
+
import type { InspectColor } from 'node:util';
|
|
351
|
+
import { styleText } from 'node:util';
|
|
352
|
+
|
|
353
|
+
type Format = InspectColor | readonly InspectColor[];
|
|
354
|
+
const noValidate = { validateStream: false } as const;
|
|
355
|
+
const style = (fmt: Format, text: string) => styleText(fmt, text, noValidate);
|
|
320
356
|
|
|
321
357
|
/**
|
|
322
358
|
* Format warnings for terminal output.
|
|
@@ -333,24 +369,24 @@ export function formatConventionWarnings(warnings: ConventionWarning[]): string
|
|
|
333
369
|
|
|
334
370
|
if (errors.length > 0) {
|
|
335
371
|
lines.push(
|
|
336
|
-
`${
|
|
372
|
+
`${style(['red', 'bold'], '[timber]')} ${style('red', `${errors.length} configuration error${errors.length !== 1 ? 's' : ''}:`)}`
|
|
337
373
|
);
|
|
338
374
|
for (const e of errors) {
|
|
339
375
|
lines.push('');
|
|
340
|
-
lines.push(` ${
|
|
341
|
-
lines.push(
|
|
376
|
+
lines.push(` ${style('red', '✗')} ${e.summary}`);
|
|
377
|
+
lines.push(style('dim', e.details));
|
|
342
378
|
}
|
|
343
379
|
}
|
|
344
380
|
|
|
345
381
|
if (warns.length > 0) {
|
|
346
382
|
if (errors.length > 0) lines.push('');
|
|
347
383
|
lines.push(
|
|
348
|
-
`${
|
|
384
|
+
`${style(['yellow', 'bold'], '[timber]')} ${style('yellow', `${warns.length} configuration warning${warns.length !== 1 ? 's' : ''}:`)}`
|
|
349
385
|
);
|
|
350
386
|
for (const w of warns) {
|
|
351
387
|
lines.push('');
|
|
352
|
-
lines.push(` ${
|
|
353
|
-
lines.push(
|
|
388
|
+
lines.push(` ${style('yellow', '⚠')} ${w.summary}`);
|
|
389
|
+
lines.push(style('dim', w.details));
|
|
354
390
|
}
|
|
355
391
|
}
|
|
356
392
|
|