@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,479 @@
1
+ /**
2
+ * 0.1 -> 0.2 stylesheet entry points.
3
+ *
4
+ * - `@lablup/ui-common/styles/base.css` becomes the layer-order statement plus
5
+ * Astryx's reset and component sheets, the Lablup theme, ui-common's global
6
+ * sheet and the deprecated `legacy-tokens.css` bridge.
7
+ * - In a stylesheet the `@import` is replaced in place, and the `@layer`
8
+ * statement goes first in the file (in SCSS, after the leading
9
+ * `@use`/`@forward` rules, which Sass requires above every other rule).
10
+ * - In a script (`import "@lablup/ui-common/styles/base.css"`) a CSS
11
+ * `@layer` statement cannot be expressed, so the import is pointed at a
12
+ * new `ui-common-entry.css` beside the script, which holds all of it.
13
+ * - `styles/themes/orange-{light,dark}.css` imports are dropped: the Lablup
14
+ * theme covers both colour schemes.
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
18
+
19
+ import postcss from "postcss";
20
+
21
+ import { TODO_TAG } from "../lib/jsx.mjs";
22
+ import { addTodo, printSource } from "../lib/todo.mjs";
23
+ import { LAB_CSS, LAB_PACKAGE, STYLESHEETS, UIC } from "./map.mjs";
24
+
25
+ const { base, layerOrder, imports: replacement, entryFile } = STYLESHEETS;
26
+ const DROPPED = STYLESHEETS.dropped;
27
+
28
+ /**
29
+ * The stylesheets that replace base.css. lab.css joins them when a Drawer was
30
+ * moved to the lab package.
31
+ *
32
+ * @param {{flags?: {packages: Map<string, string>}}} ctx
33
+ * @returns {string[]}
34
+ */
35
+ function replacementFor(ctx) {
36
+ return ctx.flags?.packages.has(LAB_PACKAGE) ? [...replacement, LAB_CSS] : replacement;
37
+ }
38
+
39
+ /** @param {{flags?: {packages: Map<string, string>}}} ctx */
40
+ export const entryCss = (ctx) => `/*
41
+ * The @lablup/ui-common stylesheet entry, written by \`ui-common upgrade\` in
42
+ * place of the 0.1 styles/base.css import. Keep it first among your
43
+ * stylesheets: the @layer statement fixes the cascade order for everything
44
+ * loaded after it. legacy-tokens.css keeps --token-* reads resolving and is
45
+ * removed in 0.3.
46
+ */
47
+ ${layerOrder}
48
+
49
+ ${replacementFor(ctx)
50
+ .map((r) => `@import "${r}";`)
51
+ .join("\n")}
52
+ `;
53
+
54
+ /**
55
+ * The URL an `@import` names, and whatever follows it (layer(), media).
56
+ *
57
+ * @param {string} params
58
+ */
59
+ function parseImport(params) {
60
+ const match = /^\s*(?:url\(\s*)?(["']?)([^"')\s]+)\1\s*\)?\s*(.*)$/.exec(params);
61
+ if (!match) return null;
62
+ return { url: match[2], rest: match[3].trim() };
63
+ }
64
+
65
+ /** @param {string} text */
66
+ function todoComment(text) {
67
+ return postcss.comment({ text: `${TODO_TAG}: ${text}` });
68
+ }
69
+
70
+ /**
71
+ * Plain CSS, through postcss.
72
+ *
73
+ * @param {string} source
74
+ * @param {string} path
75
+ */
76
+ function transformCss(source, path, ctx) {
77
+ const urls = replacementFor(ctx);
78
+ const root = postcss.parse(source, { from: path });
79
+ let changed = false;
80
+ let replacedBase = false;
81
+ root.walkAtRules("import", (rule) => {
82
+ const parsed = parseImport(rule.params);
83
+ if (!parsed) return;
84
+ if (DROPPED.test(parsed.url)) {
85
+ const next = rule.next();
86
+ if (next && rule.raws.before !== undefined) next.raws.before = rule.raws.before;
87
+ rule.remove();
88
+ changed = true;
89
+ return;
90
+ }
91
+ if (parsed.url !== base) return;
92
+ if (parsed.rest) {
93
+ const comment = todoComment(
94
+ `this import of styles/base.css carries "${parsed.rest}"; replace it with ${urls.join(", ")} under the same condition, and declare "${layerOrder}" first.`,
95
+ );
96
+ const previous = rule.prev();
97
+ if (previous?.type === "comment" && previous.text === comment.text) return;
98
+ rule.before(comment);
99
+ changed = true;
100
+ return;
101
+ }
102
+ changed = true;
103
+ const nodes = urls.map((url, i) => {
104
+ const node = postcss.atRule({ name: "import", params: `"${url}"` });
105
+ node.raws.before = i === 0 ? (rule.raws.before ?? "") : "\n";
106
+ return node;
107
+ });
108
+ rule.replaceWith(nodes);
109
+ replacedBase = true;
110
+ });
111
+
112
+ if (replacedBase) {
113
+ const hasOrder = (root.nodes ?? []).some(
114
+ (n) => n.type === "atrule" && n.name === "layer" && !n.nodes,
115
+ );
116
+ if (hasOrder) {
117
+ root.prepend(
118
+ todoComment(
119
+ `this stylesheet already declares a layer order; it must match "${layerOrder}".`,
120
+ ),
121
+ );
122
+ } else {
123
+ const layer = postcss.atRule({
124
+ name: "layer",
125
+ params: layerOrder.replace(/^@layer\s+|;$/g, ""),
126
+ });
127
+ const first = root.first;
128
+ const afterCharset = first?.type === "atrule" && first.name === "charset";
129
+ if (afterCharset) {
130
+ layer.raws.before = "\n";
131
+ first.after(layer);
132
+ const next = layer.next();
133
+ if (next) next.raws.before = "\n\n";
134
+ } else {
135
+ layer.raws.before = "";
136
+ if (first)
137
+ first.raws.before = `\n\n${(first.raws.before ?? "").replace(/^\n+/, "")}`;
138
+ root.prepend(layer);
139
+ }
140
+ }
141
+ }
142
+ if (!changed) return undefined;
143
+ return root.toString();
144
+ }
145
+
146
+ /**
147
+ * Where the leading run of statements Sass requires first ends: `@charset`,
148
+ * `@use`, `@forward`, and the variable declarations that may configure them,
149
+ * with the comments between them. Returns 0 when there is none. Sass rejects
150
+ * any other rule, a CSS `@layer` statement included, above an `@use`.
151
+ *
152
+ * @param {string} source
153
+ */
154
+ function sassPreludeEnd(source) {
155
+ let i = 0;
156
+ let end = 0;
157
+ const skipSpaceAndComments = () => {
158
+ for (;;) {
159
+ const rest = source.slice(i);
160
+ const space = /^\s+/.exec(rest);
161
+ if (space) i += space[0].length;
162
+ else if (rest.startsWith("//")) {
163
+ const nl = source.indexOf("\n", i);
164
+ i = nl === -1 ? source.length : nl + 1;
165
+ } else if (rest.startsWith("/*")) {
166
+ const close = source.indexOf("*/", i + 2);
167
+ i = close === -1 ? source.length : close + 2;
168
+ } else return;
169
+ }
170
+ };
171
+ for (;;) {
172
+ skipSpaceAndComments();
173
+ if (!/^(?:@(?:charset|use|forward)\b|\$[\w-]+\s*:)/.test(source.slice(i))) break;
174
+ // To the `;` that ends the statement, outside strings and parentheses
175
+ // (`@use "x" with ($a: 1, $b: 2);` spans lines).
176
+ let depth = 0;
177
+ /** @type {string | null} */
178
+ let quote = null;
179
+ for (; i < source.length; i++) {
180
+ const ch = source[i];
181
+ if (quote) {
182
+ if (ch === "\\") i++;
183
+ else if (ch === quote) quote = null;
184
+ } else if (ch === '"' || ch === "'") quote = ch;
185
+ else if (ch === "(") depth++;
186
+ else if (ch === ")") depth--;
187
+ else if (ch === ";" && depth <= 0) break;
188
+ }
189
+ if (i >= source.length) break;
190
+ i += 1;
191
+ end = i;
192
+ }
193
+ return end;
194
+ }
195
+
196
+ /**
197
+ * Sass and Less: postcss cannot parse them, and the imports in question are
198
+ * single lines, so rewrite the lines. The layer order goes first, or in SCSS
199
+ * right after the `@use`/`@forward` prelude.
200
+ *
201
+ * @param {string} source
202
+ * @param {string} path
203
+ */
204
+ function transformPreprocessed(source, path, ctx) {
205
+ const urls = replacementFor(ctx);
206
+ const importLine =
207
+ /^([ \t]*)@import\s+(?:url\()?["']([^"']+)["']\)?\s*;[ \t]*\r?\n?/gm;
208
+ let replacedBase = false;
209
+ const out = source.replace(importLine, (line, indent, url) => {
210
+ if (DROPPED.test(url)) return "";
211
+ if (url !== base) return line;
212
+ replacedBase = true;
213
+ return urls.map((r) => `${indent}@import "${r}";\n`).join("");
214
+ });
215
+ if (out === source) return undefined;
216
+ if (!replacedBase || /^\s*@layer\s+[^{]+;/m.test(out)) return out;
217
+ const at = path.endsWith(".scss") ? sassPreludeEnd(out) : 0;
218
+ if (at === 0) return `${layerOrder}\n\n${out}`;
219
+ return `${out.slice(0, at)}\n\n${layerOrder}\n\n${out.slice(at).replace(/^[ \t]*\r?\n+/, "")}`;
220
+ }
221
+
222
+ export const cssMeta = {
223
+ id: "stylesheet-entry",
224
+ title:
225
+ "Replace styles/base.css with the 0.2 stylesheet set; drop the orange theme sheets",
226
+ extensions: [".css", ".scss", ".sass", ".less"],
227
+ };
228
+
229
+ /**
230
+ * @param {{source: string, path: string}} file
231
+ * @param {unknown} _api
232
+ * @param {{flags?: {packages: Map<string, string>}}} ctx
233
+ */
234
+ export function transformStylesheet(file, _api, ctx) {
235
+ if (!file.source.includes("@lablup/ui-common/styles/")) return undefined;
236
+ if (file.path.endsWith(".css")) return transformCss(file.source, file.path, ctx);
237
+ return transformPreprocessed(file.source, file.path, ctx);
238
+ }
239
+
240
+ const STYLESHEET = /\.(css|scss|sass|less)(\?.*)?$/;
241
+
242
+ /**
243
+ * Whether an import has to come after the stylesheet entry: a stylesheet
244
+ * (the entry's `@layer` statement must be the first one the page sees), a
245
+ * @lablup/ui-common module (its components' styles), or a module of the
246
+ * project's own, which loads both.
247
+ *
248
+ * @param {string} source
249
+ */
250
+ function loadsStyles(source) {
251
+ return (
252
+ STYLESHEET.test(source) ||
253
+ source === UIC ||
254
+ source.startsWith(`${UIC}/`) ||
255
+ source.startsWith(".") ||
256
+ source.startsWith("/")
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Put the import of `specifier` before every import that loads styles, or
262
+ * add it there: after the last import that does not, else first. Vite
263
+ * injects stylesheets in import order, and the `@layer` order statement only
264
+ * holds if it comes first.
265
+ *
266
+ * @param {any} j
267
+ * @param {any} root
268
+ * @param {string} specifier
269
+ * @returns {boolean} whether anything moved or was added
270
+ */
271
+ export function placeEntryImport(j, root, specifier) {
272
+ const program = root.find(j.Program).get().node;
273
+ const body = program.body;
274
+ const isImport = (/** @type {any} */ n) => n.type === "ImportDeclaration";
275
+ const existing = body.find(
276
+ (/** @type {any} */ n) => isImport(n) && n.source.value === specifier,
277
+ );
278
+ const first = body.find(
279
+ (/** @type {any} */ n) =>
280
+ isImport(n) && n !== existing && loadsStyles(String(n.source.value)),
281
+ );
282
+ if (existing) {
283
+ if (!first || body.indexOf(existing) < body.indexOf(first)) return false;
284
+ body.splice(body.indexOf(existing), 1);
285
+ }
286
+ const decl = existing ?? j.importDeclaration([], j.stringLiteral(specifier));
287
+ if (first) {
288
+ const at = body.indexOf(first);
289
+ // A header comment on the first import stays on top.
290
+ if (at === 0 && first.comments?.length) {
291
+ decl.comments = [...first.comments, ...(decl.comments ?? [])];
292
+ first.comments = [];
293
+ }
294
+ body.splice(at, 0, decl);
295
+ return true;
296
+ }
297
+ let last = -1;
298
+ body.forEach((/** @type {any} */ n, /** @type {number} */ i) => {
299
+ if (isImport(n)) last = i;
300
+ });
301
+ body.splice(last + 1, 0, decl);
302
+ return true;
303
+ }
304
+
305
+ export const jsMeta = {
306
+ id: "script-stylesheet-imports",
307
+ title:
308
+ "Point script imports of styles/base.css at a generated ui-common-entry.css; drop the orange theme imports",
309
+ extensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".cts"],
310
+ };
311
+
312
+ /**
313
+ * @param {{source: string, path: string}} file
314
+ * @param {{jscodeshift: any}} api
315
+ * @param {{createFile: (path: string, content: string) => string, flags: {packages: Map<string, string>}}} ctx
316
+ */
317
+ export function transformScriptImports(file, api, ctx) {
318
+ if (!file.source.includes("@lablup/ui-common/styles/")) return undefined;
319
+ const j = api.jscodeshift;
320
+ const root = j(file.source);
321
+ let changed = false;
322
+ const entrySpecifier = `./${entryFile}`;
323
+ const alreadyImportsEntry =
324
+ root.find(j.ImportDeclaration, { source: { value: entrySpecifier } }).size() > 0;
325
+ /** @type {string[]} */
326
+ const entryImports = [];
327
+
328
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => {
329
+ const source = path.node.source.value;
330
+ if (typeof source !== "string") return;
331
+ if (DROPPED.test(source)) {
332
+ const comments = path.node.comments;
333
+ path.prune();
334
+ if (comments?.length) {
335
+ const program = root.find(j.Program).get().node;
336
+ if (program.body[0])
337
+ program.body[0].comments = [...comments, ...(program.body[0].comments ?? [])];
338
+ }
339
+ changed = true;
340
+ return;
341
+ }
342
+ if (source !== base) return;
343
+ changed = true;
344
+ if (alreadyImportsEntry) {
345
+ path.prune();
346
+ return;
347
+ }
348
+ // createFile never overwrites: it hands back the file it will write,
349
+ // which is a numbered sibling when the project has its own entry there.
350
+ const entry = ctx.createFile(join(dirname(file.path), entryFile), entryCss(ctx));
351
+ path.node.source = j.stringLiteral(`./${basename(entry)}`);
352
+ entryImports.push(`./${basename(entry)}`);
353
+ });
354
+ for (const specifier of entryImports) placeEntryImport(j, root, specifier);
355
+
356
+ root.find(j.CallExpression).forEach((/** @type {any} */ path) => {
357
+ const node = path.node;
358
+ if (node.callee.type !== "Import") return;
359
+ const source = node.arguments[0]?.value;
360
+ if (typeof source !== "string" || !source.startsWith("@lablup/ui-common/styles/"))
361
+ return;
362
+ changed = true;
363
+ addTodo(
364
+ j,
365
+ path,
366
+ `${source} is deprecated in 0.2 and removed in 0.3. Switch themes with <Theme theme={…}>, not by swapping stylesheets.`,
367
+ );
368
+ });
369
+
370
+ if (!changed) return undefined;
371
+ const out = printSource(j, root, {
372
+ quote: file.source.includes("from '") ? "single" : "double",
373
+ });
374
+ return file.source.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out;
375
+ }
376
+
377
+ /** What loading the 0.2 stylesheets looks like, in a script or a stylesheet. */
378
+ const WIRED = new RegExp(
379
+ [...replacement, `${UIC}/styles/`, entryFile.replace(/\.css$/, "")]
380
+ .map((r) => r.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"))
381
+ .join("|"),
382
+ );
383
+
384
+ /**
385
+ * The app's entry script: the module script `index.html` loads, else the
386
+ * package's `main`, else the usual `src/main.*` / `src/index.*`.
387
+ *
388
+ * @param {string} projectDir
389
+ * @param {any} pkg
390
+ * @returns {{file: string, how: string} | null}
391
+ */
392
+ export function findEntryScript(projectDir, pkg) {
393
+ const html = join(projectDir, "index.html");
394
+ if (existsSync(html)) {
395
+ const text = readFileSync(html, "utf8");
396
+ for (const tag of text.matchAll(/<script\b[^>]*>/gi)) {
397
+ const src = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(tag[0])?.[1];
398
+ if (!src || /^[a-z]+:|^\/\//i.test(src)) continue;
399
+ if (!/type\s*=\s*["']module["']/i.test(tag[0]) && !/\.[cm]?[jt]sx?$/.test(src))
400
+ continue;
401
+ const file = src.startsWith("/")
402
+ ? join(projectDir, src.replace(/^\/+/, ""))
403
+ : resolve(projectDir, src);
404
+ if (existsSync(file)) return { file, how: "the module script index.html loads" };
405
+ }
406
+ }
407
+ const main = typeof pkg?.main === "string" ? resolve(projectDir, pkg.main) : null;
408
+ if (
409
+ main &&
410
+ /\.[cm]?[jt]sx?$/.test(main) &&
411
+ existsSync(main) &&
412
+ !relative(projectDir, main)
413
+ .split(sep)
414
+ .some((d) => ["dist", "build", "out"].includes(d))
415
+ )
416
+ return { file: main, how: "package.json main" };
417
+ for (const name of ["main", "index"]) {
418
+ for (const ext of [".tsx", ".ts", ".jsx", ".js"]) {
419
+ const file = join(projectDir, "src", `${name}${ext}`);
420
+ if (existsSync(file)) return { file, how: `the conventional entry` };
421
+ }
422
+ }
423
+ return null;
424
+ }
425
+
426
+ /**
427
+ * A 0.1 app that never imported styles/base.css relied on each component
428
+ * loading its own CSS; 0.2 components load none, so after the upgrade
429
+ * nothing would load Astryx's stylesheets or the theme. When no file of the
430
+ * project loads them, write the same ui-common-entry.css the base.css
431
+ * rewrite writes beside the app's entry script and import it there, first.
432
+ * With no entry to be found, the report opens with the manual step.
433
+ * Libraries are left alone: the app that uses them loads the stylesheets.
434
+ *
435
+ * @param {any} ctx
436
+ * @param {{jscodeshift: any}} api
437
+ */
438
+ export function wireStylesheets(ctx, api) {
439
+ const pkg = ctx.pkg ?? {};
440
+ if (pkg.peerDependencies?.[UIC] != null) return;
441
+ const declared = ["dependencies", "devDependencies"].some(
442
+ (f) => pkg[f]?.[UIC] != null,
443
+ );
444
+ if (!declared) return;
445
+ for (const file of ctx.projectFiles()) {
446
+ const text = ctx.current(file);
447
+ if (text && WIRED.test(text)) return;
448
+ }
449
+ const rel = (/** @type {string} */ f) =>
450
+ relative(ctx.projectDir, f).split(sep).join("/");
451
+ const entry = findEntryScript(ctx.projectDir, pkg);
452
+ if (!entry) {
453
+ ctx.alert(
454
+ `**Load @lablup/ui-common's stylesheets.** Nothing in this project loads them, and the upgrade found no entry script to import them from (no index.html module script, package.json \`main\`, or \`src/main.*\` / \`src/index.*\`). 0.1 components loaded their own CSS; 0.2 components load none, so the app renders unstyled until its entry stylesheet starts with: \`${[layerOrder, ...replacement.map((r) => `@import "${r}";`)].join(" ")}\``,
455
+ );
456
+ return;
457
+ }
458
+ const j = api.jscodeshift.withParser(
459
+ /\.[cm]?tsx?$/.test(entry.file) ? "tsx" : "babel",
460
+ );
461
+ const cssFile = ctx.createFile(join(dirname(entry.file), entryFile), entryCss(ctx));
462
+ const specifier = `./${basename(cssFile)}`;
463
+ ctx.editFile(
464
+ entry.file,
465
+ (/** @type {string | null} */ current) => {
466
+ if (current == null) return undefined;
467
+ const root = j(current);
468
+ if (!placeEntryImport(j, root, specifier)) return undefined;
469
+ const out = root.toSource({
470
+ quote: current.includes("from '") ? "single" : "double",
471
+ });
472
+ return current.endsWith("\n") && !out.endsWith("\n") ? `${out}\n` : out;
473
+ },
474
+ "stylesheet-entry",
475
+ );
476
+ ctx.notice(
477
+ `No file loaded @lablup/ui-common's stylesheets (0.1 components loaded their own CSS; 0.2's load none), so the upgrade wrote ${rel(cssFile)} and imported it first in ${rel(entry.file)} (${entry.how}). Move the import if your app loads its stylesheets elsewhere.`,
478
+ );
479
+ }