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.
Files changed (30) 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/openapi-isr.ts +57 -42
  23. package/vendored/lib/preprocess-mdx.ts +19 -1
  24. package/vendored/lib/render-doc-page.tsx +30 -6
  25. package/vendored/lib/risky-expression-scanner.ts +60 -0
  26. package/vendored/lib/snippet-compiler-isr.ts +11 -1
  27. package/vendored/lib/snippet-loader-isr.ts +95 -2
  28. package/vendored/lib/strip-event-handlers.ts +159 -0
  29. package/vendored/lib/user-utility-css.ts +250 -0
  30. 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`. So the spec is the same warm parse the render already
392
- // did: the marginal cost of this lookup is a cache hit, never an uncached
393
- // fetch+parse. That matters because docs routes are force-dynamic, so this
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
- 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
+ }