@getvitops/cli 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,11 +1,13 @@
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
+ import { SNAPSHOT_PATH, collectEntries, formatPlan, getAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot } from "@getvitops/utils/indexing";
10
+ import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$1, processMedia } from "@getvitops/utils/media";
9
11
 
10
12
  //#region src/agents.ts
11
13
  /**
@@ -40,6 +42,10 @@ const TOPICS = {
40
42
  path: "concepts/scales.md",
41
43
  summary: "fluid modular type & space scales"
42
44
  },
45
+ icons: {
46
+ path: "concepts/icons.md",
47
+ summary: "semantic icon names across sets, bundle derivation, sprite delivery"
48
+ },
43
49
  patterns: {
44
50
  path: "concepts/patterns.md",
45
51
  summary: "pattern CSS: token cascade, override hooks, states, role variants"
@@ -95,6 +101,240 @@ function linkSkill(linkPath, targetDir) {
95
101
  }
96
102
  }
97
103
 
104
+ //#endregion
105
+ //#region src/css-scan.ts
106
+ /** Strip comments, preserving newlines so line numbers survive. */
107
+ function stripComments(css) {
108
+ return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
109
+ }
110
+ const lineAt = (src, index) => src.slice(0, index).split("\n").length;
111
+ /**
112
+ * Parse declarations from a rule body. Splits on top-level `;` only, so a
113
+ * `var(--x, a; b)` or a data URI cannot end a declaration early.
114
+ */
115
+ function parseDeclarations(body) {
116
+ const out = [];
117
+ let depth = 0;
118
+ let start = 0;
119
+ const push = (chunk) => {
120
+ const colon = chunk.indexOf(":");
121
+ if (colon < 0) return;
122
+ const property = chunk.slice(0, colon).trim();
123
+ const value = chunk.slice(colon + 1).trim();
124
+ if (!property || property.includes("{") || !value) return;
125
+ out.push({
126
+ property,
127
+ value
128
+ });
129
+ };
130
+ for (let i = 0; i < body.length; i++) {
131
+ const ch = body[i];
132
+ if (ch === "(" || ch === "[") depth++;
133
+ else if (ch === ")" || ch === "]") depth--;
134
+ else if (ch === ";" && depth === 0) {
135
+ push(body.slice(start, i));
136
+ start = i + 1;
137
+ }
138
+ }
139
+ push(body.slice(start));
140
+ return out;
141
+ }
142
+ /**
143
+ * Scan a stylesheet into its style rules.
144
+ *
145
+ * Never throws: this runs over consumer source that may be a fragment, and a
146
+ * linter that dies on one unparseable file is one nobody keeps in CI.
147
+ */
148
+ function scanCss(css) {
149
+ const src = stripComments(css);
150
+ const rules = [];
151
+ const stack = [];
152
+ let depth = 0;
153
+ let preludeStart = 0;
154
+ for (let i = 0; i < src.length; i++) {
155
+ const ch = src[i];
156
+ if (ch === "\"" || ch === "'") {
157
+ const quote = ch;
158
+ i++;
159
+ while (i < src.length && src[i] !== quote) {
160
+ if (src[i] === "\\") i++;
161
+ i++;
162
+ }
163
+ continue;
164
+ }
165
+ if (ch === "{") {
166
+ const raw = src.slice(preludeStart, i);
167
+ const prelude = raw.trim();
168
+ stack.push({
169
+ prelude,
170
+ index: preludeStart + raw.indexOf(prelude.slice(0, 1))
171
+ });
172
+ depth++;
173
+ preludeStart = i + 1;
174
+ continue;
175
+ }
176
+ if (ch === "}") {
177
+ const frame = stack.pop();
178
+ depth--;
179
+ if (frame && !frame.prelude.startsWith("@")) {
180
+ const bodyStart = src.indexOf("{", frame.index) + 1;
181
+ rules.push({
182
+ selector: frame.prelude,
183
+ declarations: parseDeclarations(src.slice(bodyStart, i)),
184
+ conditions: stack.filter((f) => f.prelude.startsWith("@")).map((f) => f.prelude),
185
+ line: lineAt(src, frame.index)
186
+ });
187
+ }
188
+ preludeStart = i + 1;
189
+ continue;
190
+ }
191
+ }
192
+ return rules.filter((r) => r.selector && depth >= 0);
193
+ }
194
+ /** `<style>` block contents from an .astro/.vue/.svelte/.html file. */
195
+ function extractStyleBlocks(src) {
196
+ const out = [];
197
+ for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
198
+ const line = lineAt(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
199
+ out.push({
200
+ css: m[1] ?? "",
201
+ line
202
+ });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ //#endregion
208
+ //#region src/lint-css.ts
209
+ /**
210
+ * The framework's own track widths. Declared in two places today
211
+ * (`core/css/layout.css` and the tailwind emitter), so a rule keyed to the token
212
+ * NAME rather than to a value survives either one moving.
213
+ */
214
+ const TRACK_WIDTHS = [
215
+ "--width-measure",
216
+ "--width-breakout",
217
+ "--width-spotlight"
218
+ ];
219
+ /**
220
+ * Column ratios that correspond to a `split-<a>-<b>` class.
221
+ *
222
+ * `.split` is FLEX, not grid — `display: flex` with `flex: 1` children, and a
223
+ * ratio class setting `--_split-a`/`--_split-b`. Equal columns are the base
224
+ * `.split` with no ratio class, which is why `1fr 1fr` is deliberately absent:
225
+ * suggesting `split-1-1` would name a class that does not exist, and this file
226
+ * would then be committing the very defect `lint.ts` reports.
227
+ */
228
+ const SPLITS = {
229
+ "1fr 2fr": "1-2",
230
+ "2fr 1fr": "2-1",
231
+ "1fr 3fr": "1-3",
232
+ "3fr 1fr": "3-1",
233
+ "1fr 4fr": "1-4",
234
+ "4fr 1fr": "4-1",
235
+ "2fr 3fr": "2-3",
236
+ "3fr 2fr": "3-2"
237
+ };
238
+ const decl = (rule, property) => rule.declarations.find((d) => d.property === property)?.value;
239
+ /** Is this rule inside a `min-width` media or container query? */
240
+ const inMinWidthQuery = (rule) => rule.conditions.some((c) => /min-width\s*:/.test(c));
241
+ /** The framework breakpoint a `min-width` condition corresponds to, if any. */
242
+ function breakpointOf(rule) {
243
+ const rem = rule.conditions.map((c) => /min-width\s*:\s*([\d.]+)rem/.exec(c)?.[1]).find(Boolean);
244
+ if (!rem) return null;
245
+ const n = Number(rem);
246
+ return n >= 80 ? "xl" : n >= 64 ? "lg" : n >= 48 ? "md" : n >= 30 ? "sm" : null;
247
+ }
248
+ /** Spell a responsive utility the way the target format actually emits it. */
249
+ const responsive = (bp, base, format) => format === "tailwind" ? `@${bp}:${base}` : `${bp}-${base}`;
250
+ /**
251
+ * One rule = one check. Kept as data rather than a chain of ifs so adding a
252
+ * primitive is adding an entry, and so each carries its own explanation.
253
+ */
254
+ const RULES = [
255
+ (rule) => {
256
+ const token = TRACK_WIDTHS.find((t) => decl(rule, "max-inline-size")?.includes(t) || decl(rule, "max-width")?.includes(t));
257
+ const centred = /\bauto\b/.test(decl(rule, "margin-inline") ?? decl(rule, "margin") ?? "");
258
+ if (!token || !centred) return null;
259
+ return {
260
+ cls: `${rule.selector} { max-inline-size: var(${token}); margin-inline: auto }`,
261
+ severity: "suggestion",
262
+ reason: "`.centered` is this pattern — a grid of named tracks, not a max-width plus auto margins",
263
+ suggestion: "use `.centered`, and widen an individual child with `breakout`, `spotlight` or `fullbleed` on that child rather than overriding the parent"
264
+ };
265
+ },
266
+ (rule, format) => {
267
+ const cols = decl(rule, "grid-template-columns");
268
+ if (!cols || !inMinWidthQuery(rule)) return null;
269
+ const ratio = SPLITS[cols.replace(/\s+/g, " ").trim()];
270
+ const bp = ratio && breakpointOf(rule);
271
+ if (!ratio || !bp) return null;
272
+ return {
273
+ cls: `${rule.selector} { grid-template-columns: ${cols} }`,
274
+ severity: "suggestion",
275
+ reason: `a ${cols} split behind a min-width query is the \`split-${ratio}\` utility`,
276
+ 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`
277
+ };
278
+ },
279
+ (rule, format) => {
280
+ if (decl(rule, "display") !== "flex") return null;
281
+ if (decl(rule, "align-items") !== "center") return null;
282
+ if (/column/.test(decl(rule, "flex-direction") ?? "")) return null;
283
+ return {
284
+ cls: `${rule.selector} { display: flex; align-items: center }`,
285
+ severity: "suggestion",
286
+ reason: "the framework has this as utilities",
287
+ 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"
288
+ };
289
+ }
290
+ ];
291
+ /**
292
+ * Lint stylesheets and the `<style>` blocks of component files.
293
+ *
294
+ * `files` carries raw text, same as `lintSource` — this module stays free of the
295
+ * filesystem so the caller owns discovery.
296
+ */
297
+ /**
298
+ * The banner every generated stylesheet opens with.
299
+ *
300
+ * Linting the generator's own output is the worst false positive this tool can
301
+ * produce: the emitted bundle contains the framework's real implementations of
302
+ * these very patterns, so it reports `.split` as reinventing `.split`. The
303
+ * `css` format writes `styles.css` into the consumer's source tree (`src/styles`
304
+ * by default), which is squarely inside what `--src` scans.
305
+ *
306
+ * Matched on the banner rather than on a path, because the output directory is
307
+ * configurable and a path rule would silently stop working when someone changes
308
+ * it.
309
+ */
310
+ const GENERATED = /^\s*\/\*!?\s*GENERATED\b|do not edit by hand\./;
311
+ function lintCss(files, format) {
312
+ const findings = [];
313
+ const seen = /* @__PURE__ */ new Set();
314
+ for (const file of files) {
315
+ if (GENERATED.test(file.text.slice(0, 400))) continue;
316
+ const blocks = file.path.endsWith(".css") ? [{
317
+ css: file.text,
318
+ line: 1
319
+ }] : extractStyleBlocks(file.text);
320
+ for (const block of blocks) for (const rule of scanCss(block.css)) for (const check of RULES) {
321
+ const verdict = check(rule, format);
322
+ if (!verdict) continue;
323
+ const line = block.line + rule.line - 1;
324
+ const key = `${file.path}:${line}:${verdict.reason}`;
325
+ if (seen.has(key)) continue;
326
+ seen.add(key);
327
+ findings.push({
328
+ file: file.path,
329
+ line,
330
+ ...verdict
331
+ });
332
+ break;
333
+ }
334
+ }
335
+ return findings;
336
+ }
337
+
98
338
  //#endregion
99
339
  //#region src/lint.ts
100
340
  /**
@@ -154,10 +394,21 @@ function vocabulary(ds, roleClasses) {
154
394
  roleClasses: new Set(roleClasses)
155
395
  };
156
396
  }
157
- /** Strip Tailwind variant prefixes (`@md:`, `hover:`, `dark:`) from a candidate. */
158
- const stripVariants = (cls) => {
397
+ /**
398
+ * Strip variant prefixes (`@md:`, `hover:`, `dark:`) from a candidate.
399
+ *
400
+ * Format-aware, because the container-query spelling only exists in tailwind.
401
+ * Stripping `@md:` unconditionally made the check asymmetric: `md-split-1-2` in a
402
+ * tailwind project was caught, but `@md:split-1-2` in a css/bricks project became
403
+ * a bare `split-1-2` that matched nothing and returned null — the same silent
404
+ * no-op, in the other direction, unreported.
405
+ */
406
+ const stripVariants = (cls, format) => {
159
407
  let out = cls;
160
- while (/^@?[a-zA-Z0-9_-]+:/.test(out)) out = out.slice(out.indexOf(":") + 1);
408
+ while (/^@?[a-zA-Z0-9_-]+:/.test(out)) {
409
+ if (format !== "tailwind" && out.startsWith("@")) break;
410
+ out = out.slice(out.indexOf(":") + 1);
411
+ }
161
412
  return out;
162
413
  };
163
414
  const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.startsWith(p));
@@ -166,10 +417,18 @@ const nearest = (want, pool) => pool.find((p) => p.startsWith(want) || want.star
166
417
  * which is the common case and the whole reason this is low-noise.
167
418
  */
168
419
  function judge(raw, v, format) {
169
- const cls = stripVariants(raw);
420
+ const cls = stripVariants(raw, format);
421
+ const cq = /^@(sm|md|lg|xl):(.+)$/.exec(cls);
422
+ if (cq && format !== "tailwind") return {
423
+ cls: raw,
424
+ severity: "error",
425
+ reason: `\`@${cq[1]}:\` container-query variants are a tailwind-format spelling; the ${format} format emits pre-expanded classes`,
426
+ suggestion: `${cq[1]}-${cq[2]}`
427
+ };
170
428
  const bp = /^(sm|md|lg|xl)-(.+)$/.exec(cls);
171
429
  if (bp && format === "tailwind") return {
172
430
  cls: raw,
431
+ severity: "error",
173
432
  reason: `\`${bp[1]}-\` responsive classes are not emitted in the tailwind format`,
174
433
  suggestion: `@${bp[1]}:${bp[2]} (framework breakpoints) or ${bp[1]}:${bp[2]} (Tailwind's)`
175
434
  };
@@ -186,6 +445,7 @@ function judge(raw, v, format) {
186
445
  const valid = emittedModifiers(v.roleClasses, fam, role);
187
446
  return {
188
447
  cls: raw,
448
+ severity: "error",
189
449
  reason: `\`${fam}-${role}${mod ? `-${mod}` : ""}\` is not emitted for the \`${role}\` role`,
190
450
  suggestion: valid.length ? `valid modifiers: ${valid.join(", ")}` : `the \`${role}\` role emits no \`${fam}-*\` utilities`
191
451
  };
@@ -194,11 +454,13 @@ function judge(raw, v, format) {
194
454
  const step = rest.slice(hue.length + 1);
195
455
  if (step === "") return {
196
456
  cls: raw,
457
+ severity: "error",
197
458
  reason: `\`${fam}-${hue}\` names a palette hue with no step`,
198
459
  suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"}`
199
460
  };
200
461
  if (!v.steps.includes(step)) return {
201
462
  cls: raw,
463
+ severity: "error",
202
464
  reason: `\`${step}\` is not a step on the \`${hue}\` scale (steps are 50…950)`,
203
465
  suggestion: `${fam}-${hue}-500, or a role such as ${fam}-${v.roles[0] ?? "neutral"} which also flips in dark mode`
204
466
  };
@@ -211,6 +473,7 @@ function judge(raw, v, format) {
211
473
  const near = nearest(font[1], v.typeRoles);
212
474
  if (near) return {
213
475
  cls: raw,
476
+ severity: "error",
214
477
  reason: `\`${font[1]}\` is not a typography role`,
215
478
  suggestion: `font-${near}`
216
479
  };
@@ -218,6 +481,7 @@ function judge(raw, v, format) {
218
481
  const ds = /^drop-shadow-(.+)$/.exec(base);
219
482
  if (ds && v.shadows.length && !v.shadows.includes(ds[1])) return {
220
483
  cls: raw,
484
+ severity: "error",
221
485
  reason: `\`${ds[1]}\` is not a defined shadow`,
222
486
  suggestion: `drop-shadow-${v.shadows[0]}`
223
487
  };
@@ -262,12 +526,14 @@ function lintSource(files, v, format) {
262
526
  *
263
527
  * vitops generate --input <json> --format <bricks|css|tailwind|design[,…]> --out <dir>
264
528
  * 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>]
529
+ * vitops validate <design-system.json|site.json>
530
+ * vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>] [--background <hex>]
267
531
  * vitops agents [--input <json>] [--out AGENTS.md] [--docs-dir <dir>]
268
532
  * vitops docs [topic] [--input <json>] [--all]
269
533
  * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
270
534
  * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
535
+ * vitops indexing [--input <site.json>] [--dry] [--all] [--check]
536
+ * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
271
537
  *
272
538
  * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
273
539
  * Every client brings their own consumer-editable design-system.json.
@@ -295,25 +561,38 @@ const LEGAL_OUTPUTS = new Set([
295
561
  ]);
296
562
  const HELP = `vitops — generate design-system outputs from a design-system.json
297
563
 
564
+ Anywhere a command takes --input, that file may be a design-system.json OR the
565
+ larger site config that embeds one (company.json / site.json). The two are told
566
+ apart by shape, and a site config also supplies the site-level facts generation
567
+ reads — its default colour scheme, its legal documents, its icon sprite.
568
+
298
569
  Usage:
299
570
  vitops generate [options] Generate platform output from a config
300
571
  vitops init [options] Scaffold a starter design-system.json
301
- vitops validate <file> Validate a config against the schema
572
+ vitops validate <file> Validate a config against the schema it says it is
302
573
  vitops favicon [options] Generate a favicon set from a source image
303
574
  vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
304
575
  vitops docs [topic] Print live design-system reference docs to stdout
305
576
  vitops lint [options] Report framework classes in your source that resolve to nothing
306
577
  vitops legal [options] Render legal documents from a site config
578
+ vitops icons [options] Report which icons your source uses, and build the sprite
579
+ vitops indexing [options] Tell search engines about a deploy (sitemap + IndexNow)
580
+ vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
307
581
 
308
582
  Generate options:
309
- -i, --input <path> Config file (default: ./design-system.json)
583
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
310
584
  -f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
311
585
  design emits DESIGN.md only (the agent-facing brief) — pair it
312
586
  with --out . to write it beside AGENTS.md, or compose:
313
587
  --format css,design
314
588
  -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.
589
+ --theme <name> Which designSystem.themes entry to build, when --input is
590
+ a site config (default: its defaultTheme, else "default")
591
+ --site <path> Site config, when it is a different file from --input.
592
+ Emits legal/*.html into the output directory (see:
593
+ vitops legal). Omitted with a plain design-system.json:
594
+ no legal files.
595
+ --site-env <env> Environment whose A/B variant applies (default: production)
317
596
 
318
597
  Init options:
319
598
  -o, --out <path> Where to write (default: ./design-system.json)
@@ -323,9 +602,13 @@ Favicon options:
323
602
  -i, --input <path> Source SVG or PNG (required)
324
603
  -o, --out <dir> Output directory (default: ./public)
325
604
  --low-res <path> Optional simplified source for the 16px icon
605
+ --background <hex> Background under the maskable icons, which must be
606
+ opaque (default: #ffffff)
326
607
 
327
608
  Agents options:
328
- -i, --input <path> Config file (default: ./design-system.json; validated if present)
609
+ -i, --input <path> design-system.json or site config (default: ./design-system.json;
610
+ validated if present)
611
+ --theme <name> Theme to read, when --input is a site config
329
612
  -o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
330
613
  --docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
331
614
  instead of linking the packaged skill
@@ -333,14 +616,25 @@ Agents options:
333
616
  Docs options:
334
617
  <topic> classes | authoring | formats | color | scales | patterns | elements
335
618
  (no topic: list topics with summaries)
336
- -i, --input <path> Config file (default: ./design-system.json)
619
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
620
+ --theme <name> Theme to render docs from, when --input is a site config
337
621
  --all Print every topic, concatenated
338
622
 
623
+ Icons options:
624
+ --site <path> Site config carrying the "icons" block (default: ./site.json)
625
+ --src <dir> Source to scan for icon usage (default: ./src)
626
+ --sprite Also build the SVG sprite
627
+ -o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)
628
+ --json Machine-readable report on stdout
629
+
339
630
  Lint options:
340
- -i, --input <path> Config file (default: ./design-system.json)
631
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
632
+ --theme <name> Theme to judge classes against, when --input is a site config
341
633
  -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
342
634
  Responsive md-* classes are real in css/bricks, inert in tailwind.
343
635
  -s, --src <dir> Directory to scan (default: ./src)
636
+ --strict Also fail on suggestions (reuse hints), not just on
637
+ classes that resolve to nothing
344
638
 
345
639
  Legal options:
346
640
  -i, --input <path> Site config: .json, or a .js/.ts module with a default
@@ -356,6 +650,56 @@ Legal options:
356
650
  Generated from your config — a starting point, not legal advice. Review before
357
651
  publishing, and make sure the config describes what your site actually does.
358
652
 
653
+ Indexing options:
654
+ -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
655
+ --site-env <env> Environment to notify for (default: production). An
656
+ environment whose robots policy says noindex is refused.
657
+ --sitemap <src> Sitemap URL or local path (default: from the config)
658
+ --urls <list> Comma-separated URLs to submit, skipping the diff
659
+ --all Submit every URL in the sitemap, not just what changed
660
+ --dry Print the plan and exit. Makes no requests.
661
+ --check Read-only: ask Google whether seo.indexing.priorityUrls
662
+ are indexed. Exits non-zero if one is not.
663
+ --new-key Print a fresh IndexNow key and exit
664
+ --write-key <dir> Write the IndexNow key file into <dir> (for stacks with
665
+ no Astro integration to do it — Bricks, WordPress)
666
+ --snapshot <path> Changed-URL state file (default: .vitops/sitemap-snapshot.json).
667
+ Persist it between runs (a CI cache) or every run submits
668
+ everything.
669
+
670
+ What this can and cannot do: Google exposes no "request indexing" API, and its
671
+ sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
672
+ through the Search Console API and verifies the result with --check. IndexNow
673
+ reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
674
+ Search Console needs a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
675
+ JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), added as an owner of the
676
+ property.
677
+
678
+ Media options:
679
+ --raw <dir> Directory of unprocessed video, walked recursively
680
+ (default: ./raw)
681
+ -o, --out <dir> Where the encoded outputs go (default: ./src/assets/processed).
682
+ Subdirectories under --raw are preserved.
683
+ --max-width <px> Cap the output width, keeping aspect ratio (default: 1920;
684
+ 0 disables scaling)
685
+ --crf <n> Quality on VP9's scale, 0-63, lower is better (default: 32).
686
+ The MP4 fallback uses the equivalent on H.264's scale.
687
+ --max-bitrate <r> Optional ceiling, e.g. 2M or 800k (default: none, which is
688
+ constant quality rather than constrained)
689
+ --audio Keep the audio track (default: dropped — the common case is
690
+ a muted autoplay loop)
691
+ --poster-time <s> Timestamp the poster frame is taken from (default: 0, which
692
+ is often black on a clip that fades in)
693
+ --outputs <list> Comma-separated: webm | mp4 | poster (default: all three)
694
+ --manifest <path> Cache file (default: .vitops/media-manifest.json)
695
+ --force Re-encode everything, ignoring the cache
696
+ --dry Print the plan and exit. Encodes nothing.
697
+
698
+ Needs ffmpeg on PATH — it is an external tool, not an npm dependency, and this
699
+ command fails rather than quietly skipping. Commit the outputs and the manifest:
700
+ ffmpeg output is not reproducible across versions, so re-encoding in CI churns
701
+ the diff on every toolchain bump. Use --force when you mean to re-encode.
702
+
359
703
  Common:
360
704
  -h, --help Show this help
361
705
  `;
@@ -382,7 +726,9 @@ async function cmdGenerate(argv) {
382
726
  short: "o",
383
727
  default: "dist"
384
728
  },
385
- site: { type: "string" }
729
+ site: { type: "string" },
730
+ theme: { type: "string" },
731
+ "site-env": { type: "string" }
386
732
  },
387
733
  allowPositionals: false
388
734
  });
@@ -397,7 +743,9 @@ async function cmdGenerate(argv) {
397
743
  input,
398
744
  format,
399
745
  outDir: values.out,
400
- ...site ? { site } : {}
746
+ ...site ? { site } : {},
747
+ ...values.theme ? { theme: values.theme } : {},
748
+ ...values["site-env"] ? { siteEnv: values["site-env"] } : {}
401
749
  });
402
750
  console.log(`✓ ${format} → ${res.outDir} (${res.written.length} paths)`);
403
751
  } catch (err) {
@@ -427,13 +775,25 @@ function cmdInit(argv) {
427
775
  console.log(` Edit it, then: vitops generate --input ${values.out} --format tailwind --out src/styles`);
428
776
  console.log(` Editors read the "$schema" (${SCHEMA_URL}) for autocomplete + validation.`);
429
777
  }
778
+ /**
779
+ * Validate a config against the schema its shape says it is.
780
+ *
781
+ * Routing on kind rather than asking the caller matters here: pointed at a site
782
+ * config, the design-system schema used to report a single `unrecognized_keys`
783
+ * for `designSystem` and nothing about the file's actual contents — a wrong
784
+ * answer that reads like a right one. A site config is validated whole, which
785
+ * also covers the cross-field integrity JSON Schema can't express.
786
+ */
430
787
  function cmdValidate(argv) {
431
788
  const { values, positionals } = parseArgs({
432
789
  args: argv,
433
- options: { input: {
434
- type: "string",
435
- short: "i"
436
- } },
790
+ options: {
791
+ input: {
792
+ type: "string",
793
+ short: "i"
794
+ },
795
+ "site-env": { type: "string" }
796
+ },
437
797
  allowPositionals: true
438
798
  });
439
799
  const path = resolve(positionals[0] ?? values.input ?? "design-system.json");
@@ -444,6 +804,30 @@ function cmdValidate(argv) {
444
804
  } catch (err) {
445
805
  fail(`could not parse JSON: ${err.message}`);
446
806
  }
807
+ if (isSiteConfig(raw)) {
808
+ let site;
809
+ try {
810
+ site = resolveSiteConfig(raw, values["site-env"]);
811
+ } catch (err) {
812
+ console.error(`✖ ${path} is invalid as a site config:`);
813
+ console.error(err.message.replace(/^Invalid site config:\n/, ""));
814
+ process.exit(1);
815
+ }
816
+ const themes = site.designSystem?.themes ?? {};
817
+ let bad = false;
818
+ for (const name of Object.keys(themes)) {
819
+ const result = validate(resolveTheme(themes, name));
820
+ for (const w of result.warnings) console.warn(` ! designSystem.themes.${name}: ${w}`);
821
+ if (result.ok) continue;
822
+ bad = true;
823
+ console.error(`✖ ${path} — designSystem.themes.${name} is not a complete design system:`);
824
+ for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
825
+ }
826
+ if (bad) process.exit(1);
827
+ const n = Object.keys(themes).length;
828
+ console.log(`✓ ${path} is a valid site config (${n} theme${n === 1 ? "" : "s"})`);
829
+ return;
830
+ }
447
831
  const result = validate(raw);
448
832
  for (const w of result.warnings) console.warn(` ! ${w}`);
449
833
  if (result.ok) {
@@ -467,7 +851,8 @@ async function cmdFavicon(argv) {
467
851
  short: "o",
468
852
  default: "public"
469
853
  },
470
- "low-res": { type: "string" }
854
+ "low-res": { type: "string" },
855
+ background: { type: "string" }
471
856
  },
472
857
  allowPositionals: false
473
858
  });
@@ -478,14 +863,117 @@ async function cmdFavicon(argv) {
478
863
  const written = await generateFavicons({
479
864
  source,
480
865
  outputDir: values.out,
481
- ...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {}
866
+ ...values["low-res"] ? { lowResSource: resolve(values["low-res"]) } : {},
867
+ ...values.background ? { backgroundColor: values.background } : {}
482
868
  });
483
869
  console.log(`✓ favicons → ${values.out} (${written.length} files)`);
484
870
  } catch (err) {
485
871
  fail(err.message);
486
872
  }
487
873
  }
488
- function loadConfig(path) {
874
+ const OUTPUT_KINDS = new Set([
875
+ "webm",
876
+ "mp4",
877
+ "poster"
878
+ ]);
879
+ /**
880
+ * `vitops media` — encode a directory of raw video into web-ready outputs.
881
+ *
882
+ * Flags only, with no config file, following `favicon`: `site.favicon` exists in
883
+ * the schema and `cmdFavicon` still doesn't read it. The same reasoning applies
884
+ * more strongly here — `legal`, `icons` and `indexing` are anchored to a
885
+ * `SiteConfig` because what they emit *describes the site*, and an encoder setting
886
+ * describes a build step.
887
+ */
888
+ async function cmdMedia(argv) {
889
+ const { values } = parseArgs({
890
+ args: argv,
891
+ options: {
892
+ raw: {
893
+ type: "string",
894
+ default: "raw"
895
+ },
896
+ out: {
897
+ type: "string",
898
+ short: "o",
899
+ default: "src/assets/processed"
900
+ },
901
+ "max-width": { type: "string" },
902
+ crf: { type: "string" },
903
+ "max-bitrate": { type: "string" },
904
+ audio: {
905
+ type: "boolean",
906
+ default: false
907
+ },
908
+ "poster-time": { type: "string" },
909
+ outputs: { type: "string" },
910
+ manifest: {
911
+ type: "string",
912
+ default: MEDIA_MANIFEST_PATH
913
+ },
914
+ force: {
915
+ type: "boolean",
916
+ default: false
917
+ },
918
+ dry: {
919
+ type: "boolean",
920
+ default: false
921
+ }
922
+ },
923
+ allowPositionals: false
924
+ });
925
+ const num = (flag, raw) => {
926
+ if (raw === void 0) return void 0;
927
+ const n = Number(raw);
928
+ if (!Number.isFinite(n) || n < 0) fail(`media: --${flag} must be a non-negative number`);
929
+ return n;
930
+ };
931
+ let outputs;
932
+ if (values.outputs) {
933
+ outputs = values.outputs.split(",").map((s) => s.trim());
934
+ for (const kind of outputs) if (!OUTPUT_KINDS.has(kind)) fail(`media: unknown output "${kind}" (expected: webm | mp4 | poster)`);
935
+ }
936
+ const maxWidth = num("max-width", values["max-width"]);
937
+ const crf = num("crf", values.crf);
938
+ const posterTime = num("poster-time", values["poster-time"]);
939
+ const config = {
940
+ ...maxWidth !== void 0 ? { maxWidth } : {},
941
+ ...crf !== void 0 ? { crf } : {},
942
+ ...values["max-bitrate"] ? { maxBitrate: values["max-bitrate"] } : {},
943
+ ...values.audio ? { audio: true } : {},
944
+ ...posterTime !== void 0 ? { posterTime } : {},
945
+ ...outputs ? { outputs } : {}
946
+ };
947
+ try {
948
+ const result = await processMedia({
949
+ raw: values.raw,
950
+ out: values.out,
951
+ config,
952
+ manifest: values.manifest,
953
+ force: values.force,
954
+ dry: values.dry,
955
+ onProgress: (message) => console.error(` … ${message}`)
956
+ });
957
+ if (values.dry) {
958
+ console.log(formatPlan$1(result.plan));
959
+ return;
960
+ }
961
+ console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
962
+ for (const note of result.plan.notes) console.error(` ! ${note}`);
963
+ } catch (err) {
964
+ fail(err.message);
965
+ }
966
+ }
967
+ /**
968
+ * Read + parse + validate the design system at `path`, or exit with a message.
969
+ *
970
+ * `path` may be a `design-system.json` **or** the larger site config that embeds
971
+ * one — every command that judges source against the design system (`docs`,
972
+ * `lint`, `agents`) accepts either, so a consumer who keeps their tokens in
973
+ * `company.json` doesn't have to maintain a second file for the tooling's sake.
974
+ * `resolveInput` tells them apart by shape and resolves the theme.
975
+ */
976
+ function loadConfig(path, theme) {
489
977
  if (!existsSync(path)) fail(`config not found: ${path}`);
490
978
  let raw;
491
979
  try {
@@ -493,9 +981,16 @@ function loadConfig(path) {
493
981
  } catch (err) {
494
982
  fail(`could not parse JSON: ${err.message}`);
495
983
  }
496
- const result = validate(raw);
984
+ let resolved;
985
+ try {
986
+ resolved = resolveInput(raw, theme != null ? { theme } : {});
987
+ } catch (err) {
988
+ fail(`${path}: ${err.message}`);
989
+ }
990
+ const result = validate(resolved.designSystem);
497
991
  if (!result.ok) {
498
- console.error(`✖ ${path} is invalid:`);
992
+ const where = resolved.theme != null ? ` (designSystem.themes.${resolved.theme})` : "";
993
+ console.error(`✖ ${path}${where} is invalid:`);
499
994
  for (const e of result.errors) console.error(` • ${e.path.join(".") || "(root)"}: ${e.message}`);
500
995
  process.exit(1);
501
996
  }
@@ -540,6 +1035,128 @@ async function loadSiteConfig(path, siteEnv) {
540
1035
  * the same documents without any integration code. Prints to stdout like
541
1036
  * `vitops docs`, or writes files when given `--out`.
542
1037
  */
1038
+ /**
1039
+ * Report the icon vocabulary a project actually uses, and optionally build the
1040
+ * sprite from it.
1041
+ *
1042
+ * A sibling of `legal` rather than a widening of `lint`: `lint` judges classes
1043
+ * against a design-system.json, whereas icons are anchored to a SiteConfig.
1044
+ *
1045
+ * Exit codes carry the same distinction the Astro integration makes. A name the
1046
+ * config DECLARES but that doesn't resolve is a config error and fails the
1047
+ * command; a name only the SCAN found is reported and tolerated, because a bare
1048
+ * unmapped name is more often a local src/icons/*.svg than a mistake. Runtime-
1049
+ * computed names are listed with file and line so they can be declared, never
1050
+ * guessed at.
1051
+ */
1052
+ async function cmdIcons(argv) {
1053
+ const { values } = parseArgs({
1054
+ args: argv,
1055
+ options: {
1056
+ site: {
1057
+ type: "string",
1058
+ default: "site.json"
1059
+ },
1060
+ src: {
1061
+ type: "string",
1062
+ default: "src"
1063
+ },
1064
+ sprite: {
1065
+ type: "boolean",
1066
+ default: false
1067
+ },
1068
+ out: {
1069
+ type: "string",
1070
+ short: "o",
1071
+ default: "dist"
1072
+ },
1073
+ json: {
1074
+ type: "boolean",
1075
+ default: false
1076
+ },
1077
+ "site-env": {
1078
+ type: "string",
1079
+ default: "production"
1080
+ }
1081
+ },
1082
+ allowPositionals: false
1083
+ });
1084
+ const sitePath = resolve(values.site);
1085
+ const icons = existsSync(sitePath) ? (await loadSiteConfig(sitePath, values["site-env"])).icons ?? {} : {};
1086
+ const ui = icons.ui ?? "fa7-solid";
1087
+ const brand = icons.brand ?? "simple-icons";
1088
+ const weight = icons.weight;
1089
+ const weightOpt = weight ? { weight } : {};
1090
+ let include = {};
1091
+ try {
1092
+ include = generateIconInclude({ ...icons });
1093
+ } catch (e) {
1094
+ fail(e.message);
1095
+ }
1096
+ const add = (prefix, name) => {
1097
+ const list = include[prefix] ??= [];
1098
+ if (!list.includes(name)) list.push(name);
1099
+ };
1100
+ const srcDir = resolve(values.src);
1101
+ const scanned = existsSync(srcDir) ? collectIconRefs(scanFiles(srcDir)) : null;
1102
+ const unmapped = [];
1103
+ for (const name of scanned?.names ?? []) {
1104
+ const colon = name.indexOf(":");
1105
+ if (colon > 0) {
1106
+ add(name.slice(0, colon), name.slice(colon + 1));
1107
+ continue;
1108
+ }
1109
+ try {
1110
+ const q = resolveIcon(name, ui, weightOpt);
1111
+ add(q.slice(0, q.indexOf(":")), q.slice(q.indexOf(":") + 1));
1112
+ } catch {
1113
+ unmapped.push(name);
1114
+ }
1115
+ }
1116
+ const dynamic = (scanned?.dynamic ?? []).map((d) => ({
1117
+ file: relative(process.cwd(), d.file),
1118
+ line: d.line,
1119
+ expr: d.expr
1120
+ }));
1121
+ const total = Object.values(include).flat().length;
1122
+ let sprite = null;
1123
+ if (values.sprite) {
1124
+ const aliases = {};
1125
+ for (const name of icons.semantic ?? []) try {
1126
+ aliases[`icon-${name}`] = resolveIcon(name, ui, weightOpt);
1127
+ } catch {}
1128
+ const built = await buildIconSprite({
1129
+ include,
1130
+ aliases
1131
+ });
1132
+ const outDir = resolve(values.out);
1133
+ mkdirSync(outDir, { recursive: true });
1134
+ writeFileSync(join(outDir, "icons.svg"), built.svg);
1135
+ sprite = {
1136
+ ids: built.ids,
1137
+ missing: built.missing
1138
+ };
1139
+ }
1140
+ if (values.json) process.stdout.write(`${JSON.stringify({
1141
+ ui,
1142
+ brand,
1143
+ weight: weight ?? null,
1144
+ include,
1145
+ unmapped,
1146
+ dynamic,
1147
+ sprite
1148
+ }, null, 2)}\n`);
1149
+ else {
1150
+ console.log(`icon sets: ui=${ui} brand=${brand}${weight ? ` weight=${weight}` : ""}`);
1151
+ for (const [prefix, names] of Object.entries(include)) console.log(` ${prefix}: ${names.length} — ${names.join(", ")}`);
1152
+ console.log(`✓ ${total} icon(s) across ${Object.keys(include).length} set(s)`);
1153
+ if (sprite) console.log(`✓ sprite → ${resolve(values.out)}/icons.svg (${sprite.ids.length} symbols)`);
1154
+ for (const d of dynamic) console.log(` ! ${d.file}:${d.line} ${d.expr} — computed at runtime, declare it in icons`);
1155
+ if (unmapped.length) console.log(` ! not in the '${ui}' map: ${unmapped.join(", ")} (fine for a local svg or sprite id)`);
1156
+ if (sprite?.missing.length) console.log(` ! absent from the sprite: ${sprite.missing.join(", ")}`);
1157
+ }
1158
+ if (sprite?.missing.length) process.exitCode = 1;
1159
+ }
543
1160
  async function cmdLegal(argv) {
544
1161
  const { values } = parseArgs({
545
1162
  args: argv,
@@ -591,6 +1208,209 @@ async function cmdLegal(argv) {
591
1208
  console.log(`✓ legal → ${outDir} (${Object.keys(files).length} files)`);
592
1209
  console.log(" These are generated from your config and are not legal advice — review them before publishing.");
593
1210
  }
1211
+ /**
1212
+ * Adapt a `SiteConfig` to the indexing module's own option shape.
1213
+ *
1214
+ * A flat field map, as intended: `@getvitops/utils` cannot import from the
1215
+ * generator (the generator already depends on it), so `IndexingConfig` mirrors the
1216
+ * `seo.indexing` block rather than being it. The one piece of judgment here is the
1217
+ * origin — see below.
1218
+ */
1219
+ function toIndexingConfig(site, siteEnv) {
1220
+ const indexing = site.seo?.indexing ?? {};
1221
+ const env = site.environments?.[siteEnv];
1222
+ const canonical = env?.url ?? site.domains?.canonical;
1223
+ return {
1224
+ ...canonical ? { canonical } : {},
1225
+ ...indexing.sitemapUrl ? { sitemapUrl: indexing.sitemapUrl } : {},
1226
+ ...indexing.indexNow ? { indexNow: indexing.indexNow } : {},
1227
+ ...indexing.searchConsole ? { searchConsole: indexing.searchConsole } : {},
1228
+ ...indexing.priorityUrls ? { priorityUrls: indexing.priorityUrls } : {},
1229
+ ...env?.robots ?? site.seo?.robots ? { robots: env?.robots ?? site.seo?.robots } : {}
1230
+ };
1231
+ }
1232
+ /**
1233
+ * The Search Console credential, from either supported source.
1234
+ *
1235
+ * Never from the config file: it is the one genuine secret in this command, and a
1236
+ * `site.json` is committed. Returns `undefined` rather than failing, so a site
1237
+ * running IndexNow alone doesn't need a GCP project at all.
1238
+ */
1239
+ function loadServiceAccount() {
1240
+ const inline = process.env.VITOPS_GSC_SERVICE_ACCOUNT;
1241
+ const path = process.env.GOOGLE_APPLICATION_CREDENTIALS;
1242
+ const raw = inline ?? (path && existsSync(path) ? readFileSync(path, "utf8") : void 0);
1243
+ if (!raw) return void 0;
1244
+ try {
1245
+ return parseServiceAccount(raw);
1246
+ } catch (err) {
1247
+ fail(`${inline ? "VITOPS_GSC_SERVICE_ACCOUNT" : `GOOGLE_APPLICATION_CREDENTIALS (${path})`}: ${err.message}`);
1248
+ }
1249
+ }
1250
+ /** Read a sitemap from a URL or a local path. */
1251
+ const readSitemap = async (source) => {
1252
+ if (/^https?:\/\//i.test(source)) {
1253
+ const res = await fetch(source);
1254
+ if (!res.ok) fail(`could not fetch ${source}: ${res.status}`);
1255
+ return res.text();
1256
+ }
1257
+ if (!existsSync(source)) fail(`sitemap not found: ${source}`);
1258
+ return readFileSync(source, "utf8");
1259
+ };
1260
+ /**
1261
+ * Tell search engines a deploy happened.
1262
+ *
1263
+ * Worth being precise about what this is, because the obvious expectation is
1264
+ * wrong: **Google has no API that requests indexing.** The Search Console button
1265
+ * is not exposed anywhere, URL Inspection is read-only, and the sitemap ping
1266
+ * endpoint was removed in 2023. So the Google half of this command resubmits the
1267
+ * sitemap and then *verifies* the outcome with `--check`; the immediate-push half
1268
+ * is IndexNow, which Google does not participate in.
1269
+ *
1270
+ * The Indexing API is deliberately absent — see `@getvitops/utils/indexing`'s
1271
+ * `gsc.ts`.
1272
+ */
1273
+ async function cmdIndexing(argv) {
1274
+ const { values } = parseArgs({
1275
+ args: argv,
1276
+ options: {
1277
+ input: {
1278
+ type: "string",
1279
+ short: "i",
1280
+ default: "site.json"
1281
+ },
1282
+ "site-env": {
1283
+ type: "string",
1284
+ default: "production"
1285
+ },
1286
+ sitemap: { type: "string" },
1287
+ urls: { type: "string" },
1288
+ all: {
1289
+ type: "boolean",
1290
+ default: false
1291
+ },
1292
+ dry: {
1293
+ type: "boolean",
1294
+ default: false
1295
+ },
1296
+ check: {
1297
+ type: "boolean",
1298
+ default: false
1299
+ },
1300
+ "new-key": {
1301
+ type: "boolean",
1302
+ default: false
1303
+ },
1304
+ "write-key": { type: "string" },
1305
+ snapshot: {
1306
+ type: "string",
1307
+ default: SNAPSHOT_PATH
1308
+ }
1309
+ },
1310
+ allowPositionals: false
1311
+ });
1312
+ if (values["new-key"]) {
1313
+ const key = newKey();
1314
+ console.log(key);
1315
+ console.log(`\n Add to your site config:\n "seo": { "indexing": { "indexNow": { "key": "${key}" } } }`);
1316
+ console.log(` Then serve it at /${key}.txt — \`vitops indexing --write-key public\`.`);
1317
+ return;
1318
+ }
1319
+ const config = toIndexingConfig(await loadSiteConfig(resolve(values.input), values["site-env"]), values["site-env"]);
1320
+ if (values["write-key"] !== void 0) {
1321
+ const key = config.indexNow?.key;
1322
+ if (!key) fail("no seo.indexing.indexNow.key in the config — generate one with --new-key");
1323
+ const dir = resolve(values["write-key"]);
1324
+ mkdirSync(dir, { recursive: true });
1325
+ const file = join(dir, `${key}.txt`);
1326
+ writeFileSync(file, keyFileContents(key));
1327
+ console.log(`✓ IndexNow key file → ${file}`);
1328
+ console.log(` It must be served at ${resolveKeyLocation(config) ?? `/${key}.txt`}`);
1329
+ return;
1330
+ }
1331
+ const sitemapUrl = values.sitemap ?? resolveSitemapUrl(config);
1332
+ if (!sitemapUrl) fail("no sitemap to read — set seo.indexing.sitemapUrl or domains.canonical, or pass --sitemap");
1333
+ const explicitUrls = values.urls ? values.urls.split(",").map((u) => u.trim()).filter(Boolean) : void 0;
1334
+ const current = explicitUrls?.length && !values.check ? [] : (await collectEntries(sitemapUrl, readSitemap)).entries;
1335
+ const snapshotPath = resolve(values.snapshot);
1336
+ const p = plan({
1337
+ config: {
1338
+ ...config,
1339
+ sitemapUrl
1340
+ },
1341
+ current,
1342
+ previous: readSnapshot(snapshotPath),
1343
+ explicitUrls,
1344
+ all: values.all
1345
+ });
1346
+ console.log(formatPlan(p));
1347
+ if (p.blocked) return;
1348
+ if (values.dry) {
1349
+ console.log("\n(--dry: nothing was submitted)");
1350
+ return;
1351
+ }
1352
+ const account = loadServiceAccount();
1353
+ let failed = false;
1354
+ if (values.check) {
1355
+ if (!p.searchConsole.siteUrl) fail("--check needs seo.indexing.searchConsole.siteUrl (the Search Console property)");
1356
+ if (!account) fail("--check needs a Search Console service account in VITOPS_GSC_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS");
1357
+ if (p.check.length === 0) fail("--check needs seo.indexing.priorityUrls — the pages whose indexing matters");
1358
+ const token = await getAccessToken(account);
1359
+ console.log(`\nInspecting ${p.check.length} priority URL(s):`);
1360
+ for (const url of p.check) {
1361
+ const r = await inspectUrl(token, p.searchConsole.siteUrl, url);
1362
+ if (r.error) {
1363
+ console.log(` ? ${url} — ${r.error}`);
1364
+ failed = true;
1365
+ } else {
1366
+ console.log(` ${r.indexed ? "✓" : "✗"} ${url} — ${r.coverageState ?? r.verdict ?? "unknown"}`);
1367
+ if (!r.indexed) failed = true;
1368
+ }
1369
+ }
1370
+ if (failed) {
1371
+ console.error("\n✖ one or more priority URLs are not indexed");
1372
+ process.exit(1);
1373
+ }
1374
+ console.log("\n✓ every priority URL is indexed");
1375
+ return;
1376
+ }
1377
+ if (p.indexNow.enabled) {
1378
+ const { key, keyLocation } = p.indexNow;
1379
+ const check = await verifyKeyFile(keyLocation, key);
1380
+ if (!check.ok) {
1381
+ console.error(`✖ IndexNow key file check failed — ${check.reason}`);
1382
+ console.error(` Write it with: vitops indexing --write-key <public dir>`);
1383
+ failed = true;
1384
+ } else {
1385
+ let sent = 0;
1386
+ for (const batch of p.indexNow.batches) {
1387
+ const r = await submitBatch(p.indexNow, batch);
1388
+ if (!r.ok) {
1389
+ console.error(`✖ IndexNow: ${r.message ?? r.status}`);
1390
+ failed = true;
1391
+ break;
1392
+ }
1393
+ sent += r.urls;
1394
+ }
1395
+ if (!failed) console.log(`✓ IndexNow: ${sent} URL(s) submitted`);
1396
+ }
1397
+ }
1398
+ if (p.searchConsole.enabled) if (!account) console.log("· Search Console: skipped — no VITOPS_GSC_SERVICE_ACCOUNT / GOOGLE_APPLICATION_CREDENTIALS");
1399
+ else {
1400
+ const r = await submitSitemap(await getAccessToken(account), p.searchConsole.siteUrl, sitemapUrl);
1401
+ if (r.ok) console.log(`✓ Search Console: sitemap resubmitted`);
1402
+ else {
1403
+ console.error(`✖ Search Console: ${r.message}`);
1404
+ failed = true;
1405
+ }
1406
+ }
1407
+ if (failed) process.exit(1);
1408
+ if (current.length > 0) {
1409
+ writeSnapshot(snapshotPath, toSnapshot(sitemapUrl, current, (/* @__PURE__ */ new Date()).toISOString()));
1410
+ console.log(` state → ${relative(process.cwd(), snapshotPath)}`);
1411
+ }
1412
+ if (p.check.length > 0) console.log(`\n Indexing is not instant. Run \`vitops indexing --check\` in a day or two to see what Google actually did.`);
1413
+ }
594
1414
  function cliVersion() {
595
1415
  try {
596
1416
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
@@ -613,6 +1433,7 @@ function cmdDocs(argv) {
613
1433
  short: "i",
614
1434
  default: "design-system.json"
615
1435
  },
1436
+ theme: { type: "string" },
616
1437
  all: {
617
1438
  type: "boolean",
618
1439
  default: false
@@ -627,45 +1448,10 @@ function cmdDocs(argv) {
627
1448
  return;
628
1449
  }
629
1450
  if (topic && !TOPICS[topic]) fail(`unknown topic "${topic}". Valid topics:\n${topicList()}`);
630
- const docs = generateDocs(loadConfig(resolve(values.input)), generatorAssetsDir());
1451
+ const docs = generateDocs(loadConfig(resolve(values.input), values.theme), generatorAssetsDir());
631
1452
  const paths = values.all ? Object.values(TOPICS).map((t) => t.path) : [TOPICS[topic].path];
632
1453
  process.stdout.write(paths.map((p) => docs[p]).join("\n"));
633
1454
  }
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
1455
  function cmdLint(argv) {
670
1456
  const { values } = parseArgs({
671
1457
  args: argv,
@@ -675,6 +1461,7 @@ function cmdLint(argv) {
675
1461
  short: "i",
676
1462
  default: "design-system.json"
677
1463
  },
1464
+ theme: { type: "string" },
678
1465
  format: {
679
1466
  type: "string",
680
1467
  short: "f",
@@ -684,13 +1471,17 @@ function cmdLint(argv) {
684
1471
  type: "string",
685
1472
  short: "s",
686
1473
  default: "src"
1474
+ },
1475
+ strict: {
1476
+ type: "boolean",
1477
+ default: false
687
1478
  }
688
1479
  },
689
1480
  allowPositionals: false
690
1481
  });
691
1482
  const format = values.format;
692
1483
  if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
693
- const ds = loadConfig(resolve(values.input));
1484
+ const ds = loadConfig(resolve(values.input), values.theme);
694
1485
  const srcDir = resolve(values.src);
695
1486
  if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
696
1487
  const palette = expandPalette(ds.colors.palette);
@@ -701,19 +1492,26 @@ function cmdLint(argv) {
701
1492
  "border"
702
1493
  ]).map((u) => u.cls);
703
1494
  const files = scanFiles(srcDir);
704
- const findings = lintSource(files, vocabulary(ds, roleClasses), format);
1495
+ const cssFiles = [...files, ...scanFiles(srcDir, { exts: [".css"] })];
1496
+ const findings = [...lintSource(files, vocabulary(ds, roleClasses), format), ...lintCss(cssFiles, format)];
705
1497
  if (!findings.length) {
706
1498
  console.log(`✓ no unresolvable framework classes in ${values.src} (${files.length} file${files.length === 1 ? "" : "s"} scanned)`);
707
1499
  return;
708
1500
  }
709
- for (const f of findings) {
710
- console.error(`${f.file}:${f.line} ${f.cls}`);
1501
+ const errors = findings.filter((f) => f.severity === "error");
1502
+ const suggestions = findings.filter((f) => f.severity === "suggestion");
1503
+ for (const f of [...errors, ...suggestions]) {
1504
+ console.error(`${f.file}:${f.line} ${f.severity === "error" ? f.cls : `(${f.cls})`}`);
711
1505
  console.error(` ${f.reason}`);
712
1506
  if (f.suggestion) console.error(` try: ${f.suggestion}`);
713
1507
  }
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);
1508
+ const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
1509
+ const parts = [];
1510
+ if (errors.length) parts.push(count(errors.length, "unresolvable class", "unresolvable classes"));
1511
+ if (suggestions.length) parts.push(count(suggestions.length, "suggestion", "suggestions"));
1512
+ const failing = errors.length > 0 || values.strict === true && suggestions.length > 0;
1513
+ console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${files.length} file${files.length === 1 ? "" : "s"}` + (suggestions.length && !values.strict ? " (suggestions do not fail; use --strict)" : ""));
1514
+ if (failing) process.exit(1);
717
1515
  }
718
1516
  function cmdAgents(argv) {
719
1517
  const { values } = parseArgs({
@@ -724,6 +1522,7 @@ function cmdAgents(argv) {
724
1522
  short: "i",
725
1523
  default: "design-system.json"
726
1524
  },
1525
+ theme: { type: "string" },
727
1526
  out: {
728
1527
  type: "string",
729
1528
  short: "o",
@@ -735,7 +1534,7 @@ function cmdAgents(argv) {
735
1534
  });
736
1535
  const inputRel = values.input;
737
1536
  let ds = null;
738
- if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel));
1537
+ if (existsSync(resolve(inputRel))) ds = loadConfig(resolve(inputRel), values.theme);
739
1538
  else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
740
1539
  const legacyDocsDir = values["docs-dir"];
741
1540
  let summary;
@@ -836,7 +1635,10 @@ async function main() {
836
1635
  case "docs": return cmdDocs(rest);
837
1636
  case "lint": return cmdLint(rest);
838
1637
  case "legal": return cmdLegal(rest);
839
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal). Try: vitops --help`);
1638
+ case "icons": return cmdIcons(rest);
1639
+ case "indexing": return cmdIndexing(rest);
1640
+ case "media": return cmdMedia(rest);
1641
+ default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | indexing | media). Try: vitops --help`);
840
1642
  }
841
1643
  }
842
1644
  main().catch((err) => fail(err.message));