onsystem 0.4.0 → 0.6.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 +36 -15
- package/dist/cli.js +339 -180
- package/dist/config-lbfW91qB.d.ts +482 -0
- package/dist/{stdio-BcJth14k.js → design-system-A3pRX3Ka.js} +8439 -7212
- package/dist/eslint-worker.d.ts +36 -0
- package/dist/eslint-worker.js +95 -0
- package/dist/eslint.d.ts +23 -0
- package/dist/eslint.js +172 -0
- package/dist/index.d.ts +84 -387
- package/dist/index.js +3 -2
- package/dist/stdio-CEChNFLR.js +1396 -0
- package/dist/workspace-CHvUwgz9.js +227 -0
- package/package.json +22 -2
- package/schema.json +19 -1
package/dist/cli.js
CHANGED
|
@@ -1,154 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as toDtcg, B as DEFAULT_DOCS, C as updateBaseline, G as RULE_IDS, R as CONFIG_FILES, S as readBaseline, V as DEFAULT_SEVERITY, W as LEGACY_CONFIG_FILES, Z as rulesFor, _ as VERSION, _t as relativePath, at as detectProject, b as applyBaseline, c as silentLogger, et as LIBRARY_IDS, ft as plural, g as NAME, gt as matchesGlob, h as formatDiagnostics, ht as isInside, i as componentFiles, it as declaredConfig, k as toDesignMd, l as stderrLogger, m as uncheckedNotice, mt as unique, nt as PACKAGE_FIELD, o as loadDesignSystem, ot as findDesignSystemPackage, s as tokenFiles, st as isDesignSystemName, tt as libraryAdapter, v as BASELINE_FILE, vt as toPosix, w as writeBaseline, x as countBaseline, y as LEGACY_BASELINE_FILE, z as ConfigError } from "./design-system-A3pRX3Ka.js";
|
|
3
|
+
import { t as serveStdio } from "./stdio-CEChNFLR.js";
|
|
4
|
+
import { r as loadTarget } from "./workspace-CHvUwgz9.js";
|
|
3
5
|
import fs from "node:fs";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
import { escapePath, glob } from "tinyglobby";
|
|
6
|
-
import
|
|
8
|
+
import fsp from "node:fs/promises";
|
|
7
9
|
import { spawnSync } from "node:child_process";
|
|
8
10
|
import readline from "node:readline/promises";
|
|
9
11
|
import { parseArgs } from "node:util";
|
|
10
|
-
//#region src/baseline.ts
|
|
11
|
-
const BASELINE_FILE = "onsystem.baseline.json";
|
|
12
|
-
/** The baseline's name from before the rename to onsystem: still read when the new one is absent. */
|
|
13
|
-
const LEGACY_BASELINE_FILE = "design-system-mcp.baseline.json";
|
|
14
|
-
/**
|
|
15
|
-
* A finding's identity within its file and rule: the offending text with
|
|
16
|
-
* whitespace collapsed, and without the quotes of a string literal, so a
|
|
17
|
-
* formatter switching quote style does not make every finding look new.
|
|
18
|
-
*/
|
|
19
|
-
function key(diagnostic) {
|
|
20
|
-
const text = diagnostic.source.replace(/\s+/g, " ").trim();
|
|
21
|
-
return (/^(['"`])(.*)\1$/.exec(text)?.[2] ?? text).slice(0, 200);
|
|
22
|
-
}
|
|
23
|
-
/** The baseline's entries, or undefined when the file does not exist. */
|
|
24
|
-
function readBaseline(file) {
|
|
25
|
-
let text;
|
|
26
|
-
try {
|
|
27
|
-
text = fs.readFileSync(file, "utf8");
|
|
28
|
-
} catch (error) {
|
|
29
|
-
if (error.code === "ENOENT") return void 0;
|
|
30
|
-
throw new ConfigError(`Could not read baseline ${file}: ${error.message}`);
|
|
31
|
-
}
|
|
32
|
-
const invalid = (why) => new ConfigError(`${file} is not a valid onsystem baseline (${why}). Fix it, or delete it and run check --update-baseline.`);
|
|
33
|
-
let parsed;
|
|
34
|
-
try {
|
|
35
|
-
parsed = JSON.parse(text);
|
|
36
|
-
} catch (error) {
|
|
37
|
-
throw invalid(error.message);
|
|
38
|
-
}
|
|
39
|
-
if (!isObject(parsed) || parsed.version !== 1) throw invalid("expected \"version\": 1");
|
|
40
|
-
if (!isObject(parsed.files)) throw invalid("\"files\" must be an object");
|
|
41
|
-
const entries = /* @__PURE__ */ new Map();
|
|
42
|
-
for (const [name, rules] of Object.entries(parsed.files)) {
|
|
43
|
-
if (!isObject(rules)) throw invalid(`"${name}" must map rules to findings`);
|
|
44
|
-
const byRule = /* @__PURE__ */ new Map();
|
|
45
|
-
for (const [rule, sources] of Object.entries(rules)) {
|
|
46
|
-
if (!isObject(sources)) throw invalid(`"${name}" › "${rule}" must map findings to counts`);
|
|
47
|
-
const counts = /* @__PURE__ */ new Map();
|
|
48
|
-
for (const [source, count] of Object.entries(sources)) {
|
|
49
|
-
if (!Number.isInteger(count) || count < 1) throw invalid(`"${name}" › "${rule}" › "${source}" must be a positive whole number`);
|
|
50
|
-
counts.set(source, count);
|
|
51
|
-
}
|
|
52
|
-
byRule.set(rule, counts);
|
|
53
|
-
}
|
|
54
|
-
entries.set(name, byRule);
|
|
55
|
-
}
|
|
56
|
-
return entries;
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Removes the findings the baseline accepts for `file`. Within a file and
|
|
60
|
-
* rule, each entry accepts that many findings with the same source text, in
|
|
61
|
-
* order; any beyond the count are new. Entries of `disabled` rules are not
|
|
62
|
-
* counted as fixed: the rule is off, not satisfied.
|
|
63
|
-
*/
|
|
64
|
-
function applyBaseline(baseline, file, result, disabled = /* @__PURE__ */ new Set()) {
|
|
65
|
-
const remaining = /* @__PURE__ */ new Map();
|
|
66
|
-
for (const [rule, sources] of baseline.get(file) ?? []) {
|
|
67
|
-
if (disabled.has(rule)) continue;
|
|
68
|
-
for (const [source, count] of sources) remaining.set(`${rule}\0${source}`, count);
|
|
69
|
-
}
|
|
70
|
-
const diagnostics = [];
|
|
71
|
-
let baselined = 0;
|
|
72
|
-
for (const diagnostic of result.diagnostics) {
|
|
73
|
-
const id = `${diagnostic.ruleId}\0${key(diagnostic)}`;
|
|
74
|
-
const left = remaining.get(id) ?? 0;
|
|
75
|
-
if (left > 0) {
|
|
76
|
-
remaining.set(id, left - 1);
|
|
77
|
-
baselined++;
|
|
78
|
-
} else diagnostics.push(diagnostic);
|
|
79
|
-
}
|
|
80
|
-
let fixed = 0;
|
|
81
|
-
for (const count of remaining.values()) fixed += count;
|
|
82
|
-
return {
|
|
83
|
-
result: {
|
|
84
|
-
...result,
|
|
85
|
-
diagnostics,
|
|
86
|
-
errorCount: diagnostics.filter((d) => d.severity === "error").length,
|
|
87
|
-
warningCount: diagnostics.filter((d) => d.severity === "warning").length
|
|
88
|
-
},
|
|
89
|
-
baselined,
|
|
90
|
-
fixed
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* The baseline after a run over `checked` (file → its findings): those files
|
|
95
|
-
* get exactly their current findings, plus their previous entries for
|
|
96
|
-
* `disabled` rules; other files keep their entries while they still exist
|
|
97
|
-
* under `root`.
|
|
98
|
-
*/
|
|
99
|
-
function updateBaseline(previous, checked, root, disabled = /* @__PURE__ */ new Set()) {
|
|
100
|
-
const disabledIn = typeof disabled === "function" ? disabled : () => disabled;
|
|
101
|
-
const next = /* @__PURE__ */ new Map();
|
|
102
|
-
for (const [file, rules] of previous ?? []) if (!checked.has(file) && fs.existsSync(path.resolve(root, file))) next.set(file, rules);
|
|
103
|
-
for (const [file, diagnostics] of checked) {
|
|
104
|
-
const rules = /* @__PURE__ */ new Map();
|
|
105
|
-
const off = disabledIn(file);
|
|
106
|
-
for (const [rule, sources] of previous?.get(file) ?? []) if (off.has(rule)) rules.set(rule, sources);
|
|
107
|
-
for (const diagnostic of diagnostics) {
|
|
108
|
-
let sources = rules.get(diagnostic.ruleId);
|
|
109
|
-
if (!sources) rules.set(diagnostic.ruleId, sources = /* @__PURE__ */ new Map());
|
|
110
|
-
const id = key(diagnostic);
|
|
111
|
-
sources.set(id, (sources.get(id) ?? 0) + 1);
|
|
112
|
-
}
|
|
113
|
-
if (rules.size) next.set(file, rules);
|
|
114
|
-
}
|
|
115
|
-
return next;
|
|
116
|
-
}
|
|
117
|
-
function countBaseline(entries) {
|
|
118
|
-
let findings = 0;
|
|
119
|
-
for (const rules of entries.values()) for (const sources of rules.values()) for (const count of sources.values()) findings += count;
|
|
120
|
-
return {
|
|
121
|
-
findings,
|
|
122
|
-
files: entries.size
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
/** Writes the baseline sorted at every level, so it diffs cleanly in review. */
|
|
126
|
-
function writeBaseline(file, entries) {
|
|
127
|
-
const sorted = (map) => [...map].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
128
|
-
const files = {};
|
|
129
|
-
for (const [name, rules] of sorted(entries)) {
|
|
130
|
-
const byRule = {};
|
|
131
|
-
for (const [rule, sources] of sorted(rules)) byRule[rule] = Object.fromEntries(sorted(sources));
|
|
132
|
-
Object.defineProperty(files, name, {
|
|
133
|
-
value: byRule,
|
|
134
|
-
enumerable: true,
|
|
135
|
-
writable: true
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
const baseline = {
|
|
139
|
-
version: 1,
|
|
140
|
-
files
|
|
141
|
-
};
|
|
142
|
-
try {
|
|
143
|
-
fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
144
|
-
} catch (error) {
|
|
145
|
-
throw new ConfigError(`Could not write baseline ${file}: ${error.message}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
function isObject(value) {
|
|
149
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
-
}
|
|
151
|
-
//#endregion
|
|
152
12
|
//#region src/explain.ts
|
|
153
13
|
/**
|
|
154
14
|
* What `inspect --explain` says about one project: every candidate zero config looked
|
|
@@ -160,24 +20,32 @@ function explainProject(ds, cwd, indent = "") {
|
|
|
160
20
|
const lines = [];
|
|
161
21
|
const out = (line = "") => lines.push(line ? `${indent}${line}` : "");
|
|
162
22
|
out("How the design system was found");
|
|
23
|
+
for (const step of packageSteps(config, cwd)) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
|
|
163
24
|
if (config.configured) out(` components come from ${config.configFile ? display(cwd, config.configFile) : "--components"}, so zero config did not look`);
|
|
164
25
|
else {
|
|
165
26
|
const steps = [];
|
|
166
|
-
detectProject(config.root, config.tsconfig, steps);
|
|
27
|
+
detectProject(config.root, config.tsconfig, steps, config.designSystemPackage);
|
|
167
28
|
for (const step of steps) out(` ${(step.accepted ? "found" : "no").padEnd(6)} ${step.candidate}: ${step.reason}`);
|
|
168
29
|
if (!config.detected) out(` So the defaults apply: ${config.components.join(", ")}${model.stats.files.components ? "" : ", which match nothing"}.`);
|
|
169
30
|
}
|
|
31
|
+
const named = (library) => `${library.package}${library.version ? ` ${library.version}` : ""}`;
|
|
32
|
+
for (const library of config.libraries ?? []) {
|
|
33
|
+
const count = model.components.filter((c) => c.library === library.package).length;
|
|
34
|
+
out(library.dir ? ` ${"found".padEnd(6)} ${named(library)}: a dependency, ${count} components from its type declarations in ${display(cwd, library.dir)}` : ` ${"no".padEnd(6)} ${named(library)}: a dependency, but not installed`);
|
|
35
|
+
}
|
|
36
|
+
for (const library of config.librariesOff ?? []) out(` ${"no".padEnd(6)} ${named(library)}: a dependency, ${librariesLeftOut(config, cwd)}`);
|
|
170
37
|
out();
|
|
171
38
|
out("Resolved config");
|
|
172
39
|
const field = (name, value) => out(` ${name.padEnd(12)} ${value}`);
|
|
173
40
|
field("root", config.root);
|
|
174
|
-
field("config", config.configFile ? display(cwd, config.configFile) : "(none)");
|
|
41
|
+
field("config", config.configFile ? `${display(cwd, config.configFile)}${config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : ""}` : "(none)");
|
|
175
42
|
field("components", listOf$1(config.components) || "(none)");
|
|
176
43
|
field("exclude", listOf$1(config.exclude));
|
|
177
|
-
field("tokens", listOf$1(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}`)));
|
|
44
|
+
field("tokens", listOf$1(config.tokens.map((t) => `${t.path}${t.prefix ? ` (prefix ${t.prefix})` : ""}${t.fallback ? " (when nothing else is found)" : ""}`)));
|
|
178
45
|
field("docs", listOf$1(config.docs));
|
|
179
46
|
field("tsconfig", config.tsconfig ?? (fs.existsSync(path.join(config.root, "tsconfig.json")) ? "tsconfig.json" : "(none)"));
|
|
180
47
|
field("importPath", importPaths(ds));
|
|
48
|
+
field("libraries", config.libraryIds === void 0 ? `every library onsystem reads (${LIBRARY_IDS.join(", ")})` : config.libraryIds !== false && config.libraryIds.length ? config.libraryIds.join(", ") : `none ("libraries": ${JSON.stringify(config.libraryIds)})`);
|
|
181
49
|
if (config.tailwindConfig) field("tailwind", display(config.root, config.tailwindConfig));
|
|
182
50
|
if (config.designSystems) field("primary", config.designSystems.map((d, i) => `${i ? "then " : ""}${d.source} (${d.imports} imports)`).join("; "));
|
|
183
51
|
const changed = RULE_IDS.filter((id) => config.rules[id].severity !== DEFAULT_SEVERITY[id]);
|
|
@@ -188,7 +56,12 @@ function explainProject(ds, cwd, indent = "") {
|
|
|
188
56
|
out("Tokens");
|
|
189
57
|
const found = ds.tokens;
|
|
190
58
|
const files = model.stats.files.tokens;
|
|
191
|
-
|
|
59
|
+
const themed = found.filter((t) => t.origin === "library");
|
|
60
|
+
out(` ${found.length - themed.length} tokens from ${files} ${files === 1 ? "file" : "files"}${files ? "" : `: no file matches ${config.tokens.map((t) => t.path).join(", ")}`}`);
|
|
61
|
+
if (themed.length) {
|
|
62
|
+
const sources = [...new Set(themed.map((t) => t.source.file))];
|
|
63
|
+
out(` ${themed.length} tokens from a library's theme in ${sources.join(", ")}`);
|
|
64
|
+
}
|
|
192
65
|
const defined = new Set(found.map((t) => t.cssVar).filter((v) => Boolean(v)));
|
|
193
66
|
const missing = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] ?? "").filter((v) => !defined.has(v));
|
|
194
67
|
const unresolved = found.filter((t) => missing(t.value).length);
|
|
@@ -209,6 +82,10 @@ function explainProject(ds, cwd, indent = "") {
|
|
|
209
82
|
}
|
|
210
83
|
return lines;
|
|
211
84
|
}
|
|
85
|
+
/** Why a library the project depends on is not read: `left out by "libraries" in onsystem.config.json`. */
|
|
86
|
+
function librariesLeftOut(config, cwd) {
|
|
87
|
+
return `left out by "libraries" in ${config.configFile ? display(cwd, config.configFile) : "the config"}`;
|
|
88
|
+
}
|
|
212
89
|
/** The first few, then how many more: a package's exports can name hundreds of files. */
|
|
213
90
|
function listOf$1(items, max = 8) {
|
|
214
91
|
const shown = items.slice(0, max).join(", ");
|
|
@@ -235,15 +112,46 @@ function display(cwd, file) {
|
|
|
235
112
|
const rel = relativePath(cwd, file);
|
|
236
113
|
return rel && !rel.startsWith("..") ? rel : file;
|
|
237
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether a dependency's declared config was taken on, for a project without a config of its
|
|
117
|
+
* own: the package it came from and the others that declare one, or that none does.
|
|
118
|
+
*/
|
|
119
|
+
function packageSteps(config, cwd) {
|
|
120
|
+
const declared = config.designSystemPackage;
|
|
121
|
+
if (config.configFile && !declared) return [];
|
|
122
|
+
const lookup = findDesignSystemPackage(config.root);
|
|
123
|
+
if (!lookup) return [];
|
|
124
|
+
const candidate = "a design-system package's config";
|
|
125
|
+
const skipped = lookup.skipped.length ? `; ${lookup.skipped.map((p) => p.name).join(", ")} ${lookup.skipped.length === 1 ? "declares one" : "declare one"} that cannot be read, skipped since installed` : "";
|
|
126
|
+
if (!declared) return [{
|
|
127
|
+
candidate,
|
|
128
|
+
accepted: false,
|
|
129
|
+
reason: `no dependency declares one ("onsystem" in its package.json) among ${lookup.dependencies} ${lookup.dependencies === 1 ? "dependency" : "dependencies"}${skipped}`
|
|
130
|
+
}];
|
|
131
|
+
return [{
|
|
132
|
+
candidate,
|
|
133
|
+
accepted: true,
|
|
134
|
+
reason: `${declared.name} (${declared.installed ? "installed" : "a workspace package"}) declares ${display(cwd, declared.config)}, which this project takes on, with its paths from ${display(cwd, declared.dir)}${lookup.others.length ? `; ${lookup.others.join(", ")} also declare one, not read: one config is taken on, from the package the code imports most` : ""}${skipped}`
|
|
135
|
+
}];
|
|
136
|
+
}
|
|
238
137
|
//#endregion
|
|
239
138
|
//#region src/init.ts
|
|
240
139
|
const SCHEMA = "https://unpkg.com/onsystem/schema.json";
|
|
241
140
|
/** More detected globs than this are files, not folders: zero config keeps them. */
|
|
242
141
|
const MAX_GLOBS = 6;
|
|
142
|
+
/** What a design-system package's apps are told about it, next to its config. */
|
|
143
|
+
const GUIDE_FILE = "ONSYSTEM.md";
|
|
243
144
|
/**
|
|
244
145
|
* Writes `onsystem.config.json` in the root from what zero config finds there, or from
|
|
245
146
|
* the answers to up to three questions when it finds nothing, then checks the project
|
|
246
147
|
* once without failing and offers to record what it found as the baseline.
|
|
148
|
+
*
|
|
149
|
+
* In a design-system package (one zero config reads through its own `exports`, or named like
|
|
150
|
+
* one, as apps look for it), it also declares the config in package.json
|
|
151
|
+
* (`"onsystem": "./onsystem.config.json"`, and in `files` when the package lists them), so
|
|
152
|
+
* every app that depends on the package reads it, and writes ONSYSTEM.md, which tells those
|
|
153
|
+
* apps' teams how to add the agent tools and the CI check. In an app that takes on such a
|
|
154
|
+
* package's config, it writes no config of its own unless --force says to.
|
|
247
155
|
*/
|
|
248
156
|
async function init(configOptions, options, io, runCheck) {
|
|
249
157
|
const { config, workspace } = await loadTarget({
|
|
@@ -251,16 +159,23 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
251
159
|
config: void 0
|
|
252
160
|
});
|
|
253
161
|
const root = config.root;
|
|
254
|
-
const
|
|
162
|
+
const declared = declaredConfig(root);
|
|
163
|
+
const existing = [...CONFIG_FILES, ...LEGACY_CONFIG_FILES].map((name) => path.join(root, name)).find((file) => fs.existsSync(file)) ?? (declared && "file" in declared ? declared.file : void 0);
|
|
164
|
+
const manifest = readManifest(root);
|
|
165
|
+
const name = typeof manifest?.json.name === "string" ? manifest.json.name : void 0;
|
|
166
|
+
const designSystem = !workspace && !config.designSystemPackage && name !== void 0 && (config.detected?.startsWith("package.json exports of") === true || isDesignSystemName(name));
|
|
255
167
|
if (existing && !options.force) {
|
|
256
|
-
|
|
168
|
+
const share = designSystem && !declared && existing.endsWith(".json") ? ` To share it with the apps that depend on ${name}, add "${PACKAGE_FIELD}": "./${path.basename(existing)}" to package.json, and to "files" when it lists them (https://onsystem.vercel.app/docs/design-system-packages).` : "";
|
|
169
|
+
io.stderr(`init: ${relativePath(io.cwd, existing) || existing} already exists. Run \`${NAME} inspect --explain\` to see what it resolves to, or pass --force to write a new onsystem.config.json.${share}`);
|
|
257
170
|
return 2;
|
|
258
171
|
}
|
|
259
172
|
const ask = options.yes ? void 0 : io.prompt;
|
|
260
173
|
const file = { $schema: SCHEMA };
|
|
261
174
|
const notes = [];
|
|
262
175
|
const relative = (abs) => relativePath(root, abs);
|
|
263
|
-
|
|
176
|
+
const inherited = options.force ? void 0 : config.designSystemPackage;
|
|
177
|
+
if (inherited) notes.push(`This project takes on the config ${inherited.name} declares in its package.json (${relativePath(io.cwd, inherited.config) || inherited.config}), so it needs none of its own; nothing was written. Pass --force to write one, which is read instead.`);
|
|
178
|
+
else if (configOptions.components?.length) {
|
|
264
179
|
file.components = configOptions.components;
|
|
265
180
|
if (configOptions.tokens?.length) file.tokens = configOptions.tokens;
|
|
266
181
|
if (configOptions.docs?.length) file.docs = configOptions.docs;
|
|
@@ -278,6 +193,16 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
278
193
|
if (docs.length) file.docs = [...DEFAULT_DOCS, ...docs];
|
|
279
194
|
}
|
|
280
195
|
if (tokens.length) file.tokens = tokens;
|
|
196
|
+
} else if ((await componentFiles(config)).length) {
|
|
197
|
+
const matching = [];
|
|
198
|
+
for (const glob of config.components) if ((await componentFiles({
|
|
199
|
+
...config,
|
|
200
|
+
components: [glob]
|
|
201
|
+
})).length) matching.push(glob);
|
|
202
|
+
notes.push(`Found the design system in the default folders: ${matching.join(", ")}.`);
|
|
203
|
+
file.components = matching;
|
|
204
|
+
const tokens = (await tokenFiles(config)).map(relative);
|
|
205
|
+
if (tokens.length) file.tokens = tokens;
|
|
281
206
|
} else {
|
|
282
207
|
if (!ask) {
|
|
283
208
|
io.stderr(`init: zero config found no design system in ${root}. Pass --components (and --tokens) to say where it is, or run init in a terminal to answer a few questions. \`${NAME} inspect --explain\` shows what it looked at.`);
|
|
@@ -307,10 +232,19 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
307
232
|
const importPath = (await ask("How do apps import them? A package name such as @acme/ui, or leave it empty for tsconfig paths: ")).trim();
|
|
308
233
|
if (importPath) file.importPath = importPath;
|
|
309
234
|
}
|
|
310
|
-
const
|
|
311
|
-
|
|
235
|
+
const written = [];
|
|
236
|
+
if (!inherited) {
|
|
237
|
+
const out = path.join(root, CONFIG_FILE);
|
|
238
|
+
fs.writeFileSync(out, `${JSON.stringify(file, null, 2)}\n`);
|
|
239
|
+
written.push(CONFIG_FILE);
|
|
240
|
+
}
|
|
241
|
+
if (designSystem && manifest && name) {
|
|
242
|
+
written.push(...declarePackage(root, manifest, name, options.force, notes));
|
|
243
|
+
const shipped = await unshipped(config, manifest.json);
|
|
244
|
+
if (shipped) notes.push(shipped);
|
|
245
|
+
}
|
|
312
246
|
for (const note of notes) io.stdout(note);
|
|
313
|
-
io.stdout(`Wrote ${relativePath(io.cwd,
|
|
247
|
+
if (written.length) io.stdout(`Wrote ${written.map((f) => relativePath(io.cwd, path.join(root, f)) || f).join(", ")}.`);
|
|
314
248
|
const checkIo = {
|
|
315
249
|
...io,
|
|
316
250
|
cwd: root
|
|
@@ -342,9 +276,11 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
342
276
|
io.stdout(total ? `Dry run of \`${NAME} check .\`: ${count$1(errors, "error")} and ${count$1(warnings, "warning")} in ${flagged} of ${count$1(results.length, "file")}. By rule: ${[...byRule].sort((a, b) => b[1] - a[1]).map(([rule, n]) => `${rule} ${n.toLocaleString("en-US")}`).join(", ")}.` : `Dry run of \`${NAME} check .\`: no problems in ${count$1(results.length, "file")}.`);
|
|
343
277
|
for (const line of dry.stderr.split("\n").filter(Boolean)) io.stdout(line);
|
|
344
278
|
const hasBaseline = [BASELINE_FILE, LEGACY_BASELINE_FILE].some((name) => fs.existsSync(path.join(root, name)));
|
|
279
|
+
let baselineWritten = false;
|
|
345
280
|
if (total && !hasBaseline) {
|
|
346
|
-
const answer = options.yes ? "y" : ask ? (await ask(`Record these ${total.toLocaleString("en-US")} findings in ${BASELINE_FILE}, so check fails only on new ones? [Y/n] `)).trim().toLowerCase() :
|
|
347
|
-
if (answer ===
|
|
281
|
+
const answer = options.yes ? "y" : ask ? (await ask(`Record these ${total.toLocaleString("en-US")} findings in ${BASELINE_FILE}, so check fails only on new ones? [Y/n] `)).trim().toLowerCase() : void 0;
|
|
282
|
+
if (answer === void 0) io.stdout(`No terminal to ask in, so the baseline question was skipped and no baseline was written; \`${NAME} check . --update-baseline\` records one.`);
|
|
283
|
+
else if (answer === "" || answer.startsWith("y")) {
|
|
348
284
|
const recorded = await runCheck([
|
|
349
285
|
"check",
|
|
350
286
|
".",
|
|
@@ -352,17 +288,134 @@ async function init(configOptions, options, io, runCheck) {
|
|
|
352
288
|
...options.cache ? [] : ["--no-cache"]
|
|
353
289
|
], checkIo);
|
|
354
290
|
if (recorded.stdout) io.stdout(recorded.stdout);
|
|
291
|
+
baselineWritten = fs.existsSync(path.join(root, BASELINE_FILE));
|
|
355
292
|
} else io.stdout(`No baseline written; \`${NAME} check . --update-baseline\` records one later.`);
|
|
356
293
|
}
|
|
357
|
-
|
|
294
|
+
const commit = [...written, ...baselineWritten ? [BASELINE_FILE] : []];
|
|
295
|
+
const files = commit.length > 1 ? `${commit.slice(0, -1).join(", ")} and ${commit.at(-1) ?? ""}` : commit[0];
|
|
296
|
+
io.stdout(designSystem && name ? `Next: commit ${files}. An app that depends on ${name} now finds it with no config of its own: run \`npx ${NAME} inspect\` in one to see it, and give its team ${GUIDE_FILE}.` : `Next: ${files ? `commit ${files}, and ` : ""}run \`npx ${NAME} check . --format github --require-design-system\` in CI.`);
|
|
358
297
|
return 0;
|
|
359
298
|
}
|
|
299
|
+
const CONFIG_FILE = "onsystem.config.json";
|
|
300
|
+
function readManifest(root) {
|
|
301
|
+
const file = path.join(root, "package.json");
|
|
302
|
+
try {
|
|
303
|
+
const text = fs.readFileSync(file, "utf8");
|
|
304
|
+
const json = JSON.parse(text);
|
|
305
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return void 0;
|
|
306
|
+
return {
|
|
307
|
+
file,
|
|
308
|
+
json,
|
|
309
|
+
indent: /^([ \t]+)"/m.exec(text)?.[1] ?? " ",
|
|
310
|
+
newline: text.endsWith("\n")
|
|
311
|
+
};
|
|
312
|
+
} catch {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Declares the config in the package's package.json, `"onsystem": "./onsystem.config.json"`,
|
|
318
|
+
* and adds it to `files` when the package lists the files it publishes, so apps that install it
|
|
319
|
+
* get it too. Writes ONSYSTEM.md for the apps' teams unless one exists. Returns what it wrote.
|
|
320
|
+
*/
|
|
321
|
+
function declarePackage(root, manifest, name, force, notes) {
|
|
322
|
+
const written = [];
|
|
323
|
+
const { json } = manifest;
|
|
324
|
+
const value = `./${CONFIG_FILE}`;
|
|
325
|
+
let changed = false;
|
|
326
|
+
if (json["onsystem"] !== value) {
|
|
327
|
+
json[PACKAGE_FIELD] = value;
|
|
328
|
+
changed = true;
|
|
329
|
+
}
|
|
330
|
+
const files = Array.isArray(json.files) ? json.files : void 0;
|
|
331
|
+
if (files && !ships(files, CONFIG_FILE)) {
|
|
332
|
+
files.push(CONFIG_FILE);
|
|
333
|
+
changed = true;
|
|
334
|
+
}
|
|
335
|
+
if (changed) {
|
|
336
|
+
fs.writeFileSync(manifest.file, `${JSON.stringify(json, null, manifest.indent)}${manifest.newline ? "\n" : ""}`);
|
|
337
|
+
written.push("package.json");
|
|
338
|
+
}
|
|
339
|
+
notes.push(`This is a design-system package: package.json declares its config ("${PACKAGE_FIELD}": "${value}"${files ? ", also added to \"files\"" : ""}), which every app that depends on ${name} reads when it has no config of its own.`);
|
|
340
|
+
const guide = path.join(root, GUIDE_FILE);
|
|
341
|
+
if (force || !fs.existsSync(guide)) {
|
|
342
|
+
fs.writeFileSync(guide, consumerGuide(name));
|
|
343
|
+
written.push(GUIDE_FILE);
|
|
344
|
+
} else notes.push(`${GUIDE_FILE} already exists; it was left as it is (--force replaces it).`);
|
|
345
|
+
return written;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* What apps that install the package from a registry will not find: the component and token
|
|
349
|
+
* files its `files` list leaves out of the published package. Undefined for a private package,
|
|
350
|
+
* one without a `files` list (npm then ships everything), or when nothing is left out.
|
|
351
|
+
*/
|
|
352
|
+
async function unshipped(config, json) {
|
|
353
|
+
if (json.private === true || !Array.isArray(json.files)) return void 0;
|
|
354
|
+
const files = json.files;
|
|
355
|
+
const missing = [...await componentFiles(config), ...await tokenFiles(config)].map((f) => relativePath(config.root, f)).filter((f) => !f.startsWith("../")).filter((f) => !ships(files, f));
|
|
356
|
+
if (!missing.length) return void 0;
|
|
357
|
+
const folders = [...new Set(missing.map((f) => f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f))];
|
|
358
|
+
return `Apps that install ${typeof json.name === "string" ? json.name : "the package"} from a registry get only what "files" in package.json publishes, which leaves out ${folders.join(", ")} (${count$1(missing.length, "component or token file")}): they will not find those. Add ${folders.length === 1 ? "it" : "them"} to "files" for them; workspace apps read the sources either way.`;
|
|
359
|
+
}
|
|
360
|
+
/** Whether npm's `files` list publishes `file`, a path relative to the package. */
|
|
361
|
+
function ships(files, file) {
|
|
362
|
+
return files.some((entry) => {
|
|
363
|
+
if (typeof entry !== "string") return false;
|
|
364
|
+
const pattern = entry.replace(/^\.?\//, "").replace(/\/+$/, "");
|
|
365
|
+
if (!pattern || pattern.startsWith("!")) return false;
|
|
366
|
+
return file === pattern || file.startsWith(`${pattern}/`) || matchesGlob(file, pattern) || matchesGlob(file, `${pattern}/**`);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/** ONSYSTEM.md: how an app that uses the package gets the design system to its agents and CI. */
|
|
370
|
+
function consumerGuide(name) {
|
|
371
|
+
return `${[
|
|
372
|
+
`# onsystem in apps that use ${name}`,
|
|
373
|
+
"",
|
|
374
|
+
`${name} declares its [onsystem](https://onsystem.vercel.app) config in its package.json: \`"${PACKAGE_FIELD}": "./${CONFIG_FILE}"\`. An app that depends on ${name}, as a workspace package or installed from npm, reads that config when it has none of its own, and its UI is checked against ${name}'s components, props, variants and tokens. To see what an app finds, run this in its folder:`,
|
|
375
|
+
"",
|
|
376
|
+
"```sh",
|
|
377
|
+
`npx -y ${NAME} inspect`,
|
|
378
|
+
"```",
|
|
379
|
+
"",
|
|
380
|
+
`An app with an ${CONFIG_FILE} of its own reads that instead.`,
|
|
381
|
+
"",
|
|
382
|
+
"## Coding agents",
|
|
383
|
+
"",
|
|
384
|
+
"Claude Code: the plugin checks every UI file the agent writes and hands the errors back to fix, and adds the MCP server and a skill.",
|
|
385
|
+
"",
|
|
386
|
+
"```text",
|
|
387
|
+
"/plugin marketplace add dgesteves/onsystem",
|
|
388
|
+
"/plugin install onsystem@dgesteves",
|
|
389
|
+
"```",
|
|
390
|
+
"",
|
|
391
|
+
`Other agents: \`npx skills add dgesteves/onsystem\` adds the skill, and \`npx -y ${NAME}\` started in the app is the MCP server. [Set up your agent](https://onsystem.vercel.app/docs/clients) has the config for each client.`,
|
|
392
|
+
"",
|
|
393
|
+
"## CI",
|
|
394
|
+
"",
|
|
395
|
+
"In the app, record the findings it already has once, and commit the baseline, so pull requests fail only on new ones:",
|
|
396
|
+
"",
|
|
397
|
+
"```sh",
|
|
398
|
+
`npm install --save-dev ${NAME}`,
|
|
399
|
+
`npx ${NAME} check . --update-baseline`,
|
|
400
|
+
"```",
|
|
401
|
+
"",
|
|
402
|
+
"```yaml",
|
|
403
|
+
`- run: npx ${NAME} check . --format github --require-design-system`,
|
|
404
|
+
"```",
|
|
405
|
+
"",
|
|
406
|
+
"[CI and baselines](https://onsystem.vercel.app/docs/ci) has the GitHub Action and the rest.",
|
|
407
|
+
"",
|
|
408
|
+
"## Changing the config",
|
|
409
|
+
"",
|
|
410
|
+
`${CONFIG_FILE} in ${name} sets what every app gets: where the components, tokens and docs are, and the rules' severities. Its \`tsconfig\`, \`overrides\` and \`includeDesignSystem\` apply to ${name}'s own files only. [Design-system packages](https://onsystem.vercel.app/docs/design-system-packages) has the details.`
|
|
411
|
+
].join("\n")}\n`;
|
|
412
|
+
}
|
|
360
413
|
function count$1(n, word) {
|
|
361
414
|
return `${n.toLocaleString("en-US")} ${n === 1 ? word : `${word}s`}`;
|
|
362
415
|
}
|
|
363
416
|
//#endregion
|
|
364
417
|
//#region src/cli.ts
|
|
365
|
-
const DOCS = "https://
|
|
418
|
+
const DOCS = "https://onsystem.vercel.app/docs";
|
|
366
419
|
const HELP = `${NAME} ${VERSION}
|
|
367
420
|
|
|
368
421
|
Keeps coding agents on your React design system: checks UI code against your
|
|
@@ -374,6 +427,9 @@ Usage
|
|
|
374
427
|
onsystem check <paths...> Lint files, folders or globs with the check_ui rules
|
|
375
428
|
(for CI)
|
|
376
429
|
onsystem inspect Print what was extracted from the project
|
|
430
|
+
onsystem export --format dtcg|design-md
|
|
431
|
+
Print the tokens it found as a DTCG 2025.10 file or a
|
|
432
|
+
DESIGN.md (--out <file> writes them to a file)
|
|
377
433
|
onsystem init Write onsystem.config.json from what was found, run a
|
|
378
434
|
dry-run check and offer a baseline
|
|
379
435
|
onsystem help Show this help
|
|
@@ -392,7 +448,9 @@ Options
|
|
|
392
448
|
--docs <glob> Component docs (Markdown/MDX), repeatable
|
|
393
449
|
--no-cache Ignore the on-disk extraction cache
|
|
394
450
|
--no-watch serve: do not reload when files change
|
|
395
|
-
--format <format> check: pretty | json | github (default: pretty)
|
|
451
|
+
--format <format> check: pretty | json | github | sarif (default: pretty);
|
|
452
|
+
export: dtcg | design-md
|
|
453
|
+
--out <file> export: write to this file instead of stdout
|
|
396
454
|
--max-warnings <n> check: exit 1 when there are more than n warnings
|
|
397
455
|
--update-baseline check: record the current findings as accepted, and exit 0
|
|
398
456
|
--baseline <file> check: baseline file (default: ${BASELINE_FILE} in the root,
|
|
@@ -432,7 +490,8 @@ const defaultIo = {
|
|
|
432
490
|
stdout: (text) => process.stdout.write(`${text}\n`),
|
|
433
491
|
stderr: (text) => process.stderr.write(`${text}\n`),
|
|
434
492
|
color: process.stdout.isTTY && !process.env.NO_COLOR,
|
|
435
|
-
prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0
|
|
493
|
+
prompt: process.stdin.isTTY && process.stdout.isTTY ? askInTerminal : void 0,
|
|
494
|
+
stdinIsTTY: process.stdin.isTTY
|
|
436
495
|
};
|
|
437
496
|
/** One question on the terminal, answered with a line. */
|
|
438
497
|
async function askInTerminal(question) {
|
|
@@ -477,10 +536,8 @@ async function main(argv, io = defaultIo) {
|
|
|
477
536
|
type: "boolean",
|
|
478
537
|
default: true
|
|
479
538
|
},
|
|
480
|
-
format: {
|
|
481
|
-
|
|
482
|
-
default: "pretty"
|
|
483
|
-
},
|
|
539
|
+
format: { type: "string" },
|
|
540
|
+
out: { type: "string" },
|
|
484
541
|
"max-warnings": { type: "string" },
|
|
485
542
|
baseline: { type: "string" },
|
|
486
543
|
"update-baseline": {
|
|
@@ -562,15 +619,18 @@ async function main(argv, io = defaultIo) {
|
|
|
562
619
|
try {
|
|
563
620
|
switch (command) {
|
|
564
621
|
case "serve":
|
|
622
|
+
if (io.stdinIsTTY) io.stderr(`${NAME} is an MCP server: it is waiting for an agent on stdin. Add it to your agent (${DOCS}/clients), or run \`npx -y ${NAME} inspect\` or \`npx -y ${NAME} check .\` here. Ctrl+C quits.`);
|
|
565
623
|
await serveStdio({
|
|
566
624
|
...configOptions,
|
|
567
625
|
cache: values.cache,
|
|
568
626
|
watch: values.watch,
|
|
569
|
-
logger: stderrLogger
|
|
627
|
+
logger: stderrLogger,
|
|
628
|
+
...io.transport ? { transport: io.transport } : {}
|
|
570
629
|
});
|
|
571
630
|
return 0;
|
|
572
631
|
case "check": return await check(rest, configOptions, values, io);
|
|
573
632
|
case "inspect": return await inspect(configOptions, values, io);
|
|
633
|
+
case "export": return await exportTokens(configOptions, values, io);
|
|
574
634
|
case "init": return await init(configOptions, {
|
|
575
635
|
yes: values.yes,
|
|
576
636
|
force: values.force,
|
|
@@ -610,13 +670,14 @@ async function check(patterns, configOptions, values, io) {
|
|
|
610
670
|
io.stderr(`check: pass files, folders or globs, e.g. ${NAME} check .`);
|
|
611
671
|
return 2;
|
|
612
672
|
}
|
|
613
|
-
const format = values.format;
|
|
673
|
+
const format = values.format ?? "pretty";
|
|
614
674
|
if (![
|
|
615
675
|
"pretty",
|
|
616
676
|
"json",
|
|
617
|
-
"github"
|
|
677
|
+
"github",
|
|
678
|
+
"sarif"
|
|
618
679
|
].includes(format)) {
|
|
619
|
-
io.stderr(`check: unknown --format "${
|
|
680
|
+
io.stderr(`check: unknown --format "${format}" (pretty, json, github, sarif)`);
|
|
620
681
|
return 2;
|
|
621
682
|
}
|
|
622
683
|
const maxWarnings = values["max-warnings"] === void 0 ? void 0 : Number(values["max-warnings"]);
|
|
@@ -626,15 +687,15 @@ async function check(patterns, configOptions, values, io) {
|
|
|
626
687
|
}
|
|
627
688
|
const target = await loadTarget(configOptions);
|
|
628
689
|
warnDeprecated(target.config.deprecations, format, io);
|
|
690
|
+
warnDeprecated(configWarnings(target), format, io);
|
|
629
691
|
const selection = target.workspace ? await selectInWorkspace(target.workspace, patterns, values, format, io) : await selectInProject(target.config, patterns, values, format, io);
|
|
630
692
|
if (typeof selection === "number") return selection;
|
|
631
693
|
const { files, notes } = selection;
|
|
632
694
|
if (!files.length) {
|
|
633
|
-
if (format === "
|
|
634
|
-
else if (format === "pretty") {
|
|
695
|
+
if (format === "pretty") {
|
|
635
696
|
const hints = [selection.skipped ? `Pass --include-design-system to check ${selection.skipped === 1 ? "it" : "them"}.` : "", selection.testsLeftOut ? "Pass --include-tests to check tests and stories." : ""].filter(Boolean);
|
|
636
697
|
io.stdout(`Nothing to check: ${notes.join("; ")}. ${hints.join(" ")}`.trimEnd());
|
|
637
|
-
}
|
|
698
|
+
} else printNoResults(format, io);
|
|
638
699
|
return 0;
|
|
639
700
|
}
|
|
640
701
|
const update = values["update-baseline"];
|
|
@@ -658,7 +719,7 @@ async function check(patterns, configOptions, values, io) {
|
|
|
658
719
|
let baselined = 0;
|
|
659
720
|
let fixed = 0;
|
|
660
721
|
for (const file of files) {
|
|
661
|
-
const code = await
|
|
722
|
+
const code = await fsp.readFile(file, "utf8");
|
|
662
723
|
const ds = selection.designSystemOf(file);
|
|
663
724
|
let result = ds.check(code, relativePath(ds.root, file));
|
|
664
725
|
const key = baselineKey(file);
|
|
@@ -693,6 +754,7 @@ async function check(patterns, configOptions, values, io) {
|
|
|
693
754
|
}
|
|
694
755
|
const output = formatDiagnostics(format === "json" ? results : results.filter((r) => !r.skipped), format, {
|
|
695
756
|
color: io.color,
|
|
757
|
+
uri: format === "sarif" || format === "github" ? repositoryPath(io.cwd, io.env ?? process.env) : void 0,
|
|
696
758
|
baselined: baseline ? baselined : void 0,
|
|
697
759
|
notes,
|
|
698
760
|
fixedHint: fixed ? `${count(fixed, "baseline finding")} no longer ${fixed === 1 ? "occurs" : "occur"}: run \`check ${patterns.map(shellQuote).join(" ")} --update-baseline\` to drop ${fixed === 1 ? "it" : "them"}.` : void 0
|
|
@@ -713,6 +775,35 @@ async function check(patterns, configOptions, values, io) {
|
|
|
713
775
|
}
|
|
714
776
|
return 0;
|
|
715
777
|
}
|
|
778
|
+
/** What `json` and `sarif` print when there is nothing to report on: `[]`, or a log without results. */
|
|
779
|
+
function printNoResults(format, io) {
|
|
780
|
+
if (format === "json") io.stdout("[]");
|
|
781
|
+
else if (format === "sarif") io.stdout(formatDiagnostics([], "sarif"));
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Maps a path relative to the working directory to one relative to the repository root, which
|
|
785
|
+
* GitHub resolves workflow command annotations (`--format github`) and code scanning locations
|
|
786
|
+
* (`--format sarif`) against, so `check` run from `apps/web` still reports
|
|
787
|
+
* `apps/web/app/page.tsx`. The root is GITHUB_WORKSPACE or the git top-level, whichever is the
|
|
788
|
+
* innermost folder holding the file: the git top-level outside Actions, GITHUB_WORKSPACE where
|
|
789
|
+
* git is missing or refuses the checkout, and the repository checked out in a folder of the
|
|
790
|
+
* workspace (`actions/checkout` with `path:`). A file under neither keeps its path relative to
|
|
791
|
+
* the working directory.
|
|
792
|
+
*/
|
|
793
|
+
function repositoryPath(cwd, env) {
|
|
794
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
795
|
+
cwd,
|
|
796
|
+
encoding: "utf8",
|
|
797
|
+
windowsHide: true
|
|
798
|
+
});
|
|
799
|
+
const roots = [result.status === 0 && typeof result.stdout === "string" ? result.stdout.trim() : "", env.GITHUB_WORKSPACE ?? ""].filter(Boolean).map((dir) => realPath(path.resolve(cwd, dir))).sort((a, b) => b.length - a.length);
|
|
800
|
+
if (!roots.length) return toPosix;
|
|
801
|
+
return (file) => {
|
|
802
|
+
const absolute = realPath(path.resolve(cwd, file));
|
|
803
|
+
const root = roots.find((dir) => isInside(dir, absolute));
|
|
804
|
+
return root ? relativePath(root, absolute) : toPosix(file);
|
|
805
|
+
};
|
|
806
|
+
}
|
|
716
807
|
/** Splits the patterns into files named outright and globs (a folder: every TSX/JSX file under it). */
|
|
717
808
|
function expandPatterns(patterns, cwd) {
|
|
718
809
|
const named = [];
|
|
@@ -766,11 +857,11 @@ async function selectInProject(config, patterns, values, format, io) {
|
|
|
766
857
|
return 2;
|
|
767
858
|
}
|
|
768
859
|
if (values["quiet-without-design-system"] && !config.configFile && !ds.components.length) {
|
|
769
|
-
|
|
860
|
+
printNoResults(format, io);
|
|
770
861
|
return 0;
|
|
771
862
|
}
|
|
772
863
|
const notice = ds.notice();
|
|
773
|
-
if (notice && values["require-design-system"]) {
|
|
864
|
+
if (notice && values["require-design-system"] && !ds.hasDesignSystem()) {
|
|
774
865
|
io.stderr(`check --require-design-system: ${notice}`);
|
|
775
866
|
return 2;
|
|
776
867
|
}
|
|
@@ -851,13 +942,13 @@ async function selectInWorkspace(workspace, patterns, values, format, io) {
|
|
|
851
942
|
if (values["quiet-without-design-system"]) {
|
|
852
943
|
files = files.filter((file) => Boolean(project(file).config.configFile) || designSystemOf(file).components.length > 0);
|
|
853
944
|
if (!files.length) {
|
|
854
|
-
|
|
945
|
+
printNoResults(format, io);
|
|
855
946
|
return 0;
|
|
856
947
|
}
|
|
857
948
|
}
|
|
858
949
|
if (values["require-design-system"]) {
|
|
859
950
|
let complete = false;
|
|
860
|
-
for (const p of workspace.projects) if (
|
|
951
|
+
for (const p of workspace.projects) if ((await load(p)).hasDesignSystem()) {
|
|
861
952
|
complete = true;
|
|
862
953
|
break;
|
|
863
954
|
}
|
|
@@ -909,6 +1000,7 @@ async function inspect(configOptions, values, io) {
|
|
|
909
1000
|
const target = await loadTarget(configOptions);
|
|
910
1001
|
const { config } = target;
|
|
911
1002
|
warnDeprecated(config.deprecations, "pretty", io);
|
|
1003
|
+
warnDeprecated(configWarnings(target), "pretty", io);
|
|
912
1004
|
if (target.workspace) return inspectWorkspace(target.workspace, values, io);
|
|
913
1005
|
const ds = await loadDesignSystem(config, {
|
|
914
1006
|
cache: values.cache,
|
|
@@ -921,12 +1013,13 @@ async function inspect(configOptions, values, io) {
|
|
|
921
1013
|
}
|
|
922
1014
|
const lines = [
|
|
923
1015
|
`root ${model.root}`,
|
|
924
|
-
`config ${config.configFile ? displayPath(io.cwd, config.configFile) : "(defaults)"}`,
|
|
1016
|
+
`config ${config.configFile ? `${displayPath(io.cwd, config.configFile)}${declaredBy(config)}` : "(defaults)"}`,
|
|
925
1017
|
...config.detected ? [`detected ${config.detected}`] : [],
|
|
1018
|
+
...describeLibraries(config, model, io.cwd).map((line) => `library ${line}`),
|
|
926
1019
|
`files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`,
|
|
927
1020
|
`time ${model.stats.durationMs}ms${model.stats.fromCache ? " (cache)" : ""}`,
|
|
928
1021
|
"",
|
|
929
|
-
`Components (${ds.roots().length} + ${
|
|
1022
|
+
`Components (${ds.roots().length} + ${ds.components.length - ds.roots().length} parts)`
|
|
930
1023
|
];
|
|
931
1024
|
for (const c of ds.roots()) {
|
|
932
1025
|
const variants = c.variants.map((v) => `${v.name}[${v.values.length}]`).join(" ");
|
|
@@ -950,6 +1043,64 @@ async function inspect(configOptions, values, io) {
|
|
|
950
1043
|
io.stdout(lines.join("\n"));
|
|
951
1044
|
return 0;
|
|
952
1045
|
}
|
|
1046
|
+
/**
|
|
1047
|
+
* `MUI (@mui/material 9.4.0): 149 components from its type declarations`, per library the
|
|
1048
|
+
* project depends on, including those the config's `libraries` leaves out.
|
|
1049
|
+
*/
|
|
1050
|
+
function describeLibraries(config, model, cwd) {
|
|
1051
|
+
const name = (library) => `${libraryAdapter(library.package)?.title ?? library.package} (${library.package}${library.version ? ` ${library.version}` : ""})`;
|
|
1052
|
+
return [...(config.libraries ?? []).map((library) => {
|
|
1053
|
+
if (!library.dir) return `${name(library)}: a dependency, but not installed`;
|
|
1054
|
+
const count = model.components.filter((c) => c.library === library.package).length;
|
|
1055
|
+
return `${name(library)}: ${plural(count, "component")} from its type declarations`;
|
|
1056
|
+
}), ...(config.librariesOff ?? []).map((library) => `${name(library)}: ${librariesLeftOut(config, cwd)}`)];
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* `export --format dtcg|design-md`: the project's tokens as a DTCG 2025.10 file or a
|
|
1060
|
+
* DESIGN.md, on stdout or in the `--out` file.
|
|
1061
|
+
*/
|
|
1062
|
+
async function exportTokens(configOptions, values, io) {
|
|
1063
|
+
const format = values.format;
|
|
1064
|
+
if (format !== "dtcg" && format !== "design-md") {
|
|
1065
|
+
io.stderr(`export: pass --format dtcg or --format design-md${format === void 0 ? "" : ` (not "${format}")`}`);
|
|
1066
|
+
return 2;
|
|
1067
|
+
}
|
|
1068
|
+
const target = await loadTarget(configOptions);
|
|
1069
|
+
warnDeprecated(target.config.deprecations, "pretty", io);
|
|
1070
|
+
warnDeprecated(configWarnings(target), "pretty", io);
|
|
1071
|
+
if (target.workspace) {
|
|
1072
|
+
const dirs = target.workspace.projects.map((p) => p.dir);
|
|
1073
|
+
io.stderr(`export: ${target.workspace.root} is a monorepo root, and each project has its own tokens. Pass --root with one of them${dirs.length ? ` (${listOf(dirs)})` : ""}.`);
|
|
1074
|
+
return 2;
|
|
1075
|
+
}
|
|
1076
|
+
const ds = await loadDesignSystem(target.config, {
|
|
1077
|
+
cache: values.cache,
|
|
1078
|
+
logger: silentLogger
|
|
1079
|
+
});
|
|
1080
|
+
if (!ds.tokens.length) {
|
|
1081
|
+
io.stderr(`export: no design tokens found in ${ds.root}. \`${NAME} inspect --explain\` shows where it looked.`);
|
|
1082
|
+
return 2;
|
|
1083
|
+
}
|
|
1084
|
+
const result = format === "dtcg" ? toDtcg(ds.tokens) : toDesignMd(ds.tokens, { name: projectName(ds.root) });
|
|
1085
|
+
for (const warning of result.warnings) io.stderr(`export: ${warning}`);
|
|
1086
|
+
if (values.out === void 0) {
|
|
1087
|
+
io.stdout(result.text.trimEnd());
|
|
1088
|
+
return 0;
|
|
1089
|
+
}
|
|
1090
|
+
const file = path.resolve(io.cwd, values.out);
|
|
1091
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
1092
|
+
await fsp.writeFile(file, result.text);
|
|
1093
|
+
io.stdout(`Wrote ${plural(result.count, "token")} to ${displayPath(io.cwd, file)} (${format === "dtcg" ? "DTCG 2025.10" : "DESIGN.md"})`);
|
|
1094
|
+
return 0;
|
|
1095
|
+
}
|
|
1096
|
+
/** The package name in the root's package.json, else the folder's name. */
|
|
1097
|
+
function projectName(root) {
|
|
1098
|
+
try {
|
|
1099
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
1100
|
+
if (typeof pkg.name === "string" && pkg.name) return pkg.name;
|
|
1101
|
+
} catch {}
|
|
1102
|
+
return path.basename(root);
|
|
1103
|
+
}
|
|
953
1104
|
function tokenCounts(model) {
|
|
954
1105
|
const counts = /* @__PURE__ */ new Map();
|
|
955
1106
|
for (const t of model.tokens) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
|
|
@@ -990,8 +1141,8 @@ async function inspectWorkspace(workspace, values, io) {
|
|
|
990
1141
|
const { model } = ds;
|
|
991
1142
|
const roots = ds.roots();
|
|
992
1143
|
const tokens = [...tokenCounts(model)].map(([category, n]) => `${category} ${n}`);
|
|
993
|
-
const own = project.config.configFile && project.config.configFile !== workspace.configFile ? displayPath(workspace.root, project.config.configFile) : void 0;
|
|
994
|
-
lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...own ? [` config ${own}`] : [], ...project.config.detected ? [` detected ${project.config.detected}`] : [], ` files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`, ` components ${roots.length} + ${
|
|
1144
|
+
const own = project.config.configFile && project.config.configFile !== workspace.configFile ? `${displayPath(workspace.root, project.config.configFile)}${declaredBy(project.config)}` : void 0;
|
|
1145
|
+
lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...own ? [` config ${own}`] : [], ...project.config.detected ? [` detected ${project.config.detected}`] : [], ...describeLibraries(project.config, model, io.cwd).map((line) => ` library ${line}`), ` files ${plural(model.stats.files.components, "component file")}, ${plural(model.stats.files.tokens, "token file")}, ${plural(model.stats.files.docs, "doc")}`, ` components ${roots.length} + ${ds.components.length - roots.length} parts${roots.length ? `: ${listNames(roots.map((c) => c.name))}` : ""}`, ` tokens ${tokens.length ? tokens.join(", ") : "none"}`, ...model.warnings.length ? [` warnings ${model.warnings.length}: ${model.warnings[0] ?? ""}`] : []);
|
|
995
1146
|
}
|
|
996
1147
|
if (!loaded.length) lines.push("", `No workspace package has a design system that zero config finds. See ${DOCS}/troubleshooting`);
|
|
997
1148
|
if (workspace.others.length) lines.push("", `Without a design system (${workspace.others.length}): ${listNames(workspace.others)}`);
|
|
@@ -1013,6 +1164,10 @@ function explainWorkspace(workspace, loaded, cwd) {
|
|
|
1013
1164
|
for (const { project, ds } of loaded) lines.push("", `${project.dir}${project.name ? ` (${project.name})` : ""}`, ...explainProject(ds, cwd, " "));
|
|
1014
1165
|
return lines;
|
|
1015
1166
|
}
|
|
1167
|
+
/** ` (declared by @acme/ui)` after a config a design-system package declares; empty otherwise. */
|
|
1168
|
+
function declaredBy(config) {
|
|
1169
|
+
return config.designSystemPackage ? ` (declared by ${config.designSystemPackage.name})` : "";
|
|
1170
|
+
}
|
|
1016
1171
|
/** `A, B, C` up to a dozen names, then `and 40 more`. */
|
|
1017
1172
|
function listNames(names, max = 12) {
|
|
1018
1173
|
const shown = names.slice(0, max).join(", ");
|
|
@@ -1022,6 +1177,10 @@ function listNames(names, max = 12) {
|
|
|
1022
1177
|
* Says once what a file's old name means: on stderr, or as a workflow warning with
|
|
1023
1178
|
* `--format github` so it shows on the run. Never on stdout otherwise, which JSON output owns.
|
|
1024
1179
|
*/
|
|
1180
|
+
/** What the configs read have to say: the project's, or every package's at a monorepo root. */
|
|
1181
|
+
function configWarnings(target) {
|
|
1182
|
+
return target.workspace ? target.workspace.warnings : target.config.warnings;
|
|
1183
|
+
}
|
|
1025
1184
|
function warnDeprecated(messages, format, io) {
|
|
1026
1185
|
for (const text of messages ?? []) if (format === "github") io.stdout(`::warning title=${NAME}::${text}`);
|
|
1027
1186
|
else io.stderr(text);
|