@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/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 { createInterface } from "node:readline/promises";
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
- //#region src/cli/fs.ts
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
- const tail = [];
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 edit script turning `a` into `b`; one big replacement when the texts are too large. */
214
- function edits(a, b) {
215
- const n = a.length;
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
- let frontier = new Int32Array(2 * max + 2);
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
- trace.push(frontier.slice());
231
- const next = frontier.slice();
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
- next[offset + k] = x;
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] ?? /* @__PURE__ */ new Int32Array(0);
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 = previous[offset + k - 1] ?? 0;
258
- const above = previous[offset + k + 1] ?? 0;
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 = previous[offset + previousK] ?? 0;
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
- //#endregion
375
- //#region src/cli/paths.ts
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
- const options = asRecord(asRecord(parseJsonc(text))?.["compilerOptions"]);
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
- //#endregion
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
- //#endregion
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
- //#endregion
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
- //#endregion
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/")) return `${aliases.ui}/${specifier.slice(16)}`;
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
- //#endregion
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
- //#endregion
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
- //#endregion
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
- //#endregion
2173
- //#region src/cli/registry-source.ts
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
- //#endregion
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.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
- const prompt = createInterface({
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 {};