@timber-js/app 0.2.0-alpha.95 → 0.2.0-alpha.96
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/LICENSE +8 -0
- package/dist/_chunks/{interception-DSv3A2Zn.js → interception-BsLCA9gk.js} +4 -4
- package/dist/_chunks/interception-BsLCA9gk.js.map +1 -0
- package/dist/_chunks/{ssr-data-DzuI0bIV.js → router-ref-C8OCm7g7.js} +26 -2
- package/dist/_chunks/router-ref-C8OCm7g7.js.map +1 -0
- package/dist/_chunks/{use-params-Br9YSUFV.js → use-params-IOPu7E8t.js} +3 -27
- package/dist/_chunks/use-params-IOPu7E8t.js.map +1 -0
- package/dist/client/browser-dev.d.ts +7 -0
- package/dist/client/browser-dev.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +16 -2
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +2 -2
- package/dist/client/internal.js +2 -2
- package/dist/fonts/pipeline.d.ts +29 -0
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts +0 -8
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +49 -5
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.js +229 -89
- package/dist/index.js.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/server/internal.js +2 -2
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline.d.ts +10 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/browser-dev.ts +25 -0
- package/src/client/error-boundary.tsx +49 -4
- package/src/fonts/pipeline.ts +39 -0
- package/src/fonts/transform.ts +61 -8
- package/src/fonts/virtual-modules.ts +73 -5
- package/src/plugins/fonts.ts +49 -14
- package/src/plugins/mdx.ts +42 -9
- package/src/routing/link-codegen.ts +29 -9
- package/src/server/pipeline.ts +12 -3
- package/src/server/rsc-entry/index.ts +54 -21
- package/dist/_chunks/interception-DSv3A2Zn.js.map +0 -1
- package/dist/_chunks/ssr-data-DzuI0bIV.js.map +0 -1
- package/dist/_chunks/use-params-Br9YSUFV.js.map +0 -1
|
@@ -119,7 +119,16 @@ export interface PipelineConfig {
|
|
|
119
119
|
* If this function throws, the pipeline falls back to a bare
|
|
120
120
|
* `new Response(null, { status: denyStatus })`.
|
|
121
121
|
*/
|
|
122
|
-
renderDenyFallback?: (deny: DenySignal, req: Request, responseHeaders: Headers
|
|
122
|
+
renderDenyFallback?: (deny: DenySignal, req: Request, responseHeaders: Headers,
|
|
123
|
+
/**
|
|
124
|
+
* The matched route, if available. Provided by both the middleware-stage
|
|
125
|
+
* and render-stage catch blocks (matching runs before middleware). When
|
|
126
|
+
* present, the renderer should resolve the deny status file against the
|
|
127
|
+
* matched chain so colocated `403.tsx`/`4xx.tsx`/`401.json` files are
|
|
128
|
+
* picked up. Falls back to the root-only chain when omitted (e.g. for
|
|
129
|
+
* deny()s thrown before route matching could complete). See TIM-822.
|
|
130
|
+
*/
|
|
131
|
+
match?: RouteMatch) => Response | Promise<Response>;
|
|
123
132
|
}
|
|
124
133
|
/**
|
|
125
134
|
* Run segment param coercion on the matched route's segments.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAY,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AA6B/E,OAAO,EAAkB,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAOvD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAMhG;AAID,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IACjD,oEAAoE;IACpE,eAAe,EAAE,YAAY,EAAE,CAAC;CACjC;AAED,6DAA6D;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAAC;AAEnE,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,CACjC,QAAQ,EAAE,MAAM,KACb,OAAO,oBAAoB,EAAE,kBAAkB,GAAG,IAAI,CAAC;AAE5D,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,YAAY,CAAC,EAAE,mBAAmB,KAC/B,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,+DAA+D;AAC/D,MAAM,MAAM,iBAAiB,GAAG,CAC9B,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAI1B,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACtD,qEAAqE;IACrE,UAAU,EAAE,YAAY,CAAC;IACzB,iGAAiG;IACjG,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C,kEAAkE;IAClE,MAAM,EAAE,aAAa,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,eAAe,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzF,kFAAkF;IAClF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,gFAAgF;IAChF,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,CAAC;IAClF;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,UAAU,GAAG,OAAO,GAAG,KAAK,CAAC;IAC5C;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IACpE;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExD;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,CACpB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CACnB,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO;IACxB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,UAAU,KACf,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC;AAID;;;;;;;;;GASG;AACH,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA+B1E;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAsc1F"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuBA,OAAO,uCAAuC,CAAC;AA0C/C,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuBA,OAAO,uCAAuC,CAAC;AA0C/C,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AAmnBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA7UrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA+UhD,wBAAiE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.96",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -110,11 +110,6 @@
|
|
|
110
110
|
"publishConfig": {
|
|
111
111
|
"access": "public"
|
|
112
112
|
},
|
|
113
|
-
"scripts": {
|
|
114
|
-
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
115
|
-
"typecheck": "tsgo --noEmit",
|
|
116
|
-
"prepublishOnly": "pnpm run build"
|
|
117
|
-
},
|
|
118
113
|
"dependencies": {
|
|
119
114
|
"@opentelemetry/api": "^1.9.1",
|
|
120
115
|
"@opentelemetry/context-async-hooks": "^2.6.1",
|
|
@@ -157,5 +152,9 @@
|
|
|
157
152
|
},
|
|
158
153
|
"engines": {
|
|
159
154
|
"node": ">=22.12.0"
|
|
155
|
+
},
|
|
156
|
+
"scripts": {
|
|
157
|
+
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
158
|
+
"typecheck": "tsgo --noEmit"
|
|
160
159
|
}
|
|
161
|
-
}
|
|
160
|
+
}
|
package/src/cli.ts
CHANGED
|
File without changes
|
|
@@ -242,6 +242,10 @@ export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): voi
|
|
|
242
242
|
if (!event.error && !event.message) return;
|
|
243
243
|
// Skip errors during page unload — these are abort-related, not application errors
|
|
244
244
|
if (isPageUnloading()) return;
|
|
245
|
+
// Skip framework signal digests (redirect/deny) — these are control-flow
|
|
246
|
+
// signals, not application errors. They should never surface as dev
|
|
247
|
+
// overlays even if they escape an error boundary. See TIM-838.
|
|
248
|
+
if (isFrameworkSignalError(event.error)) return;
|
|
245
249
|
|
|
246
250
|
const error = event.error;
|
|
247
251
|
hot.send('timber:client-error', {
|
|
@@ -256,6 +260,8 @@ export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): voi
|
|
|
256
260
|
if (!reason) return;
|
|
257
261
|
// Skip rejections during page unload — aborted fetches/streams cause these
|
|
258
262
|
if (isPageUnloading()) return;
|
|
263
|
+
// Skip framework signal digests (redirect/deny). See TIM-838.
|
|
264
|
+
if (isFrameworkSignalError(reason)) return;
|
|
259
265
|
|
|
260
266
|
const message = reason instanceof Error ? reason.message : String(reason);
|
|
261
267
|
const stack = reason instanceof Error ? (reason.stack ?? '') : '';
|
|
@@ -267,3 +273,22 @@ export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): voi
|
|
|
267
273
|
});
|
|
268
274
|
});
|
|
269
275
|
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Detect errors whose `digest` marks them as a framework control-flow signal
|
|
279
|
+
* (RedirectSignal, DenySignal). These must never surface as dev overlays —
|
|
280
|
+
* they are expected during navigation and are handled by the error boundary
|
|
281
|
+
* and router. Exported for tests.
|
|
282
|
+
*/
|
|
283
|
+
export function isFrameworkSignalError(error: unknown): boolean {
|
|
284
|
+
if (!error || typeof error !== 'object') return false;
|
|
285
|
+
const digest = (error as { digest?: unknown }).digest;
|
|
286
|
+
if (typeof digest !== 'string') return false;
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(digest) as { type?: unknown } | null;
|
|
289
|
+
if (!parsed || typeof parsed !== 'object') return false;
|
|
290
|
+
return parsed.type === 'redirect' || parsed.type === 'deny';
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { Component, createElement, type ReactNode } from 'react';
|
|
22
22
|
import { getSsrData } from './ssr-data.js';
|
|
23
|
+
import { getRouterOrNull } from './router-ref.js';
|
|
23
24
|
|
|
24
25
|
// ─── Page Unload Detection ───────────────────────────────────────────────────
|
|
25
26
|
// Track whether the page is being unloaded (user refreshed or navigated away).
|
|
@@ -93,6 +94,13 @@ interface TimberErrorBoundaryState {
|
|
|
93
94
|
error: Error | null;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
// Module-level guard: the first boundary to catch a given redirect digest
|
|
98
|
+
// schedules the navigation and marks it handled here. Subsequent renders
|
|
99
|
+
// (React retries the error-boundary render path multiple times in dev, and
|
|
100
|
+
// outer boundaries that also see the error) become no-ops, preventing
|
|
101
|
+
// duplicate router.navigate() calls.
|
|
102
|
+
const _handledRedirects = new WeakSet<object>();
|
|
103
|
+
|
|
96
104
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
97
105
|
|
|
98
106
|
export class TimberErrorBoundary extends Component<
|
|
@@ -138,11 +146,48 @@ export class TimberErrorBoundary extends Component<
|
|
|
138
146
|
const error = this.state.error;
|
|
139
147
|
const parsed = parseDigest(error);
|
|
140
148
|
|
|
141
|
-
// RedirectSignal
|
|
142
|
-
//
|
|
143
|
-
//
|
|
149
|
+
// RedirectSignal handling splits by environment:
|
|
150
|
+
//
|
|
151
|
+
// - SSR (no window): re-throw so the Fizz shell fails and the server
|
|
152
|
+
// pipeline catch block can produce a proper HTTP 3xx response. See
|
|
153
|
+
// design/04-authorization.md.
|
|
154
|
+
//
|
|
155
|
+
// - Client (window exists): a RedirectSignal that leaked into the RSC
|
|
156
|
+
// Flight payload during client navigation would otherwise bubble past
|
|
157
|
+
// every error boundary to `window.onerror`, which in dev triggers the
|
|
158
|
+
// Vite error overlay (false positive — navigation itself works).
|
|
159
|
+
// Instead, ask the router to perform an SPA navigation to the target
|
|
160
|
+
// and render nothing while it takes over. The router's own catch path
|
|
161
|
+
// handles cross-origin / hard navigations. See TIM-838.
|
|
144
162
|
if (parsed?.type === 'redirect') {
|
|
145
|
-
|
|
163
|
+
if (typeof window === 'undefined') {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
// Dedupe: React may render the error-boundary fallback path more
|
|
167
|
+
// than once for a single caught error (dev-mode double render, and
|
|
168
|
+
// additional boundaries above us in the tree). Only schedule the
|
|
169
|
+
// navigation on the first render that sees this error instance.
|
|
170
|
+
const alreadyHandled = _handledRedirects.has(error);
|
|
171
|
+
if (!alreadyHandled) {
|
|
172
|
+
_handledRedirects.add(error);
|
|
173
|
+
const router = getRouterOrNull();
|
|
174
|
+
if (router) {
|
|
175
|
+
// Schedule the navigation outside the render phase to avoid
|
|
176
|
+
// setState-during-render warnings from router state updates.
|
|
177
|
+
queueMicrotask(() => {
|
|
178
|
+
router.navigate(parsed.location, { replace: true }).catch(() => {
|
|
179
|
+
// Fall back to a hard navigation if the soft navigation fails.
|
|
180
|
+
window.location.href = parsed.location;
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
// No router available (shouldn't happen post-bootstrap) — fall
|
|
185
|
+
// back to a hard navigation so the user still ends up at the
|
|
186
|
+
// target.
|
|
187
|
+
window.location.href = parsed.location;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
146
191
|
}
|
|
147
192
|
|
|
148
193
|
// If this boundary has a status filter, check whether the error matches.
|
package/src/fonts/pipeline.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type { CachedFont } from './google.js';
|
|
|
31
31
|
import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
|
|
32
32
|
import { generateFallbackCss } from './fallbacks.js';
|
|
33
33
|
import { generateLocalFontFaces } from './local.js';
|
|
34
|
+
import { VirtualFontCssIdMap } from './virtual-modules.js';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Backwards-compatible alias for the registry shape that the standalone
|
|
@@ -55,6 +56,18 @@ export class FontPipeline {
|
|
|
55
56
|
*/
|
|
56
57
|
private readonly _entries = new Map<string, FontEntry>();
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Per-importer virtual CSS module id registry. Populated by the
|
|
61
|
+
* transform hook when it inlines a font function call, read by the
|
|
62
|
+
* plugin's `load` hook when Vite asks for the module's CSS source.
|
|
63
|
+
*
|
|
64
|
+
* Lives on the pipeline (rather than a module-level singleton) so
|
|
65
|
+
* multiple parallel plugin instances — e.g. in the test suite — cannot
|
|
66
|
+
* collide on the hash-to-importer map. Also makes `clear()` reset the
|
|
67
|
+
* id map alongside the font entries so test fixtures start clean.
|
|
68
|
+
*/
|
|
69
|
+
readonly cssIdMap = new VirtualFontCssIdMap();
|
|
70
|
+
|
|
58
71
|
// ── Read-only accessors ─────────────────────────────────────────────────
|
|
59
72
|
|
|
60
73
|
/** Number of registered fonts. */
|
|
@@ -179,6 +192,7 @@ export class FontPipeline {
|
|
|
179
192
|
/** Drop every registered font. Used by tests and rebuild flows. */
|
|
180
193
|
clear(): void {
|
|
181
194
|
this._entries.clear();
|
|
195
|
+
this.cssIdMap.clear();
|
|
182
196
|
}
|
|
183
197
|
|
|
184
198
|
// ── Derived views ───────────────────────────────────────────────────────
|
|
@@ -216,6 +230,31 @@ export class FontPipeline {
|
|
|
216
230
|
}
|
|
217
231
|
return cssParts.join('\n\n');
|
|
218
232
|
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Render the combined CSS for every font registered from a specific
|
|
236
|
+
* importer file. This is the anchor point for the per-importer virtual
|
|
237
|
+
* CSS module (`virtual:timber-font-css/<hash>.css`) that the transform
|
|
238
|
+
* hook injects as a side-effect import.
|
|
239
|
+
*
|
|
240
|
+
* Scoping by importer means two concurrent requests rendering different
|
|
241
|
+
* routes cannot see each other's font CSS: each importer's virtual
|
|
242
|
+
* module only contains CSS for fonts that module registered. It also
|
|
243
|
+
* means Vite's CSS HMR can surgically reload just the affected importer
|
|
244
|
+
* when a single file changes, rather than rebuilding one giant blob.
|
|
245
|
+
*
|
|
246
|
+
* Returns an empty string when no fonts are registered under `importer`.
|
|
247
|
+
* The empty string is semantically equivalent to "no CSS" — Vite's CSS
|
|
248
|
+
* pipeline handles it as a valid empty CSS module.
|
|
249
|
+
*/
|
|
250
|
+
getCssForSegment(importer: string): string {
|
|
251
|
+
const cssParts: string[] = [];
|
|
252
|
+
for (const entry of this._entries.values()) {
|
|
253
|
+
if (entry.importer !== importer) continue;
|
|
254
|
+
cssParts.push(renderEntryCss(entry));
|
|
255
|
+
}
|
|
256
|
+
return cssParts.join('\n\n');
|
|
257
|
+
}
|
|
219
258
|
}
|
|
220
259
|
|
|
221
260
|
/**
|
package/src/fonts/transform.ts
CHANGED
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
detectDynamicFontCallAst,
|
|
20
20
|
} from './ast.js';
|
|
21
21
|
import type { FontPipeline } from './pipeline.js';
|
|
22
|
-
import { VIRTUAL_FONT_CSS_REGISTER } from './virtual-modules.js';
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Regex that matches imports from either `@timber/fonts/google` or
|
|
@@ -306,11 +305,46 @@ function transformLocalFonts(
|
|
|
306
305
|
/**
|
|
307
306
|
* Run the timber-fonts transform pass on a single source file.
|
|
308
307
|
*
|
|
309
|
-
* Returns the rewritten code (with font calls inlined and
|
|
310
|
-
* `virtual:timber-font-css
|
|
311
|
-
*
|
|
312
|
-
* needs no transformation.
|
|
308
|
+
* Returns the rewritten code (with font calls inlined and a side-effect
|
|
309
|
+
* `import 'virtual:timber-font-css/<hash>.css'` prepended so Vite's CSS
|
|
310
|
+
* pipeline owns the font CSS) or `null` if the file does not import from
|
|
311
|
+
* any timber-fonts virtual module and therefore needs no transformation.
|
|
313
312
|
*/
|
|
313
|
+
/**
|
|
314
|
+
* File extensions that may contain real font imports. Only JS/TS module
|
|
315
|
+
* variants are scanned. Excluding non-JS extensions (.mdx, .md, .css,
|
|
316
|
+
* .json, etc.) prevents false-positive matches on text that mentions a
|
|
317
|
+
* font specifier inside documentation, code fences, or asset metadata.
|
|
318
|
+
*
|
|
319
|
+
* Why this matters: the heuristic below is a substring check (`code.includes`),
|
|
320
|
+
* not an AST parse. An MDX file documenting Next.js font compatibility
|
|
321
|
+
* (e.g. a code fence containing `import { Inter } from 'next/font/google'`)
|
|
322
|
+
* would trigger the same match as a real import. The transform then prepends
|
|
323
|
+
* `import 'virtual:timber-font-css/<hash>.css';` to the file, which breaks
|
|
324
|
+
* downstream parsers that require the file to start with their own delimiter
|
|
325
|
+
* — most notably MDX frontmatter, which must start at line 1 col 1 for
|
|
326
|
+
* `remark-frontmatter` to recognise it. See TIM-840.
|
|
327
|
+
*/
|
|
328
|
+
const FONT_IMPORT_SCANNABLE_EXTENSIONS = new Set([
|
|
329
|
+
'.js',
|
|
330
|
+
'.jsx',
|
|
331
|
+
'.ts',
|
|
332
|
+
'.tsx',
|
|
333
|
+
'.mjs',
|
|
334
|
+
'.cjs',
|
|
335
|
+
'.mts',
|
|
336
|
+
'.cts',
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
function hasScannableExtension(id: string): boolean {
|
|
340
|
+
// Strip Vite query suffixes (?v=..., ?import, ?worker, etc.) before
|
|
341
|
+
// matching so files imported with a query string are still scanned.
|
|
342
|
+
const path = id.split('?', 1)[0];
|
|
343
|
+
const dot = path.lastIndexOf('.');
|
|
344
|
+
if (dot === -1) return false;
|
|
345
|
+
return FONT_IMPORT_SCANNABLE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
346
|
+
}
|
|
347
|
+
|
|
314
348
|
export function runFontsTransform(
|
|
315
349
|
code: string,
|
|
316
350
|
id: string,
|
|
@@ -320,6 +354,14 @@ export function runFontsTransform(
|
|
|
320
354
|
// Skip virtual modules and node_modules
|
|
321
355
|
if (id.startsWith('\0') || id.includes('node_modules')) return null;
|
|
322
356
|
|
|
357
|
+
// Only scan JS/TS module variants. .mdx, .md, .css, .json, etc. may
|
|
358
|
+
// mention font specifiers in documentation or asset metadata without
|
|
359
|
+
// ever importing them — a substring match would be a false positive
|
|
360
|
+
// and the prepended virtual CSS import would corrupt downstream
|
|
361
|
+
// parsers (e.g. MDX frontmatter, which must start at line 1).
|
|
362
|
+
// See TIM-840.
|
|
363
|
+
if (!hasScannableExtension(id)) return null;
|
|
364
|
+
|
|
323
365
|
const hasGoogleImport =
|
|
324
366
|
code.includes('@timber/fonts/google') ||
|
|
325
367
|
code.includes('@timber-js/app/fonts/google') ||
|
|
@@ -341,10 +383,21 @@ export function runFontsTransform(
|
|
|
341
383
|
}
|
|
342
384
|
|
|
343
385
|
if (transformedCode !== code) {
|
|
344
|
-
// Inject side-effect import
|
|
345
|
-
// The
|
|
386
|
+
// Inject a side-effect import for this file's per-importer virtual
|
|
387
|
+
// CSS module. The `.css` suffix is what makes Vite's CSS pipeline
|
|
388
|
+
// own the module — in dev that gives us HMR + source maps, and in
|
|
389
|
+
// production @vitejs/plugin-rsc walks the RSC module graph via
|
|
390
|
+
// `collectCss`, finds this virtual CSS import, and injects a
|
|
391
|
+
// `<link rel="stylesheet" data-rsc-css-href=...>` through React
|
|
392
|
+
// Float's preinit mechanism (the same path component CSS rides on).
|
|
393
|
+
//
|
|
394
|
+
// The per-importer id guarantees two concurrent requests rendering
|
|
395
|
+
// different routes never see each other's font CSS: each importer's
|
|
396
|
+
// virtual module only resolves to the fonts that importer registered
|
|
397
|
+
// via `FontPipeline.getCssForSegment(importer)`. See TIM-828.
|
|
346
398
|
if (pipeline.size() > 0) {
|
|
347
|
-
|
|
399
|
+
const virtualCssId = pipeline.cssIdMap.idFor(id);
|
|
400
|
+
transformedCode = `import '${virtualCssId}';\n` + transformedCode;
|
|
348
401
|
}
|
|
349
402
|
return { code: transformedCode, map: null };
|
|
350
403
|
}
|
|
@@ -9,13 +9,27 @@
|
|
|
9
9
|
* transform hook hasn't run yet, or when an unknown font is referenced
|
|
10
10
|
* via the default export proxy).
|
|
11
11
|
* - `@timber/fonts/local` — exports `localFont()` as a fallback default.
|
|
12
|
-
* - `virtual:timber-font-css
|
|
13
|
-
* combined `@font-face`
|
|
14
|
-
*
|
|
12
|
+
* - `virtual:timber-font-css/<hash>.css` — per-importer CSS module that
|
|
13
|
+
* holds the combined `@font-face` + fallback + class CSS for every
|
|
14
|
+
* font registered under that importer. The transform hook injects a
|
|
15
|
+
* side-effect `import 'virtual:timber-font-css/<hash>.css'` into each
|
|
16
|
+
* file that calls a font function, so Vite's CSS pipeline picks it up
|
|
17
|
+
* like any other CSS asset. @vitejs/plugin-rsc's `collectCss` walks
|
|
18
|
+
* the RSC module graph, finds the virtual module, and injects a
|
|
19
|
+
* `<link rel="stylesheet" data-rsc-css-href=...>` via React Float —
|
|
20
|
+
* the same machinery component CSS rides on (TIM-828).
|
|
21
|
+
*
|
|
22
|
+
* The `.css` suffix is load-bearing: Vite's `isCSSRequest` regex matches
|
|
23
|
+
* on file extension, so keeping it in the virtual id is how we tell
|
|
24
|
+
* Vite's CSS pipeline (dev HMR + build asset emission) that this
|
|
25
|
+
* module is CSS. The old `virtual:timber-font-css-register` side-effect
|
|
26
|
+
* module, `globalThis.__timber_font_css` channel, and inline `<style>`
|
|
27
|
+
* tag in the RSC entry were all removed in TIM-828.
|
|
15
28
|
*
|
|
16
29
|
* Design doc: 24-fonts.md
|
|
17
30
|
*/
|
|
18
31
|
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
19
33
|
import type { ExtractedFont, FontFaceDescriptor } from './types.js';
|
|
20
34
|
import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
|
|
21
35
|
import { generateFallbackCss } from './fallbacks.js';
|
|
@@ -26,8 +40,62 @@ export const VIRTUAL_LOCAL = '@timber/fonts/local';
|
|
|
26
40
|
export const RESOLVED_GOOGLE = '\0@timber/fonts/google';
|
|
27
41
|
export const RESOLVED_LOCAL = '\0@timber/fonts/local';
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Prefix used for the per-importer virtual CSS modules. The full id is
|
|
45
|
+
* `virtual:timber-font-css/<hash>.css` where `<hash>` is a short SHA-256
|
|
46
|
+
* digest of the importer path. We encode the hash (not the path itself)
|
|
47
|
+
* so the id stays filesystem-safe and opaque to downstream consumers.
|
|
48
|
+
*/
|
|
49
|
+
export const VIRTUAL_FONT_CSS_PREFIX = 'virtual:timber-font-css/';
|
|
50
|
+
export const VIRTUAL_FONT_CSS_SUFFIX = '.css';
|
|
51
|
+
export const RESOLVED_FONT_CSS_PREFIX = '\0' + VIRTUAL_FONT_CSS_PREFIX;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* In-memory map from the short hash used in virtual module ids back to
|
|
55
|
+
* the absolute importer path. Populated by `virtualFontCssIdFor()` when
|
|
56
|
+
* the transform hook registers an importer and read by the plugin's
|
|
57
|
+
* `load` hook when Vite asks for the CSS content.
|
|
58
|
+
*
|
|
59
|
+
* Scoped to a single `FontPipeline` instance to avoid cross-plugin-
|
|
60
|
+
* instance collisions during multi-build pipelines.
|
|
61
|
+
*/
|
|
62
|
+
export class VirtualFontCssIdMap {
|
|
63
|
+
private readonly map = new Map<string, string>();
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return a stable virtual module id for `importer`. Calling this twice
|
|
67
|
+
* with the same importer returns the same id. Registering under an
|
|
68
|
+
* existing hash is idempotent.
|
|
69
|
+
*/
|
|
70
|
+
idFor(importer: string): string {
|
|
71
|
+
const hash = createHash('sha256').update(importer).digest('hex').slice(0, 16);
|
|
72
|
+
this.map.set(hash, importer);
|
|
73
|
+
return `${VIRTUAL_FONT_CSS_PREFIX}${hash}${VIRTUAL_FONT_CSS_SUFFIX}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Decode the importer from a resolved (or unresolved) virtual css id. */
|
|
77
|
+
importerFor(id: string): string | undefined {
|
|
78
|
+
const body = id.startsWith('\0') ? id.slice(1) : id;
|
|
79
|
+
if (!body.startsWith(VIRTUAL_FONT_CSS_PREFIX)) return undefined;
|
|
80
|
+
if (!body.endsWith(VIRTUAL_FONT_CSS_SUFFIX)) return undefined;
|
|
81
|
+
const hash = body.slice(
|
|
82
|
+
VIRTUAL_FONT_CSS_PREFIX.length,
|
|
83
|
+
body.length - VIRTUAL_FONT_CSS_SUFFIX.length
|
|
84
|
+
);
|
|
85
|
+
return this.map.get(hash);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True if `id` is any form of the virtual font css module. */
|
|
89
|
+
matches(id: string): boolean {
|
|
90
|
+
const body = id.startsWith('\0') ? id.slice(1) : id;
|
|
91
|
+
return body.startsWith(VIRTUAL_FONT_CSS_PREFIX) && body.endsWith(VIRTUAL_FONT_CSS_SUFFIX);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Drop every registered mapping. Used by tests. */
|
|
95
|
+
clear(): void {
|
|
96
|
+
this.map.clear();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
31
99
|
|
|
32
100
|
/**
|
|
33
101
|
* Convert a font family name to a PascalCase export name.
|
package/src/plugins/fonts.ts
CHANGED
|
@@ -32,10 +32,8 @@ import { emitFontAssets, writeFontManifest, groupCachedFontsByFamily } from '../
|
|
|
32
32
|
import {
|
|
33
33
|
RESOLVED_GOOGLE,
|
|
34
34
|
RESOLVED_LOCAL,
|
|
35
|
-
RESOLVED_FONT_CSS_REGISTER,
|
|
36
35
|
VIRTUAL_GOOGLE,
|
|
37
36
|
VIRTUAL_LOCAL,
|
|
38
|
-
VIRTUAL_FONT_CSS_REGISTER,
|
|
39
37
|
generateGoogleVirtualModule,
|
|
40
38
|
generateLocalVirtualModule,
|
|
41
39
|
} from '../fonts/virtual-modules.js';
|
|
@@ -68,12 +66,34 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
68
66
|
name: 'timber-fonts',
|
|
69
67
|
|
|
70
68
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* `enforce: 'pre'` is load-bearing for the font CSS pipeline (TIM-828).
|
|
70
|
+
*
|
|
71
|
+
* The transform hook injects a side-effect
|
|
72
|
+
* `import 'virtual:timber-font-css/<hash>.css'` into every file that
|
|
73
|
+
* registers a font. @vitejs/plugin-rsc's `rsc:rsc-css-export-transform`
|
|
74
|
+
* later inspects each server component's source for CSS imports and
|
|
75
|
+
* wraps the default export in `import.meta.viteRsc.loadCss(...)` so
|
|
76
|
+
* the matched virtual CSS module ends up `preinit`'d as
|
|
77
|
+
* `<link rel="stylesheet" data-rsc-css-href=...>`.
|
|
78
|
+
*
|
|
79
|
+
* If timber-fonts ran in normal order it would inject the CSS import
|
|
80
|
+
* *after* plugin-rsc had already scanned the source, and the font CSS
|
|
81
|
+
* would be silently dropped from the rendered HTML. Running in the
|
|
82
|
+
* `pre` slot guarantees our injected import is visible to plugin-rsc's
|
|
83
|
+
* later CSS-export scan.
|
|
84
|
+
*/
|
|
85
|
+
enforce: 'pre',
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve `@timber/fonts/google`, `@timber/fonts/local`, and the
|
|
89
|
+
* per-importer `virtual:timber-font-css/<hash>.css` modules injected
|
|
90
|
+
* by the transform hook.
|
|
73
91
|
*
|
|
74
92
|
* Strips the `\0` prefix added by the RSC plugin's re-imports and the
|
|
75
93
|
* absolute root prefix added by SSR build entries before comparing
|
|
76
|
-
* against the public IDs.
|
|
94
|
+
* against the public IDs. The `.css` suffix on font-css ids is
|
|
95
|
+
* preserved so Vite's CSS pipeline (`isCSSRequest`) recognises the
|
|
96
|
+
* resolved id as CSS.
|
|
77
97
|
*/
|
|
78
98
|
resolveId(id: string) {
|
|
79
99
|
let cleanId = id.startsWith('\0') ? id.slice(1) : id;
|
|
@@ -88,27 +108,42 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
88
108
|
|
|
89
109
|
if (cleanId === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
|
|
90
110
|
if (cleanId === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
|
|
91
|
-
if (cleanId
|
|
111
|
+
if (pipeline.cssIdMap.matches(cleanId)) return '\0' + cleanId;
|
|
92
112
|
return null;
|
|
93
113
|
},
|
|
94
114
|
|
|
95
115
|
/**
|
|
96
116
|
* Return generated source for font virtual modules.
|
|
97
117
|
*
|
|
98
|
-
* `virtual:timber-font-css
|
|
99
|
-
* the combined
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
118
|
+
* The per-importer `virtual:timber-font-css/<hash>.css` modules
|
|
119
|
+
* return the combined font CSS for the specific importer that
|
|
120
|
+
* registered them. Vite's CSS pipeline handles injection from here:
|
|
121
|
+
* in dev the module rides the CSS HMR channel; in production it is
|
|
122
|
+
* emitted as a real CSS asset and @vitejs/plugin-rsc's `collectCss`
|
|
123
|
+
* walks the RSC module graph to find it.
|
|
124
|
+
*
|
|
125
|
+
* In dev mode we also lazily resolve Google `@font-face` descriptors
|
|
126
|
+
* from the CDN here, since `buildStart` is a no-op in dev.
|
|
104
127
|
*/
|
|
105
128
|
async load(id: string) {
|
|
106
129
|
if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(pipeline.fonts());
|
|
107
130
|
if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
|
|
108
131
|
|
|
109
|
-
if (id
|
|
132
|
+
if (pipeline.cssIdMap.matches(id)) {
|
|
133
|
+
const importer = pipeline.cssIdMap.importerFor(id);
|
|
134
|
+
if (!importer) {
|
|
135
|
+
// Hash is valid but no importer is registered — return an empty
|
|
136
|
+
// CSS module rather than `null`. This can happen if Vite's
|
|
137
|
+
// module graph invalidates the virtual module between the
|
|
138
|
+
// transform that registered the hash and the load that asks
|
|
139
|
+
// for it (e.g. after an HMR edit that removes every font from
|
|
140
|
+
// the file). An empty stylesheet is a valid, safe no-op.
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
110
144
|
if (ctx.dev) {
|
|
111
145
|
for (const font of pipeline.googleFonts()) {
|
|
146
|
+
if (font.importer !== importer) continue;
|
|
112
147
|
if (pipeline.hasFaces(font.id)) continue;
|
|
113
148
|
try {
|
|
114
149
|
const faces = await resolveDevFontFaces(font);
|
|
@@ -125,7 +160,7 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
125
160
|
}
|
|
126
161
|
}
|
|
127
162
|
|
|
128
|
-
return
|
|
163
|
+
return pipeline.getCssForSegment(importer);
|
|
129
164
|
}
|
|
130
165
|
return null;
|
|
131
166
|
},
|
package/src/plugins/mdx.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import type { Plugin } from 'vite';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
14
16
|
import type { PluginContext } from '../plugin-context.js';
|
|
15
17
|
|
|
16
18
|
const MDX_EXTENSIONS = ['mdx', 'md'];
|
|
@@ -54,15 +56,45 @@ function shouldActivate(ctx: PluginContext): boolean {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
* Try to dynamically import a module by name
|
|
58
|
-
*
|
|
59
|
+
* Try to dynamically import a module by name, resolving from the user's
|
|
60
|
+
* project root rather than from `@timber-js/app`'s own location.
|
|
61
|
+
*
|
|
62
|
+
* Why this matters: pnpm only hoists declared (peer) dependencies into a
|
|
63
|
+
* package's resolution scope. The MDX integration's optional companions
|
|
64
|
+
* — `remark-frontmatter`, `remark-mdx-frontmatter`, `@mdx-js/rollup` — are
|
|
65
|
+
* installed as direct deps of the consumer (e.g. `packages/website`), not
|
|
66
|
+
* `@timber-js/app`. A bare `import('remark-frontmatter')` from inside this
|
|
67
|
+
* file resolves against `packages/timber-app/node_modules` and silently
|
|
68
|
+
* fails with `ERR_MODULE_NOT_FOUND`, which made `tryImport` return
|
|
69
|
+
* `undefined` and skipped frontmatter parsing entirely — leaving MDX to
|
|
70
|
+
* choke on YAML as JS expressions (TIM-840).
|
|
71
|
+
*
|
|
72
|
+
* Resolving relative to `projectRoot` (via `createRequire`) makes the
|
|
73
|
+
* lookup walk the consumer's `node_modules` tree first, which is where
|
|
74
|
+
* pnpm puts the user's deps.
|
|
75
|
+
*
|
|
76
|
+
* Returns the default export, the module itself, or undefined if the
|
|
77
|
+
* module is not installed.
|
|
59
78
|
*/
|
|
60
|
-
async function tryImport(name: string): Promise<unknown | undefined> {
|
|
79
|
+
async function tryImport(name: string, projectRoot: string): Promise<unknown | undefined> {
|
|
61
80
|
try {
|
|
62
|
-
|
|
81
|
+
// Anchor resolution at the consumer's package.json so we walk their
|
|
82
|
+
// node_modules tree, not @timber-js/app's. createRequire().resolve()
|
|
83
|
+
// returns an absolute file path that ESM dynamic import accepts when
|
|
84
|
+
// wrapped as a file:// URL.
|
|
85
|
+
const require = createRequire(join(projectRoot, 'package.json'));
|
|
86
|
+
const resolved = require.resolve(name);
|
|
87
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
63
88
|
return mod.default ?? mod;
|
|
64
89
|
} catch {
|
|
65
|
-
|
|
90
|
+
// Fall back to a bare import — covers the rare case where the package
|
|
91
|
+
// lives next to @timber-js/app (e.g. monorepo with hoisted deps).
|
|
92
|
+
try {
|
|
93
|
+
const mod = await import(name);
|
|
94
|
+
return mod.default ?? mod;
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
66
98
|
}
|
|
67
99
|
}
|
|
68
100
|
|
|
@@ -80,7 +112,7 @@ export function timberMdx(ctx: PluginContext): Plugin {
|
|
|
80
112
|
async function activate(): Promise<void> {
|
|
81
113
|
if (innerPlugin !== null || !shouldActivate(ctx)) return;
|
|
82
114
|
|
|
83
|
-
const createMdxPlugin = (await tryImport('@mdx-js/rollup')) as
|
|
115
|
+
const createMdxPlugin = (await tryImport('@mdx-js/rollup', ctx.root)) as
|
|
84
116
|
| ((options?: Record<string, unknown>) => Plugin)
|
|
85
117
|
| undefined;
|
|
86
118
|
|
|
@@ -99,10 +131,11 @@ export function timberMdx(ctx: PluginContext): Plugin {
|
|
|
99
131
|
|
|
100
132
|
const mdxConfig = ctx.config.mdx ?? {};
|
|
101
133
|
|
|
102
|
-
// Auto-register frontmatter plugins
|
|
134
|
+
// Auto-register frontmatter plugins. Resolve from the consumer's root
|
|
135
|
+
// so pnpm finds packages declared in the user's package.json (TIM-840).
|
|
103
136
|
const remarkPlugins: unknown[] = [];
|
|
104
|
-
const remarkFrontmatter = await tryImport('remark-frontmatter');
|
|
105
|
-
const remarkMdxFrontmatter = await tryImport('remark-mdx-frontmatter');
|
|
137
|
+
const remarkFrontmatter = await tryImport('remark-frontmatter', ctx.root);
|
|
138
|
+
const remarkMdxFrontmatter = await tryImport('remark-mdx-frontmatter', ctx.root);
|
|
106
139
|
if (remarkFrontmatter) remarkPlugins.push(remarkFrontmatter);
|
|
107
140
|
if (remarkMdxFrontmatter) remarkPlugins.push(remarkMdxFrontmatter);
|
|
108
141
|
|