jamdesk 1.1.201 → 1.1.203

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 +57 -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 +250 -0
  28. package/vendored/lib/user-utility-css.ts +250 -0
  29. package/vendored/workspace-package-lock.json +49 -49
@@ -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,250 @@
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
+ /**
83
+ * Attributes that only mean something on a `<form>`. When a form is downgraded
84
+ * to a `<div>` (see `neutralizeDeadForm`) these would become invalid DOM
85
+ * attributes and draw React warnings, so they go with it.
86
+ */
87
+ const FORM_ONLY_PROPS = new Set([
88
+ 'method',
89
+ 'encType',
90
+ 'target',
91
+ 'noValidate',
92
+ 'acceptCharset',
93
+ ]);
94
+
95
+ /**
96
+ * A `<form>` whose only submit path was an `on*` handler we just stripped, and
97
+ * which has no `action`, is downgraded to a `<div>`.
98
+ *
99
+ * WHY, and why the tag rather than the submit button:
100
+ * with the handler gone the browser falls back to NATIVE submission, and with
101
+ * no `action` that targets the current URL — so a reader who fills the form in
102
+ * is navigated to `?<field>=<whatever they typed>`, the page reloads, their
103
+ * input goes nowhere, and their free text is left in the URL, in history, and
104
+ * in CDN logs. That is strictly worse than the form doing nothing.
105
+ *
106
+ * Observed in production: a customer's feedback snippet POSTed JSON to their
107
+ * own Lambda from `onSubmit`, whose FIRST statement was `e.preventDefault()`.
108
+ * Stripping the handler removed the very thing suppressing the native submit.
109
+ *
110
+ * Neutralizing the submit BUTTON would not close it. HTML implicit submission
111
+ * fires on Enter in a form with a single text field whether or not a submit
112
+ * button exists, so the form element itself has to go. Children render exactly
113
+ * as before; only the submission path disappears.
114
+ *
115
+ * Deliberately narrow: it fires only when a handler was actually removed AND
116
+ * there is no `action`. A form with an `action` still works natively and is
117
+ * left alone, and a form that never had a handler is not this bug.
118
+ */
119
+
120
+ /** JSX factory callees emitted by the MDX/React compilers. */
121
+ const JSX_CALLEES = new Set(['_jsx', '_jsxs', '_jsxDEV']);
122
+
123
+ /** The identifier/string name of a `_jsx` prop key (`onClick`, `data-v`, …). */
124
+ function propKeyName(prop: Property): string | undefined {
125
+ const key = prop.key;
126
+ // A computed key (`{[expr]: fn}`) has no statically-known name, so it cannot
127
+ // be classified — left in place rather than guessed at.
128
+ if (prop.computed) return undefined;
129
+ if (key.type === 'Identifier') return key.name;
130
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
131
+ return undefined;
132
+ }
133
+
134
+ /**
135
+ * recma plugin — drops `on*` properties from every compiled `_jsx(tag, {…})`
136
+ * props object.
137
+ *
138
+ * Runs AFTER `recmaCompoundComponents` (so elements that plugin synthesizes are
139
+ * also cleaned) and BEFORE `recmaGuardExpressions` (no point wrapping an
140
+ * expression that is about to be deleted).
141
+ */
142
+ export function recmaStripEventHandlers() {
143
+ return (tree: Program) => {
144
+ visit(tree, (node) => {
145
+ if (
146
+ node.type !== 'CallExpression' ||
147
+ node.callee.type !== 'Identifier' ||
148
+ !JSX_CALLEES.has(node.callee.name)
149
+ ) {
150
+ return;
151
+ }
152
+ const props = node.arguments[1];
153
+ if (!props || props.type !== 'ObjectExpression') return;
154
+
155
+ const tag = node.arguments[0];
156
+ const isForm =
157
+ tag && tag.type === 'Literal' && tag.value === 'form';
158
+ let strippedHandler = false;
159
+ let hasAction = false;
160
+
161
+ props.properties = props.properties.filter((prop) => {
162
+ // A SpreadElement here is an identifier spread (`{...handlers}`) — an
163
+ // object-literal spread was already flattened into this same properties
164
+ // list by the MDX compiler. Structural, so keep it (RESIDUAL SCOPE 1).
165
+ if (prop.type !== 'Property') return true;
166
+ const name = propKeyName(prop);
167
+ if (!name) return true;
168
+ if (name === 'action') hasAction = true;
169
+ if (!isEventHandlerProp(name)) return true;
170
+ strippedHandler = true;
171
+ return false;
172
+ });
173
+
174
+ if (isForm && strippedHandler && !hasAction) {
175
+ (tag as { value: string; raw?: string }).value = 'div';
176
+ delete (tag as { raw?: string }).raw;
177
+ props.properties = props.properties.filter((prop) => {
178
+ if (prop.type !== 'Property') return true;
179
+ const name = propKeyName(prop);
180
+ return !name || !FORM_ONLY_PROPS.has(name);
181
+ });
182
+ }
183
+ });
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Babel plugin — drops `on*` JSX attributes before the JSX transform.
189
+ *
190
+ * Coexists with `onClickToHrefPlugin` in `transpileJsx`, which rewrites
191
+ * `<span onClick={() => window.open(url, '_self')}>` into `<a href>` to preserve
192
+ * the author's navigation intent. That rewrite always wins, and NOT because of
193
+ * plugin array order: it visits `JSXElement` while this visits the child
194
+ * `JSXOpeningElement`, and Babel reaches a parent before its child. Whatever it
195
+ * does not claim is stripped here instead of 500-ing. It is still listed after
196
+ * that plugin, so the guarantee survives if this ever moves to a `JSXElement`
197
+ * visitor.
198
+ */
199
+ export function babelStripEventHandlers() {
200
+ return {
201
+ name: 'jd-strip-event-handlers',
202
+ visitor: {
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ JSXOpeningElement(path: any) {
205
+ const isForm =
206
+ path.node.name?.type === 'JSXIdentifier' &&
207
+ path.node.name.name === 'form';
208
+ let strippedHandler = false;
209
+ let hasAction = false;
210
+
211
+ path.node.attributes = path.node.attributes.filter(
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ (attr: any) => {
214
+ if (
215
+ attr.type !== 'JSXAttribute' ||
216
+ attr.name?.type !== 'JSXIdentifier'
217
+ ) {
218
+ return true;
219
+ }
220
+ if (attr.name.name === 'action') hasAction = true;
221
+ if (!isEventHandlerProp(attr.name.name)) return true;
222
+ strippedHandler = true;
223
+ return false;
224
+ },
225
+ );
226
+
227
+ if (!isForm || !strippedHandler || hasAction) return;
228
+
229
+ // Downgrade to <div> — see neutralizeDeadForm above. The closing tag
230
+ // is renamed too. Under the `react` preset that lowers JSX to `_jsx`
231
+ // calls this is unobservable (the closing element is discarded), so it
232
+ // reads like dead code — but leaving a mismatched AST would emit
233
+ // `<div>…</form>` for any consumer that preserves JSX, and a test
234
+ // transpiles with JSX preserved specifically to pin it.
235
+ path.node.name.name = 'div';
236
+ const closing = path.parent?.closingElement;
237
+ if (closing?.name?.type === 'JSXIdentifier') closing.name.name = 'div';
238
+ path.node.attributes = path.node.attributes.filter(
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
+ (attr: any) =>
241
+ !(
242
+ attr.type === 'JSXAttribute' &&
243
+ attr.name?.type === 'JSXIdentifier' &&
244
+ FORM_ONLY_PROPS.has(attr.name.name)
245
+ ),
246
+ );
247
+ },
248
+ },
249
+ };
250
+ }