jamdesk 1.1.200 → 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/openapi-isr.ts +57 -42
- package/vendored/lib/preprocess-mdx.ts +19 -1
- package/vendored/lib/render-doc-page.tsx +30 -6
- 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 +84 -57
|
@@ -49,7 +49,8 @@ import { computeHasCodePanels } from './render-doc-page-helpers';
|
|
|
49
49
|
import { logger } from '../shared/logger';
|
|
50
50
|
import { preprocessMdx, containsPanel, containsView, buildSnippetAliasMap } from '@/lib/preprocess-mdx';
|
|
51
51
|
import { injectPromptSources } from '@/lib/inject-prompt-source';
|
|
52
|
-
import { loadSnippetsForIsr } from '@/lib/snippet-loader-isr';
|
|
52
|
+
import { loadSnippetsForIsr, collectPageUtilityCandidates } from '@/lib/snippet-loader-isr';
|
|
53
|
+
import { buildUserUtilityCss } from '@/lib/user-utility-css';
|
|
53
54
|
import { PanelWrapper } from '@/components/mdx/PanelWrapper';
|
|
54
55
|
import { ViewWrapper } from '@/components/mdx/View';
|
|
55
56
|
import { getLatexRemarkPlugins, getLatexRehypePlugins } from '@/lib/latex-config';
|
|
@@ -58,6 +59,7 @@ import { remarkVisibility } from '@/lib/remark-visibility';
|
|
|
58
59
|
import { recmaCompoundComponents } from '@/lib/recma-compound-components';
|
|
59
60
|
import { recmaCollectMissingRefs, type MissingRefCollector } from '@/lib/recma-collect-missing-refs';
|
|
60
61
|
import { recmaGuardExpressions } from '@/lib/recma-guard-expressions';
|
|
62
|
+
import { recmaStripEventHandlers } from '@/lib/strip-event-handlers';
|
|
61
63
|
import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
|
|
62
64
|
import { extractHeadings } from '@/lib/heading-extractor';
|
|
63
65
|
import { StepSlugProvider, type StepSlugEntry } from '@/components/mdx/StepSlugContext';
|
|
@@ -388,9 +390,12 @@ export async function resolveDerivedDescription(
|
|
|
388
390
|
// The spec loader REUSES the render path's cached loaders — the ISR module
|
|
389
391
|
// cache (`r2:${slug}:${specPath}`, 10-min TTL) that `renderDocPage`
|
|
390
392
|
// populates when it renders the page's <ApiEndpoint>, or the static
|
|
391
|
-
// `getCachedSpec`.
|
|
392
|
-
//
|
|
393
|
-
//
|
|
393
|
+
// `getCachedSpec`. That cache stores the in-flight PROMISE, so this lookup
|
|
394
|
+
// joins the render's load rather than starting a second one — Next.js runs
|
|
395
|
+
// `generateMetadata` and the page component for the same request, and a
|
|
396
|
+
// value-cache populated only after the await let BOTH miss and both pay a
|
|
397
|
+
// full R2 fetch plus a full SwaggerParser.dereference. See openapi-isr.ts
|
|
398
|
+
// `cachedSpec`. That matters because docs routes are force-dynamic, so this
|
|
394
399
|
// metadata runs on EVERY page view (every human + crawler). Mirrors the
|
|
395
400
|
// `useIsr`/`projectDir` derivation in `renderDocPage`'s OpenAPI branch.
|
|
396
401
|
const specPaths = collectLocalSpecPaths(config.api.openapi);
|
|
@@ -711,6 +716,22 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
711
716
|
});
|
|
712
717
|
const snippetInlineMs = Math.round(performance.now() - snippetInlineStart);
|
|
713
718
|
|
|
719
|
+
// Tailwind never saw this project's MDX when the app was built, so an
|
|
720
|
+
// author's arbitrary-value class (`bg-[#ED8200]`) has no rule behind it and
|
|
721
|
+
// the element renders unstyled. Generate just those rules now. Runs AFTER the
|
|
722
|
+
// snippet load above so every snippet is already a cache hit — and it must
|
|
723
|
+
// include snippets, because that is where authors put the markup that carries
|
|
724
|
+
// these classes — but only in ISR, where snippet bodies actually live in R2.
|
|
725
|
+
// See lib/user-utility-css.ts.
|
|
726
|
+
const userUtilityCss = projectSlug
|
|
727
|
+
? await buildUserUtilityCss(
|
|
728
|
+
await collectPageUtilityCandidates(projectSlug, rawContent, MDXComponents, isIsr),
|
|
729
|
+
)
|
|
730
|
+
: '';
|
|
731
|
+
const userUtilityStyle = userUtilityCss
|
|
732
|
+
? <style data-jd-user-utilities="" dangerouslySetInnerHTML={{ __html: userUtilityCss }} />
|
|
733
|
+
: null;
|
|
734
|
+
|
|
714
735
|
const overriddenComponents = Object.keys(inlineComponents).filter(
|
|
715
736
|
(name) => name in MDXComponents,
|
|
716
737
|
);
|
|
@@ -919,6 +940,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
919
940
|
recmaPlugins: [
|
|
920
941
|
recmaCompoundComponents,
|
|
921
942
|
recmaCollectMissingRefs(missingRefCollector),
|
|
943
|
+
recmaStripEventHandlers,
|
|
922
944
|
recmaGuardExpressions,
|
|
923
945
|
],
|
|
924
946
|
},
|
|
@@ -1082,6 +1104,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1082
1104
|
<>{jsonLdScript}<ApiPageWrapper hasCodePanels={hasCodePanels}>
|
|
1083
1105
|
{wrap(
|
|
1084
1106
|
<article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10 flex-1 min-w-0">
|
|
1107
|
+
{userUtilityStyle}
|
|
1085
1108
|
<Breadcrumb slug={slug} config={config} hidden={embed} />
|
|
1086
1109
|
{translationNotice}
|
|
1087
1110
|
|
|
@@ -1164,7 +1187,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1164
1187
|
...mdxSecurityOptions,
|
|
1165
1188
|
mdxOptions: {
|
|
1166
1189
|
...getCommonMdxOptions(config, highlighter),
|
|
1167
|
-
recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
|
|
1190
|
+
recmaPlugins: [recmaCompoundComponents, recmaStripEventHandlers, recmaGuardExpressions],
|
|
1168
1191
|
},
|
|
1169
1192
|
}}
|
|
1170
1193
|
/>
|
|
@@ -1198,7 +1221,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1198
1221
|
...mdxSecurityOptions,
|
|
1199
1222
|
mdxOptions: {
|
|
1200
1223
|
...getCommonMdxOptions(config, highlighter),
|
|
1201
|
-
recmaPlugins: [recmaCompoundComponents, recmaGuardExpressions],
|
|
1224
|
+
recmaPlugins: [recmaCompoundComponents, recmaStripEventHandlers, recmaGuardExpressions],
|
|
1202
1225
|
},
|
|
1203
1226
|
}}
|
|
1204
1227
|
/>
|
|
@@ -1210,6 +1233,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
1210
1233
|
|
|
1211
1234
|
const articleContent = wrap(
|
|
1212
1235
|
<article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
|
|
1236
|
+
{userUtilityStyle}
|
|
1213
1237
|
<Breadcrumb slug={slug} config={config} hidden={embed} />
|
|
1214
1238
|
{translationNotice}
|
|
1215
1239
|
|
|
@@ -29,6 +29,22 @@ import { visit as visitMdast } from 'unist-util-visit';
|
|
|
29
29
|
import { visit as visitEstree } from 'estree-util-visit';
|
|
30
30
|
import type { Node as EsNode } from 'estree-jsx';
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Event-handler prop test — a DELIBERATE copy of the canonical one in
|
|
34
|
+
* `lib/strip-event-handlers.ts`, kept in sync by
|
|
35
|
+
* `__tests__/lib/event-handler-predicate-parity.test.ts`.
|
|
36
|
+
*
|
|
37
|
+
* Not imported, because this file is vendored standalone into `cli/src/lib/`
|
|
38
|
+
* (see cli/scripts/vendor.js) where no sibling module exists and the CLI's
|
|
39
|
+
* `node16` resolution would reject the extensionless path. Every other
|
|
40
|
+
* build-service file synced there is self-contained for the same reason —
|
|
41
|
+
* adding a second file to that chain would mean touching vendor.js,
|
|
42
|
+
* verify-shared-sync.sh and the drift test for one regex.
|
|
43
|
+
*/
|
|
44
|
+
function isEventHandlerProp(name: string): boolean {
|
|
45
|
+
return /^on[A-Z]/.test(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
/** A single risky MDX expression found on a page. */
|
|
33
49
|
export interface RiskyExpressionIssue {
|
|
34
50
|
/** The expression source between the braces, e.g. `"x, y"`. */
|
|
@@ -254,5 +270,49 @@ export function findRiskyExpressions(content: string, pagePath: string): RiskyEx
|
|
|
254
270
|
});
|
|
255
271
|
});
|
|
256
272
|
|
|
273
|
+
// Pass 3: author event handlers (`onClick={…}`, `onSubmit={…}`). These are
|
|
274
|
+
// removed at compile time by `recmaStripEventHandlers` /
|
|
275
|
+
// `babelStripEventHandlers` — a Server Component cannot pass a function across
|
|
276
|
+
// the RSC boundary, and before the strip existed such a prop returned HTTP 500
|
|
277
|
+
// from the serializer (see lib/strip-event-handlers.ts).
|
|
278
|
+
//
|
|
279
|
+
// Unlike pass 2 this needs no scope modelling and has no precision tradeoff:
|
|
280
|
+
// the name alone decides it, and the prop is ALWAYS dropped, so the warning is
|
|
281
|
+
// never a false positive.
|
|
282
|
+
const jsxElementTypes = new Set(['mdxJsxFlowElement', 'mdxJsxTextElement']);
|
|
283
|
+
visitMdast(
|
|
284
|
+
tree as never,
|
|
285
|
+
(node: {
|
|
286
|
+
type: string;
|
|
287
|
+
name?: string | null;
|
|
288
|
+
attributes?: Array<{ type: string; name?: string }>;
|
|
289
|
+
}) => {
|
|
290
|
+
if (!jsxElementTypes.has(node.type) || !node.attributes) return;
|
|
291
|
+
for (const attr of node.attributes) {
|
|
292
|
+
// `mdxJsxExpressionAttribute` is a spread (`{...props}`) — no static
|
|
293
|
+
// name, so it is neither strippable nor warnable.
|
|
294
|
+
if (attr.type !== 'mdxJsxAttribute' || !attr.name) continue;
|
|
295
|
+
if (!isEventHandlerProp(attr.name)) continue;
|
|
296
|
+
const el = node.name ? `<${node.name}>` : 'an element';
|
|
297
|
+
issues.push({
|
|
298
|
+
expression: attr.name,
|
|
299
|
+
undefinedRefs: [],
|
|
300
|
+
// Deliberately does NOT suggest a `'use client'` snippet. Snippets are
|
|
301
|
+
// compiled by the same server pipeline (and `isClientComponent` is
|
|
302
|
+
// computed but never read), so the strip applies there too — the
|
|
303
|
+
// handler would be just as dead, only silently. It DOES still work
|
|
304
|
+
// under `jamdesk dev`, which makes that the worst possible advice:
|
|
305
|
+
// works locally, inert in production.
|
|
306
|
+
message:
|
|
307
|
+
`\`${attr.name}\` on ${el} in \`${pagePath}\` was removed. ` +
|
|
308
|
+
`Documentation pages render on the server, where React cannot pass ` +
|
|
309
|
+
`event handlers, so the handler never ran. Use a link, or one of the ` +
|
|
310
|
+
`built-in interactive components — a custom handler cannot run on a ` +
|
|
311
|
+
`hosted documentation page, including inside a snippet.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
257
317
|
return issues;
|
|
258
318
|
}
|
|
@@ -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
|
+
}
|