@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +28 -0
- package/bin/ui-common.mjs +12 -0
- package/cli/agents.mjs +335 -0
- package/cli/diff.mjs +125 -0
- package/cli/lab-peer.mjs +226 -0
- package/cli/main.mjs +97 -0
- package/cli/passthrough.mjs +153 -0
- package/cli/paths.mjs +290 -0
- package/cli/report.mjs +157 -0
- package/cli/resolve.mjs +257 -0
- package/cli/rewrite.mjs +133 -0
- package/cli/semver.mjs +278 -0
- package/cli/shadow.mjs +161 -0
- package/cli/sync-astryx.mjs +417 -0
- package/cli/upgrade.mjs +776 -0
- package/codemods/0.2/components.mjs +903 -0
- package/codemods/0.2/elements.mjs +745 -0
- package/codemods/0.2/index.mjs +68 -0
- package/codemods/0.2/legacy-classes.json +292 -0
- package/codemods/0.2/local-modules.mjs +510 -0
- package/codemods/0.2/map.mjs +178 -0
- package/codemods/0.2/package-json.mjs +333 -0
- package/codemods/0.2/scan.mjs +397 -0
- package/codemods/0.2/stylesheets.mjs +479 -0
- package/codemods/0.2/theme.mjs +325 -0
- package/codemods/lib/jsx.mjs +352 -0
- package/codemods/lib/todo.mjs +151 -0
- package/codemods/registry.mjs +95 -0
- package/codemods/upstream.mjs +155 -0
- package/migration/0.1-to-0.2.json +629 -0
- package/package.json +50 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.1 -> 0.2 theming. 0.1 themed the page by stylesheet and attribute
|
|
3
|
+
* (`styles/themes/orange-dark.css`, `html[data-theme="orange-dark"]`); 0.2
|
|
4
|
+
* themes it with `<Theme theme={lablupTheme} mode=…>`, which owns
|
|
5
|
+
* `html[data-theme]` and sets it to `light` or `dark` itself.
|
|
6
|
+
*
|
|
7
|
+
* - scanTheme reports what fights that: scripts that write `data-theme`,
|
|
8
|
+
* 0.1 theme names, and selectors on any other `data-theme` value.
|
|
9
|
+
* - ensureTheme wraps the app's root render in `<Theme>` when no module uses
|
|
10
|
+
* one and the root render is unambiguous (`createRoot(…).render(<App />)`);
|
|
11
|
+
* otherwise the report opens with the step.
|
|
12
|
+
*/
|
|
13
|
+
import { relative, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
import selectorParser from "postcss-selector-parser";
|
|
16
|
+
|
|
17
|
+
import { UIC } from "./map.mjs";
|
|
18
|
+
import { isTestFile } from "./scan.mjs";
|
|
19
|
+
import { findEntryScript } from "./stylesheets.mjs";
|
|
20
|
+
|
|
21
|
+
const MODES = new Set(["light", "dark"]);
|
|
22
|
+
export const THEME_IMPORT = { name: "Theme", source: UIC };
|
|
23
|
+
export const LABLUP_THEME_IMPORT = {
|
|
24
|
+
name: "lablupTheme",
|
|
25
|
+
source: `${UIC}/theme/lablup/built`,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SCRIPT_ADVICE =
|
|
29
|
+
'`<Theme>` sets `html[data-theme]` to "light" or "dark" itself: pass the mode as `<Theme theme={lablupTheme} mode="light" | "dark" | "system">` instead of writing the attribute.';
|
|
30
|
+
const SELECTOR_ADVICE =
|
|
31
|
+
'`<Theme>` only ever sets `html[data-theme]` to "light" or "dark": select on `html[data-theme="dark"]` (or "light"), and put brand colours in the theme.';
|
|
32
|
+
|
|
33
|
+
/** @param {string} text @param {number} index */
|
|
34
|
+
function lineAt(text, index) {
|
|
35
|
+
return text.slice(0, index).split("\n").length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} file relative path
|
|
40
|
+
* @param {string} source
|
|
41
|
+
* @returns {Array<{category: string, file: string, line: number, text: string, detail: string}>}
|
|
42
|
+
*/
|
|
43
|
+
export function scanTheme(file, source) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
const lines = source.split("\n");
|
|
46
|
+
/** @param {number} index @param {string} detail */
|
|
47
|
+
const add = (index, detail) => {
|
|
48
|
+
const line = lineAt(source, index);
|
|
49
|
+
if (findings.some((f) => f.line === line)) return;
|
|
50
|
+
findings.push({
|
|
51
|
+
category: "theme",
|
|
52
|
+
file,
|
|
53
|
+
line,
|
|
54
|
+
text: (lines[line - 1] ?? "").trim(),
|
|
55
|
+
detail,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (/\.(css|scss|sass|less)$/.test(file)) {
|
|
60
|
+
// Comments out, keeping offsets, so a line number still points at the rule.
|
|
61
|
+
const code = source
|
|
62
|
+
.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "))
|
|
63
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (c, head) =>
|
|
64
|
+
file.endsWith(".css") ? c : `${head}${" ".repeat(c.length - head.length)}`,
|
|
65
|
+
);
|
|
66
|
+
// One finding per value and file: a theme sheet repeats its selector on
|
|
67
|
+
// every rule.
|
|
68
|
+
/** @type {Map<string, {index: number, count: number, detail: string}>} */
|
|
69
|
+
const seen = new Map();
|
|
70
|
+
for (const m of code.matchAll(
|
|
71
|
+
/\[\s*data-theme\s*([~|^$*]?=)\s*(["']?)([^\]"']*)\2\s*\]/g,
|
|
72
|
+
)) {
|
|
73
|
+
if (m[1] === "=" && MODES.has(m[3])) continue;
|
|
74
|
+
let ok = false;
|
|
75
|
+
try {
|
|
76
|
+
selectorParser((s) => {
|
|
77
|
+
s.walkAttributes((a) => {
|
|
78
|
+
if (a.attribute === "data-theme") ok = true;
|
|
79
|
+
});
|
|
80
|
+
}).processSync(m[0]);
|
|
81
|
+
} catch {
|
|
82
|
+
ok = true;
|
|
83
|
+
}
|
|
84
|
+
if (!ok) continue;
|
|
85
|
+
const key = `${m[1]}${m[3]}`;
|
|
86
|
+
const known = seen.get(key);
|
|
87
|
+
if (known) known.count++;
|
|
88
|
+
else
|
|
89
|
+
seen.set(key, {
|
|
90
|
+
index: m.index ?? 0,
|
|
91
|
+
count: 1,
|
|
92
|
+
detail: `data-theme ${m[1]} "${m[3]}"`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const { index, count, detail } of seen.values()) {
|
|
96
|
+
add(
|
|
97
|
+
index,
|
|
98
|
+
`${detail}${count > 1 ? ` (${count} selectors in this file)` : ""}: ${SELECTOR_ADVICE}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const writes = [
|
|
105
|
+
/\bdataset\s*(?:\.\s*theme|\[\s*["'`]theme["'`]\s*\])\s*=(?!=)\s*([^;\n]*)/g,
|
|
106
|
+
/\bsetAttribute\(\s*["'`]data-theme["'`]\s*,\s*([^)\n]*)/g,
|
|
107
|
+
];
|
|
108
|
+
for (const pattern of writes) {
|
|
109
|
+
for (const m of source.matchAll(pattern)) {
|
|
110
|
+
const value = m[1].trim();
|
|
111
|
+
const literal = /^(["'`])([\w-]*)\1$/.exec(value);
|
|
112
|
+
if (literal && MODES.has(literal[2])) continue;
|
|
113
|
+
add(
|
|
114
|
+
m.index ?? 0,
|
|
115
|
+
`writes data-theme ${literal ? `"${literal[2]}"` : "from an expression"}. ${SCRIPT_ADVICE}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const m of source.matchAll(/(["'`])(orange-(?:light|dark))\1/g)) {
|
|
120
|
+
add(m.index ?? 0, `0.1 theme name "${m[2]}". ${SCRIPT_ADVICE}`);
|
|
121
|
+
}
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether a module imports `Theme` from ui-common (or Astryx).
|
|
127
|
+
*
|
|
128
|
+
* @param {string} source
|
|
129
|
+
*/
|
|
130
|
+
function usesTheme(source) {
|
|
131
|
+
return /import\s*\{[^}]*\bTheme\b[^}]*\}\s*from\s*["'](@lablup\/ui-common|@astryxdesign\/core)[^"']*["']/.test(
|
|
132
|
+
source,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The `root.render(<X />)` calls of a module: `createRoot(el).render(…)`,
|
|
138
|
+
* `ReactDOM.createRoot(el).render(…)`, or `.render(…)` on a variable
|
|
139
|
+
* initialised with one of those.
|
|
140
|
+
*
|
|
141
|
+
* @param {any} j
|
|
142
|
+
* @param {any} root
|
|
143
|
+
*/
|
|
144
|
+
function rootRenders(j, root) {
|
|
145
|
+
/** @param {any} node */
|
|
146
|
+
const isCreateRoot = (node) => {
|
|
147
|
+
if (node?.type !== "CallExpression") return false;
|
|
148
|
+
const callee = node.callee;
|
|
149
|
+
const name =
|
|
150
|
+
callee.type === "Identifier"
|
|
151
|
+
? callee.name
|
|
152
|
+
: callee.type === "MemberExpression" && callee.property.type === "Identifier"
|
|
153
|
+
? callee.property.name
|
|
154
|
+
: null;
|
|
155
|
+
return name === "createRoot";
|
|
156
|
+
};
|
|
157
|
+
const roots = new Set();
|
|
158
|
+
root.find(j.VariableDeclarator).forEach((/** @type {any} */ p) => {
|
|
159
|
+
if (p.node.id.type === "Identifier" && isCreateRoot(p.node.init))
|
|
160
|
+
roots.add(p.node.id.name);
|
|
161
|
+
});
|
|
162
|
+
const calls = [];
|
|
163
|
+
root.find(j.CallExpression).forEach((/** @type {any} */ p) => {
|
|
164
|
+
const callee = p.node.callee;
|
|
165
|
+
if (
|
|
166
|
+
callee.type !== "MemberExpression" ||
|
|
167
|
+
callee.property.type !== "Identifier" ||
|
|
168
|
+
callee.property.name !== "render"
|
|
169
|
+
)
|
|
170
|
+
return;
|
|
171
|
+
const object = callee.object;
|
|
172
|
+
if (
|
|
173
|
+
isCreateRoot(object) ||
|
|
174
|
+
(object.type === "Identifier" && roots.has(object.name))
|
|
175
|
+
)
|
|
176
|
+
calls.push(p);
|
|
177
|
+
});
|
|
178
|
+
return calls;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {any} el
|
|
183
|
+
*/
|
|
184
|
+
function isStrictMode(el) {
|
|
185
|
+
const name = el.openingElement.name;
|
|
186
|
+
return (
|
|
187
|
+
(name.type === "JSXIdentifier" && name.name === "StrictMode") ||
|
|
188
|
+
(name.type === "JSXMemberExpression" && name.property.name === "StrictMode")
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Wrap the app's root render in `<Theme theme={lablupTheme}>`, once, when
|
|
194
|
+
* no module of the project uses `<Theme>` and exactly one module renders a
|
|
195
|
+
* root with a JSX element. Anything less clear-cut goes to the report.
|
|
196
|
+
*
|
|
197
|
+
* @param {any} ctx
|
|
198
|
+
* @param {{jscodeshift: any}} api
|
|
199
|
+
*/
|
|
200
|
+
export function ensureTheme(ctx, api) {
|
|
201
|
+
const pkg = ctx.pkg ?? {};
|
|
202
|
+
if (pkg.peerDependencies?.[UIC] != null) return;
|
|
203
|
+
if (!["dependencies", "devDependencies"].some((f) => pkg[f]?.[UIC] != null)) return;
|
|
204
|
+
const rel = (/** @type {string} */ f) =>
|
|
205
|
+
relative(ctx.projectDir, f).split(sep).join("/");
|
|
206
|
+
// Tests render roots of their own; they are not the app's.
|
|
207
|
+
const scripts = ctx
|
|
208
|
+
.projectFiles()
|
|
209
|
+
.filter(
|
|
210
|
+
(/** @type {string} */ f) => /\.[cm]?[jt]sx?$/.test(f) && !isTestFile(rel(f)),
|
|
211
|
+
);
|
|
212
|
+
/** @type {Array<{file: string, text: string}>} */
|
|
213
|
+
const candidates = [];
|
|
214
|
+
for (const file of scripts) {
|
|
215
|
+
const text = ctx.current(file);
|
|
216
|
+
if (!text) continue;
|
|
217
|
+
if (usesTheme(text)) return;
|
|
218
|
+
if (/\bcreateRoot\b/.test(text) && /\.render\s*\(/.test(text))
|
|
219
|
+
candidates.push({ file, text });
|
|
220
|
+
}
|
|
221
|
+
const how = `\`<Theme theme={lablupTheme}>\` (\`import { Theme } from "${THEME_IMPORT.source}"\`, \`import { lablupTheme } from "${LABLUP_THEME_IMPORT.source}"\`)`;
|
|
222
|
+
const manual = (/** @type {string} */ why) =>
|
|
223
|
+
ctx.alert(
|
|
224
|
+
`**Wrap the app in ${how}.** ${why} Without it Astryx components get no theme; pass \`mode\` ("light" | "dark" | "system", the default) where the app switches colour schemes.`,
|
|
225
|
+
);
|
|
226
|
+
// Several roots (a second page, a verification harness): the one the
|
|
227
|
+
// entry index.html loads is the app's.
|
|
228
|
+
const entry = findEntryScript(ctx.projectDir, pkg)?.file;
|
|
229
|
+
/** @type {string[]} */
|
|
230
|
+
const others = [];
|
|
231
|
+
if (candidates.length > 1 && entry && candidates.some((c) => c.file === entry)) {
|
|
232
|
+
others.push(...candidates.filter((c) => c.file !== entry).map((c) => rel(c.file)));
|
|
233
|
+
candidates.splice(
|
|
234
|
+
0,
|
|
235
|
+
candidates.length,
|
|
236
|
+
...candidates.filter((c) => c.file === entry),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (candidates.length !== 1) {
|
|
240
|
+
manual(
|
|
241
|
+
candidates.length === 0
|
|
242
|
+
? "No module uses `<Theme>`, and the upgrade found no `createRoot(…).render(…)` to wrap."
|
|
243
|
+
: `No module uses \`<Theme>\`, and ${candidates.length} modules render a root (${candidates.map((c) => rel(c.file)).join(", ")}), so the upgrade did not pick one.`,
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const [{ file, text }] = candidates;
|
|
248
|
+
const j = api.jscodeshift.withParser(/\.[cm]?tsx?$/.test(file) ? "tsx" : "babel");
|
|
249
|
+
const root = j(text);
|
|
250
|
+
const renders = rootRenders(j, root);
|
|
251
|
+
const arg = renders.length === 1 ? renders[0].node.arguments[0] : null;
|
|
252
|
+
if (!arg || arg.type !== "JSXElement") {
|
|
253
|
+
manual(
|
|
254
|
+
`${rel(file)} renders a root, but not as a single \`render(<App />)\` the upgrade can wrap safely.`,
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const bound = new Set();
|
|
259
|
+
root.find(j.Identifier).forEach((/** @type {any} */ p) => bound.add(p.node.name));
|
|
260
|
+
root.find(j.JSXIdentifier).forEach((/** @type {any} */ p) => bound.add(p.node.name));
|
|
261
|
+
if (bound.has(THEME_IMPORT.name) || bound.has(LABLUP_THEME_IMPORT.name)) {
|
|
262
|
+
manual(
|
|
263
|
+
`${rel(file)} already uses the name Theme or lablupTheme for something else.`,
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Edit the text at the parsed positions, so the rest of the file keeps
|
|
269
|
+
// its formatting.
|
|
270
|
+
const q = text.includes("from '") && !text.includes('from "') ? "'" : '"';
|
|
271
|
+
/** @type {Array<[number, number, string]>} */
|
|
272
|
+
const edits = [];
|
|
273
|
+
/** @param {number} at */
|
|
274
|
+
const indentAt = (at) =>
|
|
275
|
+
/^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", at - 1) + 1))?.[0] ?? "";
|
|
276
|
+
/** @param {string} block @param {string} pad */
|
|
277
|
+
const indent = (block, pad) =>
|
|
278
|
+
block
|
|
279
|
+
.split("\n")
|
|
280
|
+
.map((l) => (l.trim() === "" ? l : `${pad}${l}`))
|
|
281
|
+
.join("\n");
|
|
282
|
+
if (isStrictMode(arg) && arg.closingElement) {
|
|
283
|
+
const open = arg.openingElement.end;
|
|
284
|
+
const close = arg.closingElement.start;
|
|
285
|
+
const inner = text.slice(open, close);
|
|
286
|
+
const first = inner.split("\n").find((l) => l.trim() !== "") ?? "";
|
|
287
|
+
const pad = /^[ \t]*/.exec(first)?.[0] ?? "";
|
|
288
|
+
const body = inner
|
|
289
|
+
.trim()
|
|
290
|
+
.split("\n")
|
|
291
|
+
.map((l, i) => (i === 0 ? l : l.replace(new RegExp(`^${pad}`), "")))
|
|
292
|
+
.join("\n");
|
|
293
|
+
edits.push([
|
|
294
|
+
open,
|
|
295
|
+
close,
|
|
296
|
+
`\n${pad}<Theme theme={lablupTheme}>\n${indent(body, `${pad} `)}\n${pad}</Theme>\n${indentAt(close)}`,
|
|
297
|
+
]);
|
|
298
|
+
} else {
|
|
299
|
+
const pad = indentAt(arg.start);
|
|
300
|
+
const body = text.slice(arg.start, arg.end);
|
|
301
|
+
edits.push([
|
|
302
|
+
arg.start,
|
|
303
|
+
arg.end,
|
|
304
|
+
`<Theme theme={lablupTheme}>\n${indent(body, `${pad} `)}\n${pad}</Theme>`,
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
const imports = root.find(j.ImportDeclaration).nodes();
|
|
308
|
+
const after = imports.length > 0 ? imports[imports.length - 1].end : 0;
|
|
309
|
+
const lines = [
|
|
310
|
+
`import { ${THEME_IMPORT.name} } from ${q}${THEME_IMPORT.source}${q};`,
|
|
311
|
+
`import { ${LABLUP_THEME_IMPORT.name} } from ${q}${LABLUP_THEME_IMPORT.source}${q};`,
|
|
312
|
+
];
|
|
313
|
+
edits.push([
|
|
314
|
+
after,
|
|
315
|
+
after,
|
|
316
|
+
after === 0 ? `${lines.join("\n")}\n` : `\n${lines.join("\n")}`,
|
|
317
|
+
]);
|
|
318
|
+
let out = text;
|
|
319
|
+
for (const [from, to, insert] of edits.sort((a, b) => b[0] - a[0]))
|
|
320
|
+
out = `${out.slice(0, from)}${insert}${out.slice(to)}`;
|
|
321
|
+
ctx.editFile(file, () => out, "theme");
|
|
322
|
+
ctx.notice(
|
|
323
|
+
`No module used \`<Theme>\`, so the upgrade wrapped the root render in ${rel(file)}${entry === file ? "" : " (not the entry index.html loads: check it is the app's root)"} in \`<Theme theme={lablupTheme}>\`.${others.length > 0 ? ` ${others.join(", ")} also render${others.length === 1 ? "s" : ""} a root; wrap ${others.length === 1 ? "it" : "them"} too if ${others.length === 1 ? "it renders" : "they render"} ui-common components.` : ""} Its mode defaults to "system"; pass \`mode="light" | "dark"\` where the app switches colour schemes (see "0.1 theme switches and theme selectors").`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small JSX helpers shared by the codemods. Everything works on babel-style
|
|
3
|
+
* AST nodes as jscodeshift's `tsx` and `babel` parsers produce them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const TODO_TAG = "TODO(ui-common-upgrade)";
|
|
7
|
+
|
|
8
|
+
/** @param {any} el JSXElement */
|
|
9
|
+
export function attributes(el) {
|
|
10
|
+
return el.openingElement.attributes ?? [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {any} el JSXElement
|
|
15
|
+
* @param {string} name
|
|
16
|
+
*/
|
|
17
|
+
export function getAttr(el, name) {
|
|
18
|
+
return (
|
|
19
|
+
attributes(el).find(
|
|
20
|
+
(/** @type {any} */ a) =>
|
|
21
|
+
a.type === "JSXAttribute" &&
|
|
22
|
+
a.name?.type === "JSXIdentifier" &&
|
|
23
|
+
a.name.name === name,
|
|
24
|
+
) ?? null
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {any} el */
|
|
29
|
+
export function hasSpread(el) {
|
|
30
|
+
return attributes(el).some((/** @type {any} */ a) => a.type === "JSXSpreadAttribute");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {any} el
|
|
35
|
+
* @param {any} attr
|
|
36
|
+
*/
|
|
37
|
+
export function removeAttr(el, attr) {
|
|
38
|
+
el.openingElement.attributes = attributes(el).filter(
|
|
39
|
+
(/** @type {any} */ a) => a !== attr,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The static value of an attribute, when it has one.
|
|
45
|
+
*
|
|
46
|
+
* @param {any} attr JSXAttribute
|
|
47
|
+
* @returns {{kind: 'string', value: string} | {kind: 'boolean', value: boolean} | {kind: 'number', value: number} | {kind: 'null'} | {kind: 'expression', expression: any}}
|
|
48
|
+
*/
|
|
49
|
+
export function attrValue(attr) {
|
|
50
|
+
const value = attr.value;
|
|
51
|
+
if (value == null) return { kind: "boolean", value: true };
|
|
52
|
+
if (
|
|
53
|
+
value.type === "StringLiteral" ||
|
|
54
|
+
(value.type === "Literal" && typeof value.value === "string")
|
|
55
|
+
) {
|
|
56
|
+
return { kind: "string", value: value.value };
|
|
57
|
+
}
|
|
58
|
+
if (value.type === "JSXExpressionContainer") {
|
|
59
|
+
return expressionValue(value.expression);
|
|
60
|
+
}
|
|
61
|
+
return { kind: "expression", expression: value };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {any} expression
|
|
66
|
+
* @returns {ReturnType<typeof attrValue>}
|
|
67
|
+
*/
|
|
68
|
+
export function expressionValue(expression) {
|
|
69
|
+
switch (expression?.type) {
|
|
70
|
+
case "StringLiteral":
|
|
71
|
+
return { kind: "string", value: expression.value };
|
|
72
|
+
case "BooleanLiteral":
|
|
73
|
+
return { kind: "boolean", value: expression.value };
|
|
74
|
+
case "NumericLiteral":
|
|
75
|
+
return { kind: "number", value: expression.value };
|
|
76
|
+
case "NullLiteral":
|
|
77
|
+
return { kind: "null" };
|
|
78
|
+
case "Literal":
|
|
79
|
+
if (expression.value === null) return { kind: "null" };
|
|
80
|
+
if (typeof expression.value === "string")
|
|
81
|
+
return { kind: "string", value: expression.value };
|
|
82
|
+
if (typeof expression.value === "boolean")
|
|
83
|
+
return { kind: "boolean", value: expression.value };
|
|
84
|
+
if (typeof expression.value === "number")
|
|
85
|
+
return { kind: "number", value: expression.value };
|
|
86
|
+
return { kind: "expression", expression };
|
|
87
|
+
case "TemplateLiteral":
|
|
88
|
+
if (expression.expressions.length === 0) {
|
|
89
|
+
return { kind: "string", value: expression.quasis[0]?.value.cooked ?? "" };
|
|
90
|
+
}
|
|
91
|
+
return { kind: "expression", expression };
|
|
92
|
+
default:
|
|
93
|
+
return { kind: "expression", expression };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The expression an attribute carries, for moving it somewhere else.
|
|
99
|
+
*
|
|
100
|
+
* @param {any} j
|
|
101
|
+
* @param {any} attr
|
|
102
|
+
*/
|
|
103
|
+
export function attrExpression(j, attr) {
|
|
104
|
+
const value = attr.value;
|
|
105
|
+
if (value == null) return j.booleanLiteral(true);
|
|
106
|
+
if (value.type === "JSXExpressionContainer") return value.expression;
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build an attribute. Strings become `name="value"`, `true` becomes the bare
|
|
112
|
+
* `name`, anything else `name={expression}`.
|
|
113
|
+
*
|
|
114
|
+
* @param {any} j
|
|
115
|
+
* @param {string} name
|
|
116
|
+
* @param {string|number|boolean|any} value
|
|
117
|
+
*/
|
|
118
|
+
export function makeAttr(j, name, value) {
|
|
119
|
+
if (value === true) return j.jsxAttribute(j.jsxIdentifier(name), null);
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return j.jsxAttribute(j.jsxIdentifier(name), j.stringLiteral(value));
|
|
122
|
+
if (typeof value === "number") {
|
|
123
|
+
return j.jsxAttribute(
|
|
124
|
+
j.jsxIdentifier(name),
|
|
125
|
+
j.jsxExpressionContainer(j.numericLiteral(value)),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (value?.type === "StringLiteral")
|
|
129
|
+
return j.jsxAttribute(j.jsxIdentifier(name), value);
|
|
130
|
+
return j.jsxAttribute(j.jsxIdentifier(name), j.jsxExpressionContainer(value));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Replace `attr` in place with a new attribute, keeping its position.
|
|
135
|
+
*
|
|
136
|
+
* @param {any} el
|
|
137
|
+
* @param {any} attr
|
|
138
|
+
* @param {any} replacement
|
|
139
|
+
*/
|
|
140
|
+
export function replaceAttr(el, attr, replacement) {
|
|
141
|
+
el.openingElement.attributes = attributes(el).map((/** @type {any} */ a) =>
|
|
142
|
+
a === attr ? replacement : a,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Rename an attribute, keeping its value.
|
|
148
|
+
*
|
|
149
|
+
* @param {any} attr
|
|
150
|
+
* @param {string} name
|
|
151
|
+
*/
|
|
152
|
+
export function renameAttr(attr, name) {
|
|
153
|
+
attr.name = { type: "JSXIdentifier", name };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Set (or add) an attribute.
|
|
158
|
+
*
|
|
159
|
+
* @param {any} j
|
|
160
|
+
* @param {any} el
|
|
161
|
+
* @param {string} name
|
|
162
|
+
* @param {any} value
|
|
163
|
+
*/
|
|
164
|
+
export function setAttr(j, el, name, value) {
|
|
165
|
+
const existing = getAttr(el, name);
|
|
166
|
+
const next = makeAttr(j, name, value);
|
|
167
|
+
if (existing) replaceAttr(el, existing, next);
|
|
168
|
+
else el.openingElement.attributes = [...attributes(el), next];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Children that are not layout whitespace. @param {any} el */
|
|
172
|
+
export function meaningfulChildren(el) {
|
|
173
|
+
return (el.children ?? []).filter(
|
|
174
|
+
(/** @type {any} */ c) =>
|
|
175
|
+
!(c.type === "JSXText" && c.value.trim() === "") &&
|
|
176
|
+
!(
|
|
177
|
+
c.type === "JSXExpressionContainer" &&
|
|
178
|
+
c.expression.type === "JSXEmptyExpression"
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Turn `<X ...>children</X>` into `<X ... />`. @param {any} el */
|
|
184
|
+
export function makeSelfClosing(el) {
|
|
185
|
+
el.openingElement.selfClosing = true;
|
|
186
|
+
el.closingElement = null;
|
|
187
|
+
el.children = [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `children` for a JSX element or fragment the codemod builds. Recast prints
|
|
192
|
+
* a new node's text children with their leading whitespace stripped, which
|
|
193
|
+
* changes the text when that whitespace is on the text's first line
|
|
194
|
+
* (`{n} items` -> `{n}items`); it is moved into a `{" "}` child of its own.
|
|
195
|
+
* Leading whitespace that holds a line break renders as nothing, so it may go.
|
|
196
|
+
*
|
|
197
|
+
* @param {any} j jscodeshift
|
|
198
|
+
* @param {any[]} children
|
|
199
|
+
*/
|
|
200
|
+
export function keepJsxTextWhitespace(j, children) {
|
|
201
|
+
return children.flatMap((/** @type {any} */ child) => {
|
|
202
|
+
if (child.type !== "JSXText" || !/\S/.test(child.value)) return [child];
|
|
203
|
+
const lead = /^\s*/.exec(child.value)?.[0] ?? "";
|
|
204
|
+
if (lead === "" || lead.includes("\n")) return [child];
|
|
205
|
+
return [
|
|
206
|
+
j.jsxExpressionContainer(j.stringLiteral(lead)),
|
|
207
|
+
j.jsxText(child.value.slice(lead.length)),
|
|
208
|
+
];
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whitespace-collapsed JSX text, as React renders it. @param {string} text */
|
|
213
|
+
export function jsxTextValue(text) {
|
|
214
|
+
return text
|
|
215
|
+
.split("\n")
|
|
216
|
+
.map((line, i, all) => {
|
|
217
|
+
let l = line;
|
|
218
|
+
if (i > 0) l = l.replace(/^\s+/, "");
|
|
219
|
+
if (i < all.length - 1) l = l.replace(/\s+$/, "");
|
|
220
|
+
return l;
|
|
221
|
+
})
|
|
222
|
+
.filter((l) => l !== "")
|
|
223
|
+
.join(" ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether an expression certainly evaluates to a string: a literal, a
|
|
228
|
+
* template, or a call to a translation function.
|
|
229
|
+
*
|
|
230
|
+
* @param {any} expression
|
|
231
|
+
*/
|
|
232
|
+
export function isStringish(expression) {
|
|
233
|
+
if (!expression) return false;
|
|
234
|
+
if (expression.type === "StringLiteral" || expression.type === "TemplateLiteral")
|
|
235
|
+
return true;
|
|
236
|
+
if (expression.type === "Literal" && typeof expression.value === "string")
|
|
237
|
+
return true;
|
|
238
|
+
if (expression.type === "CallExpression") {
|
|
239
|
+
const callee = expression.callee;
|
|
240
|
+
const name =
|
|
241
|
+
callee.type === "Identifier"
|
|
242
|
+
? callee.name
|
|
243
|
+
: callee.type === "MemberExpression" && callee.property.type === "Identifier"
|
|
244
|
+
? callee.property.name
|
|
245
|
+
: null;
|
|
246
|
+
return name === "t" || name === "formatMessage" || name === "translate";
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Side-effect-free, cheap to evaluate twice. @param {any} expression */
|
|
252
|
+
export function isSimpleReference(expression) {
|
|
253
|
+
if (!expression) return false;
|
|
254
|
+
if (expression.type === "Identifier") return true;
|
|
255
|
+
if (
|
|
256
|
+
expression.type === "MemberExpression" ||
|
|
257
|
+
expression.type === "OptionalMemberExpression"
|
|
258
|
+
) {
|
|
259
|
+
return !expression.computed && isSimpleReference(expression.object);
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const POSITION_KEYS = new Set([
|
|
265
|
+
"loc",
|
|
266
|
+
"start",
|
|
267
|
+
"end",
|
|
268
|
+
"range",
|
|
269
|
+
"original",
|
|
270
|
+
"tokens",
|
|
271
|
+
"comments",
|
|
272
|
+
"extra",
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A fresh copy of an expression, for using it in a second place. Recast
|
|
277
|
+
* reprints a reused node from its original source position, so a node must
|
|
278
|
+
* never appear twice in the tree.
|
|
279
|
+
*
|
|
280
|
+
* @template T
|
|
281
|
+
* @param {T} node
|
|
282
|
+
* @returns {T}
|
|
283
|
+
*/
|
|
284
|
+
export function cloneNode(node) {
|
|
285
|
+
if (Array.isArray(node)) return /** @type {any} */ (node.map(cloneNode));
|
|
286
|
+
if (!node || typeof node !== "object") return node;
|
|
287
|
+
/** @type {any} */
|
|
288
|
+
const copy = {};
|
|
289
|
+
for (const [key, value] of Object.entries(node)) {
|
|
290
|
+
if (POSITION_KEYS.has(key)) continue;
|
|
291
|
+
copy[key] = cloneNode(value);
|
|
292
|
+
}
|
|
293
|
+
return copy;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The element's tag as written, for messages. @param {any} el */
|
|
297
|
+
export function tagName(el) {
|
|
298
|
+
const name = el.openingElement.name;
|
|
299
|
+
if (name.type === "JSXIdentifier") return name.name;
|
|
300
|
+
if (name.type === "JSXMemberExpression") {
|
|
301
|
+
return `${tagName({ openingElement: { name: name.object } })}.${name.property.name}`;
|
|
302
|
+
}
|
|
303
|
+
return "?";
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Set the element's tag name (opening and closing).
|
|
308
|
+
*
|
|
309
|
+
* @param {any} el
|
|
310
|
+
* @param {string} name
|
|
311
|
+
*/
|
|
312
|
+
export function renameElement(el, name) {
|
|
313
|
+
el.openingElement.name = { type: "JSXIdentifier", name };
|
|
314
|
+
if (el.closingElement) el.closingElement.name = { type: "JSXIdentifier", name };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Every identifier the file spells: bindings, references, property keys and
|
|
319
|
+
* JSX tag names (attribute names bind nothing, so they are left out). A name
|
|
320
|
+
* outside this set can be introduced anywhere in the file without shadowing
|
|
321
|
+
* or capturing anything.
|
|
322
|
+
*
|
|
323
|
+
* @param {any} j
|
|
324
|
+
* @param {any} root
|
|
325
|
+
* @returns {Set<string>}
|
|
326
|
+
*/
|
|
327
|
+
export function identifierNames(j, root) {
|
|
328
|
+
const names = new Set();
|
|
329
|
+
root.find(j.Identifier).forEach((/** @type {any} */ p) => {
|
|
330
|
+
if (p.node.type === "JSXIdentifier" && p.parent?.node.type === "JSXAttribute")
|
|
331
|
+
return;
|
|
332
|
+
names.add(p.node.name);
|
|
333
|
+
});
|
|
334
|
+
return names;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The first of `candidates` that `taken` does not hold, else the last one
|
|
339
|
+
* numbered (`nextOpen2`, …). The name is added to `taken`.
|
|
340
|
+
*
|
|
341
|
+
* @param {Set<string>} taken
|
|
342
|
+
* @param {string[]} candidates
|
|
343
|
+
*/
|
|
344
|
+
export function freeName(taken, candidates) {
|
|
345
|
+
let name = candidates.find((c) => !taken.has(c));
|
|
346
|
+
const base = candidates[candidates.length - 1];
|
|
347
|
+
for (let i = 2; name === undefined; i++) {
|
|
348
|
+
if (!taken.has(`${base}${i}`)) name = `${base}${i}`;
|
|
349
|
+
}
|
|
350
|
+
taken.add(name);
|
|
351
|
+
return name;
|
|
352
|
+
}
|