@timber-js/app 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import { findFunctionsWithDirective, containsDirective } from '#/utils/directive-parser.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a cacheLife duration string to seconds.
|
|
6
|
+
* Supports: '30s', '5m', '1h', '2d', '1w', or a plain number (seconds).
|
|
7
|
+
*/
|
|
8
|
+
export function parseCacheLife(value: string | number): number {
|
|
9
|
+
if (typeof value === 'number') return value;
|
|
10
|
+
|
|
11
|
+
const match = value.match(/^(\d+)(s|m|h|d|w)$/);
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Invalid cacheLife value: "${value}". Expected format: "30s", "5m", "1h", "2d", "1w", or a number.`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const amount = parseInt(match[1], 10);
|
|
19
|
+
const unit = match[2];
|
|
20
|
+
const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
|
21
|
+
return amount * multipliers[unit];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Default TTL when no cacheLife() is specified (Infinity means cache until explicit invalidation).
|
|
25
|
+
const DEFAULT_TTL = Infinity;
|
|
26
|
+
|
|
27
|
+
export interface CacheTransformWarning {
|
|
28
|
+
message: string;
|
|
29
|
+
functionName: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TransformResult {
|
|
33
|
+
code: string;
|
|
34
|
+
map?: null;
|
|
35
|
+
warnings?: CacheTransformWarning[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Match cacheLife() calls: cacheLife('1h'), cacheLife("5m"), cacheLife(300)
|
|
40
|
+
*/
|
|
41
|
+
const CACHE_LIFE_PATTERN = /cacheLife\(\s*(?:'([^']+)'|"([^"]+)"|(\d+))\s*\)/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Strip the 'use cache' directive and cacheLife() call from a function body.
|
|
45
|
+
* Returns the cleaned body and the extracted TTL.
|
|
46
|
+
*/
|
|
47
|
+
function extractCacheDirectives(body: string): { cleanBody: string; ttl: number } {
|
|
48
|
+
let ttl = DEFAULT_TTL;
|
|
49
|
+
|
|
50
|
+
// Remove 'use cache' / "use cache" directive (including optional semicolon and newline)
|
|
51
|
+
let cleanBody = body.replace(/\s*['"]use cache['"];?\s*\n?/, '\n');
|
|
52
|
+
|
|
53
|
+
// Extract and remove cacheLife() calls
|
|
54
|
+
const lifeMatch = cleanBody.match(CACHE_LIFE_PATTERN);
|
|
55
|
+
if (lifeMatch) {
|
|
56
|
+
const value = lifeMatch[1] || lifeMatch[2] || parseInt(lifeMatch[3], 10);
|
|
57
|
+
ttl = parseCacheLife(value);
|
|
58
|
+
cleanBody = cleanBody.replace(/\s*cacheLife\([^)]*\);?\s*\n?/, '\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { cleanBody, ttl };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Determine if a function name is a React component (PascalCase).
|
|
66
|
+
*/
|
|
67
|
+
function isComponentName(name: string): boolean {
|
|
68
|
+
return /^[A-Z]/.test(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pattern matching page/layout file conventions in a dynamic route segment.
|
|
73
|
+
* Matches paths like: app/[slug]/page.tsx, app/[id]/layout.ts, etc.
|
|
74
|
+
*/
|
|
75
|
+
const DYNAMIC_ROUTE_PAGE_PATTERN = /\/\[[^\]]+\].*\/(page|layout)\.[jt]sx?$/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect if a function declaration has Promise-typed parameters.
|
|
79
|
+
*
|
|
80
|
+
* Checks for common patterns:
|
|
81
|
+
* - `params: Promise<...>`
|
|
82
|
+
* - `{ params }: { params: Promise<...> }`
|
|
83
|
+
* - `props: { params: Promise<...> }`
|
|
84
|
+
*/
|
|
85
|
+
const PROMISE_PARAMS_PATTERN = /params\s*(?::|.*?:)\s*Promise\s*</;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a 'use cache' function in a dynamic route page/layout receives
|
|
89
|
+
* Promise-typed params, which are not serializable as cache keys.
|
|
90
|
+
*/
|
|
91
|
+
export function detectPromiseParamsWarning(
|
|
92
|
+
declaration: string,
|
|
93
|
+
functionName: string,
|
|
94
|
+
fileId: string
|
|
95
|
+
): CacheTransformWarning | null {
|
|
96
|
+
if (!DYNAMIC_ROUTE_PAGE_PATTERN.test(fileId)) return null;
|
|
97
|
+
if (!PROMISE_PARAMS_PATTERN.test(declaration)) return null;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
message:
|
|
101
|
+
`'use cache' on "${functionName}" in "${fileId}" receives Promise params. ` +
|
|
102
|
+
`Promise is not serializable as a cache key and will cause runtime errors. ` +
|
|
103
|
+
`Either remove 'use cache' or await the params before using them in a separate cached function.`,
|
|
104
|
+
functionName,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Transform source code containing 'use cache' directives into
|
|
110
|
+
* registerCachedFunction() calls.
|
|
111
|
+
*
|
|
112
|
+
* Returns null if no transformations were made.
|
|
113
|
+
*/
|
|
114
|
+
export function transformUseCache(code: string, fileId: string): TransformResult | null {
|
|
115
|
+
if (!containsDirective(code, 'use cache')) return null;
|
|
116
|
+
|
|
117
|
+
const functions = findFunctionsWithDirective(code, 'use cache');
|
|
118
|
+
if (functions.length === 0) return null;
|
|
119
|
+
|
|
120
|
+
let result = code;
|
|
121
|
+
let needsImport = false;
|
|
122
|
+
const warnings: CacheTransformWarning[] = [];
|
|
123
|
+
|
|
124
|
+
// Process functions from end to start (sorted descending by start position)
|
|
125
|
+
for (const fn of functions) {
|
|
126
|
+
// Warn if function receives Promise params in a dynamic route page/layout
|
|
127
|
+
const promiseWarning = detectPromiseParamsWarning(fn.declaration, fn.name, fileId);
|
|
128
|
+
if (promiseWarning) warnings.push(promiseWarning);
|
|
129
|
+
const { cleanBody, ttl } = extractCacheDirectives(fn.bodyContent);
|
|
130
|
+
const stableId = `${fileId}#${fn.name}`;
|
|
131
|
+
const isComponent = isComponentName(fn.name);
|
|
132
|
+
|
|
133
|
+
// Build the options object
|
|
134
|
+
const optsParts = [`ttl: ${ttl === Infinity ? 'Infinity' : ttl}`];
|
|
135
|
+
optsParts.push(`id: '${stableId}'`);
|
|
136
|
+
if (isComponent) {
|
|
137
|
+
optsParts.push('isComponent: true');
|
|
138
|
+
}
|
|
139
|
+
const optsStr = `{ ${optsParts.join(', ')} }`;
|
|
140
|
+
|
|
141
|
+
// Build the replacement
|
|
142
|
+
let replacement: string;
|
|
143
|
+
if (fn.isArrow) {
|
|
144
|
+
// const Name = async (...) => { body } → const Name = registerCachedFunction(async (...) => { body }, opts)
|
|
145
|
+
const arrowSig = fn.declaration.replace(/^(?:const|let|var)\s+\w+\s*=\s*/, '');
|
|
146
|
+
replacement = `const ${fn.name} = registerCachedFunction(${arrowSig} {${cleanBody}}, ${optsStr})`;
|
|
147
|
+
} else {
|
|
148
|
+
// async function Name(...) { body } → const Name = registerCachedFunction(async function Name(...) { body }, opts)
|
|
149
|
+
const fnDecl = fn.declaration.replace(/^(?:export\s+default\s+|export\s+)?/, '');
|
|
150
|
+
const exportPrefix = fn.prefix.includes('default')
|
|
151
|
+
? 'export default '
|
|
152
|
+
: fn.prefix.includes('export')
|
|
153
|
+
? 'export '
|
|
154
|
+
: '';
|
|
155
|
+
|
|
156
|
+
replacement = `${exportPrefix}const ${fn.name} = registerCachedFunction(${fnDecl} {${cleanBody}}, ${optsStr})`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
result = result.slice(0, fn.start) + replacement + result.slice(fn.end);
|
|
160
|
+
needsImport = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (needsImport) {
|
|
164
|
+
// Add the import at the top of the file
|
|
165
|
+
result = `import { registerCachedFunction } from '@timber/app/cache';\n` + result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { code: result, map: null, warnings: warnings.length > 0 ? warnings : undefined };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Vite plugin: timber-cache
|
|
173
|
+
*
|
|
174
|
+
* Transforms 'use cache' directives into registerCachedFunction() calls.
|
|
175
|
+
* Only runs in the RSC environment.
|
|
176
|
+
*/
|
|
177
|
+
export function cacheTransformPlugin(): Plugin {
|
|
178
|
+
return {
|
|
179
|
+
name: 'timber-cache',
|
|
180
|
+
|
|
181
|
+
transform(code, id) {
|
|
182
|
+
// Only transform in RSC environment
|
|
183
|
+
// Skip node_modules and non-JS/TS files
|
|
184
|
+
if (id.includes('node_modules')) return null;
|
|
185
|
+
if (!/\.[jt]sx?$/.test(id)) return null;
|
|
186
|
+
|
|
187
|
+
// Quick bail-out: no 'use cache' directive in this file
|
|
188
|
+
if (!containsDirective(code, 'use cache')) return null;
|
|
189
|
+
|
|
190
|
+
const result = transformUseCache(code, id);
|
|
191
|
+
if (result?.warnings) {
|
|
192
|
+
for (const w of result.warnings) {
|
|
193
|
+
this.warn(`[timber] ${w.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-chunks — Vite sub-plugin for intelligent client chunk splitting.
|
|
3
|
+
*
|
|
4
|
+
* Splits client bundles into cache tiers based on update frequency:
|
|
5
|
+
*
|
|
6
|
+
* Tier 1: vendor-react — react, react-dom, scheduler (changes rarely)
|
|
7
|
+
* Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
|
|
8
|
+
* Tier 3: [route]-* — per-route app code (changes per deploy, handled by Vite defaults)
|
|
9
|
+
*
|
|
10
|
+
* Server environments (RSC, SSR) are left to Vite's default chunking since
|
|
11
|
+
* Cloudflare Workers load all code from a single deployment bundle with no
|
|
12
|
+
* benefit from cache-tier separation.
|
|
13
|
+
*
|
|
14
|
+
* Design docs: 27-chunking-strategy.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Plugin } from 'vite';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Categorize a module ID into a cache tier chunk name.
|
|
21
|
+
*
|
|
22
|
+
* Returns a chunk name for vendor modules, or undefined to let
|
|
23
|
+
* Rollup's default splitting handle app/route code.
|
|
24
|
+
*/
|
|
25
|
+
export function assignChunk(id: string): string | undefined {
|
|
26
|
+
// Tier 1: React ecosystem — changes on version bumps only
|
|
27
|
+
if (
|
|
28
|
+
id.includes('node_modules/react-dom') ||
|
|
29
|
+
id.includes('node_modules/react/') ||
|
|
30
|
+
id.includes('node_modules/scheduler')
|
|
31
|
+
) {
|
|
32
|
+
return 'vendor-react';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Tier 2: timber framework runtime — changes on framework updates
|
|
36
|
+
if (
|
|
37
|
+
id.includes('/timber-app/') ||
|
|
38
|
+
id.includes('react-server-dom') ||
|
|
39
|
+
id.includes('@vitejs/plugin-rsc')
|
|
40
|
+
) {
|
|
41
|
+
return 'vendor-timber';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Everything else: Rollup's default splitting (per-route chunks)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Group timber's internal 'use client' modules into the vendor-timber chunk.
|
|
49
|
+
*
|
|
50
|
+
* The RSC plugin creates separate entry points for each 'use client' module,
|
|
51
|
+
* which manualChunks can't merge. This function is passed as the RSC plugin's
|
|
52
|
+
* `clientChunks` callback to group timber internals into a single chunk.
|
|
53
|
+
* User and third-party client components are left to default per-route splitting.
|
|
54
|
+
*/
|
|
55
|
+
export function assignClientChunk(meta: {
|
|
56
|
+
id: string;
|
|
57
|
+
normalizedId: string;
|
|
58
|
+
serverChunk: string;
|
|
59
|
+
}): string | undefined {
|
|
60
|
+
if (meta.id.includes('/timber-app/')) return 'vendor-timber';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create the timber-chunks Vite plugin.
|
|
65
|
+
*
|
|
66
|
+
* Uses Vite's per-environment config to apply manualChunks only to
|
|
67
|
+
* the client build. The config hook runs before environments are
|
|
68
|
+
* created, so we use `environments.client` to target the client.
|
|
69
|
+
*/
|
|
70
|
+
export function timberChunks(): Plugin {
|
|
71
|
+
return {
|
|
72
|
+
name: 'timber-chunks',
|
|
73
|
+
|
|
74
|
+
config() {
|
|
75
|
+
return {
|
|
76
|
+
environments: {
|
|
77
|
+
client: {
|
|
78
|
+
build: {
|
|
79
|
+
rollupOptions: {
|
|
80
|
+
output: {
|
|
81
|
+
manualChunks: assignChunk,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-content — Vite sub-plugin for content collections.
|
|
3
|
+
*
|
|
4
|
+
* Wraps @content-collections/vite to provide content collection support.
|
|
5
|
+
* Activates only when a content-collections.ts config file exists at the
|
|
6
|
+
* project root.
|
|
7
|
+
*
|
|
8
|
+
* Design doc: 20-content-collections.md §"Content Collections"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Plugin } from 'vite';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import type { PluginContext } from '#/index.js';
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILE_NAMES = [
|
|
17
|
+
'content-collections.ts',
|
|
18
|
+
'content-collections.js',
|
|
19
|
+
'content-collections.mts',
|
|
20
|
+
'content-collections.mjs',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find the content-collections config file at the project root.
|
|
25
|
+
* Returns the filename (not full path) if found, otherwise undefined.
|
|
26
|
+
*/
|
|
27
|
+
function findConfigFile(root: string): string | undefined {
|
|
28
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
29
|
+
if (existsSync(join(root, name))) return name;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create the timber-content Vite plugin.
|
|
36
|
+
*
|
|
37
|
+
* Delegates all content scanning, validation, code generation, and file watching
|
|
38
|
+
* to @content-collections/vite. This plugin only handles detection and activation.
|
|
39
|
+
*/
|
|
40
|
+
export function timberContent(ctx: PluginContext): Plugin {
|
|
41
|
+
let innerPlugin: Plugin | null = null;
|
|
42
|
+
|
|
43
|
+
async function activate(root: string): Promise<void> {
|
|
44
|
+
if (innerPlugin !== null) return;
|
|
45
|
+
|
|
46
|
+
const configFile = findConfigFile(root);
|
|
47
|
+
if (!configFile) return;
|
|
48
|
+
|
|
49
|
+
let createPlugin: ((options?: { configPath?: string }) => Plugin) | undefined;
|
|
50
|
+
try {
|
|
51
|
+
const mod = await import('@content-collections/vite');
|
|
52
|
+
createPlugin = (mod.default ?? mod) as typeof createPlugin;
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(
|
|
55
|
+
[
|
|
56
|
+
'[timber] Content collections are enabled but @content-collections/vite is not installed.',
|
|
57
|
+
'',
|
|
58
|
+
'Install content-collections:',
|
|
59
|
+
' pnpm add -D @content-collections/core @content-collections/vite',
|
|
60
|
+
'',
|
|
61
|
+
'For MDX content, also install:',
|
|
62
|
+
' pnpm add -D @content-collections/mdx',
|
|
63
|
+
'',
|
|
64
|
+
'Content collections are activated because a content-collections.ts file exists.',
|
|
65
|
+
].join('\n')
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (createPlugin) {
|
|
70
|
+
innerPlugin = createPlugin({ configPath: configFile });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
name: 'timber-content',
|
|
76
|
+
|
|
77
|
+
async config(config, env) {
|
|
78
|
+
const root = config.root ?? ctx.root;
|
|
79
|
+
ctx.timer.start('content-activate');
|
|
80
|
+
await activate(root);
|
|
81
|
+
ctx.timer.end('content-activate');
|
|
82
|
+
if (!innerPlugin) return;
|
|
83
|
+
if (typeof innerPlugin.config === 'function') {
|
|
84
|
+
return innerPlugin.config.call(this, config, env);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async configResolved(config) {
|
|
89
|
+
if (!innerPlugin) return;
|
|
90
|
+
if (typeof innerPlugin.configResolved === 'function') {
|
|
91
|
+
await (innerPlugin.configResolved as (...args: unknown[]) => unknown).call(this, config);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async buildStart(options) {
|
|
96
|
+
if (!innerPlugin) return;
|
|
97
|
+
if (typeof innerPlugin.buildStart === 'function') {
|
|
98
|
+
await (innerPlugin.buildStart as (...args: unknown[]) => unknown).call(this, options);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async resolveId(source: string, importer: string | undefined, options: unknown) {
|
|
103
|
+
if (!innerPlugin) return null;
|
|
104
|
+
if (typeof innerPlugin.resolveId === 'function') {
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
106
|
+
return (innerPlugin.resolveId as any).call(this, source, importer, options);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async load(id: string) {
|
|
112
|
+
if (!innerPlugin) return null;
|
|
113
|
+
if (typeof innerPlugin.load === 'function') {
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
return (innerPlugin.load as any).call(this, id);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async transform(code: string, id: string) {
|
|
121
|
+
if (!innerPlugin) return null;
|
|
122
|
+
if (typeof innerPlugin.transform === 'function') {
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
124
|
+
return (innerPlugin.transform as any).call(this, code, id);
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async configureServer(server) {
|
|
130
|
+
if (!innerPlugin) return;
|
|
131
|
+
if (typeof innerPlugin.configureServer === 'function') {
|
|
132
|
+
await (innerPlugin.configureServer as (...args: unknown[]) => unknown).call(this, server);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev error overlay — formats and sends errors to Vite's browser overlay and stderr.
|
|
3
|
+
*
|
|
4
|
+
* Integrates with Vite's built-in error overlay (`server.ssrFixStacktrace` +
|
|
5
|
+
* `server.hot.send`) rather than implementing a custom overlay.
|
|
6
|
+
*
|
|
7
|
+
* Design doc: 21-dev-server.md §"Error Overlay"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ViteDevServer } from 'vite';
|
|
11
|
+
|
|
12
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** The phase of the pipeline where the error occurred. */
|
|
15
|
+
export type ErrorPhase =
|
|
16
|
+
| 'module-transform'
|
|
17
|
+
| 'proxy'
|
|
18
|
+
| 'middleware'
|
|
19
|
+
| 'access'
|
|
20
|
+
| 'render'
|
|
21
|
+
| 'handler';
|
|
22
|
+
|
|
23
|
+
/** Labels for terminal output. */
|
|
24
|
+
const PHASE_LABELS: Record<ErrorPhase, string> = {
|
|
25
|
+
'module-transform': 'Module Transform',
|
|
26
|
+
'proxy': 'Proxy',
|
|
27
|
+
'middleware': 'Middleware',
|
|
28
|
+
'access': 'Access Check',
|
|
29
|
+
'render': 'RSC Render',
|
|
30
|
+
'handler': 'Route Handler',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ─── Frame Classification ───────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export type FrameType = 'app' | 'framework' | 'internal';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Classify a stack frame line by origin.
|
|
39
|
+
*
|
|
40
|
+
* - 'app': user application code (in project root, not node_modules)
|
|
41
|
+
* - 'framework': timber-app internal code
|
|
42
|
+
* - 'internal': node_modules, Node.js internals
|
|
43
|
+
*/
|
|
44
|
+
export function classifyFrame(frameLine: string, projectRoot: string): FrameType {
|
|
45
|
+
// Strip leading whitespace and "at "
|
|
46
|
+
const trimmed = frameLine.trim();
|
|
47
|
+
|
|
48
|
+
if (trimmed.includes('packages/timber-app/')) return 'framework';
|
|
49
|
+
if (trimmed.includes('node_modules/')) return 'internal';
|
|
50
|
+
if (trimmed.startsWith('at node:') || trimmed.includes('(node:')) return 'internal';
|
|
51
|
+
if (trimmed.includes(projectRoot)) return 'app';
|
|
52
|
+
|
|
53
|
+
return 'internal';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Component Stack Extraction ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract the React component stack from an error, if present.
|
|
60
|
+
* React attaches this as `componentStack` during renderToReadableStream errors.
|
|
61
|
+
*/
|
|
62
|
+
export function extractComponentStack(error: unknown): string | null {
|
|
63
|
+
if (
|
|
64
|
+
error &&
|
|
65
|
+
typeof error === 'object' &&
|
|
66
|
+
'componentStack' in error &&
|
|
67
|
+
typeof (error as Record<string, unknown>).componentStack === 'string'
|
|
68
|
+
) {
|
|
69
|
+
return (error as Record<string, unknown>).componentStack as string;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── First App Frame Parsing ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
interface SourceLocation {
|
|
77
|
+
file: string;
|
|
78
|
+
line: number;
|
|
79
|
+
column: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse the first application frame from a stack trace.
|
|
84
|
+
* Returns file/line/column for the overlay's `loc` field.
|
|
85
|
+
*/
|
|
86
|
+
export function parseFirstAppFrame(stack: string, projectRoot: string): SourceLocation | null {
|
|
87
|
+
const lines = stack.split('\n');
|
|
88
|
+
// Match patterns like:
|
|
89
|
+
// at functionName (/absolute/path:line:column)
|
|
90
|
+
// at /absolute/path:line:column
|
|
91
|
+
const parenRegex = /\(([^)]+):(\d+):(\d+)\)/;
|
|
92
|
+
const bareRegex = /at (\/[^:]+):(\d+):(\d+)/;
|
|
93
|
+
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
if (classifyFrame(line, projectRoot) !== 'app') continue;
|
|
96
|
+
|
|
97
|
+
const match = parenRegex.exec(line) ?? bareRegex.exec(line);
|
|
98
|
+
if (!match) continue;
|
|
99
|
+
|
|
100
|
+
const [, file, lineNum, col] = match;
|
|
101
|
+
if (file && lineNum && col) {
|
|
102
|
+
return { file, line: parseInt(lineNum, 10), column: parseInt(col, 10) };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Error Phase Classification ─────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Classify the error phase by inspecting the error's stack trace.
|
|
113
|
+
* Falls back to 'render' if no specific phase can be determined.
|
|
114
|
+
*/
|
|
115
|
+
export function classifyErrorPhase(error: Error, projectRoot: string): ErrorPhase {
|
|
116
|
+
const stack = error.stack ?? '';
|
|
117
|
+
|
|
118
|
+
// Check for React component stack (render error)
|
|
119
|
+
if (extractComponentStack(error)) return 'render';
|
|
120
|
+
|
|
121
|
+
// Check for specific file patterns in app frames
|
|
122
|
+
const appRoot = projectRoot.replace(/\/$/, '');
|
|
123
|
+
if (stack.includes(`${appRoot}/app/`) || stack.includes('/app/')) {
|
|
124
|
+
if (stack.includes('/middleware.ts') || stack.includes('/middleware.js')) return 'middleware';
|
|
125
|
+
if (stack.includes('/access.ts') || stack.includes('/access.js')) return 'access';
|
|
126
|
+
if (stack.includes('/route.ts') || stack.includes('/route.js')) return 'handler';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return 'render';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Terminal Formatting ────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
// ANSI codes
|
|
135
|
+
const RED = '\x1b[31m';
|
|
136
|
+
const DIM = '\x1b[2m';
|
|
137
|
+
const RESET = '\x1b[0m';
|
|
138
|
+
const BOLD = '\x1b[1m';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format an error for terminal output.
|
|
142
|
+
*
|
|
143
|
+
* - Red for the error message and phase label
|
|
144
|
+
* - Dim for framework-internal frames
|
|
145
|
+
* - Normal for application frames
|
|
146
|
+
* - Separate section for component stack (if present)
|
|
147
|
+
*/
|
|
148
|
+
export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot: string): string {
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
|
|
151
|
+
// Phase header + error message
|
|
152
|
+
lines.push(`${RED}${BOLD}[timber] ${PHASE_LABELS[phase]} Error${RESET}`);
|
|
153
|
+
lines.push(`${RED}${error.message}${RESET}`);
|
|
154
|
+
lines.push('');
|
|
155
|
+
|
|
156
|
+
// Component stack (if present)
|
|
157
|
+
const componentStack = extractComponentStack(error);
|
|
158
|
+
if (componentStack) {
|
|
159
|
+
lines.push(`${BOLD}Component Stack:${RESET}`);
|
|
160
|
+
for (const csLine of componentStack.trim().split('\n')) {
|
|
161
|
+
lines.push(` ${csLine.trim()}`);
|
|
162
|
+
}
|
|
163
|
+
lines.push('');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Stack trace with frame dimming
|
|
167
|
+
if (error.stack) {
|
|
168
|
+
lines.push(`${BOLD}Stack Trace:${RESET}`);
|
|
169
|
+
const stackLines = error.stack.split('\n').slice(1); // Skip the first line (message)
|
|
170
|
+
for (const stackLine of stackLines) {
|
|
171
|
+
const frameType = classifyFrame(stackLine, projectRoot);
|
|
172
|
+
if (frameType === 'app') {
|
|
173
|
+
lines.push(stackLine);
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(`${DIM}${stackLine}${RESET}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Overlay Integration ────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Send an error to Vite's browser overlay and log it to stderr.
|
|
187
|
+
*
|
|
188
|
+
* Uses `server.ssrFixStacktrace()` to map stack traces back to source,
|
|
189
|
+
* then sends the error via `server.hot.send()` for the browser overlay.
|
|
190
|
+
*
|
|
191
|
+
* The dev server remains running — errors are handled, not fatal.
|
|
192
|
+
*/
|
|
193
|
+
export function sendErrorToOverlay(
|
|
194
|
+
server: ViteDevServer,
|
|
195
|
+
error: Error,
|
|
196
|
+
phase: ErrorPhase,
|
|
197
|
+
projectRoot: string
|
|
198
|
+
): void {
|
|
199
|
+
// Fix stack trace to use source-mapped positions
|
|
200
|
+
server.ssrFixStacktrace(error);
|
|
201
|
+
|
|
202
|
+
// Log to stderr with frame dimming
|
|
203
|
+
const formatted = formatTerminalError(error, phase, projectRoot);
|
|
204
|
+
process.stderr.write(`${formatted}\n`);
|
|
205
|
+
|
|
206
|
+
// Build overlay payload
|
|
207
|
+
const loc = parseFirstAppFrame(error.stack ?? '', projectRoot);
|
|
208
|
+
const componentStack = extractComponentStack(error);
|
|
209
|
+
|
|
210
|
+
let message = error.message;
|
|
211
|
+
if (componentStack) {
|
|
212
|
+
message = `${error.message}\n\nComponent Stack:\n${componentStack.trim()}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Send to browser via Vite's error overlay protocol
|
|
216
|
+
try {
|
|
217
|
+
server.hot.send({
|
|
218
|
+
type: 'error',
|
|
219
|
+
err: {
|
|
220
|
+
message,
|
|
221
|
+
stack: error.stack ?? '',
|
|
222
|
+
id: loc?.file,
|
|
223
|
+
plugin: `timber (${PHASE_LABELS[phase]})`,
|
|
224
|
+
loc: loc ? { file: loc.file, line: loc.line, column: loc.column } : undefined,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
} catch {
|
|
228
|
+
// Overlay send must never crash the dev server
|
|
229
|
+
}
|
|
230
|
+
}
|