@getvitops/cli 1.0.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/dist/cli.mjs CHANGED
@@ -1,11 +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, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
- import { dirname, extname, join, parse, relative, resolve } from "node:path";
4
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, parse, relative, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { NUMERIC_STEPS, SCHEMA_URL, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, resolveSiteConfig, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
8
- import { generateFavicons } from "@getvitops/utils";
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";
9
9
 
10
10
  //#region src/agents.ts
11
11
  /**
@@ -40,6 +40,10 @@ const TOPICS = {
40
40
  path: "concepts/scales.md",
41
41
  summary: "fluid modular type & space scales"
42
42
  },
43
+ icons: {
44
+ path: "concepts/icons.md",
45
+ summary: "semantic icon names across sets, bundle derivation, sprite delivery"
46
+ },
43
47
  patterns: {
44
48
  path: "concepts/patterns.md",
45
49
  summary: "pattern CSS: token cascade, override hooks, states, role variants"
@@ -95,6 +99,240 @@ function linkSkill(linkPath, targetDir) {
95
99
  }
96
100
  }
97
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
+
98
336
  //#endregion
99
337
  //#region src/lint.ts
100
338
  /**
@@ -154,10 +392,21 @@ function vocabulary(ds, roleClasses) {
154
392
  roleClasses: new Set(roleClasses)
155
393
  };
156
394
  }
157
- /** Strip Tailwind variant prefixes (`@md:`, `hover:`, `dark:`) from a candidate. */
158
- const stripVariants = (cls) => {
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) => {
159
405
  let out = cls;
160
- while (/^@?[a-zA-Z0-9_-]+:/.test(out)) out = out.slice(out.indexOf(":") + 1);
406
+ while (/^@?[a-zA-Z0-9_-]+:/.test(out)) {
407
+ if (format !== "tailwind" && out.startsWith("@")) break;
408
+ out = out.slice(out.indexOf(":") + 1);
409
+ }
161
410
  return out;
162
411
  };
163
412
  const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.startsWith(p));
@@ -166,10 +415,18 @@ const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.star
166
415
  * which is the common case and the whole reason this is low-noise.
167
416
  */
168
417
  function judge(raw, v, format) {
169
- 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
+ };
170
426
  const bp = /^(sm|md|lg|xl)-(.+)$/.exec(cls);
171
427
  if (bp && format === "tailwind") return {
172
428
  cls: raw,
429
+ severity: "error",
173
430
  reason: `\`${bp[1]}-\` responsive classes are not emitted in the tailwind format`,
174
431
  suggestion: `@${bp[1]}:${bp[2]} (framework breakpoints) or ${bp[1]}:${bp[2]} (Tailwind's)`
175
432
  };
@@ -186,6 +443,7 @@ function judge(raw, v, format) {
186
443
  const valid = emittedModifiers(v.roleClasses, fam, role);
187
444
  return {
188
445
  cls: raw,
446
+ severity: "error",
189
447
  reason: `\`${fam}-${role}${mod ? `-${mod}` : ""}\` is not emitted for the \`${role}\` role`,
190
448
  suggestion: valid.length ? `valid modifiers: ${valid.join(", ")}` : `the \`${role}\` role emits no \`${fam}-*\` utilities`
191
449
  };
@@ -194,11 +452,13 @@ function judge(raw, v, format) {
194
452
  const step = rest.slice(hue.length + 1);
195
453
  if (step === "") return {
196
454
  cls: raw,
455
+ severity: "error",
197
456
  reason: `\`${fam}-${hue}\` names a palette hue with no step`,
198
457
  suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"}`
199
458
  };
200
459
  if (!v.steps.includes(step)) return {
201
460
  cls: raw,
461
+ severity: "error",
202
462
  reason: `\`${step}\` is not a step on the \`${hue}\` scale (steps are 50…950)`,
203
463
  suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"} which also flips in dark mode`
204
464
  };
@@ -211,6 +471,7 @@ function judge(raw, v, format) {
211
471
  const near = nearest(font[1], v.typeRoles);
212
472
  if (near) return {
213
473
  cls: raw,
474
+ severity: "error",
214
475
  reason: `\`${font[1]}\` is not a typography role`,
215
476
  suggestion: `font-${near}`
216
477
  };
@@ -218,6 +479,7 @@ function judge(raw, v, format) {
218
479
  const ds = /^drop-shadow-(.+)$/.exec(base);
219
480
  if (ds && v.shadows.length && !v.shadows.includes(ds[1])) return {
220
481
  cls: raw,
482
+ severity: "error",
221
483
  reason: `\`${ds[1]}\` is not a defined shadow`,
222
484
  suggestion: `drop-shadow-${v.shadows[0]}`
223
485
  };
@@ -262,8 +524,8 @@ function lintSource(files, v, format) {
262
524
  *
263
525
  * vitops generate --input <json> --format <bricks|css|tailwind|design[,…]> --out <dir>
264
526
  * vitops init [--out design-system.json] [--force]
265
- * vitops validate <design-system.json>
266
- * 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>]
267
529
  * vitops agents [--input <json>] [--out AGENTS.md] [--docs-dir <dir>]
268
530
  * vitops docs [topic] [--input <json>] [--all]
269
531
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
@@ -295,25 +557,36 @@ const LEGAL_OUTPUTS = new Set([
295
557
  ]);
296
558
  const HELP = `vitops — generate design-system outputs from a design-system.json
297
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
+
298
565
  Usage:
299
566
  vitops generate [options] Generate platform output from a config
300
567
  vitops init [options] Scaffold a starter design-system.json
301
- vitops validate <file> Validate a config against the schema
568
+ vitops validate <file> Validate a config against the schema it says it is
302
569
  vitops favicon [options] Generate a favicon set from a source image
303
570
  vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
304
571
  vitops docs [topic] Print live design-system reference docs to stdout
305
572
  vitops lint [options] Report framework classes in your source that resolve to nothing
306
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
307
575
 
308
576
  Generate options:
309
- -i, --input <path> Config file (default: ./design-system.json)
577
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
310
578
  -f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
311
579
  design emits DESIGN.md only (the agent-facing brief) — pair it
312
580
  with --out . to write it beside AGENTS.md, or compose:
313
581
  --format css,design
314
582
  -o, --out <dir> Output directory (default: ./dist)
315
- --site <path> Site config; also emits legal/*.html into the output
316
- directory (see: vitops legal). Omitted: no legal files.
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)
317
590
 
318
591
  Init options:
319
592
  -o, --out <path> Where to write (default: ./design-system.json)
@@ -323,9 +596,13 @@ Favicon options:
323
596
  -i, --input <path> Source SVG or PNG (required)
324
597
  -o, --out <dir> Output directory (default: ./public)
325
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)
326
601
 
327
602
  Agents options:
328
- -i, --input <path> Config file (default: ./design-system.json; validated if present)
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
329
606
  -o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
330
607
  --docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
331
608
  instead of linking the packaged skill
@@ -333,14 +610,25 @@ Agents options:
333
610
  Docs options:
334
611
  <topic> classes | authoring | formats | color | scales | patterns | elements
335
612
  (no topic: list topics with summaries)
336
- -i, --input <path> Config file (default: ./design-system.json)
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
337
615
  --all Print every topic, concatenated
338
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
+
339
624
  Lint options:
340
- -i, --input <path> Config file (default: ./design-system.json)
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
341
627
  -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
342
628
  Responsive md-* classes are real in css/bricks, inert in tailwind.
343
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
344
632
 
345
633
  Legal options:
346
634
  -i, --input <path> Site config: .json, or a .js/.ts module with a default
@@ -382,7 +670,9 @@ async function cmdGenerate(argv) {
382
670
  short: "o",
383
671
  default: "dist"
384
672
  },
385
- site: { type: "string" }
673
+ site: { type: "string" },
674
+ theme: { type: "string" },
675
+ "site-env": { type: "string" }
386
676
  },
387
677
  allowPositionals: false
388
678
  });
@@ -397,7 +687,9 @@ async function cmdGenerate(argv) {
397
687
  input,
398
688
  format,
399
689
  outDir: values.out,
400
- ...site ? { site } : {}
690
+ ...site ? { site } : {},
691
+ ...values.theme ? { theme: values.theme } : {},
692
+ ...values["site-env"] ? { siteEnv: values["site-env"] } : {}
401
693
  });
402
694
  console.log(`✓ ${format} → ${res.outDir} (${res.written.length} paths)`);
403
695
  } catch (err) {
@@ -427,13 +719,25 @@ function cmdInit(argv) {
427
719
  console.log(` Edit it, then: vitops generate --input ${values.out} --format tailwind --out src/styles`);
428
720
  console.log(` Editors read the "$schema" (${SCHEMA_URL}) for autocomplete + validation.`);
429
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
+ */
430
731
  function cmdValidate(argv) {
431
732
  const { values, positionals } = parseArgs({
432
733
  args: argv,
433
- options: { input: {
434
- type: "string",
435
- short: "i"
436
- } },
734
+ options: {
735
+ input: {
736
+ type: "string",
737
+ short: "i"
738
+ },
739
+ "site-env": { type: "string" }
740
+ },
437
741
  allowPositionals: true
438
742
  });
439
743
  const path = resolve(positionals[0] ?? values.input ?? "design-system.json");
@@ -444,6 +748,30 @@ function cmdValidate(argv) {
444
748
  } catch (err) {
445
749
  fail(`could not parse JSON: ${err.message}`);
446
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
+ }
447
775
  const result = validate(raw);
448
776
  for (const w of result.warnings) console.warn(` ! ${w}`);
449
777
  if (result.ok) {
@@ -467,7 +795,8 @@ async function cmdFavicon(argv) {
467
795
  short: "o",
468
796
  default: "public"
469
797
  },
470
- "low-res": { type: "string" }
798
+ "low-res": { type: "string" },
799
+ background: { type: "string" }
471
800
  },
472
801
  allowPositionals: false
473
802
  });
@@ -478,14 +807,24 @@ async function cmdFavicon(argv) {
478
807
  const written = await generateFavicons({
479
808
  source,
480
809
  outputDir: values.out,
481
- ...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {}
810
+ ...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {},
811
+ ...values.background ? { backgroundColor: values.background } : {}
482
812
  });
483
813
  console.log(`✓ favicons → ${values.out} (${written.length} files)`);
484
814
  } catch (err) {
485
815
  fail(err.message);
486
816
  }
487
817
  }
488
- function loadConfig(path) {
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) {
489
828
  if (!existsSync(path)) fail(`config not found: ${path}`);
490
829
  let raw;
491
830
  try {
@@ -493,9 +832,16 @@ function loadConfig(path) {
493
832
  } catch (err) {
494
833
  fail(`could not parse JSON: ${err.message}`);
495
834
  }
496
- const result = validate(raw);
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);
497
842
  if (!result.ok) {
498
- console.error(`✖ ${path} is invalid:`);
843
+ const where = resolved.theme != null ? ` (designSystem.themes.${resolved.theme})` : "";
844
+ console.error(`✖ ${path}${where} is invalid:`);
499
845
  for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
500
846
  process.exit(1);
501
847
  }
@@ -540,6 +886,128 @@ async function loadSiteConfig(path, siteEnv) {
540
886
  * the same documents without any integration code. Prints to stdout like
541
887
  * `vitops docs`, or writes files when given `--out`.
542
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
+ }
543
1011
  async function cmdLegal(argv) {
544
1012
  const { values } = parseArgs({
545
1013
  args: argv,
@@ -613,6 +1081,7 @@ function cmdDocs(argv) {
613
1081
  short: "i",
614
1082
  default: "design-system.json"
615
1083
  },
1084
+ theme: { type: "string" },
616
1085
  all: {
617
1086
  type: "boolean",
618
1087
  default: false
@@ -627,45 +1096,10 @@ function cmdDocs(argv) {
627
1096
  return;
628
1097
  }
629
1098
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
630
- const docs = generateDocs(loadConfig(resolve(values.input)), generatorAssetsDir());
1099
+ const docs = generateDocs(loadConfig(resolve(values.input), values.theme), generatorAssetsDir());
631
1100
  const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
632
1101
  process.stdout.write(paths.map((p) => docs[p]).join("\n"));
633
1102
  }
634
- const SCAN_EXT = new Set([
635
- ".astro",
636
- ".html",
637
- ".htm",
638
- ".js",
639
- ".jsx",
640
- ".ts",
641
- ".tsx",
642
- ".vue",
643
- ".svelte",
644
- ".php",
645
- ".md",
646
- ".mdx"
647
- ]);
648
- const SCAN_SKIP = new Set([
649
- "node_modules",
650
- "dist",
651
- ".git",
652
- ".astro",
653
- "build",
654
- "coverage"
655
- ]);
656
- function scanFiles(dir, acc = []) {
657
- for (const e of readdirSync(dir, { withFileTypes: true })) {
658
- if (e.name.startsWith(".") && e.name !== ".") continue;
659
- const p = join(dir, e.name);
660
- if (e.isDirectory()) {
661
- if (!SCAN_SKIP.has(e.name)) scanFiles(p, acc);
662
- } else if (SCAN_EXT.has(extname(e.name))) acc.push({
663
- path: p,
664
- text: readFileSync(p, "utf8")
665
- });
666
- }
667
- return acc;
668
- }
669
1103
  function cmdLint(argv) {
670
1104
  const { values } = parseArgs({
671
1105
  args: argv,
@@ -675,6 +1109,7 @@ function cmdLint(argv) {
675
1109
  short: "i",
676
1110
  default: "design-system.json"
677
1111
  },
1112
+ theme: { type: "string" },
678
1113
  format: {
679
1114
  type: "string",
680
1115
  short: "f",
@@ -684,13 +1119,17 @@ function cmdLint(argv) {
684
1119
  type: "string",
685
1120
  short: "s",
686
1121
  default: "src"
1122
+ },
1123
+ strict: {
1124
+ type: "boolean",
1125
+ default: false
687
1126
  }
688
1127
  },
689
1128
  allowPositionals: false
690
1129
  });
691
1130
  const format = values.format;
692
1131
  if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
693
- const ds = loadConfig(resolve(values.input));
1132
+ const ds = loadConfig(resolve(values.input), values.theme);
694
1133
  const srcDir = resolve(values.src);
695
1134
  if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
696
1135
  const palette = expandPalette(ds.colors.palette);
@@ -701,19 +1140,26 @@ function cmdLint(argv) {
701
1140
  "border"
702
1141
  ]).map((u) => u.cls);
703
1142
  const files = scanFiles(srcDir);
704
- const findings = lintSource(files, vocabulary(ds, roleClasses), format);
1143
+ const cssFiles = [...files, ...scanFiles(srcDir, { exts: [".css"] })];
1144
+ const findings = [...lintSource(files, vocabulary(ds, roleClasses), format), ...lintCss(cssFiles, format)];
705
1145
  if (!findings.length) {
706
1146
  console.log(`✓ no unresolvable framework classes in ${values.src} (${files.length} file${files.length === 1 ? "" : "s"} scanned)`);
707
1147
  return;
708
1148
  }
709
- for (const f of findings) {
710
- console.error(`${f.file}:${f.line} ${f.cls}`);
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})`}`);
711
1153
  console.error(` ${f.reason}`);
712
1154
  if (f.suggestion) console.error(` try: ${f.suggestion}`);
713
1155
  }
714
- const n = findings.length;
715
- console.error(`\n✖ ${n} unresolvable class${n === 1 ? "" : "es"} in ${files.length} file${files.length === 1 ? "" : "s"}`);
716
- process.exit(1);
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);
717
1163
  }
718
1164
  function cmdAgents(argv) {
719
1165
  const { values } = parseArgs({
@@ -724,6 +1170,7 @@ function cmdAgents(argv) {
724
1170
  short: "i",
725
1171
  default: "design-system.json"
726
1172
  },
1173
+ theme: { type: "string" },
727
1174
  out: {
728
1175
  type: "string",
729
1176
  short: "o",
@@ -735,7 +1182,7 @@ function cmdAgents(argv) {
735
1182
  });
736
1183
  const inputRel = values.input;
737
1184
  let ds = null;
738
- if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel));
1185
+ if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel), values.theme);
739
1186
  else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
740
1187
  const legacyDocsDir = values["docs-dir"];
741
1188
  let summary;
@@ -836,7 +1283,8 @@ async function main() {
836
1283
  case "docs": return cmdDocs(rest);
837
1284
  case "lint": return cmdLint(rest);
838
1285
  case "legal": return cmdLegal(rest);
839
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal). Try: vitops --help`);
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`);
840
1288
  }
841
1289
  }
842
1290
  main().catch((err) => fail(err.message));