@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,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
+ }