bestax-migrate 2.2.2 → 2.3.1

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 (54) hide show
  1. package/README.md +12 -10
  2. package/dist/sources/_shared/imports.d.ts +28 -0
  3. package/dist/sources/_shared/imports.d.ts.map +1 -1
  4. package/dist/sources/_shared/imports.js +62 -0
  5. package/dist/sources/_shared/jsx-utils.d.ts +17 -0
  6. package/dist/sources/_shared/jsx-utils.d.ts.map +1 -1
  7. package/dist/sources/_shared/jsx-utils.js +49 -1
  8. package/dist/sources/_shared/props.d.ts +17 -0
  9. package/dist/sources/_shared/props.d.ts.map +1 -1
  10. package/dist/sources/_shared/props.js +72 -3
  11. package/dist/sources/_shared/specials-utils.d.ts +49 -3
  12. package/dist/sources/_shared/specials-utils.d.ts.map +1 -1
  13. package/dist/sources/_shared/specials-utils.js +165 -5
  14. package/dist/sources/_shared/viewports.d.ts +17 -0
  15. package/dist/sources/_shared/viewports.d.ts.map +1 -0
  16. package/dist/sources/_shared/viewports.js +32 -0
  17. package/dist/sources/bloomer/deps.d.ts +17 -0
  18. package/dist/sources/bloomer/deps.d.ts.map +1 -0
  19. package/dist/sources/bloomer/deps.js +119 -0
  20. package/dist/sources/bloomer/index.d.ts +3 -0
  21. package/dist/sources/bloomer/index.d.ts.map +1 -0
  22. package/dist/sources/bloomer/index.js +10 -0
  23. package/dist/sources/bloomer/mapping.d.ts +84 -0
  24. package/dist/sources/bloomer/mapping.d.ts.map +1 -0
  25. package/dist/sources/bloomer/mapping.js +988 -0
  26. package/dist/sources/bloomer/responsive.d.ts +39 -0
  27. package/dist/sources/bloomer/responsive.d.ts.map +1 -0
  28. package/dist/sources/bloomer/responsive.js +259 -0
  29. package/dist/sources/bloomer/specials.d.ts +19 -0
  30. package/dist/sources/bloomer/specials.d.ts.map +1 -0
  31. package/dist/sources/bloomer/specials.js +774 -0
  32. package/dist/sources/bloomer/styles.d.ts +13 -0
  33. package/dist/sources/bloomer/styles.d.ts.map +1 -0
  34. package/dist/sources/bloomer/styles.js +17 -0
  35. package/dist/sources/bloomer/transform.d.ts +27 -0
  36. package/dist/sources/bloomer/transform.d.ts.map +1 -0
  37. package/dist/sources/bloomer/transform.js +826 -0
  38. package/dist/sources/rbx/responsive.d.ts.map +1 -1
  39. package/dist/sources/rbx/responsive.js +1 -31
  40. package/dist/sources/rbx/specials.d.ts.map +1 -1
  41. package/dist/sources/rbx/specials.js +14 -91
  42. package/dist/sources/rbx/transform.d.ts.map +1 -1
  43. package/dist/sources/rbx/transform.js +168 -78
  44. package/dist/sources/react-bulma-components/responsive.d.ts.map +1 -1
  45. package/dist/sources/react-bulma-components/responsive.js +2 -14
  46. package/dist/sources/react-bulma-components/specials.d.ts.map +1 -1
  47. package/dist/sources/react-bulma-components/specials.js +33 -17
  48. package/dist/sources/react-bulma-components/transform.d.ts.map +1 -1
  49. package/dist/sources/react-bulma-components/transform.js +168 -78
  50. package/dist/sources/registry.d.ts.map +1 -1
  51. package/dist/sources/registry.js +2 -0
  52. package/dist/types.d.ts +11 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +6 -5
@@ -0,0 +1,774 @@
1
+ /**
2
+ * Structural handlers for bloomer → bestax conversions a rename table cannot
3
+ * express: targets chosen by a prop's value (PageControl `isNext`, NavbarItem
4
+ * `hasDropdown`), wrappers bestax renders itself (Page, whose Pagination.Link
5
+ * child renders its own <li>), replacement with plain HTML where bestax has
6
+ * no component (Help, Label, Heading, BreadcrumbItem, PanelTab, TabLink,
7
+ * HeroVideo), and the two props whose bloomer shape has no single bestax
8
+ * prop (Icon's className, Control's hasIcons).
9
+ *
10
+ * A handler may return a `target` override for the rename step, or mark the
11
+ * element `replaced` when it substituted the node itself.
12
+ */
13
+ import { RENDER_TODO, RESPONSIVE_PROPS, UNIVERSAL_PROPS } from './mapping.js';
14
+ import { addAttr, addTodo, attributesOf, findAttr, literalValueOf, makeAttr, removeAttr, resolveBooleanish, } from '../_shared/jsx-utils.js';
15
+ import { applyPropAction } from '../_shared/props.js';
16
+ import { applyIconProps, makeStripModifierProps, makeStructuralHelpers, modifierClass, parseIconClasses, restrictAsToTargets, } from '../_shared/specials-utils.js';
17
+ /* eslint-disable @typescript-eslint/no-explicit-any */
18
+ const stripModifierProps = makeStripModifierProps(UNIVERSAL_PROPS, RESPONSIVE_PROPS);
19
+ /**
20
+ * Before an element becomes intrinsic HTML: `render` gets its own hint
21
+ * rather than the generic "helper props dropped" one (it is not a helper,
22
+ * and the universal table's TODO would otherwise leave a function prop on a
23
+ * DOM element), then the helper props go.
24
+ */
25
+ const { replaceWithPlain, collapseOntoChild } = makeStructuralHelpers((ctx, path, attrs, where) => {
26
+ const kept = [];
27
+ for (const attr of attrs) {
28
+ if (attr?.name?.name === 'render') {
29
+ addTodo(ctx, path, 'prop:render', `\`render\` — ${RENDER_TODO.todo}`);
30
+ }
31
+ else {
32
+ kept.push(attr);
33
+ }
34
+ }
35
+ return stripModifierProps(ctx, path, kept, where);
36
+ });
37
+ /**
38
+ * bloomer rendered every component through `React.createElement(tag, …)`, so
39
+ * a plain-element rewrite can honour a literal `tag` instead of dropping it.
40
+ */
41
+ function plainTag(ctx, path, element, fallback, where) {
42
+ const attr = findAttr(element, 'tag');
43
+ if (!attr)
44
+ return fallback;
45
+ const literal = literalValueOf(attr);
46
+ removeAttr(element, attr);
47
+ ctx.dirty = true;
48
+ if (literal.kind === 'string')
49
+ return literal.value;
50
+ addTodo(ctx, path, 'prop:tag', `dynamic ${where} \`tag\`; it became a plain <${fallback}> — render the tag yourself`);
51
+ return fallback;
52
+ }
53
+ /** A booleanish modifier prop → an `is-*` class fragment, always removed. */
54
+ function booleanClass(ctx, path, element, prop, cls, where) {
55
+ const attr = findAttr(element, prop);
56
+ if (!attr)
57
+ return undefined;
58
+ const resolved = resolveBooleanish(attr);
59
+ removeAttr(element, attr);
60
+ ctx.dirty = true;
61
+ if (resolved === 'truthy')
62
+ return cls;
63
+ if (resolved === 'expression') {
64
+ addTodo(ctx, path, `prop:${prop}`, `dynamic ${where} \`${prop}\`; set className={${prop} ? '${cls}' : undefined} by hand`);
65
+ }
66
+ return undefined;
67
+ }
68
+ const join = (...parts) => {
69
+ const kept = parts.filter((p) => Boolean(p));
70
+ return kept.length > 0 ? kept.join(' ') : undefined;
71
+ };
72
+ /**
73
+ * Some bloomer components rendered their default `tag` (usually a <div>) and
74
+ * switched to an <a> whenever `href` was set, whatever `tag` said: Button,
75
+ * Delete, LevelItem, DropdownItem, NavbarItem, PanelBlock, CardFooterItem
76
+ * (and the Nav family). bestax's defaults differ per component, so this
77
+ * keeps bloomer's markup for those either way:
78
+ *
79
+ * - with a literal `href`, a surviving `tag` (which the mapping turns into
80
+ * `as`) would undo the anchor on targets that already render one, so it is
81
+ * dropped; where the target defaults to something else, `as="a"` is set
82
+ * (`setAs`). A dynamic `href` was a runtime decision bloomer made and
83
+ * bestax cannot: it is flagged, not guessed;
84
+ * - without `href` or `tag`, a target that defaults to an <a> gets
85
+ * `as={bareAs}` so bloomer's <div> stays a <div>.
86
+ */
87
+ function anchorWhenHref(ctx, path, element, options = {}) {
88
+ const handled = [];
89
+ const tagAttr = findAttr(element, 'tag');
90
+ const hrefAttr = findAttr(element, 'href');
91
+ // bloomer chose the element with `props.href ? 'a' : tag`, so an empty or
92
+ // false `href` kept the default element. It selected nothing, and bestax
93
+ // types `href` as a string where it exists at all, so it is dropped.
94
+ const hrefLiteral = hrefAttr ? literalValueOf(hrefAttr) : undefined;
95
+ const hrefFalsy = hrefLiteral !== undefined &&
96
+ hrefLiteral.kind !== 'expression' &&
97
+ !hrefLiteral.value;
98
+ if (hrefAttr && hrefFalsy) {
99
+ removeAttr(element, hrefAttr);
100
+ handled.push('href');
101
+ ctx.dirty = true;
102
+ }
103
+ if (hrefAttr && !hrefFalsy) {
104
+ const literal = hrefLiteral;
105
+ if (literal.kind === 'expression') {
106
+ addTodo(ctx, path, 'prop:href', options.setAs
107
+ ? `bloomer rendered an <a> only when \`href\` had a value, and this one is dynamic; set \`as="a"\` conditionally by hand${tagAttr ? ' (the `tag` beside it becomes `as`, which that conditional has to account for)' : ''}`
108
+ : `bloomer rendered an <a> only when \`href\` had a value, and this one is dynamic; the bestax target renders an <a> already, so drop the \`href\` where it is empty${tagAttr ? ' — the `tag` beside it is flagged separately' : ''}`);
109
+ return handled;
110
+ }
111
+ if (tagAttr) {
112
+ removeAttr(element, tagAttr);
113
+ handled.push('tag');
114
+ ctx.dirty = true;
115
+ }
116
+ if (options.setAs && !findAttr(element, 'as')) {
117
+ addAttr(element, makeAttr(ctx.j, 'as', 'a'));
118
+ ctx.dirty = true;
119
+ }
120
+ }
121
+ else if (options.bareAs && !tagAttr && !findAttr(element, 'as')) {
122
+ addAttr(element, makeAttr(ctx.j, 'as', options.bareAs));
123
+ ctx.dirty = true;
124
+ }
125
+ return handled;
126
+ }
127
+ /** Font Awesome 4 spelled its one style as a bare `fa` token. */
128
+ const FA4_CLASSES = /(?:^|\s)fa(?:\s|$)/;
129
+ /**
130
+ * Turn an icon-font class string into bestax `Icon` props when the parser
131
+ * can read every token (Font Awesome 5/6 and MDI, modifiers included —
132
+ * those become `features`); otherwise render it the way bloomer did — an <i>
133
+ * child carrying the classes — which bestax's `Icon` accepts as a custom
134
+ * node, and say why that may still need attention.
135
+ */
136
+ function iconFromClasses(ctx, path, element, classes, where) {
137
+ const { j } = ctx;
138
+ if (typeof classes === 'string') {
139
+ const parsed = parseIconClasses(classes);
140
+ // An app's own class on the glyph has no home on bestax's Icon props;
141
+ // keeping the child keeps it.
142
+ if (parsed && parsed.leftovers.length === 0) {
143
+ applyIconProps(ctx, path, element, parsed);
144
+ return;
145
+ }
146
+ }
147
+ const classValue = typeof classes === 'string'
148
+ ? j.stringLiteral(classes)
149
+ : j.jsxExpressionContainer(classes.dynamic);
150
+ const child = j.jsxElement(j.jsxOpeningElement(j.jsxIdentifier('i'), [
151
+ j.jsxAttribute(j.jsxIdentifier('className'), classValue),
152
+ makeAttr(j, 'aria-hidden', 'true'),
153
+ ], true), null, []);
154
+ element.children = [child];
155
+ element.openingElement.selfClosing = false;
156
+ element.closingElement = j.jsxClosingElement(element.openingElement.name);
157
+ ctx.dirty = true;
158
+ const fa4 = typeof classes === 'string' && FA4_CLASSES.test(classes);
159
+ addTodo(ctx, path, `component:${where}`, fa4
160
+ ? `kept the Font Awesome 4 classes on an <i> child, as bloomer rendered them; bestax's optional Font Awesome peer is 6.7+, where many v4 names changed (brand icons moved to \`variant="brands"\`) — keep FA4 loaded, or switch to \`name\`/\`library\`/\`variant\` (\`<Icon name="home" library="fa" variant="solid" />\`)`
161
+ : `kept the icon classes on an <i> child, as bloomer rendered them; bestax renders that child unchanged, so make sure the icon font is still loaded — or switch to \`name\`/\`library\`/\`variant\` (\`<Icon name="home" library="fa" variant="solid" />\`), with any modifier classes in \`features\``);
162
+ }
163
+ /**
164
+ * bloomer's Icon and PanelIcon both put the icon-font classes on their own
165
+ * `className` and rendered `<span class="…"><i class={className}/></span>`,
166
+ * ignoring children. Consume that className into bestax props or a child.
167
+ */
168
+ function iconFromElement(ctx, path, element, where) {
169
+ const { j } = ctx;
170
+ /**
171
+ * bestax's Icon needs a `name` or a child; with neither, bloomer rendered
172
+ * an empty glyph (`<i class="undefined">`). An empty `<i>` child keeps the
173
+ * element typechecking and the markup as it was while the TODO stands.
174
+ */
175
+ const inertChild = () => {
176
+ element.children = [
177
+ j.jsxElement(j.jsxOpeningElement(j.jsxIdentifier('i'), [makeAttr(j, 'aria-hidden', 'true')], true), null, []),
178
+ ];
179
+ element.openingElement.selfClosing = false;
180
+ element.closingElement = j.jsxClosingElement(element.openingElement.name);
181
+ ctx.dirty = true;
182
+ };
183
+ const classAttr = findAttr(element, 'className');
184
+ const solid = (element.children ?? []).filter((c) => !(c.type === 'JSXText' && c.value.trim() === ''));
185
+ if (classAttr) {
186
+ if (solid.length > 0) {
187
+ // bloomer drew the glyph from `className` and never rendered these;
188
+ // the classes win, and the removed markup is named.
189
+ addTodo(ctx, path, `component:${where}`, `removed the children of this ${where}: bloomer never rendered them (the glyph came from \`className\`), and bestax's Icon takes either classes or a child — restore them by hand if they mattered`);
190
+ }
191
+ const literal = literalValueOf(classAttr);
192
+ removeAttr(element, classAttr);
193
+ ctx.dirty = true;
194
+ if (literal.kind === 'string') {
195
+ iconFromClasses(ctx, path, element, literal.value, where);
196
+ }
197
+ else if (literal.kind === 'expression') {
198
+ iconFromClasses(ctx, path, element, { dynamic: classAttr.value.expression }, where);
199
+ }
200
+ else {
201
+ // A bare `className`, or a boolean/number: never icon classes, and
202
+ // `classAttr.value` may be null — no expression to carry over.
203
+ inertChild();
204
+ addTodo(ctx, path, 'prop:className', `\`className\` on ${where} was not an icon-class string (bloomer read the icon-font classes from it); bestax's needs a \`name\` (plus \`library\`/\`variant\`) or a child node — set one by hand`);
205
+ }
206
+ return;
207
+ }
208
+ if (solid.length === 0) {
209
+ inertChild();
210
+ addTodo(ctx, path, `component:${where}`, `no icon classes to carry over (bloomer read them from \`className\`); bestax's needs a \`name\` (plus \`library\`/\`variant\`) or a child node — an empty <i> child keeps it compiling until you set one`);
211
+ return;
212
+ }
213
+ // bloomer drew the glyph from `className` and never rendered its children;
214
+ // bestax renders them. They are kept — dropping markup is worse — but that
215
+ // is a change the user has to see.
216
+ addTodo(ctx, path, `component:${where}`, `bloomer's ${where} never rendered its children (the glyph came from \`className\`); bestax renders them, so check this is what you want to show, or replace them with \`name\`/\`library\`/\`variant\``);
217
+ }
218
+ const SPECIALS = {
219
+ /**
220
+ * `isLink` is Bulma's `is-link` colour and collides with `isColor`, and an
221
+ * `href` made bloomer render an <a> — bestax's Button renders a <button>
222
+ * unless `as` says otherwise.
223
+ */
224
+ button(ctx, path, element) {
225
+ const linkAttr = findAttr(element, 'isLink');
226
+ if (linkAttr) {
227
+ const resolved = resolveBooleanish(linkAttr);
228
+ removeAttr(element, linkAttr);
229
+ ctx.dirty = true;
230
+ if (resolved === 'truthy') {
231
+ if (findAttr(element, 'isColor') || findAttr(element, 'color')) {
232
+ addTodo(ctx, path, 'prop:isLink', '`isLink` alongside `isColor`; bestax `Button` has one `color`, so pick `color="link"` or the other colour by hand');
233
+ }
234
+ else {
235
+ addAttr(element, makeAttr(ctx.j, 'color', 'link'));
236
+ }
237
+ }
238
+ else if (resolved === 'expression') {
239
+ addTodo(ctx, path, 'prop:isLink', 'dynamic `isLink`; set `color="link"` conditionally by hand');
240
+ }
241
+ }
242
+ const handled = anchorWhenHref(ctx, path, element, { setAs: true });
243
+ return { handledProps: ['isLink', ...handled] };
244
+ },
245
+ /**
246
+ * bloomer's Heading is Bulma's `.heading` label (small caps), which Bulma
247
+ * v1 no longer styles and bestax has no component for. It becomes the
248
+ * plain element bloomer rendered, and the TODO says the styling is gone.
249
+ */
250
+ heading(ctx, path, element) {
251
+ addTodo(ctx, path, 'component:Heading', 'Bulma v1 dropped the `.heading` styles, so this plain <p className="heading"> renders unstyled; restyle it with bestax helpers (e.g. `textSize="7" textTransform="uppercase" textWeight="semibold"`) or your own class');
252
+ const tag = plainTag(ctx, path, element, 'p', 'Heading');
253
+ return replaceWithPlain(ctx, path, element, tag, 'heading', 'Heading');
254
+ },
255
+ /**
256
+ * bloomer's Icon put the icon-font classes on its own `className` and
257
+ * rendered `<span class="icon"><i class={className}/></span>`, ignoring
258
+ * children. `isAlign` is Bulma's `.icon.is-left`, which bestax's Icon has
259
+ * no prop for — it goes on the container's className.
260
+ */
261
+ icon(ctx, path, element) {
262
+ const alignAttr = findAttr(element, 'isAlign');
263
+ let alignClass;
264
+ if (alignAttr) {
265
+ const literal = literalValueOf(alignAttr);
266
+ removeAttr(element, alignAttr);
267
+ ctx.dirty = true;
268
+ if (literal.kind === 'string' &&
269
+ (literal.value === 'left' || literal.value === 'right')) {
270
+ alignClass = `is-${literal.value}`;
271
+ }
272
+ else if (literal.kind === 'expression') {
273
+ addTodo(ctx, path, 'prop:isAlign', 'dynamic Icon `isAlign`; bestax `Icon` has no align prop — set className={align ? `is-${align}` : undefined} by hand');
274
+ }
275
+ }
276
+ iconFromElement(ctx, path, element, 'Icon');
277
+ if (alignClass) {
278
+ addAttr(element, makeAttr(ctx.j, 'className', alignClass));
279
+ }
280
+ return { handledProps: ['className', 'isAlign'] };
281
+ },
282
+ /** bloomer's PanelIcon is its Icon in panel clothing: same className API. */
283
+ 'panel-icon'(ctx, path, element) {
284
+ iconFromElement(ctx, path, element, 'PanelIcon');
285
+ return { handledProps: ['className'] };
286
+ },
287
+ /**
288
+ * bloomer's `hasIcons` is `true` (both sides), a side, or an array of
289
+ * sides; bestax has one boolean per side.
290
+ */
291
+ 'control-icons'(ctx, path, element) {
292
+ const attr = findAttr(element, 'hasIcons');
293
+ if (!attr)
294
+ return {};
295
+ const { j } = ctx;
296
+ const side = (value) => {
297
+ if (value === 'left' || value === 'right') {
298
+ const name = value === 'left' ? 'hasIconsLeft' : 'hasIconsRight';
299
+ if (!findAttr(element, name))
300
+ addAttr(element, makeAttr(j, name));
301
+ }
302
+ else {
303
+ addTodo(ctx, path, 'prop:hasIcons', `\`hasIcons\` value ${JSON.stringify(value)} is not "left" or "right"; set \`hasIconsLeft\`/\`hasIconsRight\` by hand`);
304
+ }
305
+ };
306
+ const literal = literalValueOf(attr);
307
+ if (literal.kind === 'boolean') {
308
+ if (literal.value) {
309
+ side('left');
310
+ side('right');
311
+ }
312
+ }
313
+ else if (literal.kind === 'string') {
314
+ side(literal.value);
315
+ }
316
+ else if (attr.value?.type === 'JSXExpressionContainer' &&
317
+ attr.value.expression?.type === 'ArrayExpression') {
318
+ for (const item of attr.value.expression.elements ?? []) {
319
+ if (item?.type === 'StringLiteral')
320
+ side(item.value);
321
+ else {
322
+ addTodo(ctx, path, 'prop:hasIcons', '`hasIcons` entry has a dynamic value; set `hasIconsLeft`/`hasIconsRight` conditionally by hand');
323
+ }
324
+ }
325
+ }
326
+ else {
327
+ addTodo(ctx, path, 'prop:hasIcons', 'dynamic `hasIcons`; set `hasIconsLeft`/`hasIconsRight` conditionally by hand');
328
+ }
329
+ removeAttr(element, attr);
330
+ ctx.dirty = true;
331
+ return { handledProps: ['hasIcons'] };
332
+ },
333
+ /**
334
+ * bloomer's Image takes a fixed size and a ratio as two props and emits
335
+ * both classes; bestax's `size` carries either one.
336
+ */
337
+ image(ctx, path, element) {
338
+ const { j } = ctx;
339
+ const RATIOS = {
340
+ square: 'square',
341
+ '1:1': '1by1',
342
+ '4:3': '4by3',
343
+ '3:2': '3by2',
344
+ '16:9': '16by9',
345
+ '2:1': '2by1',
346
+ };
347
+ const sizeAttr = findAttr(element, 'isSize');
348
+ if (sizeAttr) {
349
+ sizeAttr.name = j.jsxIdentifier('size');
350
+ ctx.dirty = true;
351
+ }
352
+ const ratioAttr = findAttr(element, 'isRatio');
353
+ if (ratioAttr) {
354
+ const literal = literalValueOf(ratioAttr);
355
+ if (sizeAttr) {
356
+ removeAttr(element, ratioAttr);
357
+ addTodo(ctx, path, 'prop:isRatio', 'both `isSize` and `isRatio` were set; bestax `Image` has one `size` prop for either, and Bulma applies only one of the two classes — kept the fixed size, restore the ratio by hand if that is the one you wanted');
358
+ }
359
+ else if (literal.kind === 'string' && RATIOS[literal.value]) {
360
+ ratioAttr.name = j.jsxIdentifier('size');
361
+ ratioAttr.value = j.stringLiteral(RATIOS[literal.value]);
362
+ }
363
+ else {
364
+ removeAttr(element, ratioAttr);
365
+ addTodo(ctx, path, 'prop:isRatio', literal.kind === 'string'
366
+ ? `\`isRatio="${literal.value}"\` is not a ratio bloomer's helpers know; set bestax's \`size\` ("16by9", "4by3", …) by hand`
367
+ : 'dynamic `isRatio`; set bestax\'s `size` ("16by9", "4by3", …) by hand');
368
+ }
369
+ ctx.dirty = true;
370
+ }
371
+ return { handledProps: ['isSize', 'isRatio'] };
372
+ },
373
+ /** bestax has no Help component; Bulma's markup is a plain <p class="help">. */
374
+ help(ctx, path, element) {
375
+ const className = modifierClass(ctx, path, element, 'isColor', 'help', 'Help');
376
+ const tag = plainTag(ctx, path, element, 'p', 'Help');
377
+ return replaceWithPlain(ctx, path, element, tag, className, 'Help');
378
+ },
379
+ /** bestax has no standalone Label; Bulma's markup is <label class="label">. */
380
+ label(ctx, path, element) {
381
+ const className = modifierClass(ctx, path, element, 'isSize', 'label', 'Label');
382
+ const tag = plainTag(ctx, path, element, 'label', 'Label');
383
+ return replaceWithPlain(ctx, path, element, tag, className, 'Label');
384
+ },
385
+ /**
386
+ * bestax's Breadcrumb renders the <ul> and takes plain <li> children —
387
+ * exactly what bloomer's BreadcrumbItem rendered (its children carried the
388
+ * <a>).
389
+ */
390
+ 'breadcrumb-item'(ctx, path, element) {
391
+ const cls = booleanClass(ctx, path, element, 'isActive', 'is-active', 'BreadcrumbItem');
392
+ const tag = plainTag(ctx, path, element, 'li', 'BreadcrumbItem');
393
+ return replaceWithPlain(ctx, path, element, tag, cls, 'BreadcrumbItem');
394
+ },
395
+ /**
396
+ * bloomer's Dropdown owns both the trigger and the menu as children;
397
+ * bestax's takes a `label` and renders the trigger and menu itself, so the
398
+ * shape differs enough that a mechanical rewrite would be a guess.
399
+ */
400
+ dropdown(ctx, path, _element) {
401
+ addTodo(ctx, path, 'component:Dropdown', 'bestax `Dropdown` takes a `label` and renders its own trigger and menu; move the `<DropdownTrigger>` content into `label`, keep the `<DropdownItem>`s as direct children, and drop the `DropdownMenu`/`DropdownContent` wrappers');
402
+ return {};
403
+ },
404
+ /**
405
+ * bestax's Menu.Item renders its own <li><a>; bloomer's MenuLink was the
406
+ * <a> alone, so its docs (and apps) wrap it in a literal <li>. That <li>
407
+ * folds onto the item, carrying its attributes, or the list nests.
408
+ */
409
+ 'menu-link'(ctx, path, element) {
410
+ // bestax's Menu.Item renders <li><a>: `className`, `data-testid`,
411
+ // `style`, `id`, `title`, `role` and `tabIndex` go on the <li>, and
412
+ // everything left (`onClick`, `href`, `target`, `aria-*`, …) on the <a>.
413
+ // bloomer's MenuLink WAS the <a>, so those seven change element.
414
+ const LI_PROPS = [
415
+ 'className',
416
+ 'data-testid',
417
+ 'id',
418
+ 'style',
419
+ 'title',
420
+ 'role',
421
+ 'tabIndex',
422
+ ];
423
+ const toLi = attributesOf(element)
424
+ .map((a) => a.name.name)
425
+ .filter((n) => LI_PROPS.includes(n));
426
+ if (toLi.length > 0) {
427
+ addTodo(ctx, path, 'component:MenuLink', `${toLi.map((n) => `\`${n}\``).join(', ')} sat on bloomer's <a>; bestax's \`Menu.Item\` puts ${toLi.length === 1 ? 'it' : 'them'} on the <li> it renders around the link — move ${toLi.length === 1 ? 'it' : 'them'} by hand if the anchor is what you targeted`);
428
+ }
429
+ const parentPath = path.parent;
430
+ const parent = parentPath?.node;
431
+ if (parent?.type === 'JSXElement' &&
432
+ parent.openingElement?.name?.type === 'JSXIdentifier' &&
433
+ parent.openingElement.name.name === 'li') {
434
+ const siblings = (parent.children ?? []).filter((c) => !(c.type === 'JSXText' && c.value.trim() === ''));
435
+ if (siblings.length === 1 && siblings[0] === element) {
436
+ // The <li>'s className, id, style, title, role and tabIndex land
437
+ // back on the <li> Menu.Item renders, so those are unchanged; anything
438
+ // else on it (onClick, aria-*, a spread that may hold either) ends up
439
+ // on the <a> instead. Name only what actually moves.
440
+ const liMoved = (parent.openingElement.attributes ?? [])
441
+ .map((a) => a.type === 'JSXSpreadAttribute' ? 'a spread' : a.name.name)
442
+ .filter((n) => n === 'a spread' || !LI_PROPS.includes(n));
443
+ if (liMoved.length > 0) {
444
+ addTodo(ctx, path, 'component:MenuLink', `${liMoved.map((n) => (n === 'a spread' ? n : `\`${n}\``)).join(', ')} on the <li> around this MenuLink now applies to the <a> that \`Menu.Item\` renders inside its own <li> — move ${liMoved.length === 1 ? 'it' : 'them'} by hand if the <li> is what you styled`);
445
+ }
446
+ // Spreads first, as one block in source order, so the link's own
447
+ // props still win over them.
448
+ const spreads = (parent.openingElement.attributes ?? []).filter((a) => a.type === 'JSXSpreadAttribute');
449
+ if (spreads.length > 0) {
450
+ element.openingElement.attributes = [
451
+ ...spreads,
452
+ ...(element.openingElement.attributes ?? []),
453
+ ];
454
+ }
455
+ for (const attr of [...(parent.openingElement.attributes ?? [])]) {
456
+ if (attr.type === 'JSXSpreadAttribute')
457
+ continue;
458
+ const name = attr.type === 'JSXAttribute' ? attr.name.name : undefined;
459
+ if (name && findAttr(element, name)) {
460
+ addTodo(ctx, path, 'prop:' + name, `the <li> around this MenuLink and the link both set \`${name}\`; the link's value was kept — reconcile by hand`);
461
+ continue;
462
+ }
463
+ addAttr(element, attr);
464
+ }
465
+ parentPath.replace(element);
466
+ ctx.dirty = true;
467
+ }
468
+ else {
469
+ addTodo(ctx, path, 'component:MenuLink', 'bestax `Menu.Item` renders its own <li>, and this MenuLink shares its <li> with other content — remove the <li> or move the siblings by hand');
470
+ }
471
+ }
472
+ return {};
473
+ },
474
+ /**
475
+ * bestax's Breadcrumb renders its own <ul>; bloomer's was the bare <nav>,
476
+ * so its docs wrote the <ul>. It folds away, or the lists nest.
477
+ */
478
+ breadcrumb(ctx, path, element) {
479
+ const solid = (element.children ?? []).filter((c) => !(c.type === 'JSXText' && c.value.trim() === ''));
480
+ const ul = solid[0];
481
+ if (solid.length === 1 &&
482
+ ul.type === 'JSXElement' &&
483
+ ul.openingElement?.name?.type === 'JSXIdentifier' &&
484
+ ul.openingElement.name.name === 'ul') {
485
+ const attrs = ul.openingElement.attributes ?? [];
486
+ if (attrs.length > 0) {
487
+ addTodo(ctx, path, 'component:Breadcrumb', 'bestax `Breadcrumb` renders its own <ul>, so the attributes on this one were dropped with it — re-apply them to the Breadcrumb by hand');
488
+ }
489
+ element.children = ul.children ?? [];
490
+ ctx.dirty = true;
491
+ return {};
492
+ }
493
+ // bestax's Breadcrumb renders its own <ul>, so any <ul> left inside it
494
+ // nests a second one.
495
+ const nestedList = solid.some((c) => c.type === 'JSXElement' &&
496
+ c.openingElement?.name?.type === 'JSXIdentifier' &&
497
+ c.openingElement.name.name === 'ul');
498
+ if (nestedList) {
499
+ addTodo(ctx, path, 'component:Breadcrumb', 'bestax `Breadcrumb` renders its own <ul>, and this one holds a <ul> the codemod cannot fold (it is not the only child) — remove the inner list by hand, or the markup nests two');
500
+ }
501
+ return {};
502
+ },
503
+ /** bestax's Level.Item renders a <div> unless `as="a"`. */
504
+ 'level-item'(ctx, path, element) {
505
+ return {
506
+ handledProps: anchorWhenHref(ctx, path, element, { setAs: true }),
507
+ };
508
+ },
509
+ /** bloomer's default DropdownItem was a <div>; bestax's Dropdown.Item is an <a>. */
510
+ 'dropdown-item'(ctx, path, element) {
511
+ return {
512
+ handledProps: anchorWhenHref(ctx, path, element, { bareAs: 'div' }),
513
+ };
514
+ },
515
+ /**
516
+ * bestax's Panel.Block is always an <a>; bloomer's was a <div> unless
517
+ * `href` was set (a checkbox label, a search box). Only the anchor case
518
+ * is the bestax component — the rest stays the plain block it was.
519
+ */
520
+ 'panel-block'(ctx, path, element) {
521
+ // The same rule as anchorWhenHref: bloomer chose the anchor with
522
+ // `props.href ? 'a' : tag`, so an empty or false href is no anchor.
523
+ const hrefAttr = findAttr(element, 'href');
524
+ const hrefLiteral = hrefAttr ? literalValueOf(hrefAttr) : undefined;
525
+ const anchored = hrefAttr !== undefined &&
526
+ (hrefLiteral.kind === 'expression' || Boolean(hrefLiteral.value));
527
+ if (hrefAttr && !anchored) {
528
+ // A falsy href selected nothing in bloomer and cannot sit on a <div>.
529
+ removeAttr(element, hrefAttr);
530
+ ctx.dirty = true;
531
+ }
532
+ if (anchored) {
533
+ return { handledProps: anchorWhenHref(ctx, path, element) };
534
+ }
535
+ const cls = join('panel-block', booleanClass(ctx, path, element, 'isActive', 'is-active', 'PanelBlock'), booleanClass(ctx, path, element, 'isWrapped', 'is-wrapped', 'PanelBlock'));
536
+ const tag = plainTag(ctx, path, element, 'div', 'PanelBlock');
537
+ return replaceWithPlain(ctx, path, element, tag, cls, 'PanelBlock');
538
+ },
539
+ /**
540
+ * bloomer's Modal was an inert shell: no Escape handling, no scroll lock,
541
+ * no portal. bestax's closes on Escape and locks body scroll by default
542
+ * (#633) and renders inline unless `portal` is set — so every conversion
543
+ * changes behaviour, and this says how.
544
+ */
545
+ modal(ctx, path, _element) {
546
+ addTodo(ctx, path, 'component:Modal', "bestax `Modal` closes on Escape (calling `onClose`) and locks body scroll by default, which bloomer's never did: pass `onClose`, or set `closeOnEscape={false}` / `lockScroll={false}` to keep bloomer's behaviour; it renders inline unless `portal` is set");
547
+ return {};
548
+ },
549
+ /**
550
+ * bloomer's NavbarItem carries `hasDropdown`, which bestax splits into the
551
+ * Navbar.Dropdown container (`navbar-item has-dropdown`) and Navbar.Item.
552
+ */
553
+ 'navbar-item'(ctx, path, element) {
554
+ let target = 'Navbar.Item';
555
+ const dropdownAttr = findAttr(element, 'hasDropdown');
556
+ if (dropdownAttr) {
557
+ const resolved = resolveBooleanish(dropdownAttr);
558
+ removeAttr(element, dropdownAttr);
559
+ ctx.dirty = true;
560
+ if (resolved === 'truthy') {
561
+ target = 'Navbar.Dropdown';
562
+ }
563
+ else if (resolved === 'expression') {
564
+ addTodo(ctx, path, 'prop:hasDropdown', 'dynamic NavbarItem `hasDropdown`; pick between `<Navbar.Item>` and `<Navbar.Dropdown>` by hand');
565
+ }
566
+ }
567
+ // `hoverable` exists on bestax's Navbar.Dropdown, not on Navbar.Item.
568
+ const hoverAttr = findAttr(element, 'isHoverable');
569
+ if (hoverAttr) {
570
+ if (target === 'Navbar.Dropdown') {
571
+ hoverAttr.name = ctx.j.jsxIdentifier('hoverable');
572
+ }
573
+ else {
574
+ removeAttr(element, hoverAttr);
575
+ addTodo(ctx, path, 'prop:isHoverable', 'bestax `Navbar.Item` has no `hoverable` prop (only `Navbar.Dropdown` does); restructure or add className="is-hoverable"');
576
+ }
577
+ ctx.dirty = true;
578
+ }
579
+ // bloomer's default NavbarItem was a <div>; bestax's Navbar.Item is an <a>.
580
+ // The dropdown container has neither `href` nor `as`.
581
+ let handled = [];
582
+ if (target === 'Navbar.Item') {
583
+ handled = anchorWhenHref(ctx, path, element, { bareAs: 'div' });
584
+ }
585
+ else {
586
+ const hrefAttr = findAttr(element, 'href');
587
+ if (hrefAttr) {
588
+ removeAttr(element, hrefAttr);
589
+ ctx.dirty = true;
590
+ addTodo(ctx, path, 'prop:href', 'bestax `Navbar.Dropdown` is the container and takes no `href`; put it on the `<Navbar.Link>` inside');
591
+ }
592
+ }
593
+ restrictAsToTargets(ctx, path, element, target, ['Navbar.Item'], 'tag');
594
+ return {
595
+ target,
596
+ handledProps: ['hasDropdown', 'isHoverable', ...handled],
597
+ };
598
+ },
599
+ /**
600
+ * bloomer's NavbarDropdown is the MENU (`div.navbar-dropdown`); bestax
601
+ * calls that `Navbar.DropdownMenu` and reserves `Navbar.Dropdown` for the
602
+ * container — which is what `<NavbarItem hasDropdown>` becomes.
603
+ */
604
+ 'navbar-dropdown'(ctx, path, element) {
605
+ const boxedAttr = findAttr(element, 'isBoxed');
606
+ if (boxedAttr) {
607
+ applyPropAction(ctx, path, element, boxedAttr, { toClass: 'is-boxed' });
608
+ }
609
+ return { target: 'Navbar.DropdownMenu', handledProps: ['isBoxed'] };
610
+ },
611
+ /** bestax's `align` is `'centered' | 'right'`; left is Bulma's default. */
612
+ pagination(ctx, path, element) {
613
+ const attr = findAttr(element, 'isAlign');
614
+ if (!attr)
615
+ return {};
616
+ const literal = literalValueOf(attr);
617
+ if (literal.kind === 'string' && literal.value === 'left') {
618
+ removeAttr(element, attr);
619
+ }
620
+ else {
621
+ attr.name = ctx.j.jsxIdentifier('align');
622
+ if (literal.kind === 'expression') {
623
+ addTodo(ctx, path, 'prop:isAlign', 'dynamic Pagination `isAlign`; bestax `align` takes "centered" or "right" only — map "left" (the default) to undefined by hand');
624
+ }
625
+ }
626
+ ctx.dirty = true;
627
+ return { handledProps: ['isAlign'] };
628
+ },
629
+ /**
630
+ * bloomer's PageControl: `pagination-next` only when `isNext && !isPrevious`,
631
+ * `pagination-previous` whenever `!isNext` — so a link with both set
632
+ * rendered no direction at all, and a dynamic `isPrevious` beside `isNext`
633
+ * decided it at runtime. Both are flagged rather than guessed.
634
+ */
635
+ 'page-control'(ctx, path, element) {
636
+ const nextAttr = findAttr(element, 'isNext');
637
+ const prevAttr = findAttr(element, 'isPrevious');
638
+ const next = nextAttr ? resolveBooleanish(nextAttr) : 'falsy';
639
+ const prev = prevAttr ? resolveBooleanish(prevAttr) : 'falsy';
640
+ if (nextAttr)
641
+ removeAttr(element, nextAttr);
642
+ if (prevAttr)
643
+ removeAttr(element, prevAttr);
644
+ ctx.dirty = true;
645
+ let target = 'Pagination.Previous';
646
+ if (next === 'expression') {
647
+ addTodo(ctx, path, 'prop:isNext', 'dynamic PageControl `isNext`; pick between `<Pagination.Previous>` and `<Pagination.Next>` by hand');
648
+ }
649
+ else if (next === 'truthy' && prev === 'truthy') {
650
+ addTodo(ctx, path, 'prop:isPrevious', 'PageControl had both `isNext` and `isPrevious`, which bloomer rendered with no direction class at all; it became `<Pagination.Previous>` — pick the direction you meant');
651
+ }
652
+ else if (next === 'truthy' && prev === 'expression') {
653
+ addTodo(ctx, path, 'prop:isPrevious', 'dynamic PageControl `isPrevious` beside `isNext`; bloomer showed the next link only while it was false — pick between `<Pagination.Previous>` and `<Pagination.Next>` by hand');
654
+ }
655
+ else if (next === 'truthy') {
656
+ target = 'Pagination.Next';
657
+ }
658
+ return { target, handledProps: ['isNext', 'isPrevious'] };
659
+ },
660
+ /**
661
+ * bloomer's Page is the bare <li> around a PageLink or PageEllipsis;
662
+ * bestax's Pagination.Link and Pagination.Ellipsis render their own <li>,
663
+ * so the wrapper folds onto its child.
664
+ */
665
+ page(ctx, path, element) {
666
+ const isLink = (node) => node?.type === 'JSXElement' &&
667
+ ['PageLink', 'PageEllipsis'].includes(ctx.resolve?.(node.openingElement?.name)?.join('.') ?? '');
668
+ const solid = (element.children ?? []).filter((c) => !(c.type === 'JSXText' && c.value.trim() === ''));
669
+ // Exactly the shape collapseOntoChild folds: one JSX child, and it is
670
+ // the link. A link reached any other way (in an expression, beside a
671
+ // sibling) still renders its own <li>, so the plain <li> this becomes
672
+ // would nest one — said, not silently produced.
673
+ const foldable = solid.length === 1 && isLink(solid[0]);
674
+ if (!foldable) {
675
+ const nested = ctx
676
+ .j(element)
677
+ .find(ctx.j.JSXElement)
678
+ .paths()
679
+ .some(p => p.node !== element && isLink(p.node));
680
+ if (nested) {
681
+ addTodo(ctx, path, 'component:Page', "bestax's `Pagination.Link` and `Pagination.Ellipsis` render their own <li>, and this Page wraps one in a way the codemod cannot fold (an expression, or beside other children) — remove the wrapper by hand");
682
+ }
683
+ }
684
+ if (foldable) {
685
+ // The child renders the <li> itself and puts its props on the <a> (or
686
+ // the ellipsis <span>), so whatever else sat on this <li> — a class, an
687
+ // id, a helper prop — lands on a different element after the fold.
688
+ // It is moved rather than lost, and named.
689
+ const moved = (element.openingElement.attributes ?? [])
690
+ .map((a) => a.type === 'JSXSpreadAttribute'
691
+ ? 'a spread'
692
+ : a.name.type === 'JSXNamespacedName'
693
+ ? `${a.name.namespace.name}:${a.name.name.name}`
694
+ : a.name.name)
695
+ .filter((n) => n !== 'tag' && n !== 'key');
696
+ if (moved.length > 0) {
697
+ addTodo(ctx, path, 'component:Page', `${moved.map((n) => `\`${n}\``).join(', ')} sat on the Page's <li>; bestax's \`Pagination.Link\` and \`Pagination.Ellipsis\` render their own <li> and put props on the element inside it, so ${moved.length === 1 ? 'it now applies' : 'they now apply'} there — move ${moved.length === 1 ? 'it' : 'them'} by hand if the <li> is what you styled`);
698
+ }
699
+ // The child renders the <li> itself, so a `tag` on the wrapper has
700
+ // nothing left to apply to. bloomer's default is the same <li>; anything
701
+ // else is a change bestax cannot express.
702
+ const tagAttr = findAttr(element, 'tag');
703
+ if (tagAttr) {
704
+ const literal = literalValueOf(tagAttr);
705
+ removeAttr(element, tagAttr);
706
+ ctx.dirty = true;
707
+ if (!(literal.kind === 'string' && literal.value === 'li')) {
708
+ addTodo(ctx, path, 'prop:tag', 'bestax `Pagination.Link` and `Pagination.Ellipsis` render their own <li>, so the `tag` on this Page has nowhere to go; restyle the item by hand if it mattered');
709
+ }
710
+ }
711
+ }
712
+ const collapsed = collapseOntoChild(ctx, path, element, 'Page', [
713
+ 'PageLink',
714
+ 'PageEllipsis',
715
+ ]);
716
+ if (collapsed)
717
+ return collapsed;
718
+ const tag = plainTag(ctx, path, element, 'li', 'Page');
719
+ return replaceWithPlain(ctx, path, element, tag, undefined, 'Page');
720
+ },
721
+ /** bestax's Panel.Tabs takes plain anchors. */
722
+ 'panel-tab'(ctx, path, element) {
723
+ const cls = booleanClass(ctx, path, element, 'isActive', 'is-active', 'PanelTab');
724
+ const tag = plainTag(ctx, path, element, 'a', 'PanelTab');
725
+ return replaceWithPlain(ctx, path, element, tag, cls, 'PanelTab');
726
+ },
727
+ /** bestax's Tabs.Item is the <li>; the anchor inside it is plain markup. */
728
+ 'tab-link'(ctx, path, element) {
729
+ const tag = plainTag(ctx, path, element, 'a', 'TabLink');
730
+ return replaceWithPlain(ctx, path, element, tag, undefined, 'TabLink');
731
+ },
732
+ /** Bulma v1 still ships `.hero-video`; bestax's Hero has no part for it. */
733
+ 'hero-video'(ctx, path, element) {
734
+ const cls = join('hero-video', booleanClass(ctx, path, element, 'isTransparent', 'is-transparent', 'HeroVideo'));
735
+ const tag = plainTag(ctx, path, element, 'div', 'HeroVideo');
736
+ return replaceWithPlain(ctx, path, element, tag, cls, 'HeroVideo');
737
+ },
738
+ /**
739
+ * bloomer's Subtitle defaulted to an <h2>; bestax's SubTitle defaults to an
740
+ * <h1>. Keeping the heading level is what a screen reader hears.
741
+ */
742
+ subtitle(ctx, _path, element) {
743
+ if (!findAttr(element, 'tag') && !findAttr(element, 'as')) {
744
+ addAttr(element, makeAttr(ctx.j, 'as', 'h2'));
745
+ ctx.dirty = true;
746
+ }
747
+ return {};
748
+ },
749
+ /**
750
+ * bloomer's ModalClose always rendered Bulma's `.modal-close` overlay
751
+ * button. bestax's Modal.Close defaults to `variant="delete"` — the small X
752
+ * for a card header — and honours `size` only on the floating variant, so
753
+ * the variant has to be named for the markup to survive.
754
+ */
755
+ 'modal-close'(ctx, _path, element) {
756
+ if (!findAttr(element, 'variant')) {
757
+ addAttr(element, makeAttr(ctx.j, 'variant', 'floating'));
758
+ ctx.dirty = true;
759
+ }
760
+ return {};
761
+ },
762
+ /** Marks the element for the column size pass in responsive.ts. */
763
+ column() {
764
+ return {};
765
+ },
766
+ };
767
+ export function runSpecial(name, ctx, path, element) {
768
+ const handler = SPECIALS[name];
769
+ if (!handler) {
770
+ throw new Error(`bloomer mapping names an unknown special handler: ${name}`);
771
+ }
772
+ return handler(ctx, path, element);
773
+ }
774
+ export const SPECIAL_NAMES = Object.keys(SPECIALS);