@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/CHANGELOG.md +335 -0
- package/README.md +52 -0
- package/dist/cli.mjs +875 -73
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +57 -1
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,
|
|
5
|
-
import { dirname,
|
|
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
|
-
/**
|
|
158
|
-
|
|
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))
|
|
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>
|
|
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
|
-
--
|
|
316
|
-
|
|
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>
|
|
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>
|
|
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>
|
|
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: {
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
710
|
-
|
|
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
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
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));
|