bestax-migrate 2.3.1 → 2.3.3

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.
@@ -0,0 +1,703 @@
1
+ /**
2
+ * bestax's DOM props follow `as`: an element's attributes are the attributes
3
+ * of whatever `as` renders, and a few components narrow `as` to a literal
4
+ * union of the elements Bulma's markup allows there. Every source library
5
+ * this package migrates from is looser — rbx and react-bulma-components take
6
+ * any tag, bloomer took any `tag` — so a faithful prop-for-prop rename emits
7
+ * shapes that do not compile against the library:
8
+ *
9
+ * - an `as` naming an element the bestax component does not offer;
10
+ * - an `href` beside an `as` that is not an `<a>`;
11
+ * - an `href` on a component that declares none at any `as`;
12
+ * - `target` and its siblings on an element, or a component, without them.
13
+ *
14
+ * All are dropped with a TODO rather than carried, because carrying them
15
+ * hands the user a project that does not typecheck, which is the one thing a
16
+ * codemod must not do.
17
+ *
18
+ * What that costs differs by case, and each TODO says so. Dropping an `href`
19
+ * or a `target` costs nothing the source had: they sat on a `<span>` or a
20
+ * `<div>`, where no browser acts on them. Dropping an `as` does change the
21
+ * element — every source here rendered the tag it was given, and bestax
22
+ * renders its own instead — so that TODO names the tag to restore rather than
23
+ * claiming nothing moved.
24
+ *
25
+ * Source-agnostic: what it knows is bestax's side of the rename, so all three
26
+ * sources run it over every element after their prop passes.
27
+ */
28
+ import { addTodo, attrSource, findAttr, literalValueOf, removeAttr, } from './jsx-utils.js';
29
+ /* eslint-disable @typescript-eslint/no-explicit-any */
30
+ /**
31
+ * The bestax components whose `as` is a closed literal union, by the name the
32
+ * mappings use as their `target`. Every other component either takes no `as`
33
+ * (`restrictAsToTargets` handles those) or is generic over `React.ElementType`
34
+ * and accepts any tag.
35
+ *
36
+ * `as-unions.test.ts` holds each row to the library's own type, so a union
37
+ * that widens or narrows in bulma-ui fails here rather than drifting into
38
+ * output that does not compile.
39
+ */
40
+ const AS_UNION_TABLE = {
41
+ Control: ['div', 'p'],
42
+ 'Dropdown.Item': ['a', 'div', 'button'],
43
+ Footer: ['footer', 'div'],
44
+ Image: ['figure', 'div', 'p'],
45
+ 'Level.Item': ['div', 'p', 'a'],
46
+ Media: ['article', 'div'],
47
+ 'Media.Left': ['figure', 'div'],
48
+ SubTitle: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'],
49
+ Title: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'],
50
+ };
51
+ export const AS_UNIONS = AS_UNION_TABLE;
52
+ /**
53
+ * The targets that accept an `as` beyond a narrowed union -- generic over
54
+ * `React.ElementType`, so any tag is valid. Every other target either narrows
55
+ * `as` (the table above) or declares none at all.
56
+ *
57
+ * That last group is why this set has to exist. An earlier pass flags an `as`
58
+ * a component cannot take and leaves the attribute in place as the marker for
59
+ * its TODO, so reading the element off whatever `as` is present would believe
60
+ * a prop the component never had -- and `<Panel.Block as="span" href="/x">`
61
+ * would lose the `href` that compiles and keep the `as` that does not.
62
+ */
63
+ const AS_ANY = new Set(['Button', 'Menu.Item', 'Navbar.Item', 'Navbar.Link']);
64
+ /**
65
+ * Where an `href` can live, by bestax target -- the value is the element the
66
+ * component renders when no `as` is given. An `href` survives only where that
67
+ * element is the anchor, or where `as="a"` names one; a target absent from
68
+ * this table takes no `href` at any `as`, which is most of them.
69
+ *
70
+ * Stated this way round on purpose. The source libraries put an `href` on
71
+ * anything, and a table of what cannot take one is a list nobody can keep
72
+ * complete -- the first version of this rule named four targets and missed
73
+ * `Tabs.Item`, `Media.Left`, `Card.Image` and ninety more. Note the two
74
+ * `Pagination` controls: a source reaches them through a `special`, not a
75
+ * `target:` line, so a table built by reading the mappings alone misses them.
76
+ *
77
+ * The default element is here rather than a bare "takes an href" flag because
78
+ * typechecking cannot answer the question on its own. `Level.Item` declares
79
+ * `href` on its props at every `as`, so `<Level.Item href="/x">` compiles --
80
+ * but it renders a <div> and forwards `href` only when the tag is an `<a>`
81
+ * (`bulma-ui/src/layout/Level.tsx:227`), so the attribute is dropped at
82
+ * runtime. A table built from the types alone calls that supported; it is the
83
+ * same dead attribute this pass exists to remove.
84
+ */
85
+ const HREF_OK = {
86
+ Button: 'button',
87
+ 'Dropdown.Item': 'a',
88
+ 'Level.Item': 'div',
89
+ 'Menu.Item': 'a',
90
+ 'Navbar.Item': 'a',
91
+ 'Navbar.Link': 'a',
92
+ 'Pagination.Link': 'a',
93
+ 'Pagination.Next': 'a',
94
+ 'Pagination.Previous': 'a',
95
+ 'Panel.Block': 'a',
96
+ };
97
+ /**
98
+ * Where "put an <a> inside" is not the useful advice. bloomer's navbar handler
99
+ * already said this for its own source; the other two reached the same target
100
+ * and got the generic line, which is the sibling drift `bestax-migrate`'s
101
+ * CLAUDE.md warns about. Keyed by target, all three get it.
102
+ */
103
+ const NO_HREF_HINT = {
104
+ 'Navbar.Dropdown': 'bestax `Navbar.Dropdown` is the container and takes no `href`; put it on the `<Navbar.Link>` inside',
105
+ // `Delete` renders a self-closing <button/> with no `{children}` at all, so
106
+ // the generic "put an <a> inside it" is advice nobody can follow. bloomer's
107
+ // mapping said the right thing here before the entry moved into this table.
108
+ Delete: 'bestax `Delete` renders a <button> with no children and has no anchor form; wrap it in an <a>, or navigate in `onClick`',
109
+ 'Card.Header.Icon': 'bestax `Card.Header.Icon` renders a <button>, so an <a> inside it would nest interactive elements; navigate in `onClick`, or wrap the whole icon in an <a>',
110
+ };
111
+ /**
112
+ * Where to put a link on a target, for the attributes beside `href`. The same
113
+ * fact as `NO_HREF_HINT` in its short form -- fixing the `Delete` advice in
114
+ * one branch and leaving the sibling saying "put it on an <a> inside" to a
115
+ * component with no children is how these two drifted apart the first time.
116
+ */
117
+ const LINK_REMEDY = {
118
+ Delete: 'wrap it in an <a>, or navigate in `onClick`',
119
+ 'Navbar.Dropdown': 'put it on the `<Navbar.Link>` inside',
120
+ // Already renders an interactive element, so "put an <a> inside" would nest
121
+ // one inside the other, which is invalid.
122
+ 'Card.Header.Icon': 'navigate in `onClick`, or wrap the whole icon in an <a>',
123
+ };
124
+ /**
125
+ * Elements that may not CONTAIN an `<a>`, so "put an <a> inside" names markup
126
+ * the author cannot write.
127
+ *
128
+ * This is not the same question as "may an `<a>` contain it". Interactive
129
+ * content -- `button`, `input`, `details`, `iframe`, … -- may not be an `<a>`
130
+ * DESCENDANT, which rules out wrapping; only a subset also refuses an `<a>`
131
+ * child, which is what rules out nesting. `<details>` is the case that shows
132
+ * they differ: an `<a>` may not wrap one, and
133
+ * `<details><summary>…</summary><a href>…</a></details>` is ordinary valid
134
+ * markup. Conflating the two directions suppressed advice that was correct.
135
+ *
136
+ * So this set is only the second direction, read off each element's content
137
+ * model: `a` (transparent, but no `a` descendant), `button` (no interactive
138
+ * descendant), `select`/`textarea` (options and text only), `embed`/`input`
139
+ * (void), `iframe` (content model "nothing" — markup between the tags is not a
140
+ * usable child).
141
+ *
142
+ * `label` is deliberately NOT here, though it is interactive: its content model
143
+ * is phrasing content excluding labelable controls and nested labels, and an
144
+ * `<a>` is neither. It refuses to be WRAPPED, not to hold a link.
145
+ *
146
+ * `LINK_REMEDY` states the same thing per TARGET, for components that render
147
+ * such an element whatever `as` says; this states it per RENDERED ELEMENT, for
148
+ * the ones where the author's `as` chose it. `Dropdown.Item` needs both
149
+ * readings: it is a link by default and a `<button>` on request.
150
+ */
151
+ const NO_ANCHOR_CHILD = new Set([
152
+ 'a',
153
+ 'button',
154
+ 'embed',
155
+ 'iframe',
156
+ 'input',
157
+ 'select',
158
+ 'textarea',
159
+ ]);
160
+ /**
161
+ * Elements an `<a>` may not WRAP, because they are interactive content and an
162
+ * `<a>` may have no interactive descendant. The complement of the set above
163
+ * rather than the same list: `details` and `label` refuse to be wrapped and
164
+ * accept an `<a>` child perfectly well, which is the whole reason the two
165
+ * questions need two sets.
166
+ *
167
+ * `audio` and `video` are deliberately absent -- interactive only with
168
+ * `controls`, which the tag alone does not say, so wrapping one is valid as far
169
+ * as this can tell.
170
+ */
171
+ const NO_ANCHOR_WRAPPER = new Set([...NO_ANCHOR_CHILD, 'details', 'label']);
172
+ /**
173
+ * Void elements an `<a>` may actually WRAP. `<a><img></a>` is the canonical
174
+ * form, and it is the advice `Delete` already gives through `LINK_REMEDY` for
175
+ * the same reason. The plain-markup path reaches these: bloomer's `tag` is
176
+ * whatever literal the source wrote, so `<Help tag="img">` is one rewrite away.
177
+ *
178
+ * Deliberately short. Void-ness is necessary and not sufficient, and the two
179
+ * ways of getting that wrong are both invalid trees: `input` and `embed` are
180
+ * interactive (above), and the set below belongs somewhere an `<a>` cannot be.
181
+ */
182
+ const WRAPPABLE_VOID = new Set(['br', 'hr', 'img', 'wbr']);
183
+ /**
184
+ * Void elements whose parent is fixed by the spec, so no `<a>` can sit between
185
+ * them and it: `base`, `link` and `meta` belong to `<head>`, `col` to
186
+ * `<colgroup>`, `source` and `track` to a media element, `param` to `<object>`,
187
+ * `area` to `<map>`.
188
+ *
189
+ * Nothing can be nested in them and nothing can wrap them, so the only honest
190
+ * remedy is to stop using the element for this.
191
+ */
192
+ const CONTEXT_BOUND = new Set([
193
+ 'area',
194
+ 'base',
195
+ 'col',
196
+ 'link',
197
+ 'meta',
198
+ 'param',
199
+ 'source',
200
+ 'track',
201
+ ]);
202
+ /**
203
+ * What to do with a link the element refuses. `carry` is true for an attribute
204
+ * that has to land ON the anchor to do anything (`target`, `download`, …) and
205
+ * false where the anchor itself IS the remedy (a bare `href`).
206
+ *
207
+ * Four answers, because "put an <a> inside" is wrong in three different ways:
208
+ * an anchor inside interactive content is invalid, an anchor inside a void
209
+ * element is impossible, and an anchor around a context-bound element puts it
210
+ * somewhere its parent may not be.
211
+ *
212
+ * Exported because `specials-utils.ts` reaches the same question from the plain
213
+ * markup side, and `bestax-migrate/CLAUDE.md` puts a rule two transforms need in
214
+ * `_shared/` rather than in one of them.
215
+ */
216
+ export const nestOrClick = (rendered, carry = false) => {
217
+ if (rendered !== undefined && NO_ANCHOR_CHILD.has(rendered)) {
218
+ return 'navigate in `onClick`';
219
+ }
220
+ if (rendered !== undefined && CONTEXT_BOUND.has(rendered)) {
221
+ return carry
222
+ ? 'move it to an element that can take it'
223
+ : 'use an element that can be a link';
224
+ }
225
+ if (rendered !== undefined &&
226
+ WRAPPABLE_VOID.has(rendered) &&
227
+ !NO_ANCHOR_WRAPPER.has(rendered)) {
228
+ return carry
229
+ ? 'put it on an <a> wrapping this element'
230
+ : 'wrap it in an <a>';
231
+ }
232
+ return carry ? 'put it on an <a> inside' : 'put an <a> inside';
233
+ };
234
+ const remedyFor = (target, rendered) => LINK_REMEDY[target] ??
235
+ `${nestOrClick(rendered, true)}, or change the element`;
236
+ /** The targets whose `as` this pass may believe. */
237
+ export function declaresAs(target) {
238
+ return target in AS_UNIONS || AS_ANY.has(target);
239
+ }
240
+ /** Whether `target` renders `value` when told `as={value}`. */
241
+ function acceptsAs(target, value) {
242
+ const union = AS_UNIONS[target];
243
+ return union ? union.includes(value) : AS_ANY.has(target);
244
+ }
245
+ /** The rows the type test holds to the library. */
246
+ export const HREF_TABLE = HREF_OK;
247
+ export const AS_ANY_TARGETS = [...AS_ANY].sort();
248
+ /**
249
+ * The `as` value that keeps an `href`. Only the anchor, and not because it is
250
+ * the only intrinsic React types an `href` onto -- `area`, `link` and `base`
251
+ * carry one too, and a target generic over `as` accepts them.
252
+ *
253
+ * They are excluded because the components disagree about what they forward,
254
+ * so no shared set is right. `Menu.Item` strips `href` unless the tag is an
255
+ * `<a>` or a custom component (`bulma-ui/src/components/Menu.tsx:212-222`),
256
+ * while `Button` routes everything but `'button'` through its anchor path and
257
+ * would forward it. Keeping `href` beside `as="area"` therefore typechecks on
258
+ * both and does nothing on one of them, which is the silently-dead attribute
259
+ * this pass exists to remove. Dropping it is announced and the TODO quotes
260
+ * the value; keeping it is not. No source here emits `as="area"` anyway, and
261
+ * an `<area href>` outside a `<map>` navigates nowhere regardless.
262
+ */
263
+ const ANCHOR = 'a';
264
+ /**
265
+ * The elements each link attribute is valid on. Not a blanket "anchor-only"
266
+ * list, because they are not: `referrerPolicy` is real on `<img>` and
267
+ * `<script>`, `target` on `<form>`, `rel` on `<link>`. Treating them as one
268
+ * set deleted working attributes from those elements.
269
+ *
270
+ * They still have to be checked. Dropping the `href` alone left
271
+ * `<Navbar.Link as="span" target="_blank">`, which does not compile either --
272
+ * and the check has to run whether or not an `href` was there to start with,
273
+ * since that shape is just as invalid without one.
274
+ */
275
+ const LINK_ATTR_ELEMENTS = {
276
+ target: ['a', 'area', 'base', 'form'],
277
+ download: ['a', 'area'],
278
+ hrefLang: ['a', 'area', 'link'],
279
+ ping: ['a'],
280
+ referrerPolicy: ['a', 'area', 'iframe', 'img', 'link', 'script'],
281
+ media: ['a', 'area', 'link', 'meta', 'source', 'style'],
282
+ };
283
+ // `type` is the other member `AnchorHTMLAttributes` adds, and it is
284
+ // deliberately absent. It is not a link attribute in any useful sense: it is
285
+ // an ordinary prop on form controls and buttons, which bestax components
286
+ // declare in their own right. This rule cannot tell "the anchor contributed
287
+ // it" from "the component owns it", and treating it as a link attribute
288
+ // stripped `type="text"` off every `<Input>` in the fixtures. So
289
+ // `<Button as="span" type="button">` still ships a type error -- one the
290
+ // source wrote rather than one the codemod created, which is the trade.
291
+ // `rel` is deliberately absent: React declares it on `HTMLAttributes`, so it
292
+ // is valid on every intrinsic and there is nothing to remove. The same point
293
+ // `bulma-ui/src/__typetests__/polymorphic.tsx` makes about it.
294
+ /**
295
+ * The link attributes a target accepts, where its props do not follow `as`.
296
+ *
297
+ * Most components here either follow `as` (the element decides, above) or
298
+ * extend `AnchorHTMLAttributes` outright (`Pagination.*`, `Panel.Block`, so
299
+ * everything is fine). `Level.Item` is the exception that enumerates: it
300
+ * declares `href`, `target` and `rel` and nothing else, so `download`,
301
+ * `hrefLang`, `ping` and `referrerPolicy` are type errors there even at
302
+ * `as="a"`.
303
+ *
304
+ * A target absent from `HREF_OK` needs no row: a component that takes no
305
+ * `href` at any `as` takes none of its siblings either -- verified for
306
+ * `Card.FooterItem`, `Tabs.Item` and `Delete`, which is why this is derived
307
+ * from that table rather than being a second list to keep.
308
+ */
309
+ const TARGET_LINK_ATTRS = {
310
+ 'Level.Item': ['target'],
311
+ };
312
+ /** The rows the type test holds to the library. */
313
+ export const TARGET_LINK_ATTR_TABLE = TARGET_LINK_ATTRS;
314
+ /**
315
+ * The intrinsic elements React types an `href` onto.
316
+ *
317
+ * Only relevant to plain markup. On a bestax component the anchor is the only
318
+ * `as` that keeps an `href`, because the components disagree about what they
319
+ * forward (see `ANCHOR` below) -- but a plain `<area href>` has no component
320
+ * in the way, and it is valid.
321
+ */
322
+ export const HREF_ELEMENTS = ['a', 'area', 'base', 'link'];
323
+ /**
324
+ * The HTML half of `JSX.IntrinsicElements`, as of `@types/react` 19.
325
+ *
326
+ * Regenerate by reading the keys between `interface IntrinsicElements {` and
327
+ * the `// SVG` comment in `@types/react/index.d.ts`. `as-unions.test.ts`
328
+ * holds the link tables to this set, so it is checked rather than trusted.
329
+ *
330
+ * SVG is excluded deliberately: `SVGAttributes` declares `href`, `media` and
331
+ * `target`, so every SVG tag would join those rows. What that means for a
332
+ * plain rewrite is `tagRejectsHref`'s problem, not a reason to widen this.
333
+ */
334
+ export const HTML_INTRINSICS = [
335
+ 'a',
336
+ 'abbr',
337
+ 'address',
338
+ 'area',
339
+ 'article',
340
+ 'aside',
341
+ 'audio',
342
+ 'b',
343
+ 'base',
344
+ 'bdi',
345
+ 'bdo',
346
+ 'big',
347
+ 'blockquote',
348
+ 'body',
349
+ 'br',
350
+ 'button',
351
+ 'canvas',
352
+ 'caption',
353
+ 'center',
354
+ 'cite',
355
+ 'code',
356
+ 'col',
357
+ 'colgroup',
358
+ 'data',
359
+ 'datalist',
360
+ 'dd',
361
+ 'del',
362
+ 'details',
363
+ 'dfn',
364
+ 'dialog',
365
+ 'div',
366
+ 'dl',
367
+ 'dt',
368
+ 'em',
369
+ 'embed',
370
+ 'fieldset',
371
+ 'figcaption',
372
+ 'figure',
373
+ 'footer',
374
+ 'form',
375
+ 'h1',
376
+ 'h2',
377
+ 'h3',
378
+ 'h4',
379
+ 'h5',
380
+ 'h6',
381
+ 'head',
382
+ 'header',
383
+ 'hgroup',
384
+ 'hr',
385
+ 'html',
386
+ 'i',
387
+ 'iframe',
388
+ 'img',
389
+ 'input',
390
+ 'ins',
391
+ 'kbd',
392
+ 'keygen',
393
+ 'label',
394
+ 'legend',
395
+ 'li',
396
+ 'link',
397
+ 'main',
398
+ 'map',
399
+ 'mark',
400
+ 'menu',
401
+ 'menuitem',
402
+ 'meta',
403
+ 'meter',
404
+ 'nav',
405
+ 'noindex',
406
+ 'noscript',
407
+ 'object',
408
+ 'ol',
409
+ 'optgroup',
410
+ 'option',
411
+ 'output',
412
+ 'p',
413
+ 'param',
414
+ 'picture',
415
+ 'pre',
416
+ 'progress',
417
+ 'q',
418
+ 'rp',
419
+ 'rt',
420
+ 'ruby',
421
+ 's',
422
+ 'samp',
423
+ 'script',
424
+ 'search',
425
+ 'section',
426
+ 'select',
427
+ 'slot',
428
+ 'small',
429
+ 'source',
430
+ 'span',
431
+ 'strong',
432
+ 'style',
433
+ 'sub',
434
+ 'summary',
435
+ 'sup',
436
+ 'table',
437
+ 'tbody',
438
+ 'td',
439
+ 'template',
440
+ 'textarea',
441
+ 'tfoot',
442
+ 'th',
443
+ 'thead',
444
+ 'time',
445
+ 'title',
446
+ 'tr',
447
+ 'track',
448
+ 'u',
449
+ 'ul',
450
+ 'var',
451
+ 'video',
452
+ 'wbr',
453
+ 'webview',
454
+ ];
455
+ /**
456
+ * Whether a plain tag is known NOT to take an `href`.
457
+ *
458
+ * `HREF_ELEMENTS` is the HTML answer, and bloomer's `plainTag` can return any
459
+ * literal the source wrote -- including an SVG tag, where `SVGAttributes`
460
+ * declares `href`. Treating "not in the HTML list" as "takes no href" lost a
461
+ * working `href` off `<TabLink tag="use" href="#icon">`. An unrecognised tag
462
+ * is left alone: this pass removes attributes it can prove are dead, and it
463
+ * cannot prove that here.
464
+ */
465
+ export function tagRejectsHref(tag) {
466
+ return HTML_INTRINSICS.includes(tag) && !HREF_ELEMENTS.includes(tag);
467
+ }
468
+ // `<style href>` is React 19 only -- it arrived with stylesheet hoisting, and
469
+ // `StyleHTMLAttributes` has no `href` in React 18. bulma-ui's peer range is
470
+ // `^18 || ^19` and CI builds both, so a row true of only one major would have
471
+ // the codemod emit output that fails in a supported project. These rows are
472
+ // the common denominator, which is not what a probe against the installed
473
+ // types answers -- only React 19's are here, and they said to add `style`.
474
+ /** Whether `element` renders `attr` legally -- exported for the plain-markup path. */
475
+ export function elementTakesLinkAttr(name, element) {
476
+ const allowed = LINK_ATTR_ELEMENTS[name];
477
+ return !allowed || allowed.includes(element);
478
+ }
479
+ /** The link attributes, for callers that iterate them. */
480
+ export const LINK_ATTRS = Object.keys(LINK_ATTR_ELEMENTS);
481
+ /** The rows the type test holds to React. */
482
+ export const LINK_ATTR_TABLE = LINK_ATTR_ELEMENTS;
483
+ /**
484
+ * Drop an `as` the bestax target cannot render, naming the elements it can.
485
+ *
486
+ * A literal only. A dynamic `as` is left exactly as written, and the reason is
487
+ * the one `rbx/mapping.ts` gives for its `AS_OK` entries: `as={SomeComponent}`
488
+ * against a narrowed union should surface as a type error the author reads,
489
+ * not a silent rewrite. Removing it here would delete a live reference to
490
+ * their component and quietly render something else.
491
+ */
492
+ function restrictAsValue(ctx, path, element, target, attr, literal) {
493
+ const allowed = AS_UNIONS[target];
494
+ if (!allowed || !attr || !literal)
495
+ return;
496
+ if (literal.kind !== 'string' || allowed.includes(literal.value))
497
+ return;
498
+ const offered = allowed.map(a => `\`${a}\``).join(' / ');
499
+ removeAttr(element, attr);
500
+ addTodo(ctx, path, 'prop:as', `bestax \`${target}\` renders only ${offered}, so \`as="${literal.value}"\` cannot carry across — wrap it in a <${literal.value}> or restructure`);
501
+ ctx.dirty = true;
502
+ }
503
+ /**
504
+ * Drop an `href` the element cannot take.
505
+ *
506
+ * The element is read from the `as` the target actually declares, never from
507
+ * whatever attribute happens to be spelled `as`, and from the value as it was
508
+ * written -- `restrictAsValue` may be about to remove it, and an element this
509
+ * pass has already forgotten cannot be judged.
510
+ */
511
+ function dropInertHref(ctx, path, element, target, literal, elementKnown) {
512
+ const href = findAttr(element, 'href');
513
+ if (!href)
514
+ return;
515
+ const wasWritten = attrSource(ctx.j, href);
516
+ const drop = (why) => {
517
+ removeAttr(element, href);
518
+ const was = wasWritten ? ` -- it read \`${wasWritten}\`` : '';
519
+ addTodo(ctx, path, 'prop:href', `${why}${was}`);
520
+ ctx.dirty = true;
521
+ };
522
+ const defaultEl = HREF_OK[target];
523
+ if (!defaultEl) {
524
+ drop(NO_HREF_HINT[target] ??
525
+ `bestax \`${target}\` takes no \`href\` at any \`as\` -- navigate in \`onClick\`, or put an <a> inside it`);
526
+ return;
527
+ }
528
+ // Past here every answer depends on which element renders. A dynamic `as`
529
+ // may well be an anchor at runtime, and a spread may supply one; guessing
530
+ // either way is worse than leaving the pair for the author.
531
+ if (!elementKnown)
532
+ return;
533
+ if (literal && literal.kind !== 'string')
534
+ return;
535
+ // An `as` naming a tag the target does not render is dropped by
536
+ // `restrictAsValue`, so what renders is the component's own default -- the
537
+ // same element as if no `as` had been written at all.
538
+ const rendered = literal && literal.kind === 'string' && acceptsAs(target, literal.value)
539
+ ? literal.value
540
+ : undefined;
541
+ if (rendered === undefined) {
542
+ if (defaultEl === ANCHOR)
543
+ return;
544
+ drop(`bestax \`${target}\` renders a <${defaultEl}> unless \`as\` says otherwise, and only its <a> form carries an \`href\` -- set \`as="a"\` to make this a link, or ${nestOrClick(defaultEl)}`);
545
+ return;
546
+ }
547
+ if (rendered === ANCHOR)
548
+ return;
549
+ // Which remedy is right depends on what the target renders with no `as`.
550
+ // "drop the `as`" is only a link on the targets whose bare element is the
551
+ // anchor; on `Button` it gives a <button> that will not compile, and on
552
+ // `Level.Item` a <div> that compiles and quietly is not a link.
553
+ const remedy = defaultEl === ANCHOR
554
+ ? `drop the \`as\` to make this a link, or ${nestOrClick(rendered)}`
555
+ : `set \`as="a"\` to make this a link -- dropping the \`as\` gives you a <${defaultEl}> -- or ${nestOrClick(rendered)}`;
556
+ drop(`\`href\` on \`as="${rendered}"\`: bestax gives an element the attributes of the tag \`as\` names, and a <${rendered}> takes no \`href\` (it navigated nowhere in the source either) -- ${remedy}`);
557
+ }
558
+ /**
559
+ * Drop a link attribute the rendered element does not take.
560
+ *
561
+ * Independent of `href`: `<Navbar.Link as="span" target="_blank">` is invalid
562
+ * with or without one, and the sources emit both shapes. Each attribute is
563
+ * judged against the element rather than as a group, so a `referrerPolicy` on
564
+ * an `<img>` and a `target` on a `<form>` stay.
565
+ */
566
+ function dropInertLinkAttrs(ctx, path, element, target, rendered) {
567
+ const carriesLinks = HREF_OK[target] !== undefined;
568
+ const declared = TARGET_LINK_ATTRS[target];
569
+ for (const name of LINK_ATTRS) {
570
+ // Both have to allow it. A target that enumerates its props can be
571
+ // narrower than the element, and the element can be narrower than the
572
+ // target: `Level.Item` declares `target` but forwards it only when the
573
+ // tag is an `<a>`, so `<Level.Item as="p" target="_blank">` was keeping
574
+ // the same inert attribute this pass removes everywhere else.
575
+ // Which check rejects it decides both the outcome and what the TODO says.
576
+ // Keying the message off "the target has a row" instead told a
577
+ // `<Level.Item as="p" target>` reader that `Level.Item` does not declare
578
+ // `target` -- it does; the <p> is what refuses it.
579
+ let rejectedBy = null;
580
+ if (!carriesLinks)
581
+ rejectedBy = 'component';
582
+ else if (declared && !declared.includes(name))
583
+ rejectedBy = 'component';
584
+ // A dynamic `as` leaves the element unknown, and guessing either way is
585
+ // worse than leaving it for the author.
586
+ else if (rendered !== undefined && !elementTakesLinkAttr(name, rendered))
587
+ rejectedBy = 'element';
588
+ if (!rejectedBy)
589
+ continue;
590
+ const attr = findAttr(element, name);
591
+ if (!attr)
592
+ continue;
593
+ const was = attrSource(ctx.j, attr);
594
+ removeAttr(element, attr);
595
+ const because = rejectedBy === 'element'
596
+ ? `\`${name}\` needs an element that takes it, and \`${target}\` renders a <${rendered}> here`
597
+ : !carriesLinks
598
+ ? `bestax \`${target}\` is not a link at any \`as\`, so it takes no \`${name}\` either`
599
+ : `bestax \`${target}\` declares its own props rather than taking the element's, and \`${name}\` is not among them`;
600
+ addTodo(ctx, path, `prop:${name}`, `${because} -- ${was ? `it read \`${was}\`; ` : ''}${remedyFor(target, rendered)}`);
601
+ ctx.dirty = true;
602
+ }
603
+ }
604
+ /**
605
+ * The `as` attribute only if no spread can overwrite it. `findAttr` reads by
606
+ * name and knows nothing about `{...rest}`, which JSX applies last-write-wins.
607
+ */
608
+ function lastWordOnAs(element, spreadCanCarryAs) {
609
+ const attrs = element.openingElement?.attributes ?? [];
610
+ let seen;
611
+ let shadowed = false;
612
+ for (const a of attrs) {
613
+ if (a.type === 'JSXSpreadAttribute') {
614
+ if (seen && spreadCanCarryAs) {
615
+ seen = undefined;
616
+ shadowed = true;
617
+ }
618
+ }
619
+ else if (a.name?.name === 'as') {
620
+ seen = a;
621
+ shadowed = false;
622
+ }
623
+ }
624
+ return { attr: seen, shadowed };
625
+ }
626
+ /**
627
+ * The whole-element pass each source runs after its prop passes, once the
628
+ * bestax `target` and the final `as` are both known.
629
+ */
630
+ export function enforcePolymorphicProps(ctx, path, element, target,
631
+ /**
632
+ * Whether a spread on this element could be carrying an `as`.
633
+ *
634
+ * Only where the SOURCE spells its element prop `as`, which is rbx alone.
635
+ * bloomer's is `tag` and react-bulma-components' is `renderAs`, and the
636
+ * rename only ever touches a literal attribute -- a `renderAs` key inside
637
+ * `{...rest}` never becomes an `as`. Guessing `true` everywhere let
638
+ * `<Button renderAs="span" {...rest} href="/x">` through untouched, which
639
+ * is the shape this pass exists to remove.
640
+ */
641
+ spreadCanCarryAs = false) {
642
+ // Read the `as` once, before either rule can remove it, and only where the
643
+ // target declares one at all -- and only when JSX precedence says this
644
+ // attribute is the one that wins. A spread after it overwrites it, so
645
+ // `<Button as="span" {...p} href="/x">` with `p.as === "a"` rendered an
646
+ // anchor while this pass read `span` and deleted a working `href`. An `as`
647
+ // the spread can overwrite is treated as unknown, which is the same answer
648
+ // this pass already gives for a dynamic one.
649
+ const read = declaresAs(target)
650
+ ? lastWordOnAs(element, spreadCanCarryAs)
651
+ : { attr: undefined, shadowed: false };
652
+ // A spread with no `as` written beside it keeps the ordinary reading, and
653
+ // that is a settled decision rather than an oversight. `{...rest}` is on
654
+ // half the elements in a real app; treating every one as an unknown element
655
+ // would keep `href` on components that take none, which is the defect this
656
+ // pass exists to remove. It trades a silent invalid `href` for a reported
657
+ // dropped one, and the reported one is the better failure. Weighed against
658
+ // the alternative and chosen deliberately -- these are dead source
659
+ // libraries and the simpler rule is the right one. Do not re-open it
660
+ // without a real migration that it got wrong.
661
+ //
662
+ // `restrictAsValue` still runs on a shadowed `as`. An out-of-union literal
663
+ // is invalid exactly as written, and dead if the spread overwrites it, so
664
+ // removing it is right either way.
665
+ const attr = read.attr ?? findAttr(element, 'as');
666
+ const literal = attr ? literalValueOf(attr) : undefined;
667
+ // Shadowing makes the ELEMENT unknown, not the component. Returning here
668
+ // skipped the union check and the component-level `href` rule as well, so
669
+ // `<Image as="span" {...p} href="/x">` kept both an `as` outside `Image`'s
670
+ // union and an `href` it declares at no `as` -- two facts the spread cannot
671
+ // change.
672
+ dropInertHref(ctx, path, element, target, literal, !read.shadowed);
673
+ // The element as it will render: the `as` if the target takes it, otherwise
674
+ // the component's own. Unknown for a dynamic `as`, and unknown for a target
675
+ // outside `HREF_OK` that was given no `as` -- the default element is only
676
+ // recorded for the targets in that table. So this rule reaches an explicit
677
+ // accepted `as` on any target, plus those targets bare; elsewhere it
678
+ // declines rather than guesses.
679
+ const rendered = read.shadowed
680
+ ? undefined
681
+ : literal && literal.kind === 'string'
682
+ ? acceptsAs(target, literal.value)
683
+ ? literal.value
684
+ : HREF_OK[target]
685
+ : literal
686
+ ? undefined
687
+ : HREF_OK[target];
688
+ dropInertLinkAttrs(ctx, path, element, target, rendered);
689
+ restrictAsValue(ctx, path, element, target, attr, literal);
690
+ // Only now, and only if something element-dependent actually survived. The
691
+ // first version fired on every `as` beside a spread -- `<Title as="h2"
692
+ // {...rest}>` is a complete migration with nothing at stake and was getting
693
+ // a TODO -- and it ran before the passes, so it also claimed attributes
694
+ // were "left as written" that the component-level rules had just removed.
695
+ if (read.shadowed) {
696
+ const left = ['href', ...LINK_ATTRS].filter(n => findAttr(element, n));
697
+ if (left.length) {
698
+ addTodo(ctx, path, 'prop:as', `a spread after \`as\` can overwrite it, so which element \`${target}\` renders here is not knowable -- ${left
699
+ .map(n => `\`${n}\``)
700
+ .join(', ')} ${left.length > 1 ? 'were' : 'was'} left as written, and whether ${left.length > 1 ? 'they are' : 'it is'} valid depends on what the spread supplies`);
701
+ }
702
+ }
703
+ }