@eco-ds/registry-doctor 0.1.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 +123 -0
- package/bin/registry-doctor.js +1152 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# @eco-ds/registry-doctor
|
|
2
|
+
|
|
3
|
+
> **Status: EXPERIMENTAL / DRAFT**
|
|
4
|
+
> This package is not yet published. It is read-only and will never modify your project.
|
|
5
|
+
|
|
6
|
+
Diagnoses whether a consuming project is correctly set up to use [DTN Design System](https://eco-design-system-2.dtn.com) registry components.
|
|
7
|
+
|
|
8
|
+
A component can compile and render but still not match the DTN registry preview if the theme, component file, font setup, Tailwind token mapping, or app shell is incomplete. This tool checks all of that.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### After publish (product teams)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Check current directory
|
|
18
|
+
npx @eco-ds/registry-doctor
|
|
19
|
+
|
|
20
|
+
# Check a specific component
|
|
21
|
+
npx @eco-ds/registry-doctor --component button
|
|
22
|
+
|
|
23
|
+
# JSON output for CI
|
|
24
|
+
npx @eco-ds/registry-doctor --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Local development (maintainers, from repo root)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
node packages/registry-doctor/bin/registry-doctor.js \
|
|
31
|
+
--project . \
|
|
32
|
+
--registry-root ./public/r \
|
|
33
|
+
--registry-json ./registry.json \
|
|
34
|
+
--component button
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or via the npm script (forwards all args):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm run registry:doctor -- --project . --component button
|
|
41
|
+
npm run registry:doctor -- --project ../my-app --component brand-header --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| Option | Default | Description |
|
|
49
|
+
|--------|---------|-------------|
|
|
50
|
+
| `--project <path>` | cwd | Target consuming project |
|
|
51
|
+
| `--component <name>` | `button` | Component to check |
|
|
52
|
+
| `--registry <url>` | `https://eco-design-system-2.dtn.com/r/registry.json` | Registry JSON URL |
|
|
53
|
+
| `--registry-root <path>` | — | Local registry output root (e.g. `./public/r`). When provided, loads `registry.json` from this directory and skips remote fetch. |
|
|
54
|
+
| `--registry-json <path>` | — | Local source `registry.json`. Skips remote fetch. |
|
|
55
|
+
| `--live` | disabled | Extra reachability checks (pings registry URL) |
|
|
56
|
+
| `--json` | disabled | Output structured JSON |
|
|
57
|
+
| `--help`, `-h` | — | Show usage |
|
|
58
|
+
|
|
59
|
+
When `--registry-root` and `--registry-json` are both omitted, the CLI fetches metadata from the deployed registry URL. This requires network access.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## What It Checks
|
|
64
|
+
|
|
65
|
+
| # | Check | Severity | Description |
|
|
66
|
+
|---|-------|----------|-------------|
|
|
67
|
+
| 1 | `components-json` | FAIL | `components.json` exists and is valid |
|
|
68
|
+
| 2 | `registry-mapping` | WARN | `@dtn` registry mapping configured |
|
|
69
|
+
| 3 | `tailwind-installed` | FAIL | Tailwind CSS in dependencies (v4 preferred) |
|
|
70
|
+
| 4 | `shadcn-setup` | WARN | shadcn/ui style configured |
|
|
71
|
+
| 5 | `global-css` | FAIL | Global CSS file exists with Tailwind imports |
|
|
72
|
+
| 6 | `theme-tokens` | FAIL/WARN | DTN theme tokens present in CSS |
|
|
73
|
+
| 7 | `theme-inline` | WARN | `@theme inline` directive (Tailwind v4) |
|
|
74
|
+
| 8 | `component-tokens` | WARN/SKIP | Component-specific CSS variables |
|
|
75
|
+
| 9 | `component-match` | FAIL/WARN | Local component matches registry version |
|
|
76
|
+
| 10 | `dependencies` | WARN | Required npm dependencies installed |
|
|
77
|
+
| 11 | `font-setup` | WARN | Inter font loaded and bound to `--font-sans` |
|
|
78
|
+
| 12 | `app-shell-css` | FAIL | Root layout imports global CSS |
|
|
79
|
+
| 13 | `registry-reachable` | FAIL | Registry URL responds (requires `--live`) |
|
|
80
|
+
| 14 | `component-collisions` | WARN/SKIP | Generic shadcn components that should be DTN |
|
|
81
|
+
|
|
82
|
+
## Exit Codes
|
|
83
|
+
|
|
84
|
+
| Code | Meaning |
|
|
85
|
+
|------|---------|
|
|
86
|
+
| `0` | All checks passed (warnings are acceptable) |
|
|
87
|
+
| `1` | One or more checks failed, or registry metadata could not be loaded |
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Remote vs Local Mode
|
|
92
|
+
|
|
93
|
+
**Remote mode** (default, no `--registry-root`):
|
|
94
|
+
- Fetches `registry.json` and component JSON from the deployed registry URL
|
|
95
|
+
- Requires network access
|
|
96
|
+
- `--live` adds an extra explicit ping check on top of the required fetches
|
|
97
|
+
|
|
98
|
+
**Local mode** (`--registry-root ./public/r`):
|
|
99
|
+
- Loads `registry.json` from `<registry-root>/registry.json` automatically
|
|
100
|
+
- Reads all packaged item JSON from local files
|
|
101
|
+
- No network required
|
|
102
|
+
- Combine with `--registry-json ./registry.json` to use the repo root source registry instead
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Publishing
|
|
107
|
+
|
|
108
|
+
This package is not yet published. When ready:
|
|
109
|
+
|
|
110
|
+
1. Confirm the package name `@eco-ds/registry-doctor` is available on your npm registry
|
|
111
|
+
2. Update `publishConfig.registry` in `package.json` if using a private registry
|
|
112
|
+
3. Remove the `UNLICENSED` license and add the appropriate license
|
|
113
|
+
4. Run `npm publish` from `packages/registry-doctor/`
|
|
114
|
+
|
|
115
|
+
The `publishConfig.access` is set to `"restricted"` — change to `"public"` for public npm.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Notes
|
|
120
|
+
|
|
121
|
+
- **Read-only**: never modifies the target project
|
|
122
|
+
- **No external dependencies**: uses only Node.js built-ins
|
|
123
|
+
- **CommonJS**: compatible with Node.js ≥ 18
|
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const https = require("node:https");
|
|
6
|
+
const http = require("node:http");
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// CLI Argument Parsing
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
|
|
14
|
+
function getArg(name) {
|
|
15
|
+
const idx = args.indexOf(name);
|
|
16
|
+
if (idx === -1) return undefined;
|
|
17
|
+
return args[idx + 1];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hasFlag(name) {
|
|
21
|
+
return args.includes(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const projectPath = getArg("--project") || process.cwd();
|
|
25
|
+
const componentName = getArg("--component") || "button";
|
|
26
|
+
const registryUrl =
|
|
27
|
+
getArg("--registry") || "https://eco-design-system-2.dtn.com/r/registry.json";
|
|
28
|
+
const registryRoot = getArg("--registry-root") || null; // e.g. ./public/r
|
|
29
|
+
const registryJsonPath = getArg("--registry-json") || null; // e.g. ./registry.json
|
|
30
|
+
const liveCheck = hasFlag("--live");
|
|
31
|
+
const jsonOutput = hasFlag("--json");
|
|
32
|
+
const helpFlag = hasFlag("--help") || hasFlag("-h");
|
|
33
|
+
|
|
34
|
+
// Derive base URL from the registry URL (strip /registry.json suffix)
|
|
35
|
+
const REGISTRY_BASE_URL = registryUrl.replace(/\/r\/registry\.json$/, "");
|
|
36
|
+
const REGISTRY_R_BASE = `${REGISTRY_BASE_URL}/r`;
|
|
37
|
+
|
|
38
|
+
if (helpFlag) {
|
|
39
|
+
console.log(`
|
|
40
|
+
@eco-ds/registry-doctor — DTN Registry Readiness Checker (EXPERIMENTAL)
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
npx @eco-ds/registry-doctor [options]
|
|
44
|
+
node packages/registry-doctor/bin/registry-doctor.js [options]
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--project <path> Target consuming project (default: cwd)
|
|
48
|
+
--component <name> Component to check (default: button)
|
|
49
|
+
--registry <url> Registry JSON URL
|
|
50
|
+
(default: https://eco-design-system-2.dtn.com/r/registry.json)
|
|
51
|
+
--registry-root <path> Local registry output root (e.g. ./public/r)
|
|
52
|
+
When provided, loads registry.json from this
|
|
53
|
+
directory and skips remote fetch.
|
|
54
|
+
Combine with --registry-json to use a different
|
|
55
|
+
source registry.json (e.g. the repo root one).
|
|
56
|
+
--registry-json <path> Local source registry.json (e.g. ./registry.json)
|
|
57
|
+
Skips remote fetch when provided.
|
|
58
|
+
--live Enable extra reachability checks beyond required
|
|
59
|
+
metadata fetches (registry URL ping)
|
|
60
|
+
--json Output structured JSON
|
|
61
|
+
--help, -h Show this help
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
# Product team — fetches metadata from deployed registry
|
|
65
|
+
npx @eco-ds/registry-doctor --project .
|
|
66
|
+
npx @eco-ds/registry-doctor --project . --component brand-header --json
|
|
67
|
+
|
|
68
|
+
# Maintainer — uses local registry output (no network required)
|
|
69
|
+
node packages/registry-doctor/bin/registry-doctor.js \\
|
|
70
|
+
--project . \\
|
|
71
|
+
--registry-root ./public/r \\
|
|
72
|
+
--registry-json ./registry.json \\
|
|
73
|
+
--component button
|
|
74
|
+
`);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Result Collection
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const results = [];
|
|
83
|
+
|
|
84
|
+
function pass(id, message, details) {
|
|
85
|
+
results.push({ status: "pass", id, message, details });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fail(id, message, details) {
|
|
89
|
+
results.push({ status: "fail", id, message, details });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function warn(id, message, details) {
|
|
93
|
+
results.push({ status: "warn", id, message, details });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function skip(id, message, details) {
|
|
97
|
+
results.push({ status: "skip", id, message, details });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// HTTP Fetch Helper (no external dependencies)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fetch a URL and return the response body as a string.
|
|
106
|
+
* Uses Node's built-in http/https modules.
|
|
107
|
+
*/
|
|
108
|
+
function fetchUrl(url, timeoutMs = 10000) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const client = url.startsWith("https://") ? https : http;
|
|
111
|
+
const req = client.get(url, { timeout: timeoutMs }, (res) => {
|
|
112
|
+
if (res.statusCode !== 200) {
|
|
113
|
+
res.resume();
|
|
114
|
+
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const chunks = [];
|
|
118
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
119
|
+
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
120
|
+
res.on("error", reject);
|
|
121
|
+
});
|
|
122
|
+
req.on("error", reject);
|
|
123
|
+
req.on("timeout", () => {
|
|
124
|
+
req.destroy();
|
|
125
|
+
reject(new Error(`Timeout fetching ${url}`));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Registry Metadata Loading
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Classify a registry item type to its output subdirectory.
|
|
136
|
+
*/
|
|
137
|
+
function classifyItemType(itemType) {
|
|
138
|
+
switch (itemType) {
|
|
139
|
+
case "registry:ui":
|
|
140
|
+
return "styles";
|
|
141
|
+
case "registry:component":
|
|
142
|
+
return "components";
|
|
143
|
+
case "registry:block":
|
|
144
|
+
return "blocks";
|
|
145
|
+
case "registry:theme":
|
|
146
|
+
case "registry:style":
|
|
147
|
+
return "themes";
|
|
148
|
+
default:
|
|
149
|
+
return "styles";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load the source registry index (registry.json).
|
|
155
|
+
* Priority:
|
|
156
|
+
* 1. --registry-json <path> (explicit local file)
|
|
157
|
+
* 2. --registry-root <path>/registry.json (implicit local file when root is given)
|
|
158
|
+
* 3. Remote fetch from --registry URL
|
|
159
|
+
*/
|
|
160
|
+
async function loadRegistryIndex() {
|
|
161
|
+
if (registryJsonPath) {
|
|
162
|
+
const resolved = path.resolve(registryJsonPath);
|
|
163
|
+
if (!fs.existsSync(resolved)) {
|
|
164
|
+
throw new Error(`--registry-json path not found: ${resolved}`);
|
|
165
|
+
}
|
|
166
|
+
return JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
167
|
+
}
|
|
168
|
+
if (registryRoot) {
|
|
169
|
+
const implicit = path.join(path.resolve(registryRoot), "registry.json");
|
|
170
|
+
if (fs.existsSync(implicit)) {
|
|
171
|
+
return JSON.parse(fs.readFileSync(implicit, "utf8"));
|
|
172
|
+
}
|
|
173
|
+
// registry-root given but no registry.json inside it — fall through to remote
|
|
174
|
+
}
|
|
175
|
+
// Remote fetch
|
|
176
|
+
const body = await fetchUrl(registryUrl);
|
|
177
|
+
return JSON.parse(body);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load a packaged item JSON by name.
|
|
182
|
+
* Uses local file if --registry-root is provided, otherwise fetches remotely.
|
|
183
|
+
* Requires the registry index to determine item type.
|
|
184
|
+
*/
|
|
185
|
+
async function loadPackagedItem(name, registryIndex) {
|
|
186
|
+
const item = registryIndex.items.find((i) => i.name === name);
|
|
187
|
+
if (!item) return null;
|
|
188
|
+
|
|
189
|
+
const subDir = classifyItemType(item.type);
|
|
190
|
+
|
|
191
|
+
if (registryRoot) {
|
|
192
|
+
const resolved = path.resolve(registryRoot);
|
|
193
|
+
const jsonPath = path.join(resolved, subDir, `${name}.json`);
|
|
194
|
+
if (!fs.existsSync(jsonPath)) return null;
|
|
195
|
+
return JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Remote fetch
|
|
199
|
+
const url = `${REGISTRY_R_BASE}/${subDir}/${name}.json`;
|
|
200
|
+
try {
|
|
201
|
+
const body = await fetchUrl(url);
|
|
202
|
+
return JSON.parse(body);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Failed to fetch packaged item "${name}" from ${url}: ${err.message}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Load the packaged theme.json.
|
|
212
|
+
* Uses local file if --registry-root is provided, otherwise fetches remotely.
|
|
213
|
+
*/
|
|
214
|
+
async function loadTheme() {
|
|
215
|
+
if (registryRoot) {
|
|
216
|
+
const resolved = path.resolve(registryRoot);
|
|
217
|
+
const themePath = path.join(resolved, "themes", "theme.json");
|
|
218
|
+
if (!fs.existsSync(themePath)) return null;
|
|
219
|
+
return JSON.parse(fs.readFileSync(themePath, "utf8"));
|
|
220
|
+
}
|
|
221
|
+
const url = `${REGISTRY_R_BASE}/themes/theme.json`;
|
|
222
|
+
try {
|
|
223
|
+
const body = await fetchUrl(url);
|
|
224
|
+
return JSON.parse(body);
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Derive expected theme tokens from a loaded theme object.
|
|
232
|
+
*/
|
|
233
|
+
function deriveThemeTokens(theme) {
|
|
234
|
+
if (!theme) {
|
|
235
|
+
return [
|
|
236
|
+
"--background",
|
|
237
|
+
"--foreground",
|
|
238
|
+
"--primary",
|
|
239
|
+
"--primary-foreground",
|
|
240
|
+
"--secondary",
|
|
241
|
+
"--muted",
|
|
242
|
+
"--border",
|
|
243
|
+
"--ring",
|
|
244
|
+
"--radius",
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
const vars = theme.cssVars || {};
|
|
248
|
+
const keys = new Set();
|
|
249
|
+
for (const section of ["theme", "light", "dark"]) {
|
|
250
|
+
if (vars[section]) {
|
|
251
|
+
for (const key of Object.keys(vars[section])) {
|
|
252
|
+
keys.add(`--${key}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (theme.files) {
|
|
257
|
+
for (const file of theme.files) {
|
|
258
|
+
if (file.content) {
|
|
259
|
+
const matches = file.content.match(/--[\w-]+/g);
|
|
260
|
+
if (matches) {
|
|
261
|
+
for (const m of matches) keys.add(m);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return [...keys];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Project File Helpers
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function resolveProjectPath(...segments) {
|
|
274
|
+
return path.resolve(projectPath, ...segments);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function fileExists(...segments) {
|
|
278
|
+
return fs.existsSync(resolveProjectPath(...segments));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readProjectFile(...segments) {
|
|
282
|
+
const p = resolveProjectPath(...segments);
|
|
283
|
+
if (!fs.existsSync(p)) return null;
|
|
284
|
+
return fs.readFileSync(p, "utf8");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve a registry file target to a local path using components.json aliases.
|
|
289
|
+
* Handles both src/ and non-src/ project layouts.
|
|
290
|
+
*/
|
|
291
|
+
function resolveTarget(target, componentsJson) {
|
|
292
|
+
const aliases = componentsJson?.aliases || {};
|
|
293
|
+
const hasSrcDir = fs.existsSync(resolveProjectPath("src"));
|
|
294
|
+
|
|
295
|
+
if (fileExists(target)) return resolveProjectPath(target);
|
|
296
|
+
if (hasSrcDir && fileExists("src", target))
|
|
297
|
+
return resolveProjectPath("src", target);
|
|
298
|
+
|
|
299
|
+
for (const [, aliasValue] of Object.entries(aliases)) {
|
|
300
|
+
const aliasPath = aliasValue.replace(/^@\//, "");
|
|
301
|
+
if (target.startsWith(`${aliasPath}/`) || target.startsWith(aliasPath)) {
|
|
302
|
+
if (hasSrcDir) {
|
|
303
|
+
const resolved = resolveProjectPath("src", target);
|
|
304
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
305
|
+
}
|
|
306
|
+
const resolved = resolveProjectPath(target);
|
|
307
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const patterns = [
|
|
312
|
+
target,
|
|
313
|
+
`src/${target}`,
|
|
314
|
+
target.replace("components/", "src/components/"),
|
|
315
|
+
];
|
|
316
|
+
for (const p of patterns) {
|
|
317
|
+
const resolved = resolveProjectPath(p);
|
|
318
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Extract bare npm package specifiers from file content.
|
|
326
|
+
* Skips relative imports, @/ aliases, and node: builtins.
|
|
327
|
+
*/
|
|
328
|
+
function extractBareImports(content) {
|
|
329
|
+
const importRegex =
|
|
330
|
+
/(?:import|export)\s+(?:[\w\s{},*]+\s+from\s+)?["']([^"']+)["']/g;
|
|
331
|
+
const requireRegex = /require\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
332
|
+
const packages = new Set();
|
|
333
|
+
|
|
334
|
+
for (const regex of [importRegex, requireRegex]) {
|
|
335
|
+
const matches = content.matchAll(regex);
|
|
336
|
+
for (const match of matches) {
|
|
337
|
+
const specifier = match[1];
|
|
338
|
+
if (
|
|
339
|
+
specifier.startsWith(".") ||
|
|
340
|
+
specifier.startsWith("@/") ||
|
|
341
|
+
specifier.startsWith("node:")
|
|
342
|
+
) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (specifier.startsWith("@")) {
|
|
346
|
+
const parts = specifier.split("/");
|
|
347
|
+
if (parts.length >= 2) packages.add(`${parts[0]}/${parts[1]}`);
|
|
348
|
+
} else {
|
|
349
|
+
packages.add(specifier.split("/")[0]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return [...packages];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Checks
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function checkComponentsJson() {
|
|
361
|
+
const content = readProjectFile("components.json");
|
|
362
|
+
if (!content) {
|
|
363
|
+
fail(
|
|
364
|
+
"components-json",
|
|
365
|
+
"components.json not found",
|
|
366
|
+
"Run `npx shadcn@latest init` to create it, or manually create components.json with DTN registry configuration.",
|
|
367
|
+
);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const parsed = JSON.parse(content);
|
|
372
|
+
pass("components-json", "components.json exists and is valid JSON");
|
|
373
|
+
return parsed;
|
|
374
|
+
} catch (e) {
|
|
375
|
+
fail(
|
|
376
|
+
"components-json",
|
|
377
|
+
"components.json exists but is not valid JSON",
|
|
378
|
+
e.message,
|
|
379
|
+
);
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function checkRegistryMapping(componentsJson) {
|
|
385
|
+
if (!componentsJson) {
|
|
386
|
+
skip("registry-mapping", "Skipped (components.json not available)");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const registries = componentsJson.registries;
|
|
390
|
+
const hasRegistryUrl =
|
|
391
|
+
JSON.stringify(componentsJson).includes("eco-design-system");
|
|
392
|
+
if (registries?.["@dtn"]) {
|
|
393
|
+
pass(
|
|
394
|
+
"registry-mapping",
|
|
395
|
+
`@dtn registry mapping found: ${registries["@dtn"].url || registries["@dtn"]}`,
|
|
396
|
+
);
|
|
397
|
+
} else if (hasRegistryUrl) {
|
|
398
|
+
pass(
|
|
399
|
+
"registry-mapping",
|
|
400
|
+
"DTN registry URL found in components.json configuration",
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
warn(
|
|
404
|
+
"registry-mapping",
|
|
405
|
+
"No explicit @dtn registry mapping found in components.json",
|
|
406
|
+
`Components can still be installed with --registry flag: npx shadcn@latest add button --registry ${REGISTRY_BASE_URL}/r/registry.json`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function checkTailwind() {
|
|
412
|
+
const pkgContent = readProjectFile("package.json");
|
|
413
|
+
if (!pkgContent) {
|
|
414
|
+
fail(
|
|
415
|
+
"tailwind-installed",
|
|
416
|
+
"package.json not found — cannot verify Tailwind installation",
|
|
417
|
+
);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const pkg = JSON.parse(pkgContent);
|
|
421
|
+
const allDeps = {
|
|
422
|
+
...pkg.dependencies,
|
|
423
|
+
...pkg.devDependencies,
|
|
424
|
+
...pkg.peerDependencies,
|
|
425
|
+
};
|
|
426
|
+
if (!allDeps.tailwindcss) {
|
|
427
|
+
fail(
|
|
428
|
+
"tailwind-installed",
|
|
429
|
+
"tailwindcss not found in dependencies",
|
|
430
|
+
"Install with: npm install -D tailwindcss @tailwindcss/postcss postcss",
|
|
431
|
+
);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const twVersion = allDeps.tailwindcss;
|
|
435
|
+
if (
|
|
436
|
+
twVersion.includes("4") ||
|
|
437
|
+
twVersion.startsWith("^4") ||
|
|
438
|
+
twVersion.startsWith("~4")
|
|
439
|
+
) {
|
|
440
|
+
pass("tailwind-installed", `Tailwind CSS v4 found: ${twVersion}`);
|
|
441
|
+
} else if (twVersion.includes("3") || twVersion.startsWith("^3")) {
|
|
442
|
+
warn(
|
|
443
|
+
"tailwind-installed",
|
|
444
|
+
`Tailwind CSS v3 found (${twVersion}) — DTN registry uses v4 with @theme inline`,
|
|
445
|
+
"Consider upgrading to Tailwind v4 for full compatibility.",
|
|
446
|
+
);
|
|
447
|
+
} else {
|
|
448
|
+
pass("tailwind-installed", `Tailwind CSS found: ${twVersion}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function checkShadcnSetup(componentsJson) {
|
|
453
|
+
if (!componentsJson) {
|
|
454
|
+
skip("shadcn-setup", "Skipped (components.json not available)");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (componentsJson.style) {
|
|
458
|
+
pass(
|
|
459
|
+
"shadcn-setup",
|
|
460
|
+
`shadcn/ui configured with style: "${componentsJson.style}"`,
|
|
461
|
+
);
|
|
462
|
+
} else {
|
|
463
|
+
warn(
|
|
464
|
+
"shadcn-setup",
|
|
465
|
+
"No style field in components.json",
|
|
466
|
+
"DTN registry uses 'new-york' style. Run `npx shadcn@latest init` to configure.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function checkGlobalCss(componentsJson) {
|
|
472
|
+
const cssPath = componentsJson?.tailwind?.css || componentsJson?.css;
|
|
473
|
+
const candidates = [
|
|
474
|
+
cssPath,
|
|
475
|
+
"src/app/globals.css",
|
|
476
|
+
"app/globals.css",
|
|
477
|
+
"src/styles/globals.css",
|
|
478
|
+
"styles/globals.css",
|
|
479
|
+
"src/index.css",
|
|
480
|
+
"index.css",
|
|
481
|
+
].filter(Boolean);
|
|
482
|
+
|
|
483
|
+
let foundCssPath = null;
|
|
484
|
+
let cssContent = null;
|
|
485
|
+
for (const candidate of candidates) {
|
|
486
|
+
const content = readProjectFile(candidate);
|
|
487
|
+
if (content !== null) {
|
|
488
|
+
foundCssPath = candidate;
|
|
489
|
+
cssContent = content;
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!cssContent) {
|
|
495
|
+
fail(
|
|
496
|
+
"global-css",
|
|
497
|
+
"No global CSS file found",
|
|
498
|
+
`Checked: ${candidates.join(", ")}. Create a globals.css with Tailwind imports.`,
|
|
499
|
+
);
|
|
500
|
+
return { cssContent: null, cssPath: null };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const hasTailwindImport =
|
|
504
|
+
cssContent.includes('@import "tailwindcss"') ||
|
|
505
|
+
cssContent.includes("@import 'tailwindcss'") ||
|
|
506
|
+
cssContent.includes("@tailwind base") ||
|
|
507
|
+
cssContent.includes("@tailwind components") ||
|
|
508
|
+
cssContent.includes("@tailwind utilities");
|
|
509
|
+
|
|
510
|
+
if (hasTailwindImport) {
|
|
511
|
+
pass(
|
|
512
|
+
"global-css",
|
|
513
|
+
`Global CSS found at ${foundCssPath} with Tailwind imports`,
|
|
514
|
+
);
|
|
515
|
+
} else {
|
|
516
|
+
fail(
|
|
517
|
+
"global-css",
|
|
518
|
+
`Global CSS found at ${foundCssPath} but missing Tailwind imports`,
|
|
519
|
+
'Add `@import "tailwindcss";` at the top of your CSS file (Tailwind v4) or @tailwind directives (v3).',
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return { cssContent, cssPath: foundCssPath };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function checkThemeTokens(cssContent, cssPath, expectedTokens) {
|
|
526
|
+
if (!cssContent) {
|
|
527
|
+
skip("theme-tokens", "Skipped (no global CSS found)");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const missingTokens = expectedTokens.filter((t) => !cssContent.includes(t));
|
|
531
|
+
const total = expectedTokens.length;
|
|
532
|
+
const presentCount = total - missingTokens.length;
|
|
533
|
+
|
|
534
|
+
if (missingTokens.length === 0) {
|
|
535
|
+
pass("theme-tokens", `All ${total} DTN theme tokens present in global CSS`);
|
|
536
|
+
} else if (missingTokens.length <= Math.ceil(total * 0.1)) {
|
|
537
|
+
const sample = missingTokens.slice(0, 5).join(", ");
|
|
538
|
+
warn(
|
|
539
|
+
"theme-tokens",
|
|
540
|
+
`${missingTokens.length}/${total} theme tokens missing from ${cssPath} (sample: ${sample})`,
|
|
541
|
+
`Install the DTN theme: npx shadcn@latest add theme --registry ${REGISTRY_BASE_URL}/r/registry.json --overwrite`,
|
|
542
|
+
);
|
|
543
|
+
} else {
|
|
544
|
+
const sample = missingTokens.slice(0, 5).join(", ");
|
|
545
|
+
fail(
|
|
546
|
+
"theme-tokens",
|
|
547
|
+
`${missingTokens.length}/${total} theme tokens missing from ${cssPath} (${presentCount} present, sample missing: ${sample})`,
|
|
548
|
+
`Install the DTN theme: npx shadcn@latest add theme --registry ${REGISTRY_BASE_URL}/r/registry.json --overwrite`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function checkThemeInline(cssContent, cssPath) {
|
|
554
|
+
if (!cssContent) {
|
|
555
|
+
skip("theme-inline", "Skipped (no global CSS found)");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (cssContent.includes("@theme inline") || cssContent.includes("@theme")) {
|
|
559
|
+
pass(
|
|
560
|
+
"theme-inline",
|
|
561
|
+
"@theme directive found in global CSS (Tailwind v4 compatible)",
|
|
562
|
+
);
|
|
563
|
+
} else if (cssContent.includes("@tailwind base")) {
|
|
564
|
+
warn(
|
|
565
|
+
"theme-inline",
|
|
566
|
+
"Using Tailwind v3 directives — DTN registry uses @theme inline (Tailwind v4)",
|
|
567
|
+
"If using Tailwind v4, add `@theme inline { ... }` block with your design tokens.",
|
|
568
|
+
);
|
|
569
|
+
} else {
|
|
570
|
+
warn(
|
|
571
|
+
"theme-inline",
|
|
572
|
+
`No @theme inline directive found in ${cssPath}`,
|
|
573
|
+
"DTN theme uses Tailwind v4's @theme inline for token registration. This may affect utility class generation.",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function checkComponentTokens(cssContent) {
|
|
579
|
+
if (!cssContent) {
|
|
580
|
+
skip("component-tokens", "Skipped (no global CSS found)");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const componentPrefix = `--${componentName}`;
|
|
584
|
+
if (cssContent.includes(componentPrefix)) {
|
|
585
|
+
pass(
|
|
586
|
+
"component-tokens",
|
|
587
|
+
`Component-specific tokens found for "${componentName}" (${componentPrefix}-*)`,
|
|
588
|
+
);
|
|
589
|
+
} else if (componentName === "button") {
|
|
590
|
+
if (
|
|
591
|
+
cssContent.includes("--button-primary") ||
|
|
592
|
+
cssContent.includes("--button-")
|
|
593
|
+
) {
|
|
594
|
+
pass("component-tokens", "Button-specific tokens found (--button-*)");
|
|
595
|
+
} else {
|
|
596
|
+
warn(
|
|
597
|
+
"component-tokens",
|
|
598
|
+
"No button-specific tokens (--button-*) found in global CSS",
|
|
599
|
+
"The DTN button uses dedicated CSS variables. Install theme to get them.",
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
} else {
|
|
603
|
+
skip(
|
|
604
|
+
"component-tokens",
|
|
605
|
+
`No dedicated tokens for "${componentName}" (not all components require them)`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function checkComponentMatch(componentsJson, packaged) {
|
|
611
|
+
if (!componentsJson) {
|
|
612
|
+
skip("component-match", "Skipped (components.json not available)");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!packaged) {
|
|
616
|
+
skip(
|
|
617
|
+
"component-match",
|
|
618
|
+
`"${componentName}" not found in registry metadata — cannot compare`,
|
|
619
|
+
);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Blocks: verify file targets exist locally (exact comparison is too broad)
|
|
624
|
+
if (packaged.type === "registry:block") {
|
|
625
|
+
const mainFiles = (packaged.files || []).filter(
|
|
626
|
+
(f) => f.target && !f.target.includes("demo"),
|
|
627
|
+
);
|
|
628
|
+
const missingTargets = [];
|
|
629
|
+
for (const file of mainFiles.slice(0, 5)) {
|
|
630
|
+
if (!resolveTarget(file.target, componentsJson))
|
|
631
|
+
missingTargets.push(file.target);
|
|
632
|
+
}
|
|
633
|
+
if (missingTargets.length === 0) {
|
|
634
|
+
pass(
|
|
635
|
+
"component-match",
|
|
636
|
+
`Block "${componentName}" — checked ${mainFiles.length} file targets, all found locally`,
|
|
637
|
+
);
|
|
638
|
+
} else {
|
|
639
|
+
warn(
|
|
640
|
+
"component-match",
|
|
641
|
+
`Block "${componentName}" — ${missingTargets.length} target(s) not found locally: ${missingTargets.slice(0, 3).join(", ")}`,
|
|
642
|
+
`Install with: npx shadcn@latest add ${componentName} --registry ${REGISTRY_BASE_URL}/r/registry.json --overwrite`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ui/component: find main file and compare content
|
|
649
|
+
const mainFile = (packaged.files || []).find(
|
|
650
|
+
(f) => f.target && !f.target.includes("demo"),
|
|
651
|
+
);
|
|
652
|
+
if (!mainFile) {
|
|
653
|
+
skip(
|
|
654
|
+
"component-match",
|
|
655
|
+
`"${componentName}" has no installable target in registry metadata`,
|
|
656
|
+
);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const localFile = resolveTarget(mainFile.target, componentsJson);
|
|
661
|
+
if (!localFile) {
|
|
662
|
+
fail(
|
|
663
|
+
"component-match",
|
|
664
|
+
`Component "${componentName}" not found locally (expected at ${mainFile.target})`,
|
|
665
|
+
`Install with: npx shadcn@latest add ${componentName} --registry ${REGISTRY_BASE_URL}/r/registry.json --overwrite`,
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (mainFile.content) {
|
|
671
|
+
const localContent = fs.readFileSync(localFile, "utf8");
|
|
672
|
+
if (localContent === mainFile.content) {
|
|
673
|
+
pass(
|
|
674
|
+
"component-match",
|
|
675
|
+
`Local "${componentName}" matches registry version exactly`,
|
|
676
|
+
);
|
|
677
|
+
} else {
|
|
678
|
+
warn(
|
|
679
|
+
"component-match",
|
|
680
|
+
`Local "${componentName}" differs from packaged DTN registry item (may be customized or outdated)`,
|
|
681
|
+
`To sync: npx shadcn@latest add ${componentName} --registry ${REGISTRY_BASE_URL}/r/registry.json --overwrite`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
pass(
|
|
686
|
+
"component-match",
|
|
687
|
+
`Component "${componentName}" found locally at ${path.relative(projectPath, localFile)}`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function checkDependencies(packaged, registryIndex) {
|
|
693
|
+
const pkgContent = readProjectFile("package.json");
|
|
694
|
+
if (!pkgContent) {
|
|
695
|
+
skip("dependencies", "Skipped (no package.json)");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const pkg = JSON.parse(pkgContent);
|
|
699
|
+
const projectDeps = {
|
|
700
|
+
...pkg.dependencies,
|
|
701
|
+
...pkg.devDependencies,
|
|
702
|
+
...pkg.peerDependencies,
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
const requiredSet = new Set();
|
|
706
|
+
if (packaged) {
|
|
707
|
+
for (const dep of packaged.dependencies || []) requiredSet.add(dep);
|
|
708
|
+
for (const dep of packaged.devDependencies || []) requiredSet.add(dep);
|
|
709
|
+
for (const file of packaged.files || []) {
|
|
710
|
+
if (file.content) {
|
|
711
|
+
for (const p of extractBareImports(file.content)) requiredSet.add(p);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// First-level registryDependencies
|
|
715
|
+
for (const dep of packaged.registryDependencies || []) {
|
|
716
|
+
const depName = dep.includes("/")
|
|
717
|
+
? dep.split("/").pop().replace(".json", "")
|
|
718
|
+
: dep;
|
|
719
|
+
const depItem = registryIndex?.items?.find((i) => i.name === depName);
|
|
720
|
+
if (depItem) {
|
|
721
|
+
for (const d of depItem.dependencies || []) requiredSet.add(d);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
requiredSet.add("class-variance-authority");
|
|
726
|
+
requiredSet.add("tailwind-merge");
|
|
727
|
+
requiredSet.add("clsx");
|
|
728
|
+
|
|
729
|
+
const builtins = new Set(["react", "react-dom", "next"]);
|
|
730
|
+
const allRequired = [...requiredSet].filter((d) => !builtins.has(d));
|
|
731
|
+
const missing = allRequired.filter((dep) => !projectDeps[dep]);
|
|
732
|
+
|
|
733
|
+
if (missing.length === 0) {
|
|
734
|
+
pass(
|
|
735
|
+
"dependencies",
|
|
736
|
+
`All required dependencies resolved (${allRequired.length} checked)`,
|
|
737
|
+
);
|
|
738
|
+
} else {
|
|
739
|
+
const sample = missing.slice(0, 8).join(", ");
|
|
740
|
+
const extra = missing.length > 8 ? ` (+${missing.length - 8} more)` : "";
|
|
741
|
+
warn(
|
|
742
|
+
"dependencies",
|
|
743
|
+
`${missing.length} missing dependencies: ${sample}${extra}`,
|
|
744
|
+
`Install with: npm install ${missing.slice(0, 10).join(" ")}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function checkFontSetup() {
|
|
750
|
+
const layoutCandidates = [
|
|
751
|
+
"src/app/layout.tsx",
|
|
752
|
+
"src/app/layout.jsx",
|
|
753
|
+
"app/layout.tsx",
|
|
754
|
+
"app/layout.jsx",
|
|
755
|
+
"src/pages/_app.tsx",
|
|
756
|
+
"src/pages/_app.jsx",
|
|
757
|
+
"pages/_app.tsx",
|
|
758
|
+
"pages/_app.jsx",
|
|
759
|
+
];
|
|
760
|
+
let layoutContent = null;
|
|
761
|
+
let layoutPath = null;
|
|
762
|
+
for (const candidate of layoutCandidates) {
|
|
763
|
+
const content = readProjectFile(candidate);
|
|
764
|
+
if (content !== null) {
|
|
765
|
+
layoutContent = content;
|
|
766
|
+
layoutPath = candidate;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (!layoutContent) {
|
|
771
|
+
warn(
|
|
772
|
+
"font-setup",
|
|
773
|
+
"No layout file found — cannot verify font configuration",
|
|
774
|
+
`Checked: ${layoutCandidates.slice(0, 4).join(", ")}`,
|
|
775
|
+
);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const hasInter =
|
|
779
|
+
layoutContent.includes("Inter") || layoutContent.includes("inter");
|
|
780
|
+
const hasGeist =
|
|
781
|
+
layoutContent.includes("Geist") || layoutContent.includes("geist");
|
|
782
|
+
const hasFontSansBinding =
|
|
783
|
+
layoutContent.includes("--font-sans") ||
|
|
784
|
+
layoutContent.includes("font-sans");
|
|
785
|
+
|
|
786
|
+
if (hasInter && hasFontSansBinding) {
|
|
787
|
+
pass(
|
|
788
|
+
"font-setup",
|
|
789
|
+
`Inter font loaded and bound to --font-sans in ${layoutPath}`,
|
|
790
|
+
);
|
|
791
|
+
} else if (hasInter && !hasFontSansBinding) {
|
|
792
|
+
warn(
|
|
793
|
+
"font-setup",
|
|
794
|
+
`Inter font found in ${layoutPath} but --font-sans binding not detected`,
|
|
795
|
+
"Ensure Inter is bound to the --font-sans CSS variable for DTN theme compatibility.",
|
|
796
|
+
);
|
|
797
|
+
} else if (hasGeist && !hasInter) {
|
|
798
|
+
warn(
|
|
799
|
+
"font-setup",
|
|
800
|
+
`${layoutPath} loads Geist font (Next.js default) but DTN theme expects Inter`,
|
|
801
|
+
"Replace Geist with Inter and bind to --font-sans. Example:\n" +
|
|
802
|
+
" import { Inter } from 'next/font/google'\n" +
|
|
803
|
+
" const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })",
|
|
804
|
+
);
|
|
805
|
+
} else {
|
|
806
|
+
warn(
|
|
807
|
+
"font-setup",
|
|
808
|
+
`Inter font not detected in ${layoutPath}`,
|
|
809
|
+
"DTN theme expects Inter bound to --font-sans. Add:\n" +
|
|
810
|
+
" import { Inter } from 'next/font/google'\n" +
|
|
811
|
+
" const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })",
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function checkAppShellCssImport(cssPath) {
|
|
817
|
+
if (!cssPath) {
|
|
818
|
+
skip("app-shell-css", "Skipped (no global CSS path determined)");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const layoutCandidates = [
|
|
822
|
+
"src/app/layout.tsx",
|
|
823
|
+
"src/app/layout.jsx",
|
|
824
|
+
"app/layout.tsx",
|
|
825
|
+
"app/layout.jsx",
|
|
826
|
+
];
|
|
827
|
+
let layoutContent = null;
|
|
828
|
+
let layoutFilePath = null;
|
|
829
|
+
for (const candidate of layoutCandidates) {
|
|
830
|
+
const content = readProjectFile(candidate);
|
|
831
|
+
if (content !== null) {
|
|
832
|
+
layoutContent = content;
|
|
833
|
+
layoutFilePath = candidate;
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (!layoutContent) {
|
|
838
|
+
skip("app-shell-css", "No app layout found — cannot verify CSS import");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const cssFileName = path.basename(cssPath);
|
|
842
|
+
const hasCssImport =
|
|
843
|
+
layoutContent.includes(cssFileName) ||
|
|
844
|
+
layoutContent.includes("globals.css") ||
|
|
845
|
+
layoutContent.includes("global.css") ||
|
|
846
|
+
layoutContent.includes("index.css");
|
|
847
|
+
|
|
848
|
+
if (hasCssImport) {
|
|
849
|
+
pass("app-shell-css", `App shell (${layoutFilePath}) imports global CSS`);
|
|
850
|
+
} else {
|
|
851
|
+
fail(
|
|
852
|
+
"app-shell-css",
|
|
853
|
+
`App shell (${layoutFilePath}) does not appear to import global CSS`,
|
|
854
|
+
`Add: import "./${cssFileName}" or import "@/app/globals.css" to your root layout.`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function checkRegistryReachable() {
|
|
860
|
+
if (!liveCheck) {
|
|
861
|
+
skip(
|
|
862
|
+
"registry-reachable",
|
|
863
|
+
"Skipped (use --live flag to enable extra reachability checks)",
|
|
864
|
+
);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
await fetchUrl(registryUrl, 10000);
|
|
869
|
+
pass("registry-reachable", `Registry URL reachable: ${registryUrl}`);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
fail(
|
|
872
|
+
"registry-reachable",
|
|
873
|
+
`Registry URL unreachable: ${registryUrl}`,
|
|
874
|
+
`Error: ${err.message}. Check network connectivity.`,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function checkComponentCollisions(componentsJson, registryIndex) {
|
|
880
|
+
if (!componentsJson) {
|
|
881
|
+
skip("component-collisions", "Skipped (components.json not available)");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const hasSrcDir = fs.existsSync(resolveProjectPath("src"));
|
|
885
|
+
const uiDir = hasSrcDir
|
|
886
|
+
? resolveProjectPath("src", "components", "ui")
|
|
887
|
+
: resolveProjectPath("components", "ui");
|
|
888
|
+
|
|
889
|
+
if (!fs.existsSync(uiDir)) {
|
|
890
|
+
skip("component-collisions", "No components/ui directory found");
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const localComponents = fs
|
|
895
|
+
.readdirSync(uiDir)
|
|
896
|
+
.filter((f) => f.endsWith(".tsx") || f.endsWith(".ts"))
|
|
897
|
+
.map((f) => f.replace(/\.(tsx|ts)$/, ""));
|
|
898
|
+
|
|
899
|
+
const registryNames = (registryIndex?.items || [])
|
|
900
|
+
.filter((item) => item.type === "registry:ui")
|
|
901
|
+
.map((item) => item.name);
|
|
902
|
+
|
|
903
|
+
const potentialCollisions = localComponents.filter((name) =>
|
|
904
|
+
registryNames.includes(name),
|
|
905
|
+
);
|
|
906
|
+
if (potentialCollisions.length === 0) {
|
|
907
|
+
pass("component-collisions", "No component collisions detected");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Without packaged content (remote mode without fetching all items), we can only
|
|
912
|
+
// report the count of potential collisions, not whether they match.
|
|
913
|
+
// The collision detail check is best-effort: only runs when registry-root is available.
|
|
914
|
+
if (!registryRoot) {
|
|
915
|
+
skip(
|
|
916
|
+
"component-collisions",
|
|
917
|
+
`${potentialCollisions.length} local components overlap with registry names — run with --registry-root for content comparison`,
|
|
918
|
+
);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const genericComponents = [];
|
|
923
|
+
for (const name of potentialCollisions) {
|
|
924
|
+
const subDir = classifyItemType(
|
|
925
|
+
registryIndex.items.find((i) => i.name === name)?.type ?? "registry:ui",
|
|
926
|
+
);
|
|
927
|
+
const jsonPath = path.join(
|
|
928
|
+
path.resolve(registryRoot),
|
|
929
|
+
subDir,
|
|
930
|
+
`${name}.json`,
|
|
931
|
+
);
|
|
932
|
+
if (!fs.existsSync(jsonPath)) continue;
|
|
933
|
+
|
|
934
|
+
const packaged = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
|
935
|
+
const mainFile = (packaged.files || []).find(
|
|
936
|
+
(f) => f.target && !f.target.includes("demo") && f.content,
|
|
937
|
+
);
|
|
938
|
+
if (!mainFile) continue;
|
|
939
|
+
|
|
940
|
+
const targetBasename = path.basename(mainFile.target);
|
|
941
|
+
const localViaTarget = path.join(uiDir, targetBasename);
|
|
942
|
+
const localViaName = path.join(uiDir, `${name}.tsx`);
|
|
943
|
+
|
|
944
|
+
let localContent = null;
|
|
945
|
+
if (fs.existsSync(localViaTarget)) {
|
|
946
|
+
localContent = fs.readFileSync(localViaTarget, "utf8");
|
|
947
|
+
} else if (fs.existsSync(localViaName)) {
|
|
948
|
+
localContent = fs.readFileSync(localViaName, "utf8");
|
|
949
|
+
}
|
|
950
|
+
if (!localContent) continue;
|
|
951
|
+
if (localContent === mainFile.content) continue;
|
|
952
|
+
genericComponents.push(name);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (genericComponents.length > 0) {
|
|
956
|
+
warn(
|
|
957
|
+
"component-collisions",
|
|
958
|
+
`${genericComponents.length} component(s) differ from packaged DTN registry: ${genericComponents.slice(0, 10).join(", ")}`,
|
|
959
|
+
`Overwrite with DTN versions: npx shadcn@latest add ${genericComponents.slice(0, 5).join(" ")} --registry ${REGISTRY_BASE_URL}/r/registry.json --overwrite`,
|
|
960
|
+
);
|
|
961
|
+
} else {
|
|
962
|
+
pass(
|
|
963
|
+
"component-collisions",
|
|
964
|
+
`${potentialCollisions.length} registry components found locally (all match packaged DTN versions)`,
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ---------------------------------------------------------------------------
|
|
970
|
+
// Main
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
async function main() {
|
|
974
|
+
const resolvedProject = path.resolve(projectPath);
|
|
975
|
+
if (!fs.existsSync(resolvedProject)) {
|
|
976
|
+
console.error(`❌ Project path does not exist: ${resolvedProject}`);
|
|
977
|
+
process.exit(1);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (!jsonOutput) {
|
|
981
|
+
console.log("");
|
|
982
|
+
console.log(
|
|
983
|
+
"═══════════════════════════════════════════════════════════════",
|
|
984
|
+
);
|
|
985
|
+
console.log(" DTN Registry Readiness Checker (EXPERIMENTAL)");
|
|
986
|
+
console.log(
|
|
987
|
+
"═══════════════════════════════════════════════════════════════",
|
|
988
|
+
);
|
|
989
|
+
console.log(` Project: ${resolvedProject}`);
|
|
990
|
+
console.log(` Component: ${componentName}`);
|
|
991
|
+
const metadataSource = registryRoot
|
|
992
|
+
? `local (${registryRoot})`
|
|
993
|
+
: registryUrl;
|
|
994
|
+
console.log(` Metadata: ${metadataSource}`);
|
|
995
|
+
console.log(
|
|
996
|
+
` Live: ${liveCheck ? "enabled" : "disabled (use --live)"}`,
|
|
997
|
+
);
|
|
998
|
+
console.log(
|
|
999
|
+
"═══════════════════════════════════════════════════════════════\n",
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Load registry metadata (local or remote)
|
|
1004
|
+
let registryIndex = null;
|
|
1005
|
+
let packaged = null;
|
|
1006
|
+
let theme = null;
|
|
1007
|
+
|
|
1008
|
+
try {
|
|
1009
|
+
registryIndex = await loadRegistryIndex();
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
const source =
|
|
1012
|
+
registryJsonPath ||
|
|
1013
|
+
(registryRoot
|
|
1014
|
+
? path.join(path.resolve(registryRoot), "registry.json")
|
|
1015
|
+
: registryUrl);
|
|
1016
|
+
if (jsonOutput) {
|
|
1017
|
+
console.log(
|
|
1018
|
+
JSON.stringify(
|
|
1019
|
+
{
|
|
1020
|
+
error: {
|
|
1021
|
+
message: "Failed to load registry index",
|
|
1022
|
+
details: err.message,
|
|
1023
|
+
source,
|
|
1024
|
+
},
|
|
1025
|
+
summary: {
|
|
1026
|
+
total: 0,
|
|
1027
|
+
passed: 0,
|
|
1028
|
+
failed: 1,
|
|
1029
|
+
warnings: 0,
|
|
1030
|
+
skipped: 0,
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
null,
|
|
1034
|
+
2,
|
|
1035
|
+
),
|
|
1036
|
+
);
|
|
1037
|
+
} else {
|
|
1038
|
+
console.error(`❌ Failed to load registry index: ${err.message}`);
|
|
1039
|
+
console.error(` Source: ${source}`);
|
|
1040
|
+
console.error(
|
|
1041
|
+
" Check network connectivity or provide --registry-root / --registry-json for local mode.",
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
packaged = await loadPackagedItem(componentName, registryIndex);
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
// Non-fatal: checks that need packaged data will skip
|
|
1051
|
+
if (!jsonOutput) {
|
|
1052
|
+
console.error(
|
|
1053
|
+
`⚠️ Could not load packaged item "${componentName}": ${err.message}`,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
theme = await loadTheme();
|
|
1060
|
+
} catch {
|
|
1061
|
+
// Non-fatal: theme token check will use fallback
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const expectedTokens = deriveThemeTokens(theme);
|
|
1065
|
+
|
|
1066
|
+
// Run all checks
|
|
1067
|
+
const componentsJson = checkComponentsJson();
|
|
1068
|
+
checkRegistryMapping(componentsJson);
|
|
1069
|
+
checkTailwind();
|
|
1070
|
+
checkShadcnSetup(componentsJson);
|
|
1071
|
+
const { cssContent, cssPath } = checkGlobalCss(componentsJson);
|
|
1072
|
+
checkThemeTokens(cssContent, cssPath, expectedTokens);
|
|
1073
|
+
checkThemeInline(cssContent, cssPath);
|
|
1074
|
+
checkComponentTokens(cssContent);
|
|
1075
|
+
checkComponentMatch(componentsJson, packaged);
|
|
1076
|
+
checkDependencies(packaged, registryIndex);
|
|
1077
|
+
checkFontSetup();
|
|
1078
|
+
checkAppShellCssImport(cssPath);
|
|
1079
|
+
await checkRegistryReachable();
|
|
1080
|
+
checkComponentCollisions(componentsJson, registryIndex);
|
|
1081
|
+
|
|
1082
|
+
// Output
|
|
1083
|
+
if (jsonOutput) {
|
|
1084
|
+
const output = {
|
|
1085
|
+
project: resolvedProject,
|
|
1086
|
+
component: componentName,
|
|
1087
|
+
timestamp: new Date().toISOString(),
|
|
1088
|
+
results,
|
|
1089
|
+
summary: {
|
|
1090
|
+
total: results.length,
|
|
1091
|
+
passed: results.filter((r) => r.status === "pass").length,
|
|
1092
|
+
failed: results.filter((r) => r.status === "fail").length,
|
|
1093
|
+
warnings: results.filter((r) => r.status === "warn").length,
|
|
1094
|
+
skipped: results.filter((r) => r.status === "skip").length,
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1098
|
+
} else {
|
|
1099
|
+
const statusIcons = { pass: "✅", fail: "❌", warn: "⚠️ ", skip: "⏭️ " };
|
|
1100
|
+
for (const result of results) {
|
|
1101
|
+
console.log(
|
|
1102
|
+
`${statusIcons[result.status]} [${result.id}] ${result.message}`,
|
|
1103
|
+
);
|
|
1104
|
+
if (
|
|
1105
|
+
result.details &&
|
|
1106
|
+
result.status !== "pass" &&
|
|
1107
|
+
result.status !== "skip"
|
|
1108
|
+
) {
|
|
1109
|
+
for (const line of result.details.split("\n")) {
|
|
1110
|
+
console.log(` ${line}`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
1116
|
+
const failed = results.filter((r) => r.status === "fail").length;
|
|
1117
|
+
const warnings = results.filter((r) => r.status === "warn").length;
|
|
1118
|
+
const skipped = results.filter((r) => r.status === "skip").length;
|
|
1119
|
+
|
|
1120
|
+
console.log("");
|
|
1121
|
+
console.log(
|
|
1122
|
+
"───────────────────────────────────────────────────────────────",
|
|
1123
|
+
);
|
|
1124
|
+
console.log(
|
|
1125
|
+
` Results: ${passed} passed, ${failed} failed, ${warnings} warnings, ${skipped} skipped`,
|
|
1126
|
+
);
|
|
1127
|
+
console.log(
|
|
1128
|
+
"───────────────────────────────────────────────────────────────",
|
|
1129
|
+
);
|
|
1130
|
+
|
|
1131
|
+
if (failed > 0) {
|
|
1132
|
+
console.log(
|
|
1133
|
+
"\n ❌ READINESS CHECK FAILED — Fix the issues above before using DTN components.\n",
|
|
1134
|
+
);
|
|
1135
|
+
} else if (warnings > 0) {
|
|
1136
|
+
console.log(
|
|
1137
|
+
"\n ⚠️ READINESS CHECK PASSED WITH WARNINGS — Components may work but review warnings.\n",
|
|
1138
|
+
);
|
|
1139
|
+
} else {
|
|
1140
|
+
console.log(
|
|
1141
|
+
"\n ✅ READINESS CHECK PASSED — Project is ready for DTN registry components.\n",
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
process.exit(results.some((r) => r.status === "fail") ? 1 : 0);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
main().catch((err) => {
|
|
1150
|
+
console.error("Unexpected error:", err);
|
|
1151
|
+
process.exit(1);
|
|
1152
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eco-ds/registry-doctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DTN Registry Readiness Checker — diagnoses whether a consuming project is correctly set up to use DTN Design System registry components (EXPERIMENTAL / DRAFT)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dtn",
|
|
7
|
+
"design-system",
|
|
8
|
+
"registry",
|
|
9
|
+
"shadcn",
|
|
10
|
+
"diagnostics",
|
|
11
|
+
"experimental"
|
|
12
|
+
],
|
|
13
|
+
"license": "UNLICENSED",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"eco-ds-registry-doctor": "bin/registry-doctor.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "restricted",
|
|
26
|
+
"registry": "https://registry.npmjs.org"
|
|
27
|
+
}
|
|
28
|
+
}
|