shadcn-nsh 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -15,7 +15,7 @@ Default registry: `https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/pu
15
15
 
16
16
  | Command | What it does |
17
17
  | ------------------- | -------------------------------------------------------------------- |
18
- | `init` | Writes `nsh-ui.json`, installs `theme`, points each `nsh.json` at it |
18
+ | `init` | Writes `nsh-ui.json`, installs a theme, points each `nsh.json` at it |
19
19
  | `add [components…]` | Copies items and their `registryDependencies` into the app |
20
20
  | `list` | The registry's items, `✓` on the ones the app has |
21
21
  | `view <items…>` | The files `add` would write: target paths and contents |
@@ -26,7 +26,8 @@ Default registry: `https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/pu
26
26
  Every command takes `-c, --cwd <dir>` (default: the current directory). `init`, `add`, `list`,
27
27
  `view` and `diff` also take `--registry <url>`, a template with `{name}` in it; without it, `add`,
28
28
  `list`, `view` and `diff` use the one in `nsh-ui.json`, and `init` uses the public registry.
29
- `init` also takes `--overwrite` and `-y` (accepted; nothing prompts today).
29
+ `init` also takes `--overwrite`, `-y` (accepted; nothing prompts today) and `--theme <name>`,
30
+ `default` (the library's own) or `shadcn` (shadcn's neutral palette and flat finishes).
30
31
 
31
32
  ## add
32
33
 
@@ -43,6 +44,11 @@ shadcn-nsh add button --overwrite
43
44
  - `theme.json` is merged by key: the app keeps its colors and gains the tokens it lacks. A
44
45
  recolored theme is kept as it is (`kept theme.json (the app's colors stay)`).
45
46
  - `--all` (`-a`) adds every `registry:ui` item the catalog lists, dependencies included.
47
+ - Naming a theme (`add theme-shadcn`, or `add theme` to go back) switches the app's theme: its
48
+ files are written where they differ and `nsh-ui.json`'s `theme` records the new one. Naming the
49
+ theme the app already has is a plain `add`: its colors stay unless `--overwrite`.
50
+ - After that, `theme` as a dependency resolves to the theme the app has, so `add button`,
51
+ `add button --overwrite` and `add --all --overwrite` never write the default theme back.
46
52
  - `--dry-run` prints the same lines as `would write …` / `would overwrite …` and writes nothing —
47
53
  not even `nsh-ui.json` or the theme `init` would install first.
48
54
 
@@ -80,6 +86,7 @@ recolored theme is the app's own, and only `add --overwrite` replaces it.
80
86
  ```json
81
87
  {
82
88
  "registry": "https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json",
89
+ "theme": "theme",
83
90
  "aliases": {
84
91
  "components": "./components",
85
92
  "ui": "./components/ui",
@@ -89,6 +96,9 @@ recolored theme is the app's own, and only `add --overwrite` replaces it.
89
96
  }
90
97
  ```
91
98
 
99
+ `theme` is the `registry:theme` item the app installed (`theme`, or `theme-shadcn`); a file
100
+ without it is on `theme`.
101
+
92
102
  In a monorepo (`apps/*` + `packages/ui`) it lives in `packages/ui` and `add -c apps/web`
93
103
  installs there.
94
104
 
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@ bunx shadcn-nsh@latest add button
15
15
 
16
16
  | 命令 | 作用 |
17
17
  | ------------------- | ---------------------------------------------------------- |
18
- | `init` | 写 `nsh-ui.json`、装 `theme`、把每个 `nsh.json` 指向它 |
18
+ | `init` | 写 `nsh-ui.json`、装一个主题、把每个 `nsh.json` 指向它 |
19
19
  | `add [components…]` | 复制条目及其 `registryDependencies` |
20
20
  | `list` | registry 的全部条目,应用已有的标 `✓` |
21
21
  | `view <items…>` | `add` 会写的文件:目标路径与内容 |
@@ -25,7 +25,8 @@ bunx shadcn-nsh@latest add button
25
25
 
26
26
  每个命令都接受 `-c, --cwd <dir>`(默认:当前目录)。`init`、`add`、`list`、`view`、`diff`
27
27
  另接受 `--registry <url>`(含 `{name}` 的模板);省略时 `add`、`list`、`view`、`diff` 用
28
- `nsh-ui.json` 里的,`init` 用公共 registry。`init` 还接受 `--overwrite` 与 `-y`(接受,当前不弹确认)。
28
+ `nsh-ui.json` 里的,`init` 用公共 registry。`init` 还接受 `--overwrite`、`-y`(接受,当前不弹确认)与 `--theme <name>`:
29
+ `default`(本库自己的主题)或 `shadcn`(shadcn 的 neutral 配色与平面 finish)。
29
30
 
30
31
  ## add
31
32
 
@@ -42,6 +43,11 @@ shadcn-nsh add button --overwrite
42
43
  - `theme.json` 按 key 合并:应用保留自己的颜色,补上缺的 token;改过颜色的主题原样保留
43
44
  (`kept theme.json (the app's colors stay)`)。
44
45
  - `--all`(`-a`)加全部 `registry:ui` 条目(含依赖)。
46
+ - 点名一个主题(`add theme-shadcn`,`add theme` 是换回默认)就是换主题:文件写到应用里,
47
+ `nsh-ui.json` 的 `theme` 记下新的那个。点名应用已有的主题只是普通的 `add`:不加 `--overwrite`
48
+ 就保留应用的配色。
49
+ - 此后 `theme` 这个依赖按应用的主题解析,所以 `add button`、`add button --overwrite`、
50
+ `add --all --overwrite` 都不会把默认主题写回来。
45
51
  - `--dry-run` 用 `would write …` / `would overwrite …` 打印同样的行,一个字节都不写——
46
52
  包括 `init` 会先写的 `nsh-ui.json` 与主题。
47
53
 
@@ -77,6 +83,7 @@ shadcn-nsh diff || echo "run shadcn-nsh add <item> --overwrite"
77
83
  ```json
78
84
  {
79
85
  "registry": "https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json",
86
+ "theme": "theme",
80
87
  "aliases": {
81
88
  "components": "./components",
82
89
  "ui": "./components/ui",
@@ -86,6 +93,9 @@ shadcn-nsh diff || echo "run shadcn-nsh add <item> --overwrite"
86
93
  }
87
94
  ```
88
95
 
96
+ `theme` 是应用装的 `registry:theme` 条目(`theme`,或 `theme-shadcn`);文件里没有它就是
97
+ `theme`。
98
+
89
99
  monorepo(`apps/*` + `packages/ui`)里它位于 `packages/ui`,`add -c apps/web` 装到那里。
90
100
 
91
101
  ## 本仓开发
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { Command as Command8 } from "commander";
6
6
  // package.json
7
7
  var package_default = {
8
8
  name: "shadcn-nsh",
9
- version: "0.2.0",
9
+ version: "0.3.0",
10
10
  description: "Add styled NSH components (copy-into-app) from the shadcn-nsh registry",
11
11
  keywords: [
12
12
  "cli",
@@ -52,13 +52,27 @@ var package_default = {
52
52
  };
53
53
 
54
54
  // src/commands/add.ts
55
- import path10 from "path";
55
+ import path11 from "path";
56
56
  import { Command as Command2 } from "commander";
57
57
 
58
58
  // src/types.ts
59
59
  var DEFAULT_REGISTRY = "https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json";
60
+ var DEFAULT_THEME = "theme";
61
+ var THEMES = {
62
+ default: DEFAULT_THEME,
63
+ shadcn: "theme-shadcn"
64
+ };
65
+ var isThemeName = (value) => value in THEMES;
66
+ function themeItem(name) {
67
+ if (!isThemeName(name)) {
68
+ const options = Object.keys(THEMES).join(", ");
69
+ throw new Error(`Unknown theme "${name}". Choose one of: ${options}.`);
70
+ }
71
+ return THEMES[name];
72
+ }
60
73
  var DEFAULT_CONFIG = () => ({
61
74
  registry: DEFAULT_REGISTRY,
75
+ theme: DEFAULT_THEME,
62
76
  aliases: {
63
77
  components: "./components",
64
78
  ui: "./components/ui",
@@ -194,9 +208,223 @@ async function readApp(cwd, registry) {
194
208
  return { ws, config, hasConfig: stored != null };
195
209
  }
196
210
 
197
- // src/utils/install.ts
211
+ // src/utils/deps.ts
212
+ import { spawnSync } from "child_process";
198
213
  import fs4 from "fs/promises";
199
- import path7 from "path";
214
+ import path5 from "path";
215
+
216
+ // src/utils/registry.ts
217
+ import fs3 from "fs/promises";
218
+ import path4 from "path";
219
+ function itemUrl(config, name, registryOverride) {
220
+ const template = registryOverride ?? config.registry;
221
+ if (!template.includes("{name}")) {
222
+ throw new Error(`Registry URL must contain {name}: ${template}`);
223
+ }
224
+ return template.replaceAll("{name}", name);
225
+ }
226
+ function indexUrl(config, registryOverride) {
227
+ const template = registryOverride ?? config.registry;
228
+ if (!template.includes("{name}")) {
229
+ throw new Error(`Registry URL must contain {name}: ${template}`);
230
+ }
231
+ return template.replaceAll("{name}", "index");
232
+ }
233
+ async function fetchItem(config, name, registryOverride) {
234
+ const url = itemUrl(config, name, registryOverride);
235
+ const res = await fetch(url);
236
+ if (!res.ok) {
237
+ throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
238
+ }
239
+ return await res.json();
240
+ }
241
+ async function fetchIndex(config, registryOverride) {
242
+ const url = indexUrl(config, registryOverride);
243
+ const res = await fetch(url);
244
+ if (!res.ok) {
245
+ throw new Error(
246
+ `Failed to fetch ${url}: ${res.status} ${res.statusText}. A registry needs an index.json (bun run registry:build writes one).`
247
+ );
248
+ }
249
+ return await res.json();
250
+ }
251
+ function resolveDependency(config, name) {
252
+ return name === DEFAULT_THEME && config.theme !== DEFAULT_THEME ? config.theme : name;
253
+ }
254
+ async function resolveTree(config, names, registryOverride) {
255
+ const seen = /* @__PURE__ */ new Set();
256
+ const out = [];
257
+ async function walk(name) {
258
+ if (seen.has(name)) return;
259
+ seen.add(name);
260
+ const item = await fetchItem(config, name, registryOverride);
261
+ for (const dep of item.registryDependencies ?? []) {
262
+ await walk(resolveDependency(config, dep));
263
+ }
264
+ out.push(item);
265
+ }
266
+ for (const name of names) {
267
+ await walk(name);
268
+ }
269
+ return out;
270
+ }
271
+ var IMPORT_RE = /(?:import|export)\s[^;]*?from\s+["']([^"']+)["']|import\s+["']([^"']+)["']/g;
272
+ var THEME_PACKAGE = "@shadcn-nsh/theme";
273
+ var RESOLVE_SUFFIXES = ["", ".ts", ".tsx", ".json", "/index.ts", "/index.tsx"];
274
+ var fromRuntime = (spec) => spec === "nsh" || spec.startsWith("nsh/");
275
+ function packageOf(spec) {
276
+ const parts = spec.split("/");
277
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] ?? spec;
278
+ }
279
+ function declaredPackage(entry) {
280
+ const at = entry.indexOf("@", 1);
281
+ return at === -1 ? entry : entry.slice(0, at);
282
+ }
283
+ async function checkRegistryDependencies(registry, baseDir) {
284
+ const owner = /* @__PURE__ */ new Map();
285
+ const targets = /* @__PURE__ */ new Map();
286
+ for (const item of registry.items) {
287
+ const own = /* @__PURE__ */ new Set();
288
+ for (const file of item.files) {
289
+ owner.set(path4.resolve(baseDir, file.path), item.name);
290
+ if (file.target) own.add(file.target);
291
+ }
292
+ targets.set(item.name, own);
293
+ }
294
+ const themeItems = new Set(
295
+ registry.items.filter((item) => item.type === "registry:theme").map((item) => item.name)
296
+ );
297
+ const hasTheme = themeItems.has(DEFAULT_THEME);
298
+ const problems = [];
299
+ for (const item of registry.items) {
300
+ const used = /* @__PURE__ */ new Set();
301
+ const own = targets.get(item.name) ?? /* @__PURE__ */ new Set();
302
+ const packages = /* @__PURE__ */ new Set();
303
+ for (const file of item.files) {
304
+ const abs = path4.resolve(baseDir, file.path);
305
+ if (!/\.(ts|tsx)$/.test(abs)) continue;
306
+ const source = await fs3.readFile(abs, "utf8");
307
+ for (const match of source.matchAll(IMPORT_RE)) {
308
+ const spec = match[1] ?? match[2];
309
+ if (spec === THEME_PACKAGE && (hasTheme || themeItems.has(item.name))) {
310
+ used.add(themeItems.has(item.name) ? item.name : DEFAULT_THEME);
311
+ } else if (spec.startsWith(".")) {
312
+ const beside = file.target ? RESOLVE_SUFFIXES.map(
313
+ (suffix) => `${path4.posix.normalize(path4.posix.join(path4.posix.dirname(file.target), spec))}${suffix}`
314
+ ) : [];
315
+ if (beside.some((candidate) => own.has(candidate))) continue;
316
+ const target = RESOLVE_SUFFIXES.map(
317
+ (x) => path4.resolve(path4.dirname(abs), spec) + x
318
+ ).find((candidate) => owner.has(candidate));
319
+ if (target) used.add(owner.get(target));
320
+ else problems.push(`${item.name}: imports "${spec}", which no registry item ships`);
321
+ } else if (spec !== THEME_PACKAGE && !fromRuntime(spec)) packages.add(packageOf(spec));
322
+ }
323
+ }
324
+ used.delete(item.name);
325
+ const declared = new Set(item.registryDependencies ?? []);
326
+ for (const dep of used) {
327
+ if (!declared.has(dep))
328
+ problems.push(`${item.name}: imports "${dep}" but does not declare it`);
329
+ }
330
+ for (const dep of declared) {
331
+ if (!used.has(dep)) problems.push(`${item.name}: declares "${dep}" but never imports it`);
332
+ }
333
+ const installed = new Map(
334
+ (item.dependencies ?? []).map((entry) => [declaredPackage(entry), entry])
335
+ );
336
+ for (const name of packages) {
337
+ if (!installed.has(name))
338
+ problems.push(`${item.name}: imports "${name}" but does not declare it in dependencies`);
339
+ }
340
+ for (const [name, entry] of installed) {
341
+ if (!packages.has(name))
342
+ problems.push(`${item.name}: declares "${entry}" but never imports it`);
343
+ }
344
+ }
345
+ return problems;
346
+ }
347
+
348
+ // src/utils/deps.ts
349
+ var LOCKFILES = [
350
+ ["bun.lock", "bun"],
351
+ ["pnpm-lock.yaml", "pnpm"],
352
+ ["yarn.lock", "yarn"]
353
+ ];
354
+ async function exists2(file) {
355
+ try {
356
+ await fs4.access(file);
357
+ return true;
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+ async function projectOf(dir) {
363
+ const start = path5.resolve(dir);
364
+ let root;
365
+ let manager;
366
+ for (let at = start; ; at = path5.dirname(at)) {
367
+ for (const [lock, name] of LOCKFILES) {
368
+ if (manager == null && await exists2(path5.join(at, lock))) manager = name;
369
+ }
370
+ if (root == null && await exists2(path5.join(at, "package.json"))) root = at;
371
+ if (manager != null || path5.dirname(at) === at) {
372
+ return { root: root ?? start, manager: manager ?? "npm" };
373
+ }
374
+ }
375
+ }
376
+ function dependenciesOf(items) {
377
+ const packages = /* @__PURE__ */ new Map();
378
+ for (const item of items) {
379
+ for (const entry of item.dependencies ?? []) {
380
+ const name = declaredPackage(entry);
381
+ if (!packages.has(name)) packages.set(name, entry);
382
+ }
383
+ }
384
+ return [...packages.values()];
385
+ }
386
+ function packageName(dir) {
387
+ const name = path5.basename(dir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "");
388
+ return name === "" ? "app" : name;
389
+ }
390
+ async function ensurePackageJson(dir) {
391
+ const file = path5.join(dir, "package.json");
392
+ if (await exists2(file)) return null;
393
+ const content = { name: packageName(dir), private: true, dependencies: {} };
394
+ await fs4.writeFile(file, `${JSON.stringify(content, null, 2)}
395
+ `, "utf8");
396
+ return file;
397
+ }
398
+ async function installDependencies(dir, items, opts = {}) {
399
+ const dependencies = dependenciesOf(items);
400
+ if (dependencies.length === 0) return null;
401
+ const { root, manager } = await projectOf(dir);
402
+ const command = [manager, "add", ...dependencies];
403
+ const shown = command.join(" ");
404
+ const install = { manager, root, command: shown, ok: true };
405
+ if (opts.dryRun) {
406
+ console.log(`would install ${dependencies.join(" ")} (${shown})`);
407
+ return install;
408
+ }
409
+ const written = await ensurePackageJson(root);
410
+ if (written != null) {
411
+ console.log(`write ${path5.relative(path5.resolve(dir), written) || "package.json"}`);
412
+ }
413
+ const result = spawnSync(manager, command.slice(1), { cwd: root, stdio: "inherit" });
414
+ const failed = result.error ?? (result.status === 0 ? null : `exited ${result.status}`);
415
+ if (failed == null) {
416
+ console.log(`installed ${dependencies.join(" ")} (${shown})`);
417
+ return install;
418
+ }
419
+ const why = failed instanceof Error ? failed.message : failed;
420
+ console.error(`shadcn-nsh: \`${shown}\` failed in ${root}: ${why}`);
421
+ console.error(` run it yourself: cd ${root} && ${shown}`);
422
+ return { ...install, ok: false };
423
+ }
424
+
425
+ // src/utils/install.ts
426
+ import fs6 from "fs/promises";
427
+ import path9 from "path";
200
428
 
201
429
  // src/utils/diff.ts
202
430
  function diffLines(before, after) {
@@ -249,25 +477,25 @@ function formatDiff(lines) {
249
477
  }
250
478
 
251
479
  // src/utils/plan.ts
252
- import fs3 from "fs";
480
+ import fs5 from "fs";
253
481
  import fsPromises from "fs/promises";
254
- import path6 from "path";
482
+ import path8 from "path";
255
483
 
256
484
  // src/utils/paths.ts
257
- import path4 from "path";
485
+ import path6 from "path";
258
486
  function resolveAlias(cwd, alias) {
259
- return path4.resolve(cwd, alias);
487
+ return path6.resolve(cwd, alias);
260
488
  }
261
489
  function relativeImport(fromFile, toModuleFile) {
262
- let rel = path4.relative(path4.dirname(fromFile), toModuleFile);
490
+ let rel = path6.relative(path6.dirname(fromFile), toModuleFile);
263
491
  if (!rel.startsWith(".")) rel = `./${rel}`;
264
492
  return rel.replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "");
265
493
  }
266
494
  function defaultTarget(file, aliases) {
267
495
  if (file.target) return file.target;
268
- const base = path4.basename(file.path);
269
- if (file.type === "registry:ui") return path4.posix.join(aliases.ui.replace(/^\.\//, ""), base);
270
- if (file.type === "registry:lib") return path4.posix.join(aliases.lib.replace(/^\.\//, ""), base);
496
+ const base = path6.basename(file.path);
497
+ if (file.type === "registry:ui") return path6.posix.join(aliases.ui.replace(/^\.\//, ""), base);
498
+ if (file.type === "registry:lib") return path6.posix.join(aliases.lib.replace(/^\.\//, ""), base);
271
499
  return base;
272
500
  }
273
501
 
@@ -293,7 +521,7 @@ function mergeTheme(existing, incoming) {
293
521
  }
294
522
 
295
523
  // src/utils/transform.ts
296
- import path5 from "path";
524
+ import path7 from "path";
297
525
  function transformContent(content, absoluteTargetFile, config, cwd) {
298
526
  const themeModule = resolveAlias(cwd, config.aliases.theme);
299
527
  const themeImport = relativeImport(absoluteTargetFile, themeModule);
@@ -301,13 +529,13 @@ function transformContent(content, absoluteTargetFile, config, cwd) {
301
529
  const rewritten = content.replaceAll(`from "@shadcn-nsh/theme"`, `from "${themeImport}"`).replaceAll(`from '@shadcn-nsh/theme'`, `from '${themeImport}'`);
302
530
  return rewritten.replace(
303
531
  /from (["'])\.\/lib\/([\w-]+)\1/g,
304
- (_match, quote, name) => `from ${quote}${relativeImport(absoluteTargetFile, path5.join(libDir, name))}${quote}`
532
+ (_match, quote, name) => `from ${quote}${relativeImport(absoluteTargetFile, path7.join(libDir, name))}${quote}`
305
533
  );
306
534
  }
307
535
 
308
536
  // src/utils/plan.ts
309
537
  function isThemeFile(file) {
310
- return path6.basename(file.absolute) === "theme.json";
538
+ return path8.basename(file.absolute) === "theme.json";
311
539
  }
312
540
  function hasChange(file) {
313
541
  if (file.status === "merge") return true;
@@ -330,18 +558,19 @@ function themeMerge(existing, incoming) {
330
558
  return null;
331
559
  }
332
560
  }
333
- async function planItems(uiRoot, config, items) {
561
+ async function planItems(uiRoot, config, items, opts = {}) {
334
562
  const planned = [];
563
+ const adopted = new Set(opts.adopt ?? []);
335
564
  for (const item of items) {
336
565
  for (const file of item.files) {
337
566
  if (file.content == null) {
338
567
  throw new Error(`Item ${item.name} file ${file.path} has no content (run registry:build)`);
339
568
  }
340
569
  const target = defaultTarget(file, config.aliases);
341
- const absolute = path6.isAbsolute(target) ? target : path6.resolve(uiRoot, target);
570
+ const absolute = path8.isAbsolute(target) ? target : path8.resolve(uiRoot, target);
342
571
  const content = transformContent(file.content, absolute, config, uiRoot);
343
- const existing = await readOrNull(absolute);
344
- const relative = path6.relative(uiRoot, absolute) || path6.basename(absolute);
572
+ const existing = adopted.has(item.name) ? null : await readOrNull(absolute);
573
+ const relative = path8.relative(uiRoot, absolute) || path8.basename(absolute);
345
574
  const entry = {
346
575
  item: item.name,
347
576
  file,
@@ -356,7 +585,7 @@ async function planItems(uiRoot, config, items) {
356
585
  if (existing != null) {
357
586
  if (existing === content) {
358
587
  entry.status = "same";
359
- } else if (path6.basename(absolute) === "theme.json") {
588
+ } else if (path8.basename(absolute) === "theme.json") {
360
589
  const merged2 = themeMerge(existing, content);
361
590
  if (merged2 && merged2.added > 0) {
362
591
  entry.status = "merge";
@@ -381,13 +610,13 @@ async function planItems(uiRoot, config, items) {
381
610
  function isInstalled(uiRoot, config, item) {
382
611
  return (item.files ?? []).some((file) => {
383
612
  const target = defaultTarget(file, config.aliases);
384
- return fs3.existsSync(path6.isAbsolute(target) ? target : path6.resolve(uiRoot, target));
613
+ return fs5.existsSync(path8.isAbsolute(target) ? target : path8.resolve(uiRoot, target));
385
614
  });
386
615
  }
387
616
 
388
617
  // src/utils/install.ts
389
618
  async function installItems(uiRoot, config, items, opts = {}) {
390
- const planned = await planItems(uiRoot, config, items);
619
+ const planned = await planItems(uiRoot, config, items, opts);
391
620
  const written = [];
392
621
  for (const file of planned) {
393
622
  const shown = file.path;
@@ -431,8 +660,8 @@ async function installItems(uiRoot, config, items, opts = {}) {
431
660
  return { planned, written };
432
661
  }
433
662
  async function write(file, content) {
434
- await fs4.mkdir(path7.dirname(file.absolute), { recursive: true });
435
- await fs4.writeFile(file.absolute, content, "utf8");
663
+ await fs6.mkdir(path9.dirname(file.absolute), { recursive: true });
664
+ await fs6.writeFile(file.absolute, content, "utf8");
436
665
  }
437
666
  async function merged(file) {
438
667
  if (file.existing == null) return null;
@@ -448,105 +677,12 @@ async function merged(file) {
448
677
  }
449
678
  }
450
679
 
451
- // src/utils/registry.ts
452
- import fs5 from "fs/promises";
453
- import path8 from "path";
454
- function itemUrl(config, name, registryOverride) {
455
- const template = registryOverride ?? config.registry;
456
- if (!template.includes("{name}")) {
457
- throw new Error(`Registry URL must contain {name}: ${template}`);
458
- }
459
- return template.replaceAll("{name}", name);
460
- }
461
- function indexUrl(config, registryOverride) {
462
- const template = registryOverride ?? config.registry;
463
- if (!template.includes("{name}")) {
464
- throw new Error(`Registry URL must contain {name}: ${template}`);
465
- }
466
- return template.replaceAll("{name}", "index");
467
- }
468
- async function fetchItem(config, name, registryOverride) {
469
- const url = itemUrl(config, name, registryOverride);
470
- const res = await fetch(url);
471
- if (!res.ok) {
472
- throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
473
- }
474
- return await res.json();
475
- }
476
- async function fetchIndex(config, registryOverride) {
477
- const url = indexUrl(config, registryOverride);
478
- const res = await fetch(url);
479
- if (!res.ok) {
480
- throw new Error(
481
- `Failed to fetch ${url}: ${res.status} ${res.statusText}. A registry needs an index.json (bun run registry:build writes one).`
482
- );
483
- }
484
- return await res.json();
485
- }
486
- async function resolveTree(config, names, registryOverride) {
487
- const seen = /* @__PURE__ */ new Set();
488
- const out = [];
489
- async function walk(name) {
490
- if (seen.has(name)) return;
491
- seen.add(name);
492
- const item = await fetchItem(config, name, registryOverride);
493
- for (const dep of item.registryDependencies ?? []) {
494
- await walk(dep);
495
- }
496
- out.push(item);
497
- }
498
- for (const name of names) {
499
- await walk(name);
500
- }
501
- return out;
502
- }
503
- var IMPORT_RE = /(?:import|export)\s[^;]*?from\s+["']([^"']+)["']|import\s+["']([^"']+)["']/g;
504
- var THEME_PACKAGE = "@shadcn-nsh/theme";
505
- var RESOLVE_SUFFIXES = ["", ".ts", ".tsx", ".json", "/index.ts", "/index.tsx"];
506
- async function checkRegistryDependencies(registry, baseDir) {
507
- const owner = /* @__PURE__ */ new Map();
508
- for (const item of registry.items) {
509
- for (const file of item.files) owner.set(path8.resolve(baseDir, file.path), item.name);
510
- }
511
- const hasTheme = registry.items.some((item) => item.name === "theme");
512
- const problems = [];
513
- for (const item of registry.items) {
514
- const used = /* @__PURE__ */ new Set();
515
- for (const file of item.files) {
516
- const abs = path8.resolve(baseDir, file.path);
517
- if (!/\.(ts|tsx)$/.test(abs)) continue;
518
- const source = await fs5.readFile(abs, "utf8");
519
- for (const match of source.matchAll(IMPORT_RE)) {
520
- const spec = match[1] ?? match[2];
521
- if (spec === THEME_PACKAGE && hasTheme) used.add("theme");
522
- else if (spec.startsWith(".")) {
523
- const target = RESOLVE_SUFFIXES.map(
524
- (x) => path8.resolve(path8.dirname(abs), spec) + x
525
- ).find((candidate) => owner.has(candidate));
526
- if (target) used.add(owner.get(target));
527
- else problems.push(`${item.name}: imports "${spec}", which no registry item ships`);
528
- }
529
- }
530
- }
531
- used.delete(item.name);
532
- const declared = new Set(item.registryDependencies ?? []);
533
- for (const dep of used) {
534
- if (!declared.has(dep))
535
- problems.push(`${item.name}: imports "${dep}" but does not declare it`);
536
- }
537
- for (const dep of declared) {
538
- if (!used.has(dep)) problems.push(`${item.name}: declares "${dep}" but never imports it`);
539
- }
540
- }
541
- return problems;
542
- }
543
-
544
680
  // src/commands/init.ts
545
- import fs6 from "fs/promises";
546
- import path9 from "path";
681
+ import fs7 from "fs/promises";
682
+ import path10 from "path";
547
683
  import { Command } from "commander";
548
684
  async function runInit(opts) {
549
- const cwd = path9.resolve(opts.cwd);
685
+ const cwd = path10.resolve(opts.cwd);
550
686
  const ws = await resolveWorkspace(cwd);
551
687
  if (ws.kind === "app") {
552
688
  const nshJsonPath = await findNshJson(cwd);
@@ -562,10 +698,12 @@ async function runInit(opts) {
562
698
  }
563
699
  const config = DEFAULT_CONFIG();
564
700
  if (opts.registry) config.registry = opts.registry;
701
+ config.theme = themeItem(opts.theme ?? "default");
565
702
  await writeConfig(ws.uiRoot, config);
566
- console.log(`write ${path9.relative(cwd, path9.join(ws.uiRoot, "nsh-ui.json")) || "nsh-ui.json"}`);
567
- const items = await resolveTree(config, ["theme"], opts.registry);
703
+ console.log(`write ${path10.relative(cwd, path10.join(ws.uiRoot, "nsh-ui.json")) || "nsh-ui.json"}`);
704
+ const items = await resolveTree(config, [config.theme], opts.registry);
568
705
  await installItems(ws.uiRoot, config, items, { overwrite: opts.overwrite });
706
+ await installDependencies(ws.uiRoot, items);
569
707
  for (const appDir of ws.apps) {
570
708
  const nshJsonPath = await findNshJson(appDir);
571
709
  if (!nshJsonPath) continue;
@@ -573,13 +711,13 @@ async function runInit(opts) {
573
711
  nsh.theme = ws.kind === "monorepo" ? themePathForApp(appDir, ws.uiRoot) : "./theme.json";
574
712
  await writeJson(nshJsonPath, nsh);
575
713
  console.log(
576
- `update ${path9.relative(cwd, nshJsonPath) || "nsh.json"} theme \u2192 ${String(nsh.theme)}`
714
+ `update ${path10.relative(cwd, nshJsonPath) || "nsh.json"} theme \u2192 ${String(nsh.theme)}`
577
715
  );
578
716
  }
579
717
  if (ws.kind === "app") {
580
- const tsconfig = path9.join(cwd, "tsconfig.json");
718
+ const tsconfig = path10.join(cwd, "tsconfig.json");
581
719
  try {
582
- await fs6.access(tsconfig);
720
+ await fs7.access(tsconfig);
583
721
  } catch {
584
722
  await writeJson(tsconfig, {
585
723
  extends: "./.nsh/tsconfig.json",
@@ -590,14 +728,16 @@ async function runInit(opts) {
590
728
  }
591
729
  return config;
592
730
  }
593
- var init = new Command().name("init").description("Initialize shadcn-nsh (writes nsh-ui.json + theme; monorepo \u2192 packages/ui)").option("-c, --cwd <cwd>", "app or monorepo directory", process.cwd()).option("-y, --yes", "skip confirmation", false).option("--overwrite", "overwrite existing files", false).option("--registry <url>", "registry URL template containing {name}").action(async (opts) => {
594
- await runInit(opts);
595
- console.log("\nDone. Next: shadcn-nsh add button");
596
- });
731
+ var init = new Command().name("init").description("Initialize shadcn-nsh (writes nsh-ui.json + theme; monorepo \u2192 packages/ui)").option("-c, --cwd <cwd>", "app or monorepo directory", process.cwd()).option("-y, --yes", "skip confirmation", false).option("--overwrite", "overwrite existing files", false).option("--theme <name>", "theme to install: default (the library's own) or shadcn", "default").option("--registry <url>", "registry URL template containing {name}").action(
732
+ async (opts) => {
733
+ await runInit(opts);
734
+ console.log("\nDone. Next: shadcn-nsh add button");
735
+ }
736
+ );
597
737
 
598
738
  // src/commands/add.ts
599
739
  async function runAdd(components, opts) {
600
- const cwd = path10.resolve(opts.cwd);
740
+ const cwd = path11.resolve(opts.cwd);
601
741
  const dryRun = opts.dryRun === true;
602
742
  if (components.length === 0 && !opts.all) {
603
743
  throw new Error("Name at least one component, or pass --all.");
@@ -629,7 +769,22 @@ async function runAdd(components, opts) {
629
769
  throw new Error("The registry lists no registry:ui item to add.");
630
770
  }
631
771
  const items = await resolveTree(config, names, opts.registry);
632
- await installItems(ws.uiRoot, config, items, { overwrite: opts.overwrite, dryRun });
772
+ const themes = items.filter((item) => item.type === "registry:theme").map((item) => item.name);
773
+ const named = names.filter((name) => themes.includes(name));
774
+ if (named.length > 1) {
775
+ throw new Error(`Name one theme, not ${named.join(", ")}: an app installs one.`);
776
+ }
777
+ const theme = named[0] === config.theme ? void 0 : named[0];
778
+ if (theme != null) {
779
+ config.theme = theme;
780
+ if (!dryRun) await writeConfig(ws.uiRoot, config);
781
+ }
782
+ await installItems(ws.uiRoot, config, items, {
783
+ overwrite: opts.overwrite,
784
+ dryRun,
785
+ adopt: theme == null ? [] : [theme]
786
+ });
787
+ const installed = await installDependencies(ws.uiRoot, items, { dryRun });
633
788
  if (dryRun) {
634
789
  const count = `${names.length} item${names.length === 1 ? "" : "s"}`;
635
790
  console.log(`
@@ -642,9 +797,9 @@ Added: all ${names.length} registry:ui items` : `
642
797
  Added: ${names.join(", ")}`
643
798
  );
644
799
  if (ws.kind === "monorepo") {
645
- console.log(`(installed under ${path10.relative(ws.root, ws.uiRoot) || "packages/ui"})`);
800
+ console.log(`(installed under ${path11.relative(ws.root, ws.uiRoot) || "packages/ui"})`);
646
801
  }
647
- return 0;
802
+ return installed?.ok === false ? 1 : 0;
648
803
  }
649
804
  var add = new Command2().name("add").description("Add registry components (monorepo: installs into packages/ui)").argument("[components...]", "component names (e.g. button)").option("-c, --cwd <cwd>", "app or monorepo directory", process.cwd()).option("-o, --overwrite", "replace files the app has changed", false).option("-a, --all", "every registry:ui item the registry lists", false).option("--dry-run", "print what would be written; write nothing", false).option("--registry <url>", "registry URL template containing {name}").action(
650
805
  async (components, opts) => {
@@ -653,8 +808,8 @@ var add = new Command2().name("add").description("Add registry components (monor
653
808
  );
654
809
 
655
810
  // src/commands/build.ts
656
- import fs7 from "fs/promises";
657
- import path11 from "path";
811
+ import fs8 from "fs/promises";
812
+ import path12 from "path";
658
813
  import { Command as Command3 } from "commander";
659
814
 
660
815
  // src/utils/registry-schema.ts
@@ -736,7 +891,7 @@ function parseRegistry(value, where) {
736
891
  async function holdsItem(file, name) {
737
892
  let value;
738
893
  try {
739
- value = JSON.parse(await fs7.readFile(file, "utf8"));
894
+ value = JSON.parse(await fs8.readFile(file, "utf8"));
740
895
  } catch (error) {
741
896
  if (error instanceof SyntaxError) return false;
742
897
  throw error;
@@ -749,33 +904,33 @@ async function holdsItem(file, name) {
749
904
  }
750
905
  }
751
906
  async function runBuild(registryFile, opts) {
752
- const cwd = path11.resolve(opts.cwd);
753
- const registryPath = path11.resolve(cwd, registryFile);
754
- const outputDir = path11.resolve(cwd, opts.output);
755
- const registry = parseRegistry(JSON.parse(await fs7.readFile(registryPath, "utf8")), registryPath);
756
- const problems = await checkRegistryDependencies(registry, path11.dirname(registryPath));
907
+ const cwd = path12.resolve(opts.cwd);
908
+ const registryPath = path12.resolve(cwd, registryFile);
909
+ const outputDir = path12.resolve(cwd, opts.output);
910
+ const registry = parseRegistry(JSON.parse(await fs8.readFile(registryPath, "utf8")), registryPath);
911
+ const problems = await checkRegistryDependencies(registry, path12.dirname(registryPath));
757
912
  if (problems.length > 0) {
758
913
  for (const problem of problems) console.error(`registry: ${problem}`);
759
914
  return 1;
760
915
  }
761
- await fs7.mkdir(outputDir, { recursive: true });
916
+ await fs8.mkdir(outputDir, { recursive: true });
762
917
  const catalog = [];
763
918
  for (const item of registry.items) {
764
919
  const built = {
765
920
  ...item,
766
921
  files: await Promise.all(
767
922
  item.files.map(async (file) => {
768
- const abs = path11.resolve(path11.dirname(registryPath), file.path);
769
- const content = await fs7.readFile(abs, "utf8");
923
+ const abs = path12.resolve(path12.dirname(registryPath), file.path);
924
+ const source = await fs8.readFile(abs, "utf8");
770
925
  return {
771
926
  ...file,
772
- content
927
+ content: opts.publish ? opts.publish(source, file.path) : source
773
928
  };
774
929
  })
775
930
  )
776
931
  };
777
- const outFile = path11.join(outputDir, `${item.name}.json`);
778
- await fs7.writeFile(outFile, `${JSON.stringify(built, null, 2)}
932
+ const outFile = path12.join(outputDir, `${item.name}.json`);
933
+ await fs8.writeFile(outFile, `${JSON.stringify(built, null, 2)}
779
934
  `, "utf8");
780
935
  catalog.push({
781
936
  name: item.name,
@@ -789,24 +944,24 @@ async function runBuild(registryFile, opts) {
789
944
  target: file.target
790
945
  }))
791
946
  });
792
- console.log(`built ${path11.relative(cwd, outFile)}`);
947
+ console.log(`built ${path12.relative(cwd, outFile)}`);
793
948
  }
794
- const indexPath = path11.join(outputDir, "index.json");
795
- await fs7.writeFile(
949
+ const indexPath = path12.join(outputDir, "index.json");
950
+ await fs8.writeFile(
796
951
  indexPath,
797
952
  `${JSON.stringify({ name: registry.name, homepage: registry.homepage, items: catalog }, null, 2)}
798
953
  `,
799
954
  "utf8"
800
955
  );
801
- console.log(`built ${path11.relative(cwd, indexPath)}`);
956
+ console.log(`built ${path12.relative(cwd, indexPath)}`);
802
957
  const written = /* @__PURE__ */ new Set([...registry.items.map((item) => `${item.name}.json`), "index.json"]);
803
- for (const entry of await fs7.readdir(outputDir, { withFileTypes: true })) {
958
+ for (const entry of await fs8.readdir(outputDir, { withFileTypes: true })) {
804
959
  if (!entry.isFile() || !entry.name.endsWith(".json") || written.has(entry.name)) continue;
805
- const stale = path11.join(outputDir, entry.name);
960
+ const stale = path12.join(outputDir, entry.name);
806
961
  const name = entry.name.slice(0, -".json".length);
807
962
  if (!await holdsItem(stale, name)) continue;
808
- await fs7.rm(stale);
809
- console.log(`removed ${path11.relative(cwd, stale)} (not in ${path11.basename(registryPath)})`);
963
+ await fs8.rm(stale);
964
+ console.log(`removed ${path12.relative(cwd, stale)} (not in ${path12.basename(registryPath)})`);
810
965
  }
811
966
  return 0;
812
967
  }
@@ -822,8 +977,9 @@ async function runDiff(names, opts) {
822
977
  console.log("No nsh-ui.json \u2014 run init first.");
823
978
  return 1;
824
979
  }
980
+ const installedHere = (item) => isInstalled(ws.uiRoot, config, item) && (item.type !== "registry:theme" || item.name === config.theme);
825
981
  const index = await fetchIndex(config, opts.registry);
826
- const targets = names.length ? names : index.items.filter((item) => isInstalled(ws.uiRoot, config, item)).map((item) => item.name);
982
+ const targets = names.length ? names : index.items.filter(installedHere).map((item) => item.name);
827
983
  if (targets.length === 0) {
828
984
  console.log("Nothing installed.");
829
985
  return 0;
@@ -866,12 +1022,13 @@ import { Command as Command5 } from "commander";
866
1022
  async function runList(opts) {
867
1023
  const { ws, config } = await readApp(opts.cwd, opts.registry);
868
1024
  const index = await fetchIndex(config, opts.registry);
1025
+ const installedHere = (item) => isInstalled(ws.uiRoot, config, item) && (item.type !== "registry:theme" || item.name === config.theme);
869
1026
  const rows = index.items.map((item) => ({
870
1027
  name: item.name,
871
1028
  type: item.type,
872
1029
  title: item.title,
873
1030
  description: item.description,
874
- installed: isInstalled(ws.uiRoot, config, item)
1031
+ installed: installedHere(item)
875
1032
  }));
876
1033
  if (opts.json) {
877
1034
  console.log(
@@ -896,20 +1053,20 @@ var list = new Command5().name("list").description("List the registry's items, m
896
1053
  });
897
1054
 
898
1055
  // src/commands/serve.ts
899
- import fs8 from "fs/promises";
1056
+ import fs9 from "fs/promises";
900
1057
  import http from "http";
901
- import path12 from "path";
1058
+ import path13 from "path";
902
1059
  import { Command as Command6 } from "commander";
903
1060
  function fileUnder(root, pathname) {
904
- const file = path12.join(root, pathname);
905
- const relative = path12.relative(root, file);
906
- if (relative === "" || relative === ".." || relative.startsWith(`..${path12.sep}`) || path12.isAbsolute(relative)) {
1061
+ const file = path13.join(root, pathname);
1062
+ const relative = path13.relative(root, file);
1063
+ if (relative === "" || relative === ".." || relative.startsWith(`..${path13.sep}`) || path13.isAbsolute(relative)) {
907
1064
  return null;
908
1065
  }
909
1066
  return file;
910
1067
  }
911
1068
  async function startRegistryServer(opts) {
912
- const root = path12.resolve(opts.dir);
1069
+ const root = path13.resolve(opts.dir);
913
1070
  const port = opts.port ?? 0;
914
1071
  const server = http.createServer(async (req, res) => {
915
1072
  try {
@@ -925,7 +1082,7 @@ async function startRegistryServer(opts) {
925
1082
  return;
926
1083
  }
927
1084
  try {
928
- const body = await fs8.readFile(filePath);
1085
+ const body = await fs9.readFile(filePath);
929
1086
  res.writeHead(200, {
930
1087
  "content-type": "application/json; charset=utf-8",
931
1088
  "access-control-allow-origin": "*"
@@ -956,7 +1113,7 @@ async function startRegistryServer(opts) {
956
1113
  };
957
1114
  }
958
1115
  var serve = new Command6().name("serve").description("Serve public/r over HTTP for local CLI testing").option("-p, --port <port>", "port (0 picks a free one)", "4173").option("-d, --dir <path>", "registry directory", "./public/r").option("-c, --cwd <cwd>", "working directory", process.cwd()).action(async (opts) => {
959
- const root = path12.resolve(opts.cwd, opts.dir);
1116
+ const root = path13.resolve(opts.cwd, opts.dir);
960
1117
  const server = await startRegistryServer({ dir: root, port: Number(opts.port) });
961
1118
  console.log(`Serving ${root}`);
962
1119
  console.log(` ${server.url}/index.json`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadcn-nsh",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Add styled NSH components (copy-into-app) from the shadcn-nsh registry",
5
5
  "keywords": [
6
6
  "cli",