@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,151 @@
1
+ /**
2
+ * Leave `TODO(ui-common-upgrade)` markers where a codemod could not prove a
3
+ * rewrite safe. Where the marker goes:
4
+ * - a JSX child gets a `{/* … *\/}` line right above it, or right before it
5
+ * when it shares its line with other children;
6
+ * - an element that is a `return` value or a parenthesised expression gets a
7
+ * `//` line right above it, inside the parentheses;
8
+ * - anything else gets a `//` line above its statement.
9
+ * A marker already there (from an earlier run) is not added twice.
10
+ * `ui-common upgrade` lists every marker in its report, with its line.
11
+ *
12
+ * A file whose transform calls `addTodo` is printed with `printSource`: the
13
+ * JSX-child markers are written into the printed text, not into the parent's
14
+ * `children`. Adding a child makes recast reprint the parent, and recast's
15
+ * reprint drops the leading whitespace of every text child
16
+ * (`{n} items` -> `{n}items`).
17
+ */
18
+ import { TODO_TAG } from "./jsx.mjs";
19
+
20
+ /** The markers waiting on a JSX child, written by `printSource`. */
21
+ const PENDING = Symbol("ui-common-upgrade.todo");
22
+ const SENTINEL = "__ui_common_upgrade_todo_";
23
+
24
+ /** @param {any} node */
25
+ function isStatement(node) {
26
+ return (
27
+ node &&
28
+ typeof node.type === "string" &&
29
+ /(Statement|Declaration)$/.test(node.type) &&
30
+ !node.type.startsWith("TS")
31
+ );
32
+ }
33
+
34
+ /** @param {any[] | undefined} comments @param {string} text */
35
+ function hasComment(comments, text) {
36
+ return (comments ?? []).some((c) => String(c.value).trim() === text);
37
+ }
38
+
39
+ /** @param {any} node @param {string} text */
40
+ function carries(node, text) {
41
+ return (
42
+ hasComment(node?.comments, text) ||
43
+ hasComment(node?.innerComments, text) ||
44
+ hasComment(node?.leadingComments, text)
45
+ );
46
+ }
47
+
48
+ /**
49
+ * @param {any} j jscodeshift
50
+ * @param {any} path NodePath of the node the message is about
51
+ * @param {string} message
52
+ */
53
+ export function addTodo(j, path, message) {
54
+ const text = `${TODO_TAG}: ${message}`;
55
+ const node = path.node;
56
+ const parent = path.parent?.node;
57
+ const isElement = node.type === "JSXElement" || node.type === "JSXFragment";
58
+
59
+ if (
60
+ isElement &&
61
+ parent &&
62
+ (parent.type === "JSXElement" || parent.type === "JSXFragment") &&
63
+ Array.isArray(parent.children)
64
+ ) {
65
+ const index = parent.children.indexOf(node);
66
+ if (index === -1) return;
67
+ // Walk back over whitespace and earlier markers looking for this one.
68
+ for (let i = index - 1; i >= 0; i--) {
69
+ const sibling = parent.children[i];
70
+ if (sibling.type === "JSXText" && sibling.value.trim() === "") continue;
71
+ if (
72
+ sibling.type === "JSXExpressionContainer" &&
73
+ sibling.expression.type === "JSXEmptyExpression"
74
+ ) {
75
+ if (carries(sibling.expression, text)) return;
76
+ continue;
77
+ }
78
+ break;
79
+ }
80
+ const pending = (node[PENDING] ??= []);
81
+ if (!pending.includes(text)) pending.push(text);
82
+ return;
83
+ }
84
+
85
+ if (isElement && (parent?.type === "ReturnStatement" || node.extra?.parenthesized)) {
86
+ if (carries(node, text)) return;
87
+ node.comments = [...(node.comments ?? []), j.commentLine(` ${text}`, true, false)];
88
+ return;
89
+ }
90
+
91
+ let p = path;
92
+ while (p && !isStatement(p.node)) p = p.parent;
93
+ if (!p) return;
94
+ // An exported declaration carries the comment on the export, so it prints
95
+ // above `export`, not between `export` and the declaration.
96
+ if (p.parent && /^Export(Named|Default)Declaration$/.test(p.parent.node.type))
97
+ p = p.parent;
98
+ if (carries(p.node, text)) return;
99
+ p.node.comments = [
100
+ ...(p.node.comments ?? []),
101
+ j.commentLine(` ${text}`, true, false),
102
+ ];
103
+ }
104
+
105
+ /**
106
+ * `root.toSource(options)`, with the JSX-child markers `addTodo` left
107
+ * pending written in. Each marked element carries a sentinel block comment
108
+ * while it prints: a comment changes the element alone, so recast reprints
109
+ * neither the parent nor its text. The sentinel then becomes the marker:
110
+ * a `{/* … *\/}` line above an element that starts its line, or a
111
+ * `{/* … *\/}` right before one that follows other children on its line,
112
+ * with no whitespace added (the text renders as it did).
113
+ *
114
+ * @param {any} j jscodeshift
115
+ * @param {any} root the file's Collection
116
+ * @param {object} [options] recast print options
117
+ * @returns {string}
118
+ */
119
+ export function printSource(j, root, options) {
120
+ /** @type {Map<string, string[]>} */
121
+ const markers = new Map();
122
+ for (const type of [j.JSXElement, j.JSXFragment]) {
123
+ root.find(type).forEach((/** @type {any} */ path) => {
124
+ const texts = path.node[PENDING];
125
+ if (!texts?.length) return;
126
+ const id = `${SENTINEL}${markers.size}__`;
127
+ markers.set(id, texts);
128
+ path.node.comments = [
129
+ ...(path.node.comments ?? []),
130
+ j.commentBlock(id, true, false),
131
+ ];
132
+ });
133
+ }
134
+ let out = root.toSource(options);
135
+ for (const [id, texts] of markers) {
136
+ const comment = `/*${id}*/`;
137
+ const at = out.indexOf(comment);
138
+ if (at === -1) throw new Error(`ui-common upgrade: lost the TODO marker ${id}`);
139
+ // Recast breaks the line after a leading comment; the element follows.
140
+ const after = at + comment.length;
141
+ const gap = /^\s*/.exec(out.slice(after))?.[0] ?? "";
142
+ const lineStart = out.lastIndexOf("\n", at - 1) + 1;
143
+ const indent = out.slice(lineStart, at);
144
+ const blocks = texts.map((text) => `{/* ${text} */}`);
145
+ const insert = /^[ \t]*$/.test(indent)
146
+ ? blocks.map((b) => `${b}\n${indent}`).join("")
147
+ : blocks.join("");
148
+ out = out.slice(0, at) + insert + out.slice(after + gap.length);
149
+ }
150
+ return out;
151
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The ui-common upgrade registry: codemod steps keyed by the ui-common version
3
+ * that introduced the change. `ui-common upgrade --from A --to B` runs every
4
+ * step with A < version <= B, in version order (semver precedence, so
5
+ * prerelease keys work: 0.1.0-alpha.19 < 0.2.0-alpha.0 < 0.2.0).
6
+ *
7
+ * Two kinds of step:
8
+ * - hand-written ones, listed in STATIC_STEPS (0.1 -> 0.2);
9
+ * - `codemods/<version>/upstream.json`, written by `ui-common sync-astryx`
10
+ * when ui-common moves to a new Astryx: the Astryx codemods a consumer needs
11
+ * for that bump, run through ./upstream.mjs.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { compare, parse } from "../cli/semver.mjs";
18
+
19
+ /**
20
+ * @typedef {object} Transform
21
+ * @property {string} id
22
+ * @property {string} title
23
+ * @property {string[]} extensions
24
+ * @property {boolean} [parse] re-parse the output as a script before accepting it
25
+ * @property {(file: {path: string, source: string}, api: {jscodeshift: any}, ctx: any) => string | null | undefined} run
26
+ *
27
+ * @typedef {object} Step
28
+ * @property {string} title
29
+ * @property {Transform[]} transforms
30
+ * @property {(ctx: any, api: {jscodeshift: any}) => void} [afterTransforms] project-level edits once every file is transformed
31
+ * @property {(text: string, ctx: any) => string | undefined} [packageJson]
32
+ * @property {(files: Array<[string, string]>) => any} [prepareScan] a context for `scan`, from every file it will read ([relative path, content])
33
+ * @property {(file: string, source: string, context?: any) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [scan]
34
+ * @property {(ctx: any, rel: (file: string) => string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [findings] report findings the transforms collected, after they ran
35
+ * @property {Record<string, {title: string, help: string}>} [categories]
36
+ * @property {string[] | ((ctx: any) => string[])} [notes] report notes, or a function of the run
37
+ */
38
+
39
+ export const CODEMODS_DIR = dirname(fileURLToPath(import.meta.url));
40
+
41
+ /** @type {Array<{version: string, load: () => Promise<Step>}>} */
42
+ const STATIC_STEPS = [
43
+ {
44
+ version: "0.2.0-alpha.0",
45
+ load: async () => (await import("./0.2/index.mjs")).default,
46
+ },
47
+ ];
48
+
49
+ /** Upstream steps recorded by `sync-astryx`: `codemods/<version>/upstream.json`. */
50
+ export function upstreamManifests() {
51
+ if (!existsSync(CODEMODS_DIR)) return [];
52
+ return readdirSync(CODEMODS_DIR, { withFileTypes: true })
53
+ .filter((e) => e.isDirectory() && parse(e.name) && /^\d+\.\d+\.\d+/.test(e.name))
54
+ .map((e) => ({
55
+ version: e.name,
56
+ file: join(CODEMODS_DIR, e.name, "upstream.json"),
57
+ }))
58
+ .filter((m) => existsSync(m.file));
59
+ }
60
+
61
+ /** Every registered version, ascending. */
62
+ export function registeredVersions() {
63
+ const versions = new Set([
64
+ ...STATIC_STEPS.map((s) => s.version),
65
+ ...upstreamManifests().map((m) => m.version),
66
+ ]);
67
+ return [...versions].sort(compare);
68
+ }
69
+
70
+ /**
71
+ * The steps to run for `from` (exclusive) to `to` (inclusive).
72
+ *
73
+ * @param {string} from
74
+ * @param {string} to
75
+ * @returns {Promise<Array<{version: string, step: Step}>>}
76
+ */
77
+ export async function stepsBetween(from, to) {
78
+ /** @type {Array<{version: string, step: Step}>} */
79
+ const out = [];
80
+ for (const version of registeredVersions()) {
81
+ if (compare(version, from) <= 0 || compare(version, to) > 0) continue;
82
+ for (const s of STATIC_STEPS.filter((x) => x.version === version)) {
83
+ out.push({ version, step: await s.load() });
84
+ }
85
+ const upstream = upstreamManifests().find((m) => m.version === version);
86
+ if (upstream) {
87
+ const { upstreamStep } = await import("./upstream.mjs");
88
+ out.push({
89
+ version,
90
+ step: await upstreamStep(JSON.parse(readFileSync(upstream.file, "utf8"))),
91
+ });
92
+ }
93
+ }
94
+ return out;
95
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Run Astryx's own codemods on a ui-common consumer.
3
+ *
4
+ * Astryx codemods match `@astryxdesign/*` import specifiers; a ui-common
5
+ * consumer imports the same modules as `@lablup/ui-common/*`. So each
6
+ * transform sees the file with the module specifiers swapped to Astryx's, and
7
+ * its output's module specifiers are swapped back. Only specifiers are
8
+ * swapped (imports, re-exports, `import()`, `require()`): a comment or string
9
+ * that names either package is left as written. A file the transform does not
10
+ * change is left alone.
11
+ *
12
+ * `codemods/<ui-common version>/upstream.json` (written by
13
+ * `ui-common sync-astryx`) names the Astryx version range and codemod ids.
14
+ */
15
+ import { importAstryxInternal } from "../cli/paths.mjs";
16
+ import { rewriteSpecifiers } from "../cli/rewrite.mjs";
17
+
18
+ const DEFAULT_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"];
19
+
20
+ /**
21
+ * `@lablup/ui-common/…` -> the `@astryxdesign/…` specifier Astryx's codemods
22
+ * look for. The inverse is rewriteSpecifiers.
23
+ *
24
+ * @param {string} text
25
+ */
26
+ export function toAstryxSpecifiers(text) {
27
+ return text
28
+ .replace(/@lablup\/ui-common\/lab(?=[/"'`\s),;:]|$)/g, "@astryxdesign/lab")
29
+ .replace(
30
+ /@lablup\/ui-common\/theme\/neutral(?=[/"'`\s),;:]|$)/g,
31
+ "@astryxdesign/theme-neutral",
32
+ )
33
+ .replace(/@lablup\/ui-common(?=[/"'`\s),;:]|$)/g, "@astryxdesign/core");
34
+ }
35
+
36
+ /**
37
+ * Apply `swap` to the module specifier strings of a script and nothing else.
38
+ * A source jscodeshift cannot parse (a stylesheet an Astryx codemod targets)
39
+ * is swapped as a whole.
40
+ *
41
+ * @param {any} j
42
+ * @param {string} source
43
+ * @param {(text: string) => string} swap
44
+ */
45
+ export function swapModuleSpecifiers(j, source, swap) {
46
+ let root;
47
+ try {
48
+ root = j(source);
49
+ } catch {
50
+ return swap(source);
51
+ }
52
+ /** @type {Array<{start: number, end: number}>} */
53
+ const ranges = [];
54
+ const add = (/** @type {any} */ node) => {
55
+ const isString =
56
+ node?.type === "StringLiteral" ||
57
+ (node?.type === "Literal" && typeof node.value === "string");
58
+ if (isString && typeof node.start === "number" && typeof node.end === "number")
59
+ ranges.push({ start: node.start, end: node.end });
60
+ };
61
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ p) => add(p.node.source));
62
+ root
63
+ .find(j.ExportNamedDeclaration)
64
+ .forEach((/** @type {any} */ p) => add(p.node.source));
65
+ root
66
+ .find(j.ExportAllDeclaration)
67
+ .forEach((/** @type {any} */ p) => add(p.node.source));
68
+ root.find(j.CallExpression).forEach((/** @type {any} */ p) => {
69
+ const callee = p.node.callee;
70
+ const isImport = callee.type === "Import";
71
+ const isRequire = callee.type === "Identifier" && callee.name === "require";
72
+ if (isImport || isRequire) add(p.node.arguments[0]);
73
+ });
74
+ if (j.ImportExpression)
75
+ root.find(j.ImportExpression).forEach((/** @type {any} */ p) => add(p.node.source));
76
+ if (j.TSImportType)
77
+ root.find(j.TSImportType).forEach((/** @type {any} */ p) => {
78
+ add(p.node.argument?.literal ?? p.node.argument);
79
+ });
80
+ let out = source;
81
+ for (const { start, end } of ranges.sort((a, b) => b.start - a.start)) {
82
+ out = `${out.slice(0, start)}${swap(out.slice(start, end))}${out.slice(end)}`;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * @typedef {{astryx: {from: string, to: string}, codemods: Array<{id: string, version: string, title?: string}>}} UpstreamManifest
89
+ */
90
+
91
+ /**
92
+ * @param {{name: string, transform: Function, meta: {title: string, fileExtensions?: string[], codemodType?: string}}} entry
93
+ * @param {string} version Astryx version the codemod belongs to
94
+ * @returns {import('./registry.mjs').Transform}
95
+ */
96
+ export function wrapAstryxTransform(entry, version) {
97
+ const extensions = entry.meta.fileExtensions ?? DEFAULT_EXTENSIONS;
98
+ return {
99
+ id: `astryx:${entry.name}`,
100
+ title: `Astryx ${version}: ${entry.meta.title}`,
101
+ extensions,
102
+ parse: extensions.some((e) => DEFAULT_EXTENSIONS.includes(e)),
103
+ run(file, api) {
104
+ const j = api.jscodeshift;
105
+ const swapped = swapModuleSpecifiers(j, file.source, toAstryxSpecifiers);
106
+ const out = entry.transform(
107
+ { path: file.path, source: swapped },
108
+ { jscodeshift: j, stats: () => {}, report: () => {} },
109
+ );
110
+ if (out == null || out === swapped) return undefined;
111
+ return swapModuleSpecifiers(j, out, rewriteSpecifiers);
112
+ },
113
+ };
114
+ }
115
+
116
+ /**
117
+ * @param {UpstreamManifest} manifest
118
+ * @returns {Promise<import('./registry.mjs').Step>}
119
+ */
120
+ export async function upstreamStep(manifest) {
121
+ const { getTransformsBetween } = await importAstryxInternal(
122
+ "assets/codemods/registry.mjs",
123
+ );
124
+ const groups = await getTransformsBetween(manifest.astryx.from, manifest.astryx.to);
125
+ /** @type {Map<string, {entry: any, version: string}>} */
126
+ const byName = new Map();
127
+ for (const group of groups) {
128
+ for (const entry of group.transforms)
129
+ byName.set(entry.name, { entry, version: group.version });
130
+ }
131
+ const notes = [];
132
+ /** @type {import('./registry.mjs').Transform[]} */
133
+ const transforms = [];
134
+ for (const codemod of manifest.codemods) {
135
+ const found = byName.get(codemod.id);
136
+ if (!found) {
137
+ notes.push(
138
+ `Astryx codemod "${codemod.id}" (${codemod.version}) is not in the installed @astryxdesign/cli; run it by hand if you use what it migrates.`,
139
+ );
140
+ continue;
141
+ }
142
+ if (found.entry.meta?.codemodType === "config") {
143
+ notes.push(
144
+ `Astryx codemod "${codemod.id}" edits astryx.config.*; run \`ui-common astryx upgrade\` if you keep one.`,
145
+ );
146
+ continue;
147
+ }
148
+ transforms.push(wrapAstryxTransform(found.entry, found.version));
149
+ }
150
+ return {
151
+ title: `Astryx ${manifest.astryx.from} → ${manifest.astryx.to} codemods`,
152
+ transforms,
153
+ notes,
154
+ };
155
+ }