bestax-migrate 2.3.0 → 2.3.2

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,593 @@
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
+ 'Level.Item': 'div',
88
+ 'Menu.Item': 'a',
89
+ 'Navbar.Item': 'a',
90
+ 'Navbar.Link': 'a',
91
+ 'Pagination.Link': 'a',
92
+ 'Pagination.Next': 'a',
93
+ 'Pagination.Previous': 'a',
94
+ 'Panel.Block': 'a',
95
+ };
96
+ /**
97
+ * Where "put an <a> inside" is not the useful advice. bloomer's navbar handler
98
+ * already said this for its own source; the other two reached the same target
99
+ * and got the generic line, which is the sibling drift `bestax-migrate`'s
100
+ * CLAUDE.md warns about. Keyed by target, all three get it.
101
+ */
102
+ const NO_HREF_HINT = {
103
+ 'Navbar.Dropdown': 'bestax `Navbar.Dropdown` is the container and takes no `href`; put it on the `<Navbar.Link>` inside',
104
+ // `Delete` renders a self-closing <button/> with no `{children}` at all, so
105
+ // the generic "put an <a> inside it" is advice nobody can follow. bloomer's
106
+ // mapping said the right thing here before the entry moved into this table.
107
+ Delete: 'bestax `Delete` renders a <button> with no children and has no anchor form; wrap it in an <a>, or navigate in `onClick`',
108
+ '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>',
109
+ 'Dropdown.Item': 'bestax `Dropdown.Item` declares no `href` and already renders an <a> by default, so an <a> inside would nest anchors; navigate in `onClick`',
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
+ // Both already render interactive elements -- a <button> and an <a> -- so
121
+ // "put an <a> inside" would nest one inside the other, which is invalid.
122
+ 'Card.Header.Icon': 'navigate in `onClick`, or wrap the whole icon in an <a>',
123
+ 'Dropdown.Item': 'navigate in `onClick`',
124
+ };
125
+ const remedyFor = (target) => LINK_REMEDY[target] ?? 'put it on an <a> inside, or change the element';
126
+ /** The targets whose `as` this pass may believe. */
127
+ export function declaresAs(target) {
128
+ return target in AS_UNIONS || AS_ANY.has(target);
129
+ }
130
+ /** Whether `target` renders `value` when told `as={value}`. */
131
+ function acceptsAs(target, value) {
132
+ const union = AS_UNIONS[target];
133
+ return union ? union.includes(value) : AS_ANY.has(target);
134
+ }
135
+ /** The rows the type test holds to the library. */
136
+ export const HREF_TABLE = HREF_OK;
137
+ export const AS_ANY_TARGETS = [...AS_ANY].sort();
138
+ /**
139
+ * The `as` value that keeps an `href`. Only the anchor, and not because it is
140
+ * the only intrinsic React types an `href` onto -- `area`, `link` and `base`
141
+ * carry one too, and a target generic over `as` accepts them.
142
+ *
143
+ * They are excluded because the components disagree about what they forward,
144
+ * so no shared set is right. `Menu.Item` strips `href` unless the tag is an
145
+ * `<a>` or a custom component (`bulma-ui/src/components/Menu.tsx:212-222`),
146
+ * while `Button` routes everything but `'button'` through its anchor path and
147
+ * would forward it. Keeping `href` beside `as="area"` therefore typechecks on
148
+ * both and does nothing on one of them, which is the silently-dead attribute
149
+ * this pass exists to remove. Dropping it is announced and the TODO quotes
150
+ * the value; keeping it is not. No source here emits `as="area"` anyway, and
151
+ * an `<area href>` outside a `<map>` navigates nowhere regardless.
152
+ */
153
+ const ANCHOR = 'a';
154
+ /**
155
+ * The elements each link attribute is valid on. Not a blanket "anchor-only"
156
+ * list, because they are not: `referrerPolicy` is real on `<img>` and
157
+ * `<script>`, `target` on `<form>`, `rel` on `<link>`. Treating them as one
158
+ * set deleted working attributes from those elements.
159
+ *
160
+ * They still have to be checked. Dropping the `href` alone left
161
+ * `<Navbar.Link as="span" target="_blank">`, which does not compile either --
162
+ * and the check has to run whether or not an `href` was there to start with,
163
+ * since that shape is just as invalid without one.
164
+ */
165
+ const LINK_ATTR_ELEMENTS = {
166
+ target: ['a', 'area', 'base', 'form'],
167
+ download: ['a', 'area'],
168
+ hrefLang: ['a', 'area', 'link'],
169
+ ping: ['a'],
170
+ referrerPolicy: ['a', 'area', 'iframe', 'img', 'link', 'script'],
171
+ media: ['a', 'area', 'link', 'meta', 'source', 'style'],
172
+ };
173
+ // `type` is the other member `AnchorHTMLAttributes` adds, and it is
174
+ // deliberately absent. It is not a link attribute in any useful sense: it is
175
+ // an ordinary prop on form controls and buttons, which bestax components
176
+ // declare in their own right. This rule cannot tell "the anchor contributed
177
+ // it" from "the component owns it", and treating it as a link attribute
178
+ // stripped `type="text"` off every `<Input>` in the fixtures. So
179
+ // `<Button as="span" type="button">` still ships a type error -- one the
180
+ // source wrote rather than one the codemod created, which is the trade.
181
+ // `rel` is deliberately absent: React declares it on `HTMLAttributes`, so it
182
+ // is valid on every intrinsic and there is nothing to remove. The same point
183
+ // `bulma-ui/src/__typetests__/polymorphic.tsx` makes about it.
184
+ /**
185
+ * The link attributes a target accepts, where its props do not follow `as`.
186
+ *
187
+ * Most components here either follow `as` (the element decides, above) or
188
+ * extend `AnchorHTMLAttributes` outright (`Pagination.*`, `Panel.Block`, so
189
+ * everything is fine). `Level.Item` is the exception that enumerates: it
190
+ * declares `href`, `target` and `rel` and nothing else, so `download`,
191
+ * `hrefLang`, `ping` and `referrerPolicy` are type errors there even at
192
+ * `as="a"`.
193
+ *
194
+ * A target absent from `HREF_OK` needs no row: a component that takes no
195
+ * `href` at any `as` takes none of its siblings either -- verified for
196
+ * `Dropdown.Item`, `Card.FooterItem`, `Tabs.Item` and `Delete`, which is why
197
+ * this is derived from that table rather than being a second list to keep.
198
+ */
199
+ const TARGET_LINK_ATTRS = {
200
+ 'Level.Item': ['target'],
201
+ };
202
+ /** The rows the type test holds to the library. */
203
+ export const TARGET_LINK_ATTR_TABLE = TARGET_LINK_ATTRS;
204
+ /**
205
+ * The intrinsic elements React types an `href` onto.
206
+ *
207
+ * Only relevant to plain markup. On a bestax component the anchor is the only
208
+ * `as` that keeps an `href`, because the components disagree about what they
209
+ * forward (see `ANCHOR` below) -- but a plain `<area href>` has no component
210
+ * in the way, and it is valid.
211
+ */
212
+ export const HREF_ELEMENTS = ['a', 'area', 'base', 'link'];
213
+ /**
214
+ * The HTML half of `JSX.IntrinsicElements`, as of `@types/react` 19.
215
+ *
216
+ * Regenerate by reading the keys between `interface IntrinsicElements {` and
217
+ * the `// SVG` comment in `@types/react/index.d.ts`. `as-unions.test.ts`
218
+ * holds the link tables to this set, so it is checked rather than trusted.
219
+ *
220
+ * SVG is excluded deliberately: `SVGAttributes` declares `href`, `media` and
221
+ * `target`, so every SVG tag would join those rows. What that means for a
222
+ * plain rewrite is `tagRejectsHref`'s problem, not a reason to widen this.
223
+ */
224
+ export const HTML_INTRINSICS = [
225
+ 'a',
226
+ 'abbr',
227
+ 'address',
228
+ 'area',
229
+ 'article',
230
+ 'aside',
231
+ 'audio',
232
+ 'b',
233
+ 'base',
234
+ 'bdi',
235
+ 'bdo',
236
+ 'big',
237
+ 'blockquote',
238
+ 'body',
239
+ 'br',
240
+ 'button',
241
+ 'canvas',
242
+ 'caption',
243
+ 'center',
244
+ 'cite',
245
+ 'code',
246
+ 'col',
247
+ 'colgroup',
248
+ 'data',
249
+ 'datalist',
250
+ 'dd',
251
+ 'del',
252
+ 'details',
253
+ 'dfn',
254
+ 'dialog',
255
+ 'div',
256
+ 'dl',
257
+ 'dt',
258
+ 'em',
259
+ 'embed',
260
+ 'fieldset',
261
+ 'figcaption',
262
+ 'figure',
263
+ 'footer',
264
+ 'form',
265
+ 'h1',
266
+ 'h2',
267
+ 'h3',
268
+ 'h4',
269
+ 'h5',
270
+ 'h6',
271
+ 'head',
272
+ 'header',
273
+ 'hgroup',
274
+ 'hr',
275
+ 'html',
276
+ 'i',
277
+ 'iframe',
278
+ 'img',
279
+ 'input',
280
+ 'ins',
281
+ 'kbd',
282
+ 'keygen',
283
+ 'label',
284
+ 'legend',
285
+ 'li',
286
+ 'link',
287
+ 'main',
288
+ 'map',
289
+ 'mark',
290
+ 'menu',
291
+ 'menuitem',
292
+ 'meta',
293
+ 'meter',
294
+ 'nav',
295
+ 'noindex',
296
+ 'noscript',
297
+ 'object',
298
+ 'ol',
299
+ 'optgroup',
300
+ 'option',
301
+ 'output',
302
+ 'p',
303
+ 'param',
304
+ 'picture',
305
+ 'pre',
306
+ 'progress',
307
+ 'q',
308
+ 'rp',
309
+ 'rt',
310
+ 'ruby',
311
+ 's',
312
+ 'samp',
313
+ 'script',
314
+ 'search',
315
+ 'section',
316
+ 'select',
317
+ 'slot',
318
+ 'small',
319
+ 'source',
320
+ 'span',
321
+ 'strong',
322
+ 'style',
323
+ 'sub',
324
+ 'summary',
325
+ 'sup',
326
+ 'table',
327
+ 'tbody',
328
+ 'td',
329
+ 'template',
330
+ 'textarea',
331
+ 'tfoot',
332
+ 'th',
333
+ 'thead',
334
+ 'time',
335
+ 'title',
336
+ 'tr',
337
+ 'track',
338
+ 'u',
339
+ 'ul',
340
+ 'var',
341
+ 'video',
342
+ 'wbr',
343
+ 'webview',
344
+ ];
345
+ /**
346
+ * Whether a plain tag is known NOT to take an `href`.
347
+ *
348
+ * `HREF_ELEMENTS` is the HTML answer, and bloomer's `plainTag` can return any
349
+ * literal the source wrote -- including an SVG tag, where `SVGAttributes`
350
+ * declares `href`. Treating "not in the HTML list" as "takes no href" lost a
351
+ * working `href` off `<TabLink tag="use" href="#icon">`. An unrecognised tag
352
+ * is left alone: this pass removes attributes it can prove are dead, and it
353
+ * cannot prove that here.
354
+ */
355
+ export function tagRejectsHref(tag) {
356
+ return HTML_INTRINSICS.includes(tag) && !HREF_ELEMENTS.includes(tag);
357
+ }
358
+ // `<style href>` is React 19 only -- it arrived with stylesheet hoisting, and
359
+ // `StyleHTMLAttributes` has no `href` in React 18. bulma-ui's peer range is
360
+ // `^18 || ^19` and CI builds both, so a row true of only one major would have
361
+ // the codemod emit output that fails in a supported project. These rows are
362
+ // the common denominator, which is not what a probe against the installed
363
+ // types answers -- only React 19's are here, and they said to add `style`.
364
+ /** Whether `element` renders `attr` legally -- exported for the plain-markup path. */
365
+ export function elementTakesLinkAttr(name, element) {
366
+ const allowed = LINK_ATTR_ELEMENTS[name];
367
+ return !allowed || allowed.includes(element);
368
+ }
369
+ /** The link attributes, for callers that iterate them. */
370
+ export const LINK_ATTRS = Object.keys(LINK_ATTR_ELEMENTS);
371
+ /** The rows the type test holds to React. */
372
+ export const LINK_ATTR_TABLE = LINK_ATTR_ELEMENTS;
373
+ /**
374
+ * Drop an `as` the bestax target cannot render, naming the elements it can.
375
+ *
376
+ * A literal only. A dynamic `as` is left exactly as written, and the reason is
377
+ * the one `rbx/mapping.ts` gives for its `AS_OK` entries: `as={SomeComponent}`
378
+ * against a narrowed union should surface as a type error the author reads,
379
+ * not a silent rewrite. Removing it here would delete a live reference to
380
+ * their component and quietly render something else.
381
+ */
382
+ function restrictAsValue(ctx, path, element, target, attr, literal) {
383
+ const allowed = AS_UNIONS[target];
384
+ if (!allowed || !attr || !literal)
385
+ return;
386
+ if (literal.kind !== 'string' || allowed.includes(literal.value))
387
+ return;
388
+ const offered = allowed.map(a => `\`${a}\``).join(' / ');
389
+ removeAttr(element, attr);
390
+ 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`);
391
+ ctx.dirty = true;
392
+ }
393
+ /**
394
+ * Drop an `href` the element cannot take.
395
+ *
396
+ * The element is read from the `as` the target actually declares, never from
397
+ * whatever attribute happens to be spelled `as`, and from the value as it was
398
+ * written -- `restrictAsValue` may be about to remove it, and an element this
399
+ * pass has already forgotten cannot be judged.
400
+ */
401
+ function dropInertHref(ctx, path, element, target, literal, elementKnown) {
402
+ const href = findAttr(element, 'href');
403
+ if (!href)
404
+ return;
405
+ const wasWritten = attrSource(ctx.j, href);
406
+ const drop = (why) => {
407
+ removeAttr(element, href);
408
+ const was = wasWritten ? ` -- it read \`${wasWritten}\`` : '';
409
+ addTodo(ctx, path, 'prop:href', `${why}${was}`);
410
+ ctx.dirty = true;
411
+ };
412
+ const defaultEl = HREF_OK[target];
413
+ if (!defaultEl) {
414
+ drop(NO_HREF_HINT[target] ??
415
+ `bestax \`${target}\` takes no \`href\` at any \`as\` -- navigate in \`onClick\`, or put an <a> inside it`);
416
+ return;
417
+ }
418
+ // Past here every answer depends on which element renders. A dynamic `as`
419
+ // may well be an anchor at runtime, and a spread may supply one; guessing
420
+ // either way is worse than leaving the pair for the author.
421
+ if (!elementKnown)
422
+ return;
423
+ if (literal && literal.kind !== 'string')
424
+ return;
425
+ // An `as` naming a tag the target does not render is dropped by
426
+ // `restrictAsValue`, so what renders is the component's own default -- the
427
+ // same element as if no `as` had been written at all.
428
+ const rendered = literal && literal.kind === 'string' && acceptsAs(target, literal.value)
429
+ ? literal.value
430
+ : undefined;
431
+ if (rendered === undefined) {
432
+ if (defaultEl === ANCHOR)
433
+ return;
434
+ 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 navigate in \`onClick\``);
435
+ return;
436
+ }
437
+ if (rendered === ANCHOR)
438
+ return;
439
+ // Which remedy is right depends on what the target renders with no `as`.
440
+ // "drop the `as`" is only a link on the targets whose bare element is the
441
+ // anchor; on `Button` it gives a <button> that will not compile, and on
442
+ // `Level.Item` a <div> that compiles and quietly is not a link.
443
+ const remedy = defaultEl === ANCHOR
444
+ ? 'drop the `as` to make this a link, or put an <a> inside'
445
+ : `set \`as="a"\` to make this a link -- dropping the \`as\` gives you a <${defaultEl}> -- or put an <a> inside`;
446
+ 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}`);
447
+ }
448
+ /**
449
+ * Drop a link attribute the rendered element does not take.
450
+ *
451
+ * Independent of `href`: `<Navbar.Link as="span" target="_blank">` is invalid
452
+ * with or without one, and the sources emit both shapes. Each attribute is
453
+ * judged against the element rather than as a group, so a `referrerPolicy` on
454
+ * an `<img>` and a `target` on a `<form>` stay.
455
+ */
456
+ function dropInertLinkAttrs(ctx, path, element, target, rendered) {
457
+ const carriesLinks = HREF_OK[target] !== undefined;
458
+ const declared = TARGET_LINK_ATTRS[target];
459
+ for (const name of LINK_ATTRS) {
460
+ // Both have to allow it. A target that enumerates its props can be
461
+ // narrower than the element, and the element can be narrower than the
462
+ // target: `Level.Item` declares `target` but forwards it only when the
463
+ // tag is an `<a>`, so `<Level.Item as="p" target="_blank">` was keeping
464
+ // the same inert attribute this pass removes everywhere else.
465
+ // Which check rejects it decides both the outcome and what the TODO says.
466
+ // Keying the message off "the target has a row" instead told a
467
+ // `<Level.Item as="p" target>` reader that `Level.Item` does not declare
468
+ // `target` -- it does; the <p> is what refuses it.
469
+ let rejectedBy = null;
470
+ if (!carriesLinks)
471
+ rejectedBy = 'component';
472
+ else if (declared && !declared.includes(name))
473
+ rejectedBy = 'component';
474
+ // A dynamic `as` leaves the element unknown, and guessing either way is
475
+ // worse than leaving it for the author.
476
+ else if (rendered !== undefined && !elementTakesLinkAttr(name, rendered))
477
+ rejectedBy = 'element';
478
+ if (!rejectedBy)
479
+ continue;
480
+ const attr = findAttr(element, name);
481
+ if (!attr)
482
+ continue;
483
+ const was = attrSource(ctx.j, attr);
484
+ removeAttr(element, attr);
485
+ const because = rejectedBy === 'element'
486
+ ? `\`${name}\` needs an element that takes it, and \`${target}\` renders a <${rendered}> here`
487
+ : !carriesLinks
488
+ ? `bestax \`${target}\` is not a link at any \`as\`, so it takes no \`${name}\` either`
489
+ : `bestax \`${target}\` declares its own props rather than taking the element's, and \`${name}\` is not among them`;
490
+ addTodo(ctx, path, `prop:${name}`, `${because} -- ${was ? `it read \`${was}\`; ` : ''}${remedyFor(target)}`);
491
+ ctx.dirty = true;
492
+ }
493
+ }
494
+ /**
495
+ * The `as` attribute only if no spread can overwrite it. `findAttr` reads by
496
+ * name and knows nothing about `{...rest}`, which JSX applies last-write-wins.
497
+ */
498
+ function lastWordOnAs(element, spreadCanCarryAs) {
499
+ const attrs = element.openingElement?.attributes ?? [];
500
+ let seen;
501
+ let shadowed = false;
502
+ for (const a of attrs) {
503
+ if (a.type === 'JSXSpreadAttribute') {
504
+ if (seen && spreadCanCarryAs) {
505
+ seen = undefined;
506
+ shadowed = true;
507
+ }
508
+ }
509
+ else if (a.name?.name === 'as') {
510
+ seen = a;
511
+ shadowed = false;
512
+ }
513
+ }
514
+ return { attr: seen, shadowed };
515
+ }
516
+ /**
517
+ * The whole-element pass each source runs after its prop passes, once the
518
+ * bestax `target` and the final `as` are both known.
519
+ */
520
+ export function enforcePolymorphicProps(ctx, path, element, target,
521
+ /**
522
+ * Whether a spread on this element could be carrying an `as`.
523
+ *
524
+ * Only where the SOURCE spells its element prop `as`, which is rbx alone.
525
+ * bloomer's is `tag` and react-bulma-components' is `renderAs`, and the
526
+ * rename only ever touches a literal attribute -- a `renderAs` key inside
527
+ * `{...rest}` never becomes an `as`. Guessing `true` everywhere let
528
+ * `<Button renderAs="span" {...rest} href="/x">` through untouched, which
529
+ * is the shape this pass exists to remove.
530
+ */
531
+ spreadCanCarryAs = false) {
532
+ // Read the `as` once, before either rule can remove it, and only where the
533
+ // target declares one at all -- and only when JSX precedence says this
534
+ // attribute is the one that wins. A spread after it overwrites it, so
535
+ // `<Button as="span" {...p} href="/x">` with `p.as === "a"` rendered an
536
+ // anchor while this pass read `span` and deleted a working `href`. An `as`
537
+ // the spread can overwrite is treated as unknown, which is the same answer
538
+ // this pass already gives for a dynamic one.
539
+ const read = declaresAs(target)
540
+ ? lastWordOnAs(element, spreadCanCarryAs)
541
+ : { attr: undefined, shadowed: false };
542
+ // A spread with no `as` written beside it keeps the ordinary reading, and
543
+ // that is a settled decision rather than an oversight. `{...rest}` is on
544
+ // half the elements in a real app; treating every one as an unknown element
545
+ // would keep `href` on components that take none, which is the defect this
546
+ // pass exists to remove. It trades a silent invalid `href` for a reported
547
+ // dropped one, and the reported one is the better failure. Weighed against
548
+ // the alternative and chosen deliberately -- these are dead source
549
+ // libraries and the simpler rule is the right one. Do not re-open it
550
+ // without a real migration that it got wrong.
551
+ //
552
+ // `restrictAsValue` still runs on a shadowed `as`. An out-of-union literal
553
+ // is invalid exactly as written, and dead if the spread overwrites it, so
554
+ // removing it is right either way.
555
+ const attr = read.attr ?? findAttr(element, 'as');
556
+ const literal = attr ? literalValueOf(attr) : undefined;
557
+ // Shadowing makes the ELEMENT unknown, not the component. Returning here
558
+ // skipped the union check and the component-level `href` rule as well, so
559
+ // `<Image as="span" {...p} href="/x">` kept both an `as` outside `Image`'s
560
+ // union and an `href` it declares at no `as` -- two facts the spread cannot
561
+ // change.
562
+ dropInertHref(ctx, path, element, target, literal, !read.shadowed);
563
+ // The element as it will render: the `as` if the target takes it, otherwise
564
+ // the component's own. Unknown for a dynamic `as`, and unknown for a target
565
+ // outside `HREF_OK` that was given no `as` -- the default element is only
566
+ // recorded for the nine that can carry a link. So this rule reaches an
567
+ // explicit accepted `as` on any target, plus those nine bare; elsewhere it
568
+ // declines rather than guesses.
569
+ const rendered = read.shadowed
570
+ ? undefined
571
+ : literal && literal.kind === 'string'
572
+ ? acceptsAs(target, literal.value)
573
+ ? literal.value
574
+ : HREF_OK[target]
575
+ : literal
576
+ ? undefined
577
+ : HREF_OK[target];
578
+ dropInertLinkAttrs(ctx, path, element, target, rendered);
579
+ restrictAsValue(ctx, path, element, target, attr, literal);
580
+ // Only now, and only if something element-dependent actually survived. The
581
+ // first version fired on every `as` beside a spread -- `<Title as="h2"
582
+ // {...rest}>` is a complete migration with nothing at stake and was getting
583
+ // a TODO -- and it ran before the passes, so it also claimed attributes
584
+ // were "left as written" that the component-level rules had just removed.
585
+ if (read.shadowed) {
586
+ const left = ['href', ...LINK_ATTRS].filter(n => findAttr(element, n));
587
+ if (left.length) {
588
+ addTodo(ctx, path, 'prop:as', `a spread after \`as\` can overwrite it, so which element \`${target}\` renders here is not knowable -- ${left
589
+ .map(n => `\`${n}\``)
590
+ .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`);
591
+ }
592
+ }
593
+ }
@@ -76,6 +76,15 @@ export declare function modifierClass(ctx: TransformContext, path: ASTPath<any>,
76
76
  * it is picked.
77
77
  */
78
78
  export declare function restrictAsToTargets(ctx: TransformContext, path: ASTPath<any>, element: any, target: string | undefined, allowed: string[], prop?: string): void;
79
+ /**
80
+ * Strip the link attributes a plain element cannot carry.
81
+ *
82
+ * Exported because not every source reaches plain markup through
83
+ * `replaceWithPlain`: react-bulma-components builds six of its replacements
84
+ * by hand, and they bypassed this entirely -- `<Form.Help href="/x">` became
85
+ * `<p className="help" href="/x">`, which does not compile.
86
+ */
87
+ export declare function dropLinkAttrsForPlainTag(ctx: TransformContext, path: ASTPath<any>, element: any, tag: string, where: string): void;
79
88
  /** Attribute filter a source applies before an element becomes plain HTML. */
80
89
  export type AttrStrip = (ctx: TransformContext, path: ASTPath<any>, attrs: any[], where: string) => any[];
81
90
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"specials-utils.d.ts","sourceRoot":"","sources":["../../../src/sources/_shared/specials-utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"specials-utils.d.ts","sourceRoot":"","sources":["../../../src/sources/_shared/specials-utils.ts"],"names":[],"mappings":"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"}