shadcn-nsh 0.0.0-stage → 0.2.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 +104 -2
- package/README.zh.md +100 -0
- package/dist/index.js +1000 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# shadcn-nsh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[中文](./README.zh.md)
|
|
4
|
+
|
|
5
|
+
CLI for adding styled NSH components (copy-into-app) from the shadcn-nsh registry.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bunx shadcn-nsh@latest init -c apps/web
|
|
9
|
+
bunx shadcn-nsh@latest add button
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Default registry: `https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json`.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
| Command | What it does |
|
|
17
|
+
| ------------------- | -------------------------------------------------------------------- |
|
|
18
|
+
| `init` | Writes `nsh-ui.json`, installs `theme`, points each `nsh.json` at it |
|
|
19
|
+
| `add [components…]` | Copies items and their `registryDependencies` into the app |
|
|
20
|
+
| `list` | The registry's items, `✓` on the ones the app has |
|
|
21
|
+
| `view <items…>` | The files `add` would write: target paths and contents |
|
|
22
|
+
| `diff [items…]` | The app's files against the registry's; exit code 1 when they differ |
|
|
23
|
+
| `build` | Builds `registry.json` into `public/r` (maintaining this registry) |
|
|
24
|
+
| `serve` | Serves `public/r` over HTTP for local testing |
|
|
25
|
+
|
|
26
|
+
Every command takes `-c, --cwd <dir>` (default: the current directory). `init`, `add`, `list`,
|
|
27
|
+
`view` and `diff` also take `--registry <url>`, a template with `{name}` in it; without it, `add`,
|
|
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).
|
|
30
|
+
|
|
31
|
+
## add
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
shadcn-nsh add button -c apps/web
|
|
35
|
+
shadcn-nsh add --all
|
|
36
|
+
shadcn-nsh add button --dry-run
|
|
37
|
+
shadcn-nsh add button --overwrite
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- A file the app does not have is written; one identical to the registry's is skipped.
|
|
41
|
+
- A file the app changed is kept and reported: `kept components/ui/button.tsx, differs (run diff)`.
|
|
42
|
+
`--overwrite` (`-o`) replaces it and prints the change: `overwrite components/ui/button.tsx (+2 -1)`.
|
|
43
|
+
- `theme.json` is merged by key: the app keeps its colors and gains the tokens it lacks. A
|
|
44
|
+
recolored theme is kept as it is (`kept theme.json (the app's colors stay)`).
|
|
45
|
+
- `--all` (`-a`) adds every `registry:ui` item the catalog lists, dependencies included.
|
|
46
|
+
- `--dry-run` prints the same lines as `would write …` / `would overwrite …` and writes nothing —
|
|
47
|
+
not even `nsh-ui.json` or the theme `init` would install first.
|
|
48
|
+
|
|
49
|
+
## list, view, diff
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
shadcn-nsh list
|
|
53
|
+
shadcn-nsh list --json
|
|
54
|
+
shadcn-nsh view button
|
|
55
|
+
shadcn-nsh diff
|
|
56
|
+
shadcn-nsh diff button
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`list` reads the catalog (`index.json`) and marks an item when any of its files is in the app.
|
|
60
|
+
|
|
61
|
+
`view` prints what `add` would write — dependencies first, at their target paths, with imports
|
|
62
|
+
rewritten for this app — and writes nothing.
|
|
63
|
+
|
|
64
|
+
`diff` compares the same rewritten content with the app's files. Without an item it lists the
|
|
65
|
+
installed items that differ; with one it prints the changed lines. It exits 1 when anything
|
|
66
|
+
differs, so it can gate an update:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
shadcn-nsh diff || echo "run shadcn-nsh add <item> --overwrite"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Lines are the registry's version against the app's: `-` is a line of the app's file that
|
|
73
|
+
overwriting would drop, `+` one it would add. `theme.json` compares keys, not values — a
|
|
74
|
+
recolored theme is the app's own, and only `add --overwrite` replaces it.
|
|
75
|
+
|
|
76
|
+
## nsh-ui.json
|
|
77
|
+
|
|
78
|
+
`init` writes it where components install:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"registry": "https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json",
|
|
83
|
+
"aliases": {
|
|
84
|
+
"components": "./components",
|
|
85
|
+
"ui": "./components/ui",
|
|
86
|
+
"lib": "./lib",
|
|
87
|
+
"theme": "./lib/theme"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
In a monorepo (`apps/*` + `packages/ui`) it lives in `packages/ui` and `add -c apps/web`
|
|
93
|
+
installs there.
|
|
94
|
+
|
|
95
|
+
## Develop in this monorepo
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
bun run shadcn-nsh -- --help
|
|
99
|
+
bun run cli:build
|
|
100
|
+
bun run registry:build # public/r, then e2e against it
|
|
101
|
+
bun run shadcn-nsh -- add button -c apps/web --registry "file://$PWD/public/r/{name}.json"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`bun test packages/cli` runs the command tests against a temporary app and a `file://` registry
|
|
105
|
+
built from `public/r`.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# shadcn-nsh
|
|
2
|
+
|
|
3
|
+
[English](./README.md)
|
|
4
|
+
|
|
5
|
+
从 shadcn-nsh registry 向应用复制 styled NSH 组件的 CLI。
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bunx shadcn-nsh@latest init -c apps/web
|
|
9
|
+
bunx shadcn-nsh@latest add button
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
默认 registry:`https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json`。
|
|
13
|
+
|
|
14
|
+
## 命令
|
|
15
|
+
|
|
16
|
+
| 命令 | 作用 |
|
|
17
|
+
| ------------------- | ---------------------------------------------------------- |
|
|
18
|
+
| `init` | 写 `nsh-ui.json`、装 `theme`、把每个 `nsh.json` 指向它 |
|
|
19
|
+
| `add [components…]` | 复制条目及其 `registryDependencies` |
|
|
20
|
+
| `list` | registry 的全部条目,应用已有的标 `✓` |
|
|
21
|
+
| `view <items…>` | `add` 会写的文件:目标路径与内容 |
|
|
22
|
+
| `diff [items…]` | 对比应用内文件与 registry 的;有差异时退出码 1 |
|
|
23
|
+
| `build` | 把 `registry.json` 构建到 `public/r`(维护本 registry 用) |
|
|
24
|
+
| `serve` | 用 HTTP 提供 `public/r`,本地测试用 |
|
|
25
|
+
|
|
26
|
+
每个命令都接受 `-c, --cwd <dir>`(默认:当前目录)。`init`、`add`、`list`、`view`、`diff`
|
|
27
|
+
另接受 `--registry <url>`(含 `{name}` 的模板);省略时 `add`、`list`、`view`、`diff` 用
|
|
28
|
+
`nsh-ui.json` 里的,`init` 用公共 registry。`init` 还接受 `--overwrite` 与 `-y`(接受,当前不弹确认)。
|
|
29
|
+
|
|
30
|
+
## add
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
shadcn-nsh add button -c apps/web
|
|
34
|
+
shadcn-nsh add --all
|
|
35
|
+
shadcn-nsh add button --dry-run
|
|
36
|
+
shadcn-nsh add button --overwrite
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- 应用没有的文件写入;与 registry 相同的跳过。
|
|
40
|
+
- 应用改过的文件保留并报告 `kept components/ui/button.tsx, differs (run diff)`。
|
|
41
|
+
`--overwrite`(`-o`)替换它并打印改动量:`overwrite components/ui/button.tsx (+2 -1)`。
|
|
42
|
+
- `theme.json` 按 key 合并:应用保留自己的颜色,补上缺的 token;改过颜色的主题原样保留
|
|
43
|
+
(`kept theme.json (the app's colors stay)`)。
|
|
44
|
+
- `--all`(`-a`)加全部 `registry:ui` 条目(含依赖)。
|
|
45
|
+
- `--dry-run` 用 `would write …` / `would overwrite …` 打印同样的行,一个字节都不写——
|
|
46
|
+
包括 `init` 会先写的 `nsh-ui.json` 与主题。
|
|
47
|
+
|
|
48
|
+
## list、view、diff
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
shadcn-nsh list
|
|
52
|
+
shadcn-nsh list --json
|
|
53
|
+
shadcn-nsh view button
|
|
54
|
+
shadcn-nsh diff
|
|
55
|
+
shadcn-nsh diff button
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`list` 读目录(`index.json`),条目任一文件在应用里就标为已装。
|
|
59
|
+
|
|
60
|
+
`view` 打印 `add` 会写的内容:依赖在前、目标路径、import 已按本应用改写;不写文件。
|
|
61
|
+
|
|
62
|
+
`diff` 用同一份改写后的内容与应用里的文件比较。不带条目时列出有差异的已装条目,带条目时
|
|
63
|
+
打印改动的行。有差异时退出码 1,可以用来卡更新:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
shadcn-nsh diff || echo "run shadcn-nsh add <item> --overwrite"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
行以 registry 版本对照应用版本:`-` 是覆盖会删掉的应用中的行,`+` 是它会加的行。
|
|
70
|
+
`theme.json` 按 key 比较、不按值——改过颜色的主题是应用自己的,只有 `add --overwrite`
|
|
71
|
+
会整体替换。
|
|
72
|
+
|
|
73
|
+
## nsh-ui.json
|
|
74
|
+
|
|
75
|
+
`init` 把它写在组件安装的地方:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"registry": "https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json",
|
|
80
|
+
"aliases": {
|
|
81
|
+
"components": "./components",
|
|
82
|
+
"ui": "./components/ui",
|
|
83
|
+
"lib": "./lib",
|
|
84
|
+
"theme": "./lib/theme"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
monorepo(`apps/*` + `packages/ui`)里它位于 `packages/ui`,`add -c apps/web` 装到那里。
|
|
90
|
+
|
|
91
|
+
## 本仓开发
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
bun run shadcn-nsh -- --help
|
|
95
|
+
bun run cli:build
|
|
96
|
+
bun run registry:build # 生成 public/r,再用它做端到端
|
|
97
|
+
bun run shadcn-nsh -- add button -c apps/web --registry "file://$PWD/public/r/{name}.json"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`bun test packages/cli` 用临时应用和由 `public/r` 生成的 `file://` registry 跑命令测试。
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command8 } from "commander";
|
|
5
|
+
|
|
6
|
+
// package.json
|
|
7
|
+
var package_default = {
|
|
8
|
+
name: "shadcn-nsh",
|
|
9
|
+
version: "0.2.0",
|
|
10
|
+
description: "Add styled NSH components (copy-into-app) from the shadcn-nsh registry",
|
|
11
|
+
keywords: [
|
|
12
|
+
"cli",
|
|
13
|
+
"components",
|
|
14
|
+
"nsh",
|
|
15
|
+
"registry",
|
|
16
|
+
"shadcn"
|
|
17
|
+
],
|
|
18
|
+
homepage: "https://github.com/aningdev/shadcn-nsh#readme",
|
|
19
|
+
bugs: {
|
|
20
|
+
url: "https://github.com/aningdev/shadcn-nsh/issues"
|
|
21
|
+
},
|
|
22
|
+
license: "MIT",
|
|
23
|
+
repository: {
|
|
24
|
+
type: "git",
|
|
25
|
+
url: "git+https://github.com/aningdev/shadcn-nsh.git",
|
|
26
|
+
directory: "packages/cli"
|
|
27
|
+
},
|
|
28
|
+
bin: {
|
|
29
|
+
"shadcn-nsh": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
files: [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
type: "module",
|
|
35
|
+
publishConfig: {
|
|
36
|
+
access: "public"
|
|
37
|
+
},
|
|
38
|
+
scripts: {
|
|
39
|
+
build: "tsup",
|
|
40
|
+
typecheck: "tsc --noEmit",
|
|
41
|
+
prepublishOnly: "bun run build"
|
|
42
|
+
},
|
|
43
|
+
dependencies: {
|
|
44
|
+
commander: "^15.0.0"
|
|
45
|
+
},
|
|
46
|
+
devDependencies: {
|
|
47
|
+
tsup: "^8.5.0"
|
|
48
|
+
},
|
|
49
|
+
engines: {
|
|
50
|
+
node: ">=20"
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// src/commands/add.ts
|
|
55
|
+
import path10 from "path";
|
|
56
|
+
import { Command as Command2 } from "commander";
|
|
57
|
+
|
|
58
|
+
// src/types.ts
|
|
59
|
+
var DEFAULT_REGISTRY = "https://raw.githubusercontent.com/aningdev/shadcn-nsh/main/public/r/{name}.json";
|
|
60
|
+
var DEFAULT_CONFIG = () => ({
|
|
61
|
+
registry: DEFAULT_REGISTRY,
|
|
62
|
+
aliases: {
|
|
63
|
+
components: "./components",
|
|
64
|
+
ui: "./components/ui",
|
|
65
|
+
lib: "./lib",
|
|
66
|
+
theme: "./lib/theme"
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// src/utils/app.ts
|
|
71
|
+
import path3 from "path";
|
|
72
|
+
|
|
73
|
+
// src/utils/config.ts
|
|
74
|
+
import fs from "fs/promises";
|
|
75
|
+
import path from "path";
|
|
76
|
+
var CONFIG_FILE = "nsh-ui.json";
|
|
77
|
+
async function readConfig(cwd) {
|
|
78
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
79
|
+
try {
|
|
80
|
+
const raw = await fs.readFile(file, "utf8");
|
|
81
|
+
return { ...DEFAULT_CONFIG(), ...JSON.parse(raw) };
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function writeConfig(cwd, config) {
|
|
87
|
+
const file = path.join(cwd, CONFIG_FILE);
|
|
88
|
+
await fs.writeFile(file, `${JSON.stringify(config, null, 2)}
|
|
89
|
+
`, "utf8");
|
|
90
|
+
}
|
|
91
|
+
async function findNshJson(cwd) {
|
|
92
|
+
const file = path.join(cwd, "nsh.json");
|
|
93
|
+
try {
|
|
94
|
+
await fs.access(file);
|
|
95
|
+
return file;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function readJson(file) {
|
|
101
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
102
|
+
}
|
|
103
|
+
async function writeJson(file, data) {
|
|
104
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
105
|
+
await fs.writeFile(file, `${JSON.stringify(data, null, 2)}
|
|
106
|
+
`, "utf8");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/utils/workspace.ts
|
|
110
|
+
import fs2 from "fs/promises";
|
|
111
|
+
import path2 from "path";
|
|
112
|
+
async function exists(file) {
|
|
113
|
+
try {
|
|
114
|
+
await fs2.access(file);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function isConsumerUiPackage(uiDir) {
|
|
121
|
+
if (await exists(path2.join(uiDir, CONFIG_FILE))) return true;
|
|
122
|
+
try {
|
|
123
|
+
const pkg = JSON.parse(await fs2.readFile(path2.join(uiDir, "package.json"), "utf8"));
|
|
124
|
+
return pkg.name === "@workspace/ui";
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function findMonorepoRoot(start) {
|
|
130
|
+
let dir = path2.resolve(start);
|
|
131
|
+
for (; ; ) {
|
|
132
|
+
const uiDir = path2.join(dir, "packages", "ui");
|
|
133
|
+
if (await isConsumerUiPackage(uiDir)) {
|
|
134
|
+
return dir;
|
|
135
|
+
}
|
|
136
|
+
const parent = path2.dirname(dir);
|
|
137
|
+
if (parent === dir) return null;
|
|
138
|
+
dir = parent;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function listApps(root) {
|
|
142
|
+
const appsDir = path2.join(root, "apps");
|
|
143
|
+
if (!await exists(appsDir)) return [];
|
|
144
|
+
const entries = await fs2.readdir(appsDir, { withFileTypes: true });
|
|
145
|
+
const apps = [];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (!entry.isDirectory()) continue;
|
|
148
|
+
const appDir = path2.join(appsDir, entry.name);
|
|
149
|
+
if (await findNshJson(appDir)) apps.push(appDir);
|
|
150
|
+
}
|
|
151
|
+
return apps;
|
|
152
|
+
}
|
|
153
|
+
async function resolveWorkspace(cwd) {
|
|
154
|
+
const resolved = path2.resolve(cwd);
|
|
155
|
+
const monoRoot = await findMonorepoRoot(resolved);
|
|
156
|
+
if (monoRoot) {
|
|
157
|
+
const uiRoot = path2.join(monoRoot, "packages", "ui");
|
|
158
|
+
return {
|
|
159
|
+
kind: "monorepo",
|
|
160
|
+
uiRoot,
|
|
161
|
+
apps: await listApps(monoRoot),
|
|
162
|
+
root: monoRoot
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (path2.basename(resolved) === "ui" && path2.basename(path2.dirname(resolved)) === "packages" && await isConsumerUiPackage(resolved)) {
|
|
166
|
+
const root = path2.dirname(path2.dirname(resolved));
|
|
167
|
+
return {
|
|
168
|
+
kind: "monorepo",
|
|
169
|
+
uiRoot: resolved,
|
|
170
|
+
apps: await listApps(root),
|
|
171
|
+
root
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
kind: "app",
|
|
176
|
+
uiRoot: resolved,
|
|
177
|
+
apps: await findNshJson(resolved) ? [resolved] : [],
|
|
178
|
+
root: resolved
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function themePathForApp(appDir, uiRoot) {
|
|
182
|
+
const abs = path2.join(uiRoot, "theme.json");
|
|
183
|
+
let rel = path2.relative(appDir, abs);
|
|
184
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
185
|
+
return rel.replace(/\\/g, "/");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/utils/app.ts
|
|
189
|
+
async function readApp(cwd, registry) {
|
|
190
|
+
const ws = await resolveWorkspace(path3.resolve(cwd));
|
|
191
|
+
const stored = await readConfig(ws.uiRoot);
|
|
192
|
+
const config = stored ?? DEFAULT_CONFIG();
|
|
193
|
+
if (registry) config.registry = registry;
|
|
194
|
+
return { ws, config, hasConfig: stored != null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/utils/install.ts
|
|
198
|
+
import fs4 from "fs/promises";
|
|
199
|
+
import path7 from "path";
|
|
200
|
+
|
|
201
|
+
// src/utils/diff.ts
|
|
202
|
+
function diffLines(before, after) {
|
|
203
|
+
const a = before.split("\n");
|
|
204
|
+
const b = after.split("\n");
|
|
205
|
+
const n = a.length;
|
|
206
|
+
const m = b.length;
|
|
207
|
+
const length = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
208
|
+
for (let i2 = n - 1; i2 >= 0; i2 -= 1) {
|
|
209
|
+
for (let j2 = m - 1; j2 >= 0; j2 -= 1) {
|
|
210
|
+
length[i2][j2] = a[i2] === b[j2] ? length[i2 + 1][j2 + 1] + 1 : Math.max(length[i2 + 1][j2], length[i2][j2 + 1]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const out = [];
|
|
214
|
+
let i = 0;
|
|
215
|
+
let j = 0;
|
|
216
|
+
while (i < n && j < m) {
|
|
217
|
+
if (a[i] === b[j]) {
|
|
218
|
+
out.push({ kind: "same", text: a[i] });
|
|
219
|
+
i += 1;
|
|
220
|
+
j += 1;
|
|
221
|
+
} else if (length[i + 1][j] >= length[i][j + 1]) {
|
|
222
|
+
out.push({ kind: "del", text: a[i] });
|
|
223
|
+
i += 1;
|
|
224
|
+
} else {
|
|
225
|
+
out.push({ kind: "add", text: b[j] });
|
|
226
|
+
j += 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
while (i < n) out.push({ kind: "del", text: a[i++] });
|
|
230
|
+
while (j < m) out.push({ kind: "add", text: b[j++] });
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
function countChanges(lines) {
|
|
234
|
+
let added = 0;
|
|
235
|
+
let removed = 0;
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
if (line.kind === "add") added += 1;
|
|
238
|
+
else if (line.kind === "del") removed += 1;
|
|
239
|
+
}
|
|
240
|
+
return { added, removed };
|
|
241
|
+
}
|
|
242
|
+
function changeSummary(added, removed) {
|
|
243
|
+
return `(+${added} -${removed})`;
|
|
244
|
+
}
|
|
245
|
+
function formatDiff(lines) {
|
|
246
|
+
return lines.map(
|
|
247
|
+
(line) => line.kind === "add" ? `+ ${line.text}` : line.kind === "del" ? `- ${line.text}` : ` ${line.text}`
|
|
248
|
+
).join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/utils/plan.ts
|
|
252
|
+
import fs3 from "fs";
|
|
253
|
+
import fsPromises from "fs/promises";
|
|
254
|
+
import path6 from "path";
|
|
255
|
+
|
|
256
|
+
// src/utils/paths.ts
|
|
257
|
+
import path4 from "path";
|
|
258
|
+
function resolveAlias(cwd, alias) {
|
|
259
|
+
return path4.resolve(cwd, alias);
|
|
260
|
+
}
|
|
261
|
+
function relativeImport(fromFile, toModuleFile) {
|
|
262
|
+
let rel = path4.relative(path4.dirname(fromFile), toModuleFile);
|
|
263
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
264
|
+
return rel.replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "");
|
|
265
|
+
}
|
|
266
|
+
function defaultTarget(file, aliases) {
|
|
267
|
+
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);
|
|
271
|
+
return base;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/utils/theme-merge.ts
|
|
275
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
function mergeTheme(existing, incoming) {
|
|
277
|
+
const missing = [];
|
|
278
|
+
const merge = (base, extra, prefix) => {
|
|
279
|
+
const out = { ...base };
|
|
280
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
281
|
+
const at = prefix ? `${prefix}.${key}` : key;
|
|
282
|
+
if (!(key in out)) {
|
|
283
|
+
out[key] = value;
|
|
284
|
+
missing.push(at);
|
|
285
|
+
} else if (isObject(out[key]) && isObject(value)) {
|
|
286
|
+
out[key] = merge(out[key], value, at);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
};
|
|
291
|
+
const theme = merge(existing, incoming, "");
|
|
292
|
+
return { theme, added: missing.length, missing };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/utils/transform.ts
|
|
296
|
+
import path5 from "path";
|
|
297
|
+
function transformContent(content, absoluteTargetFile, config, cwd) {
|
|
298
|
+
const themeModule = resolveAlias(cwd, config.aliases.theme);
|
|
299
|
+
const themeImport = relativeImport(absoluteTargetFile, themeModule);
|
|
300
|
+
const libDir = resolveAlias(cwd, config.aliases.lib);
|
|
301
|
+
const rewritten = content.replaceAll(`from "@shadcn-nsh/theme"`, `from "${themeImport}"`).replaceAll(`from '@shadcn-nsh/theme'`, `from '${themeImport}'`);
|
|
302
|
+
return rewritten.replace(
|
|
303
|
+
/from (["'])\.\/lib\/([\w-]+)\1/g,
|
|
304
|
+
(_match, quote, name) => `from ${quote}${relativeImport(absoluteTargetFile, path5.join(libDir, name))}${quote}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/utils/plan.ts
|
|
309
|
+
function isThemeFile(file) {
|
|
310
|
+
return path6.basename(file.absolute) === "theme.json";
|
|
311
|
+
}
|
|
312
|
+
function hasChange(file) {
|
|
313
|
+
if (file.status === "merge") return true;
|
|
314
|
+
return file.status === "differs" && !isThemeFile(file);
|
|
315
|
+
}
|
|
316
|
+
async function readOrNull(file) {
|
|
317
|
+
try {
|
|
318
|
+
return await fsPromises.readFile(file, "utf8");
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function themeMerge(existing, incoming) {
|
|
324
|
+
try {
|
|
325
|
+
return mergeTheme(
|
|
326
|
+
JSON.parse(existing),
|
|
327
|
+
JSON.parse(incoming)
|
|
328
|
+
);
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function planItems(uiRoot, config, items) {
|
|
334
|
+
const planned = [];
|
|
335
|
+
for (const item of items) {
|
|
336
|
+
for (const file of item.files) {
|
|
337
|
+
if (file.content == null) {
|
|
338
|
+
throw new Error(`Item ${item.name} file ${file.path} has no content (run registry:build)`);
|
|
339
|
+
}
|
|
340
|
+
const target = defaultTarget(file, config.aliases);
|
|
341
|
+
const absolute = path6.isAbsolute(target) ? target : path6.resolve(uiRoot, target);
|
|
342
|
+
const content = transformContent(file.content, absolute, config, uiRoot);
|
|
343
|
+
const existing = await readOrNull(absolute);
|
|
344
|
+
const relative = path6.relative(uiRoot, absolute) || path6.basename(absolute);
|
|
345
|
+
const entry = {
|
|
346
|
+
item: item.name,
|
|
347
|
+
file,
|
|
348
|
+
path: relative,
|
|
349
|
+
absolute,
|
|
350
|
+
content,
|
|
351
|
+
existing,
|
|
352
|
+
status: "create",
|
|
353
|
+
added: 0,
|
|
354
|
+
removed: 0
|
|
355
|
+
};
|
|
356
|
+
if (existing != null) {
|
|
357
|
+
if (existing === content) {
|
|
358
|
+
entry.status = "same";
|
|
359
|
+
} else if (path6.basename(absolute) === "theme.json") {
|
|
360
|
+
const merged2 = themeMerge(existing, content);
|
|
361
|
+
if (merged2 && merged2.added > 0) {
|
|
362
|
+
entry.status = "merge";
|
|
363
|
+
entry.missing = merged2.missing;
|
|
364
|
+
} else {
|
|
365
|
+
entry.status = "differs";
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
entry.status = "differs";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (existing != null && entry.status !== "same") {
|
|
372
|
+
const changes = countChanges(diffLines(existing, content));
|
|
373
|
+
entry.added = changes.added;
|
|
374
|
+
entry.removed = changes.removed;
|
|
375
|
+
}
|
|
376
|
+
planned.push(entry);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return planned;
|
|
380
|
+
}
|
|
381
|
+
function isInstalled(uiRoot, config, item) {
|
|
382
|
+
return (item.files ?? []).some((file) => {
|
|
383
|
+
const target = defaultTarget(file, config.aliases);
|
|
384
|
+
return fs3.existsSync(path6.isAbsolute(target) ? target : path6.resolve(uiRoot, target));
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/utils/install.ts
|
|
389
|
+
async function installItems(uiRoot, config, items, opts = {}) {
|
|
390
|
+
const planned = await planItems(uiRoot, config, items);
|
|
391
|
+
const written = [];
|
|
392
|
+
for (const file of planned) {
|
|
393
|
+
const shown = file.path;
|
|
394
|
+
if (file.status === "same") {
|
|
395
|
+
console.log(`skip ${shown} (identical)`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (file.status === "create") {
|
|
399
|
+
if (opts.dryRun) console.log(`would write ${shown}`);
|
|
400
|
+
else {
|
|
401
|
+
await write(file, file.content);
|
|
402
|
+
written.push(shown);
|
|
403
|
+
console.log(`write ${shown}`);
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (file.status === "merge" && !opts.overwrite) {
|
|
408
|
+
const count = file.missing?.length ?? 0;
|
|
409
|
+
if (opts.dryRun) {
|
|
410
|
+
console.log(`would merge ${shown} (${count} keys missing)`);
|
|
411
|
+
} else {
|
|
412
|
+
await write(file, await merged(file) ?? file.content);
|
|
413
|
+
written.push(shown);
|
|
414
|
+
console.log(`merge ${shown} (added ${count} keys)`);
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const summary2 = changeSummary(file.added, file.removed);
|
|
419
|
+
if (!opts.overwrite) {
|
|
420
|
+
console.log(
|
|
421
|
+
isThemeFile(file) ? `kept ${shown} (the app's colors stay)` : `kept ${shown}, differs (run diff)`
|
|
422
|
+
);
|
|
423
|
+
} else if (opts.dryRun) {
|
|
424
|
+
console.log(`would overwrite ${shown} ${summary2}`);
|
|
425
|
+
} else {
|
|
426
|
+
await write(file, file.content);
|
|
427
|
+
written.push(shown);
|
|
428
|
+
console.log(`overwrite ${shown} ${summary2}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { planned, written };
|
|
432
|
+
}
|
|
433
|
+
async function write(file, content) {
|
|
434
|
+
await fs4.mkdir(path7.dirname(file.absolute), { recursive: true });
|
|
435
|
+
await fs4.writeFile(file.absolute, content, "utf8");
|
|
436
|
+
}
|
|
437
|
+
async function merged(file) {
|
|
438
|
+
if (file.existing == null) return null;
|
|
439
|
+
try {
|
|
440
|
+
const { theme } = mergeTheme(
|
|
441
|
+
JSON.parse(file.existing),
|
|
442
|
+
JSON.parse(file.content)
|
|
443
|
+
);
|
|
444
|
+
return `${JSON.stringify(theme, null, 2)}
|
|
445
|
+
`;
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
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
|
+
// src/commands/init.ts
|
|
545
|
+
import fs6 from "fs/promises";
|
|
546
|
+
import path9 from "path";
|
|
547
|
+
import { Command } from "commander";
|
|
548
|
+
async function runInit(opts) {
|
|
549
|
+
const cwd = path9.resolve(opts.cwd);
|
|
550
|
+
const ws = await resolveWorkspace(cwd);
|
|
551
|
+
if (ws.kind === "app") {
|
|
552
|
+
const nshJsonPath = await findNshJson(cwd);
|
|
553
|
+
if (!nshJsonPath) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`No nsh.json in ${cwd}. Create an NSH app first (nsh init), then run shadcn-nsh init.`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
} else if (ws.apps.length === 0) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
`Monorepo at ${ws.root} has packages/ui but no apps/*/nsh.json. Add an NSH app under apps/.`
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
const config = DEFAULT_CONFIG();
|
|
564
|
+
if (opts.registry) config.registry = opts.registry;
|
|
565
|
+
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);
|
|
568
|
+
await installItems(ws.uiRoot, config, items, { overwrite: opts.overwrite });
|
|
569
|
+
for (const appDir of ws.apps) {
|
|
570
|
+
const nshJsonPath = await findNshJson(appDir);
|
|
571
|
+
if (!nshJsonPath) continue;
|
|
572
|
+
const nsh = await readJson(nshJsonPath);
|
|
573
|
+
nsh.theme = ws.kind === "monorepo" ? themePathForApp(appDir, ws.uiRoot) : "./theme.json";
|
|
574
|
+
await writeJson(nshJsonPath, nsh);
|
|
575
|
+
console.log(
|
|
576
|
+
`update ${path9.relative(cwd, nshJsonPath) || "nsh.json"} theme \u2192 ${String(nsh.theme)}`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
if (ws.kind === "app") {
|
|
580
|
+
const tsconfig = path9.join(cwd, "tsconfig.json");
|
|
581
|
+
try {
|
|
582
|
+
await fs6.access(tsconfig);
|
|
583
|
+
} catch {
|
|
584
|
+
await writeJson(tsconfig, {
|
|
585
|
+
extends: "./.nsh/tsconfig.json",
|
|
586
|
+
include: ["**/*.ts", "**/*.tsx"]
|
|
587
|
+
});
|
|
588
|
+
console.log("write tsconfig.json");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return config;
|
|
592
|
+
}
|
|
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
|
+
});
|
|
597
|
+
|
|
598
|
+
// src/commands/add.ts
|
|
599
|
+
async function runAdd(components, opts) {
|
|
600
|
+
const cwd = path10.resolve(opts.cwd);
|
|
601
|
+
const dryRun = opts.dryRun === true;
|
|
602
|
+
if (components.length === 0 && !opts.all) {
|
|
603
|
+
throw new Error("Name at least one component, or pass --all.");
|
|
604
|
+
}
|
|
605
|
+
const { ws, config: stored, hasConfig } = await readApp(cwd, opts.registry);
|
|
606
|
+
let config = stored;
|
|
607
|
+
if (!hasConfig) {
|
|
608
|
+
if (dryRun) {
|
|
609
|
+
console.log("No nsh-ui.json \u2014 a dry run uses the default aliases (run init first).");
|
|
610
|
+
config = DEFAULT_CONFIG();
|
|
611
|
+
if (opts.registry) config.registry = opts.registry;
|
|
612
|
+
} else {
|
|
613
|
+
console.log("No nsh-ui.json \u2014 running init first\u2026");
|
|
614
|
+
config = await runInit({ cwd, overwrite: opts.overwrite, registry: opts.registry });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (opts.registry) {
|
|
618
|
+
config.registry = opts.registry;
|
|
619
|
+
if (hasConfig && !dryRun) await writeConfig(ws.uiRoot, config);
|
|
620
|
+
}
|
|
621
|
+
const names = [...components];
|
|
622
|
+
if (opts.all) {
|
|
623
|
+
const index = await fetchIndex(config, opts.registry);
|
|
624
|
+
for (const item of index.items) {
|
|
625
|
+
if (item.type === "registry:ui" && !names.includes(item.name)) names.push(item.name);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (names.length === 0) {
|
|
629
|
+
throw new Error("The registry lists no registry:ui item to add.");
|
|
630
|
+
}
|
|
631
|
+
const items = await resolveTree(config, names, opts.registry);
|
|
632
|
+
await installItems(ws.uiRoot, config, items, { overwrite: opts.overwrite, dryRun });
|
|
633
|
+
if (dryRun) {
|
|
634
|
+
const count = `${names.length} item${names.length === 1 ? "" : "s"}`;
|
|
635
|
+
console.log(`
|
|
636
|
+
Dry run: nothing written. ${count} checked.`);
|
|
637
|
+
return 0;
|
|
638
|
+
}
|
|
639
|
+
console.log(
|
|
640
|
+
opts.all ? `
|
|
641
|
+
Added: all ${names.length} registry:ui items` : `
|
|
642
|
+
Added: ${names.join(", ")}`
|
|
643
|
+
);
|
|
644
|
+
if (ws.kind === "monorepo") {
|
|
645
|
+
console.log(`(installed under ${path10.relative(ws.root, ws.uiRoot) || "packages/ui"})`);
|
|
646
|
+
}
|
|
647
|
+
return 0;
|
|
648
|
+
}
|
|
649
|
+
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
|
+
async (components, opts) => {
|
|
651
|
+
process.exitCode = await runAdd(components, opts);
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// src/commands/build.ts
|
|
656
|
+
import fs7 from "fs/promises";
|
|
657
|
+
import path11 from "path";
|
|
658
|
+
import { Command as Command3 } from "commander";
|
|
659
|
+
|
|
660
|
+
// src/utils/registry-schema.ts
|
|
661
|
+
var RegistryShapeError = class extends Error {
|
|
662
|
+
name = "RegistryShapeError";
|
|
663
|
+
};
|
|
664
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
665
|
+
function stringAt(value, where) {
|
|
666
|
+
if (typeof value !== "string") throw new RegistryShapeError(`${where}: expected a string`);
|
|
667
|
+
return value;
|
|
668
|
+
}
|
|
669
|
+
function optionalString(value, where) {
|
|
670
|
+
return value === void 0 ? void 0 : stringAt(value, where);
|
|
671
|
+
}
|
|
672
|
+
function optionalStrings(value, where) {
|
|
673
|
+
if (value === void 0) return void 0;
|
|
674
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
675
|
+
throw new RegistryShapeError(`${where}: expected a list of strings`);
|
|
676
|
+
}
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
function fileAt(value, where) {
|
|
680
|
+
if (!isRecord(value)) throw new RegistryShapeError(`${where}: not an object`);
|
|
681
|
+
const file = {
|
|
682
|
+
path: stringAt(value.path, `${where}.path`),
|
|
683
|
+
type: stringAt(value.type, `${where}.type`)
|
|
684
|
+
};
|
|
685
|
+
const target = optionalString(value.target, `${where}.target`);
|
|
686
|
+
if (target !== void 0) file.target = target;
|
|
687
|
+
const content = optionalString(value.content, `${where}.content`);
|
|
688
|
+
if (content !== void 0) file.content = content;
|
|
689
|
+
return inSourceOrder(value, file);
|
|
690
|
+
}
|
|
691
|
+
function inSourceOrder(source, checked) {
|
|
692
|
+
const ordered = {};
|
|
693
|
+
for (const key of Object.keys(source)) {
|
|
694
|
+
if (key in checked) ordered[key] = checked[key];
|
|
695
|
+
}
|
|
696
|
+
return Object.assign(ordered, checked);
|
|
697
|
+
}
|
|
698
|
+
function parseRegistryItem(value, where) {
|
|
699
|
+
if (!isRecord(value)) throw new RegistryShapeError(`${where}: not an object`);
|
|
700
|
+
const type = stringAt(value.type, `${where}: type`);
|
|
701
|
+
if (!type.startsWith("registry:")) {
|
|
702
|
+
throw new RegistryShapeError(`${where}: type "${type}" is not a registry: type`);
|
|
703
|
+
}
|
|
704
|
+
if (!Array.isArray(value.files) || value.files.length === 0) {
|
|
705
|
+
throw new RegistryShapeError(`${where}: no files`);
|
|
706
|
+
}
|
|
707
|
+
const item = {
|
|
708
|
+
name: stringAt(value.name, `${where}: name`),
|
|
709
|
+
type,
|
|
710
|
+
files: value.files.map((file, at) => fileAt(file, `${where}: files[${at}]`))
|
|
711
|
+
};
|
|
712
|
+
const title = optionalString(value.title, `${where}: title`);
|
|
713
|
+
if (title !== void 0) item.title = title;
|
|
714
|
+
const description = optionalString(value.description, `${where}: description`);
|
|
715
|
+
if (description !== void 0) item.description = description;
|
|
716
|
+
const registryDependencies = optionalStrings(
|
|
717
|
+
value.registryDependencies,
|
|
718
|
+
`${where}: registryDependencies`
|
|
719
|
+
);
|
|
720
|
+
if (registryDependencies !== void 0) item.registryDependencies = registryDependencies;
|
|
721
|
+
const dependencies = optionalStrings(value.dependencies, `${where}: dependencies`);
|
|
722
|
+
if (dependencies !== void 0) item.dependencies = dependencies;
|
|
723
|
+
return inSourceOrder(value, item);
|
|
724
|
+
}
|
|
725
|
+
function parseRegistry(value, where) {
|
|
726
|
+
if (!isRecord(value)) throw new RegistryShapeError(`${where}: not a JSON object`);
|
|
727
|
+
if (!Array.isArray(value.items)) throw new RegistryShapeError(`${where}: no items list`);
|
|
728
|
+
return {
|
|
729
|
+
name: stringAt(value.name, `${where}: name`),
|
|
730
|
+
homepage: stringAt(value.homepage, `${where}: homepage`),
|
|
731
|
+
items: value.items.map((item, at) => parseRegistryItem(item, `${where}: items[${at}]`))
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/commands/build.ts
|
|
736
|
+
async function holdsItem(file, name) {
|
|
737
|
+
let value;
|
|
738
|
+
try {
|
|
739
|
+
value = JSON.parse(await fs7.readFile(file, "utf8"));
|
|
740
|
+
} catch (error) {
|
|
741
|
+
if (error instanceof SyntaxError) return false;
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
return parseRegistryItem(value, file).name === name;
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error instanceof RegistryShapeError) return false;
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
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));
|
|
757
|
+
if (problems.length > 0) {
|
|
758
|
+
for (const problem of problems) console.error(`registry: ${problem}`);
|
|
759
|
+
return 1;
|
|
760
|
+
}
|
|
761
|
+
await fs7.mkdir(outputDir, { recursive: true });
|
|
762
|
+
const catalog = [];
|
|
763
|
+
for (const item of registry.items) {
|
|
764
|
+
const built = {
|
|
765
|
+
...item,
|
|
766
|
+
files: await Promise.all(
|
|
767
|
+
item.files.map(async (file) => {
|
|
768
|
+
const abs = path11.resolve(path11.dirname(registryPath), file.path);
|
|
769
|
+
const content = await fs7.readFile(abs, "utf8");
|
|
770
|
+
return {
|
|
771
|
+
...file,
|
|
772
|
+
content
|
|
773
|
+
};
|
|
774
|
+
})
|
|
775
|
+
)
|
|
776
|
+
};
|
|
777
|
+
const outFile = path11.join(outputDir, `${item.name}.json`);
|
|
778
|
+
await fs7.writeFile(outFile, `${JSON.stringify(built, null, 2)}
|
|
779
|
+
`, "utf8");
|
|
780
|
+
catalog.push({
|
|
781
|
+
name: item.name,
|
|
782
|
+
type: item.type,
|
|
783
|
+
title: item.title,
|
|
784
|
+
description: item.description,
|
|
785
|
+
// The targets `list` checks and `add` writes, without the contents.
|
|
786
|
+
files: item.files.map((file) => ({
|
|
787
|
+
path: file.path,
|
|
788
|
+
type: file.type,
|
|
789
|
+
target: file.target
|
|
790
|
+
}))
|
|
791
|
+
});
|
|
792
|
+
console.log(`built ${path11.relative(cwd, outFile)}`);
|
|
793
|
+
}
|
|
794
|
+
const indexPath = path11.join(outputDir, "index.json");
|
|
795
|
+
await fs7.writeFile(
|
|
796
|
+
indexPath,
|
|
797
|
+
`${JSON.stringify({ name: registry.name, homepage: registry.homepage, items: catalog }, null, 2)}
|
|
798
|
+
`,
|
|
799
|
+
"utf8"
|
|
800
|
+
);
|
|
801
|
+
console.log(`built ${path11.relative(cwd, indexPath)}`);
|
|
802
|
+
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 })) {
|
|
804
|
+
if (!entry.isFile() || !entry.name.endsWith(".json") || written.has(entry.name)) continue;
|
|
805
|
+
const stale = path11.join(outputDir, entry.name);
|
|
806
|
+
const name = entry.name.slice(0, -".json".length);
|
|
807
|
+
if (!await holdsItem(stale, name)) continue;
|
|
808
|
+
await fs7.rm(stale);
|
|
809
|
+
console.log(`removed ${path11.relative(cwd, stale)} (not in ${path11.basename(registryPath)})`);
|
|
810
|
+
}
|
|
811
|
+
return 0;
|
|
812
|
+
}
|
|
813
|
+
var build = new Command3().name("build").description("Build registry items into public/r/*.json").argument("[registry]", "path to registry.json", "./registry.json").option("-o, --output <path>", "output directory", "./public/r").option("-c, --cwd <cwd>", "working directory", process.cwd()).action(async (registryFile, opts) => {
|
|
814
|
+
process.exitCode = await runBuild(registryFile, opts);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// src/commands/diff.ts
|
|
818
|
+
import { Command as Command4 } from "commander";
|
|
819
|
+
async function runDiff(names, opts) {
|
|
820
|
+
const { ws, config, hasConfig } = await readApp(opts.cwd, opts.registry);
|
|
821
|
+
if (!hasConfig) {
|
|
822
|
+
console.log("No nsh-ui.json \u2014 run init first.");
|
|
823
|
+
return 1;
|
|
824
|
+
}
|
|
825
|
+
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);
|
|
827
|
+
if (targets.length === 0) {
|
|
828
|
+
console.log("Nothing installed.");
|
|
829
|
+
return 0;
|
|
830
|
+
}
|
|
831
|
+
const items = await resolveTree(config, targets, opts.registry);
|
|
832
|
+
const changed = (await planItems(ws.uiRoot, config, items)).filter(hasChange);
|
|
833
|
+
if (changed.length === 0) {
|
|
834
|
+
console.log("No differences.");
|
|
835
|
+
return 0;
|
|
836
|
+
}
|
|
837
|
+
if (names.length === 0) {
|
|
838
|
+
for (const name of targets) {
|
|
839
|
+
const files = changed.filter((file) => file.item === name);
|
|
840
|
+
if (files.length === 0) continue;
|
|
841
|
+
const lines = files.map((file) => ` ${file.path}`);
|
|
842
|
+
console.log(`${name} ${summary(files)}`);
|
|
843
|
+
console.log(lines.join("\n"));
|
|
844
|
+
}
|
|
845
|
+
console.log(`
|
|
846
|
+
Run shadcn-nsh diff <item> to see the changes.`);
|
|
847
|
+
return 1;
|
|
848
|
+
}
|
|
849
|
+
for (const file of changed) {
|
|
850
|
+
console.log(`${file.path} ${summary([file])}`);
|
|
851
|
+
console.log(formatDiff(diffLines(file.existing ?? "", file.content)));
|
|
852
|
+
console.log();
|
|
853
|
+
}
|
|
854
|
+
return 1;
|
|
855
|
+
}
|
|
856
|
+
var summary = (files) => changeSummary(
|
|
857
|
+
files.reduce((total, file) => total + file.added, 0),
|
|
858
|
+
files.reduce((total, file) => total + file.removed, 0)
|
|
859
|
+
);
|
|
860
|
+
var diff = new Command4().name("diff").description("Compare the app's installed files with the registry's; exit 1 when they differ").argument("[items...]", "item names (default: every installed item)").option("-c, --cwd <cwd>", "app or monorepo directory", process.cwd()).option("--registry <url>", "registry URL template containing {name}").action(async (items, opts) => {
|
|
861
|
+
process.exitCode = await runDiff(items, opts);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// src/commands/list.ts
|
|
865
|
+
import { Command as Command5 } from "commander";
|
|
866
|
+
async function runList(opts) {
|
|
867
|
+
const { ws, config } = await readApp(opts.cwd, opts.registry);
|
|
868
|
+
const index = await fetchIndex(config, opts.registry);
|
|
869
|
+
const rows = index.items.map((item) => ({
|
|
870
|
+
name: item.name,
|
|
871
|
+
type: item.type,
|
|
872
|
+
title: item.title,
|
|
873
|
+
description: item.description,
|
|
874
|
+
installed: isInstalled(ws.uiRoot, config, item)
|
|
875
|
+
}));
|
|
876
|
+
if (opts.json) {
|
|
877
|
+
console.log(
|
|
878
|
+
JSON.stringify({ name: index.name, homepage: index.homepage, items: rows }, null, 2)
|
|
879
|
+
);
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
const nameWidth = Math.max(...rows.map((row) => row.name.length));
|
|
883
|
+
const typeWidth = Math.max(...rows.map((row) => row.type.length));
|
|
884
|
+
for (const row of rows) {
|
|
885
|
+
const mark = row.installed ? "\u2713" : " ";
|
|
886
|
+
const line = `${mark} ${row.name.padEnd(nameWidth)} ${row.type.padEnd(typeWidth)} ${row.description ?? ""}`;
|
|
887
|
+
console.log(line.trimEnd());
|
|
888
|
+
}
|
|
889
|
+
const installed = rows.filter((row) => row.installed).length;
|
|
890
|
+
console.log(`
|
|
891
|
+
${rows.length} items, ${installed} installed`);
|
|
892
|
+
return 0;
|
|
893
|
+
}
|
|
894
|
+
var list = new Command5().name("list").description("List the registry's items, marking the ones the app has").option("-c, --cwd <cwd>", "app or monorepo directory", process.cwd()).option("--registry <url>", "registry URL template containing {name}").option("--json", "print the catalog as JSON", false).action(async (opts) => {
|
|
895
|
+
process.exitCode = await runList(opts);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// src/commands/serve.ts
|
|
899
|
+
import fs8 from "fs/promises";
|
|
900
|
+
import http from "http";
|
|
901
|
+
import path12 from "path";
|
|
902
|
+
import { Command as Command6 } from "commander";
|
|
903
|
+
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)) {
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
return file;
|
|
910
|
+
}
|
|
911
|
+
async function startRegistryServer(opts) {
|
|
912
|
+
const root = path12.resolve(opts.dir);
|
|
913
|
+
const port = opts.port ?? 0;
|
|
914
|
+
const server = http.createServer(async (req, res) => {
|
|
915
|
+
try {
|
|
916
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
917
|
+
let pathname = url.pathname;
|
|
918
|
+
if (pathname === "/") pathname = "/index.json";
|
|
919
|
+
if (pathname.startsWith("/r/")) pathname = pathname.slice(2);
|
|
920
|
+
if (!pathname.startsWith("/")) pathname = `/${pathname}`;
|
|
921
|
+
const filePath = fileUnder(root, pathname);
|
|
922
|
+
if (filePath === null) {
|
|
923
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
924
|
+
res.end("Forbidden");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
const body = await fs8.readFile(filePath);
|
|
929
|
+
res.writeHead(200, {
|
|
930
|
+
"content-type": "application/json; charset=utf-8",
|
|
931
|
+
"access-control-allow-origin": "*"
|
|
932
|
+
});
|
|
933
|
+
res.end(body);
|
|
934
|
+
} catch {
|
|
935
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
936
|
+
res.end(`Not found: ${pathname}`);
|
|
937
|
+
}
|
|
938
|
+
} catch (error) {
|
|
939
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
940
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
await new Promise((resolve, reject) => {
|
|
944
|
+
server.once("error", reject);
|
|
945
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
946
|
+
});
|
|
947
|
+
const address = server.address();
|
|
948
|
+
const bound = typeof address === "object" && address != null ? address.port : port;
|
|
949
|
+
return {
|
|
950
|
+
url: `http://127.0.0.1:${bound}`,
|
|
951
|
+
port: bound,
|
|
952
|
+
close: () => new Promise((resolve, reject) => {
|
|
953
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
954
|
+
server.closeAllConnections?.();
|
|
955
|
+
})
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
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);
|
|
960
|
+
const server = await startRegistryServer({ dir: root, port: Number(opts.port) });
|
|
961
|
+
console.log(`Serving ${root}`);
|
|
962
|
+
console.log(` ${server.url}/index.json`);
|
|
963
|
+
console.log(` ${server.url}/button.json`);
|
|
964
|
+
console.log(`CLI: --registry ${server.url}/{name}.json`);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// src/commands/view.ts
|
|
968
|
+
import { Command as Command7 } from "commander";
|
|
969
|
+
async function runView(names, opts) {
|
|
970
|
+
const { ws, config } = await readApp(opts.cwd, opts.registry);
|
|
971
|
+
const items = await resolveTree(config, names, opts.registry);
|
|
972
|
+
const planned = await planItems(ws.uiRoot, config, items);
|
|
973
|
+
let first = true;
|
|
974
|
+
for (const item of items) {
|
|
975
|
+
if (!first) console.log();
|
|
976
|
+
first = false;
|
|
977
|
+
console.log(`${item.name} (${item.type})`);
|
|
978
|
+
const deps = item.registryDependencies ?? [];
|
|
979
|
+
if (deps.length > 0) console.log(` depends on: ${deps.join(", ")}`);
|
|
980
|
+
for (const file of planned.filter((entry) => entry.item === item.name)) {
|
|
981
|
+
console.log(`file ${file.path}`);
|
|
982
|
+
console.log(file.content.replace(/\n$/, ""));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return 0;
|
|
986
|
+
}
|
|
987
|
+
var view = new Command7().name("view").description("Print the files an item would write, its dependencies resolved").argument("<items...>", "item names (e.g. button)").option("-c, --cwd <cwd>", "app or monorepo directory", process.cwd()).option("--registry <url>", "registry URL template containing {name}").action(async (items, opts) => {
|
|
988
|
+
process.exitCode = await runView(items, opts);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// src/index.ts
|
|
992
|
+
var program = new Command8().name("shadcn-nsh").description("Add styled NSH components (copy-into-app) from the shadcn-nsh registry").version(package_default.version);
|
|
993
|
+
program.addCommand(build);
|
|
994
|
+
program.addCommand(init);
|
|
995
|
+
program.addCommand(add);
|
|
996
|
+
program.addCommand(list);
|
|
997
|
+
program.addCommand(view);
|
|
998
|
+
program.addCommand(diff);
|
|
999
|
+
program.addCommand(serve);
|
|
1000
|
+
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shadcn-nsh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Add styled NSH components (copy-into-app) from the shadcn-nsh registry",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"components",
|
|
8
|
+
"nsh",
|
|
9
|
+
"registry",
|
|
10
|
+
"shadcn"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/aningdev/shadcn-nsh#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/aningdev/shadcn-nsh/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/aningdev/shadcn-nsh.git",
|
|
20
|
+
"directory": "packages/cli"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"shadcn-nsh": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"prepublishOnly": "bun run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"commander": "^15.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"tsup": "^8.5.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
}
|
|
46
|
+
}
|