jamdesk 1.1.201 → 1.1.202
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/__tests__/unit/migrate-risky-expression-warning.test.d.ts +2 -0
- package/dist/__tests__/unit/migrate-risky-expression-warning.test.d.ts.map +1 -0
- package/dist/__tests__/unit/migrate-risky-expression-warning.test.js +47 -0
- package/dist/__tests__/unit/migrate-risky-expression-warning.test.js.map +1 -0
- package/dist/commands/migrate/convert-mdx.d.ts.map +1 -1
- package/dist/commands/migrate/convert-mdx.js +5 -1
- package/dist/commands/migrate/convert-mdx.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +7 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/risky-expression-scanner.d.ts.map +1 -1
- package/dist/lib/risky-expression-scanner.js +53 -0
- package/dist/lib/risky-expression-scanner.js.map +1 -1
- package/dist/lib/validate-risky-expressions.d.ts +6 -0
- package/dist/lib/validate-risky-expressions.d.ts.map +1 -1
- package/dist/lib/validate-risky-expressions.js +5 -1
- package/dist/lib/validate-risky-expressions.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/globals.css +19 -0
- package/vendored/components/theme/ThemeToggle.tsx +4 -1
- package/vendored/lib/mdx-inline-components.ts +4 -0
- package/vendored/lib/preprocess-mdx.ts +19 -1
- package/vendored/lib/render-doc-page.tsx +24 -3
- package/vendored/lib/risky-expression-scanner.ts +60 -0
- package/vendored/lib/snippet-compiler-isr.ts +11 -1
- package/vendored/lib/snippet-loader-isr.ts +95 -2
- package/vendored/lib/strip-event-handlers.ts +159 -0
- package/vendored/lib/user-utility-css.ts +250 -0
- package/vendored/workspace-package-lock.json +46 -46
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { transform } from '@babel/standalone';
|
|
9
|
+
import { babelStripEventHandlers } from './strip-event-handlers';
|
|
9
10
|
import { fetchSnippet } from './r2-content';
|
|
10
11
|
|
|
11
12
|
interface CompiledSnippet {
|
|
@@ -52,7 +53,16 @@ export async function compileSnippetIsr(
|
|
|
52
53
|
// Transpile JSX to JavaScript
|
|
53
54
|
const transpiled = transform(source, {
|
|
54
55
|
presets: ['react', 'typescript'],
|
|
55
|
-
|
|
56
|
+
// babelStripEventHandlers even though NOTHING calls compileSnippetIsr today
|
|
57
|
+
// (only clearSnippetCache/getSnippetCacheSize are imported elsewhere). This
|
|
58
|
+
// file is named as THE ISR snippet compiler, so it is exactly what someone
|
|
59
|
+
// reaches for next — and without the strip it silently reintroduces the
|
|
60
|
+
// HTTP 500 that lib/strip-event-handlers.ts exists to prevent. Two lines of
|
|
61
|
+
// insurance on a dead path beats rediscovering that incident.
|
|
62
|
+
plugins: [
|
|
63
|
+
babelStripEventHandlers,
|
|
64
|
+
['transform-react-jsx', { runtime: 'automatic' }],
|
|
65
|
+
],
|
|
56
66
|
filename: snippetPath,
|
|
57
67
|
});
|
|
58
68
|
|
|
@@ -19,12 +19,15 @@ import { injectPromptSources } from './inject-prompt-source';
|
|
|
19
19
|
import { mdxSecurityOptions } from './mdx-security-options';
|
|
20
20
|
import { remarkSvgNamespaceAttrs } from './remark-svg-namespace-attrs';
|
|
21
21
|
import { remarkStyleStringToObject } from './remark-style-string-to-object';
|
|
22
|
+
import { recmaStripEventHandlers, babelStripEventHandlers } from './strip-event-handlers';
|
|
22
23
|
import {
|
|
23
24
|
mdxEvalGuardPlugin,
|
|
24
25
|
guardPropertyKey,
|
|
25
26
|
KEY_GUARD_NAME,
|
|
26
27
|
STRICT_MODE_PROLOGUE,
|
|
27
28
|
} from './mdx-eval-guard';
|
|
29
|
+
import { extractUtilityCandidates, MAX_UTILITY_CANDIDATES } from './user-utility-css';
|
|
30
|
+
import { logger } from '../shared/logger';
|
|
28
31
|
|
|
29
32
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
33
|
type AnyComponent = React.ComponentType<any>;
|
|
@@ -32,6 +35,14 @@ type AnyComponent = React.ComponentType<any>;
|
|
|
32
35
|
interface CompiledSnippet {
|
|
33
36
|
exports: Record<string, AnyComponent>;
|
|
34
37
|
isClientComponent: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Arbitrary-value Tailwind classes written in this snippet's own source.
|
|
40
|
+
* Carried on the compiled result so it rides the existing snippet cache —
|
|
41
|
+
* the classes an author writes live in the SNIPPET, not in the page that
|
|
42
|
+
* imports it, so a page-source-only scan would miss them entirely.
|
|
43
|
+
* See lib/user-utility-css.ts.
|
|
44
|
+
*/
|
|
45
|
+
utilityCandidates: string[];
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
// In-memory cache for compiled snippet components
|
|
@@ -172,6 +183,12 @@ export function transpileJsx(source: string): string {
|
|
|
172
183
|
// own identifiers, ahead of the JSX transform.
|
|
173
184
|
mdxEvalGuardPlugin,
|
|
174
185
|
onClickToHrefPlugin,
|
|
186
|
+
// The <span onClick={window.open}> → <a href> rewrite above keeps the
|
|
187
|
+
// author's navigation intent and always wins — it visits JSXElement,
|
|
188
|
+
// this visits the child JSXOpeningElement, and Babel reaches the parent
|
|
189
|
+
// first, so array order is not what decides it. Every handler that plugin
|
|
190
|
+
// does not claim is dropped here rather than 500-ing the including page.
|
|
191
|
+
babelStripEventHandlers,
|
|
175
192
|
['transform-react-jsx', { runtime: 'automatic', importSource: 'react' }],
|
|
176
193
|
],
|
|
177
194
|
filename: 'snippet.tsx',
|
|
@@ -282,7 +299,13 @@ async function compilePlainMdxSnippet(
|
|
|
282
299
|
// does on a page, and a snippet crash takes down every page that includes
|
|
283
300
|
// it. The REHYPE pipeline is still deliberately omitted — see the note
|
|
284
301
|
// above about D2 fences.
|
|
285
|
-
|
|
302
|
+
// recmaStripEventHandlers for the same reason the page pipeline runs it:
|
|
303
|
+
// an author `on*` in a snippet is unserializable in RSC and 500s the
|
|
304
|
+
// whole page that includes it (see lib/strip-event-handlers.ts).
|
|
305
|
+
mdxOptions: {
|
|
306
|
+
remarkPlugins: [remarkSvgNamespaceAttrs, remarkStyleStringToObject],
|
|
307
|
+
recmaPlugins: [recmaStripEventHandlers],
|
|
308
|
+
},
|
|
286
309
|
},
|
|
287
310
|
});
|
|
288
311
|
const PlainMdxSnippet: AnyComponent = () => content as React.ReactElement;
|
|
@@ -327,6 +350,7 @@ async function compileSnippet(
|
|
|
327
350
|
const result: CompiledSnippet = {
|
|
328
351
|
exports: { default: component },
|
|
329
352
|
isClientComponent: false,
|
|
353
|
+
utilityCandidates: extractUtilityCandidates(source),
|
|
330
354
|
};
|
|
331
355
|
snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
332
356
|
return result;
|
|
@@ -378,7 +402,11 @@ async function compileSnippet(
|
|
|
378
402
|
}
|
|
379
403
|
}
|
|
380
404
|
|
|
381
|
-
const result: CompiledSnippet = {
|
|
405
|
+
const result: CompiledSnippet = {
|
|
406
|
+
exports,
|
|
407
|
+
isClientComponent,
|
|
408
|
+
utilityCandidates: extractUtilityCandidates(source),
|
|
409
|
+
};
|
|
382
410
|
|
|
383
411
|
// Cache the result
|
|
384
412
|
snippetComponentCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
@@ -454,6 +482,71 @@ export async function loadSnippetsForIsr(
|
|
|
454
482
|
return components;
|
|
455
483
|
}
|
|
456
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Collect the arbitrary-value Tailwind classes used by a page — its own source
|
|
487
|
+
* plus every snippet it imports.
|
|
488
|
+
*
|
|
489
|
+
* Deliberately a separate pass rather than an extra return value from
|
|
490
|
+
* `loadSnippetsForIsr`: `compileSnippet` is cached per `project:path`, so by the
|
|
491
|
+
* time this runs during a page render every snippet is a cache hit and this
|
|
492
|
+
* costs a map lookup. Threading the candidates back through
|
|
493
|
+
* `loadSnippetsForIsr` would instead have changed a signature that
|
|
494
|
+
* `render-doc-page-parallel-helpers` and its tests both depend on.
|
|
495
|
+
*
|
|
496
|
+
* Never throws: a failure to collect styling candidates must not fail a render.
|
|
497
|
+
*/
|
|
498
|
+
export async function collectPageUtilityCandidates(
|
|
499
|
+
projectSlug: string,
|
|
500
|
+
mdxContent: string,
|
|
501
|
+
builtInComponents: Record<string, AnyComponent> = {},
|
|
502
|
+
// Snippet bodies live in R2, which only exists in ISR. Outside it every
|
|
503
|
+
// fetch here would throw from assertR2Configured and be swallowed below —
|
|
504
|
+
// dead work on the render critical path, and an R2-configured non-ISR
|
|
505
|
+
// environment would pull PRODUCTION snippet content into a local preview.
|
|
506
|
+
// The page's own source is still scanned either way, because the CLI dev
|
|
507
|
+
// workspace keeps project content outside the tree Tailwind scans, so
|
|
508
|
+
// skipping it would make dev render unstyled where prod renders correctly.
|
|
509
|
+
includeSnippets = true
|
|
510
|
+
): Promise<string[]> {
|
|
511
|
+
const candidates = new Set<string>(extractUtilityCandidates(mdxContent));
|
|
512
|
+
|
|
513
|
+
if (includeSnippets) {
|
|
514
|
+
try {
|
|
515
|
+
const imports = extractSnippetImports(mdxContent);
|
|
516
|
+
await Promise.all(
|
|
517
|
+
imports.map(async (imp) => {
|
|
518
|
+
try {
|
|
519
|
+
const compiled = await compileSnippet(
|
|
520
|
+
projectSlug,
|
|
521
|
+
normalizeSnippetPath(imp.path),
|
|
522
|
+
builtInComponents
|
|
523
|
+
);
|
|
524
|
+
for (const c of compiled.utilityCandidates) candidates.add(c);
|
|
525
|
+
} catch {
|
|
526
|
+
// A snippet that fails to compile renders degraded anyway; its
|
|
527
|
+
// styling is not worth failing the page for.
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
// Not reachable today: extractSnippetImports and normalizeSnippetPath do
|
|
533
|
+
// not throw, and the Promise.all cannot reject because every element
|
|
534
|
+
// catches. Kept as a render-path guard — but logged, so a future change
|
|
535
|
+
// that does start throwing here is visible rather than silently costing
|
|
536
|
+
// every page its snippet styling.
|
|
537
|
+
logger.warn('[user-utility-css] snippet candidate scan failed', {
|
|
538
|
+
projectSlug,
|
|
539
|
+
error: error instanceof Error ? error.message : String(error),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The per-source cap inside extractUtilityCandidates does NOT bound this
|
|
545
|
+
// union: a page importing 10 snippets could otherwise reach the compiler with
|
|
546
|
+
// 10x the cap and build a cache key tens of KB long. Re-apply it to the merge.
|
|
547
|
+
return [...candidates].sort().slice(0, MAX_UTILITY_CANDIDATES);
|
|
548
|
+
}
|
|
549
|
+
|
|
457
550
|
/**
|
|
458
551
|
* Clear the snippet component cache.
|
|
459
552
|
*/
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { visit } from 'estree-util-visit';
|
|
2
|
+
import type { Program, Property } from 'estree-jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* strip-event-handlers — removes author-written `on*` event-handler props from
|
|
6
|
+
* user MDX/JSX before it is rendered as a React Server Component.
|
|
7
|
+
*
|
|
8
|
+
* WHY this exists, and why it is NOT covered by the existing never-500 layers:
|
|
9
|
+
* a Server Component cannot pass a function across the RSC boundary. React
|
|
10
|
+
* throws `Event handlers cannot be passed to Client Component props` while
|
|
11
|
+
* SERIALIZING the flight payload — after every component has rendered
|
|
12
|
+
* successfully. That makes it invisible to both existing guards:
|
|
13
|
+
*
|
|
14
|
+
* - `MdxRenderBoundary` (components/errors/MdxRenderBoundary.tsx) is a client
|
|
15
|
+
* class boundary. It only catches throws during CLIENT render, so it cannot
|
|
16
|
+
* stop a server-side serialization failure. The response is still HTTP 500.
|
|
17
|
+
* Its `fallback` prop IS serialized alongside `children`, so the browser
|
|
18
|
+
* shows "⚠ This content couldn't be displayed (page content)" while the
|
|
19
|
+
* status is 500 — content degrades, the status does not.
|
|
20
|
+
* - `recmaGuardExpressions` wraps author expressions in a try/catch IIFE, but
|
|
21
|
+
* `onSubmit={() => …}` does not throw when EVALUATED — it returns a function
|
|
22
|
+
* perfectly well. The guard hands that function straight to the serializer.
|
|
23
|
+
*
|
|
24
|
+
* So, exactly as the bare-`{x, y}` class before it, the only RSC-safe fix is to
|
|
25
|
+
* neutralize the construct at COMPILE time. Observed in production on
|
|
26
|
+
* 2026-08-31: one customer page rendering a snippet with `<form onSubmit={…}>`
|
|
27
|
+
* returned 500 for three weeks.
|
|
28
|
+
*
|
|
29
|
+
* WHAT is removed: any prop whose name matches `/^on[A-Z]/` — React's own
|
|
30
|
+
* event-handler naming convention, and the same test React applies when it
|
|
31
|
+
* decides a prop is an event handler. The element and all its other props and
|
|
32
|
+
* children render normally; only the dead handler goes. This loses nothing that
|
|
33
|
+
* ever worked: an author `on*` in server-rendered MDX has never been functional,
|
|
34
|
+
* it has only ever been a 500.
|
|
35
|
+
*
|
|
36
|
+
* Two plugins because user content reaches React down two different pipelines:
|
|
37
|
+
* - `recmaStripEventHandlers` — the MDX compile path (page bodies and
|
|
38
|
+
* plain-markdown snippets), operating on compiled `_jsx(tag, props)` calls.
|
|
39
|
+
* - `babelStripEventHandlers` — the Babel path (`export`-style JSX snippets
|
|
40
|
+
* and inline page components), operating on JSX attributes before the JSX
|
|
41
|
+
* transform runs.
|
|
42
|
+
*
|
|
43
|
+
* RESIDUAL SCOPE — three shapes still reach the serializer and still 500. All
|
|
44
|
+
* three were reproduced against the real flight writer
|
|
45
|
+
* (`react-server-dom-webpack/server.edge` under `--conditions=react-server`):
|
|
46
|
+
*
|
|
47
|
+
* 1. A handler spread from an IDENTIFIER: `<form {...handlers} />`. Not
|
|
48
|
+
* statically visible, so it survives. Note an object-LITERAL spread
|
|
49
|
+
* (`<div {...{onClick: f}} />`) is NOT in this class — the MDX compiler
|
|
50
|
+
* flattens it into the props ObjectExpression, where this plugin sees and
|
|
51
|
+
* removes it like any other property.
|
|
52
|
+
* 2. A computed key: `<div {...{['on' + 'Click']: f}} />` — `propKeyName`
|
|
53
|
+
* declines to guess at computed keys.
|
|
54
|
+
* 3. A function-valued prop whose name is NOT `on[A-Z]` — `render={() => …}`
|
|
55
|
+
* or `children={() => …}`. These fail with a DIFFERENT React error
|
|
56
|
+
* ("Functions cannot be passed directly to Client Components" /
|
|
57
|
+
* "Functions are not valid as a child"), so they are a separate class
|
|
58
|
+
* rather than a hole in this one. Stripping every function-valued prop
|
|
59
|
+
* would close it, but would also strip callbacks that a purely
|
|
60
|
+
* server-rendered inline component legitimately consumes without ever
|
|
61
|
+
* serializing them — so that is deliberately NOT done here.
|
|
62
|
+
*
|
|
63
|
+
* None of the three has been observed in customer content; `on[A-Z]` is the
|
|
64
|
+
* shape authors actually reach for when they paste React into MDX.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* React's own rule for "this prop is an event handler": `on` followed by an
|
|
69
|
+
* uppercase letter. Deliberately NOT a fixed list of known DOM events — a
|
|
70
|
+
* custom `onFoo` on a component prop is just as unserializable as `onClick`,
|
|
71
|
+
* and a list would silently miss every event React adds later.
|
|
72
|
+
*
|
|
73
|
+
* The uppercase requirement is what keeps legitimate props safe: `once`, `only`
|
|
74
|
+
* and `onboarding` are ordinary words, not handlers.
|
|
75
|
+
*/
|
|
76
|
+
const EVENT_HANDLER_PROP = /^on[A-Z]/;
|
|
77
|
+
|
|
78
|
+
export function isEventHandlerProp(name: string): boolean {
|
|
79
|
+
return EVENT_HANDLER_PROP.test(name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** JSX factory callees emitted by the MDX/React compilers. */
|
|
83
|
+
const JSX_CALLEES = new Set(['_jsx', '_jsxs', '_jsxDEV']);
|
|
84
|
+
|
|
85
|
+
/** The identifier/string name of a `_jsx` prop key (`onClick`, `data-v`, …). */
|
|
86
|
+
function propKeyName(prop: Property): string | undefined {
|
|
87
|
+
const key = prop.key;
|
|
88
|
+
// A computed key (`{[expr]: fn}`) has no statically-known name, so it cannot
|
|
89
|
+
// be classified — left in place rather than guessed at.
|
|
90
|
+
if (prop.computed) return undefined;
|
|
91
|
+
if (key.type === 'Identifier') return key.name;
|
|
92
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* recma plugin — drops `on*` properties from every compiled `_jsx(tag, {…})`
|
|
98
|
+
* props object.
|
|
99
|
+
*
|
|
100
|
+
* Runs AFTER `recmaCompoundComponents` (so elements that plugin synthesizes are
|
|
101
|
+
* also cleaned) and BEFORE `recmaGuardExpressions` (no point wrapping an
|
|
102
|
+
* expression that is about to be deleted).
|
|
103
|
+
*/
|
|
104
|
+
export function recmaStripEventHandlers() {
|
|
105
|
+
return (tree: Program) => {
|
|
106
|
+
visit(tree, (node) => {
|
|
107
|
+
if (
|
|
108
|
+
node.type !== 'CallExpression' ||
|
|
109
|
+
node.callee.type !== 'Identifier' ||
|
|
110
|
+
!JSX_CALLEES.has(node.callee.name)
|
|
111
|
+
) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const props = node.arguments[1];
|
|
115
|
+
if (!props || props.type !== 'ObjectExpression') return;
|
|
116
|
+
|
|
117
|
+
props.properties = props.properties.filter((prop) => {
|
|
118
|
+
// A SpreadElement here is an identifier spread (`{...handlers}`) — an
|
|
119
|
+
// object-literal spread was already flattened into this same properties
|
|
120
|
+
// list by the MDX compiler. Structural, so keep it (RESIDUAL SCOPE 1).
|
|
121
|
+
if (prop.type !== 'Property') return true;
|
|
122
|
+
const name = propKeyName(prop);
|
|
123
|
+
return !name || !isEventHandlerProp(name);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Babel plugin — drops `on*` JSX attributes before the JSX transform.
|
|
131
|
+
*
|
|
132
|
+
* Coexists with `onClickToHrefPlugin` in `transpileJsx`, which rewrites
|
|
133
|
+
* `<span onClick={() => window.open(url, '_self')}>` into `<a href>` to preserve
|
|
134
|
+
* the author's navigation intent. That rewrite always wins, and NOT because of
|
|
135
|
+
* plugin array order: it visits `JSXElement` while this visits the child
|
|
136
|
+
* `JSXOpeningElement`, and Babel reaches a parent before its child. Whatever it
|
|
137
|
+
* does not claim is stripped here instead of 500-ing. It is still listed after
|
|
138
|
+
* that plugin, so the guarantee survives if this ever moves to a `JSXElement`
|
|
139
|
+
* visitor.
|
|
140
|
+
*/
|
|
141
|
+
export function babelStripEventHandlers() {
|
|
142
|
+
return {
|
|
143
|
+
name: 'jd-strip-event-handlers',
|
|
144
|
+
visitor: {
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
+
JSXOpeningElement(path: any) {
|
|
147
|
+
path.node.attributes = path.node.attributes.filter(
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
149
|
+
(attr: any) =>
|
|
150
|
+
!(
|
|
151
|
+
attr.type === 'JSXAttribute' &&
|
|
152
|
+
attr.name?.type === 'JSXIdentifier' &&
|
|
153
|
+
isEventHandlerProp(attr.name.name)
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { stripFencedBlocks } from './preprocess-mdx';
|
|
2
|
+
import { logger } from '../shared/logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* user-utility-css — generates CSS at render time for the Tailwind
|
|
6
|
+
* ARBITRARY-VALUE classes an author writes in their own MDX.
|
|
7
|
+
*
|
|
8
|
+
* WHY this is needed, and why only arbitrary values:
|
|
9
|
+
* Tailwind generates CSS for the class names it can see when the ISR app is
|
|
10
|
+
* BUILT. Customer MDX lives in R2 and is rendered long afterwards, so Tailwind
|
|
11
|
+
* never sees it. Standard utilities survive that gap by luck — `px-4`,
|
|
12
|
+
* `rounded-md`, `text-black` and friends are already in the bundle because a
|
|
13
|
+
* platform component happens to use them, and every standard class in the
|
|
14
|
+
* production customer page that prompted this was in fact present. Arbitrary
|
|
15
|
+
* values can NEVER survive it: `bg-[#ED8200]` has an infinite value space, so
|
|
16
|
+
* nothing can pre-generate it. That page rendered a `<button>` with padding and
|
|
17
|
+
* rounded corners and no background — it read as plain text.
|
|
18
|
+
*
|
|
19
|
+
* So this deliberately handles arbitrary values ONLY. That is not a shortcut:
|
|
20
|
+
* the utilities-only compiler entry used here provably CANNOT emit theme-backed
|
|
21
|
+
* utilities (`px-4` needs `--spacing`, `text-red-500` needs the color palette),
|
|
22
|
+
* and feeding it those candidates would burn work to produce nothing. Closing
|
|
23
|
+
* the standard-utility half is a different job — a build-time safelist — and is
|
|
24
|
+
* not needed while the bundle already covers them.
|
|
25
|
+
*
|
|
26
|
+
* WHY the output is safe to inject:
|
|
27
|
+
* Tailwind wraps its own output in `@layer utilities`, so a generated rule lands
|
|
28
|
+
* in exactly the layer it would have occupied had the build seen it. That
|
|
29
|
+
* matters here specifically: `app/globals.css` imports `themes/base.css`
|
|
30
|
+
* UNLAYERED, and unlayered rules beat layered ones regardless of specificity
|
|
31
|
+
* (see `components/mdx/markdown-classes.ts`). Emitting these rules unlayered
|
|
32
|
+
* would let an author's class silently outrank theme CSS. It does not.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The whole stylesheet this compiles. Deliberately a literal with no `@import`:
|
|
37
|
+
* the compiler then never touches the filesystem, so nothing depends on
|
|
38
|
+
* Next.js tracing `node_modules/tailwindcss/*.css` into the serverless bundle
|
|
39
|
+
* (it traces JS imports, and a runtime `fs.readFile` is invisible to it — this
|
|
40
|
+
* would have failed in production and worked locally).
|
|
41
|
+
*
|
|
42
|
+
* The `@layer utilities` wrapper is ours rather than Tailwind's, and it is
|
|
43
|
+
* load-bearing — see the note on cascade layers in the file header.
|
|
44
|
+
*
|
|
45
|
+
* The `@theme` block declares ONLY breakpoints. Without it a responsive
|
|
46
|
+
* arbitrary value (`md:bg-[#ED8200]`) compiles to nothing and renders unstyled —
|
|
47
|
+
* failing exactly as silently as the bug this file exists to fix, since the
|
|
48
|
+
* variant needs `--breakpoint-*` to build its media query. Verified that adding
|
|
49
|
+
* it leaks no `:root` custom properties and no preflight into the output.
|
|
50
|
+
*
|
|
51
|
+
* These are Tailwind's defaults, duplicated here because the compiler gets no
|
|
52
|
+
* config. `tailwind.config.ts` does not override `screens`, and a test pins
|
|
53
|
+
* that — if it ever does, these must change with it.
|
|
54
|
+
*
|
|
55
|
+
* `@custom-variant dark` is the same problem, and it bites HARDER than the
|
|
56
|
+
* breakpoints did. `tailwind.config.ts` sets `darkMode: 'class'`, but the
|
|
57
|
+
* runtime compiler gets no config and so falls back to Tailwind's default
|
|
58
|
+
* `@media (prefers-color-scheme: dark)`. That does not merely fail to apply —
|
|
59
|
+
* it applies at the WRONG TIME: on the reader's OS preference rather than the
|
|
60
|
+
* site's theme toggle, so `dark:bg-[#0b0b0b]` would paint a dark background
|
|
61
|
+
* under light-theme text. Unlike every other gap in this file, that is worse
|
|
62
|
+
* than generating nothing. The selector below is byte-identical to what the
|
|
63
|
+
* build-time bundle emits (`dark\:hidden:is(.dark *)`), and a test pins the
|
|
64
|
+
* `darkMode` setting it mirrors.
|
|
65
|
+
*
|
|
66
|
+
* Nothing else from the theme is pulled in, which is why only arbitrary values
|
|
67
|
+
* can be generated.
|
|
68
|
+
*/
|
|
69
|
+
const UTILITIES_ENTRY = [
|
|
70
|
+
'@custom-variant dark (&:is(.dark *));',
|
|
71
|
+
'@theme {',
|
|
72
|
+
' --breakpoint-sm: 40rem;',
|
|
73
|
+
' --breakpoint-md: 48rem;',
|
|
74
|
+
' --breakpoint-lg: 64rem;',
|
|
75
|
+
' --breakpoint-xl: 80rem;',
|
|
76
|
+
' --breakpoint-2xl: 96rem;',
|
|
77
|
+
'}',
|
|
78
|
+
'@layer utilities { @tailwind utilities; }',
|
|
79
|
+
].join('\n');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Class attributes in author MDX/JSX. Covers `class="…"`, `className="…"`,
|
|
83
|
+
* `className='…'` and `className={"…"}` / `{'…'}` / {`…`}. A className built
|
|
84
|
+
* from an expression is out of reach and is left alone — an author writing
|
|
85
|
+
* computed classes is already outside what a docs page can round-trip.
|
|
86
|
+
*
|
|
87
|
+
* Values deliberately allow newlines, because a formatted JSX `className`
|
|
88
|
+
* legitimately wraps across lines. The cost is that one unbalanced `class="` in
|
|
89
|
+
* prose would swallow everything up to the next quote anywhere in the document.
|
|
90
|
+
* That is bounded by truncating the captured VALUE (see MAX_ATTR_VALUE_LENGTH)
|
|
91
|
+
* rather than by bounding the regex: a bounded quantifier makes an over-long
|
|
92
|
+
* attribute fail to match AT ALL, so a page with a very long class list would
|
|
93
|
+
* silently get no styling instead of most of it.
|
|
94
|
+
*/
|
|
95
|
+
const CLASS_ATTR =
|
|
96
|
+
/(?<![-\w])class(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*[`'"]([^`'"]*)[`'"]\s*\})/g;
|
|
97
|
+
|
|
98
|
+
/** Caps: a page far past these is pathological, not authored. */
|
|
99
|
+
export const MAX_UTILITY_CANDIDATES = 500;
|
|
100
|
+
const MAX_CANDIDATE_LENGTH = 120;
|
|
101
|
+
/**
|
|
102
|
+
* How much of one class attribute's value is scanned. Bounds a runaway match
|
|
103
|
+
* from an unbalanced quote while still letting a long-but-real class list
|
|
104
|
+
* contribute its leading classes — see the note on CLASS_ATTR.
|
|
105
|
+
*/
|
|
106
|
+
const MAX_ATTR_VALUE_LENGTH = 2000;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A candidate must contain `[` to be worth compiling (see the file header), and
|
|
110
|
+
* must not carry characters that could break out of the `<style>` element it is
|
|
111
|
+
* injected into. Tailwind escapes its selectors and would reject these anyway,
|
|
112
|
+
* but rejecting them here means the guarantee does not depend on that.
|
|
113
|
+
*/
|
|
114
|
+
function isCompilableCandidate(token: string): boolean {
|
|
115
|
+
if (token.length > MAX_CANDIDATE_LENGTH) return false;
|
|
116
|
+
if (!token.includes('[')) return false;
|
|
117
|
+
return !/[<>"'`\s]/.test(token);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pull arbitrary-value Tailwind candidates out of raw author source.
|
|
122
|
+
* Returns them sorted and de-duplicated so the cache key is stable.
|
|
123
|
+
*/
|
|
124
|
+
export function extractUtilityCandidates(source: string): string[] {
|
|
125
|
+
if (!source) return [];
|
|
126
|
+
const found = new Set<string>();
|
|
127
|
+
|
|
128
|
+
// A ```html fence demonstrating `class="bg-[#fff]"` is documentation, not
|
|
129
|
+
// markup to style. Uses the same strict fence regex the snippet-import
|
|
130
|
+
// scanner uses, so the two cannot disagree about what a code block is.
|
|
131
|
+
const scannable = stripFencedBlocks(source);
|
|
132
|
+
|
|
133
|
+
for (const match of scannable.matchAll(CLASS_ATTR)) {
|
|
134
|
+
const value = match[1] ?? match[2] ?? match[3];
|
|
135
|
+
if (!value) continue;
|
|
136
|
+
for (const token of value.slice(0, MAX_ATTR_VALUE_LENGTH).split(/\s+/)) {
|
|
137
|
+
if (isCompilableCandidate(token)) found.add(token);
|
|
138
|
+
if (found.size >= MAX_UTILITY_CANDIDATES) break;
|
|
139
|
+
}
|
|
140
|
+
if (found.size >= MAX_UTILITY_CANDIDATES) break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...found].sort();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The entry above has no `@import`, `@plugin` or `@config`, so neither of the
|
|
148
|
+
* loaders Tailwind accepts is reachable. Both get this one, which throws rather
|
|
149
|
+
* than returning something empty so that a future edit which reintroduces a
|
|
150
|
+
* filesystem dependency fails loudly here instead of silently in production.
|
|
151
|
+
*/
|
|
152
|
+
async function refuseFilesystemLoad(): Promise<never> {
|
|
153
|
+
throw new Error('user-utility-css: the utilities entry must not load from disk');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Compiled-CSS cache, keyed by the exact candidate set.
|
|
158
|
+
*
|
|
159
|
+
* A Tailwind compiler ACCUMULATES candidates across `build()` calls — a second
|
|
160
|
+
* call still emits the first call's rules. Reusing one compiler across renders
|
|
161
|
+
* would therefore grow without bound and leak one project's classes into
|
|
162
|
+
* another project's page. So each distinct candidate set gets a FRESH compiler
|
|
163
|
+
* (~3ms) and only the finished CSS is cached.
|
|
164
|
+
*/
|
|
165
|
+
const cssCache = new Map<string, string>();
|
|
166
|
+
const MAX_CACHE_ENTRIES = 500;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build the extra stylesheet for a page's arbitrary-value classes.
|
|
170
|
+
* Returns '' when there is nothing to generate.
|
|
171
|
+
*/
|
|
172
|
+
export async function buildUserUtilityCss(
|
|
173
|
+
candidates: string[]
|
|
174
|
+
): Promise<string> {
|
|
175
|
+
if (candidates.length === 0) return '';
|
|
176
|
+
|
|
177
|
+
const key = candidates.join(' ');
|
|
178
|
+
const cached = cssCache.get(key);
|
|
179
|
+
if (cached !== undefined) return cached;
|
|
180
|
+
|
|
181
|
+
let css = '';
|
|
182
|
+
try {
|
|
183
|
+
// Imported lazily on purpose. Docs routes are force-dynamic, so a static
|
|
184
|
+
// import would pay Tailwind's ~23ms module load on EVERY cold start —
|
|
185
|
+
// including the majority of pages that carry no arbitrary-value class at
|
|
186
|
+
// all. The empty-candidates early return above means reaching this line
|
|
187
|
+
// already means there is real work to do.
|
|
188
|
+
const { compile } = await import('tailwindcss');
|
|
189
|
+
const compiler = await compile(UTILITIES_ENTRY, {
|
|
190
|
+
// Only ever used to resolve relative @imports, of which the entry has
|
|
191
|
+
// none. Pinned rather than process.cwd() so this cannot acquire a
|
|
192
|
+
// dependency on where the server happens to be started from.
|
|
193
|
+
base: '/',
|
|
194
|
+
loadStylesheet: refuseFilesystemLoad,
|
|
195
|
+
loadModule: refuseFilesystemLoad,
|
|
196
|
+
});
|
|
197
|
+
css = compiler.build(candidates);
|
|
198
|
+
// When nothing matched, Tailwind still emits a banner plus a bare
|
|
199
|
+
// `@layer utilities;` DECLARATION (semicolon, no block). Testing for the
|
|
200
|
+
// string '@layer' would pass on that and ship an empty <style>; the brace
|
|
201
|
+
// is what distinguishes a real rule block.
|
|
202
|
+
if (!/@layer[^;{]*\{/.test(css)) {
|
|
203
|
+
// Candidates existed but Tailwind matched none of them. Logged because
|
|
204
|
+
// this is otherwise INDISTINGUISHABLE from a page that simply has no
|
|
205
|
+
// arbitrary classes — both are an empty string and no log line. That is
|
|
206
|
+
// precisely how the responsive-variant gap (`md:bg-[#ED8200]` emitting
|
|
207
|
+
// nothing without `--breakpoint-*`) hid: the failure state was an
|
|
208
|
+
// ABSENCE of output, so nothing could alert on it. A future Tailwind
|
|
209
|
+
// upgrade or a new variant form that stops compiling now says so.
|
|
210
|
+
logger.warn('[user-utility-css] candidates matched no utilities', {
|
|
211
|
+
count: candidates.length,
|
|
212
|
+
sample: candidates.slice(0, 5),
|
|
213
|
+
});
|
|
214
|
+
css = '';
|
|
215
|
+
}
|
|
216
|
+
// Defence in depth for the `<style>` element this is injected into.
|
|
217
|
+
// `isCompilableCandidate` already rejects `<`, `>` and quotes, and Tailwind
|
|
218
|
+
// escapes what it emits — so reaching this branch should be impossible.
|
|
219
|
+
// Dropping the whole sheet rather than patching it keeps that an assertion
|
|
220
|
+
// instead of a sanitizer: a cosmetic loss, never a broken page.
|
|
221
|
+
if (/<\/|<!/.test(css)) {
|
|
222
|
+
logger.error('[user-utility-css] generated CSS contained markup; discarding');
|
|
223
|
+
css = '';
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
// Never let author styling break a page render — a missing background is a
|
|
227
|
+
// cosmetic loss, a throw here would be a 500.
|
|
228
|
+
logger.error('[user-utility-css] failed to compile author utilities', {
|
|
229
|
+
error: error instanceof Error ? error.message : String(error),
|
|
230
|
+
});
|
|
231
|
+
// Deliberately NOT cached. A thrown compile is the one outcome here that
|
|
232
|
+
// can be transient, and caching it would pin this page unstyled for the
|
|
233
|
+
// life of the process — silently, because the cache is checked before the
|
|
234
|
+
// logger, so it would never complain again. The empty result from the
|
|
235
|
+
// brace test above IS cached: that one is deterministic.
|
|
236
|
+
return '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (cssCache.size >= MAX_CACHE_ENTRIES) {
|
|
240
|
+
const oldest = cssCache.keys().next().value;
|
|
241
|
+
if (oldest !== undefined) cssCache.delete(oldest);
|
|
242
|
+
}
|
|
243
|
+
cssCache.set(key, css);
|
|
244
|
+
return css;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Test seam — the cache is process-global and would otherwise leak between tests. */
|
|
248
|
+
export function __clearUserUtilityCssCache(): void {
|
|
249
|
+
cssCache.clear();
|
|
250
|
+
}
|