bestax-migrate 2.2.1 → 2.3.0

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 (57) 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/mapping.d.ts.map +1 -1
  39. package/dist/sources/rbx/mapping.js +20 -18
  40. package/dist/sources/rbx/responsive.d.ts.map +1 -1
  41. package/dist/sources/rbx/responsive.js +1 -31
  42. package/dist/sources/rbx/specials.d.ts.map +1 -1
  43. package/dist/sources/rbx/specials.js +36 -98
  44. package/dist/sources/rbx/transform.d.ts.map +1 -1
  45. package/dist/sources/rbx/transform.js +168 -78
  46. package/dist/sources/react-bulma-components/mapping.js +4 -4
  47. package/dist/sources/react-bulma-components/responsive.d.ts.map +1 -1
  48. package/dist/sources/react-bulma-components/responsive.js +2 -14
  49. package/dist/sources/react-bulma-components/specials.d.ts.map +1 -1
  50. package/dist/sources/react-bulma-components/specials.js +45 -18
  51. package/dist/sources/react-bulma-components/transform.d.ts.map +1 -1
  52. package/dist/sources/react-bulma-components/transform.js +168 -78
  53. package/dist/sources/registry.d.ts.map +1 -1
  54. package/dist/sources/registry.js +2 -0
  55. package/dist/types.d.ts +11 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/package.json +4 -3
@@ -0,0 +1,826 @@
1
+ /**
2
+ * The bloomer (0.6) → @allxsmith/bestax-bulma transform.
3
+ *
4
+ * Passes, in order:
5
+ * 1. resolve imports (named/namespace) and `const { Item } = Card`-style
6
+ * destructuring into canonical bloomer component paths
7
+ * 2. per-element: structural special → rename → helper flattening
8
+ * (isDisplay / isHidden / Column sizes) → component prop map →
9
+ * universal helper props
10
+ * 3. rewrite imports: drop bloomer, add one merged bestax-bulma import; keep
11
+ * a trimmed bloomer import (with a TODO) only for components that have
12
+ * no bestax equivalent (Tile, the Nav family, …)
13
+ *
14
+ * Anything unsafe gets a `// TODO(bestax-migrate)` comment on the enclosing
15
+ * statement plus an entry in the run report.
16
+ *
17
+ * Next to the bloomer transform this is the plain case: bloomer has no helper
18
+ * props that become wrapping components, no stylesheet of its own and no
19
+ * extension packages — so there is no wrapping pass and the CSS pass only
20
+ * ever sees the app's own Bulma import. Every bloomer export is a flat name,
21
+ * and most of them rename onto a dotted bestax compound; the rename and
22
+ * value-reference passes below build those from the mapping's `target`.
23
+ */
24
+ import { HELPERLESS_TARGETS, MAPPING, NO_CLASSNAME_TARGETS, UNIVERSAL_PROPS, helperClassHint, resolveMapping, } from './mapping.js';
25
+ import { addTodo, attributesOf, jsxNameParts, literalValueOf, removeAttr, renameElement, } from '../_shared/jsx-utils.js';
26
+ import { applyPropAction, applyUniversalProps, mergeClass, } from '../_shared/props.js';
27
+ import { collectBoundNames, makeReserve, nameOf, prefersTabs, resolvesToBinding, } from '../_shared/imports.js';
28
+ import { flattenResponsiveProps, RESPONSIVE_KINDS } from './responsive.js';
29
+ import { runSpecial } from './specials.js';
30
+ /* eslint-disable @typescript-eslint/no-explicit-any */
31
+ const BLOOMER = 'bloomer';
32
+ const BESTAX = '@allxsmith/bestax-bulma';
33
+ const BESTAX_CSS = '@allxsmith/bestax-bulma/bestax.css';
34
+ const EXTRAS_CSS = '@allxsmith/bestax-bulma/extras.css';
35
+ const BULMA_CSS_SPECIFIERS = new Set([
36
+ 'bulma/css/bulma.css',
37
+ 'bulma/css/bulma.min.css',
38
+ ]);
39
+ const BESTAX_CSS_SPECIFIERS = new Set([
40
+ BESTAX_CSS,
41
+ '@allxsmith/bestax-bulma/bestax.min.css',
42
+ '@allxsmith/bestax-bulma/dist/bestax.css',
43
+ '@allxsmith/bestax-bulma/dist/bestax.min.css',
44
+ ]);
45
+ const EXTRAS_CSS_SPECIFIERS = new Set([
46
+ EXTRAS_CSS,
47
+ '@allxsmith/bestax-bulma/dist/extras.css',
48
+ ]);
49
+ export default function transform(fileInfo, api, options = {}) {
50
+ const j = api.jscodeshift;
51
+ const root = j(fileInfo.source);
52
+ const ctx = {
53
+ j,
54
+ file: fileInfo.path,
55
+ collector: options.collector,
56
+ retained: new Set(),
57
+ needed: new Map(),
58
+ reserve: root => root, // replaced below once local bindings are known
59
+ overrides: new WeakMap(),
60
+ dirty: false,
61
+ };
62
+ // ---- 1. Collect bloomer imports -------------------------------------------
63
+ /** local identifier → imported bloomer name ('*' for namespace imports). */
64
+ const imports = new Map();
65
+ const sourceImportPaths = [];
66
+ /** Whether any bloomer import declaration carried a (broken) default binding. */
67
+ let sawDefaultImport = false;
68
+ /**
69
+ * Whether a namespace binding (`import * as bloomer`) is still referenced as a
70
+ * VALUE after the JSX pass. JSX names like `<bloomer.Box>` are rewritten away,
71
+ * but `as={bloomer.Block}` or a bare `String(bloomer)` are not — and pruning the
72
+ * import under them leaves `bloomer is not defined`.
73
+ */
74
+ let namespaceStillReferenced = false;
75
+ root.find(j.ImportDeclaration).forEach(path => {
76
+ const source = String(path.node.source.value);
77
+ if (source === BLOOMER) {
78
+ sourceImportPaths.push(path);
79
+ for (const spec of path.node.specifiers ?? []) {
80
+ if (spec.type === 'ImportSpecifier' && spec.local) {
81
+ imports.set(nameOf(spec.local), nameOf(spec.imported));
82
+ }
83
+ else if (spec.type === 'ImportNamespaceSpecifier' && spec.local) {
84
+ imports.set(nameOf(spec.local), '*');
85
+ }
86
+ else if (spec.type === 'ImportDefaultSpecifier') {
87
+ // bloomer has no default export, so this binding is already broken —
88
+ // but it may still be referenced, and dropping it turns a bad
89
+ // import into `X is not defined`. Flag it and keep it.
90
+ sawDefaultImport = true;
91
+ addTodo(ctx, path, 'imports', 'bloomer has no default export; convert to named imports first');
92
+ }
93
+ }
94
+ }
95
+ else if (source.startsWith(`${BLOOMER}/`)) {
96
+ // Deep imports (`bloomer/lib/elements/Button`) reach the compiled
97
+ // internals; the same component is exported from the package root,
98
+ // which is the only surface the mapping knows.
99
+ addTodo(ctx, path, 'imports', `\`${source}\` reaches into bloomer's internals; import the component from 'bloomer' first, then re-run the codemod`);
100
+ }
101
+ });
102
+ // A barrel re-export reaches the library without an import declaration,
103
+ // so nothing above saw it; it cannot be rewritten (a compound bestax
104
+ // target has no single name to re-export) and must not vanish silently.
105
+ root
106
+ .find(j.ExportNamedDeclaration)
107
+ .filter(p => String(p.node.source?.value ?? '') === BLOOMER)
108
+ .forEach(p => {
109
+ addTodo(ctx, p, 'imports', `re-exports from '${BLOOMER}' were not migrated; import the components, migrate them, then re-export the bestax ones by hand`);
110
+ });
111
+ root
112
+ .find(j.ExportAllDeclaration)
113
+ .filter(p => String(p.node.source?.value ?? '') === BLOOMER)
114
+ .forEach(p => {
115
+ addTodo(ctx, p, 'imports', `\`export * from '${BLOOMER}'\` was not migrated; re-export the bestax components you need by hand`);
116
+ });
117
+ // ---- 1a. Stylesheet imports (mode-driven) -----------------------------
118
+ // `bestax` (default): everything converges on the recommended combined
119
+ // bundle. `bulma`: plain Bulma v1 CSS plus the separate extras file.
120
+ // `keep`: nothing to do — bloomer has no stylesheet of its own.
121
+ const cssMode = options.cssMode ?? 'bestax';
122
+ let sawBestaxCss = root
123
+ .find(j.ImportDeclaration)
124
+ .paths()
125
+ .some(p => BESTAX_CSS_SPECIFIERS.has(String(p.node.source.value)));
126
+ // Whether some import in this file will become bestax.css, regardless of
127
+ // where it sits relative to an existing extras import.
128
+ const willAdoptBestaxCss = cssMode === 'bestax' &&
129
+ root
130
+ .find(j.ImportDeclaration)
131
+ .paths()
132
+ .some(p => BULMA_CSS_SPECIFIERS.has(String(p.node.source.value)));
133
+ root.find(j.ImportDeclaration).forEach(path => {
134
+ const source = String(path.node.source.value);
135
+ const isBulmaCss = BULMA_CSS_SPECIFIERS.has(source);
136
+ const isExtrasCss = EXTRAS_CSS_SPECIFIERS.has(source);
137
+ if (!isBulmaCss && !isExtrasCss)
138
+ return;
139
+ if (cssMode === 'bestax') {
140
+ if (isBulmaCss) {
141
+ if (sawBestaxCss) {
142
+ path.prune(); // bestax.css already imported elsewhere in this file
143
+ }
144
+ else {
145
+ path.node.source = j.stringLiteral(BESTAX_CSS);
146
+ sawBestaxCss = true;
147
+ }
148
+ ctx.dirty = true;
149
+ }
150
+ else if (isExtrasCss && (sawBestaxCss || willAdoptBestaxCss)) {
151
+ // bestax.css already contains the extras. `willAdoptBestaxCss` covers
152
+ // the case where the extras import comes FIRST in the file and the
153
+ // bulma import that becomes bestax.css has not been visited yet —
154
+ // previously the extras survived alongside it and double-loaded.
155
+ path.prune();
156
+ ctx.dirty = true;
157
+ }
158
+ }
159
+ else if (cssMode === 'bulma') {
160
+ if (isBulmaCss && !sawBestaxCss) {
161
+ const hasExtras = root
162
+ .find(j.ImportDeclaration)
163
+ .paths()
164
+ .some(p => EXTRAS_CSS_SPECIFIERS.has(String(p.node.source.value)));
165
+ if (!hasExtras) {
166
+ // Themed Radio/Checkbox need the bestax extras next to plain Bulma.
167
+ path.insertAfter(j.importDeclaration([], j.stringLiteral(EXTRAS_CSS)));
168
+ ctx.dirty = true;
169
+ }
170
+ }
171
+ }
172
+ });
173
+ if (imports.size === 0 && !ctx.dirty) {
174
+ return undefined;
175
+ }
176
+ // The scope each binding was collected in, so a reference can be checked
177
+ // against the binding it actually resolves to rather than matched by name.
178
+ const programScope = root.find(j.Program).paths()[0]?.scope;
179
+ // There is no destructuring pass: every bloomer export is a flat component
180
+ // with no sub-components, so `const { Item } = Card` has nothing to bind.
181
+ /** Resolve a JSX name into a canonical bloomer component path, or null. */
182
+ function resolveJsxPath(name) {
183
+ const parts = jsxNameParts(name);
184
+ if (!parts)
185
+ return null;
186
+ const [head, ...rest] = parts;
187
+ const imported = imports.get(head);
188
+ if (imported === undefined)
189
+ return null;
190
+ if (imported === '*')
191
+ return rest.length > 0 ? rest : null;
192
+ return [imported, ...rest];
193
+ }
194
+ ctx.resolve = resolveJsxPath;
195
+ // ---- 1c. Collect local bindings so new imports never collide -----------
196
+ const bound = collectBoundNames(j, root, BLOOMER);
197
+ // Seed the reserved set with the local names of bloomer imports that CANNOT be
198
+ // migrated, so a bestax import never claims one of them. Without this, an
199
+ // unmappable component aliased to a name bestax also wants
200
+ // (`import { Tile as Button }`) had its retained specifier dropped on the
201
+ // collision — and its `<Button>` JSX then silently resolved to the bestax
202
+ // Button. A component quietly became a different component.
203
+ //
204
+ // Keyed on the mapping status so only genuinely unmappable names force an
205
+ // alias; the common case (`Button` → `Button`) stays un-aliased.
206
+ for (const [local, imported] of imports) {
207
+ if (imported === '*')
208
+ continue;
209
+ const entry = MAPPING[imported];
210
+ // Unmappable roots, plus ANY aliased import. A root can be retained by an
211
+ // unknown or `todo` child even when the root itself maps (`Icon` is
212
+ // `partial`, but `<Icon.Unknown>` retains it), and when the local name
213
+ // differs from the imported one that retained binding can collide with a
214
+ // bestax local for a completely different component. An unaliased import
215
+ // is safe: its name already means the same component on both sides.
216
+ if (!entry || entry.status === 'todo' || local !== imported) {
217
+ bound.add(local);
218
+ }
219
+ }
220
+ // A root that DOES map can still be retained by a child the table cannot
221
+ // (`Icon` is `partial`, but `<Icon.Unknown>` keeps the source import alive).
222
+ // The status check above misses that case whenever the local name equals the
223
+ // imported one, so the bestax import took the plain local, the retained
224
+ // specifier was dropped on the collision, and `<Icon.Unknown>` silently
225
+ // resolved to the bestax component instead.
226
+ //
227
+ // Scanned rather than reserved wholesale: adding every partial root to
228
+ // `bound` would alias them in every file that uses one, whether or not
229
+ // anything is actually retained.
230
+ root.find(j.JSXElement).forEach(path => {
231
+ const head = jsxNameParts(path.node.openingElement.name)?.[0];
232
+ if (!head || !imports.has(head))
233
+ return;
234
+ const parts = resolveJsxPath(path.node.openingElement.name);
235
+ const mapping = parts ? resolveMapping(parts) : undefined;
236
+ if (!mapping || mapping.status === 'todo')
237
+ bound.add(head);
238
+ });
239
+ // The same root can be retained by a VALUE chain -- `const X = Icon.Unknown`
240
+ // -- which only the value-reference pass discovers, after `reserve` has
241
+ // already handed the plain local to bestax. Scan those chains here too.
242
+ root.find(j.Identifier).forEach(path => {
243
+ if (path.node.type !== 'Identifier')
244
+ return;
245
+ const head = path.node.name;
246
+ const imported = imports.get(head);
247
+ if (imported === undefined || imported === '*')
248
+ return;
249
+ const parent = path.parent?.node;
250
+ // A target-less special (NavbarItem, Help, PageControl, …) used as a bare
251
+ // value is retained by the value-reference pass, so its local must not be
252
+ // handed to a bestax import first.
253
+ if (parent?.type !== 'ImportSpecifier' &&
254
+ parent?.type !== 'MemberExpression') {
255
+ const bare = MAPPING[imported];
256
+ if (bare && bare.status !== 'todo' && !bare.target)
257
+ bound.add(head);
258
+ return;
259
+ }
260
+ if (parent?.type !== 'MemberExpression' ||
261
+ parent.object !== path.node ||
262
+ parent.computed) {
263
+ return;
264
+ }
265
+ const chain = [imported];
266
+ let outer = path.parent;
267
+ while (outer?.node?.type === 'MemberExpression' &&
268
+ !outer.node.computed &&
269
+ outer.node.property?.type === 'Identifier') {
270
+ chain.push(nameOf(outer.node.property));
271
+ const next = outer.parent;
272
+ if (next?.node?.type === 'MemberExpression' &&
273
+ next.node.object === outer.node) {
274
+ outer = next;
275
+ }
276
+ else {
277
+ break;
278
+ }
279
+ }
280
+ const mapping = resolveMapping(chain);
281
+ if (!mapping || mapping.status === 'todo')
282
+ bound.add(head);
283
+ });
284
+ ctx.reserve = makeReserve(ctx, bound);
285
+ // Names the passes actually asked for. `ctx.needed` is also seeded from an
286
+ // existing bestax import's specifiers below (so JSX reuses their locals),
287
+ // and that seeding alone must not turn a type-only specifier into a value
288
+ // import nobody needed.
289
+ const requested = new Set();
290
+ const baseReserve = ctx.reserve;
291
+ ctx.reserve = root => {
292
+ requested.add(root);
293
+ return baseReserve(root);
294
+ };
295
+ // Merge with an existing bestax import: reuse its locals verbatim.
296
+ // Only a declaration that already uses NAMED specifiers can absorb more of
297
+ // them. `import * as Bestax from '…'` cannot: a namespace specifier may not
298
+ // share a declaration with named ones, and appending to it emits a file
299
+ // that does not parse.
300
+ const existingBestax = root
301
+ .find(j.ImportDeclaration, { source: { value: BESTAX } })
302
+ .paths()
303
+ .find(p =>
304
+ // A type-only declaration cannot take a value specifier: merging a
305
+ // component into `import type { … }` erases it at runtime.
306
+ p.node.importKind !== 'type' &&
307
+ (p.node.specifiers ?? []).every((spec) => spec.type === 'ImportSpecifier'));
308
+ const preExistingImports = new Set();
309
+ if (existingBestax) {
310
+ for (const spec of existingBestax.node.specifiers ?? []) {
311
+ if (spec.type === 'ImportSpecifier' && spec.local) {
312
+ // The local name is reused either way, so the JSX never needs an
313
+ // alias. But an inline `type` specifier (`import { type Box }`) is
314
+ // not a value binding: it is not counted as already imported, so the
315
+ // component is written as a value below — onto this specifier, which
316
+ // keeps its name, rather than as a duplicate.
317
+ ctx.needed.set(nameOf(spec.imported), nameOf(spec.local));
318
+ if (spec.importKind !== 'type') {
319
+ preExistingImports.add(nameOf(spec.imported));
320
+ }
321
+ }
322
+ }
323
+ }
324
+ // ---- 2. Transform JSX elements ----------------------------------------
325
+ root.find(j.JSXElement).forEach(path => {
326
+ const element = path.node;
327
+ const sourcePath = resolveJsxPath(element.openingElement.name);
328
+ if (!sourcePath)
329
+ return;
330
+ // Resolve by BINDING, not by name. `function F({ Card })` shadows the
331
+ // import with the caller's object, and rewriting its JSX to bestax's
332
+ // `Card.FooterItem` changed which component rendered.
333
+ const jsxHead = jsxNameParts(element.openingElement.name)?.[0];
334
+ if (jsxHead && !resolvesToBinding(path, jsxHead, programScope)) {
335
+ return;
336
+ }
337
+ const mapping = resolveMapping(sourcePath);
338
+ const dotted = sourcePath.join('.');
339
+ if (!mapping) {
340
+ ctx.retained.add(sourcePath[0]);
341
+ addTodo(ctx, path, 'unknown-component', `\`${dotted}\` is not a known bloomer export; migrate it by hand`);
342
+ return;
343
+ }
344
+ if (mapping.status === 'todo') {
345
+ ctx.retained.add(sourcePath[0]);
346
+ addTodo(ctx, path, `component:${dotted}`, `\`${dotted}\` — ${mapping.todo ?? 'migrate by hand'}`);
347
+ return;
348
+ }
349
+ // Structural special first — it may pick the target or replace the node.
350
+ let target = mapping.target;
351
+ const handled = new Set();
352
+ if (mapping.special) {
353
+ const result = runSpecial(mapping.special, ctx, path, element);
354
+ if (result.replaced)
355
+ return;
356
+ if (result.target)
357
+ target = result.target;
358
+ for (const prop of result.handledProps ?? [])
359
+ handled.add(prop);
360
+ }
361
+ // A parent's structural handler may have picked this element's target.
362
+ const override = ctx.overrides.get(element);
363
+ if (override)
364
+ target = override;
365
+ if (!target)
366
+ return;
367
+ // Targets that extend only React's HTML attributes take none of bloomer's
368
+ // universal helpers; each becomes a TODO naming the Bulma class rather
369
+ // than an excess-property type error the report never mentions.
370
+ // `Navbar.Divider` and `Pagination.Ellipsis` spread their props after
371
+ // their own className, and `Dropdown.Divider` declares none at all, so a
372
+ // class cannot be written on any of them: every helper is named instead,
373
+ // and anything else left on the divider would not compile.
374
+ if (NO_CLASSNAME_TARGETS.has(target)) {
375
+ // A spread may carry a `className` that would replace the structural
376
+ // class on these two, or props `Dropdown.Divider` silently ignores —
377
+ // and the codemod cannot read inside it. Keep bloomer's element.
378
+ const spread = (element.openingElement.attributes ?? []).some((a) => a.type === 'JSXSpreadAttribute');
379
+ if (spread) {
380
+ ctx.retained.add(sourcePath[0]);
381
+ addTodo(ctx, path, `component:${dotted}`, `bestax \`${target}\` writes its own className last (or takes no props at all), and this element carries a spread whose contents the codemod cannot read — left as bloomer's, since a \`className\` inside it would replace the class that makes the element work`);
382
+ return;
383
+ }
384
+ for (const attr of [...attributesOf(element)]) {
385
+ const name = attr.name.name;
386
+ if (handled.has(name))
387
+ continue;
388
+ const literal = literalValueOf(attr);
389
+ const cls = helperClassHint(name, literal.kind === 'expression' ? undefined : literal.value);
390
+ if (!cls)
391
+ continue;
392
+ removeAttr(element, attr);
393
+ handled.add(name);
394
+ if (literal.kind === 'boolean' && !literal.value)
395
+ continue;
396
+ addTodo(ctx, path, `prop:${name}`, `bestax \`${target}\` writes its own className last (or takes no props at all), so the \`${cls}\` class this became cannot be set on it — wrap it, or restyle by hand`);
397
+ }
398
+ if (target === 'Dropdown.Divider') {
399
+ const left = attributesOf(element).filter((a) => !handled.has(a.name.name));
400
+ if (left.length > 0) {
401
+ for (const attr of left)
402
+ removeAttr(element, attr);
403
+ ctx.dirty = true;
404
+ addTodo(ctx, path, 'component:DropdownDivider', `bestax \`Dropdown.Divider\` is a bare component that declares no props and renders its own <hr>, so ${left
405
+ .map((a) => `\`${a.name.name}\``)
406
+ .join(', ')} could not be carried over — put them on a wrapping element if you need them`);
407
+ }
408
+ }
409
+ }
410
+ else if (HELPERLESS_TARGETS.has(target)) {
411
+ for (const attr of [...attributesOf(element)]) {
412
+ const name = attr.name.name;
413
+ if (handled.has(name))
414
+ continue;
415
+ const literal = literalValueOf(attr);
416
+ const cls = helperClassHint(name, literal.kind === 'expression' ? undefined : literal.value);
417
+ if (!cls)
418
+ continue;
419
+ removeAttr(element, attr);
420
+ handled.add(name);
421
+ // A false boolean was a no-op in bloomer; nothing to re-add.
422
+ if (literal.kind === 'boolean' && !literal.value)
423
+ continue;
424
+ // A literal is exactly one Bulma class, which every one of these
425
+ // parts still takes as `className`; anything else is a TODO.
426
+ if (literal.kind === 'string' || literal.kind === 'boolean') {
427
+ mergeClass(ctx, path, element, cls, name);
428
+ continue;
429
+ }
430
+ addTodo(ctx, path, `prop:${name}`, `bestax \`${target}\` takes no Bulma helper props (it extends only React's HTML attributes); add the matching \`${cls}\` class to \`className\` by hand`);
431
+ }
432
+ }
433
+ // Rename to the bestax name, aliasing the import root on collision.
434
+ const [targetRoot, ...targetRest] = target.split('.');
435
+ const localTarget = [ctx.reserve(targetRoot), ...targetRest].join('.');
436
+ const currentParts = jsxNameParts(element.openingElement.name);
437
+ if (!currentParts || currentParts.join('.') !== localTarget) {
438
+ renameElement(j, element, localTarget);
439
+ ctx.dirty = true;
440
+ }
441
+ // Helper shapes: `isDisplay` / `isHidden` on every element, plus the
442
+ // Column size and offset objects.
443
+ const kind = mapping.special
444
+ ? (RESPONSIVE_KINDS[mapping.special] ?? 'generic')
445
+ : 'generic';
446
+ flattenResponsiveProps(ctx, path, element, kind);
447
+ // Component-specific prop actions, then the universal helper pass.
448
+ if (mapping.props) {
449
+ for (const attr of [...attributesOf(element)]) {
450
+ const name = attr.name.name;
451
+ const action = mapping.props[name];
452
+ if (!action || handled.has(name))
453
+ continue;
454
+ handled.add(name);
455
+ applyPropAction(ctx, path, element, attr, action);
456
+ }
457
+ }
458
+ applyUniversalProps(ctx, path, element, handled, UNIVERSAL_PROPS);
459
+ });
460
+ // ---- 2b. Value references to bloomer components ---------------------------
461
+ // Components can be referenced as plain values too (`as={Block}`, passed to
462
+ // helpers, …). JSX usages were renamed above; map the leftover identifier
463
+ // references so the pruned bloomer import doesn't strand them.
464
+ // A shorthand property's key and value are the SAME node, so the walker
465
+ // reaches it twice. The first visit rewrites it and detaches the original;
466
+ // the second then matched neither key nor value and fell through to the
467
+ // generic "referenced as a value" branch, emitting a spurious TODO and
468
+ // marking the root retained when it was not.
469
+ const handledValueRefs = new WeakSet();
470
+ root.find(j.Identifier).forEach(path => {
471
+ // find(Identifier) also matches JSXIdentifier (a subtype) — JSX names
472
+ // were already handled by the element walker above.
473
+ if (path.node.type !== 'Identifier')
474
+ return;
475
+ if (handledValueRefs.has(path.node))
476
+ return;
477
+ const name = path.node.name;
478
+ const imported = imports.get(name);
479
+ if (imported === undefined)
480
+ return;
481
+ // Resolve by BINDING, not by name: a local that shadows the import is a
482
+ // different object, and rewriting it repoints the code at bestax's
483
+ // component.
484
+ if (!resolvesToBinding(path, name, programScope)) {
485
+ return;
486
+ }
487
+ const parentNode = path.parent?.node;
488
+ const parentType = parentNode?.type;
489
+ if (parentType === 'ImportSpecifier' ||
490
+ parentType === 'ImportDefaultSpecifier' ||
491
+ parentType === 'ImportNamespaceSpecifier' ||
492
+ parentType === 'JSXOpeningElement' ||
493
+ parentType === 'JSXClosingElement' ||
494
+ parentType === 'JSXMemberExpression') {
495
+ return;
496
+ }
497
+ // A TS qualified name (`B.BoxProps` in a type position) is erased at
498
+ // runtime, so it is not a value use of the namespace at all.
499
+ if (parentType === 'TSQualifiedName')
500
+ return;
501
+ // A namespace binding in a real value position. `bloomer.Button` is still a
502
+ // mappable component reference, so fall through to the member-expression
503
+ // branch below with an empty prefix (the namespace itself is not part of
504
+ // the bloomer path). Anything else — a bare `bloomer` — just pins the import.
505
+ const isNamespace = imported === '*';
506
+ if (isNamespace &&
507
+ !(parentType === 'MemberExpression' &&
508
+ parentNode.object === path.node &&
509
+ !parentNode.computed)) {
510
+ namespaceStillReferenced = true;
511
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is the library's namespace import used as a plain value (destructured, or passed around); nothing reached through it was migrated — reference its components as \`${name}.Component\` or import them by name, then re-run`);
512
+ return;
513
+ }
514
+ // Members, keys and signatures name a thing; they do not reference the
515
+ // import. An export specifier does, and is handled on its own: a flat
516
+ // target keeps the public name (\`export { SubTitle as Subtitle }\`), a
517
+ // dotted one cannot be re-exported under a member name and is flagged.
518
+ if (parentType === 'ExportSpecifier') {
519
+ if (parentNode.local !== path.node)
520
+ return;
521
+ const exportedName = nameOf(parentNode.exported ?? parentNode.local);
522
+ const mapping = MAPPING[imported];
523
+ if (mapping &&
524
+ mapping.status !== 'todo' &&
525
+ mapping.target &&
526
+ !mapping.target.includes('.')) {
527
+ const local = ctx.reserve(mapping.target);
528
+ // Replace the node: recast reprints a specifier whose identifiers were
529
+ // swapped in place in its original shorthand form, dropping the `as`.
530
+ path.parent.replace(j.exportSpecifier.from({
531
+ local: j.identifier(local),
532
+ exported: j.identifier(exportedName),
533
+ }));
534
+ ctx.dirty = true;
535
+ return;
536
+ }
537
+ ctx.retained.add(imported);
538
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is re-exported; ${mapping?.target ? `its bestax counterpart \`${mapping.target}\` is a member of \`${mapping.target.split('.')[0]}\` and cannot be re-exported under this name` : 'it has no bestax counterpart to re-export'} — migrate the consumers by hand`);
539
+ return;
540
+ }
541
+ if (parentType === 'TSEnumMember' ||
542
+ ((parentType === 'ObjectMethod' ||
543
+ parentType === 'ClassMethod' ||
544
+ parentType === 'ClassProperty' ||
545
+ parentType === 'PropertyDefinition' ||
546
+ parentType === 'MethodDefinition' ||
547
+ parentType === 'TSPropertySignature' ||
548
+ parentType === 'TSMethodSignature') &&
549
+ parentNode.key === path.node &&
550
+ !parentNode.computed)) {
551
+ return;
552
+ }
553
+ // Non-reference positions: member property names and object keys.
554
+ if (parentType === 'MemberExpression' &&
555
+ parentNode.property === path.node &&
556
+ !parentNode.computed) {
557
+ return;
558
+ }
559
+ // Member-expression value references (`Card.Header`, `Tag.Group` used as
560
+ // expressions): resolve the full chain; when the bestax target is the same
561
+ // dotted chain the runtime compound still exists — only flag (or rewrite,
562
+ // for flat single-name targets) when it differs.
563
+ if (parentType === 'MemberExpression' &&
564
+ parentNode.object === path.node &&
565
+ !parentNode.computed) {
566
+ // Walk the full chain: `Card.Footer.Item` is one path, not
567
+ // `Card.Footer` with a stray `.Item`. Resolving only the first level
568
+ // left the tail pointing at a compound bestax does not have
569
+ // (bestax exposes `Card.FooterItem`).
570
+ const memberPath = isNamespace ? [] : [imported];
571
+ let outer = path.parent;
572
+ while (outer?.node?.type === 'MemberExpression' &&
573
+ !outer.node.computed &&
574
+ outer.node.property?.type === 'Identifier') {
575
+ memberPath.push(nameOf(outer.node.property));
576
+ const next = outer.parent;
577
+ if (next?.node?.type === 'MemberExpression' &&
578
+ next.node.object === outer.node) {
579
+ outer = next;
580
+ }
581
+ else {
582
+ break;
583
+ }
584
+ }
585
+ const memberMapping = resolveMapping(memberPath);
586
+ const dotted = memberPath.join('.');
587
+ // Only a namespace chain can resolve: a flat bloomer component has no
588
+ // members of its own, so `Box.anything` is never a component reference.
589
+ if (isNamespace &&
590
+ memberMapping &&
591
+ memberMapping.status !== 'todo' &&
592
+ memberMapping.target &&
593
+ !memberMapping.special) {
594
+ // `B.CardHeaderTitle` → `Card.Header.Title`: the namespace prefix has
595
+ // to go and the target may be dotted, so rebuild the whole chain from
596
+ // the target rather than renaming anything in place.
597
+ const [targetRoot, ...targetRest] = memberMapping.target.split('.');
598
+ let rebuilt = j.identifier(ctx.reserve(targetRoot));
599
+ for (const part of targetRest) {
600
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
601
+ }
602
+ outer.replace(rebuilt);
603
+ ctx.dirty = true;
604
+ return;
605
+ }
606
+ addTodo(ctx, outer, 'value-reference', `\`${isNamespace ? `${name}.` : ''}${dotted}\` is referenced as a value; migrate this usage by hand`);
607
+ if (isNamespace)
608
+ namespaceStillReferenced = true;
609
+ else
610
+ ctx.retained.add(imported);
611
+ return;
612
+ }
613
+ if ((parentType === 'ObjectProperty' || parentType === 'Property') &&
614
+ parentNode.key === path.node &&
615
+ !parentNode.shorthand) {
616
+ return;
617
+ }
618
+ // A shorthand property is both the key and the reference. Renaming the
619
+ // node in place rewrote the object's PUBLIC key
620
+ // (`{ Textarea }` → `{ TextArea }`), so expand it instead: the key keeps
621
+ // the name callers use, the value points at the migrated binding.
622
+ if ((parentType === 'ObjectProperty' || parentType === 'Property') &&
623
+ parentNode.shorthand) {
624
+ const shorthandMapping = MAPPING[imported];
625
+ if (shorthandMapping &&
626
+ shorthandMapping.status !== 'todo' &&
627
+ shorthandMapping.target) {
628
+ const [root, ...rest] = shorthandMapping.target.split('.');
629
+ const local = ctx.reserve(root);
630
+ let value = j.identifier(local);
631
+ for (const part of rest) {
632
+ value = j.memberExpression(value, j.identifier(part));
633
+ }
634
+ if (rest.length > 0 || local !== name) {
635
+ // ast-types keeps DISTINCT key and value nodes even when
636
+ // `shorthand` is true, so both positions are visited. Mark both, or
637
+ // the second visit sees a node that matches neither the (new) key
638
+ // nor the (new) value and falls through to the generic branch.
639
+ handledValueRefs.add(parentNode.key);
640
+ handledValueRefs.add(parentNode.value);
641
+ parentNode.shorthand = false;
642
+ parentNode.key = j.identifier(name);
643
+ parentNode.value = value;
644
+ ctx.dirty = true;
645
+ }
646
+ }
647
+ else {
648
+ // Mark both positions before reporting: the key and the value are
649
+ // distinct nodes, so an unmarked branch reports the same property
650
+ // twice. The AST comment dedupes, which hid this in the output while
651
+ // the report still double-counted it.
652
+ handledValueRefs.add(parentNode.key);
653
+ handledValueRefs.add(parentNode.value);
654
+ ctx.retained.add(imported);
655
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is referenced as a value; migrate this usage by hand`);
656
+ }
657
+ return;
658
+ }
659
+ const mapping = MAPPING[imported];
660
+ // A special that has a `target` only adjusts props on the JSX path; it
661
+ // never changes the target, so a value reference points at the same
662
+ // bestax component (`const Old = Icon` → bestax's Icon). Only a
663
+ // target-less special (NavbarItem, Help, …) has nothing to rename to.
664
+ if (mapping && mapping.status !== 'todo' && mapping.target) {
665
+ const [root, ...rest] = mapping.target.split('.');
666
+ const local = ctx.reserve(root);
667
+ if (rest.length > 0) {
668
+ // A dotted target needs a member expression, not a rename. Without
669
+ // this, a destructured alias used as a bare value
670
+ // (`const { Content } = Card; const V = Content`) fell through to the
671
+ // TODO branch and was left referencing a binding the destructuring
672
+ // pass had already deleted.
673
+ let value = j.identifier(local);
674
+ for (const part of rest) {
675
+ value = j.memberExpression(value, j.identifier(part));
676
+ }
677
+ path.replace(value);
678
+ }
679
+ else if (local !== name) {
680
+ path.node.name = local;
681
+ }
682
+ ctx.dirty = true;
683
+ }
684
+ else {
685
+ ctx.retained.add(imported);
686
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is referenced as a value; migrate this usage by hand`);
687
+ }
688
+ });
689
+ // ---- 3. Rewrite imports -----------------------------------------------
690
+ if (imports.size > 0) {
691
+ const retainedNames = [...ctx.retained].sort((a, b) => a.localeCompare(b));
692
+ const freshNames = [...ctx.needed.entries()]
693
+ .filter(([imported]) => requested.has(imported) && !preExistingImports.has(imported))
694
+ .sort((a, b) => a[0].localeCompare(b[0]));
695
+ const bestaxImport = freshNames.length > 0
696
+ ? j.importDeclaration(freshNames.map(([imported, local]) => j.importSpecifier(j.identifier(imported), j.identifier(local))), j.stringLiteral(BESTAX))
697
+ : null;
698
+ /**
699
+
700
+ * A pruned declaration takes its comments with it — a licence header, an
701
+
702
+ * eslint directive. Hand them to what replaces it, or to the next statement.
703
+
704
+ */
705
+ const carryComments = (node, importPath) => {
706
+ const comments = node.comments ?? [];
707
+ if (comments.length === 0)
708
+ return;
709
+ const body = importPath.parent?.node?.body ?? [];
710
+ const index = body.indexOf(node);
711
+ // The fresh specifiers are merged INTO an existing bestax import when there
712
+ // is one, and the `bestaxImport` node is then discarded — so the comments
713
+ // have to follow the declaration that survives, not the one that does not.
714
+ const carrier = (existingBestax ? existingBestax.node : bestaxImport) ??
715
+ (index >= 0 ? body[index + 1] : undefined);
716
+ if (carrier) {
717
+ carrier.comments = [...comments, ...(carrier.comments ?? [])];
718
+ }
719
+ else if (index > 0) {
720
+ // The import was the last statement: the comments stay where they
721
+ // were, after what precedes it.
722
+ const previous = body[index - 1];
723
+ previous.comments = [
724
+ ...(previous.comments ?? []),
725
+ ...comments.map((c) => ({
726
+ ...c,
727
+ leading: false,
728
+ trailing: true,
729
+ })),
730
+ ];
731
+ }
732
+ else {
733
+ // The import was the only statement: the comments become the file's.
734
+ const program = importPath.parent?.node;
735
+ if (program)
736
+ program.comments = [...comments, ...(program.comments ?? [])];
737
+ }
738
+ node.comments = [];
739
+ };
740
+ let inserted = false;
741
+ // A retained bloomer specifier must never collide with a bestax import local
742
+ // (possible when one component is both JSX-migrated and value-retained).
743
+ // Only a VALUE local can collide at runtime: a type-only specifier the
744
+ // seeding above recorded, and nothing promoted, binds no value.
745
+ const bestaxLocals = new Set([...ctx.needed.entries()]
746
+ .filter(([imported]) => requested.has(imported) || preExistingImports.has(imported))
747
+ .map(([, local]) => local));
748
+ for (const path of sourceImportPaths) {
749
+ const node = path.node;
750
+ const keepSpecifiers = (node.specifiers ?? []).filter((spec) => {
751
+ // `import * as bloomer` reaches every export at once, so it has to
752
+ // survive whenever ANY component is retained — the JSX still says
753
+ // `<bloomer.Tile>`, and pruning the import leaves `bloomer is not defined`.
754
+ if (spec.type === 'ImportNamespaceSpecifier') {
755
+ return ctx.retained.size > 0 || namespaceStillReferenced;
756
+ }
757
+ // Keep a default binding rather than stranding its references.
758
+ if (spec.type === 'ImportDefaultSpecifier')
759
+ return sawDefaultImport;
760
+ return (spec.type === 'ImportSpecifier' &&
761
+ ctx.retained.has(nameOf(spec.imported)) &&
762
+ !bestaxLocals.has(nameOf(spec.local)));
763
+ });
764
+ if (!inserted) {
765
+ if (existingBestax && bestaxImport) {
766
+ const current = existingBestax.node.specifiers ?? [];
767
+ const appended = [];
768
+ for (const fresh of bestaxImport.specifiers) {
769
+ // A type-only specifier for the same name becomes the value
770
+ // import (a value import carries the type too); appending a
771
+ // second `Box` beside `type Box` would be a duplicate identifier.
772
+ const typeOnly = current.find((spec) => spec.type === 'ImportSpecifier' &&
773
+ spec.importKind === 'type' &&
774
+ nameOf(spec.imported) === nameOf(fresh.imported));
775
+ if (typeOnly) {
776
+ typeOnly.importKind = null;
777
+ }
778
+ else {
779
+ appended.push(fresh);
780
+ }
781
+ }
782
+ existingBestax.node.specifiers = [...current, ...appended];
783
+ }
784
+ else if (bestaxImport) {
785
+ path.insertBefore(bestaxImport);
786
+ }
787
+ inserted = true;
788
+ }
789
+ const droppedOnCollision = (node.specifiers ?? []).filter((spec) => spec.type === 'ImportSpecifier' &&
790
+ ctx.retained.has(nameOf(spec.imported)) &&
791
+ bestaxLocals.has(nameOf(spec.local)));
792
+ for (const spec of droppedOnCollision) {
793
+ addTodo(ctx, path, 'imports', `\`${nameOf(spec.imported)}\` is retained but its local name \`${nameOf(spec.local)}\` is also a bestax import in this file; rename one of them by hand — its JSX would otherwise resolve to the bestax component`);
794
+ }
795
+ if (keepSpecifiers.length > 0) {
796
+ node.specifiers = keepSpecifiers;
797
+ // Only when named/namespace bindings are being retained. An import
798
+ // kept solely for a broken default binding has no names to list, and
799
+ // already carries its own "bloomer has no default export" TODO.
800
+ if (retainedNames.length > 0) {
801
+ node.comments = node.comments ?? [];
802
+ const text = ` TODO(bestax-migrate): ${retainedNames.join(', ')} ${retainedNames.length === 1 ? 'has' : 'have'} no bestax-bulma equivalent yet — migrate and remove this import`;
803
+ if (!node.comments.some((c) => c.value === text)) {
804
+ node.comments.push(j.commentLine(text, true, false));
805
+ }
806
+ }
807
+ }
808
+ else {
809
+ carryComments(node, path);
810
+ path.prune();
811
+ }
812
+ ctx.dirty = true;
813
+ }
814
+ }
815
+ // Flush the deferred stylesheet notes onto the first node that survived the
816
+ // import rewrite, so the drop is visible in the file and not only the report.
817
+ if (!ctx.dirty)
818
+ return undefined;
819
+ // Double quotes match the dominant JSX-attribute convention; users run
820
+ // their own formatter afterwards anyway. Tab-indented sources keep tabs so
821
+ // reprinted nodes don't drift from the untouched lines around them.
822
+ return root.toSource({
823
+ quote: 'double',
824
+ useTabs: prefersTabs(fileInfo.source),
825
+ });
826
+ }