bestax-migrate 2.1.6 → 2.2.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 (64) 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/semver-range.d.ts +31 -0
  18. package/dist/sources/_shared/semver-range.d.ts.map +1 -0
  19. package/dist/sources/_shared/semver-range.js +186 -0
  20. package/dist/sources/_shared/specials-utils.d.ts +45 -0
  21. package/dist/sources/_shared/specials-utils.d.ts.map +1 -0
  22. package/dist/sources/_shared/specials-utils.js +124 -0
  23. package/dist/sources/rbx/deps.d.ts +18 -0
  24. package/dist/sources/rbx/deps.d.ts.map +1 -0
  25. package/dist/sources/rbx/deps.js +192 -0
  26. package/dist/sources/rbx/index.d.ts +3 -0
  27. package/dist/sources/rbx/index.d.ts.map +1 -0
  28. package/dist/sources/rbx/index.js +10 -0
  29. package/dist/sources/rbx/mapping.d.ts +49 -0
  30. package/dist/sources/rbx/mapping.d.ts.map +1 -0
  31. package/dist/sources/rbx/mapping.js +924 -0
  32. package/dist/sources/rbx/responsive.d.ts +30 -0
  33. package/dist/sources/rbx/responsive.d.ts.map +1 -0
  34. package/dist/sources/rbx/responsive.js +279 -0
  35. package/dist/sources/rbx/specials.d.ts +21 -0
  36. package/dist/sources/rbx/specials.d.ts.map +1 -0
  37. package/dist/sources/rbx/specials.js +706 -0
  38. package/dist/sources/rbx/styles.d.ts +21 -0
  39. package/dist/sources/rbx/styles.d.ts.map +1 -0
  40. package/dist/sources/rbx/styles.js +29 -0
  41. package/dist/sources/rbx/transform.d.ts +25 -0
  42. package/dist/sources/rbx/transform.d.ts.map +1 -0
  43. package/dist/sources/rbx/transform.js +911 -0
  44. package/dist/sources/react-bulma-components/deps.d.ts.map +1 -1
  45. package/dist/sources/react-bulma-components/deps.js +17 -3
  46. package/dist/sources/react-bulma-components/responsive.d.ts +1 -1
  47. package/dist/sources/react-bulma-components/responsive.d.ts.map +1 -1
  48. package/dist/sources/react-bulma-components/responsive.js +1 -1
  49. package/dist/sources/react-bulma-components/specials.d.ts +3 -9
  50. package/dist/sources/react-bulma-components/specials.d.ts.map +1 -1
  51. package/dist/sources/react-bulma-components/specials.js +4 -91
  52. package/dist/sources/react-bulma-components/styles.d.ts +9 -9
  53. package/dist/sources/react-bulma-components/styles.d.ts.map +1 -1
  54. package/dist/sources/react-bulma-components/styles.js +17 -389
  55. package/dist/sources/react-bulma-components/transform.d.ts.map +1 -1
  56. package/dist/sources/react-bulma-components/transform.js +378 -101
  57. package/dist/sources/registry.d.ts.map +1 -1
  58. package/dist/sources/registry.js +2 -0
  59. package/dist/types.d.ts +8 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +8 -7
  62. package/dist/sources/react-bulma-components/jsx-utils.d.ts.map +0 -1
  63. package/dist/sources/react-bulma-components/props.d.ts +0 -15
  64. package/dist/sources/react-bulma-components/props.d.ts.map +0 -1
@@ -0,0 +1,911 @@
1
+ /**
2
+ * The rbx (v2) → @allxsmith/bestax-bulma transform.
3
+ *
4
+ * Passes, in order:
5
+ * 1. resolve imports (named/namespace) and `const { Item } = Card`-style
6
+ * destructuring into canonical rbx component paths
7
+ * 2. per-element: structural special → rename → responsive flattening →
8
+ * component prop map → universal helper props → badge/tooltip wrapping
9
+ * 3. rewrite imports: drop rbx, add one merged bestax-bulma import; keep a
10
+ * trimmed rbx import (with a TODO) only for components that have no
11
+ * bestax equivalent (Generic, Tile, List, …)
12
+ *
13
+ * Anything unsafe gets a `// TODO(bestax-migrate)` comment on the enclosing
14
+ * statement plus an entry in the run report.
15
+ *
16
+ * The pass that has no react-bulma-components counterpart is the last one.
17
+ * rbx exposes badges and tooltips as *helper props on any element*, backed by
18
+ * the bulma-badge / bulma-tooltip extensions; bestax has real `Badge` and
19
+ * `Tooltip` components. So `<Button tooltip="hi" />` is a wrapping rewrite,
20
+ * not a rename.
21
+ */
22
+ import { BADGE_PROPS, MAPPING, TOOLTIP_PROPS, UNIVERSAL_PROPS, resolveMapping, } from './mapping.js';
23
+ import { addTodo, attributesOf, findAttr, jsxNameParts, literalValueOf, removeAttr, renameElement, } from '../_shared/jsx-utils.js';
24
+ import { applyPropAction, applyUniversalProps } from '../_shared/props.js';
25
+ import { collectBoundNames, makeReserve, nameOf, prefersTabs, resolvesToBinding, } from '../_shared/imports.js';
26
+ import { flattenResponsiveProps, RESPONSIVE_KINDS } from './responsive.js';
27
+ import { runSpecial } from './specials.js';
28
+ /* eslint-disable @typescript-eslint/no-explicit-any */
29
+ const RBX = 'rbx';
30
+ const BESTAX = '@allxsmith/bestax-bulma';
31
+ const BULMA_CSS = 'bulma/css/bulma.min.css';
32
+ const BESTAX_CSS = '@allxsmith/bestax-bulma/bestax.css';
33
+ const EXTRAS_CSS = '@allxsmith/bestax-bulma/extras.css';
34
+ const BULMA_CSS_SPECIFIERS = new Set([
35
+ 'bulma/css/bulma.css',
36
+ 'bulma/css/bulma.min.css',
37
+ ]);
38
+ const BESTAX_CSS_SPECIFIERS = new Set([
39
+ BESTAX_CSS,
40
+ '@allxsmith/bestax-bulma/bestax.min.css',
41
+ '@allxsmith/bestax-bulma/dist/bestax.css',
42
+ '@allxsmith/bestax-bulma/dist/bestax.min.css',
43
+ ]);
44
+ const EXTRAS_CSS_SPECIFIERS = new Set([
45
+ EXTRAS_CSS,
46
+ '@allxsmith/bestax-bulma/dist/extras.css',
47
+ ]);
48
+ /**
49
+ * The four Bulma extensions rbx pinned. An app that imported their CSS
50
+ * directly no longer needs to: bestax ships Badge/Tooltip/Loading/Divider.
51
+ */
52
+ const RBX_EXTENSION_CSS = /^bulma-(badge|divider|pageloader|tooltip)(\/|$)/;
53
+ export default function transform(fileInfo, api, options = {}) {
54
+ const j = api.jscodeshift;
55
+ const root = j(fileInfo.source);
56
+ const ctx = {
57
+ j,
58
+ file: fileInfo.path,
59
+ collector: options.collector,
60
+ retained: new Set(),
61
+ needed: new Map(),
62
+ reserve: root => root, // replaced below once local bindings are known
63
+ overrides: new WeakMap(),
64
+ dirty: false,
65
+ };
66
+ // ---- 1. Collect rbx imports -------------------------------------------
67
+ /** local identifier → imported rbx name ('*' for namespace imports). */
68
+ const imports = new Map();
69
+ /** local identifier → canonical rbx path (from destructuring). */
70
+ const aliases = new Map();
71
+ const rbxImportPaths = [];
72
+ /** Whether any rbx import declaration carried a (broken) default binding. */
73
+ let sawDefaultImport = false;
74
+ /**
75
+ * Whether a namespace binding (`import * as rbx`) is still referenced as a
76
+ * VALUE after the JSX pass. JSX names like `<rbx.Box>` are rewritten away,
77
+ * but `as={rbx.Block}` or a bare `String(rbx)` are not — and pruning the
78
+ * import under them leaves `rbx is not defined`.
79
+ */
80
+ let namespaceStillReferenced = false;
81
+ root.find(j.ImportDeclaration).forEach(path => {
82
+ const source = String(path.node.source.value);
83
+ if (source === RBX) {
84
+ rbxImportPaths.push(path);
85
+ for (const spec of path.node.specifiers ?? []) {
86
+ if (spec.type === 'ImportSpecifier' && spec.local) {
87
+ imports.set(nameOf(spec.local), nameOf(spec.imported));
88
+ }
89
+ else if (spec.type === 'ImportNamespaceSpecifier' && spec.local) {
90
+ imports.set(nameOf(spec.local), '*');
91
+ }
92
+ else if (spec.type === 'ImportDefaultSpecifier') {
93
+ // rbx has no default export, so this binding is already broken —
94
+ // but it may still be referenced, and dropping it turns a bad
95
+ // import into `X is not defined`. Flag it and keep it.
96
+ sawDefaultImport = true;
97
+ addTodo(ctx, path, 'imports', 'rbx has no default export; convert to named imports first');
98
+ }
99
+ }
100
+ }
101
+ else if (source.startsWith(`${RBX}/`) && source.endsWith('.css')) {
102
+ // rbx/index.css — handled by the CSS pass below.
103
+ }
104
+ else if (source.startsWith(`${RBX}/`)) {
105
+ // Deep imports reach rbx internals with no bestax counterpart —
106
+ // `rbx/base/theme` (the ThemeContext its customisation guide teaches)
107
+ // is the common one.
108
+ addTodo(ctx, path, 'imports', `\`${source}\` reaches into rbx's internals; bestax's equivalents are its \`Theme\` and \`ConfigProvider\` helpers (https://bestax.io/docs/api/helpers/theme)`);
109
+ }
110
+ });
111
+ // ---- 1a. Stylesheet imports (mode-driven) -----------------------------
112
+ // `bestax` (default): everything converges on the recommended combined
113
+ // bundle. `bulma`: plain Bulma v1 CSS plus the separate extras file.
114
+ // `keep`: only the dead rbx CSS import is retargeted.
115
+ const cssMode = options.cssMode ?? 'bestax';
116
+ let sawBestaxCss = root
117
+ .find(j.ImportDeclaration)
118
+ .paths()
119
+ .some(p => BESTAX_CSS_SPECIFIERS.has(String(p.node.source.value)));
120
+ // Whether some import in this file will become bestax.css, regardless of
121
+ // where it sits relative to an existing extras import.
122
+ const willAdoptBestaxCss = cssMode === 'bestax' &&
123
+ root
124
+ .find(j.ImportDeclaration)
125
+ .paths()
126
+ .some(p => {
127
+ const v = String(p.node.source.value);
128
+ return ((v.startsWith(`${RBX}/`) && v.endsWith('.css')) ||
129
+ BULMA_CSS_SPECIFIERS.has(v));
130
+ });
131
+ root.find(j.ImportDeclaration).forEach(path => {
132
+ const source = String(path.node.source.value);
133
+ const isRbxCss = source.startsWith(`${RBX}/`) && source.endsWith('.css');
134
+ const isBulmaCss = BULMA_CSS_SPECIFIERS.has(source);
135
+ const isExtrasCss = EXTRAS_CSS_SPECIFIERS.has(source);
136
+ // The four extensions rbx pinned; bestax covers all of them.
137
+ const isExtensionCss = RBX_EXTENSION_CSS.test(source) && source.endsWith('.css');
138
+ if (!isRbxCss && !isBulmaCss && !isExtrasCss && !isExtensionCss)
139
+ return;
140
+ if (isExtensionCss) {
141
+ // Kept, with a TODO, rather than dropped. The rbx JSX that needed this
142
+ // stylesheet becomes bestax components that carry their own styles, so
143
+ // for THAT markup it is dead -- but an app can also use the extension's
144
+ // classes directly (`has-tooltip-*`, `is-divider`) outside anything rbx
145
+ // rendered, and bestax's components style none of that. Deleting the
146
+ // import on the strength of a component mapping is the best-guess
147
+ // rewrite this package refuses to make, and it would contradict the
148
+ // manifest pass, which reports the same extensions rather than removing
149
+ // them for exactly this reason.
150
+ addTodo(ctx, path, 'css', `kept \`${source}\`: bestax ships Badge, Tooltip, Loading and Divider with their own styles, so this is only still needed by markup outside rbx that uses the extension's classes directly — drop the import once nothing does`);
151
+ return;
152
+ }
153
+ if (cssMode === 'bestax') {
154
+ if (isRbxCss || isBulmaCss) {
155
+ if (sawBestaxCss) {
156
+ path.prune(); // bestax.css already imported elsewhere in this file
157
+ }
158
+ else {
159
+ path.node.source = j.stringLiteral(BESTAX_CSS);
160
+ sawBestaxCss = true;
161
+ }
162
+ ctx.dirty = true;
163
+ }
164
+ else if (isExtrasCss && (sawBestaxCss || willAdoptBestaxCss)) {
165
+ // bestax.css already contains the extras. `willAdoptBestaxCss` covers
166
+ // the case where the extras import comes FIRST in the file and the
167
+ // bulma/rbx import that becomes bestax.css has not been visited yet —
168
+ // previously the extras survived alongside it and double-loaded.
169
+ path.prune();
170
+ ctx.dirty = true;
171
+ }
172
+ }
173
+ else if (cssMode === 'bulma') {
174
+ if (isRbxCss) {
175
+ path.node.source = j.stringLiteral(BULMA_CSS);
176
+ ctx.dirty = true;
177
+ }
178
+ if ((isRbxCss || isBulmaCss) && !sawBestaxCss) {
179
+ const hasExtras = root
180
+ .find(j.ImportDeclaration)
181
+ .paths()
182
+ .some(p => EXTRAS_CSS_SPECIFIERS.has(String(p.node.source.value)));
183
+ if (!hasExtras) {
184
+ // Themed Radio/Checkbox need the bestax extras next to plain Bulma.
185
+ path.insertAfter(j.importDeclaration([], j.stringLiteral(EXTRAS_CSS)));
186
+ ctx.dirty = true;
187
+ }
188
+ }
189
+ }
190
+ else if (isRbxCss) {
191
+ // keep: minimal fix — rbx's CSS carries Bulma 0.7.5.
192
+ path.node.source = j.stringLiteral(BULMA_CSS);
193
+ addTodo(ctx, path, 'css', `replaced rbx's CSS import with '${BULMA_CSS}'; install bulma@^1 (see https://bestax.io/docs/guides/getting-started/installation)`);
194
+ }
195
+ });
196
+ if (imports.size === 0 && !ctx.dirty) {
197
+ return undefined;
198
+ }
199
+ // The scope each binding was collected in, so a reference can be checked
200
+ // against the binding it actually resolves to rather than matched by name.
201
+ const programScope = root.find(j.Program).paths()[0]?.scope;
202
+ // Aliases are keyed by the AST node that owns the declaring scope, not by
203
+ // bare name. Two functions can each destructure `Header` from a different
204
+ // component; a name-keyed map let the second overwrite the first, and both
205
+ // declarations were then pruned while both `<Header…>` references were left
206
+ // resolving to nothing.
207
+ //
208
+ // Resolution walks the reference's ANCESTORS rather than consulting
209
+ // `scope.lookup`, because by the time references are rewritten the alias
210
+ // pass has already pruned the declaration -- so the binding is gone from
211
+ // the scope table, and `path.scope` objects are not identity-stable across
212
+ // traversals either.
213
+ const aliasesByOwner = new Map();
214
+ const ownersFor = (name) => {
215
+ const owners = [];
216
+ for (const [owner, perScope] of aliasesByOwner) {
217
+ if (perScope.has(name))
218
+ owners.push(owner);
219
+ }
220
+ return owners;
221
+ };
222
+ const aliasAt = (name, at) => {
223
+ const owners = ownersFor(name);
224
+ if (owners.length === 0)
225
+ return undefined;
226
+ // Without a reference location there is nothing to walk, so the only
227
+ // owner is the best answer available. WITH one, always walk: an unrelated
228
+ // `Header` parameter in another function must not resolve to the alias
229
+ // merely because the alias is the only one by that name. A single-owner
230
+ // shortcut here rewrote such a parameter to `{ Header: Card.Header }`,
231
+ // which is not even valid syntax.
232
+ if (at === undefined) {
233
+ return owners.length === 1
234
+ ? aliasesByOwner.get(owners[0]).get(name)
235
+ : undefined;
236
+ }
237
+ let cursor = at;
238
+ while (cursor) {
239
+ if (cursor.node !== undefined) {
240
+ const hit = aliasesByOwner.get(cursor.node)?.get(name);
241
+ if (hit) {
242
+ // The walk finds the nearest OWNER, but a binding can intervene
243
+ // between the reference and that owner: `function F(Header)` under
244
+ // a module-level `const { Header } = Card` shadows the alias, and
245
+ // resolving through it rewrote the parameter itself to
246
+ // `F(Card.Header)`. If the scope table still binds the name
247
+ // somewhere other than the owner, that nearer binding wins. (After
248
+ // the alias declaration is pruned the lookup returns nothing for
249
+ // the alias itself, which is the case this must keep allowing.)
250
+ const declaring = at.scope?.lookup(name)?.path?.node;
251
+ if (declaring !== undefined &&
252
+ declaring !== null &&
253
+ declaring !== cursor.node) {
254
+ return undefined;
255
+ }
256
+ return hit;
257
+ }
258
+ }
259
+ cursor = cursor.parent;
260
+ }
261
+ return undefined;
262
+ };
263
+ const anyAlias = (name) => ownersFor(name).length > 0;
264
+ // ---- 1b. Resolve `const { Item } = Card` destructuring -----------------
265
+ root.find(j.VariableDeclarator).forEach(path => {
266
+ const node = path.node;
267
+ if (node.id?.type !== 'ObjectPattern' || node.init?.type !== 'Identifier')
268
+ return;
269
+ const base = node.init.name;
270
+ const baseAlias = aliasAt(base, path);
271
+ const imported = imports.get(base) ?? baseAlias?.join('.');
272
+ if (!imported || imported === '*')
273
+ return;
274
+ if (!imports.has(base) && !baseAlias)
275
+ return;
276
+ const basePath = imports.has(base)
277
+ ? [imports.get(base)]
278
+ : baseAlias;
279
+ let allResolved = true;
280
+ for (const prop of node.id.properties) {
281
+ if ((prop.type === 'ObjectProperty' || prop.type === 'Property') &&
282
+ prop.key?.type === 'Identifier' &&
283
+ prop.value?.type === 'Identifier') {
284
+ const owner = path.scope
285
+ ?.path?.node;
286
+ let perScope = aliasesByOwner.get(owner);
287
+ if (!perScope) {
288
+ perScope = new Map();
289
+ aliasesByOwner.set(owner, perScope);
290
+ }
291
+ const target = [...basePath, prop.key.name];
292
+ perScope.set(prop.value.name, target);
293
+ aliases.set(prop.value.name, target);
294
+ // Pruning the declaration is only safe if every use of the alias will
295
+ // actually be rewritten. For a name the table cannot map, the JSX is
296
+ // left as written -- so dropping `const { Header } = Panel` left
297
+ // `<Header/>` referencing nothing at all.
298
+ // A `special` handler rewrites the element without naming a plain
299
+ // target (`Form.Label` becomes a bare <label>), so it counts as
300
+ // resolved just as a target does. Requiring `target` alone kept every
301
+ // declaration that mentioned one.
302
+ const targetMapping = resolveMapping(target);
303
+ if (!targetMapping ||
304
+ targetMapping.status === 'todo' ||
305
+ !(targetMapping.target || targetMapping.special)) {
306
+ allResolved = false;
307
+ }
308
+ }
309
+ else {
310
+ allResolved = false;
311
+ }
312
+ }
313
+ if (allResolved) {
314
+ const declaration = path.parent;
315
+ if (declaration?.node?.type === 'VariableDeclaration' &&
316
+ declaration.node.declarations.length === 1) {
317
+ declaration.prune();
318
+ }
319
+ else {
320
+ path.prune();
321
+ }
322
+ ctx.dirty = true;
323
+ }
324
+ else {
325
+ addTodo(ctx, path, 'imports', `could not resolve every name destructured from \`${base}\`; migrate the leftovers by hand`);
326
+ }
327
+ });
328
+ /** Resolve a JSX name into a canonical rbx component path, or null. */
329
+ function resolveJsxPath(name, at) {
330
+ const parts = jsxNameParts(name);
331
+ if (!parts)
332
+ return null;
333
+ const [head, ...rest] = parts;
334
+ const alias = aliasAt(head, at);
335
+ if (alias)
336
+ return [...alias, ...rest];
337
+ // An alias exists under this name but not for THIS reference's binding,
338
+ // so the reference is not ours to rewrite.
339
+ if (anyAlias(head) && !imports.has(head))
340
+ return null;
341
+ const imported = imports.get(head);
342
+ if (imported === undefined)
343
+ return null;
344
+ if (imported === '*')
345
+ return rest.length > 0 ? rest : null;
346
+ return [imported, ...rest];
347
+ }
348
+ ctx.resolve = resolveJsxPath;
349
+ // ---- 1c. Collect local bindings so new imports never collide -----------
350
+ const bound = collectBoundNames(j, root, RBX);
351
+ // Seed the reserved set with the local names of rbx imports that CANNOT be
352
+ // migrated, so a bestax import never claims one of them. Without this, an
353
+ // unmappable component aliased to a name bestax also wants
354
+ // (`import { Tile as Button }`) had its retained specifier dropped on the
355
+ // collision — and its `<Button>` JSX then silently resolved to the bestax
356
+ // Button. A component quietly became a different component.
357
+ //
358
+ // Keyed on the mapping status so only genuinely unmappable names force an
359
+ // alias; the common case (`Button` → `Button`) stays un-aliased.
360
+ for (const [local, imported] of imports) {
361
+ if (imported === '*')
362
+ continue;
363
+ const entry = MAPPING[imported];
364
+ // Unmappable roots, plus ANY aliased import. A root can be retained by an
365
+ // unknown or `todo` child even when the root itself maps (`Icon` is
366
+ // `partial`, but `<Icon.Unknown>` retains it), and when the local name
367
+ // differs from the imported one that retained binding can collide with a
368
+ // bestax local for a completely different component. An unaliased import
369
+ // is safe: its name already means the same component on both sides.
370
+ if (!entry || entry.status === 'todo' || local !== imported) {
371
+ bound.add(local);
372
+ }
373
+ }
374
+ // A root that DOES map can still be retained by a child the table cannot
375
+ // (`Icon` is `partial`, but `<Icon.Unknown>` keeps the source import alive).
376
+ // The status check above misses that case whenever the local name equals the
377
+ // imported one, so the bestax import took the plain local, the retained
378
+ // specifier was dropped on the collision, and `<Icon.Unknown>` silently
379
+ // resolved to the bestax component instead.
380
+ //
381
+ // Scanned rather than reserved wholesale: adding every partial root to
382
+ // `bound` would alias them in every file that uses one, whether or not
383
+ // anything is actually retained.
384
+ root.find(j.JSXElement).forEach(path => {
385
+ const head = jsxNameParts(path.node.openingElement.name)?.[0];
386
+ if (!head || !imports.has(head))
387
+ return;
388
+ const parts = resolveJsxPath(path.node.openingElement.name, path);
389
+ const mapping = parts ? resolveMapping(parts) : undefined;
390
+ if (!mapping || mapping.status === 'todo')
391
+ bound.add(head);
392
+ });
393
+ // The same root can be retained by a VALUE chain -- `const X = Icon.Unknown`
394
+ // -- which only the value-reference pass discovers, after `reserve` has
395
+ // already handed the plain local to bestax. Scan those chains here too.
396
+ root.find(j.Identifier).forEach(path => {
397
+ if (path.node.type !== 'Identifier')
398
+ return;
399
+ const head = path.node.name;
400
+ const imported = imports.get(head);
401
+ if (imported === undefined || imported === '*')
402
+ return;
403
+ const parent = path.parent?.node;
404
+ if (parent?.type !== 'MemberExpression' ||
405
+ parent.object !== path.node ||
406
+ parent.computed) {
407
+ return;
408
+ }
409
+ const chain = [imported];
410
+ let outer = path.parent;
411
+ while (outer?.node?.type === 'MemberExpression' &&
412
+ !outer.node.computed &&
413
+ outer.node.property?.type === 'Identifier') {
414
+ chain.push(nameOf(outer.node.property));
415
+ const next = outer.parent;
416
+ if (next?.node?.type === 'MemberExpression' &&
417
+ next.node.object === outer.node) {
418
+ outer = next;
419
+ }
420
+ else {
421
+ break;
422
+ }
423
+ }
424
+ const mapping = resolveMapping(chain);
425
+ if (!mapping || mapping.status === 'todo')
426
+ bound.add(head);
427
+ });
428
+ ctx.reserve = makeReserve(ctx, bound);
429
+ // Merge with an existing bestax import: reuse its locals verbatim.
430
+ // Only a declaration that already uses NAMED specifiers can absorb more of
431
+ // them. `import * as Bestax from '…'` cannot: a namespace specifier may not
432
+ // share a declaration with named ones, and appending to it emits a file
433
+ // that does not parse.
434
+ const existingBestax = root
435
+ .find(j.ImportDeclaration, { source: { value: BESTAX } })
436
+ .paths()
437
+ .find(p => (p.node.specifiers ?? []).every((spec) => spec.type === 'ImportSpecifier'));
438
+ const preExistingImports = new Set();
439
+ if (existingBestax) {
440
+ for (const spec of existingBestax.node.specifiers ?? []) {
441
+ if (spec.type === 'ImportSpecifier' && spec.local) {
442
+ ctx.needed.set(nameOf(spec.imported), nameOf(spec.local));
443
+ preExistingImports.add(nameOf(spec.imported));
444
+ }
445
+ }
446
+ }
447
+ /**
448
+ * Move one family of rbx helper props onto a wrapping bestax component.
449
+ * Returns the wrapper element, or null when the element carries none of
450
+ * them. Props with no bestax counterpart are dropped with a TODO rather
451
+ * than guessed at.
452
+ */
453
+ function buildWrapper(path, element, child, propMap, componentName, triggerProp) {
454
+ const present = attributesOf(element).filter((a) => a.name.name in propMap);
455
+ if (present.length === 0)
456
+ return null;
457
+ // The trigger prop is what carries the content; without it the rest are
458
+ // modifiers with nothing to modify.
459
+ if (!findAttr(element, triggerProp)) {
460
+ for (const attr of present)
461
+ removeAttr(element, attr);
462
+ addTodo(ctx, path, `prop:${triggerProp}`, `\`${present
463
+ .map((a) => a.name.name)
464
+ .join('`, `')}\` set without \`${triggerProp}\`; bestax's \`<${componentName}>\` needs content — dropped`);
465
+ ctx.dirty = true;
466
+ return null;
467
+ }
468
+ const wrapperAttrs = [];
469
+ for (const attr of present) {
470
+ const name = attr.name.name;
471
+ const target = propMap[name];
472
+ removeAttr(element, attr);
473
+ if (target === null) {
474
+ addTodo(ctx, path, `prop:${name}`, `bestax's \`<${componentName}>\` has no \`${name}\` equivalent; restyle by hand`);
475
+ continue;
476
+ }
477
+ attr.name = j.jsxIdentifier(target);
478
+ // rbx types both `badge` and `tooltip` as `number | string`. bestax's
479
+ // `Badge.content` is a ReactNode and takes either, but `Tooltip.label`
480
+ // is `string` — so a numeric tooltip has to be stringified or it fails
481
+ // to typecheck after migration.
482
+ if (target === 'label') {
483
+ const literal = literalValueOf(attr);
484
+ if (literal.kind === 'number') {
485
+ attr.value = j.stringLiteral(String(literal.value));
486
+ }
487
+ else if (literal.kind === 'expression') {
488
+ addTodo(ctx, path, `prop:${name}`, "`tooltip` is `number | string` in rbx but bestax's `label` takes a string; wrap a dynamic value in String(...) if it can be numeric");
489
+ }
490
+ }
491
+ wrapperAttrs.push(attr);
492
+ }
493
+ // `key` is only meaningful in the parent's child position, and the
494
+ // wrapper is what becomes the array member now. Leaving it on the inner
495
+ // element gives React a keyless child: a console warning, and index-based
496
+ // reconciliation that mis-reuses DOM on reorder.
497
+ const keyAttr = findAttr(child, 'key');
498
+ if (keyAttr) {
499
+ removeAttr(child, keyAttr);
500
+ wrapperAttrs.unshift(keyAttr);
501
+ }
502
+ const local = ctx.reserve(componentName);
503
+ const wrapper = j.jsxElement(j.jsxOpeningElement(j.jsxIdentifier(local), wrapperAttrs, false), j.jsxClosingElement(j.jsxIdentifier(local)), [child]);
504
+ ctx.dirty = true;
505
+ return wrapper;
506
+ }
507
+ // ---- 2. Transform JSX elements ----------------------------------------
508
+ root.find(j.JSXElement).forEach(path => {
509
+ const element = path.node;
510
+ const rbxPath = resolveJsxPath(element.openingElement.name, path);
511
+ if (!rbxPath)
512
+ return;
513
+ // Resolve by BINDING, not by name. `function F({ Card })` shadows the
514
+ // import with the caller's object, and rewriting its JSX to bestax's
515
+ // `Card.FooterItem` changed which component rendered.
516
+ const jsxHead = jsxNameParts(element.openingElement.name)?.[0];
517
+ if (jsxHead &&
518
+ !resolvesToBinding(path, jsxHead, anyAlias(jsxHead) ? undefined : programScope)) {
519
+ return;
520
+ }
521
+ const mapping = resolveMapping(rbxPath);
522
+ const dotted = rbxPath.join('.');
523
+ if (!mapping) {
524
+ ctx.retained.add(rbxPath[0]);
525
+ addTodo(ctx, path, 'unknown-component', `\`${dotted}\` is not a known rbx v2 export; migrate it by hand`);
526
+ return;
527
+ }
528
+ if (mapping.status === 'todo') {
529
+ ctx.retained.add(rbxPath[0]);
530
+ addTodo(ctx, path, `component:${dotted}`, `\`${dotted}\` — ${mapping.todo ?? 'migrate by hand'}`);
531
+ return;
532
+ }
533
+ // Structural special first — it may pick the target or replace the node.
534
+ let target = mapping.target;
535
+ const handled = new Set();
536
+ if (mapping.special) {
537
+ const result = runSpecial(mapping.special, ctx, path, element);
538
+ if (result.replaced)
539
+ return;
540
+ if (result.target)
541
+ target = result.target;
542
+ for (const prop of result.handledProps ?? [])
543
+ handled.add(prop);
544
+ }
545
+ // A parent's structural handler may have picked this element's target.
546
+ const override = ctx.overrides.get(element);
547
+ if (override)
548
+ target = override;
549
+ if (!target)
550
+ return;
551
+ // Rename to the bestax name, aliasing the import root on collision.
552
+ const [targetRoot, ...targetRest] = target.split('.');
553
+ const localTarget = [ctx.reserve(targetRoot), ...targetRest].join('.');
554
+ const currentParts = jsxNameParts(element.openingElement.name);
555
+ if (!currentParts || currentParts.join('.') !== localTarget) {
556
+ renameElement(j, element, localTarget);
557
+ ctx.dirty = true;
558
+ }
559
+ // Breakpoint objects: the universal `responsive` prop, plus the Column
560
+ // and Column.Group per-breakpoint shapes.
561
+ const kind = mapping.special
562
+ ? (RESPONSIVE_KINDS[mapping.special] ?? 'generic')
563
+ : 'generic';
564
+ flattenResponsiveProps(ctx, path, element, kind);
565
+ // Component-specific prop actions, then the universal helper pass.
566
+ if (mapping.props) {
567
+ for (const attr of [...attributesOf(element)]) {
568
+ const name = attr.name.name;
569
+ const action = mapping.props[name];
570
+ if (!action || handled.has(name))
571
+ continue;
572
+ handled.add(name);
573
+ applyPropAction(ctx, path, element, attr, action);
574
+ }
575
+ }
576
+ applyUniversalProps(ctx, path, element, handled, UNIVERSAL_PROPS);
577
+ // ---- 2a. badge/tooltip helper props → wrapping components ------------
578
+ // Last, so the inner element is already fully migrated. Both families are
579
+ // read off `element` — the props live there whichever wrapper is built
580
+ // first — while each wrapper takes the current outermost node as its
581
+ // child. Tooltip ends up outside Badge when both are present: the badge
582
+ // is positioned against the element it decorates, and the tooltip covers
583
+ // the pair.
584
+ let wrapped = element;
585
+ const badge = buildWrapper(path, element, wrapped, BADGE_PROPS, 'Badge', 'badge');
586
+ if (badge)
587
+ wrapped = badge;
588
+ const tooltip = buildWrapper(path, element, wrapped, TOOLTIP_PROPS, 'Tooltip', 'tooltip');
589
+ if (tooltip)
590
+ wrapped = tooltip;
591
+ if (wrapped !== element)
592
+ path.replace(wrapped);
593
+ });
594
+ // ---- 2b. Value references to rbx components ---------------------------
595
+ // Components can be referenced as plain values too (`as={Block}`, passed to
596
+ // helpers, …). JSX usages were renamed above; map the leftover identifier
597
+ // references so the pruned rbx import doesn't strand them.
598
+ // A shorthand property's key and value are the SAME node, so the walker
599
+ // reaches it twice. The first visit rewrites it and detaches the original;
600
+ // the second then matched neither key nor value and fell through to the
601
+ // generic "referenced as a value" branch, emitting a spurious TODO and
602
+ // marking the root retained when it was not.
603
+ const handledValueRefs = new WeakSet();
604
+ root.find(j.Identifier).forEach(path => {
605
+ // find(Identifier) also matches JSXIdentifier (a subtype) — JSX names
606
+ // were already handled by the element walker above.
607
+ if (path.node.type !== 'Identifier')
608
+ return;
609
+ if (handledValueRefs.has(path.node))
610
+ return;
611
+ const name = path.node.name;
612
+ // A name bound by `const { Footer } = Card` is in `aliases`, not
613
+ // `imports` — and the destructuring pass has already deleted the
614
+ // declaration that bound it. Skipping those left `Footer.Item` in the
615
+ // output with its `Card` import pruned: a reference to nothing.
616
+ const aliasPath = aliasAt(name, path);
617
+ const imported = aliasPath ? aliasPath[0] : imports.get(name);
618
+ if (imported === undefined)
619
+ return;
620
+ // Resolve by BINDING, not by name: a local that shadows the import is a
621
+ // different object, and rewriting it repoints the code at bestax's
622
+ // component.
623
+ if (!resolvesToBinding(path, name, aliasPath ? undefined : programScope)) {
624
+ return;
625
+ }
626
+ const parentNode = path.parent?.node;
627
+ const parentType = parentNode?.type;
628
+ if (parentType === 'ImportSpecifier' ||
629
+ parentType === 'ImportDefaultSpecifier' ||
630
+ parentType === 'ImportNamespaceSpecifier' ||
631
+ parentType === 'JSXOpeningElement' ||
632
+ parentType === 'JSXClosingElement' ||
633
+ parentType === 'JSXMemberExpression') {
634
+ return;
635
+ }
636
+ // A namespace binding in a real value position. `rbx.Button` is still a
637
+ // mappable component reference, so fall through to the member-expression
638
+ // branch below with an empty prefix (the namespace itself is not part of
639
+ // the rbx path). Anything else — a bare `rbx` — just pins the import.
640
+ const isNamespace = imported === '*';
641
+ if (isNamespace &&
642
+ !(parentType === 'MemberExpression' &&
643
+ parentNode.object === path.node &&
644
+ !parentNode.computed)) {
645
+ namespaceStillReferenced = true;
646
+ return;
647
+ }
648
+ // Non-reference positions: member property names and object keys.
649
+ if (parentType === 'MemberExpression' &&
650
+ parentNode.property === path.node &&
651
+ !parentNode.computed) {
652
+ return;
653
+ }
654
+ // Member-expression value references (`Card.Header`, `Tag.Group` used as
655
+ // expressions): resolve the full chain; when the bestax target is the same
656
+ // dotted chain the runtime compound still exists — only flag (or rewrite,
657
+ // for flat single-name targets) when it differs.
658
+ if (parentType === 'MemberExpression' &&
659
+ parentNode.object === path.node &&
660
+ !parentNode.computed) {
661
+ // Walk the full chain: `Card.Footer.Item` is one path, not
662
+ // `Card.Footer` with a stray `.Item`. Resolving only the first level
663
+ // left the tail pointing at a compound bestax does not have
664
+ // (bestax exposes `Card.FooterItem`).
665
+ const memberPath = isNamespace ? [] : [...(aliasPath ?? [imported])];
666
+ let outer = path.parent;
667
+ while (outer?.node?.type === 'MemberExpression' &&
668
+ !outer.node.computed &&
669
+ outer.node.property?.type === 'Identifier') {
670
+ memberPath.push(nameOf(outer.node.property));
671
+ const next = outer.parent;
672
+ if (next?.node?.type === 'MemberExpression' &&
673
+ next.node.object === outer.node) {
674
+ outer = next;
675
+ }
676
+ else {
677
+ break;
678
+ }
679
+ }
680
+ const memberMapping = resolveMapping(memberPath);
681
+ const dotted = memberPath.join('.');
682
+ if (memberMapping &&
683
+ memberMapping.status !== 'todo' &&
684
+ memberMapping.target &&
685
+ !memberMapping.special) {
686
+ if (memberMapping.target === dotted) {
687
+ if (isNamespace) {
688
+ // `rbx.Button` → `Button`: the namespace prefix has to go, so
689
+ // rebuild the chain from the target rather than renaming the
690
+ // namespace identifier in place (there is no MAPPING['*'] root
691
+ // to rename it to).
692
+ const [root, ...rest] = memberMapping.target.split('.');
693
+ let rebuilt = j.identifier(ctx.reserve(root));
694
+ for (const part of rest) {
695
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
696
+ }
697
+ outer.replace(rebuilt);
698
+ ctx.dirty = true;
699
+ return;
700
+ }
701
+ if (aliasPath) {
702
+ // A destructured alias stands for MORE than one segment of the
703
+ // chain (`const { Header } = Card` makes `Header` mean
704
+ // `Card.Header`), so renaming the identifier drops every segment
705
+ // but the last: `Header.Title` became `Card.Title`. Rebuild the
706
+ // whole chain from the target instead.
707
+ const [aliasRoot, ...aliasRest] = memberMapping.target.split('.');
708
+ let rebuilt = j.identifier(ctx.reserve(aliasRoot));
709
+ for (const part of aliasRest) {
710
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
711
+ }
712
+ outer.replace(rebuilt);
713
+ ctx.dirty = true;
714
+ return;
715
+ }
716
+ const rootMapping = MAPPING[imported];
717
+ if (rootMapping?.target) {
718
+ const local = ctx.reserve(rootMapping.target);
719
+ if (local !== name)
720
+ path.node.name = local;
721
+ ctx.dirty = true;
722
+ }
723
+ return;
724
+ }
725
+ if (!memberMapping.target.includes('.')) {
726
+ // Flat target (Column.Group → Columns): swap the whole chain.
727
+ const local = ctx.reserve(memberMapping.target);
728
+ outer.replace(j.identifier(local));
729
+ ctx.dirty = true;
730
+ return;
731
+ }
732
+ // Dotted target that differs from the source chain
733
+ // (Card.Footer.Item → Card.FooterItem): rebuild the chain against the
734
+ // bestax path rather than flagging something the table can answer.
735
+ const [targetRoot, ...targetRest] = memberMapping.target.split('.');
736
+ const rootLocal = ctx.reserve(targetRoot);
737
+ let rebuilt = j.identifier(rootLocal);
738
+ for (const part of targetRest) {
739
+ rebuilt = j.memberExpression(rebuilt, j.identifier(part));
740
+ }
741
+ outer.replace(rebuilt);
742
+ ctx.dirty = true;
743
+ return;
744
+ }
745
+ addTodo(ctx, outer, 'value-reference', `\`${isNamespace ? `${name}.` : ''}${dotted}\` is referenced as a value; migrate this usage by hand`);
746
+ if (isNamespace)
747
+ namespaceStillReferenced = true;
748
+ else
749
+ ctx.retained.add(imported);
750
+ return;
751
+ }
752
+ if ((parentType === 'ObjectProperty' || parentType === 'Property') &&
753
+ parentNode.key === path.node &&
754
+ !parentNode.shorthand) {
755
+ return;
756
+ }
757
+ // A shorthand property is both the key and the reference. Renaming the
758
+ // node in place rewrote the object's PUBLIC key
759
+ // (`{ Textarea }` → `{ TextArea }`), so expand it instead: the key keeps
760
+ // the name callers use, the value points at the migrated binding.
761
+ if ((parentType === 'ObjectProperty' || parentType === 'Property') &&
762
+ parentNode.shorthand) {
763
+ // Resolve through the alias path when there is one: `const { Footer } =
764
+ // Card` makes `{ Footer }` mean `Card.Footer`, not `Card`. Reading the
765
+ // root's mapping here emitted `{ Footer: Card }` — the wrong component
766
+ // under the right key.
767
+ const shorthandMapping = aliasPath
768
+ ? resolveMapping(aliasPath)
769
+ : MAPPING[imported];
770
+ if (shorthandMapping &&
771
+ shorthandMapping.status !== 'todo' &&
772
+ shorthandMapping.target &&
773
+ !shorthandMapping.special) {
774
+ const [root, ...rest] = shorthandMapping.target.split('.');
775
+ const local = ctx.reserve(root);
776
+ let value = j.identifier(local);
777
+ for (const part of rest) {
778
+ value = j.memberExpression(value, j.identifier(part));
779
+ }
780
+ if (rest.length > 0 || local !== name) {
781
+ // ast-types keeps DISTINCT key and value nodes even when
782
+ // `shorthand` is true, so both positions are visited. Mark both, or
783
+ // the second visit sees a node that matches neither the (new) key
784
+ // nor the (new) value and falls through to the generic branch.
785
+ handledValueRefs.add(parentNode.key);
786
+ handledValueRefs.add(parentNode.value);
787
+ parentNode.shorthand = false;
788
+ parentNode.key = j.identifier(name);
789
+ parentNode.value = value;
790
+ ctx.dirty = true;
791
+ }
792
+ }
793
+ else {
794
+ // Mark both positions before reporting: the key and the value are
795
+ // distinct nodes, so an unmarked branch reports the same property
796
+ // twice. The AST comment dedupes, which hid this in the output while
797
+ // the report still double-counted it.
798
+ handledValueRefs.add(parentNode.key);
799
+ handledValueRefs.add(parentNode.value);
800
+ ctx.retained.add(imported);
801
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is referenced as a value; migrate this usage by hand`);
802
+ }
803
+ return;
804
+ }
805
+ const mapping = aliasPath ? resolveMapping(aliasPath) : MAPPING[imported];
806
+ if (mapping &&
807
+ mapping.status !== 'todo' &&
808
+ mapping.target &&
809
+ !mapping.special) {
810
+ const [root, ...rest] = mapping.target.split('.');
811
+ const local = ctx.reserve(root);
812
+ if (rest.length > 0) {
813
+ // A dotted target needs a member expression, not a rename. Without
814
+ // this, a destructured alias used as a bare value
815
+ // (`const { Content } = Card; const V = Content`) fell through to the
816
+ // TODO branch and was left referencing a binding the destructuring
817
+ // pass had already deleted.
818
+ let value = j.identifier(local);
819
+ for (const part of rest) {
820
+ value = j.memberExpression(value, j.identifier(part));
821
+ }
822
+ path.replace(value);
823
+ }
824
+ else if (local !== name) {
825
+ path.node.name = local;
826
+ }
827
+ ctx.dirty = true;
828
+ }
829
+ else {
830
+ ctx.retained.add(imported);
831
+ addTodo(ctx, path, 'value-reference', `\`${name}\` is referenced as a value; migrate this usage by hand`);
832
+ }
833
+ });
834
+ // ---- 3. Rewrite imports -----------------------------------------------
835
+ if (imports.size > 0) {
836
+ const retainedNames = [...ctx.retained].sort((a, b) => a.localeCompare(b));
837
+ const freshNames = [...ctx.needed.entries()]
838
+ .filter(([imported]) => !preExistingImports.has(imported))
839
+ .sort((a, b) => a[0].localeCompare(b[0]));
840
+ const bestaxImport = freshNames.length > 0
841
+ ? j.importDeclaration(freshNames.map(([imported, local]) => j.importSpecifier(j.identifier(imported), j.identifier(local))), j.stringLiteral(BESTAX))
842
+ : null;
843
+ let inserted = false;
844
+ // A retained rbx specifier must never collide with a bestax import local
845
+ // (possible when one component is both JSX-migrated and value-retained).
846
+ const bestaxLocals = new Set(ctx.needed.values());
847
+ for (const path of rbxImportPaths) {
848
+ const node = path.node;
849
+ const keepSpecifiers = (node.specifiers ?? []).filter((spec) => {
850
+ // `import * as rbx` reaches every export at once, so it has to
851
+ // survive whenever ANY component is retained — the JSX still says
852
+ // `<rbx.Tile>`, and pruning the import leaves `rbx is not defined`.
853
+ if (spec.type === 'ImportNamespaceSpecifier') {
854
+ return ctx.retained.size > 0 || namespaceStillReferenced;
855
+ }
856
+ // Keep a default binding rather than stranding its references.
857
+ if (spec.type === 'ImportDefaultSpecifier')
858
+ return sawDefaultImport;
859
+ return (spec.type === 'ImportSpecifier' &&
860
+ ctx.retained.has(nameOf(spec.imported)) &&
861
+ !bestaxLocals.has(nameOf(spec.local)));
862
+ });
863
+ if (!inserted) {
864
+ if (existingBestax && bestaxImport) {
865
+ existingBestax.node.specifiers = [
866
+ ...(existingBestax.node.specifiers ?? []),
867
+ ...bestaxImport.specifiers,
868
+ ];
869
+ }
870
+ else if (bestaxImport) {
871
+ path.insertBefore(bestaxImport);
872
+ }
873
+ inserted = true;
874
+ }
875
+ const droppedOnCollision = (node.specifiers ?? []).filter((spec) => spec.type === 'ImportSpecifier' &&
876
+ ctx.retained.has(nameOf(spec.imported)) &&
877
+ bestaxLocals.has(nameOf(spec.local)));
878
+ for (const spec of droppedOnCollision) {
879
+ 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`);
880
+ }
881
+ if (keepSpecifiers.length > 0) {
882
+ node.specifiers = keepSpecifiers;
883
+ // Only when named/namespace bindings are being retained. An import
884
+ // kept solely for a broken default binding has no names to list, and
885
+ // already carries its own "rbx has no default export" TODO.
886
+ if (retainedNames.length > 0) {
887
+ node.comments = node.comments ?? [];
888
+ const text = ` TODO(bestax-migrate): ${retainedNames.join(', ')} ${retainedNames.length === 1 ? 'has' : 'have'} no bestax-bulma equivalent yet — migrate and remove this import`;
889
+ if (!node.comments.some((c) => c.value === text)) {
890
+ node.comments.push(j.commentLine(text, true, false));
891
+ }
892
+ }
893
+ }
894
+ else {
895
+ path.prune();
896
+ }
897
+ ctx.dirty = true;
898
+ }
899
+ }
900
+ // Flush the deferred stylesheet notes onto the first node that survived the
901
+ // import rewrite, so the drop is visible in the file and not only the report.
902
+ if (!ctx.dirty)
903
+ return undefined;
904
+ // Double quotes match the dominant JSX-attribute convention; users run
905
+ // their own formatter afterwards anyway. Tab-indented sources keep tabs so
906
+ // reprinted nodes don't drift from the untouched lines around them.
907
+ return root.toSource({
908
+ quote: 'double',
909
+ useTabs: prefersTabs(fileInfo.source),
910
+ });
911
+ }