@lablup/ui-common-cli 0.2.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,903 @@
1
+ /**
2
+ * 0.1 -> 0.2: the twelve 0.1 components Astryx now covers move to their
3
+ * Astryx counterparts, and their props are reshaped.
4
+ *
5
+ * - Imports from the root barrel and from `@lablup/ui-common/components/<Name>`
6
+ * move to `@lablup/ui-common/<Astryx subpath>` (Drawer to `/lab`). Kept
7
+ * components (PageHeader, StatCard, the Skeleton composites, …) keep the
8
+ * import they had. `usePrefersReducedMotion` moves from `/hooks` to the root.
9
+ * - Re-exports (`export { Select } from …`) keep their public name.
10
+ * - Every JSX element of a moved component goes through ./elements.mjs.
11
+ */
12
+ import {
13
+ freeName,
14
+ hasSpread,
15
+ identifierNames,
16
+ renameElement,
17
+ tagName,
18
+ TODO_TAG,
19
+ } from "../lib/jsx.mjs";
20
+ import { addTodo, printSource } from "../lib/todo.mjs";
21
+ import { ELEMENT_TRANSFORMS } from "./elements.mjs";
22
+ import { importsLocalLegacy, localExports, registerWrapper } from "./local-modules.mjs";
23
+ import { MOVED, REMOVED, REMOVED_TYPES, UIC } from "./map.mjs";
24
+
25
+ export const meta = {
26
+ id: "components",
27
+ title:
28
+ "Move removed 0.1 components to their Astryx counterparts and reshape their props",
29
+ extensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".cts"],
30
+ };
31
+
32
+ /**
33
+ * @param {string} source
34
+ * @returns {{kind: 'root'} | {kind: 'component', dir: string} | {kind: 'hooks'} | null}
35
+ */
36
+ function classify(source) {
37
+ if (source === UIC) return { kind: "root" };
38
+ if (source === `${UIC}/hooks`) return { kind: "hooks" };
39
+ const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source);
40
+ if (match) return { kind: "component", dir: match[1] };
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * @param {string} name exported name
46
+ * @param {NonNullable<ReturnType<typeof classify>>} from
47
+ */
48
+ function resolveName(name, from) {
49
+ if (from.kind === "hooks") {
50
+ const moved = MOVED.get(name);
51
+ return moved && moved.from === `${UIC}/hooks`
52
+ ? { action: "move", source: moved.to }
53
+ : { action: "keep" };
54
+ }
55
+ if (REMOVED.has(name)) return { action: "component", entry: REMOVED.get(name) };
56
+ if (REMOVED_TYPES.has(name))
57
+ return { action: "type", .../** @type {any} */ (REMOVED_TYPES.get(name)) };
58
+ return { action: "keep" };
59
+ }
60
+
61
+ /** @param {string} source */
62
+ function detectQuote(source) {
63
+ const double = (source.match(/from "/g) ?? []).length;
64
+ const single = (source.match(/from '/g) ?? []).length;
65
+ return single > double ? "single" : "double";
66
+ }
67
+
68
+ /**
69
+ * Names a new import binding must not take: every import binding, and every
70
+ * name bound or read in any scope of the file (a function-local `const Card`,
71
+ * a parameter, a global). A rename onto one of them would capture the
72
+ * other's references, or be captured by it.
73
+ *
74
+ * @param {any} j
75
+ * @param {any} root
76
+ */
77
+ function takenNames(j, root) {
78
+ const names = new Set();
79
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ p) => {
80
+ for (const s of p.node.specifiers ?? []) if (s.local) names.add(s.local.name);
81
+ });
82
+ root.find(j.Identifier).forEach((/** @type {any} */ p) => {
83
+ if (p.node.type === "JSXIdentifier") {
84
+ const parent = p.parent?.node;
85
+ const isTag =
86
+ parent?.type === "JSXOpeningElement" ||
87
+ parent?.type === "JSXClosingElement" ||
88
+ (parent?.type === "JSXMemberExpression" && parent.object === p.node);
89
+ if (isTag) names.add(p.node.name);
90
+ return;
91
+ }
92
+ if (isReference(p)) names.add(p.node.name);
93
+ });
94
+ return names;
95
+ }
96
+
97
+ /**
98
+ * Whether `name`, read at `path`, is the module-level binding (an import)
99
+ * rather than a local that shadows it.
100
+ *
101
+ * @param {any} path
102
+ * @param {string} name
103
+ * @param {any} programScope
104
+ */
105
+ function isModuleBinding(path, name, programScope) {
106
+ const scope = path.scope?.lookup(name);
107
+ return scope == null || scope === programScope;
108
+ }
109
+
110
+ /**
111
+ * Whether an Identifier path is a reference to a binding, not a property
112
+ * name, an import/export name slot, or a declaration key.
113
+ *
114
+ * @param {any} path
115
+ */
116
+ function isReference(path) {
117
+ const parent = path.parent?.node;
118
+ const node = path.node;
119
+ if (!parent) return true;
120
+ switch (parent.type) {
121
+ case "ImportSpecifier":
122
+ case "ImportDefaultSpecifier":
123
+ case "ImportNamespaceSpecifier":
124
+ case "ExportSpecifier":
125
+ return false;
126
+ case "MemberExpression":
127
+ case "OptionalMemberExpression":
128
+ return parent.object === node || parent.computed;
129
+ case "ObjectProperty":
130
+ case "Property":
131
+ return parent.value === node || parent.computed;
132
+ case "TSPropertySignature":
133
+ case "ClassProperty":
134
+ case "PropertyDefinition":
135
+ case "ClassMethod":
136
+ case "MethodDefinition":
137
+ case "ObjectMethod":
138
+ return parent.computed === true && parent.key === node;
139
+ case "TSQualifiedName":
140
+ return parent.left === node;
141
+ default:
142
+ return true;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Rename every reference to a module-level binding, JSX tags included. A
148
+ * local that shadows it keeps its name.
149
+ *
150
+ * @param {any} j
151
+ * @param {any} root
152
+ * @param {string} from
153
+ * @param {string} to
154
+ * @param {any} programScope
155
+ */
156
+ function renameReferences(j, root, from, to, programScope) {
157
+ root.find(j.Identifier, { name: from }).forEach((/** @type {any} */ p) => {
158
+ if (p.node.type === "JSXIdentifier") return;
159
+ if (!isModuleBinding(p, from, programScope)) return;
160
+ const parent = p.parent?.node;
161
+ if (
162
+ (parent?.type === "ObjectProperty" || parent?.type === "Property") &&
163
+ parent.shorthand &&
164
+ parent.value === p.node
165
+ ) {
166
+ parent.shorthand = false;
167
+ parent.value = j.identifier(to);
168
+ return;
169
+ }
170
+ if (parent?.type === "ExportSpecifier" && parent.local === p.node) {
171
+ // A fresh node: recast prints a parsed specifier from its original
172
+ // fields, so editing local/exported in place loses the public name.
173
+ const spec = j.exportSpecifier.from({
174
+ local: j.identifier(to),
175
+ exported: j.identifier(parent.exported?.name ?? from),
176
+ });
177
+ if (parent.exportKind) spec.exportKind = parent.exportKind;
178
+ p.parent.replace(spec);
179
+ return;
180
+ }
181
+ if (!isReference(p)) return;
182
+ p.node.name = to;
183
+ });
184
+ root.find(j.JSXIdentifier, { name: from }).forEach((/** @type {any} */ p) => {
185
+ if (!isModuleBinding(p, from, programScope)) return;
186
+ const parent = p.parent?.node;
187
+ if (
188
+ parent?.type === "JSXOpeningElement" ||
189
+ parent?.type === "JSXClosingElement" ||
190
+ (parent?.type === "JSXMemberExpression" && parent.object === p.node)
191
+ ) {
192
+ p.node.name = to;
193
+ }
194
+ });
195
+ }
196
+
197
+ /**
198
+ * @param {any} j
199
+ * @param {string} imported
200
+ * @param {string} local
201
+ */
202
+ function importSpecifier(j, imported, local) {
203
+ return imported === local
204
+ ? j.importSpecifier(j.identifier(imported))
205
+ : j.importSpecifier(j.identifier(imported), j.identifier(local));
206
+ }
207
+
208
+ /**
209
+ * @param {{source: string, kind: 'value'|'type', imported: string, local: string}[]} adds
210
+ * @param {any} j
211
+ */
212
+ function buildImports(j, adds) {
213
+ /** @type {Map<string, any[]>} */
214
+ const groups = new Map();
215
+ for (const add of adds) {
216
+ const key = `${add.kind}\u0000${add.source}`;
217
+ const list = groups.get(key) ?? [];
218
+ if (!list.some((s) => s.imported === add.imported && s.local === add.local))
219
+ list.push(add);
220
+ groups.set(key, list);
221
+ }
222
+ return [...groups.entries()].map(([key, list]) => {
223
+ const [kind, source] = key.split("\u0000");
224
+ const decl = j.importDeclaration(
225
+ list.map((a) => importSpecifier(j, a.imported, a.local)),
226
+ j.stringLiteral(source),
227
+ );
228
+ if (kind === "type") decl.importKind = "type";
229
+ return decl;
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Put `decls` before `path`'s statement. Leading comments of the statement
235
+ * (a file header, say) move to the first inserted declaration, so they stay
236
+ * on top. Inserting before an existing statement, not after it, keeps recast
237
+ * from moving the blank line that follows the import block.
238
+ *
239
+ * @param {any} path
240
+ * @param {any[]} decls
241
+ */
242
+ function insertBeforeKeepingComments(path, decls) {
243
+ const node = path.node;
244
+ const leading = (node.comments ?? []).filter(
245
+ (/** @type {any} */ c) => c.leading !== false,
246
+ );
247
+ if (leading.length > 0) {
248
+ decls[0].comments = [...leading, ...(decls[0].comments ?? [])];
249
+ node.comments = (node.comments ?? []).filter(
250
+ (/** @type {any} */ c) => !leading.includes(c),
251
+ );
252
+ }
253
+ path.insertBefore(...decls);
254
+ }
255
+
256
+ /**
257
+ * Replace a declaration with `decls` by reusing its node for the last one, so
258
+ * recast keeps it in place without inventing blank lines around it.
259
+ *
260
+ * @param {any} path
261
+ * @param {any[]} decls
262
+ * @param {"importKind" | "exportKind"} kindKey
263
+ */
264
+ function reuseNode(path, decls, kindKey) {
265
+ const node = path.node;
266
+ const last = decls[decls.length - 1];
267
+ const before = decls.slice(0, -1);
268
+ const own = node.comments ?? [];
269
+ node.specifiers = last.specifiers;
270
+ node.source = last.source;
271
+ node[kindKey] = last[kindKey] ?? "value";
272
+ if (before.length > 0) {
273
+ node.comments = last.comments ?? [];
274
+ before[0].comments = [...own, ...(before[0].comments ?? [])];
275
+ path.insertBefore(...before);
276
+ } else {
277
+ node.comments = [...own, ...(last.comments ?? [])];
278
+ }
279
+ }
280
+
281
+ /** @param {any} entry a local-module export */
282
+ const isRemovedType = (entry) => entry?.kind === "type" && entry.removed === true;
283
+
284
+ /**
285
+ * Remove a top-level statement, handing its comments (TODOs included) to the
286
+ * statement after it, or before it when it was the last.
287
+ *
288
+ * @param {any} j
289
+ * @param {any} root
290
+ * @param {any} path
291
+ */
292
+ function pruneKeepingComments(j, root, path) {
293
+ const node = path.node;
294
+ const comments = node.comments ?? [];
295
+ const body = root.find(j.Program).get().node.body;
296
+ const index = body.indexOf(node);
297
+ path.prune();
298
+ if (comments.length === 0 || index === -1) return;
299
+ const next = body[index];
300
+ if (next) {
301
+ next.comments = [...comments, ...(next.comments ?? [])];
302
+ return;
303
+ }
304
+ const previous = body[index - 1];
305
+ if (previous) {
306
+ previous.comments = [
307
+ ...(previous.comments ?? []),
308
+ ...comments.map((/** @type {any} */ c) => ({
309
+ ...c,
310
+ leading: false,
311
+ trailing: true,
312
+ })),
313
+ ];
314
+ return;
315
+ }
316
+ keepCommentsInEmptyModule(j, root.find(j.Program).get().node, comments);
317
+ }
318
+
319
+ /**
320
+ * A module the codemod emptied keeps its TODOs on an `export {};`, which also
321
+ * keeps it a module.
322
+ *
323
+ * @param {any} j
324
+ * @param {any} program
325
+ * @param {any[]} comments
326
+ */
327
+ function keepCommentsInEmptyModule(j, program, comments) {
328
+ const decl = j.exportNamedDeclaration(null, []);
329
+ decl.comments = comments.map((/** @type {any} */ c) => ({
330
+ ...c,
331
+ leading: true,
332
+ trailing: false,
333
+ }));
334
+ program.body.push(decl);
335
+ }
336
+
337
+ /**
338
+ * A removed 0.1 type with no Astryx counterpart loses its import. A local
339
+ * re-export of it (`import type { X } from …; export type { X };`) goes too,
340
+ * the same way a re-export straight from ui-common does: left behind it names
341
+ * nothing, and the file no longer parses. No placeholder type is declared in
342
+ * its place: an `unknown` alias would keep every importer compiling against a
343
+ * type that no longer exists. The TODO goes on the export, or where it stood.
344
+ *
345
+ * @param {any} j
346
+ * @param {any} root
347
+ * @param {Map<string, {imported: string, component: string}>} dropped
348
+ * @param {any} programScope
349
+ */
350
+ function dropLocalReexports(j, root, dropped, programScope) {
351
+ root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => {
352
+ const node = path.node;
353
+ if (node.source || node.declaration) return;
354
+ const specifiers = node.specifiers ?? [];
355
+ const gone = specifiers.filter(
356
+ (/** @type {any} */ s) =>
357
+ s.local?.name &&
358
+ dropped.has(s.local.name) &&
359
+ isModuleBinding(path, s.local.name, programScope),
360
+ );
361
+ if (gone.length === 0) return;
362
+ const names = gone.map((/** @type {any} */ s) => s.exported?.name ?? s.local.name);
363
+ const components = [
364
+ ...new Set(
365
+ gone.map((/** @type {any} */ s) => dropped.get(s.local.name)?.component),
366
+ ),
367
+ ];
368
+ const message = `${names.join(", ")} (removed with ${components.join(", ")} in 0.2, no Astryx counterpart) ${names.length === 1 ? "is" : "are"} no longer re-exported from here; modules importing ${names.length === 1 ? "it" : "them"} from here need a type of their own.`;
369
+ const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s));
370
+ if (rest.length > 0) {
371
+ node.specifiers = rest;
372
+ addTodo(j, path, message);
373
+ return;
374
+ }
375
+ node.comments = [
376
+ ...(node.comments ?? []),
377
+ j.commentLine(` ${TODO_TAG}: ${message}`, true, false),
378
+ ];
379
+ pruneKeepingComments(j, root, path);
380
+ });
381
+ }
382
+
383
+ /**
384
+ * @param {{source: string, path: string}} file
385
+ * @param {{jscodeshift: any}} api
386
+ * @param {{flags: {packages: Map<string, string>, touched: Set<string>}}} ctx
387
+ */
388
+ export default function transform(file, api, ctx) {
389
+ const direct = file.source.includes(UIC);
390
+ const viaLocal = importsLocalLegacy(api.jscodeshift, ctx, file.path, file.source);
391
+ if (!direct && !viaLocal) return undefined;
392
+ const j = api.jscodeshift;
393
+ // Registers the wrappers this module defines, for the report.
394
+ if (direct && ctx.source) localExports(j, ctx, file.path);
395
+ const root = j(file.source);
396
+ const isTS = /\.[cm]?tsx?$/.test(file.path);
397
+ const taken = takenNames(j, root);
398
+ const programScope = root.find(j.Program).get().scope;
399
+ let touched = false;
400
+
401
+ /**
402
+ * The local name for an import of `target` that replaces `local`: the
403
+ * target itself when nothing in any scope of the file uses it, else a
404
+ * `Uic`-prefixed alias that is free.
405
+ *
406
+ * @param {string} target
407
+ */
408
+ const localFor = (target) =>
409
+ freeName(taken, taken.has(target) ? [`Uic${target}`] : [target]);
410
+
411
+ /** @type {Map<string, string>} */
412
+ const renames = new Map();
413
+ /** @type {Map<string, {component: string, entry: any, local: string, jsxCount: number, valueRefs: number, add: any}>} */
414
+ const bindings = new Map();
415
+ /** @type {Array<{path: any, keep: any[], adds: any[], pruned?: boolean}>} */
416
+ const plans = [];
417
+ /** @type {any[]} */
418
+ const extra = [];
419
+ /**
420
+ * Local bindings of removed 0.1 types whose import was dropped: a local
421
+ * `export type { X }` of one goes with it (see dropLocalReexports).
422
+ *
423
+ * @type {Map<string, {imported: string, component: string}>}
424
+ */
425
+ const droppedTypes = new Map();
426
+
427
+ /**
428
+ * The local name to use for `name` from `@lablup/ui-common/<subpath>`,
429
+ * importing it if nothing does yet.
430
+ *
431
+ * @param {string} name
432
+ * @param {string} subpath
433
+ */
434
+ const ensureImport = (name, subpath) => {
435
+ const source = `${UIC}/${subpath}`;
436
+ for (const plan of plans) {
437
+ const hit = plan.adds.find(
438
+ (a) => a.source === source && a.imported === name && a.kind === "value",
439
+ );
440
+ if (hit) {
441
+ hit.used = true;
442
+ return hit.local;
443
+ }
444
+ }
445
+ const existing = extra.find((a) => a.source === source && a.imported === name);
446
+ if (existing) return existing.local;
447
+ const local = localFor(name);
448
+ extra.push({ source, kind: "value", imported: name, local });
449
+ return local;
450
+ };
451
+
452
+ /**
453
+ * An import from a project module that hands 0.1 components on (a barrel
454
+ * the codemod rewrites under the 0.1 names): its components get the same
455
+ * element rewrite as a direct import, and the import itself stays. Its
456
+ * removed types go, as from ui-common. A wrapper's import is only counted.
457
+ *
458
+ * @param {any} path
459
+ * @param {string} source
460
+ */
461
+ const localImport = (path, source) => {
462
+ const target = ctx.resolveImport?.(file.path, source);
463
+ const entries = target ? localExports(j, ctx, target) : null;
464
+ if (!entries || entries.size === 0) return;
465
+ const declIsType = path.node.importKind === "type";
466
+ const kept = [];
467
+ for (const spec of path.node.specifiers ?? []) {
468
+ const name =
469
+ spec.type === "ImportSpecifier"
470
+ ? spec.imported.name
471
+ : spec.type === "ImportDefaultSpecifier"
472
+ ? "default"
473
+ : null;
474
+ const entry = name == null ? undefined : entries.get(name);
475
+ if (entry?.kind === "type" && entry.removed) {
476
+ touched = true;
477
+ addTodo(
478
+ j,
479
+ path,
480
+ `type ${entry.type} was removed with ${entry.component} in 0.2 and has no Astryx counterpart; ${source} no longer exports it.`,
481
+ );
482
+ droppedTypes.set(spec.local.name, {
483
+ imported: entry.type,
484
+ component: entry.component,
485
+ });
486
+ continue;
487
+ }
488
+ kept.push(spec);
489
+ if (entry?.kind === "wrapper") registerWrapper(ctx, entry, file.path);
490
+ if (entry?.kind !== "component") continue;
491
+ if (declIsType || spec.importKind === "type") continue;
492
+ touched = true;
493
+ ctx.flags.touched.add(entry.component);
494
+ bindings.set(spec.local.name, {
495
+ component: entry.component,
496
+ entry: REMOVED.get(entry.component),
497
+ local: spec.local.name,
498
+ jsxCount: 0,
499
+ valueRefs: 0,
500
+ add: null,
501
+ via: path,
502
+ });
503
+ }
504
+ if (kept.length > 0) path.node.specifiers = kept;
505
+ else pruneKeepingComments(j, root, path);
506
+ };
507
+
508
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => {
509
+ const source = path.node.source.value;
510
+ const from = typeof source === "string" ? classify(source) : null;
511
+ if (!from) {
512
+ if (viaLocal && typeof source === "string") localImport(path, source);
513
+ return;
514
+ }
515
+ const specifiers = path.node.specifiers ?? [];
516
+ if (specifiers.length === 0) return;
517
+ const declIsType = path.node.importKind === "type";
518
+ const keep = [];
519
+ const adds = [];
520
+ for (const spec of specifiers) {
521
+ if (spec.type === "ImportNamespaceSpecifier") {
522
+ keep.push(spec);
523
+ addTodo(
524
+ j,
525
+ path,
526
+ `namespace import of ${source}: in 0.2 Badge, Button, Select, Tabs and the other removed 0.1 components are Astryx's (or gone). Rewrite the ${spec.local.name}.X uses by hand.`,
527
+ );
528
+ touched = true;
529
+ continue;
530
+ }
531
+ const imported =
532
+ spec.type === "ImportDefaultSpecifier"
533
+ ? from.kind === "component"
534
+ ? from.dir
535
+ : null
536
+ : spec.imported.name;
537
+ if (imported == null) {
538
+ keep.push(spec);
539
+ continue;
540
+ }
541
+ const local = spec.local?.name ?? imported;
542
+ const kind = declIsType || spec.importKind === "type" ? "type" : "value";
543
+ const resolved = resolveName(imported, from);
544
+
545
+ if (resolved.action === "keep") {
546
+ if (spec.type === "ImportDefaultSpecifier") {
547
+ adds.push({ source, kind, imported, local });
548
+ touched = true;
549
+ } else {
550
+ keep.push(spec);
551
+ }
552
+ continue;
553
+ }
554
+ touched = true;
555
+ if (resolved.action === "move") {
556
+ adds.push({ source: resolved.source, kind, imported, local });
557
+ continue;
558
+ }
559
+ if (resolved.action === "component") {
560
+ const entry = resolved.entry;
561
+ let finalLocal = local;
562
+ if (local === imported && entry.to !== local) {
563
+ finalLocal = localFor(entry.to);
564
+ renames.set(local, finalLocal);
565
+ }
566
+ const add = {
567
+ source: `${UIC}/${entry.subpath}`,
568
+ kind,
569
+ imported: entry.to,
570
+ local: finalLocal,
571
+ };
572
+ adds.push(add);
573
+ ctx.flags.touched.add(imported);
574
+ if (kind === "value") {
575
+ bindings.set(local, {
576
+ component: imported,
577
+ entry,
578
+ local: finalLocal,
579
+ jsxCount: 0,
580
+ valueRefs: 0,
581
+ add,
582
+ });
583
+ }
584
+ for (const [name, range] of Object.entries(entry.requiresPackages)) {
585
+ ctx.flags.packages.set(name, range);
586
+ }
587
+ continue;
588
+ }
589
+ // A 0.1 type.
590
+ if (resolved.to == null) {
591
+ addTodo(
592
+ j,
593
+ path,
594
+ `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`,
595
+ );
596
+ droppedTypes.set(local, { imported, component: resolved.component });
597
+ continue;
598
+ }
599
+ let finalLocal = local;
600
+ if (local === imported && resolved.to !== local) {
601
+ finalLocal = localFor(resolved.to);
602
+ renames.set(local, finalLocal);
603
+ }
604
+ adds.push({
605
+ source: `${UIC}/${REMOVED.get(resolved.component).subpath}`,
606
+ kind: "type",
607
+ imported: resolved.to,
608
+ local: finalLocal,
609
+ });
610
+ }
611
+ plans.push({ path, keep, adds });
612
+ });
613
+
614
+ /**
615
+ * A type re-export from a project module (`export type { X }` from `./DataTable`)
616
+ * of a type that module no longer exports (its own import of X from ui-common
617
+ * was dropped): drop it too.
618
+ *
619
+ * @param {any} path
620
+ * @param {string} source
621
+ */
622
+ const localReexport = (path, source) => {
623
+ const target = ctx.resolveImport?.(file.path, source);
624
+ const entries = target ? localExports(j, ctx, target) : null;
625
+ if (!entries || entries.size === 0) return;
626
+ const specifiers = path.node.specifiers ?? [];
627
+ const gone = specifiers.filter(
628
+ (/** @type {any} */ s) =>
629
+ s.type === "ExportSpecifier" && isRemovedType(entries.get(s.local?.name)),
630
+ );
631
+ if (gone.length === 0) return;
632
+ touched = true;
633
+ const names = gone.map((/** @type {any} */ s) => s.exported.name);
634
+ const message = `${names.join(", ")} ${names.length === 1 ? "is" : "are"} no longer re-exported: ${source} does not export ${names.length === 1 ? "it" : "them"} any more (removed in 0.2, no Astryx counterpart).`;
635
+ const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s));
636
+ if (rest.length > 0) {
637
+ path.node.specifiers = rest;
638
+ addTodo(j, path, message);
639
+ } else {
640
+ path.node.comments = [
641
+ ...(path.node.comments ?? []),
642
+ j.commentLine(` ${TODO_TAG}: ${message}`, true, false),
643
+ ];
644
+ pruneKeepingComments(j, root, path);
645
+ }
646
+ };
647
+
648
+ // Re-exports: `export { Select } from "@lablup/ui-common"` keeps its name.
649
+ root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => {
650
+ const source = path.node.source?.value;
651
+ const from = typeof source === "string" ? classify(source) : null;
652
+ if (!from) {
653
+ if (viaLocal && typeof source === "string") localReexport(path, source);
654
+ return;
655
+ }
656
+ const declIsType = path.node.exportKind === "type";
657
+ const keep = [];
658
+ /** @type {Map<string, any[]>} */
659
+ const moved = new Map();
660
+ let reexportedComponent = false;
661
+ let droppedSpecs = 0;
662
+ for (const spec of path.node.specifiers ?? []) {
663
+ const localName = spec.local?.name ?? spec.exported.name;
664
+ const imported =
665
+ localName === "default" && from.kind === "component" ? from.dir : localName;
666
+ const exported = spec.exported.name;
667
+ const kind = declIsType || spec.exportKind === "type" ? "type" : "value";
668
+ const resolved = resolveName(imported, from);
669
+ let target = null;
670
+ let targetSource = null;
671
+ if (resolved.action === "move") {
672
+ target = imported;
673
+ targetSource = resolved.source;
674
+ } else if (resolved.action === "component") {
675
+ target = resolved.entry.to;
676
+ targetSource = `${UIC}/${resolved.entry.subpath}`;
677
+ reexportedComponent = true;
678
+ for (const [name, range] of Object.entries(resolved.entry.requiresPackages)) {
679
+ ctx.flags.packages.set(name, range);
680
+ }
681
+ } else if (resolved.action === "type") {
682
+ if (resolved.to == null) {
683
+ addTodo(
684
+ j,
685
+ path,
686
+ `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`,
687
+ );
688
+ touched = true;
689
+ droppedSpecs++;
690
+ continue;
691
+ }
692
+ target = resolved.to;
693
+ targetSource = `${UIC}/${REMOVED.get(resolved.component).subpath}`;
694
+ } else if (localName === "default" && from.kind === "component") {
695
+ target = imported;
696
+ targetSource = source;
697
+ }
698
+ if (target == null || targetSource == null) {
699
+ keep.push(spec);
700
+ continue;
701
+ }
702
+ touched = true;
703
+ const key = `${kind}\u0000${targetSource}`;
704
+ const list = moved.get(key) ?? [];
705
+ list.push(
706
+ j.exportSpecifier.from({
707
+ local: j.identifier(target),
708
+ exported: j.identifier(exported),
709
+ }),
710
+ );
711
+ moved.set(key, list);
712
+ }
713
+ if (moved.size === 0) {
714
+ // Only removed types: they go, and the TODO stays where they stood.
715
+ if (droppedSpecs === 0) return;
716
+ if (keep.length > 0) path.node.specifiers = keep;
717
+ else pruneKeepingComments(j, root, path);
718
+ return;
719
+ }
720
+ const decls = [...moved.entries()].map(([key, specs]) => {
721
+ const [kind, targetSource] = key.split("\u0000");
722
+ const decl = j.exportNamedDeclaration(null, specs, j.stringLiteral(targetSource));
723
+ if (kind === "type") decl.exportKind = "type";
724
+ return decl;
725
+ });
726
+ if (reexportedComponent) {
727
+ decls[0].comments = [
728
+ j.commentLine(
729
+ " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props.",
730
+ true,
731
+ false,
732
+ ),
733
+ ];
734
+ }
735
+ if (keep.length > 0) {
736
+ path.node.specifiers = keep;
737
+ insertBeforeKeepingComments(path, decls);
738
+ } else {
739
+ reuseNode(path, decls, "exportKind");
740
+ }
741
+ });
742
+
743
+ if (droppedTypes.size > 0) dropLocalReexports(j, root, droppedTypes, programScope);
744
+
745
+ root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => {
746
+ const source = path.node.source?.value;
747
+ if (typeof source !== "string" || !classify(source)) return;
748
+ touched = true;
749
+ addTodo(
750
+ j,
751
+ path,
752
+ `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names. The upgrade migrated the elements of them in the modules it scanned that import them from here; any other importer still passes 0.1 props.`,
753
+ );
754
+ });
755
+
756
+ // Dynamic imports of a removed component's subpath.
757
+ root.find(j.CallExpression).forEach((/** @type {any} */ path) => {
758
+ const node = path.node;
759
+ const isDynamic = node.callee.type === "Import";
760
+ const isRequire =
761
+ node.callee.type === "Identifier" && node.callee.name === "require";
762
+ if (!isDynamic && !isRequire) return;
763
+ const arg = node.arguments[0];
764
+ const source = arg?.value;
765
+ if (typeof source !== "string") return;
766
+ const from = classify(source);
767
+ if (from?.kind === "component" && REMOVED.has(from.dir)) {
768
+ touched = true;
769
+ addTodo(
770
+ j,
771
+ path,
772
+ `${source} is gone in 0.2; ${from.dir} is Astryx's ${REMOVED.get(from.dir).to} at ${UIC}/${REMOVED.get(from.dir).subpath}.`,
773
+ );
774
+ }
775
+ });
776
+
777
+ if (!touched) return undefined;
778
+
779
+ // Elements.
780
+ const spelled = new Set([...identifierNames(j, root), ...taken]);
781
+ for (const [local, binding] of bindings) {
782
+ const transformElement = /** @type {Record<string, any>} */ (ELEMENT_TRANSFORMS)[
783
+ binding.component
784
+ ];
785
+ root.findJSXElements(local).forEach((/** @type {any} */ path) => {
786
+ // A local component that shadows the import is not ui-common's.
787
+ if (!isModuleBinding(path, local, programScope)) return;
788
+ const el = path.node;
789
+ let tag = null;
790
+ /** @type {import('./elements.mjs').Helpers} */
791
+ const helpers = {
792
+ j,
793
+ path,
794
+ el,
795
+ isTS,
796
+ removed: binding.entry,
797
+ todo: (message) => addTodo(j, path, message),
798
+ ensureImport,
799
+ setTag: (name) => {
800
+ tag = name;
801
+ },
802
+ freeName: (candidates) => freeName(spelled, candidates),
803
+ };
804
+ if (hasSpread(el)) {
805
+ addTodo(
806
+ j,
807
+ path,
808
+ `props spread into <${tagName(el)}> are not migrated; check them against Astryx ${binding.entry.to}'s props.`,
809
+ );
810
+ }
811
+ transformElement?.(helpers);
812
+ if (tag) renameElement(el, tag);
813
+ else binding.jsxCount++;
814
+ });
815
+ root.find(j.Identifier, { name: local }).forEach((/** @type {any} */ path) => {
816
+ if (path.node.type === "JSXIdentifier" || !isReference(path)) return;
817
+ if (!isModuleBinding(path, local, programScope)) return;
818
+ const parent = path.parent?.node;
819
+ if (parent?.type === "TSTypeQuery" || parent?.type === "TSTypeReference") return;
820
+ binding.valueRefs++;
821
+ addTodo(
822
+ j,
823
+ path,
824
+ `${local} is used as a value here; props passed to it this way are not migrated to Astryx ${binding.entry.to}.`,
825
+ );
826
+ });
827
+ }
828
+
829
+ // A local-barrel import whose every element became another component
830
+ // (Button → IconButton) is unused now.
831
+ for (const [local, binding] of bindings) {
832
+ if (!binding.via || binding.via.pruned) continue;
833
+ let used = false;
834
+ root.findJSXElements(local).forEach((/** @type {any} */ p) => {
835
+ if (isModuleBinding(p, local, programScope)) used = true;
836
+ });
837
+ root.find(j.Identifier, { name: local }).forEach((/** @type {any} */ p) => {
838
+ if (p.node.type === "JSXIdentifier" || !isReference(p)) return;
839
+ if (isModuleBinding(p, local, programScope)) used = true;
840
+ });
841
+ if (used) continue;
842
+ const node = binding.via.node;
843
+ node.specifiers = (node.specifiers ?? []).filter(
844
+ (/** @type {any} */ s) => s.local?.name !== local,
845
+ );
846
+ if (node.specifiers.length === 0) {
847
+ pruneKeepingComments(j, root, binding.via);
848
+ binding.via.pruned = true;
849
+ }
850
+ }
851
+
852
+ for (const [from, to] of renames) renameReferences(j, root, from, to, programScope);
853
+
854
+ // Imports: the planned moves, minus a Card import every BaseCard outgrew.
855
+ const unused = new Set(
856
+ [...bindings.values()]
857
+ .filter(
858
+ (b) =>
859
+ b.add != null &&
860
+ b.component === "BaseCard" &&
861
+ b.jsxCount === 0 &&
862
+ b.valueRefs === 0 &&
863
+ !b.add.used,
864
+ )
865
+ .map((b) => b.add),
866
+ );
867
+ for (const plan of plans) {
868
+ const adds = plan.adds.filter((a) => !unused.has(a));
869
+ const decls = buildImports(j, adds);
870
+ const node = plan.path.node;
871
+ if (plan.keep.length > 0) {
872
+ node.specifiers = plan.keep;
873
+ if (decls.length > 0) insertBeforeKeepingComments(plan.path, decls);
874
+ } else if (decls.length > 0) {
875
+ reuseNode(plan.path, decls, "importKind");
876
+ } else {
877
+ const comments = node.comments;
878
+ plan.path.prune();
879
+ plan.pruned = true;
880
+ if (comments?.length) {
881
+ const program = root.find(j.Program).get().node;
882
+ const first = program.body[0];
883
+ if (first) first.comments = [...comments, ...(first.comments ?? [])];
884
+ else keepCommentsInEmptyModule(j, program, comments);
885
+ }
886
+ }
887
+ }
888
+ if (extra.length > 0) {
889
+ // Imports a rewrite needed that no 0.1 import mapped to (EmptyState's
890
+ // actions render a Button): after the last rewritten import.
891
+ const anchor = plans.find((plan) => !plan.pruned);
892
+ const decls = buildImports(j, extra);
893
+ if (anchor) anchor.path.insertAfter(...decls);
894
+ else
895
+ root
896
+ .find(j.Program)
897
+ .get()
898
+ .node.body.unshift(...decls);
899
+ }
900
+
901
+ const out = printSource(j, root, { quote: detectQuote(file.source) });
902
+ return file.source.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out;
903
+ }