@getvitops/cli 0.9.0 → 2.0.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/CHANGELOG.md +440 -0
- package/README.md +28 -10
- package/dist/cli.mjs +700 -100
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +57 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync,
|
|
5
|
-
import { dirname,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, parse, relative, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isSiteConfig, resolveInput, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
|
|
8
|
+
import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
|
|
8
9
|
|
|
9
10
|
//#region src/agents.ts
|
|
10
11
|
/**
|
|
@@ -39,6 +40,10 @@ const TOPICS = {
|
|
|
39
40
|
path: "concepts/scales.md",
|
|
40
41
|
summary: "fluid modular type & space scales"
|
|
41
42
|
},
|
|
43
|
+
icons: {
|
|
44
|
+
path: "concepts/icons.md",
|
|
45
|
+
summary: "semantic icon names across sets, bundle derivation, sprite delivery"
|
|
46
|
+
},
|
|
42
47
|
patterns: {
|
|
43
48
|
path: "concepts/patterns.md",
|
|
44
49
|
summary: "pattern CSS: token cascade, override hooks, states, role variants"
|
|
@@ -94,53 +99,314 @@ function linkSkill(linkPath, targetDir) {
|
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/css-scan.ts
|
|
104
|
+
/** Strip comments, preserving newlines so line numbers survive. */
|
|
105
|
+
function stripComments(css) {
|
|
106
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
|
|
107
|
+
}
|
|
108
|
+
const lineAt = (src, index) => src.slice(0, index).split("\n").length;
|
|
109
|
+
/**
|
|
110
|
+
* Parse declarations from a rule body. Splits on top-level `;` only, so a
|
|
111
|
+
* `var(--x, a; b)` or a data URI cannot end a declaration early.
|
|
112
|
+
*/
|
|
113
|
+
function parseDeclarations(body) {
|
|
114
|
+
const out = [];
|
|
115
|
+
let depth = 0;
|
|
116
|
+
let start = 0;
|
|
117
|
+
const push = (chunk) => {
|
|
118
|
+
const colon = chunk.indexOf(":");
|
|
119
|
+
if (colon < 0) return;
|
|
120
|
+
const property = chunk.slice(0, colon).trim();
|
|
121
|
+
const value = chunk.slice(colon + 1).trim();
|
|
122
|
+
if (!property || property.includes("{") || !value) return;
|
|
123
|
+
out.push({
|
|
124
|
+
property,
|
|
125
|
+
value
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
for (let i = 0; i < body.length; i++) {
|
|
129
|
+
const ch = body[i];
|
|
130
|
+
if (ch === "(" || ch === "[") depth++;
|
|
131
|
+
else if (ch === ")" || ch === "]") depth--;
|
|
132
|
+
else if (ch === ";" && depth === 0) {
|
|
133
|
+
push(body.slice(start, i));
|
|
134
|
+
start = i + 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
push(body.slice(start));
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Scan a stylesheet into its style rules.
|
|
142
|
+
*
|
|
143
|
+
* Never throws: this runs over consumer source that may be a fragment, and a
|
|
144
|
+
* linter that dies on one unparseable file is one nobody keeps in CI.
|
|
145
|
+
*/
|
|
146
|
+
function scanCss(css) {
|
|
147
|
+
const src = stripComments(css);
|
|
148
|
+
const rules = [];
|
|
149
|
+
const stack = [];
|
|
150
|
+
let depth = 0;
|
|
151
|
+
let preludeStart = 0;
|
|
152
|
+
for (let i = 0; i < src.length; i++) {
|
|
153
|
+
const ch = src[i];
|
|
154
|
+
if (ch === "\"" || ch === "'") {
|
|
155
|
+
const quote = ch;
|
|
156
|
+
i++;
|
|
157
|
+
while (i < src.length && src[i] !== quote) {
|
|
158
|
+
if (src[i] === "\\") i++;
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === "{") {
|
|
164
|
+
const raw = src.slice(preludeStart, i);
|
|
165
|
+
const prelude = raw.trim();
|
|
166
|
+
stack.push({
|
|
167
|
+
prelude,
|
|
168
|
+
index: preludeStart + raw.indexOf(prelude.slice(0, 1))
|
|
169
|
+
});
|
|
170
|
+
depth++;
|
|
171
|
+
preludeStart = i + 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === "}") {
|
|
175
|
+
const frame = stack.pop();
|
|
176
|
+
depth--;
|
|
177
|
+
if (frame && !frame.prelude.startsWith("@")) {
|
|
178
|
+
const bodyStart = src.indexOf("{", frame.index) + 1;
|
|
179
|
+
rules.push({
|
|
180
|
+
selector: frame.prelude,
|
|
181
|
+
declarations: parseDeclarations(src.slice(bodyStart, i)),
|
|
182
|
+
conditions: stack.filter((f) => f.prelude.startsWith("@")).map((f) => f.prelude),
|
|
183
|
+
line: lineAt(src, frame.index)
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
preludeStart = i + 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return rules.filter((r) => r.selector && depth >= 0);
|
|
191
|
+
}
|
|
192
|
+
/** `<style>` block contents from an .astro/.vue/.svelte/.html file. */
|
|
193
|
+
function extractStyleBlocks(src) {
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
196
|
+
const line = lineAt(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
|
|
197
|
+
out.push({
|
|
198
|
+
css: m[1] ?? "",
|
|
199
|
+
line
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/lint-css.ts
|
|
207
|
+
/**
|
|
208
|
+
* The framework's own track widths. Declared in two places today
|
|
209
|
+
* (`core/css/layout.css` and the tailwind emitter), so a rule keyed to the token
|
|
210
|
+
* NAME rather than to a value survives either one moving.
|
|
211
|
+
*/
|
|
212
|
+
const TRACK_WIDTHS = [
|
|
213
|
+
"--width-measure",
|
|
214
|
+
"--width-breakout",
|
|
215
|
+
"--width-spotlight"
|
|
216
|
+
];
|
|
217
|
+
/**
|
|
218
|
+
* Column ratios that correspond to a `split-<a>-<b>` class.
|
|
219
|
+
*
|
|
220
|
+
* `.split` is FLEX, not grid — `display: flex` with `flex: 1` children, and a
|
|
221
|
+
* ratio class setting `--_split-a`/`--_split-b`. Equal columns are the base
|
|
222
|
+
* `.split` with no ratio class, which is why `1fr 1fr` is deliberately absent:
|
|
223
|
+
* suggesting `split-1-1` would name a class that does not exist, and this file
|
|
224
|
+
* would then be committing the very defect `lint.ts` reports.
|
|
225
|
+
*/
|
|
226
|
+
const SPLITS = {
|
|
227
|
+
"1fr 2fr": "1-2",
|
|
228
|
+
"2fr 1fr": "2-1",
|
|
229
|
+
"1fr 3fr": "1-3",
|
|
230
|
+
"3fr 1fr": "3-1",
|
|
231
|
+
"1fr 4fr": "1-4",
|
|
232
|
+
"4fr 1fr": "4-1",
|
|
233
|
+
"2fr 3fr": "2-3",
|
|
234
|
+
"3fr 2fr": "3-2"
|
|
235
|
+
};
|
|
236
|
+
const decl = (rule, property) => rule.declarations.find((d) => d.property === property)?.value;
|
|
237
|
+
/** Is this rule inside a `min-width` media or container query? */
|
|
238
|
+
const inMinWidthQuery = (rule) => rule.conditions.some((c) => /min-width\s*:/.test(c));
|
|
239
|
+
/** The framework breakpoint a `min-width` condition corresponds to, if any. */
|
|
240
|
+
function breakpointOf(rule) {
|
|
241
|
+
const rem = rule.conditions.map((c) => /min-width\s*:\s*([\d.]+)rem/.exec(c)?.[1]).find(Boolean);
|
|
242
|
+
if (!rem) return null;
|
|
243
|
+
const n = Number(rem);
|
|
244
|
+
return n >= 80 ? "xl" : n >= 64 ? "lg" : n >= 48 ? "md" : n >= 30 ? "sm" : null;
|
|
245
|
+
}
|
|
246
|
+
/** Spell a responsive utility the way the target format actually emits it. */
|
|
247
|
+
const responsive = (bp, base, format) => format === "tailwind" ? `@${bp}:${base}` : `${bp}-${base}`;
|
|
248
|
+
/**
|
|
249
|
+
* One rule = one check. Kept as data rather than a chain of ifs so adding a
|
|
250
|
+
* primitive is adding an entry, and so each carries its own explanation.
|
|
251
|
+
*/
|
|
252
|
+
const RULES = [
|
|
253
|
+
(rule) => {
|
|
254
|
+
const token = TRACK_WIDTHS.find((t) => decl(rule, "max-inline-size")?.includes(t) || decl(rule, "max-width")?.includes(t));
|
|
255
|
+
const centred = /\bauto\b/.test(decl(rule, "margin-inline") ?? decl(rule, "margin") ?? "");
|
|
256
|
+
if (!token || !centred) return null;
|
|
257
|
+
return {
|
|
258
|
+
cls: `${rule.selector} { max-inline-size: var(${token}); margin-inline: auto }`,
|
|
259
|
+
severity: "suggestion",
|
|
260
|
+
reason: "`.centered` is this pattern — a grid of named tracks, not a max-width plus auto margins",
|
|
261
|
+
suggestion: "use `.centered`, and widen an individual child with `breakout`, `spotlight` or `fullbleed` on that child rather than overriding the parent"
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
(rule, format) => {
|
|
265
|
+
const cols = decl(rule, "grid-template-columns");
|
|
266
|
+
if (!cols || !inMinWidthQuery(rule)) return null;
|
|
267
|
+
const ratio = SPLITS[cols.replace(/\s+/g, " ").trim()];
|
|
268
|
+
const bp = ratio && breakpointOf(rule);
|
|
269
|
+
if (!ratio || !bp) return null;
|
|
270
|
+
return {
|
|
271
|
+
cls: `${rule.selector} { grid-template-columns: ${cols} }`,
|
|
272
|
+
severity: "suggestion",
|
|
273
|
+
reason: `a ${cols} split behind a min-width query is the \`split-${ratio}\` utility`,
|
|
274
|
+
suggestion: `${responsive(bp, `split-${ratio}`, format)} on the container — the responsive form is equal below the breakpoint and takes the ratio above it, which is what the query is doing by hand`
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
(rule, format) => {
|
|
278
|
+
if (decl(rule, "display") !== "flex") return null;
|
|
279
|
+
if (decl(rule, "align-items") !== "center") return null;
|
|
280
|
+
if (/column/.test(decl(rule, "flex-direction") ?? "")) return null;
|
|
281
|
+
return {
|
|
282
|
+
cls: `${rule.selector} { display: flex; align-items: center }`,
|
|
283
|
+
severity: "suggestion",
|
|
284
|
+
reason: "the framework has this as utilities",
|
|
285
|
+
suggestion: format === "tailwind" ? "Tailwind's own `flex items-center` (the framework drops these names in this format)" : "`flex items-center`, or `.cluster-start` if this is a row of related controls"
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
];
|
|
289
|
+
/**
|
|
290
|
+
* Lint stylesheets and the `<style>` blocks of component files.
|
|
291
|
+
*
|
|
292
|
+
* `files` carries raw text, same as `lintSource` — this module stays free of the
|
|
293
|
+
* filesystem so the caller owns discovery.
|
|
294
|
+
*/
|
|
295
|
+
/**
|
|
296
|
+
* The banner every generated stylesheet opens with.
|
|
297
|
+
*
|
|
298
|
+
* Linting the generator's own output is the worst false positive this tool can
|
|
299
|
+
* produce: the emitted bundle contains the framework's real implementations of
|
|
300
|
+
* these very patterns, so it reports `.split` as reinventing `.split`. The
|
|
301
|
+
* `css` format writes `styles.css` into the consumer's source tree (`src/styles`
|
|
302
|
+
* by default), which is squarely inside what `--src` scans.
|
|
303
|
+
*
|
|
304
|
+
* Matched on the banner rather than on a path, because the output directory is
|
|
305
|
+
* configurable and a path rule would silently stop working when someone changes
|
|
306
|
+
* it.
|
|
307
|
+
*/
|
|
308
|
+
const GENERATED = /^\s*\/\*!?\s*GENERATED\b|do not edit by hand\./;
|
|
309
|
+
function lintCss(files, format) {
|
|
310
|
+
const findings = [];
|
|
311
|
+
const seen = /* @__PURE__ */ new Set();
|
|
312
|
+
for (const file of files) {
|
|
313
|
+
if (GENERATED.test(file.text.slice(0, 400))) continue;
|
|
314
|
+
const blocks = file.path.endsWith(".css") ? [{
|
|
315
|
+
css: file.text,
|
|
316
|
+
line: 1
|
|
317
|
+
}] : extractStyleBlocks(file.text);
|
|
318
|
+
for (const block of blocks) for (const rule of scanCss(block.css)) for (const check of RULES) {
|
|
319
|
+
const verdict = check(rule, format);
|
|
320
|
+
if (!verdict) continue;
|
|
321
|
+
const line = block.line + rule.line - 1;
|
|
322
|
+
const key = `${file.path}:${line}:${verdict.reason}`;
|
|
323
|
+
if (seen.has(key)) continue;
|
|
324
|
+
seen.add(key);
|
|
325
|
+
findings.push({
|
|
326
|
+
file: file.path,
|
|
327
|
+
line,
|
|
328
|
+
...verdict
|
|
329
|
+
});
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return findings;
|
|
334
|
+
}
|
|
335
|
+
|
|
97
336
|
//#endregion
|
|
98
337
|
//#region src/lint.ts
|
|
338
|
+
/**
|
|
339
|
+
* `vitops lint` — find framework classes in consumer source that resolve to
|
|
340
|
+
* nothing in the format being built.
|
|
341
|
+
*
|
|
342
|
+
* The problem this exists for: an unknown utility class is indistinguishable
|
|
343
|
+
* from a working one. Nothing errors, nothing warns, the element just doesn't
|
|
344
|
+
* get the style. Two shapes of that were found in a real consumer site:
|
|
345
|
+
*
|
|
346
|
+
* • `bg-navy-d` — a leftover from the named-step scale (`d`/`xd`/`xxd`)
|
|
347
|
+
* deleted in favour of numeric steps. The hue is real, the step is not.
|
|
348
|
+
* It appeared ~10× and looked fine only because the `.card` it sat on
|
|
349
|
+
* supplied its own background.
|
|
350
|
+
* • `md-flex-row` — the css/bricks spelling of a responsive variant, used in
|
|
351
|
+
* a `tailwind`-format project where that class is never emitted (Tailwind
|
|
352
|
+
* regenerates those on demand as `@md:`). Silently inert.
|
|
353
|
+
*
|
|
354
|
+
* DESIGN: this deliberately only inspects classes ANCHORED TO THE CONSUMER'S
|
|
355
|
+
* OWN CONFIG — a hue name, a role name, a type role, a shadow, a pattern. It
|
|
356
|
+
* never tries to enumerate "all valid classes", because in the tailwind format
|
|
357
|
+
* that would mean knowing Tailwind's entire built-in vocabulary, and every
|
|
358
|
+
* unknown would be a false positive. Anchoring means a finding is always
|
|
359
|
+
* actionable: you named something from your design system, and it doesn't
|
|
360
|
+
* resolve.
|
|
361
|
+
*/
|
|
99
362
|
const UTIL_FAMILIES = [
|
|
100
363
|
"bg",
|
|
101
364
|
"text",
|
|
365
|
+
"icon",
|
|
102
366
|
"border",
|
|
103
367
|
"outline",
|
|
104
368
|
"fill",
|
|
105
369
|
"stroke"
|
|
106
370
|
];
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
371
|
+
/**
|
|
372
|
+
* The modifiers actually emitted for one family + role, read back out of the
|
|
373
|
+
* generator's own class list.
|
|
374
|
+
*
|
|
375
|
+
* This used to be a hand-maintained array, which is the wrong shape for a
|
|
376
|
+
* suggestion: the two role kinds emit different modifiers (a chromatic role has
|
|
377
|
+
* `solid` and no bare form; a surface role the reverse), so a single list was
|
|
378
|
+
* wrong for one of them no matter what it said.
|
|
379
|
+
*/
|
|
380
|
+
const emittedModifiers = (roleClasses, fam, role) => {
|
|
381
|
+
const prefix = `${fam}-${role}`;
|
|
382
|
+
return [...roleClasses].filter((c) => c === prefix || c.startsWith(`${prefix}-`)).map((c) => c.slice(prefix.length + 1)).filter(Boolean).sort();
|
|
383
|
+
};
|
|
117
384
|
function vocabulary(ds, roleClasses) {
|
|
118
385
|
return {
|
|
119
386
|
roles: Object.keys(ds.colors?.roles ?? {}),
|
|
120
387
|
hues: Object.keys(ds.colors?.palette ?? {}),
|
|
121
|
-
steps: [
|
|
122
|
-
"50",
|
|
123
|
-
"100",
|
|
124
|
-
"200",
|
|
125
|
-
"300",
|
|
126
|
-
"400",
|
|
127
|
-
"500",
|
|
128
|
-
"600",
|
|
129
|
-
"700",
|
|
130
|
-
"800",
|
|
131
|
-
"900",
|
|
132
|
-
"950"
|
|
133
|
-
],
|
|
388
|
+
steps: [...NUMERIC_STEPS],
|
|
134
389
|
typeRoles: Object.keys(ds.typography?.roles ?? {}),
|
|
135
390
|
shadows: Object.keys(ds.shadows ?? {}),
|
|
136
391
|
patterns: Object.keys(ds.patterns?.items ?? {}),
|
|
137
392
|
roleClasses: new Set(roleClasses)
|
|
138
393
|
};
|
|
139
394
|
}
|
|
140
|
-
/**
|
|
141
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Strip variant prefixes (`@md:`, `hover:`, `dark:`) from a candidate.
|
|
397
|
+
*
|
|
398
|
+
* Format-aware, because the container-query spelling only exists in tailwind.
|
|
399
|
+
* Stripping `@md:` unconditionally made the check asymmetric: `md-split-1-2` in a
|
|
400
|
+
* tailwind project was caught, but `@md:split-1-2` in a css/bricks project became
|
|
401
|
+
* a bare `split-1-2` that matched nothing and returned null — the same silent
|
|
402
|
+
* no-op, in the other direction, unreported.
|
|
403
|
+
*/
|
|
404
|
+
const stripVariants = (cls, format) => {
|
|
142
405
|
let out = cls;
|
|
143
|
-
while (/^@?[a-zA-Z0-9_-]+:/.test(out))
|
|
406
|
+
while (/^@?[a-zA-Z0-9_-]+:/.test(out)) {
|
|
407
|
+
if (format !== "tailwind" && out.startsWith("@")) break;
|
|
408
|
+
out = out.slice(out.indexOf(":") + 1);
|
|
409
|
+
}
|
|
144
410
|
return out;
|
|
145
411
|
};
|
|
146
412
|
const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.startsWith(p));
|
|
@@ -149,10 +415,18 @@ const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.star
|
|
|
149
415
|
* which is the common case and the whole reason this is low-noise.
|
|
150
416
|
*/
|
|
151
417
|
function judge(raw, v, format) {
|
|
152
|
-
const cls = stripVariants(raw);
|
|
418
|
+
const cls = stripVariants(raw, format);
|
|
419
|
+
const cq = /^@(sm|md|lg|xl):(.+)$/.exec(cls);
|
|
420
|
+
if (cq && format !== "tailwind") return {
|
|
421
|
+
cls: raw,
|
|
422
|
+
severity: "error",
|
|
423
|
+
reason: `\`@${cq[1]}:\` container-query variants are a tailwind-format spelling; the ${format} format emits pre-expanded classes`,
|
|
424
|
+
suggestion: `${cq[1]}-${cq[2]}`
|
|
425
|
+
};
|
|
153
426
|
const bp = /^(sm|md|lg|xl)-(.+)$/.exec(cls);
|
|
154
427
|
if (bp && format === "tailwind") return {
|
|
155
428
|
cls: raw,
|
|
429
|
+
severity: "error",
|
|
156
430
|
reason: `\`${bp[1]}-\` responsive classes are not emitted in the tailwind format`,
|
|
157
431
|
suggestion: `@${bp[1]}:${bp[2]} (framework breakpoints) or ${bp[1]}:${bp[2]} (Tailwind's)`
|
|
158
432
|
};
|
|
@@ -166,21 +440,25 @@ function judge(raw, v, format) {
|
|
|
166
440
|
const role = prefix(v.roles);
|
|
167
441
|
if (role && (!hue || role.length > hue.length)) {
|
|
168
442
|
const mod = rest.slice(role.length + 1);
|
|
443
|
+
const valid = emittedModifiers(v.roleClasses, fam, role);
|
|
169
444
|
return {
|
|
170
445
|
cls: raw,
|
|
446
|
+
severity: "error",
|
|
171
447
|
reason: `\`${fam}-${role}${mod ? `-${mod}` : ""}\` is not emitted for the \`${role}\` role`,
|
|
172
|
-
suggestion: `valid modifiers: ${
|
|
448
|
+
suggestion: valid.length ? `valid modifiers: ${valid.join(", ")}` : `the \`${role}\` role emits no \`${fam}-*\` utilities`
|
|
173
449
|
};
|
|
174
450
|
}
|
|
175
451
|
if (hue) {
|
|
176
452
|
const step = rest.slice(hue.length + 1);
|
|
177
453
|
if (step === "") return {
|
|
178
454
|
cls: raw,
|
|
455
|
+
severity: "error",
|
|
179
456
|
reason: `\`${fam}-${hue}\` names a palette hue with no step`,
|
|
180
457
|
suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"}`
|
|
181
458
|
};
|
|
182
459
|
if (!v.steps.includes(step)) return {
|
|
183
460
|
cls: raw,
|
|
461
|
+
severity: "error",
|
|
184
462
|
reason: `\`${step}\` is not a step on the \`${hue}\` scale (steps are 50…950)`,
|
|
185
463
|
suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"} which also flips in dark mode`
|
|
186
464
|
};
|
|
@@ -193,6 +471,7 @@ function judge(raw, v, format) {
|
|
|
193
471
|
const near = nearest(font[1], v.typeRoles);
|
|
194
472
|
if (near) return {
|
|
195
473
|
cls: raw,
|
|
474
|
+
severity: "error",
|
|
196
475
|
reason: `\`${font[1]}\` is not a typography role`,
|
|
197
476
|
suggestion: `font-${near}`
|
|
198
477
|
};
|
|
@@ -200,6 +479,7 @@ function judge(raw, v, format) {
|
|
|
200
479
|
const ds = /^drop-shadow-(.+)$/.exec(base);
|
|
201
480
|
if (ds && v.shadows.length && !v.shadows.includes(ds[1])) return {
|
|
202
481
|
cls: raw,
|
|
482
|
+
severity: "error",
|
|
203
483
|
reason: `\`${ds[1]}\` is not a defined shadow`,
|
|
204
484
|
suggestion: `drop-shadow-${v.shadows[0]}`
|
|
205
485
|
};
|
|
@@ -242,37 +522,71 @@ function lintSource(files, v, format) {
|
|
|
242
522
|
/**
|
|
243
523
|
* `vitops` — CLI for the Vitops design-system generator.
|
|
244
524
|
*
|
|
245
|
-
* vitops generate --input <json> --format <bricks|css|tailwind[,…]> --out <dir>
|
|
525
|
+
* vitops generate --input <json> --format <bricks|css|tailwind|design[,…]> --out <dir>
|
|
246
526
|
* vitops init [--out design-system.json] [--force]
|
|
247
|
-
* vitops validate <design-system.json>
|
|
248
|
-
* vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>]
|
|
527
|
+
* vitops validate <design-system.json|site.json>
|
|
528
|
+
* vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>] [--background <hex>]
|
|
249
529
|
* vitops agents [--input <json>] [--out AGENTS.md] [--docs-dir <dir>]
|
|
250
530
|
* vitops docs [topic] [--input <json>] [--all]
|
|
251
531
|
* vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
|
|
532
|
+
* vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
|
|
252
533
|
*
|
|
253
534
|
* Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
|
|
254
535
|
* Every client brings their own consumer-editable design-system.json.
|
|
255
536
|
*/
|
|
256
537
|
const FORMATS = new Set([
|
|
538
|
+
"bricks",
|
|
539
|
+
"css",
|
|
540
|
+
"tailwind",
|
|
541
|
+
"design"
|
|
542
|
+
]);
|
|
543
|
+
const LINT_FORMATS = new Set([
|
|
257
544
|
"bricks",
|
|
258
545
|
"css",
|
|
259
546
|
"tailwind"
|
|
260
547
|
]);
|
|
548
|
+
const LEGAL_DOCS = new Set([
|
|
549
|
+
"privacy",
|
|
550
|
+
"terms",
|
|
551
|
+
"cookies"
|
|
552
|
+
]);
|
|
553
|
+
const LEGAL_OUTPUTS = new Set([
|
|
554
|
+
"md",
|
|
555
|
+
"html",
|
|
556
|
+
"portable-text"
|
|
557
|
+
]);
|
|
261
558
|
const HELP = `vitops — generate design-system outputs from a design-system.json
|
|
262
559
|
|
|
560
|
+
Anywhere a command takes --input, that file may be a design-system.json OR the
|
|
561
|
+
larger site config that embeds one (company.json / site.json). The two are told
|
|
562
|
+
apart by shape, and a site config also supplies the site-level facts generation
|
|
563
|
+
reads — its default colour scheme, its legal documents, its icon sprite.
|
|
564
|
+
|
|
263
565
|
Usage:
|
|
264
566
|
vitops generate [options] Generate platform output from a config
|
|
265
567
|
vitops init [options] Scaffold a starter design-system.json
|
|
266
|
-
vitops validate <file> Validate a config against the schema
|
|
568
|
+
vitops validate <file> Validate a config against the schema it says it is
|
|
267
569
|
vitops favicon [options] Generate a favicon set from a source image
|
|
268
570
|
vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
|
|
269
571
|
vitops docs [topic] Print live design-system reference docs to stdout
|
|
270
572
|
vitops lint [options] Report framework classes in your source that resolve to nothing
|
|
573
|
+
vitops legal [options] Render legal documents from a site config
|
|
574
|
+
vitops icons [options] Report which icons your source uses, and build the sprite
|
|
271
575
|
|
|
272
576
|
Generate options:
|
|
273
|
-
-i, --input <path>
|
|
274
|
-
-f, --format <list> bricks | css | tailwind (comma-separated; default: bricks)
|
|
577
|
+
-i, --input <path> design-system.json or site config (default: ./design-system.json)
|
|
578
|
+
-f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
|
|
579
|
+
design emits DESIGN.md only (the agent-facing brief) — pair it
|
|
580
|
+
with --out . to write it beside AGENTS.md, or compose:
|
|
581
|
+
--format css,design
|
|
275
582
|
-o, --out <dir> Output directory (default: ./dist)
|
|
583
|
+
--theme <name> Which designSystem.themes entry to build, when --input is
|
|
584
|
+
a site config (default: its defaultTheme, else "default")
|
|
585
|
+
--site <path> Site config, when it is a different file from --input.
|
|
586
|
+
Emits legal/*.html into the output directory (see:
|
|
587
|
+
vitops legal). Omitted with a plain design-system.json:
|
|
588
|
+
no legal files.
|
|
589
|
+
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
276
590
|
|
|
277
591
|
Init options:
|
|
278
592
|
-o, --out <path> Where to write (default: ./design-system.json)
|
|
@@ -282,9 +596,13 @@ Favicon options:
|
|
|
282
596
|
-i, --input <path> Source SVG or PNG (required)
|
|
283
597
|
-o, --out <dir> Output directory (default: ./public)
|
|
284
598
|
--low-res <path> Optional simplified source for the 16px icon
|
|
599
|
+
--background <hex> Background under the maskable icons, which must be
|
|
600
|
+
opaque (default: #ffffff)
|
|
285
601
|
|
|
286
602
|
Agents options:
|
|
287
|
-
-i, --input <path>
|
|
603
|
+
-i, --input <path> design-system.json or site config (default: ./design-system.json;
|
|
604
|
+
validated if present)
|
|
605
|
+
--theme <name> Theme to read, when --input is a site config
|
|
288
606
|
-o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
|
|
289
607
|
--docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
|
|
290
608
|
instead of linking the packaged skill
|
|
@@ -292,14 +610,39 @@ Agents options:
|
|
|
292
610
|
Docs options:
|
|
293
611
|
<topic> classes | authoring | formats | color | scales | patterns | elements
|
|
294
612
|
(no topic: list topics with summaries)
|
|
295
|
-
-i, --input <path>
|
|
613
|
+
-i, --input <path> design-system.json or site config (default: ./design-system.json)
|
|
614
|
+
--theme <name> Theme to render docs from, when --input is a site config
|
|
296
615
|
--all Print every topic, concatenated
|
|
297
616
|
|
|
617
|
+
Icons options:
|
|
618
|
+
--site <path> Site config carrying the "icons" block (default: ./site.json)
|
|
619
|
+
--src <dir> Source to scan for icon usage (default: ./src)
|
|
620
|
+
--sprite Also build the SVG sprite
|
|
621
|
+
-o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)
|
|
622
|
+
--json Machine-readable report on stdout
|
|
623
|
+
|
|
298
624
|
Lint options:
|
|
299
|
-
-i, --input <path>
|
|
625
|
+
-i, --input <path> design-system.json or site config (default: ./design-system.json)
|
|
626
|
+
--theme <name> Theme to judge classes against, when --input is a site config
|
|
300
627
|
-f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
|
|
301
628
|
Responsive md-* classes are real in css/bricks, inert in tailwind.
|
|
302
629
|
-s, --src <dir> Directory to scan (default: ./src)
|
|
630
|
+
--strict Also fail on suggestions (reuse hints), not just on
|
|
631
|
+
classes that resolve to nothing
|
|
632
|
+
|
|
633
|
+
Legal options:
|
|
634
|
+
-i, --input <path> Site config: .json, or a .js/.ts module with a default
|
|
635
|
+
export (default: ./site.json)
|
|
636
|
+
-d, --doc <name> privacy | terms | cookies (repeatable; default: those
|
|
637
|
+
enabled in the config)
|
|
638
|
+
-f, --format <fmt> md | html | portable-text (default: md)
|
|
639
|
+
html suits WordPress/Bricks and plain sites;
|
|
640
|
+
portable-text suits EmDash
|
|
641
|
+
-o, --out <dir> Write files here (default: print to stdout)
|
|
642
|
+
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
643
|
+
|
|
644
|
+
Generated from your config — a starting point, not legal advice. Review before
|
|
645
|
+
publishing, and make sure the config describes what your site actually does.
|
|
303
646
|
|
|
304
647
|
Common:
|
|
305
648
|
-h, --help Show this help
|
|
@@ -326,19 +669,27 @@ async function cmdGenerate(argv) {
|
|
|
326
669
|
type: "string",
|
|
327
670
|
short: "o",
|
|
328
671
|
default: "dist"
|
|
329
|
-
}
|
|
672
|
+
},
|
|
673
|
+
site: { type: "string" },
|
|
674
|
+
theme: { type: "string" },
|
|
675
|
+
"site-env": { type: "string" }
|
|
330
676
|
},
|
|
331
677
|
allowPositionals: false
|
|
332
678
|
});
|
|
333
679
|
const input = resolve(values.input);
|
|
334
680
|
if (!existsSync(input)) fail(`config not found: ${input}`);
|
|
681
|
+
const site = values.site ? resolve(values.site) : void 0;
|
|
682
|
+
if (site && !existsSync(site)) fail(`site config not found: ${site}`);
|
|
335
683
|
const formats = values.format.split(",").map((f) => f.trim()).filter(Boolean);
|
|
336
|
-
for (const f of formats) if (!FORMATS.has(f)) fail(`unknown format "${f}" (expected: bricks | css | tailwind)`);
|
|
684
|
+
for (const f of formats) if (!FORMATS.has(f)) fail(`unknown format "${f}" (expected: bricks | css | tailwind | design)`);
|
|
337
685
|
for (const format of formats) try {
|
|
338
686
|
const res = await generate({
|
|
339
687
|
input,
|
|
340
688
|
format,
|
|
341
|
-
outDir: values.out
|
|
689
|
+
outDir: values.out,
|
|
690
|
+
...site ? { site } : {},
|
|
691
|
+
...values.theme ? { theme: values.theme } : {},
|
|
692
|
+
...values["site-env"] ? { siteEnv: values["site-env"] } : {}
|
|
342
693
|
});
|
|
343
694
|
console.log(`✓ ${format} → ${res.outDir} (${res.written.length} paths)`);
|
|
344
695
|
} catch (err) {
|
|
@@ -368,13 +719,25 @@ function cmdInit(argv) {
|
|
|
368
719
|
console.log(` Edit it, then: vitops generate --input ${values.out} --format tailwind --out src/styles`);
|
|
369
720
|
console.log(` Editors read the "$schema" (${SCHEMA_URL}) for autocomplete + validation.`);
|
|
370
721
|
}
|
|
722
|
+
/**
|
|
723
|
+
* Validate a config against the schema its shape says it is.
|
|
724
|
+
*
|
|
725
|
+
* Routing on kind rather than asking the caller matters here: pointed at a site
|
|
726
|
+
* config, the design-system schema used to report a single `unrecognized_keys`
|
|
727
|
+
* for `designSystem` and nothing about the file's actual contents — a wrong
|
|
728
|
+
* answer that reads like a right one. A site config is validated whole, which
|
|
729
|
+
* also covers the cross-field integrity JSON Schema can't express.
|
|
730
|
+
*/
|
|
371
731
|
function cmdValidate(argv) {
|
|
372
732
|
const { values, positionals } = parseArgs({
|
|
373
733
|
args: argv,
|
|
374
|
-
options: {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
734
|
+
options: {
|
|
735
|
+
input: {
|
|
736
|
+
type: "string",
|
|
737
|
+
short: "i"
|
|
738
|
+
},
|
|
739
|
+
"site-env": { type: "string" }
|
|
740
|
+
},
|
|
378
741
|
allowPositionals: true
|
|
379
742
|
});
|
|
380
743
|
const path = resolve(positionals[0] ?? values.input ?? "design-system.json");
|
|
@@ -385,6 +748,30 @@ function cmdValidate(argv) {
|
|
|
385
748
|
} catch (err) {
|
|
386
749
|
fail(`could not parse JSON: ${err.message}`);
|
|
387
750
|
}
|
|
751
|
+
if (isSiteConfig(raw)) {
|
|
752
|
+
let site;
|
|
753
|
+
try {
|
|
754
|
+
site = resolveSiteConfig(raw, values["site-env"]);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
console.error(`✖ ${path} is invalid as a site config:`);
|
|
757
|
+
console.error(err.message.replace(/^Invalid site config:\n/, ""));
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
const themes = site.designSystem?.themes ?? {};
|
|
761
|
+
let bad = false;
|
|
762
|
+
for (const name of Object.keys(themes)) {
|
|
763
|
+
const result = validate(resolveTheme(themes, name));
|
|
764
|
+
for (const w of result.warnings) console.warn(` ! designSystem.themes.${name}: ${w}`);
|
|
765
|
+
if (result.ok) continue;
|
|
766
|
+
bad = true;
|
|
767
|
+
console.error(`✖ ${path} — designSystem.themes.${name} is not a complete design system:`);
|
|
768
|
+
for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
|
|
769
|
+
}
|
|
770
|
+
if (bad) process.exit(1);
|
|
771
|
+
const n = Object.keys(themes).length;
|
|
772
|
+
console.log(`✓ ${path} is a valid site config (${n} theme${n === 1 ? "" : "s"})`);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
388
775
|
const result = validate(raw);
|
|
389
776
|
for (const w of result.warnings) console.warn(` ! ${w}`);
|
|
390
777
|
if (result.ok) {
|
|
@@ -408,7 +795,8 @@ async function cmdFavicon(argv) {
|
|
|
408
795
|
short: "o",
|
|
409
796
|
default: "public"
|
|
410
797
|
},
|
|
411
|
-
"low-res": { type: "string" }
|
|
798
|
+
"low-res": { type: "string" },
|
|
799
|
+
background: { type: "string" }
|
|
412
800
|
},
|
|
413
801
|
allowPositionals: false
|
|
414
802
|
});
|
|
@@ -419,14 +807,24 @@ async function cmdFavicon(argv) {
|
|
|
419
807
|
const written = await generateFavicons({
|
|
420
808
|
source,
|
|
421
809
|
outputDir: values.out,
|
|
422
|
-
...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {}
|
|
810
|
+
...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {},
|
|
811
|
+
...values.background ? { backgroundColor: values.background } : {}
|
|
423
812
|
});
|
|
424
813
|
console.log(`✓ favicons → ${values.out} (${written.length} files)`);
|
|
425
814
|
} catch (err) {
|
|
426
815
|
fail(err.message);
|
|
427
816
|
}
|
|
428
817
|
}
|
|
429
|
-
|
|
818
|
+
/**
|
|
819
|
+
* Read + parse + validate the design system at `path`, or exit with a message.
|
|
820
|
+
*
|
|
821
|
+
* `path` may be a `design-system.json` **or** the larger site config that embeds
|
|
822
|
+
* one — every command that judges source against the design system (`docs`,
|
|
823
|
+
* `lint`, `agents`) accepts either, so a consumer who keeps their tokens in
|
|
824
|
+
* `company.json` doesn't have to maintain a second file for the tooling's sake.
|
|
825
|
+
* `resolveInput` tells them apart by shape and resolves the theme.
|
|
826
|
+
*/
|
|
827
|
+
function loadConfig(path, theme) {
|
|
430
828
|
if (!existsSync(path)) fail(`config not found: ${path}`);
|
|
431
829
|
let raw;
|
|
432
830
|
try {
|
|
@@ -434,15 +832,233 @@ function loadConfig(path) {
|
|
|
434
832
|
} catch (err) {
|
|
435
833
|
fail(`could not parse JSON: ${err.message}`);
|
|
436
834
|
}
|
|
437
|
-
|
|
835
|
+
let resolved;
|
|
836
|
+
try {
|
|
837
|
+
resolved = resolveInput(raw, theme != null ? { theme } : {});
|
|
838
|
+
} catch (err) {
|
|
839
|
+
fail(`${path}: ${err.message}`);
|
|
840
|
+
}
|
|
841
|
+
const result = validate(resolved.designSystem);
|
|
438
842
|
if (!result.ok) {
|
|
439
|
-
|
|
843
|
+
const where = resolved.theme != null ? ` (designSystem.themes.${resolved.theme})` : "";
|
|
844
|
+
console.error(`✖ ${path}${where} is invalid:`);
|
|
440
845
|
for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
|
|
441
846
|
process.exit(1);
|
|
442
847
|
}
|
|
443
848
|
for (const w of result.warnings) console.warn(` ! ${w}`);
|
|
444
849
|
return result.data;
|
|
445
850
|
}
|
|
851
|
+
/**
|
|
852
|
+
* Read + resolve a site config, or exit with a message.
|
|
853
|
+
*
|
|
854
|
+
* Accepts JSON, or a JS/TS module with a default export — deliberately not YAML,
|
|
855
|
+
* which would mean a parser dependency for one command. `resolveSiteConfig` is
|
|
856
|
+
* documented as taking an already-parsed object precisely so the loader is the
|
|
857
|
+
* consumer's choice; anyone on YAML can convert it or export from a module.
|
|
858
|
+
*/
|
|
859
|
+
async function loadSiteConfig(path, siteEnv) {
|
|
860
|
+
if (!existsSync(path)) fail(`site config not found: ${path}`);
|
|
861
|
+
let raw;
|
|
862
|
+
if (/\.(json)$/.test(path)) try {
|
|
863
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
864
|
+
} catch (err) {
|
|
865
|
+
fail(`could not parse JSON: ${err.message}`);
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
try {
|
|
869
|
+
raw = (await import(pathToFileURL(path).href)).default;
|
|
870
|
+
} catch (err) {
|
|
871
|
+
fail(`could not import ${path}: ${err.message}`);
|
|
872
|
+
}
|
|
873
|
+
if (raw == null) fail(`${path} has no default export`);
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
return resolveSiteConfig(raw, siteEnv);
|
|
877
|
+
} catch (err) {
|
|
878
|
+
fail(err.message);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Render the site's legal documents.
|
|
883
|
+
*
|
|
884
|
+
* The universal delivery path: every consumer has this CLI regardless of stack,
|
|
885
|
+
* so a WordPress theme, an Eleventy build or a hand-written HTML site all get
|
|
886
|
+
* the same documents without any integration code. Prints to stdout like
|
|
887
|
+
* `vitops docs`, or writes files when given `--out`.
|
|
888
|
+
*/
|
|
889
|
+
/**
|
|
890
|
+
* Report the icon vocabulary a project actually uses, and optionally build the
|
|
891
|
+
* sprite from it.
|
|
892
|
+
*
|
|
893
|
+
* A sibling of `legal` rather than a widening of `lint`: `lint` judges classes
|
|
894
|
+
* against a design-system.json, whereas icons are anchored to a SiteConfig.
|
|
895
|
+
*
|
|
896
|
+
* Exit codes carry the same distinction the Astro integration makes. A name the
|
|
897
|
+
* config DECLARES but that doesn't resolve is a config error and fails the
|
|
898
|
+
* command; a name only the SCAN found is reported and tolerated, because a bare
|
|
899
|
+
* unmapped name is more often a local src/icons/*.svg than a mistake. Runtime-
|
|
900
|
+
* computed names are listed with file and line so they can be declared, never
|
|
901
|
+
* guessed at.
|
|
902
|
+
*/
|
|
903
|
+
async function cmdIcons(argv) {
|
|
904
|
+
const { values } = parseArgs({
|
|
905
|
+
args: argv,
|
|
906
|
+
options: {
|
|
907
|
+
site: {
|
|
908
|
+
type: "string",
|
|
909
|
+
default: "site.json"
|
|
910
|
+
},
|
|
911
|
+
src: {
|
|
912
|
+
type: "string",
|
|
913
|
+
default: "src"
|
|
914
|
+
},
|
|
915
|
+
sprite: {
|
|
916
|
+
type: "boolean",
|
|
917
|
+
default: false
|
|
918
|
+
},
|
|
919
|
+
out: {
|
|
920
|
+
type: "string",
|
|
921
|
+
short: "o",
|
|
922
|
+
default: "dist"
|
|
923
|
+
},
|
|
924
|
+
json: {
|
|
925
|
+
type: "boolean",
|
|
926
|
+
default: false
|
|
927
|
+
},
|
|
928
|
+
"site-env": {
|
|
929
|
+
type: "string",
|
|
930
|
+
default: "production"
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
allowPositionals: false
|
|
934
|
+
});
|
|
935
|
+
const sitePath = resolve(values.site);
|
|
936
|
+
const icons = existsSync(sitePath) ? (await loadSiteConfig(sitePath, values["site-env"])).icons ?? {} : {};
|
|
937
|
+
const ui = icons.ui ?? "fa7-solid";
|
|
938
|
+
const brand = icons.brand ?? "simple-icons";
|
|
939
|
+
const weight = icons.weight;
|
|
940
|
+
const weightOpt = weight ? { weight } : {};
|
|
941
|
+
let include = {};
|
|
942
|
+
try {
|
|
943
|
+
include = generateIconInclude({ ...icons });
|
|
944
|
+
} catch (e) {
|
|
945
|
+
fail(e.message);
|
|
946
|
+
}
|
|
947
|
+
const add = (prefix, name) => {
|
|
948
|
+
const list = include[prefix] ??= [];
|
|
949
|
+
if (!list.includes(name)) list.push(name);
|
|
950
|
+
};
|
|
951
|
+
const srcDir = resolve(values.src);
|
|
952
|
+
const scanned = existsSync(srcDir) ? collectIconRefs(scanFiles(srcDir)) : null;
|
|
953
|
+
const unmapped = [];
|
|
954
|
+
for (const name of scanned?.names ?? []) {
|
|
955
|
+
const colon = name.indexOf(":");
|
|
956
|
+
if (colon > 0) {
|
|
957
|
+
add(name.slice(0, colon), name.slice(colon + 1));
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
const q = resolveIcon(name, ui, weightOpt);
|
|
962
|
+
add(q.slice(0, q.indexOf(":")), q.slice(q.indexOf(":") + 1));
|
|
963
|
+
} catch {
|
|
964
|
+
unmapped.push(name);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const dynamic = (scanned?.dynamic ?? []).map((d) => ({
|
|
968
|
+
file: relative(process.cwd(), d.file),
|
|
969
|
+
line: d.line,
|
|
970
|
+
expr: d.expr
|
|
971
|
+
}));
|
|
972
|
+
const total = Object.values(include).flat().length;
|
|
973
|
+
let sprite = null;
|
|
974
|
+
if (values.sprite) {
|
|
975
|
+
const aliases = {};
|
|
976
|
+
for (const name of icons.semantic ?? []) try {
|
|
977
|
+
aliases[`icon-${name}`] = resolveIcon(name, ui, weightOpt);
|
|
978
|
+
} catch {}
|
|
979
|
+
const built = await buildIconSprite({
|
|
980
|
+
include,
|
|
981
|
+
aliases
|
|
982
|
+
});
|
|
983
|
+
const outDir = resolve(values.out);
|
|
984
|
+
mkdirSync(outDir, { recursive: true });
|
|
985
|
+
writeFileSync(join(outDir, "icons.svg"), built.svg);
|
|
986
|
+
sprite = {
|
|
987
|
+
ids: built.ids,
|
|
988
|
+
missing: built.missing
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
if (values.json) process.stdout.write(`${JSON.stringify({
|
|
992
|
+
ui,
|
|
993
|
+
brand,
|
|
994
|
+
weight: weight ?? null,
|
|
995
|
+
include,
|
|
996
|
+
unmapped,
|
|
997
|
+
dynamic,
|
|
998
|
+
sprite
|
|
999
|
+
}, null, 2)}\n`);
|
|
1000
|
+
else {
|
|
1001
|
+
console.log(`icon sets: ui=${ui} brand=${brand}${weight ? ` weight=${weight}` : ""}`);
|
|
1002
|
+
for (const [prefix, names] of Object.entries(include)) console.log(` ${prefix}: ${names.length} — ${names.join(", ")}`);
|
|
1003
|
+
console.log(`✓ ${total} icon(s) across ${Object.keys(include).length} set(s)`);
|
|
1004
|
+
if (sprite) console.log(`✓ sprite → ${resolve(values.out)}/icons.svg (${sprite.ids.length} symbols)`);
|
|
1005
|
+
for (const d of dynamic) console.log(` ! ${d.file}:${d.line} ${d.expr} — computed at runtime, declare it in icons`);
|
|
1006
|
+
if (unmapped.length) console.log(` ! not in the '${ui}' map: ${unmapped.join(", ")} (fine for a local svg or sprite id)`);
|
|
1007
|
+
if (sprite?.missing.length) console.log(` ! absent from the sprite: ${sprite.missing.join(", ")}`);
|
|
1008
|
+
}
|
|
1009
|
+
if (sprite?.missing.length) process.exitCode = 1;
|
|
1010
|
+
}
|
|
1011
|
+
async function cmdLegal(argv) {
|
|
1012
|
+
const { values } = parseArgs({
|
|
1013
|
+
args: argv,
|
|
1014
|
+
options: {
|
|
1015
|
+
input: {
|
|
1016
|
+
type: "string",
|
|
1017
|
+
short: "i",
|
|
1018
|
+
default: "site.json"
|
|
1019
|
+
},
|
|
1020
|
+
doc: {
|
|
1021
|
+
type: "string",
|
|
1022
|
+
short: "d",
|
|
1023
|
+
multiple: true
|
|
1024
|
+
},
|
|
1025
|
+
format: {
|
|
1026
|
+
type: "string",
|
|
1027
|
+
short: "f",
|
|
1028
|
+
default: "md"
|
|
1029
|
+
},
|
|
1030
|
+
out: {
|
|
1031
|
+
type: "string",
|
|
1032
|
+
short: "o"
|
|
1033
|
+
},
|
|
1034
|
+
"site-env": {
|
|
1035
|
+
type: "string",
|
|
1036
|
+
default: "production"
|
|
1037
|
+
}
|
|
1038
|
+
},
|
|
1039
|
+
allowPositionals: false
|
|
1040
|
+
});
|
|
1041
|
+
const output = values.format;
|
|
1042
|
+
if (!LEGAL_OUTPUTS.has(output)) fail(`unknown format "${output}" (expected: ${[...LEGAL_OUTPUTS].join(" | ")})`);
|
|
1043
|
+
const requested = values.doc ?? [];
|
|
1044
|
+
for (const d of requested) if (!LEGAL_DOCS.has(d)) fail(`unknown doc "${d}" (expected: ${[...LEGAL_DOCS].join(" | ")})`);
|
|
1045
|
+
const site = await loadSiteConfig(resolve(values.input), values["site-env"]);
|
|
1046
|
+
const docs = requested.length ? requested : enabledDocs(site);
|
|
1047
|
+
if (docs.length === 0) fail("no legal documents are enabled — set legal.privacyPolicy.enabled (or termsOfService / cookieConsent) in your site config, or name one with --doc");
|
|
1048
|
+
const files = generateLegal(site, {
|
|
1049
|
+
docs,
|
|
1050
|
+
output
|
|
1051
|
+
});
|
|
1052
|
+
if (!values.out) {
|
|
1053
|
+
process.stdout.write(Object.values(files).join("\n"));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const outDir = resolve(values.out);
|
|
1057
|
+
mkdirSync(outDir, { recursive: true });
|
|
1058
|
+
for (const [name, content] of Object.entries(files)) writeFileSync(join(outDir, name), content);
|
|
1059
|
+
console.log(`✓ legal → ${outDir} (${Object.keys(files).length} files)`);
|
|
1060
|
+
console.log(" These are generated from your config and are not legal advice — review them before publishing.");
|
|
1061
|
+
}
|
|
446
1062
|
function cliVersion() {
|
|
447
1063
|
try {
|
|
448
1064
|
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
|
|
@@ -465,6 +1081,7 @@ function cmdDocs(argv) {
|
|
|
465
1081
|
short: "i",
|
|
466
1082
|
default: "design-system.json"
|
|
467
1083
|
},
|
|
1084
|
+
theme: { type: "string" },
|
|
468
1085
|
all: {
|
|
469
1086
|
type: "boolean",
|
|
470
1087
|
default: false
|
|
@@ -479,45 +1096,10 @@ function cmdDocs(argv) {
|
|
|
479
1096
|
return;
|
|
480
1097
|
}
|
|
481
1098
|
if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
|
|
482
|
-
const docs = generateDocs(loadConfig(resolve(values.input)), generatorAssetsDir());
|
|
1099
|
+
const docs = generateDocs(loadConfig(resolve(values.input), values.theme), generatorAssetsDir());
|
|
483
1100
|
const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
|
|
484
1101
|
process.stdout.write(paths.map((p) => docs[p]).join("\n"));
|
|
485
1102
|
}
|
|
486
|
-
const SCAN_EXT = new Set([
|
|
487
|
-
".astro",
|
|
488
|
-
".html",
|
|
489
|
-
".htm",
|
|
490
|
-
".js",
|
|
491
|
-
".jsx",
|
|
492
|
-
".ts",
|
|
493
|
-
".tsx",
|
|
494
|
-
".vue",
|
|
495
|
-
".svelte",
|
|
496
|
-
".php",
|
|
497
|
-
".md",
|
|
498
|
-
".mdx"
|
|
499
|
-
]);
|
|
500
|
-
const SCAN_SKIP = new Set([
|
|
501
|
-
"node_modules",
|
|
502
|
-
"dist",
|
|
503
|
-
".git",
|
|
504
|
-
".astro",
|
|
505
|
-
"build",
|
|
506
|
-
"coverage"
|
|
507
|
-
]);
|
|
508
|
-
function scanFiles(dir, acc = []) {
|
|
509
|
-
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
510
|
-
if (e.name.startsWith(".") && e.name !== ".") continue;
|
|
511
|
-
const p = join(dir, e.name);
|
|
512
|
-
if (e.isDirectory()) {
|
|
513
|
-
if (!SCAN_SKIP.has(e.name)) scanFiles(p, acc);
|
|
514
|
-
} else if (SCAN_EXT.has(extname(e.name))) acc.push({
|
|
515
|
-
path: p,
|
|
516
|
-
text: readFileSync(p, "utf8")
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
return acc;
|
|
520
|
-
}
|
|
521
1103
|
function cmdLint(argv) {
|
|
522
1104
|
const { values } = parseArgs({
|
|
523
1105
|
args: argv,
|
|
@@ -527,6 +1109,7 @@ function cmdLint(argv) {
|
|
|
527
1109
|
short: "i",
|
|
528
1110
|
default: "design-system.json"
|
|
529
1111
|
},
|
|
1112
|
+
theme: { type: "string" },
|
|
530
1113
|
format: {
|
|
531
1114
|
type: "string",
|
|
532
1115
|
short: "f",
|
|
@@ -536,34 +1119,47 @@ function cmdLint(argv) {
|
|
|
536
1119
|
type: "string",
|
|
537
1120
|
short: "s",
|
|
538
1121
|
default: "src"
|
|
1122
|
+
},
|
|
1123
|
+
strict: {
|
|
1124
|
+
type: "boolean",
|
|
1125
|
+
default: false
|
|
539
1126
|
}
|
|
540
1127
|
},
|
|
541
1128
|
allowPositionals: false
|
|
542
1129
|
});
|
|
543
1130
|
const format = values.format;
|
|
544
|
-
if (!
|
|
545
|
-
const ds = loadConfig(resolve(values.input));
|
|
1131
|
+
if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
|
|
1132
|
+
const ds = loadConfig(resolve(values.input), values.theme);
|
|
546
1133
|
const srcDir = resolve(values.src);
|
|
547
1134
|
if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
|
|
548
|
-
const
|
|
1135
|
+
const palette = expandPalette(ds.colors.palette);
|
|
1136
|
+
const roleClasses = roleColorUtilities(Object.entries(ds.colors.roles).map(([role, spec]) => functionalRole(role, roleHue(spec), palette[roleHue(spec)], roleKind(spec))), ds.colors.utilities ?? [
|
|
549
1137
|
"bg",
|
|
550
1138
|
"text",
|
|
1139
|
+
"icon",
|
|
551
1140
|
"border"
|
|
552
1141
|
]).map((u) => u.cls);
|
|
553
1142
|
const files = scanFiles(srcDir);
|
|
554
|
-
const
|
|
1143
|
+
const cssFiles = [...files, ...scanFiles(srcDir, { exts: [".css"] })];
|
|
1144
|
+
const findings = [...lintSource(files, vocabulary(ds, roleClasses), format), ...lintCss(cssFiles, format)];
|
|
555
1145
|
if (!findings.length) {
|
|
556
1146
|
console.log(`✓ no unresolvable framework classes in ${values.src} (${files.length} file${files.length === 1 ? "" : "s"} scanned)`);
|
|
557
1147
|
return;
|
|
558
1148
|
}
|
|
559
|
-
|
|
560
|
-
|
|
1149
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
1150
|
+
const suggestions = findings.filter((f) => f.severity === "suggestion");
|
|
1151
|
+
for (const f of [...errors, ...suggestions]) {
|
|
1152
|
+
console.error(`${f.file}:${f.line} ${f.severity === "error" ? f.cls : `(${f.cls})`}`);
|
|
561
1153
|
console.error(` ${f.reason}`);
|
|
562
1154
|
if (f.suggestion) console.error(` try: ${f.suggestion}`);
|
|
563
1155
|
}
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
1156
|
+
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
1157
|
+
const parts = [];
|
|
1158
|
+
if (errors.length) parts.push(count(errors.length, "unresolvable class", "unresolvable classes"));
|
|
1159
|
+
if (suggestions.length) parts.push(count(suggestions.length, "suggestion", "suggestions"));
|
|
1160
|
+
const failing = errors.length > 0 || values.strict === true && suggestions.length > 0;
|
|
1161
|
+
console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${files.length} file${files.length === 1 ? "" : "s"}` + (suggestions.length && !values.strict ? " (suggestions do not fail; use --strict)" : ""));
|
|
1162
|
+
if (failing) process.exit(1);
|
|
567
1163
|
}
|
|
568
1164
|
function cmdAgents(argv) {
|
|
569
1165
|
const { values } = parseArgs({
|
|
@@ -574,6 +1170,7 @@ function cmdAgents(argv) {
|
|
|
574
1170
|
short: "i",
|
|
575
1171
|
default: "design-system.json"
|
|
576
1172
|
},
|
|
1173
|
+
theme: { type: "string" },
|
|
577
1174
|
out: {
|
|
578
1175
|
type: "string",
|
|
579
1176
|
short: "o",
|
|
@@ -585,7 +1182,7 @@ function cmdAgents(argv) {
|
|
|
585
1182
|
});
|
|
586
1183
|
const inputRel = values.input;
|
|
587
1184
|
let ds = null;
|
|
588
|
-
if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel));
|
|
1185
|
+
if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel), values.theme);
|
|
589
1186
|
else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
|
|
590
1187
|
const legacyDocsDir = values["docs-dir"];
|
|
591
1188
|
let summary;
|
|
@@ -641,6 +1238,7 @@ function cmdAgents(argv) {
|
|
|
641
1238
|
"- `vitops generate --format tailwind --out src/styles` — Tailwind v4 / Astro",
|
|
642
1239
|
"- `vitops generate --format css --out dist` — standalone CSS",
|
|
643
1240
|
"- `vitops generate --format bricks --out <theme>/dist` — WordPress / Bricks",
|
|
1241
|
+
"- `vitops generate --format design --out .` — `DESIGN.md`, the agent-facing brief",
|
|
644
1242
|
"- `vitops init` · `vitops validate` · `vitops favicon`",
|
|
645
1243
|
"",
|
|
646
1244
|
...pointer,
|
|
@@ -684,7 +1282,9 @@ async function main() {
|
|
|
684
1282
|
case "agents": return cmdAgents(rest);
|
|
685
1283
|
case "docs": return cmdDocs(rest);
|
|
686
1284
|
case "lint": return cmdLint(rest);
|
|
687
|
-
|
|
1285
|
+
case "legal": return cmdLegal(rest);
|
|
1286
|
+
case "icons": return cmdIcons(rest);
|
|
1287
|
+
default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons). Try: vitops --help`);
|
|
688
1288
|
}
|
|
689
1289
|
}
|
|
690
1290
|
main().catch((err) => fail(err.message));
|