bestax-migrate 2.1.6 → 2.2.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 (61) hide show
  1. package/README.md +13 -8
  2. package/dist/cli.d.ts +53 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +159 -9
  5. package/dist/sources/_shared/imports.d.ts +46 -0
  6. package/dist/sources/_shared/imports.d.ts.map +1 -0
  7. package/dist/sources/_shared/imports.js +152 -0
  8. package/dist/sources/{react-bulma-components → _shared}/jsx-utils.d.ts +20 -12
  9. package/dist/sources/_shared/jsx-utils.d.ts.map +1 -0
  10. package/dist/sources/{react-bulma-components → _shared}/jsx-utils.js +15 -10
  11. package/dist/sources/_shared/make-styles-transform.d.ts +37 -0
  12. package/dist/sources/_shared/make-styles-transform.d.ts.map +1 -0
  13. package/dist/sources/_shared/make-styles-transform.js +440 -0
  14. package/dist/sources/_shared/props.d.ts +20 -0
  15. package/dist/sources/_shared/props.d.ts.map +1 -0
  16. package/dist/sources/{react-bulma-components → _shared}/props.js +11 -7
  17. package/dist/sources/_shared/specials-utils.d.ts +45 -0
  18. package/dist/sources/_shared/specials-utils.d.ts.map +1 -0
  19. package/dist/sources/_shared/specials-utils.js +124 -0
  20. package/dist/sources/rbx/deps.d.ts +18 -0
  21. package/dist/sources/rbx/deps.d.ts.map +1 -0
  22. package/dist/sources/rbx/deps.js +287 -0
  23. package/dist/sources/rbx/index.d.ts +3 -0
  24. package/dist/sources/rbx/index.d.ts.map +1 -0
  25. package/dist/sources/rbx/index.js +10 -0
  26. package/dist/sources/rbx/mapping.d.ts +49 -0
  27. package/dist/sources/rbx/mapping.d.ts.map +1 -0
  28. package/dist/sources/rbx/mapping.js +924 -0
  29. package/dist/sources/rbx/responsive.d.ts +30 -0
  30. package/dist/sources/rbx/responsive.d.ts.map +1 -0
  31. package/dist/sources/rbx/responsive.js +279 -0
  32. package/dist/sources/rbx/specials.d.ts +21 -0
  33. package/dist/sources/rbx/specials.d.ts.map +1 -0
  34. package/dist/sources/rbx/specials.js +706 -0
  35. package/dist/sources/rbx/styles.d.ts +21 -0
  36. package/dist/sources/rbx/styles.d.ts.map +1 -0
  37. package/dist/sources/rbx/styles.js +29 -0
  38. package/dist/sources/rbx/transform.d.ts +25 -0
  39. package/dist/sources/rbx/transform.d.ts.map +1 -0
  40. package/dist/sources/rbx/transform.js +911 -0
  41. package/dist/sources/react-bulma-components/deps.d.ts.map +1 -1
  42. package/dist/sources/react-bulma-components/deps.js +16 -0
  43. package/dist/sources/react-bulma-components/responsive.d.ts +1 -1
  44. package/dist/sources/react-bulma-components/responsive.d.ts.map +1 -1
  45. package/dist/sources/react-bulma-components/responsive.js +1 -1
  46. package/dist/sources/react-bulma-components/specials.d.ts +3 -9
  47. package/dist/sources/react-bulma-components/specials.d.ts.map +1 -1
  48. package/dist/sources/react-bulma-components/specials.js +4 -91
  49. package/dist/sources/react-bulma-components/styles.d.ts +9 -9
  50. package/dist/sources/react-bulma-components/styles.d.ts.map +1 -1
  51. package/dist/sources/react-bulma-components/styles.js +17 -389
  52. package/dist/sources/react-bulma-components/transform.d.ts.map +1 -1
  53. package/dist/sources/react-bulma-components/transform.js +378 -101
  54. package/dist/sources/registry.d.ts.map +1 -1
  55. package/dist/sources/registry.js +2 -0
  56. package/dist/types.d.ts +8 -0
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +8 -7
  59. package/dist/sources/react-bulma-components/jsx-utils.d.ts.map +0 -1
  60. package/dist/sources/react-bulma-components/props.d.ts +0 -15
  61. package/dist/sources/react-bulma-components/props.d.ts.map +0 -1
@@ -13,9 +13,10 @@
13
13
  * Anything unsafe gets a `// TODO(bestax-migrate)` comment on the enclosing
14
14
  * statement plus an entry in the run report.
15
15
  */
16
- import { MAPPING, resolveMapping } from './mapping.js';
17
- import { addTodo, attributesOf, jsxNameParts, renameElement, } from './jsx-utils.js';
18
- import { applyPropAction, applyUniversalProps } from './props.js';
16
+ import { MAPPING, UNIVERSAL_PROPS, resolveMapping } from './mapping.js';
17
+ import { addTodo, attributesOf, jsxNameParts, renameElement, } from '../_shared/jsx-utils.js';
18
+ import { applyPropAction, applyUniversalProps } from '../_shared/props.js';
19
+ import { collectBoundNames, makeReserve, nameOf, prefersTabs, resolvesToBinding, } from '../_shared/imports.js';
19
20
  import { flattenResponsiveProps } from './responsive.js';
20
21
  import { RESPONSIVE_KINDS, runSpecial } from './specials.js';
21
22
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -57,7 +58,12 @@ export default function transform(fileInfo, api, options = {}) {
57
58
  /** local identifier → canonical RBC path (from destructuring). */
58
59
  const aliases = new Map();
59
60
  const rbcImportPaths = [];
60
- const nameOf = (node) => typeof node === 'string' ? node : String(node?.name ?? '');
61
+ /**
62
+ * Whether a namespace binding (`import * as RBC`) is still referenced after
63
+ * the JSX pass — either by a retained component (`<RBC.Tile>`) or as a bare
64
+ * value. Pruning the import under one leaves `RBC is not defined`.
65
+ */
66
+ let namespaceStillReferenced = false;
61
67
  root.find(j.ImportDeclaration).forEach(path => {
62
68
  const source = String(path.node.source.value);
63
69
  if (source === RBC) {
@@ -141,24 +147,115 @@ export default function transform(fileInfo, api, options = {}) {
141
147
  if (imports.size === 0 && !ctx.dirty) {
142
148
  return undefined;
143
149
  }
150
+ // The scope each binding was collected in, so a reference can be checked
151
+ // against the binding it actually resolves to rather than matched by name.
152
+ const programScope = root.find(j.Program).paths()[0]?.scope;
153
+ // Aliases are keyed by the AST node that owns the declaring scope, not by
154
+ // bare name. Two functions can each destructure `Header` from a different
155
+ // component; a name-keyed map let the second overwrite the first, and both
156
+ // declarations were then pruned while both `<Header…>` references were left
157
+ // resolving to nothing.
158
+ //
159
+ // Resolution walks the reference's ANCESTORS rather than consulting
160
+ // `scope.lookup`, because by the time references are rewritten the alias
161
+ // pass has already pruned the declaration -- so the binding is gone from
162
+ // the scope table, and `path.scope` objects are not identity-stable across
163
+ // traversals either.
164
+ const aliasesByOwner = new Map();
165
+ const ownersFor = (name) => {
166
+ const owners = [];
167
+ for (const [owner, perScope] of aliasesByOwner) {
168
+ if (perScope.has(name))
169
+ owners.push(owner);
170
+ }
171
+ return owners;
172
+ };
173
+ const aliasAt = (name, at) => {
174
+ const owners = ownersFor(name);
175
+ if (owners.length === 0)
176
+ return undefined;
177
+ // Without a reference location there is nothing to walk, so the only
178
+ // owner is the best answer available. WITH one, always walk: an unrelated
179
+ // `Header` parameter in another function must not resolve to the alias
180
+ // merely because the alias is the only one by that name. A single-owner
181
+ // shortcut here rewrote such a parameter to `{ Header: Card.Header }`,
182
+ // which is not even valid syntax.
183
+ if (at === undefined) {
184
+ return owners.length === 1
185
+ ? aliasesByOwner.get(owners[0]).get(name)
186
+ : undefined;
187
+ }
188
+ let cursor = at;
189
+ while (cursor) {
190
+ if (cursor.node !== undefined) {
191
+ const hit = aliasesByOwner.get(cursor.node)?.get(name);
192
+ if (hit) {
193
+ // The walk finds the nearest OWNER, but a binding can intervene
194
+ // between the reference and that owner: `function F(Header)` under
195
+ // a module-level `const { Header } = Card` shadows the alias, and
196
+ // resolving through it rewrote the parameter itself to
197
+ // `F(Card.Header)`. If the scope table still binds the name
198
+ // somewhere other than the owner, that nearer binding wins. (After
199
+ // the alias declaration is pruned the lookup returns nothing for
200
+ // the alias itself, which is the case this must keep allowing.)
201
+ const declaring = at.scope?.lookup(name)?.path?.node;
202
+ if (declaring !== undefined &&
203
+ declaring !== null &&
204
+ declaring !== cursor.node) {
205
+ return undefined;
206
+ }
207
+ return hit;
208
+ }
209
+ }
210
+ cursor = cursor.parent;
211
+ }
212
+ return undefined;
213
+ };
214
+ const anyAlias = (name) => ownersFor(name).length > 0;
144
215
  // ---- 1b. Resolve `const { Input, Field: F } = Form` destructuring -----
145
216
  root.find(j.VariableDeclarator).forEach(path => {
146
217
  const node = path.node;
147
218
  if (node.id?.type !== 'ObjectPattern' || node.init?.type !== 'Identifier')
148
219
  return;
149
220
  const base = node.init.name;
150
- const imported = imports.get(base) ?? aliases.get(base)?.join('.');
221
+ const baseAlias = aliasAt(base, path);
222
+ const imported = imports.get(base) ?? baseAlias?.join('.');
151
223
  if (!imported || imported === '*')
152
224
  return;
225
+ if (!imports.has(base) && !baseAlias)
226
+ return;
153
227
  const basePath = imports.has(base)
154
228
  ? [imports.get(base)]
155
- : aliases.get(base);
229
+ : baseAlias;
156
230
  let allResolved = true;
157
231
  for (const prop of node.id.properties) {
158
232
  if ((prop.type === 'ObjectProperty' || prop.type === 'Property') &&
159
233
  prop.key?.type === 'Identifier' &&
160
234
  prop.value?.type === 'Identifier') {
161
- aliases.set(prop.value.name, [...basePath, prop.key.name]);
235
+ const owner = path.scope
236
+ ?.path?.node;
237
+ let perScope = aliasesByOwner.get(owner);
238
+ if (!perScope) {
239
+ perScope = new Map();
240
+ aliasesByOwner.set(owner, perScope);
241
+ }
242
+ const target = [...basePath, prop.key.name];
243
+ perScope.set(prop.value.name, target);
244
+ aliases.set(prop.value.name, target);
245
+ // Pruning the declaration is only safe if every use of the alias will
246
+ // actually be rewritten. For a name the table cannot map, the JSX is
247
+ // left as written -- so dropping `const { Header } = Panel` left
248
+ // `<Header/>` referencing nothing at all.
249
+ // A `special` handler rewrites the element without naming a plain
250
+ // target (`Form.Label` becomes a bare <label>), so it counts as
251
+ // resolved just as a target does. Requiring `target` alone kept every
252
+ // declaration that mentioned one.
253
+ const targetMapping = resolveMapping(target);
254
+ if (!targetMapping ||
255
+ targetMapping.status === 'todo' ||
256
+ !(targetMapping.target || targetMapping.special)) {
257
+ allResolved = false;
258
+ }
162
259
  }
163
260
  else {
164
261
  allResolved = false;
@@ -180,13 +277,18 @@ export default function transform(fileInfo, api, options = {}) {
180
277
  }
181
278
  });
182
279
  /** Resolve a JSX name into a canonical RBC component path, or null. */
183
- function resolveJsxPath(name) {
280
+ function resolveJsxPath(name, at) {
184
281
  const parts = jsxNameParts(name);
185
282
  if (!parts)
186
283
  return null;
187
284
  const [head, ...rest] = parts;
188
- if (aliases.has(head))
189
- return [...aliases.get(head), ...rest];
285
+ const alias = aliasAt(head, at);
286
+ if (alias)
287
+ return [...alias, ...rest];
288
+ // An alias exists under this name but not for THIS reference's binding,
289
+ // so the reference is not ours to rewrite.
290
+ if (anyAlias(head) && !imports.has(head))
291
+ return null;
190
292
  const imported = imports.get(head);
191
293
  if (imported === undefined)
192
294
  return null;
@@ -199,78 +301,92 @@ export default function transform(fileInfo, api, options = {}) {
199
301
  // A file may already bind names like `Field` or `Control` (components,
200
302
  // variables, function params); importing the bestax component under the
201
303
  // same name would redeclare it, so those imports get a Bulma* alias.
202
- const bound = new Set();
203
- const collectPattern = (node) => {
204
- if (!node)
205
- return;
206
- switch (node.type) {
207
- case 'Identifier':
208
- bound.add(node.name);
209
- break;
210
- case 'ObjectPattern':
211
- for (const prop of node.properties ?? []) {
212
- collectPattern(prop.value ?? prop.argument);
213
- }
214
- break;
215
- case 'ArrayPattern':
216
- for (const el of node.elements ?? [])
217
- collectPattern(el);
218
- break;
219
- case 'RestElement':
220
- collectPattern(node.argument);
221
- break;
222
- case 'AssignmentPattern':
223
- collectPattern(node.left);
224
- break;
304
+ const bound = collectBoundNames(j, root, RBC);
305
+ // Seed the reserved set with the locals of RBC imports that CANNOT be
306
+ // migrated, so a bestax import never claims one of them. Without this, an
307
+ // unmappable component aliased to a name bestax also wants
308
+ // (`import { Element as Button }`) had its retained specifier dropped on
309
+ // the collision, and its `<Button>` JSX then silently resolved to the
310
+ // bestax Button — one component quietly becoming another.
311
+ for (const [local, imported] of imports) {
312
+ if (imported === '*')
313
+ continue;
314
+ const entry = MAPPING[imported];
315
+ // Unmappable roots, plus ANY aliased import. A root can be retained by an
316
+ // unknown or `todo` child even when the root itself maps (`Icon` is
317
+ // `partial`, but `<Icon.Unknown>` retains it), and when the local name
318
+ // differs from the imported one that retained binding can collide with a
319
+ // bestax local for a completely different component. An unaliased import
320
+ // is safe: its name already means the same component on both sides.
321
+ if (!entry || entry.status === 'todo' || local !== imported) {
322
+ bound.add(local);
225
323
  }
226
- };
227
- root.find(j.VariableDeclarator).forEach(path => collectPattern(path.node.id));
228
- root.find(j.FunctionDeclaration).forEach(path => {
229
- if (path.node.id)
230
- bound.add(nameOf(path.node.id));
231
- for (const param of path.node.params ?? [])
232
- collectPattern(param);
233
- });
234
- root
235
- .find(j.ClassDeclaration)
236
- .forEach(path => path.node.id && bound.add(nameOf(path.node.id)));
237
- root.find(j.FunctionExpression).forEach(path => {
238
- for (const param of path.node.params ?? [])
239
- collectPattern(param);
240
- });
241
- root.find(j.ArrowFunctionExpression).forEach(path => {
242
- for (const param of path.node.params ?? [])
243
- collectPattern(param);
324
+ }
325
+ // A root that DOES map can still be retained by a child the table cannot
326
+ // (`Icon` is `partial`, but `<Icon.Unknown>` keeps the source import alive).
327
+ // The status check above misses that case whenever the local name equals the
328
+ // imported one, so the bestax import took the plain local, the retained
329
+ // specifier was dropped on the collision, and `<Icon.Unknown>` silently
330
+ // resolved to the bestax component instead.
331
+ //
332
+ // Scanned rather than reserved wholesale: adding every partial root to
333
+ // `bound` would alias them in every file that uses one, whether or not
334
+ // anything is actually retained.
335
+ root.find(j.JSXElement).forEach(path => {
336
+ const head = jsxNameParts(path.node.openingElement.name)?.[0];
337
+ if (!head || !imports.has(head))
338
+ return;
339
+ const parts = resolveJsxPath(path.node.openingElement.name, path);
340
+ const mapping = parts ? resolveMapping(parts) : undefined;
341
+ if (!mapping || mapping.status === 'todo')
342
+ bound.add(head);
244
343
  });
245
- root.find(j.ImportDeclaration).forEach(path => {
246
- if (String(path.node.source.value) === RBC)
247
- return; // pruned later
248
- for (const spec of path.node.specifiers ?? []) {
249
- if (spec.local)
250
- bound.add(nameOf(spec.local));
344
+ // The same root can be retained by a VALUE chain -- `const X = Icon.Unknown`
345
+ // -- which only the value-reference pass discovers, after `reserve` has
346
+ // already handed the plain local to bestax. Scan those chains here too.
347
+ root.find(j.Identifier).forEach(path => {
348
+ if (path.node.type !== 'Identifier')
349
+ return;
350
+ const head = path.node.name;
351
+ const imported = imports.get(head);
352
+ if (imported === undefined || imported === '*')
353
+ return;
354
+ const parent = path.parent?.node;
355
+ if (parent?.type !== 'MemberExpression' ||
356
+ parent.object !== path.node ||
357
+ parent.computed) {
358
+ return;
251
359
  }
252
- });
253
- ctx.reserve = (importedName) => {
254
- const existing = ctx.needed.get(importedName);
255
- if (existing)
256
- return existing;
257
- let local = importedName;
258
- if (bound.has(local)) {
259
- local = `Bulma${importedName}`;
260
- let suffix = 2;
261
- const locals = new Set(ctx.needed.values());
262
- while (bound.has(local) || locals.has(local)) {
263
- local = `Bulma${importedName}${suffix}`;
264
- suffix += 1;
360
+ const chain = [imported];
361
+ let outer = path.parent;
362
+ while (outer?.node?.type === 'MemberExpression' &&
363
+ !outer.node.computed &&
364
+ outer.node.property?.type === 'Identifier') {
365
+ chain.push(nameOf(outer.node.property));
366
+ const next = outer.parent;
367
+ if (next?.node?.type === 'MemberExpression' &&
368
+ next.node.object === outer.node) {
369
+ outer = next;
370
+ }
371
+ else {
372
+ break;
265
373
  }
266
374
  }
267
- ctx.needed.set(importedName, local);
268
- return local;
269
- };
375
+ const mapping = resolveMapping(chain);
376
+ if (!mapping || mapping.status === 'todo')
377
+ bound.add(head);
378
+ });
379
+ ctx.reserve = makeReserve(ctx, bound);
270
380
  // Merge with an existing bestax import: reuse its locals verbatim.
381
+ // Only a declaration made entirely of NAMED specifiers can be merged into.
382
+ // `import * as Bulma from '@allxsmith/bestax-bulma'` matches the source too,
383
+ // and appending named specifiers to it emitted
384
+ // `import * as Bulma, { Box } from …` — not valid JavaScript, so the whole
385
+ // file failed to parse after a migration that reported success.
271
386
  const existingBestax = root
272
387
  .find(j.ImportDeclaration, { source: { value: BESTAX } })
273
- .paths()[0];
388
+ .paths()
389
+ .find(path => (path.node.specifiers ?? []).every((spec) => spec.type === 'ImportSpecifier'));
274
390
  const preExistingImports = new Set();
275
391
  if (existingBestax) {
276
392
  for (const spec of existingBestax.node.specifiers ?? []) {
@@ -283,9 +399,17 @@ export default function transform(fileInfo, api, options = {}) {
283
399
  // ---- 2. Transform JSX elements ----------------------------------------
284
400
  root.find(j.JSXElement).forEach(path => {
285
401
  const element = path.node;
286
- const rbcPath = resolveJsxPath(element.openingElement.name);
402
+ const rbcPath = resolveJsxPath(element.openingElement.name, path);
287
403
  if (!rbcPath)
288
404
  return;
405
+ // Resolve by BINDING, not by name. `function F({ Card })` shadows the
406
+ // import with the caller's object, and rewriting its JSX to bestax's
407
+ // `Card.FooterItem` changed which component rendered.
408
+ const jsxHead = jsxNameParts(element.openingElement.name)?.[0];
409
+ if (jsxHead &&
410
+ !resolvesToBinding(path, jsxHead, anyAlias(jsxHead) ? undefined : programScope)) {
411
+ return;
412
+ }
289
413
  const mapping = resolveMapping(rbcPath);
290
414
  const dotted = rbcPath.join('.');
291
415
  if (!mapping) {
@@ -340,21 +464,40 @@ export default function transform(fileInfo, api, options = {}) {
340
464
  applyPropAction(ctx, path, element, attr, action);
341
465
  }
342
466
  }
343
- applyUniversalProps(ctx, path, element, handled);
467
+ applyUniversalProps(ctx, path, element, handled, UNIVERSAL_PROPS);
344
468
  });
345
469
  // ---- 2b. Value references to RBC components ---------------------------
346
470
  // Components can be referenced as plain values too (`renderAs={Block}`,
347
471
  // passed to helpers, …). JSX usages were renamed above; map the leftover
348
472
  // identifier references so the pruned RBC import doesn't strand them.
473
+ // A shorthand property's key and value are distinct nodes, so the walker
474
+ // reaches the property twice. The first visit rewrites it and detaches the
475
+ // original; the second then matched neither key nor value and fell through
476
+ // to the generic "referenced as a value" branch, emitting a spurious TODO
477
+ // and marking the root retained when it was not.
478
+ const handledValueRefs = new WeakSet();
349
479
  root.find(j.Identifier).forEach(path => {
350
480
  // find(Identifier) also matches JSXIdentifier (a subtype) — JSX names
351
481
  // were already handled by the element walker above.
352
482
  if (path.node.type !== 'Identifier')
353
483
  return;
484
+ if (handledValueRefs.has(path.node))
485
+ return;
354
486
  const name = path.node.name;
355
- const imported = imports.get(name);
356
- if (imported === undefined || imported === '*')
487
+ // A name bound by `const { Field } = Form` lives in `aliases`, not
488
+ // `imports`, and the destructuring pass has already deleted the
489
+ // declaration that bound it. Skipping those left `Field.Label` in the
490
+ // output with its `Form` import pruned: a reference to nothing.
491
+ const aliasPath = aliasAt(name, path);
492
+ const imported = aliasPath ? aliasPath[0] : imports.get(name);
493
+ if (imported === undefined)
357
494
  return;
495
+ // Resolve by BINDING, not by name: a local that shadows the import is a
496
+ // different object, and rewriting it repoints the code at bestax's
497
+ // component.
498
+ if (!resolvesToBinding(path, name, aliasPath ? undefined : programScope)) {
499
+ return;
500
+ }
358
501
  const parentNode = path.parent?.node;
359
502
  const parentType = parentNode?.type;
360
503
  if (parentType === 'ImportSpecifier' ||
@@ -365,6 +508,20 @@ export default function transform(fileInfo, api, options = {}) {
365
508
  parentType === 'JSXMemberExpression') {
366
509
  return;
367
510
  }
511
+ // A namespace binding in a real value position. `RBC.Box` is still a
512
+ // mappable component reference, so fall through to the member-expression
513
+ // branch below with an empty prefix (the namespace itself is not part of
514
+ // the RBC path). Anything else — a bare `RBC` — just pins the import.
515
+ // Treating every namespace member as opaque left `const C = RBC.Box`
516
+ // referencing an import that dependency migration then removed.
517
+ const isNamespace = imported === '*';
518
+ if (isNamespace &&
519
+ !(parentType === 'MemberExpression' &&
520
+ parentNode.object === path.node &&
521
+ !parentNode.computed)) {
522
+ namespaceStillReferenced = true;
523
+ return;
524
+ }
368
525
  // Non-reference positions: member property names and object keys.
369
526
  if (parentType === 'MemberExpression' &&
370
527
  parentNode.property === path.node &&
@@ -378,7 +535,25 @@ export default function transform(fileInfo, api, options = {}) {
378
535
  if (parentType === 'MemberExpression' &&
379
536
  parentNode.object === path.node &&
380
537
  !parentNode.computed) {
381
- const memberPath = [imported, nameOf(parentNode.property)];
538
+ // Walk the full chain: `Card.Footer.Item` is one path, not
539
+ // `Card.Footer` with a stray `.Item`. Resolving only the first level
540
+ // left the tail pointing at a compound bestax does not have
541
+ // (bestax exposes `Card.FooterItem`).
542
+ const memberPath = isNamespace ? [] : [...(aliasPath ?? [imported])];
543
+ let outer = path.parent;
544
+ while (outer?.node?.type === 'MemberExpression' &&
545
+ !outer.node.computed &&
546
+ outer.node.property?.type === 'Identifier') {
547
+ memberPath.push(nameOf(outer.node.property));
548
+ const next = outer.parent;
549
+ if (next?.node?.type === 'MemberExpression' &&
550
+ next.node.object === outer.node) {
551
+ outer = next;
552
+ }
553
+ else {
554
+ break;
555
+ }
556
+ }
382
557
  const memberMapping = resolveMapping(memberPath);
383
558
  const dotted = memberPath.join('.');
384
559
  if (memberMapping &&
@@ -386,7 +561,36 @@ export default function transform(fileInfo, api, options = {}) {
386
561
  memberMapping.target &&
387
562
  !memberMapping.special) {
388
563
  if (memberMapping.target === dotted) {
564
+ if (isNamespace) {
565
+ // `RBC.Card.Header` → `Card.Header`: the namespace prefix has to
566
+ // go, so rebuild the chain from the target rather than renaming
567
+ // the namespace identifier in place (there is no MAPPING['*']
568
+ // root to rename it to).
569
+ const [root, ...rest] = memberMapping.target.split('.');
570
+ let rebuilt = j.identifier(ctx.reserve(root));
571
+ for (const part of rest) {
572
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
573
+ }
574
+ outer.replace(rebuilt);
575
+ ctx.dirty = true;
576
+ return;
577
+ }
389
578
  // Same compound exists on the bestax component at runtime.
579
+ if (aliasPath) {
580
+ // A destructured alias stands for MORE than one segment of the
581
+ // chain (`const { Header } = Card` makes `Header` mean
582
+ // `Card.Header`), so renaming the identifier drops every segment
583
+ // but the last: `Header.Title` became `Card.Title`. Rebuild the
584
+ // whole chain from the target instead.
585
+ const [aliasRoot, ...aliasRest] = memberMapping.target.split('.');
586
+ let rebuilt = j.identifier(ctx.reserve(aliasRoot));
587
+ for (const part of aliasRest) {
588
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
589
+ }
590
+ outer.replace(rebuilt);
591
+ ctx.dirty = true;
592
+ return;
593
+ }
390
594
  const rootMapping = MAPPING[imported];
391
595
  if (rootMapping?.target) {
392
596
  const local = ctx.reserve(rootMapping.target);
@@ -397,15 +601,30 @@ export default function transform(fileInfo, api, options = {}) {
397
601
  return;
398
602
  }
399
603
  if (!memberMapping.target.includes('.')) {
400
- // Flat target (Icon.Text → IconText): swap the member expression.
604
+ // Flat target (Icon.Text → IconText): swap the whole chain.
401
605
  const local = ctx.reserve(memberMapping.target);
402
- path.parent.replace(j.identifier(local));
606
+ outer.replace(j.identifier(local));
403
607
  ctx.dirty = true;
404
608
  return;
405
609
  }
610
+ // Dotted target that differs from the source chain
611
+ // (Card.Footer.Item → Card.FooterItem): rebuild the chain against the
612
+ // bestax path rather than flagging something the table can answer.
613
+ const [targetRoot, ...targetRest] = memberMapping.target.split('.');
614
+ const rootLocal = ctx.reserve(targetRoot);
615
+ let rebuilt = j.identifier(rootLocal);
616
+ for (const part of targetRest) {
617
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
618
+ }
619
+ outer.replace(rebuilt);
620
+ ctx.dirty = true;
621
+ return;
406
622
  }
407
- addTodo(ctx, path.parent, 'value-reference', `\`${dotted}\` is referenced as a value; migrate this usage by hand`);
408
- ctx.retained.add(imported);
623
+ addTodo(ctx, outer, 'value-reference', `\`${isNamespace ? `${name}.` : ''}${dotted}\` is referenced as a value; migrate this usage by hand`);
624
+ if (isNamespace)
625
+ namespaceStillReferenced = true;
626
+ else
627
+ ctx.retained.add(imported);
409
628
  return;
410
629
  }
411
630
  if ((parentType === 'ObjectProperty' || parentType === 'Property') &&
@@ -413,14 +632,75 @@ export default function transform(fileInfo, api, options = {}) {
413
632
  !parentNode.shorthand) {
414
633
  return;
415
634
  }
416
- const mapping = MAPPING[imported];
635
+ // A shorthand property is both the key and the reference. Renaming the
636
+ // node in place rewrote the object's PUBLIC key
637
+ // (`{ Textarea }` → `{ TextArea }`), so expand it instead: the key keeps
638
+ // the name callers use, the value points at the migrated binding.
639
+ if ((parentType === 'ObjectProperty' || parentType === 'Property') &&
640
+ parentNode.shorthand) {
641
+ // Resolve through the alias path when there is one: `const { Footer } =
642
+ // Card` makes `{ Footer }` mean `Card.Footer`, not `Card`. Reading the
643
+ // root's mapping here emitted `{ Footer: Card }` — the wrong component
644
+ // under the right key.
645
+ const shorthandMapping = aliasPath
646
+ ? resolveMapping(aliasPath)
647
+ : MAPPING[imported];
648
+ if (shorthandMapping &&
649
+ shorthandMapping.status !== 'todo' &&
650
+ shorthandMapping.target &&
651
+ !shorthandMapping.special) {
652
+ const [root, ...rest] = shorthandMapping.target.split('.');
653
+ const local = ctx.reserve(root);
654
+ let value = j.identifier(local);
655
+ for (const part of rest) {
656
+ value = j.memberExpression(value, j.identifier(part));
657
+ }
658
+ if (rest.length > 0 || local !== name) {
659
+ // ast-types keeps DISTINCT key and value nodes even when
660
+ // `shorthand` is true, so both positions are visited. Mark both, or
661
+ // the second visit sees a node that matches neither the (new) key
662
+ // nor the (new) value and falls through to the generic branch.
663
+ handledValueRefs.add(parentNode.key);
664
+ handledValueRefs.add(parentNode.value);
665
+ parentNode.shorthand = false;
666
+ parentNode.key = j.identifier(name);
667
+ parentNode.value = value;
668
+ ctx.dirty = true;
669
+ }
670
+ }
671
+ else {
672
+ // Mark both positions before reporting: the key and the value are
673
+ // distinct nodes, so an unmarked branch reports the same property
674
+ // twice. The AST comment dedupes, which hid this in the output while
675
+ // the report still double-counted it.
676
+ handledValueRefs.add(parentNode.key);
677
+ handledValueRefs.add(parentNode.value);
678
+ ctx.retained.add(imported);
679
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is referenced as a value; migrate this usage by hand`);
680
+ }
681
+ return;
682
+ }
683
+ const mapping = aliasPath ? resolveMapping(aliasPath) : MAPPING[imported];
417
684
  if (mapping &&
418
685
  mapping.status !== 'todo' &&
419
686
  mapping.target &&
420
- !mapping.target.includes('.')) {
421
- const local = ctx.reserve(mapping.target);
422
- if (local !== name)
687
+ !mapping.special) {
688
+ const [root, ...rest] = mapping.target.split('.');
689
+ const local = ctx.reserve(root);
690
+ if (rest.length > 0) {
691
+ // A dotted target needs a member expression, not a rename — otherwise
692
+ // a destructured alias used as a bare value (`const { Header } = Card;
693
+ // const V = Header`) fell through to the TODO branch, left referencing
694
+ // a binding the destructuring pass had already deleted.
695
+ let value = j.identifier(local);
696
+ for (const part of rest) {
697
+ value = j.memberExpression(value, j.identifier(part));
698
+ }
699
+ path.replace(value);
700
+ }
701
+ else if (local !== name) {
423
702
  path.node.name = local;
703
+ }
424
704
  ctx.dirty = true;
425
705
  }
426
706
  else {
@@ -443,9 +723,17 @@ export default function transform(fileInfo, api, options = {}) {
443
723
  const bestaxLocals = new Set(ctx.needed.values());
444
724
  for (const path of rbcImportPaths) {
445
725
  const node = path.node;
446
- const keepSpecifiers = (node.specifiers ?? []).filter((spec) => spec.type === 'ImportSpecifier' &&
447
- ctx.retained.has(nameOf(spec.imported)) &&
448
- !bestaxLocals.has(nameOf(spec.local)));
726
+ const keepSpecifiers = (node.specifiers ?? []).filter((spec) => {
727
+ // `import * as RBC` reaches every export at once, so it must survive
728
+ // whenever a component is retained (`<RBC.Tile>` stays in the JSX) or
729
+ // the namespace is referenced as a value.
730
+ if (spec.type === 'ImportNamespaceSpecifier') {
731
+ return ctx.retained.size > 0 || namespaceStillReferenced;
732
+ }
733
+ return (spec.type === 'ImportSpecifier' &&
734
+ ctx.retained.has(nameOf(spec.imported)) &&
735
+ !bestaxLocals.has(nameOf(spec.local)));
736
+ });
449
737
  if (!inserted) {
450
738
  if (existingBestax && bestaxImport) {
451
739
  existingBestax.node.specifiers = [
@@ -482,14 +770,3 @@ export default function transform(fileInfo, api, options = {}) {
482
770
  useTabs: prefersTabs(fileInfo.source),
483
771
  });
484
772
  }
485
- function prefersTabs(source) {
486
- let tabs = 0;
487
- let spaces = 0;
488
- for (const line of source.split('\n')) {
489
- if (line.startsWith('\t'))
490
- tabs += 1;
491
- else if (line.startsWith(' '))
492
- spaces += 1;
493
- }
494
- return tabs > spaces;
495
- }
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/sources/registry.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAGnD,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAEnD,CAAC;AAEF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEnE;AAED,wBAAgB,WAAW,IAAI,MAAM,EAAE,CAEtC"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/sources/registry.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAInD,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAGnD,CAAC;AAEF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEnE;AAED,wBAAgB,WAAW,IAAI,MAAM,EAAE,CAEtC"}
@@ -3,8 +3,10 @@
3
3
  * registers here, and the CLI resolves its first argument against this map.
4
4
  */
5
5
  import { reactBulmaComponents } from './react-bulma-components/index.js';
6
+ import { rbx } from './rbx/index.js';
6
7
  export const SOURCES = {
7
8
  [reactBulmaComponents.name]: reactBulmaComponents,
9
+ [rbx.name]: rbx,
8
10
  };
9
11
  export function getSource(name) {
10
12
  return SOURCES[name];
package/dist/types.d.ts CHANGED
@@ -109,6 +109,14 @@ export interface DepsOptions {
109
109
  cssMode?: CssMode;
110
110
  /** True when migrated sources still reference `bulma/…` specifiers. */
111
111
  bulmaReferenced?: boolean;
112
+ /**
113
+ * True when at least one migrated file still imports the SOURCE library —
114
+ * the trimmed, TODO-annotated import the transform deliberately keeps for
115
+ * components with no bestax equivalent. Removing the package from the
116
+ * manifest while those remain leaves them unresolvable after the install
117
+ * step the report tells the user to run.
118
+ */
119
+ sourceStillImported?: boolean;
112
120
  }
113
121
  /** The standard jscodeshift transform contract. */
114
122
  export type Transform = (fileInfo: FileInfo, api: API, options: TransformOptions) => string | undefined;