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.
Files changed (29) hide show
  1. package/dist/__tests__/unit/migrate-risky-expression-warning.test.d.ts +2 -0
  2. package/dist/__tests__/unit/migrate-risky-expression-warning.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/migrate-risky-expression-warning.test.js +47 -0
  4. package/dist/__tests__/unit/migrate-risky-expression-warning.test.js.map +1 -0
  5. package/dist/commands/migrate/convert-mdx.d.ts.map +1 -1
  6. package/dist/commands/migrate/convert-mdx.js +5 -1
  7. package/dist/commands/migrate/convert-mdx.js.map +1 -1
  8. package/dist/commands/validate.d.ts.map +1 -1
  9. package/dist/commands/validate.js +7 -1
  10. package/dist/commands/validate.js.map +1 -1
  11. package/dist/lib/risky-expression-scanner.d.ts.map +1 -1
  12. package/dist/lib/risky-expression-scanner.js +53 -0
  13. package/dist/lib/risky-expression-scanner.js.map +1 -1
  14. package/dist/lib/validate-risky-expressions.d.ts +6 -0
  15. package/dist/lib/validate-risky-expressions.d.ts.map +1 -1
  16. package/dist/lib/validate-risky-expressions.js +5 -1
  17. package/dist/lib/validate-risky-expressions.js.map +1 -1
  18. package/package.json +1 -1
  19. package/vendored/app/globals.css +19 -0
  20. package/vendored/components/theme/ThemeToggle.tsx +4 -1
  21. package/vendored/lib/mdx-inline-components.ts +4 -0
  22. package/vendored/lib/preprocess-mdx.ts +19 -1
  23. package/vendored/lib/render-doc-page.tsx +24 -3
  24. package/vendored/lib/risky-expression-scanner.ts +60 -0
  25. package/vendored/lib/snippet-compiler-isr.ts +11 -1
  26. package/vendored/lib/snippet-loader-isr.ts +95 -2
  27. package/vendored/lib/strip-event-handlers.ts +159 -0
  28. package/vendored/lib/user-utility-css.ts +250 -0
  29. 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
- plugins: [['transform-react-jsx', { runtime: 'automatic' }]],
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
- mdxOptions: { remarkPlugins: [remarkSvgNamespaceAttrs, remarkStyleStringToObject] },
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 = { exports, isClientComponent };
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
+ }