@shadow-js/ui 1.0.0 → 1.0.2
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/CHANGELOG.md +21 -0
- package/dist/cli.mjs +324 -247
- package/dist/dev/cli/schema.d.ts +0 -2
- package/dist/dev/locales/ar.d.ts +0 -2
- package/dist/dev/locales/ar.js +1 -2
- package/dist/dev/locales/en.d.ts +0 -2
- package/dist/dev/locales/en.js +1 -2
- package/dist/dev/registry/utils/utils.d.ts +0 -2
- package/dist/dev/registry/utils/utils.js +1 -2
- package/dist/dev/theme/theme-logic.d.ts +0 -2
- package/dist/dev/theme/theme-logic.js +1 -2
- package/dist/dev/theme/theme-provider.d.ts +0 -2
- package/dist/dev/theme/theme-provider.js +1 -2
- package/package.json +11 -11
- package/registry/carousel/carousel.tsx +79 -15
- package/registry/chart/chart.tsx +5 -1
- package/registry/input-group/input-group.tsx +2 -1
- package/registry/resizable/resizable.tsx +175 -34
- package/registry.json +7 -3
package/dist/cli.mjs
CHANGED
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
|
|
8
|
+
// src/cli/fs.ts
|
|
9
9
|
/**
|
|
10
10
|
* The filesystem the CLI works on, as an interface so commands are tested against a temp
|
|
11
11
|
* directory or an in-memory tree, and so every path goes through one place.
|
|
12
12
|
* @packageDocumentation
|
|
13
13
|
*/
|
|
14
|
+
/** Symbolic links followed while resolving one path; the same bound the operating system uses. */
|
|
15
|
+
const MAX_LINK_HOPS = 40;
|
|
16
|
+
/** Whether a failed file call means "nothing is there" (or a file stands where a directory should). */
|
|
17
|
+
function isMissing(error) {
|
|
18
|
+
const code = error.code;
|
|
19
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The real path of `path`, where the part that does not exist yet is kept as written. A symbolic
|
|
23
|
+
* link whose target is missing is followed to that target: a write through it would create the
|
|
24
|
+
* target, so the guard has to see where it is.
|
|
25
|
+
*/
|
|
26
|
+
function resolveReal(path, hops) {
|
|
27
|
+
try {
|
|
28
|
+
return realpathSync(path);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (!isMissing(error)) throw error;
|
|
31
|
+
}
|
|
32
|
+
let link;
|
|
33
|
+
try {
|
|
34
|
+
link = readlinkSync(path);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (!isMissing(error) && error.code !== "EINVAL") throw error;
|
|
37
|
+
}
|
|
38
|
+
if (link !== void 0) {
|
|
39
|
+
if (hops >= MAX_LINK_HOPS) throw Object.assign(/* @__PURE__ */ new Error(`Too many symbolic links: ${path}`), { code: "ELOOP" });
|
|
40
|
+
return resolveReal(resolve(resolveReal(dirname(path), hops + 1), link), hops + 1);
|
|
41
|
+
}
|
|
42
|
+
const parent = dirname(path);
|
|
43
|
+
if (parent === path) return path;
|
|
44
|
+
return join(resolveReal(parent, hops), basename(path));
|
|
45
|
+
}
|
|
14
46
|
/**
|
|
15
47
|
* The real filesystem.
|
|
16
48
|
*
|
|
@@ -26,7 +58,7 @@ function nodeFs() {
|
|
|
26
58
|
exists: (path) => existsSync(path),
|
|
27
59
|
read(path) {
|
|
28
60
|
try {
|
|
29
|
-
return readFileSync(path, "utf8");
|
|
61
|
+
return readFileSync(path, "utf8").replace(/^\uFEFF/, "");
|
|
30
62
|
} catch (error) {
|
|
31
63
|
if (error.code === "ENOENT") return void 0;
|
|
32
64
|
throw error;
|
|
@@ -40,15 +72,7 @@ function nodeFs() {
|
|
|
40
72
|
rmSync(path, { force: true });
|
|
41
73
|
},
|
|
42
74
|
realpath(path) {
|
|
43
|
-
|
|
44
|
-
let current = path;
|
|
45
|
-
while (!existsSync(current)) {
|
|
46
|
-
const parent = dirname(current);
|
|
47
|
-
if (parent === current) return path;
|
|
48
|
-
tail.unshift(basename(current));
|
|
49
|
-
current = parent;
|
|
50
|
-
}
|
|
51
|
-
return join(realpathSync(current), ...tail);
|
|
75
|
+
return resolveReal(path, 0);
|
|
52
76
|
}
|
|
53
77
|
};
|
|
54
78
|
}
|
|
@@ -210,25 +234,37 @@ function installCrashHandlers(argv) {
|
|
|
210
234
|
process.on("uncaughtException", crash);
|
|
211
235
|
process.on("unhandledRejection", crash);
|
|
212
236
|
}
|
|
213
|
-
/** The
|
|
214
|
-
function
|
|
215
|
-
|
|
216
|
-
const m = b.length;
|
|
217
|
-
const max = n + m;
|
|
218
|
-
if (max > 2e4) return [...a.map((line) => ({
|
|
237
|
+
/** The whole of `a` deleted and the whole of `b` inserted: always correct, never minimal. */
|
|
238
|
+
function replacement(a, b) {
|
|
239
|
+
return [...a.map((line) => ({
|
|
219
240
|
kind: "delete",
|
|
220
241
|
line
|
|
221
242
|
})), ...b.map((line) => ({
|
|
222
243
|
kind: "insert",
|
|
223
244
|
line
|
|
224
245
|
}))];
|
|
246
|
+
}
|
|
247
|
+
/** The edit script turning `a` into `b`; one big replacement when the texts are too large or too different. */
|
|
248
|
+
function edits(a, b) {
|
|
249
|
+
const n = a.length;
|
|
250
|
+
const m = b.length;
|
|
251
|
+
const max = n + m;
|
|
252
|
+
if (max > 2e4) return replacement(a, b);
|
|
225
253
|
const offset = max;
|
|
226
|
-
|
|
254
|
+
const frontier = new Int32Array(2 * max + 2);
|
|
255
|
+
/** The frontier before each round, only the diagonals the next round can read: `[offset - d - 1, offset + d + 1]`. */
|
|
227
256
|
const trace = [];
|
|
257
|
+
let kept = 0;
|
|
228
258
|
let reached = -1;
|
|
229
259
|
for (let d = 0; d <= max && reached === -1; d += 1) {
|
|
230
|
-
|
|
231
|
-
const
|
|
260
|
+
const from = Math.max(0, offset - d - 1);
|
|
261
|
+
const cells = frontier.slice(from, Math.min(frontier.length, offset + d + 2));
|
|
262
|
+
kept += cells.length;
|
|
263
|
+
if (kept > 16e6) return replacement(a, b);
|
|
264
|
+
trace.push({
|
|
265
|
+
from,
|
|
266
|
+
cells
|
|
267
|
+
});
|
|
232
268
|
for (let k = -d; k <= d; k += 2) {
|
|
233
269
|
let x;
|
|
234
270
|
const below = frontier[offset + k - 1] ?? 0;
|
|
@@ -240,24 +276,24 @@ function edits(a, b) {
|
|
|
240
276
|
x += 1;
|
|
241
277
|
y += 1;
|
|
242
278
|
}
|
|
243
|
-
|
|
279
|
+
frontier[offset + k] = x;
|
|
244
280
|
if (x >= n && y >= m) {
|
|
245
281
|
reached = d;
|
|
246
282
|
break;
|
|
247
283
|
}
|
|
248
284
|
}
|
|
249
|
-
frontier = next;
|
|
250
285
|
}
|
|
251
286
|
const result = [];
|
|
252
287
|
let x = n;
|
|
253
288
|
let y = m;
|
|
254
289
|
for (let d = reached; d > 0; d -= 1) {
|
|
255
|
-
const previous = trace[d]
|
|
290
|
+
const previous = trace[d];
|
|
291
|
+
const at = (index) => previous === void 0 ? 0 : previous.cells[index - previous.from] ?? 0;
|
|
256
292
|
const k = x - y;
|
|
257
|
-
const below =
|
|
258
|
-
const above =
|
|
293
|
+
const below = at(offset + k - 1);
|
|
294
|
+
const above = at(offset + k + 1);
|
|
259
295
|
const previousK = k === -d || k !== d && below < above ? k + 1 : k - 1;
|
|
260
|
-
const previousX =
|
|
296
|
+
const previousX = at(offset + previousK);
|
|
261
297
|
const previousY = previousX - previousK;
|
|
262
298
|
while (x > previousX && y > previousY) {
|
|
263
299
|
x -= 1;
|
|
@@ -371,8 +407,187 @@ function unifiedDiff(before, after, labels, context = 3) {
|
|
|
371
407
|
...hunks
|
|
372
408
|
].join("\n");
|
|
373
409
|
}
|
|
374
|
-
|
|
375
|
-
|
|
410
|
+
// src/cli/schema.ts
|
|
411
|
+
/**
|
|
412
|
+
* The base colors, in documentation order.
|
|
413
|
+
* @since 1.0.0
|
|
414
|
+
*
|
|
415
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
416
|
+
*/
|
|
417
|
+
const BASE_COLORS = [
|
|
418
|
+
"neutral",
|
|
419
|
+
"zinc",
|
|
420
|
+
"slate",
|
|
421
|
+
"stone",
|
|
422
|
+
"gray"
|
|
423
|
+
];
|
|
424
|
+
/**
|
|
425
|
+
* The default aliases (shadcn's).
|
|
426
|
+
* @since 1.0.0
|
|
427
|
+
*
|
|
428
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
429
|
+
*/
|
|
430
|
+
const DEFAULT_ALIASES = {
|
|
431
|
+
components: "@/components",
|
|
432
|
+
ui: "@/components/ui",
|
|
433
|
+
lib: "@/lib",
|
|
434
|
+
utils: "@/lib/utils",
|
|
435
|
+
hooks: "@/hooks"
|
|
436
|
+
};
|
|
437
|
+
/** Registry item names are kebab-case; nothing else may reach a file path. */
|
|
438
|
+
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
439
|
+
/** A file name inside `registry/<name>/`. */
|
|
440
|
+
const FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
441
|
+
/**
|
|
442
|
+
* Whether `name` is a valid registry item name.
|
|
443
|
+
*
|
|
444
|
+
* @param name Any string.
|
|
445
|
+
* @returns `true` for kebab-case names such as `dropdown-menu`.
|
|
446
|
+
* @example
|
|
447
|
+
* isValidItemName("../etc/passwd"); // false
|
|
448
|
+
* @since 1.0.0
|
|
449
|
+
*
|
|
450
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
451
|
+
*/
|
|
452
|
+
function isValidItemName(name) {
|
|
453
|
+
return NAME_PATTERN.test(name);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Thrown for malformed registry or configuration data.
|
|
457
|
+
* @since 1.0.0
|
|
458
|
+
*
|
|
459
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
460
|
+
*/
|
|
461
|
+
var SchemaError = class extends Error {
|
|
462
|
+
/** The stable code `--json` prints. */
|
|
463
|
+
code = "SHADOW_UI_INVALID_FILE";
|
|
464
|
+
constructor(message) {
|
|
465
|
+
super(message);
|
|
466
|
+
this.name = "SchemaError";
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
function record(value, where) {
|
|
470
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SchemaError(`${where} must be an object`);
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
function text(value, where) {
|
|
474
|
+
if (typeof value !== "string" || value === "") throw new SchemaError(`${where} must be a string`);
|
|
475
|
+
return value;
|
|
476
|
+
}
|
|
477
|
+
function strings(value, where) {
|
|
478
|
+
if (!Array.isArray(value)) throw new SchemaError(`${where} must be an array`);
|
|
479
|
+
return value.map((entry, index) => text(entry, `${where}[${index}]`));
|
|
480
|
+
}
|
|
481
|
+
function itemType(value, where) {
|
|
482
|
+
if (value === "ui" || value === "lib" || value === "hook") return value;
|
|
483
|
+
throw new SchemaError(`${where} must be "ui", "lib" or "hook"`);
|
|
484
|
+
}
|
|
485
|
+
function parseItem(value, index) {
|
|
486
|
+
const where = `items[${index}]`;
|
|
487
|
+
const item = record(value, where);
|
|
488
|
+
const name = text(item["name"], `${where}.name`);
|
|
489
|
+
if (!isValidItemName(name)) throw new SchemaError(`${where}.name "${name}" is not a valid name`);
|
|
490
|
+
const files = item["files"];
|
|
491
|
+
if (!Array.isArray(files) || files.length === 0) throw new SchemaError(`${where}.files must be a non-empty array`);
|
|
492
|
+
const dependencies = record(item["dependencies"], `${where}.dependencies`);
|
|
493
|
+
const tailwind = record(item["tailwind"], `${where}.tailwind`);
|
|
494
|
+
return {
|
|
495
|
+
name,
|
|
496
|
+
type: itemType(item["type"], `${where}.type`),
|
|
497
|
+
title: text(item["title"], `${where}.title`),
|
|
498
|
+
description: text(item["description"], `${where}.description`),
|
|
499
|
+
files: files.map((file, fileIndex) => {
|
|
500
|
+
const entry = record(file, `${where}.files[${fileIndex}]`);
|
|
501
|
+
const path = text(entry["path"], `${where}.files[${fileIndex}].path`);
|
|
502
|
+
const segments = path.split("/");
|
|
503
|
+
const [root, folder, base] = segments;
|
|
504
|
+
if (segments.length !== 3 || root !== "registry" || folder !== name || base === void 0 || !FILE_PATTERN.test(base)) throw new SchemaError(`${where}.files[${fileIndex}].path "${path}" must be registry/${name}/<file>`);
|
|
505
|
+
return {
|
|
506
|
+
path,
|
|
507
|
+
type: itemType(entry["type"], `${where}.files[${fileIndex}].type`)
|
|
508
|
+
};
|
|
509
|
+
}),
|
|
510
|
+
registryDependencies: strings(item["registryDependencies"], `${where}.registryDependencies`),
|
|
511
|
+
dependencies: Object.fromEntries(Object.entries(dependencies).map(([pkg, version]) => [pkg, text(version, `${where}.dependencies.${pkg}`)])),
|
|
512
|
+
exports: strings(item["exports"], `${where}.exports`),
|
|
513
|
+
primitives: strings(item["primitives"], `${where}.primitives`),
|
|
514
|
+
tailwind: {
|
|
515
|
+
tokens: strings(tailwind["tokens"], `${where}.tailwind.tokens`),
|
|
516
|
+
animations: tailwind["animations"] === true
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Validates parsed `registry.json` data.
|
|
522
|
+
*
|
|
523
|
+
* @param value The parsed JSON.
|
|
524
|
+
* @returns The typed index.
|
|
525
|
+
* @throws SchemaError naming the first malformed field.
|
|
526
|
+
* @example
|
|
527
|
+
* const index = parseRegistryIndex(JSON.parse(readFileSync("registry.json", "utf8")));
|
|
528
|
+
* @since 1.0.0
|
|
529
|
+
*
|
|
530
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
531
|
+
*/
|
|
532
|
+
function parseRegistryIndex(value) {
|
|
533
|
+
const root = record(value, "registry");
|
|
534
|
+
const items = root["items"];
|
|
535
|
+
if (!Array.isArray(items)) throw new SchemaError("registry.items must be an array");
|
|
536
|
+
const parsed = items.map(parseItem);
|
|
537
|
+
const seen = /* @__PURE__ */ new Set();
|
|
538
|
+
for (const item of parsed) {
|
|
539
|
+
if (seen.has(item.name)) throw new SchemaError(`registry has two items named "${item.name}"`);
|
|
540
|
+
seen.add(item.name);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
name: text(root["name"], "registry.name"),
|
|
544
|
+
version: text(root["version"], "registry.version"),
|
|
545
|
+
frameworkDependencies: strings(root["frameworkDependencies"], "registry.frameworkDependencies"),
|
|
546
|
+
items: parsed
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Validates parsed `components.json` data.
|
|
551
|
+
*
|
|
552
|
+
* @param value The parsed JSON.
|
|
553
|
+
* @returns The typed configuration, with defaults for missing aliases.
|
|
554
|
+
* @throws SchemaError naming the first malformed field.
|
|
555
|
+
* @example
|
|
556
|
+
* const config = parseComponentsConfig(JSON.parse(readFileSync("components.json", "utf8")));
|
|
557
|
+
* @since 1.0.0
|
|
558
|
+
*
|
|
559
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
560
|
+
*/
|
|
561
|
+
function parseComponentsConfig(value) {
|
|
562
|
+
const root = record(value, "components.json");
|
|
563
|
+
const tailwind = record(root["tailwind"], "components.json tailwind");
|
|
564
|
+
const baseColor = tailwind["baseColor"] ?? "neutral";
|
|
565
|
+
if (!BASE_COLORS.includes(baseColor)) throw new SchemaError(`components.json tailwind.baseColor must be one of ${BASE_COLORS.join(", ")}`);
|
|
566
|
+
const given = root["aliases"] === void 0 ? {} : record(root["aliases"], "components.json aliases");
|
|
567
|
+
const alias = (key) => {
|
|
568
|
+
const entry = given[key];
|
|
569
|
+
if (entry === void 0) return DEFAULT_ALIASES[key];
|
|
570
|
+
const aliasText = text(entry, `components.json aliases.${key}`);
|
|
571
|
+
if (aliasText.includes("..") || /\s/.test(aliasText)) throw new SchemaError(`components.json aliases.${key} "${aliasText}" is not a valid alias`);
|
|
572
|
+
return aliasText;
|
|
573
|
+
};
|
|
574
|
+
const framework = root["framework"];
|
|
575
|
+
return {
|
|
576
|
+
framework: framework === "vite" || framework === "dusk" ? framework : "unknown",
|
|
577
|
+
tailwind: {
|
|
578
|
+
css: text(tailwind["css"], "components.json tailwind.css"),
|
|
579
|
+
baseColor
|
|
580
|
+
},
|
|
581
|
+
aliases: {
|
|
582
|
+
components: alias("components"),
|
|
583
|
+
ui: alias("ui"),
|
|
584
|
+
lib: alias("lib"),
|
|
585
|
+
utils: alias("utils"),
|
|
586
|
+
hooks: alias("hooks")
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
// src/cli/paths.ts
|
|
376
591
|
/**
|
|
377
592
|
* Path handling: reading `tsconfig` path aliases, mapping an import alias to a directory, and the
|
|
378
593
|
* guard that keeps every write inside the project. Pure except for the `ProjectFs` it is handed.
|
|
@@ -478,7 +693,14 @@ function readPathMappings(fs, root) {
|
|
|
478
693
|
for (const name of ["tsconfig.json", "tsconfig.app.json"]) {
|
|
479
694
|
const text = fs.read(join(root, name));
|
|
480
695
|
if (text === void 0) continue;
|
|
481
|
-
|
|
696
|
+
let parsed;
|
|
697
|
+
try {
|
|
698
|
+
parsed = parseJsonc(text);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
if (error instanceof SyntaxError) throw new SchemaError(`${name} is not valid JSON: ${error.message}`);
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
const options = asRecord(asRecord(parsed)?.["compilerOptions"]);
|
|
482
704
|
const paths = asRecord(options?.["paths"]);
|
|
483
705
|
if (paths === void 0) continue;
|
|
484
706
|
const base = typeof options?.["baseUrl"] === "string" ? resolve(root, options["baseUrl"]) : root;
|
|
@@ -567,8 +789,7 @@ function resolveInsideReal(fs, root, target) {
|
|
|
567
789
|
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new PathError(`Refusing to write outside the project (symbolic link): ${target}`);
|
|
568
790
|
return absolute;
|
|
569
791
|
}
|
|
570
|
-
|
|
571
|
-
//#region src/cli/pm.ts
|
|
792
|
+
// src/cli/pm.ts
|
|
572
793
|
/**
|
|
573
794
|
* Package manager detection and the install command line. npm is the default; pnpm, yarn and bun
|
|
574
795
|
* are used when their lockfile (or the `packageManager` field) says so.
|
|
@@ -635,189 +856,7 @@ function installCommand(manager, packages) {
|
|
|
635
856
|
};
|
|
636
857
|
}
|
|
637
858
|
}
|
|
638
|
-
|
|
639
|
-
//#region src/cli/schema.ts
|
|
640
|
-
/**
|
|
641
|
-
* The base colors, in documentation order.
|
|
642
|
-
* @since 1.0.0
|
|
643
|
-
*
|
|
644
|
-
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
645
|
-
*/
|
|
646
|
-
const BASE_COLORS = [
|
|
647
|
-
"neutral",
|
|
648
|
-
"zinc",
|
|
649
|
-
"slate",
|
|
650
|
-
"stone",
|
|
651
|
-
"gray"
|
|
652
|
-
];
|
|
653
|
-
/**
|
|
654
|
-
* The default aliases (shadcn's).
|
|
655
|
-
* @since 1.0.0
|
|
656
|
-
*
|
|
657
|
-
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
658
|
-
*/
|
|
659
|
-
const DEFAULT_ALIASES = {
|
|
660
|
-
components: "@/components",
|
|
661
|
-
ui: "@/components/ui",
|
|
662
|
-
lib: "@/lib",
|
|
663
|
-
utils: "@/lib/utils",
|
|
664
|
-
hooks: "@/hooks"
|
|
665
|
-
};
|
|
666
|
-
/** Registry item names are kebab-case; nothing else may reach a file path. */
|
|
667
|
-
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
668
|
-
/** A file name inside `registry/<name>/`. */
|
|
669
|
-
const FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
670
|
-
/**
|
|
671
|
-
* Whether `name` is a valid registry item name.
|
|
672
|
-
*
|
|
673
|
-
* @param name Any string.
|
|
674
|
-
* @returns `true` for kebab-case names such as `dropdown-menu`.
|
|
675
|
-
* @example
|
|
676
|
-
* isValidItemName("../etc/passwd"); // false
|
|
677
|
-
* @since 1.0.0
|
|
678
|
-
*
|
|
679
|
-
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
680
|
-
*/
|
|
681
|
-
function isValidItemName(name) {
|
|
682
|
-
return NAME_PATTERN.test(name);
|
|
683
|
-
}
|
|
684
|
-
/**
|
|
685
|
-
* Thrown for malformed registry or configuration data.
|
|
686
|
-
* @since 1.0.0
|
|
687
|
-
*
|
|
688
|
-
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
689
|
-
*/
|
|
690
|
-
var SchemaError = class extends Error {
|
|
691
|
-
/** The stable code `--json` prints. */
|
|
692
|
-
code = "SHADOW_UI_INVALID_FILE";
|
|
693
|
-
constructor(message) {
|
|
694
|
-
super(message);
|
|
695
|
-
this.name = "SchemaError";
|
|
696
|
-
}
|
|
697
|
-
};
|
|
698
|
-
function record(value, where) {
|
|
699
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new SchemaError(`${where} must be an object`);
|
|
700
|
-
return value;
|
|
701
|
-
}
|
|
702
|
-
function text(value, where) {
|
|
703
|
-
if (typeof value !== "string" || value === "") throw new SchemaError(`${where} must be a string`);
|
|
704
|
-
return value;
|
|
705
|
-
}
|
|
706
|
-
function strings(value, where) {
|
|
707
|
-
if (!Array.isArray(value)) throw new SchemaError(`${where} must be an array`);
|
|
708
|
-
return value.map((entry, index) => text(entry, `${where}[${index}]`));
|
|
709
|
-
}
|
|
710
|
-
function itemType(value, where) {
|
|
711
|
-
if (value === "ui" || value === "lib" || value === "hook") return value;
|
|
712
|
-
throw new SchemaError(`${where} must be "ui", "lib" or "hook"`);
|
|
713
|
-
}
|
|
714
|
-
function parseItem(value, index) {
|
|
715
|
-
const where = `items[${index}]`;
|
|
716
|
-
const item = record(value, where);
|
|
717
|
-
const name = text(item["name"], `${where}.name`);
|
|
718
|
-
if (!isValidItemName(name)) throw new SchemaError(`${where}.name "${name}" is not a valid name`);
|
|
719
|
-
const files = item["files"];
|
|
720
|
-
if (!Array.isArray(files) || files.length === 0) throw new SchemaError(`${where}.files must be a non-empty array`);
|
|
721
|
-
const dependencies = record(item["dependencies"], `${where}.dependencies`);
|
|
722
|
-
const tailwind = record(item["tailwind"], `${where}.tailwind`);
|
|
723
|
-
return {
|
|
724
|
-
name,
|
|
725
|
-
type: itemType(item["type"], `${where}.type`),
|
|
726
|
-
title: text(item["title"], `${where}.title`),
|
|
727
|
-
description: text(item["description"], `${where}.description`),
|
|
728
|
-
files: files.map((file, fileIndex) => {
|
|
729
|
-
const entry = record(file, `${where}.files[${fileIndex}]`);
|
|
730
|
-
const path = text(entry["path"], `${where}.files[${fileIndex}].path`);
|
|
731
|
-
const segments = path.split("/");
|
|
732
|
-
const [root, folder, base] = segments;
|
|
733
|
-
if (segments.length !== 3 || root !== "registry" || folder !== name || base === void 0 || !FILE_PATTERN.test(base)) throw new SchemaError(`${where}.files[${fileIndex}].path "${path}" must be registry/${name}/<file>`);
|
|
734
|
-
return {
|
|
735
|
-
path,
|
|
736
|
-
type: itemType(entry["type"], `${where}.files[${fileIndex}].type`)
|
|
737
|
-
};
|
|
738
|
-
}),
|
|
739
|
-
registryDependencies: strings(item["registryDependencies"], `${where}.registryDependencies`),
|
|
740
|
-
dependencies: Object.fromEntries(Object.entries(dependencies).map(([pkg, version]) => [pkg, text(version, `${where}.dependencies.${pkg}`)])),
|
|
741
|
-
exports: strings(item["exports"], `${where}.exports`),
|
|
742
|
-
primitives: strings(item["primitives"], `${where}.primitives`),
|
|
743
|
-
tailwind: {
|
|
744
|
-
tokens: strings(tailwind["tokens"], `${where}.tailwind.tokens`),
|
|
745
|
-
animations: tailwind["animations"] === true
|
|
746
|
-
}
|
|
747
|
-
};
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Validates parsed `registry.json` data.
|
|
751
|
-
*
|
|
752
|
-
* @param value The parsed JSON.
|
|
753
|
-
* @returns The typed index.
|
|
754
|
-
* @throws SchemaError naming the first malformed field.
|
|
755
|
-
* @example
|
|
756
|
-
* const index = parseRegistryIndex(JSON.parse(readFileSync("registry.json", "utf8")));
|
|
757
|
-
* @since 1.0.0
|
|
758
|
-
*
|
|
759
|
-
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
760
|
-
*/
|
|
761
|
-
function parseRegistryIndex(value) {
|
|
762
|
-
const root = record(value, "registry");
|
|
763
|
-
const items = root["items"];
|
|
764
|
-
if (!Array.isArray(items)) throw new SchemaError("registry.items must be an array");
|
|
765
|
-
const parsed = items.map(parseItem);
|
|
766
|
-
const seen = /* @__PURE__ */ new Set();
|
|
767
|
-
for (const item of parsed) {
|
|
768
|
-
if (seen.has(item.name)) throw new SchemaError(`registry has two items named "${item.name}"`);
|
|
769
|
-
seen.add(item.name);
|
|
770
|
-
}
|
|
771
|
-
return {
|
|
772
|
-
name: text(root["name"], "registry.name"),
|
|
773
|
-
version: text(root["version"], "registry.version"),
|
|
774
|
-
frameworkDependencies: strings(root["frameworkDependencies"], "registry.frameworkDependencies"),
|
|
775
|
-
items: parsed
|
|
776
|
-
};
|
|
777
|
-
}
|
|
778
|
-
/**
|
|
779
|
-
* Validates parsed `components.json` data.
|
|
780
|
-
*
|
|
781
|
-
* @param value The parsed JSON.
|
|
782
|
-
* @returns The typed configuration, with defaults for missing aliases.
|
|
783
|
-
* @throws SchemaError naming the first malformed field.
|
|
784
|
-
* @example
|
|
785
|
-
* const config = parseComponentsConfig(JSON.parse(readFileSync("components.json", "utf8")));
|
|
786
|
-
* @since 1.0.0
|
|
787
|
-
*
|
|
788
|
-
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
789
|
-
*/
|
|
790
|
-
function parseComponentsConfig(value) {
|
|
791
|
-
const root = record(value, "components.json");
|
|
792
|
-
const tailwind = record(root["tailwind"], "components.json tailwind");
|
|
793
|
-
const baseColor = tailwind["baseColor"] ?? "neutral";
|
|
794
|
-
if (!BASE_COLORS.includes(baseColor)) throw new SchemaError(`components.json tailwind.baseColor must be one of ${BASE_COLORS.join(", ")}`);
|
|
795
|
-
const given = root["aliases"] === void 0 ? {} : record(root["aliases"], "components.json aliases");
|
|
796
|
-
const alias = (key) => {
|
|
797
|
-
const entry = given[key];
|
|
798
|
-
if (entry === void 0) return DEFAULT_ALIASES[key];
|
|
799
|
-
const aliasText = text(entry, `components.json aliases.${key}`);
|
|
800
|
-
if (aliasText.includes("..") || /\s/.test(aliasText)) throw new SchemaError(`components.json aliases.${key} "${aliasText}" is not a valid alias`);
|
|
801
|
-
return aliasText;
|
|
802
|
-
};
|
|
803
|
-
const framework = root["framework"];
|
|
804
|
-
return {
|
|
805
|
-
framework: framework === "vite" || framework === "dusk" ? framework : "unknown",
|
|
806
|
-
tailwind: {
|
|
807
|
-
css: text(tailwind["css"], "components.json tailwind.css"),
|
|
808
|
-
baseColor
|
|
809
|
-
},
|
|
810
|
-
aliases: {
|
|
811
|
-
components: alias("components"),
|
|
812
|
-
ui: alias("ui"),
|
|
813
|
-
lib: alias("lib"),
|
|
814
|
-
utils: alias("utils"),
|
|
815
|
-
hooks: alias("hooks")
|
|
816
|
-
}
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
//#endregion
|
|
820
|
-
//#region src/cli/project.ts
|
|
859
|
+
// src/cli/project.ts
|
|
821
860
|
/**
|
|
822
861
|
* The project the CLI works in: `components.json`, `package.json`, the stylesheet that imports
|
|
823
862
|
* Tailwind, and the theme stylesheet `init` generates. Reads through {@link ProjectFs}.
|
|
@@ -1084,8 +1123,7 @@ function display(root, absolute) {
|
|
|
1084
1123
|
function themePathFor(cssPath) {
|
|
1085
1124
|
return join(dirname(cssPath), THEME_FILE);
|
|
1086
1125
|
}
|
|
1087
|
-
|
|
1088
|
-
//#region src/cli/resolve.ts
|
|
1126
|
+
// src/cli/resolve.ts
|
|
1089
1127
|
/**
|
|
1090
1128
|
* Registry resolution: from the names a user typed to the ordered list of items to copy and the
|
|
1091
1129
|
* npm packages they need. Pure: no filesystem, no network.
|
|
@@ -1242,13 +1280,13 @@ function dependentsOf(index, name, candidates) {
|
|
|
1242
1280
|
}
|
|
1243
1281
|
return found.sort();
|
|
1244
1282
|
}
|
|
1245
|
-
|
|
1246
|
-
//#region src/cli/rewrite.ts
|
|
1283
|
+
// src/cli/rewrite.ts
|
|
1247
1284
|
/**
|
|
1248
1285
|
* Maps one import specifier from the registry layout to the project's aliases.
|
|
1249
1286
|
*
|
|
1250
1287
|
* @param specifier An import specifier such as `@/components/ui/button`.
|
|
1251
1288
|
* @param aliases The aliases from `components.json`.
|
|
1289
|
+
* @param options Rewrites besides the aliases (relative imports between ui components).
|
|
1252
1290
|
* @returns The rewritten specifier, or the input unchanged when it is not a registry alias.
|
|
1253
1291
|
* @example
|
|
1254
1292
|
* rewriteSpecifier("@/lib/utils", { ...aliases, utils: "~/helpers/cn" }); // "~/helpers/cn"
|
|
@@ -1256,9 +1294,12 @@ function dependentsOf(index, name, candidates) {
|
|
|
1256
1294
|
*
|
|
1257
1295
|
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
1258
1296
|
*/
|
|
1259
|
-
function rewriteSpecifier(specifier, aliases) {
|
|
1297
|
+
function rewriteSpecifier(specifier, aliases, options = {}) {
|
|
1260
1298
|
if (specifier === "@/lib/utils") return aliases.utils;
|
|
1261
|
-
if (specifier.startsWith("@/components/ui/"))
|
|
1299
|
+
if (specifier.startsWith("@/components/ui/")) {
|
|
1300
|
+
const rest = specifier.slice(16);
|
|
1301
|
+
return options.relativeUi === true ? `./${rest}` : `${aliases.ui}/${rest}`;
|
|
1302
|
+
}
|
|
1262
1303
|
if (specifier.startsWith("@/hooks/")) return `${aliases.hooks}/${specifier.slice(8)}`;
|
|
1263
1304
|
if (specifier.startsWith("@/lib/")) return `${aliases.lib}/${specifier.slice(6)}`;
|
|
1264
1305
|
return specifier;
|
|
@@ -1271,6 +1312,7 @@ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(@\/[^"'\n]+)\2
|
|
|
1271
1312
|
*
|
|
1272
1313
|
* @param source A registry file's text.
|
|
1273
1314
|
* @param aliases The aliases from `components.json`.
|
|
1315
|
+
* @param options Rewrites besides the aliases (relative imports between ui components).
|
|
1274
1316
|
* @returns The text with rewritten specifiers.
|
|
1275
1317
|
* @example
|
|
1276
1318
|
* rewriteImports('import { cn } from "@/lib/utils";', aliases);
|
|
@@ -1278,11 +1320,10 @@ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])(@\/[^"'\n]+)\2
|
|
|
1278
1320
|
*
|
|
1279
1321
|
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
1280
1322
|
*/
|
|
1281
|
-
function rewriteImports(source, aliases) {
|
|
1282
|
-
return source.replace(SPECIFIER, (_match, lead, quote, specifier) => `${lead}${quote}${rewriteSpecifier(specifier, aliases)}${quote}`);
|
|
1323
|
+
function rewriteImports(source, aliases, options = {}) {
|
|
1324
|
+
return source.replace(SPECIFIER, (_match, lead, quote, specifier) => `${lead}${quote}${rewriteSpecifier(specifier, aliases, options)}${quote}`);
|
|
1283
1325
|
}
|
|
1284
|
-
|
|
1285
|
-
//#region src/cli/commands.ts
|
|
1326
|
+
// src/cli/commands.ts
|
|
1286
1327
|
/**
|
|
1287
1328
|
* The CLI commands. Each takes a {@link CliContext} (filesystem, registry, output, prompt and
|
|
1288
1329
|
* installer are parameters), so tests run them against a temp directory with a recording
|
|
@@ -1341,12 +1382,26 @@ function targetFor(item, file, config, mappings, ctx, pathOverride) {
|
|
|
1341
1382
|
if (item.type === "lib" && item.name === "utils") return resolveInsideReal(ctx.fs, ctx.cwd, `${aliasToDirectory(config.aliases.utils, mappings)}${extname(base)}`);
|
|
1342
1383
|
return resolveInsideReal(ctx.fs, ctx.cwd, join(directoryFor(item, config, mappings, ctx, pathOverride), base));
|
|
1343
1384
|
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Whether ui components written with `--path` need relative imports between them: they do when that directory is not the one the
|
|
1387
|
+
* `ui` alias names (the alias would point at a directory that does not hold them), or when the alias names no directory at all.
|
|
1388
|
+
*/
|
|
1389
|
+
function importsBetweenUiAreRelative(ctx, config, mappings, pathOverride) {
|
|
1390
|
+
if (pathOverride === void 0) return false;
|
|
1391
|
+
try {
|
|
1392
|
+
return resolve(aliasToDirectory(config.aliases.ui, mappings)) !== resolve(ctx.cwd, pathOverride);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
if (error instanceof PathError) return true;
|
|
1395
|
+
throw error;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1344
1398
|
/** Builds the per-file plan for the items, reading what is on disk. */
|
|
1345
1399
|
function planFiles(ctx, config, items, pathOverride, mappings = readPathMappings(ctx.fs, ctx.cwd)) {
|
|
1346
1400
|
const plans = [];
|
|
1401
|
+
const relativeUi = importsBetweenUiAreRelative(ctx, config, mappings, pathOverride);
|
|
1347
1402
|
for (const item of items) for (const file of item.files) {
|
|
1348
1403
|
const raw = ctx.registry.readFile(file.path);
|
|
1349
|
-
const content = file.path.endsWith(".css") ? raw : rewriteImports(raw, config.aliases);
|
|
1404
|
+
const content = file.path.endsWith(".css") ? raw : rewriteImports(raw, config.aliases, { relativeUi: relativeUi && item.type === "ui" });
|
|
1350
1405
|
const target = targetFor(item, file, config, mappings, ctx, pathOverride);
|
|
1351
1406
|
const current = ctx.fs.read(target);
|
|
1352
1407
|
plans.push({
|
|
@@ -1823,8 +1878,7 @@ function runRemove(ctx, names, flags) {
|
|
|
1823
1878
|
return failed(ctx, error);
|
|
1824
1879
|
}
|
|
1825
1880
|
}
|
|
1826
|
-
|
|
1827
|
-
//#region src/cli/completion.ts
|
|
1881
|
+
// src/cli/completion.ts
|
|
1828
1882
|
/**
|
|
1829
1883
|
* Shell completion scripts for the `shadow-ui` command. Each script is static text built from the
|
|
1830
1884
|
* commands and flags it is given. It never calls back into the CLI, so it cannot complete
|
|
@@ -1935,8 +1989,7 @@ function fishScript(commands, flags) {
|
|
|
1935
1989
|
...flags.map((flag) => `complete -c shadow-ui -l ${flag}`)
|
|
1936
1990
|
].join("\n");
|
|
1937
1991
|
}
|
|
1938
|
-
|
|
1939
|
-
//#region src/cli/main.ts
|
|
1992
|
+
// src/cli/main.ts
|
|
1940
1993
|
/**
|
|
1941
1994
|
* Argument parsing and dispatch for the `shadow-ui` command. `main` takes its context as a
|
|
1942
1995
|
* parameter, so it is tested without spawning a process.
|
|
@@ -2169,8 +2222,43 @@ async function dispatch(ctx, command, rest, values, common) {
|
|
|
2169
2222
|
}
|
|
2170
2223
|
}
|
|
2171
2224
|
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2225
|
+
// src/cli/prompt.ts
|
|
2226
|
+
/**
|
|
2227
|
+
* The yes/no question of the executable, kept apart from `bin.ts` (which runs on import) so a test
|
|
2228
|
+
* can drive it with in-memory streams.
|
|
2229
|
+
* @packageDocumentation
|
|
2230
|
+
*/
|
|
2231
|
+
/**
|
|
2232
|
+
* Asks a yes/no question and resolves `true` only for `y` or `yes` (any case). An empty answer is
|
|
2233
|
+
* no, and so is the end of input (Ctrl-D, a closed pipe): readline never settles `question()` when
|
|
2234
|
+
* its input ends, which left Node to exit with code 13 ("unsettled top-level await").
|
|
2235
|
+
*
|
|
2236
|
+
* @param question The question, without the `(y/N)` suffix.
|
|
2237
|
+
* @param input The stream the answer is read from.
|
|
2238
|
+
* @param output The stream the question is written to.
|
|
2239
|
+
* @returns Whether the user said yes.
|
|
2240
|
+
* @example
|
|
2241
|
+
* const yes = await askYesNo("Install packages?", process.stdin, process.stdout);
|
|
2242
|
+
* @since 1.0.0
|
|
2243
|
+
*
|
|
2244
|
+
* @internal Not exported from `@shadow-js/ui`: part of the shadow-ui command, whose public surface is the CLI.
|
|
2245
|
+
*/
|
|
2246
|
+
async function askYesNo(question, input, output) {
|
|
2247
|
+
const prompt = createInterface({
|
|
2248
|
+
input,
|
|
2249
|
+
output
|
|
2250
|
+
});
|
|
2251
|
+
try {
|
|
2252
|
+
const answer = await new Promise((settle, fail) => {
|
|
2253
|
+
prompt.once("close", () => settle(""));
|
|
2254
|
+
prompt.question(`${question} (y/N) `).then(settle, fail);
|
|
2255
|
+
});
|
|
2256
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
2257
|
+
} finally {
|
|
2258
|
+
prompt.close();
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
// src/cli/registry-source.ts
|
|
2174
2262
|
/**
|
|
2175
2263
|
* Where the CLI reads the registry from. The registry ships inside the package, so nothing is
|
|
2176
2264
|
* downloaded: `registry.json` plus the files under `registry/` and `styles/`.
|
|
@@ -2243,8 +2331,7 @@ function loadRegistrySource(root = packageRoot()) {
|
|
|
2243
2331
|
return existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
2244
2332
|
});
|
|
2245
2333
|
}
|
|
2246
|
-
|
|
2247
|
-
//#region src/cli/bin.ts
|
|
2334
|
+
// src/cli/bin.ts
|
|
2248
2335
|
/**
|
|
2249
2336
|
* The `shadow-ui` executable: wires the real filesystem, terminal and package manager into
|
|
2250
2337
|
* {@link main}. Kept tiny; everything testable lives in the other modules.
|
|
@@ -2254,21 +2341,12 @@ function realContext(cwd) {
|
|
|
2254
2341
|
cwd: resolve(cwd ?? process.cwd()),
|
|
2255
2342
|
fs: nodeFs(),
|
|
2256
2343
|
registry: loadRegistrySource(),
|
|
2257
|
-
version: "1.0.
|
|
2344
|
+
version: "1.0.2",
|
|
2258
2345
|
out: (line) => process.stdout.write(`${line}\n`),
|
|
2259
2346
|
err: (line) => process.stderr.write(`${line}\n`),
|
|
2260
2347
|
async confirm(question) {
|
|
2261
2348
|
if (!process.stdin.isTTY) return false;
|
|
2262
|
-
|
|
2263
|
-
input: process.stdin,
|
|
2264
|
-
output: process.stdout
|
|
2265
|
-
});
|
|
2266
|
-
try {
|
|
2267
|
-
const answer = await prompt.question(`${question} (y/N) `);
|
|
2268
|
-
return /^y(es)?$/i.test(answer.trim());
|
|
2269
|
-
} finally {
|
|
2270
|
-
prompt.close();
|
|
2271
|
-
}
|
|
2349
|
+
return askYesNo(question, process.stdin, process.stdout);
|
|
2272
2350
|
},
|
|
2273
2351
|
install: ({ cwd: directory, manager, packages, output }) => new Promise((done, reject) => {
|
|
2274
2352
|
const args = manager === "npm" ? ["install", ...packages] : ["add", ...packages];
|
|
@@ -2289,5 +2367,4 @@ function realContext(cwd) {
|
|
|
2289
2367
|
const argv = process.argv.slice(2);
|
|
2290
2368
|
if (wantsJson(argv)) installCrashHandlers(argv);
|
|
2291
2369
|
process.exitCode = await main(argv, realContext);
|
|
2292
|
-
//#endregion
|
|
2293
2370
|
export {};
|