@getvitops/cli 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -2,13 +2,16 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs } from "node:util";
4
4
  import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
- import { dirname, join, parse, relative, resolve } from "node:path";
5
+ import { dirname, extname, join, parse, relative, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { NUMERIC_STEPS, SCHEMA_URL, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
7
+ import { JURISDICTION_COUNTRIES, NUMERIC_STEPS, SCHEMA_LOCAL_PATH, buildIconSprite, defaultConfig, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, movedTokens, processorsMissingLocation, resolveConfig, resolveInput, resolveTheme, roleColorUtilities, roleHue, roleKind, validate } from "@getvitops/generator";
8
8
  import { collectIconRefs, generateFavicons, generateIconInclude, resolveIcon, scanFiles } from "@getvitops/utils";
9
9
  import { SCOPES, SNAPSHOT_PATH, collectEntries, formatPlan, googleAccessToken, inspectUrl, keyFileContents, newKey, parseServiceAccount, plan, readSnapshot, resolveKeyLocation, resolveSitemapUrl, submitBatch, submitSitemap, toSnapshot, verifyKeyFile, writeSnapshot } from "@getvitops/utils/indexing";
10
10
  import { addSite, backoffSchedule, createApexTxt, findZoneId, formatPlan as formatPlan$1, formatSummary, getSite, getVerificationToken, getWebResource, hasDrift, listApexTxt, ownerUnion, plan as plan$1, refreshTokenGrant, siteUrlFor, updateOwners, verifyWebResource } from "@getvitops/utils/onboarding";
11
- import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$2, processMedia } from "@getvitops/utils/media";
11
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS, formatPlan as formatPlan$2, formatSummary as formatSummary$1, hasDrift as hasDrift$1, isAdProvider, missingFields, plan as plan$2, renderTag, tagId } from "@getvitops/utils/ads";
12
+ import { PLATFORM_PARAMS } from "@getvitops/utils/tracking";
13
+ import { MEDIA_MANIFEST_PATH, formatPlan as formatPlan$3, processMedia } from "@getvitops/utils/media";
14
+ import { createInterface } from "node:readline/promises";
12
15
 
13
16
  //#region src/agents.ts
14
17
  /**
@@ -126,13 +129,469 @@ function linkSkill(linkPath, targetDir) {
126
129
  }
127
130
  }
128
131
 
132
+ //#endregion
133
+ //#region src/help.ts
134
+ /**
135
+ * The CLI's help text, and the addressing that lets every subcommand answer
136
+ * `--help`.
137
+ *
138
+ * It lives beside `cli.ts` rather than inside it for one reason: `cli.ts` calls
139
+ * `main()` at import time, so nothing can import it to check anything. The
140
+ * drift guard in `help.test.ts` needs `COMMANDS` and `helpSection` as values.
141
+ */
142
+ const HELP = `vitops — generate design-system outputs from a design-system.json
143
+
144
+ Anywhere a command takes --input, that file may be a design-system.json OR the
145
+ larger site config that embeds one (company.json / site.json). The two are told
146
+ apart by shape, and a site config also supplies the site-level facts generation
147
+ reads — its default colour scheme, its legal documents, its icon sprite.
148
+
149
+ Usage:
150
+ vitops generate [options] Generate platform output from a config
151
+ vitops init [options] Scaffold a starter design-system.json
152
+ vitops validate <file> Validate a config against the schema it says it is
153
+ vitops favicon [options] Generate a favicon set from a source image
154
+ vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
155
+ vitops docs [topic] Print live design-system reference docs to stdout
156
+ vitops lint [opts] [files] Report classes that resolve to nothing + primitives you re-implemented
157
+ vitops legal [options] Render legal documents from a site config
158
+ vitops icons [options] Report which icons your source uses, and build the sprite
159
+ vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
160
+ vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
161
+ vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
162
+
163
+ Generate options:
164
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
165
+ -f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
166
+ design emits DESIGN.md only (the agent-facing brief) — pair it
167
+ with --out . to write it beside AGENTS.md, or compose:
168
+ --format css,design
169
+ -o, --out <dir> Output directory (default: ./dist)
170
+ --theme <name> Which designSystem.themes entry to build, when --input is
171
+ a site config (default: its defaultTheme, else "default")
172
+ --site <path> Site config, when it is a different file from --input.
173
+ Emits legal/*.html into the output directory (see:
174
+ vitops legal). Omitted with a plain design-system.json:
175
+ no legal files.
176
+ --site-env <env> Environment whose A/B variant applies (default: production)
177
+
178
+ Init options:
179
+ -o, --out <path> Where to write (default: ./design-system.json)
180
+ --force Overwrite an existing file
181
+
182
+ Validate options:
183
+ <file> Config to check (default: ./design-system.json). Either
184
+ kind — a design-system.json or the config that embeds one.
185
+ -i, --input <path> Same, as a flag
186
+ --site-env <env> Environment whose A/B variant applies (default: production)
187
+
188
+ Checks the schema, then the things a schema cannot: that every role points at a
189
+ palette hue that exists, that every var() an authored declaration references
190
+ resolves to a token this config emits, and that the colour grammar is current.
191
+
192
+ Favicon options:
193
+ -i, --input <path> Source SVG or PNG (required)
194
+ -o, --out <dir> Output directory (default: ./public)
195
+ --low-res <path> Optional simplified source for the 16px icon
196
+ --background <hex> Background under the maskable icons, which must be
197
+ opaque (default: #ffffff)
198
+
199
+ Agents options:
200
+ -i, --input <path> design-system.json or site config (default: ./design-system.json;
201
+ validated if present)
202
+ --theme <name> Theme to read, when --input is a site config
203
+ -o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
204
+ --docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
205
+ instead of linking the packaged skill
206
+
207
+ Docs options:
208
+ <topic> classes | authoring | formats | color | scales | patterns | elements
209
+ (no topic: list topics with summaries)
210
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
211
+ --theme <name> Theme to render docs from, when --input is a site config
212
+ --all Print every topic, concatenated
213
+
214
+ Icons options:
215
+ --site <path> Site config carrying the "icons" block (default: ./site.json)
216
+ --src <dir> Source to scan for icon usage (default: ./src)
217
+ --sprite Also build the SVG sprite
218
+ -o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)
219
+ --json Machine-readable report on stdout
220
+
221
+ Lint options:
222
+ -i, --input <path> design-system.json or site config (default: ./design-system.json)
223
+ --theme <name> Theme to judge classes against, when --input is a site config
224
+ -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
225
+ Responsive md-* classes are real in css/bricks, inert in tailwind.
226
+ -s, --src <dir> Directory to scan (default: ./src)
227
+ --strict Also fail on suggestions (reuse hints), not just on
228
+ classes that resolve to nothing
229
+ [files...] Lint these paths instead of scanning --src. Unreadable
230
+ and non-source paths are skipped, so this can be wired
231
+ into a pre-commit hook that appends the staged files.
232
+ --fix Rewrite pre-1.0 colour-grammar token references
233
+ (var(--<role>-<suffix>) → var(--color-<target>-<role>…))
234
+ in CSS and <style> blocks. Applied in ONE pass, so the
235
+ renames that rotate cannot compound. Nothing else is
236
+ rewritten — every other finding is a judgement call.
237
+
238
+ Run this after a major bump: it is the migration tool for anything the
239
+ changelog says was renamed.
240
+
241
+ Legal options:
242
+ -i, --input <path> Site config: .json, or a .js/.ts module with a default
243
+ export (default: ./site.json)
244
+ -d, --doc <name> privacy | terms | cookies (repeatable; default: those
245
+ enabled in the config)
246
+ -f, --format <fmt> md | html | portable-text (default: md)
247
+ html suits WordPress/Bricks and plain sites;
248
+ portable-text suits EmDash
249
+ -o, --out <dir> Write files here (default: print to stdout)
250
+ --site-env <env> Environment whose A/B variant applies (default: production)
251
+
252
+ Generated from your config — a starting point, not legal advice. Review before
253
+ publishing, and make sure the config describes what your site actually does.
254
+
255
+ Search subcommands:
256
+ vitops search setup [opts] Onboard site.searchConsole domains into GSC
257
+ vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)
258
+
259
+ Search setup options:
260
+ -i, --input <path> Site config carrying site.searchConsole (default: ./site.json)
261
+ --site-env <env> Environment whose A/B variant applies (default: production)
262
+ --domain <name> Scope to a single site.searchConsole entry
263
+ --dry Print the plan and exit. Changes nothing, and runs without
264
+ credentials (planning from scratch, which it says).
265
+ --check Report drift and exit non-zero if any domain is not fully
266
+ onboarded. Mutates nothing, but does need credentials —
267
+ drift is a comparison against live state.
268
+
269
+ For each domain it ensures the apex verification TXT in Cloudflare, verifies
270
+ ownership (DNS_TXT, retried with backoff while DNS propagates — a still-
271
+ unverified domain is reported PENDING, not failed), adds the sc-domain: property,
272
+ and adds any delegatedOwners to the web resource. DNS is only ever created,
273
+ never edited or deleted. Credentials come from the environment:
274
+ CLOUDFLARE_API_TOKEN (Zone:DNS:Edit + Zone:Read — the standard "Edit zone DNS"
275
+ template covers both; Zone:Read is what the zone-by-name lookup uses), and
276
+ VITOPS_GOOGLE_CLIENT_ID /
277
+ VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token
278
+ scoped to siteverification + webmasters). Granting a Google Group Full-User
279
+ access has no API and is surfaced as a reminder.
280
+
281
+ Search notify options:
282
+ -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
283
+ --site-env <env> Environment to notify for (default: production). An
284
+ environment whose robots policy says noindex is refused.
285
+ --sitemap <src> Sitemap URL or local path (default: from the config)
286
+ --urls <list> Comma-separated URLs to submit, skipping the diff
287
+ --all Submit every URL in the sitemap, not just what changed
288
+ --dry Print the plan and exit. Makes no requests.
289
+ --check Read-only: ask Google whether seo.indexing.priorityUrls
290
+ are indexed. Exits non-zero if one is not.
291
+ --new-key Print a fresh IndexNow key and exit
292
+ --write-key <dir> Write the IndexNow key file into <dir> (for stacks with
293
+ no Astro integration to do it — Bricks, WordPress)
294
+ --snapshot <path> Changed-URL state file (default: .vitops/sitemap-snapshot.json).
295
+ Persist it between runs (a CI cache) or every run submits
296
+ everything.
297
+
298
+ What notify can and cannot do: Google exposes no "request indexing" API, and its
299
+ sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
300
+ through the Search Console API and verifies the result with --check. IndexNow
301
+ reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
302
+ Search Console takes either credential, whichever you already have, added as an
303
+ owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
304
+ JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth
305
+ credential "search setup" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /
306
+ _REFRESH_TOKEN). The service account is preferred when both are set — this runs
307
+ on every deploy, and it does not expire.
308
+
309
+ Ads subcommands:
310
+ vitops ads setup [opts] Ensure each platform's domain-verification DNS record
311
+ vitops ads tags [opts] Print the consent-gated pixel snippets
312
+ vitops ads lint [opts] Report ad properties the rest of the config can't see
313
+
314
+ Ads setup options:
315
+ -i, --input <path> Site config carrying site.ads (default: ./site.json)
316
+ --site-env <env> Environment whose A/B variant applies (default: production)
317
+ --provider <name> Scope to one site.ads entry (google | meta | linkedin |
318
+ reddit | tiktok | microsoft | pinterest | snapchat)
319
+ --dry Print the plan and exit. Creates nothing. With
320
+ CLOUDFLARE_API_TOKEN set it reads live DNS first, so the
321
+ plan says what is already done; without one it plans from
322
+ scratch and says so, rather than refusing to run.
323
+ --check Report drift and exit non-zero if any property is not
324
+ linked. Mutates nothing, prompts for nothing.
325
+ --no-prompt Never ask for a missing token; fail with the field name
326
+ instead (the default when stdin is not a TTY, e.g. in CI)
327
+ --no-write Don't persist an answered token back into the config
328
+
329
+ Four platforms verify a domain by DNS TXT — Meta, TikTok, Pinterest, Snapchat —
330
+ and that record is the one thing this creates for you (in Cloudflare, via
331
+ CLOUDFLARE_API_TOKEN; created only, never edited or deleted). Google Ads,
332
+ LinkedIn, Reddit and Microsoft Ads have no domain verification at all: linking
333
+ there is the tag and the account ID, and the run says so rather than skipping in
334
+ silence. No platform Marketing API is called — Meta's needs a system-user token
335
+ and Google's needs an approved developer token, so the final "Verify" click stays
336
+ a reminder. The verification token is not a secret (it is published in DNS, which
337
+ is the ownership proof), so it lives in the config and is prompted for on the
338
+ first run.
339
+
340
+ Ads tags options:
341
+ -i, --input <path> Site config carrying site.ads (default: ./site.json)
342
+ --provider <name> Print one platform's tag
343
+ --strategy <s> idle | async | interaction (default: idle)
344
+
345
+ Prints each pixel as an INERT, consent-gated <script>: type="text/plain" with the
346
+ library URL on data-src, so an undecided visitor's page issues no third-party
347
+ request. For stacks with no Astro integration — Bricks, WordPress, Eleventy.
348
+ Paste into your template; @getvitops/core's consent runtime activates them.
349
+
350
+ Ads lint options:
351
+ -i, --input <path> Site config carrying site.ads (default: ./site.json)
352
+
353
+ Reports the gaps that are invisible at runtime: a click-ID parameter this
354
+ platform stamps that attribution doesn't capture, a pixel while site.tracking is
355
+ off (every conversion arrives unattributed), and a configured property with no
356
+ tag ID. Exits non-zero on a finding.
357
+
358
+ Media options:
359
+ --raw <dir> Directory of unprocessed video, walked recursively
360
+ (default: ./raw)
361
+ -o, --out <dir> Where the encoded outputs go (default: ./src/assets/processed).
362
+ Subdirectories under --raw are preserved.
363
+ --max-width <px> Cap the output width, keeping aspect ratio (default: 1920;
364
+ 0 disables scaling)
365
+ --crf <n> Quality on VP9's scale, 0-63, lower is better (default: 32).
366
+ The MP4 fallback uses the equivalent on H.264's scale.
367
+ --max-bitrate <r> Optional ceiling, e.g. 2M or 800k (default: none, which is
368
+ constant quality rather than constrained)
369
+ --audio Keep the audio track (default: dropped — the common case is
370
+ a muted autoplay loop)
371
+ --poster-time <s> Timestamp the poster frame is taken from (default: 0, which
372
+ is often black on a clip that fades in)
373
+ --outputs <list> Comma-separated: webm | mp4 | poster (default: all three)
374
+ --manifest <path> Cache file (default: .vitops/media-manifest.json)
375
+ --force Re-encode everything, ignoring the cache
376
+ --dry Print the plan and exit. Encodes nothing.
377
+
378
+ Needs ffmpeg on PATH — it is an external tool, not an npm dependency, and this
379
+ command fails rather than quietly skipping. Commit the outputs and the manifest:
380
+ ffmpeg output is not reproducible across versions, so re-encoding in CI churns
381
+ the diff on every toolchain bump. Use --force when you mean to re-encode.
382
+
383
+ Common:
384
+ -h, --help Show this help
385
+ `;
386
+ const SEARCH_HELP = `vitops search — Google Search Console
387
+
388
+ vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
389
+ vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
390
+
391
+ Run \`vitops --help\` for the full option list for each subcommand.
392
+ `;
393
+ const ADS_HELP = `vitops ads — link this site to its ad properties
394
+
395
+ vitops ads setup [opts] Ensure each platform's domain-verification DNS record
396
+ vitops ads tags [opts] Print the consent-gated pixel snippets
397
+ vitops ads lint [opts] Report ad properties the rest of the config can't see
398
+
399
+ Run \`vitops --help\` for the full option list for each subcommand.
400
+ `;
401
+ /**
402
+ * Every command `main()` dispatches. Exported so the unknown-command message
403
+ * and the help drift guard read the same list — the message used to carry its
404
+ * own hand-maintained copy.
405
+ */
406
+ const COMMANDS = [
407
+ "generate",
408
+ "init",
409
+ "validate",
410
+ "favicon",
411
+ "agents",
412
+ "docs",
413
+ "lint",
414
+ "legal",
415
+ "icons",
416
+ "search",
417
+ "ads",
418
+ "media"
419
+ ];
420
+ /**
421
+ * The slice of `HELP` documenting one command, addressed by its heading.
422
+ *
423
+ * `HELP` is already organised as `<Command> options:` blocks, so this reads the
424
+ * section rather than duplicating it — a second copy of every option list is a
425
+ * second thing to keep true.
426
+ */
427
+ function helpSection(...parts) {
428
+ const name = parts.join(" ");
429
+ const title = `${name.charAt(0).toUpperCase()}${name.slice(1)} options:`;
430
+ const start = HELP.indexOf(`\n${title}\n`);
431
+ if (start === -1) return void 0;
432
+ const body = HELP.slice(start + 1);
433
+ const next = body.slice(title.length).search(/\n[A-Z][A-Za-z ]*:\n/);
434
+ return next === -1 ? body : body.slice(0, title.length + next);
435
+ }
436
+ /**
437
+ * `--help` on a leaf subcommand used to reach `parseArgs`, which is strict and
438
+ * declares no `help` option — so `vitops lint --help` exited non-zero with a
439
+ * bare `Unknown option '--help'`. Every subcommand answers it now, and it is
440
+ * resolved in one place rather than in fifteen `options` objects, so a new
441
+ * command cannot forget to.
442
+ */
443
+ const wantsHelp = (argv) => argv.includes("-h") || argv.includes("--help");
444
+ /** The help text to print for `vitops <command> [sub] --help`. */
445
+ function helpFor(command, argv) {
446
+ const sub = argv[0] && !argv[0].startsWith("-") ? argv[0] : void 0;
447
+ return (sub ? helpSection(command, sub) : void 0) ?? helpSection(command) ?? (command === "search" ? SEARCH_HELP : command === "ads" ? ADS_HELP : void 0) ?? HELP;
448
+ }
449
+
450
+ //#endregion
451
+ //#region src/prompt.ts
452
+ /**
453
+ * Asking for the config values `vitops ads setup` cannot derive, and writing the
454
+ * answers back.
455
+ *
456
+ * The realistic first run has no verification token in the config, because the
457
+ * token does not exist until someone opens the platform UI and asks for one. That
458
+ * makes a missing token a **question**, not an error — but only when there is
459
+ * somebody there to answer it, which is why every decision about *whether* to ask
460
+ * is made here and the planner stays pure.
461
+ *
462
+ * The split mirrors the rest of the toolchain: `questionFor` and `patchRaw` are
463
+ * pure and tested; `ask` and `writeConfigPatch` are the I/O, and they decide
464
+ * nothing.
465
+ *
466
+ * Two rules hold the whole file together:
467
+ *
468
+ * - **A prompt must never hang a CI run.** Asking requires a TTY and an explicit
469
+ * absence of `--dry` / `--check` / `--no-prompt`; otherwise the caller gets the
470
+ * same named error it would have got before this file existed.
471
+ * - **A fact about the site goes in the config; a credential stays in the
472
+ * environment.** A verification token is published in DNS — the platform
473
+ * fetching it back is the ownership proof — so persisting it is right, and it is
474
+ * the only thing here that gets written. `CLOUDFLARE_API_TOKEN` may be typed at
475
+ * a prompt and lives only in the process.
476
+ */
477
+ /**
478
+ * What to ask for one gap, and where the operator finds it.
479
+ *
480
+ * The hints are the point. "meta.domainVerification is missing" is true and
481
+ * useless; the UI path plus the console URL is the difference between a prompt
482
+ * someone can answer now and one they abandon to go searching.
483
+ */
484
+ function questionFor(provider, needs) {
485
+ const platform = AD_PLATFORMS[provider];
486
+ if (needs === "domainVerification") return {
487
+ provider,
488
+ needs,
489
+ title: `${platform.name} — domain verification token`,
490
+ hints: [...platform.verification.where ? [platform.verification.where] : [], platform.consoleUrl],
491
+ label: platform.verification.txtPrefix ?? "token"
492
+ };
493
+ const what = needs === "pixelId" ? provider === "google" ? "conversion ID (AW-…, not the customer ID)" : "pixel / tag ID" : "advertising account ID";
494
+ return {
495
+ provider,
496
+ needs,
497
+ title: `${platform.name} — ${what}`,
498
+ hints: [platform.consoleUrl],
499
+ label: needs
500
+ };
501
+ }
502
+ /** The error a non-interactive run gets instead of a prompt. */
503
+ function missingFieldMessage(provider, needs) {
504
+ const q = questionFor(provider, needs);
505
+ return `site.ads.${provider}.${needs} is not set — ${q.title}.\n` + q.hints.map((h) => ` ${h}`).join("\n") + `\n Run this interactively to be prompted for it, or add it to the config yourself.`;
506
+ }
507
+ /** Whether this run is allowed to ask. Everything that would hang CI is checked here. */
508
+ function canPrompt(opts) {
509
+ if (opts.dry || opts.check || opts.noPrompt) return false;
510
+ return opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
511
+ }
512
+ /**
513
+ * Ask one question. An empty answer means "skip this one" — reported as still
514
+ * blocked rather than treated as a value, so nothing is written and nothing is
515
+ * created on the strength of a stray Enter.
516
+ */
517
+ async function ask(q) {
518
+ const rl = createInterface({
519
+ input: process.stdin,
520
+ output: process.stdout
521
+ });
522
+ try {
523
+ console.log(`\n${q.title}`);
524
+ for (const hint of q.hints) console.log(` ${hint}`);
525
+ return (await rl.question(` ${q.label} `)).trim() || void 0;
526
+ } finally {
527
+ rl.close();
528
+ }
529
+ }
530
+ /**
531
+ * Merge one ad-property field into a **raw, on-disk** config object.
532
+ *
533
+ * Raw is load-bearing, and it is the rule `@getvitops/vite`'s `designSystemPath()`
534
+ * already follows for the theme editor's save: `resolveConfig` normalises shorthand
535
+ * shapes and resolves `extends` in memory, so writing from the resolved object
536
+ * would grow keys beside the author's and silently edit a copy nothing builds from.
537
+ * Everything outside `site.ads.<provider>.<field>` is left exactly as it was.
538
+ */
539
+ function patchRaw(raw, provider, field, value) {
540
+ if (typeof raw !== "object" || raw == null || Array.isArray(raw)) throw new Error("config is not an object");
541
+ const root = raw;
542
+ const site = root["site"] ?? {};
543
+ const ads = site["ads"] ?? {};
544
+ const entry = ads[provider] ?? {};
545
+ return {
546
+ ...root,
547
+ site: {
548
+ ...site,
549
+ ads: {
550
+ ...ads,
551
+ [provider]: {
552
+ ...entry,
553
+ [field]: value
554
+ }
555
+ }
556
+ }
557
+ };
558
+ }
559
+ /** Indentation the file already uses, so a patch doesn't reformat the whole document. */
560
+ function detectIndent(source) {
561
+ const match = /\n([ \t]+)"/.exec(source);
562
+ if (!match?.[1]) return 2;
563
+ return match[1].includes(" ") ? " " : match[1].length;
564
+ }
565
+ /**
566
+ * Persist one answered field into the config file.
567
+ *
568
+ * JSON only: a `.js`/`.ts` config is code, and rewriting code to insert a value is
569
+ * a different (and much worse) problem than merging an object — so the caller is
570
+ * told to paste it instead. Returns whether it wrote.
571
+ */
572
+ function writeConfigPatch(path, provider, field, value) {
573
+ if (!path.endsWith(".json")) return {
574
+ written: false,
575
+ reason: `${path} is a module, not JSON — add "${field}": "${value}" to site.ads.${provider} yourself`
576
+ };
577
+ if (!existsSync(path)) return {
578
+ written: false,
579
+ reason: `${path} no longer exists`
580
+ };
581
+ const source = readFileSync(path, "utf8");
582
+ const next = patchRaw(JSON.parse(source), provider, field, value);
583
+ const trailingNewline = source.endsWith("\n") ? "\n" : "";
584
+ writeFileSync(path, JSON.stringify(next, null, detectIndent(source)) + trailingNewline);
585
+ return { written: true };
586
+ }
587
+
129
588
  //#endregion
130
589
  //#region src/css-scan.ts
131
590
  /** Strip comments, preserving newlines so line numbers survive. */
132
591
  function stripComments(css) {
133
592
  return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
134
593
  }
135
- const lineAt = (src, index) => src.slice(0, index).split("\n").length;
594
+ const lineAt$1 = (src, index) => src.slice(0, index).split("\n").length;
136
595
  /**
137
596
  * Parse declarations from a rule body. Splits on top-level `;` only, so a
138
597
  * `var(--x, a; b)` or a data URI cannot end a declaration early.
@@ -207,7 +666,7 @@ function scanCss(css) {
207
666
  selector: frame.prelude,
208
667
  declarations: parseDeclarations(src.slice(bodyStart, i)),
209
668
  conditions: stack.filter((f) => f.prelude.startsWith("@")).map((f) => f.prelude),
210
- line: lineAt(src, frame.index)
669
+ line: lineAt$1(src, frame.index)
211
670
  });
212
671
  }
213
672
  preludeStart = i + 1;
@@ -220,7 +679,7 @@ function scanCss(css) {
220
679
  function extractStyleBlocks(src) {
221
680
  const out = [];
222
681
  for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
223
- const line = lineAt(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
682
+ const line = lineAt$1(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
224
683
  out.push({
225
684
  css: m[1] ?? "",
226
685
  line
@@ -242,6 +701,68 @@ const TRACK_WIDTHS = [
242
701
  "--width-spotlight"
243
702
  ];
244
703
  /**
704
+ * Class names that mean "the thing that constrains page width".
705
+ *
706
+ * The token-anchored rule below only fires once someone already knows about
707
+ * `--width-measure`, which is precisely the author who was never going to
708
+ * hand-roll a container. The reported failure was the other author: agents
709
+ * inventing a `.wrap` from scratch, with a plain pixel `max-width`, on site after
710
+ * site. `wrap` is also the more *available* name for the concept, so it wins the
711
+ * naming race against `.centered` every time unless something says otherwise.
712
+ *
713
+ * Matched on the name only in combination with a width-constraining declaration —
714
+ * a `.wrapper` that merely sets `display: flex` is not this pattern.
715
+ */
716
+ const CONTAINER_NAMES = [
717
+ "wrap",
718
+ "wrapper",
719
+ "container",
720
+ "inner",
721
+ "outer",
722
+ "content-wrap",
723
+ "content-wrapper",
724
+ "page-wrap",
725
+ "page-wrapper",
726
+ "site-wrap",
727
+ "site-wrapper",
728
+ "shell",
729
+ "constrain",
730
+ "measure"
731
+ ];
732
+ /**
733
+ * Page scale, as a rem-equivalent, above which a width cap is a *container*
734
+ * rather than an element.
735
+ *
736
+ * Keyed to the framework's own `md` breakpoint (48rem) rather than to a number
737
+ * picked for this rule, and it is what keeps a capped figure quiet — someone
738
+ * writing `max-width: 40rem` on an image is not reinventing `.centered`.
739
+ */
740
+ const PAGE_SCALE_REM = 48;
741
+ /** The same line in `ch`, where a width cap is a reading measure by construction. */
742
+ const PAGE_SCALE_CH = 40;
743
+ /**
744
+ * The largest length in a width value, as a rem-equivalent — so `min(100%,
745
+ * 1200px)` and `clamp(20rem, 90vw, 75rem)` are read at their cap rather than
746
+ * skipped for not being a bare length.
747
+ *
748
+ * Relative-to-viewport units (`%`, `vw`, `vmin`) are deliberately ignored: they
749
+ * express "as wide as there is", which is not a container cap.
750
+ */
751
+ function widthScale(value) {
752
+ let rem = 0;
753
+ let ch = 0;
754
+ for (const m of value.matchAll(/([\d.]+)(rem|px|em|ch)\b/g)) {
755
+ const n = Number(m[1]);
756
+ if (!Number.isFinite(n)) continue;
757
+ if (m[2] === "ch") ch = Math.max(ch, n);
758
+ else rem = Math.max(rem, m[2] === "px" ? n / 16 : n);
759
+ }
760
+ return {
761
+ rem,
762
+ ch
763
+ };
764
+ }
765
+ /**
245
766
  * Column ratios that correspond to a `split-<a>-<b>` class.
246
767
  *
247
768
  * `.split` is FLEX, not grid — `display: flex` with `flex: 1` children, and a
@@ -278,14 +799,21 @@ const responsive = (bp, base, format) => format === "tailwind" ? `@${bp}:${base}
278
799
  */
279
800
  const RULES = [
280
801
  (rule) => {
281
- const token = TRACK_WIDTHS.find((t) => decl(rule, "max-inline-size")?.includes(t) || decl(rule, "max-width")?.includes(t));
802
+ const cap = decl(rule, "max-inline-size") ?? decl(rule, "max-width") ?? decl(rule, "width");
282
803
  const centred = /\bauto\b/.test(decl(rule, "margin-inline") ?? decl(rule, "margin") ?? "");
283
- if (!token || !centred) return null;
804
+ if (!cap) return null;
805
+ const token = TRACK_WIDTHS.find((t) => cap.includes(t));
806
+ const { rem, ch } = widthScale(cap);
807
+ const pageScale = rem >= PAGE_SCALE_REM || ch >= PAGE_SCALE_CH;
808
+ const named = CONTAINER_NAMES.some((n) => new RegExp(`[.#]([a-z0-9]+[-_]+)*${n}([-_]+[a-z0-9]+)*\\b`, "i").test(rule.selector));
809
+ const why = token ? `it caps width at \`var(${token})\`, a framework track` : named ? `\`${rule.selector.trim()}\` names a page container` : pageScale && centred ? `it caps width at page scale and centres with auto margins` : null;
810
+ if (!why) return null;
811
+ if (token && !centred) return null;
284
812
  return {
285
- cls: `${rule.selector} { max-inline-size: var(${token}); margin-inline: auto }`,
813
+ cls: `${rule.selector} { ${cap.length > 40 ? "width cap" : `max-width: ${cap}`} }`,
286
814
  severity: "suggestion",
287
- reason: "`.centered` is this pattern — a grid of named tracks, not a max-width plus auto margins",
288
- suggestion: "use `.centered`, and widen an individual child with `breakout`, `spotlight` or `fullbleed` on that child rather than overriding the parent"
815
+ reason: `\`.centered\` is this pattern — ${why}`,
816
+ suggestion: "use `.centered`. It is a grid of named tracks, not a max-width box, so a child opts into a wider track with `breakout`, `spotlight` or `fullbleed` on itself — a hand-rolled container has no answer for the full-bleed image except more CSS"
289
817
  };
290
818
  },
291
819
  (rule, format) => {
@@ -301,6 +829,22 @@ const RULES = [
301
829
  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`
302
830
  };
303
831
  },
832
+ (rule) => {
833
+ const cols = decl(rule, "grid-template-columns");
834
+ if (!cols || !/^\s*repeat\(/i.test(cols)) return null;
835
+ if (!/\bgrid\b/.test(decl(rule, "display") ?? "")) return null;
836
+ if (rule.declarations.some((d) => /subgrid/.test(d.value))) return null;
837
+ const count = /^\s*repeat\(\s*(\d+)/i.exec(cols)?.[1];
838
+ const auto = /^\s*repeat\(\s*auto-(fit|fill)/i.test(cols);
839
+ if (!count && !auto) return null;
840
+ if (count && Number(count) < 2) return null;
841
+ return {
842
+ cls: `${rule.selector} { grid-template-columns: ${cols} }`,
843
+ severity: "suggestion",
844
+ reason: "a grid of repeated items is `.subgrid` — a plain grid aligns the outer boxes but not the tranches inside them, so headings, bodies and footers land at different heights across the set",
845
+ suggestion: auto ? "`.subgrid subgrid-responsive` (2 columns under 60rem, 1 under 40rem) with `--subgrid-row-span` set to the number of row bands each item contains — or `.grid-auto` with `--grid-min`, which is the framework class for this exact auto-fit track and the right answer when the items have no tranches to align" : `\`.subgrid subgrid-cols-${count}\`, with \`--subgrid-row-span\` (or \`subgrid-rows-<n>\`) set to the number of row bands each item contains — and \`.subgrid-card\` on the items to get an edge-bled \`__media\` and a pinned \`__footer\``
846
+ };
847
+ },
304
848
  (rule, format) => {
305
849
  if (decl(rule, "display") !== "flex") return null;
306
850
  if (decl(rule, "align-items") !== "center") return null;
@@ -360,6 +904,286 @@ function lintCss(files, format) {
360
904
  return findings;
361
905
  }
362
906
 
907
+ //#endregion
908
+ //#region src/lint-markup.ts
909
+ /**
910
+ * The third linting direction: markup that lays out a repeated set of things
911
+ * without the framework's pattern for it.
912
+ *
913
+ * `lint.ts` catches a class that resolves to nothing. `lint-css.ts` catches
914
+ * hand-written CSS that re-implements a primitive. Neither reaches the most common
915
+ * reported drift, because it involves no bad class and no hand-written CSS at all:
916
+ * a page renders a list of cards with `class="grid"`, or with nothing, and every
917
+ * class in it is real. The design system has `.subgrid` for exactly this and it
918
+ * goes unused, so across three cards the headings sit at three heights and the
919
+ * CTAs float wherever the copy ended.
920
+ *
921
+ * That cannot be judged from a class token in isolation, so this module is the one
922
+ * that looks at *shape*: is there a repeated card construct here, and is it inside
923
+ * a subgrid?
924
+ *
925
+ * **It is heuristic, and the heuristics are chosen to under-report.** Every finding
926
+ * is a `suggestion`, at most one per file, and each requires a repetition signal
927
+ * strong enough to state in the message: a loop that renders a card, or three or
928
+ * more sibling cards written out. Two cards in a file is not enough — they are as
929
+ * likely to be in unrelated sections, and a rule that fires there would teach
930
+ * people to stop reading the output, which costs more than the drift.
931
+ */
932
+ /**
933
+ * Only real markup is examined.
934
+ *
935
+ * Markdown is excluded on purpose, and it is the exclusion that matters most: a
936
+ * `.md` file is as likely to *discuss* `.card` as to use it. Run against this
937
+ * repo's own docs site, the first version reported `changelog.md` and two
938
+ * generated reference pages — prose about the framework, counted as drift by it.
939
+ * A consumer's markdown content can hold real markup, so this does give up a
940
+ * little coverage; that is the right side to err on for a heuristic.
941
+ */
942
+ const MARKUP_EXT = new Set([
943
+ ".astro",
944
+ ".html",
945
+ ".htm",
946
+ ".jsx",
947
+ ".tsx",
948
+ ".vue",
949
+ ".svelte",
950
+ ".ts",
951
+ ".js"
952
+ ]);
953
+ /**
954
+ * A class attribute's value, in the spellings the supported frameworks use.
955
+ * Mirrors `lint.ts`'s extractor — the token must be in a real `class`, not merely
956
+ * somewhere in the file.
957
+ */
958
+ const CLASS_ATTR$1 = /(?:class|className|class:list)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\}|\{([^}]*)\})/g;
959
+ /**
960
+ * The card-ish classes worth caring about.
961
+ *
962
+ * Anchored to `card` as a whole word or suffix, which covers the framework's own
963
+ * `card` / `subgrid-card` / `pricing-card` and a consumer's `service-card` alike.
964
+ * Not `*-item` or `*-tile`: too many of those are not laid out as a set.
965
+ */
966
+ const CARD_CLASS = /(?:^|[\s"'`])((?:[a-z0-9]+-)*card)(?:__[a-z0-9-]+)?(?=[\s"'`]|$)/i;
967
+ /** Every card class named in a real class attribute, with its offset in `src`. */
968
+ function cardsInClassAttrs(src) {
969
+ const out = [];
970
+ for (const m of src.matchAll(CLASS_ATTR$1)) {
971
+ const value = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
972
+ for (const tok of value.split(/[\s'"`]+/)) {
973
+ const card = CARD_CLASS.exec(` ${tok} `);
974
+ if (card && !tok.includes("__")) out.push({
975
+ name: card[1],
976
+ index: m.index ?? 0
977
+ });
978
+ }
979
+ }
980
+ return out;
981
+ }
982
+ /**
983
+ * Already using the pattern — in any tier's spelling.
984
+ *
985
+ * `<Cards>` and `<wc-cards>` count: `<Cards>` renders a `Subgrid`, and `<wc-cards>`
986
+ * only ever wraps one. Omitting them reported a correct card grid as a missed one.
987
+ */
988
+ const HAS_SUBGRID = /\bsubgrid\b|<Subgrid\b|<Cards\b|<wc-cards\b/;
989
+ /**
990
+ * Constructs that render one item many times.
991
+ *
992
+ * The JS/Astro/JSX `.map(`, and the block forms Svelte/Vue/Handlebars use. A loop
993
+ * whose body contains a card is a repeated card set by construction — no counting
994
+ * and no sibling analysis needed, which is why it is the primary signal.
995
+ */
996
+ const LOOPS = [
997
+ /\.\s*map\s*\(/g,
998
+ /\{#each\b/g,
999
+ /v-for\s*=/g,
1000
+ /\{%\s*for\b/g
1001
+ ];
1002
+ /** Line number of a character offset. */
1003
+ const lineAt = (src, index) => src.slice(0, index).split("\n").length;
1004
+ /**
1005
+ * The body of a loop, approximated by taking the text that follows it.
1006
+ *
1007
+ * A real brace-matcher would be more precise and is not worth it: the question is
1008
+ * only "does a card appear inside this repetition", and a card 600 characters past
1009
+ * a `.map(` is inside it in every realistic template. Over-reaching here can only
1010
+ * produce a suggestion on a file that does render cards somewhere.
1011
+ */
1012
+ const LOOP_WINDOW = 600;
1013
+ /** The strongest repetition signal in one file, or none. */
1014
+ function cardSignal(text) {
1015
+ const cards = cardsInClassAttrs(text);
1016
+ if (!cards.length) return null;
1017
+ for (const loop of LOOPS) for (const m of text.matchAll(loop)) {
1018
+ const start = (m.index ?? 0) + m[0].length;
1019
+ const card = cards.find((c) => c.index >= start && c.index < start + LOOP_WINDOW);
1020
+ if (!card) continue;
1021
+ return {
1022
+ line: lineAt(text, m.index ?? 0),
1023
+ evidence: `a \`${m[0].trim()}\` loop renders \`.${card.name}\``,
1024
+ cls: `${m[0].trim()} … .${card.name}`
1025
+ };
1026
+ }
1027
+ if (cards.length >= 3) {
1028
+ const first = cards[0];
1029
+ return {
1030
+ line: lineAt(text, first.index),
1031
+ evidence: `\`.${first.name}\` appears on ${cards.length} elements in this file`,
1032
+ cls: `.${first.name} ×${cards.length}`
1033
+ };
1034
+ }
1035
+ return null;
1036
+ }
1037
+ /**
1038
+ * Lint markup for repeated card sets laid out without `.subgrid`.
1039
+ *
1040
+ * Takes raw text like the other linters — discovery stays with the caller. One
1041
+ * finding per file: the drift is a decision about a layout, not about a line, and
1042
+ * repeating it per card would bury every other finding the command prints.
1043
+ */
1044
+ /**
1045
+ * An anchor carrying a card class inside a subgrid.
1046
+ *
1047
+ * The shape everyone reaches for when the whole card is a link — and it renders
1048
+ * fine, which is why it survives. The `<li>` is the grid item, so the anchor is an
1049
+ * ordinary block inside it and the tranches within the anchor never reach the
1050
+ * parent's shared row lines: the alignment `.subgrid` exists for silently does not
1051
+ * happen, and the anchor doesn't fill the cell either. This is the one markup
1052
+ * finding that reports a *broken* result rather than a missed primitive.
1053
+ */
1054
+ const LINK_CARD = /<a\b[^>]*\bclass(?:Name)?\s*=\s*["'{][^"'}]*\b(?:[a-z0-9]+-)*card\b/gi;
1055
+ /**
1056
+ * Is this offset inside an `<li>`?
1057
+ *
1058
+ * The rule's claim is "the anchor is nested inside the grid item rather than being
1059
+ * it", and only an anchor within an `<li>` is that. Checking the file merely
1060
+ * *mentions* subgrid is far too coarse: a page documenting several patterns tripped
1061
+ * on a standalone `<a class="tile card">`, which is a perfectly good anchor-as-card —
1062
+ * outside a grid there is no grid item for it to compete with.
1063
+ */
1064
+ function insideListItem(text, index) {
1065
+ const before = text.slice(0, index);
1066
+ return before.lastIndexOf("<li") > before.lastIndexOf("</li");
1067
+ }
1068
+ function linkCardFinding(file) {
1069
+ const m = [...file.text.matchAll(LINK_CARD)].find((hit) => insideListItem(file.text, hit.index ?? 0));
1070
+ if (!m || m.index === void 0) return null;
1071
+ return {
1072
+ file: file.path,
1073
+ line: lineAt(file.text, m.index),
1074
+ cls: "<a class=\"…card\">",
1075
+ severity: "suggestion",
1076
+ reason: "a card class on an `<a>` inside a `subgrid` does not align — the `<li>` is the grid item, so the anchor's tranches never reach the parent's shared row lines, and the anchor does not fill the cell either. It renders, it just silently is not a subgrid",
1077
+ suggestion: "keep the card as the item (`<li class=\"card subgrid-card\">`) and make the whole card clickable from inside it — either `class=\"stretched-link\"` on a link (no JS, but the card's text stops being selectable) or `<Cards>` / `<wc-cards>` around the list (keeps selection, needs JS, falls back to the link). Both keep the accessible name on real text. Lift anything that must stay above a stretched overlay with `raised`"
1078
+ };
1079
+ }
1080
+ /**
1081
+ * `.stretched-link` inside `<wc-cards>` — the two whole-card-click mechanisms layered.
1082
+ *
1083
+ * They are alternatives, not layers, and combining them is silently the worse of the
1084
+ * two: the overlay takes the pointer-drag, so the card's text stops being selectable
1085
+ * — which is the only reason to reach for `<wc-cards>` — and the element's
1086
+ * click-vs-drag logic never gets a chance to run, because the browser follows the
1087
+ * overlaid link natively first.
1088
+ */
1089
+ const WC_CARDS = /<wc-cards\b|<Cards\b/;
1090
+ const STRETCHED = /\bclass(?:Name)?\s*=\s*["'{][^"'}]*\bstretched-link\b/;
1091
+ function layeredCardLinkFinding(file) {
1092
+ if (!WC_CARDS.test(file.text)) return null;
1093
+ const m = STRETCHED.exec(file.text);
1094
+ if (!m) return null;
1095
+ return {
1096
+ file: file.path,
1097
+ line: lineAt(file.text, m.index),
1098
+ cls: "stretched-link inside wc-cards",
1099
+ severity: "suggestion",
1100
+ reason: "`.stretched-link` and `<wc-cards>` are two ways to do the same thing, and layering them silently picks the worse one — the overlay takes the pointer-drag, so the card's text is no longer selectable, which is the only reason to use `<wc-cards>` at all",
1101
+ suggestion: "drop one. Keep `stretched-link` and use `<Subgrid>` if you want zero JS and can accept unselectable card text; keep `<Cards>` / `<wc-cards>` and remove `stretched-link` if selectable text matters — the plain `link` class on the heading is then the fallback"
1102
+ };
1103
+ }
1104
+ function lintMarkup(files) {
1105
+ const findings = [];
1106
+ for (const file of files) {
1107
+ if (!MARKUP_EXT.has(extname(file.path).toLowerCase())) continue;
1108
+ const layered = layeredCardLinkFinding(file);
1109
+ if (layered) findings.push(layered);
1110
+ if (HAS_SUBGRID.test(file.text)) {
1111
+ const broken = linkCardFinding(file);
1112
+ if (broken) findings.push(broken);
1113
+ continue;
1114
+ }
1115
+ const signal = cardSignal(file.text);
1116
+ if (!signal) continue;
1117
+ const usingGrid = /\bgrid-auto\b|class(?:Name)?="[^"]*\bgrid\b/.test(file.text);
1118
+ findings.push({
1119
+ file: file.path,
1120
+ line: signal.line,
1121
+ cls: signal.cls,
1122
+ severity: "suggestion",
1123
+ reason: `${signal.evidence}, ${usingGrid ? "laid out with a plain grid rather than a `subgrid`" : "and nothing here is a `subgrid`"} — more than one card is what \`.subgrid\` is for. A plain grid aligns the outer boxes but not the tranches inside them, so the set's headings, bodies and footers land at different heights`,
1124
+ suggestion: "`<ul class=\"subgrid subgrid-cols-3\" role=\"list\">` with `<li class=\"card subgrid-card\">` items (a set of cards is a list; `role=\"list\"` is needed because `.subgrid` is markerless and Safari stops announcing a marker-less `ul` as a list); set `--subgrid-row-span` to the number of row bands each card contains. In Astro, `<Subgrid />` adds the role for you. If the items genuinely have no internal tranches to align (a bare image gallery), `.grid-auto` is the right call and this is a false positive"
1125
+ });
1126
+ }
1127
+ return findings;
1128
+ }
1129
+
1130
+ //#endregion
1131
+ //#region src/lint-tokens.ts
1132
+ /**
1133
+ * Rewrite every old name in one pass.
1134
+ *
1135
+ * Longest-first alternation so a name that is a prefix of another (`--x-bg` vs
1136
+ * `--x-bg-bold`) cannot be matched short, and a single `replace` so no output of
1137
+ * one rename is ever input to the next.
1138
+ */
1139
+ function applyRenames(text, renames) {
1140
+ const names = Object.keys(renames).sort((a, b) => b.length - a.length);
1141
+ if (!names.length) return text;
1142
+ const escaped = names.map((n) => n.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`));
1143
+ const re = new RegExp(`(${escaped.join("|")})(?![\\w-])`, "g");
1144
+ return text.replace(re, (m) => renames[m] ?? m);
1145
+ }
1146
+ /** Every `var(--name)` in a text, with its line number. */
1147
+ function varRefs(src) {
1148
+ const out = [];
1149
+ for (const m of src.matchAll(/var\(\s*(--[\w-]+)/g)) out.push({
1150
+ name: m[1],
1151
+ line: src.slice(0, m.index).split("\n").length
1152
+ });
1153
+ return out;
1154
+ }
1155
+ /**
1156
+ * Report old-grammar token references in source files.
1157
+ *
1158
+ * `renames` comes from the generator's `movedTokens()`, built from the
1159
+ * consumer's own role names — there is no fixed list, which is why nothing
1160
+ * detected these before.
1161
+ */
1162
+ function lintTokens(files, renames) {
1163
+ const findings = [];
1164
+ const seen = /* @__PURE__ */ new Set();
1165
+ for (const f of files) for (const { name, line } of varRefs(f.text)) {
1166
+ const to = renames[name];
1167
+ if (to == null) continue;
1168
+ const key = `${f.path}:${line}:${name}`;
1169
+ if (seen.has(key)) continue;
1170
+ seen.add(key);
1171
+ findings.push({
1172
+ file: f.path,
1173
+ line,
1174
+ cls: `var(${name})`,
1175
+ severity: "error",
1176
+ reason: `\`${name}\` is the pre-1.0 colour grammar and resolves to nothing`,
1177
+ suggestion: `var(${to})`,
1178
+ fix: {
1179
+ from: name,
1180
+ to
1181
+ }
1182
+ });
1183
+ }
1184
+ return findings;
1185
+ }
1186
+
363
1187
  //#endregion
364
1188
  //#region src/lint.ts
365
1189
  /**
@@ -498,268 +1322,93 @@ function judge(raw, v, format) {
498
1322
  const near = nearest(font[1], v.typeRoles);
499
1323
  if (near) return {
500
1324
  cls: raw,
501
- severity: "error",
502
- reason: `\`${font[1]}\` is not a typography role`,
503
- suggestion: `font-${near}`
504
- };
505
- }
506
- const ds = /^drop-shadow-(.+)$/.exec(base);
507
- if (ds && v.shadows.length && !v.shadows.includes(ds[1])) return {
508
- cls: raw,
509
- severity: "error",
510
- reason: `\`${ds[1]}\` is not a defined shadow`,
511
- suggestion: `drop-shadow-${v.shadows[0]}`
512
- };
513
- return null;
514
- }
515
- const CLASS_ATTR = /(?:class|className|:class)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
516
- /** Extract class tokens from a source file, with line numbers. */
517
- function extractClasses(src) {
518
- const out = [];
519
- for (const m of src.matchAll(CLASS_ATTR)) {
520
- const value = m[1] ?? m[2] ?? m[3] ?? "";
521
- const line = src.slice(0, m.index).split("\n").length;
522
- for (const tok of value.split(/\s+/)) if (tok && !tok.includes("${") && !tok.includes("{") && /^[@\w:[\]().%/-]+$/.test(tok)) out.push({
523
- cls: tok,
524
- line
525
- });
526
- }
527
- return out;
528
- }
529
- function lintSource(files, v, format) {
530
- const findings = [];
531
- const seen = /* @__PURE__ */ new Set();
532
- for (const f of files) for (const { cls, line } of extractClasses(f.text)) {
533
- const verdict = judge(cls, v, format);
534
- if (!verdict) continue;
535
- const key = `${f.path}:${line}:${cls}`;
536
- if (seen.has(key)) continue;
537
- seen.add(key);
538
- findings.push({
539
- file: f.path,
540
- line,
541
- ...verdict
542
- });
543
- }
544
- return findings;
545
- }
546
-
547
- //#endregion
548
- //#region src/cli.ts
549
- /**
550
- * `vitops` — CLI for the Vitops design-system generator.
551
- *
552
- * vitops generate --input <json> --format <bricks|css|tailwind|design[,…]> --out <dir>
553
- * vitops init [--out design-system.json] [--force]
554
- * vitops validate <design-system.json|site.json>
555
- * vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>] [--background <hex>]
556
- * vitops agents [--input <json>] [--out AGENTS.md] [--docs-dir <dir>]
557
- * vitops docs [topic] [--input <json>] [--all]
558
- * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
559
- * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
560
- * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
561
- * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
562
- *
563
- * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
564
- * Every client brings their own consumer-editable design-system.json.
565
- */
566
- const FORMATS = new Set([
567
- "bricks",
568
- "css",
569
- "tailwind",
570
- "design"
571
- ]);
572
- const LINT_FORMATS = new Set([
573
- "bricks",
574
- "css",
575
- "tailwind"
576
- ]);
577
- const LEGAL_DOCS = new Set([
578
- "privacy",
579
- "terms",
580
- "cookies"
581
- ]);
582
- const LEGAL_OUTPUTS = new Set([
583
- "md",
584
- "html",
585
- "portable-text"
586
- ]);
587
- const HELP = `vitops — generate design-system outputs from a design-system.json
588
-
589
- Anywhere a command takes --input, that file may be a design-system.json OR the
590
- larger site config that embeds one (company.json / site.json). The two are told
591
- apart by shape, and a site config also supplies the site-level facts generation
592
- reads — its default colour scheme, its legal documents, its icon sprite.
593
-
594
- Usage:
595
- vitops generate [options] Generate platform output from a config
596
- vitops init [options] Scaffold a starter design-system.json
597
- vitops validate <file> Validate a config against the schema it says it is
598
- vitops favicon [options] Generate a favicon set from a source image
599
- vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
600
- vitops docs [topic] Print live design-system reference docs to stdout
601
- vitops lint [options] Report framework classes in your source that resolve to nothing
602
- vitops legal [options] Render legal documents from a site config
603
- vitops icons [options] Report which icons your source uses, and build the sprite
604
- vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
605
- vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
606
-
607
- Generate options:
608
- -i, --input <path> design-system.json or site config (default: ./design-system.json)
609
- -f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
610
- design emits DESIGN.md only (the agent-facing brief) — pair it
611
- with --out . to write it beside AGENTS.md, or compose:
612
- --format css,design
613
- -o, --out <dir> Output directory (default: ./dist)
614
- --theme <name> Which designSystem.themes entry to build, when --input is
615
- a site config (default: its defaultTheme, else "default")
616
- --site <path> Site config, when it is a different file from --input.
617
- Emits legal/*.html into the output directory (see:
618
- vitops legal). Omitted with a plain design-system.json:
619
- no legal files.
620
- --site-env <env> Environment whose A/B variant applies (default: production)
621
-
622
- Init options:
623
- -o, --out <path> Where to write (default: ./design-system.json)
624
- --force Overwrite an existing file
625
-
626
- Favicon options:
627
- -i, --input <path> Source SVG or PNG (required)
628
- -o, --out <dir> Output directory (default: ./public)
629
- --low-res <path> Optional simplified source for the 16px icon
630
- --background <hex> Background under the maskable icons, which must be
631
- opaque (default: #ffffff)
632
-
633
- Agents options:
634
- -i, --input <path> design-system.json or site config (default: ./design-system.json;
635
- validated if present)
636
- --theme <name> Theme to read, when --input is a site config
637
- -o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
638
- --docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
639
- instead of linking the packaged skill
640
-
641
- Docs options:
642
- <topic> classes | authoring | formats | color | scales | patterns | elements
643
- (no topic: list topics with summaries)
644
- -i, --input <path> design-system.json or site config (default: ./design-system.json)
645
- --theme <name> Theme to render docs from, when --input is a site config
646
- --all Print every topic, concatenated
647
-
648
- Icons options:
649
- --site <path> Site config carrying the "icons" block (default: ./site.json)
650
- --src <dir> Source to scan for icon usage (default: ./src)
651
- --sprite Also build the SVG sprite
652
- -o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)
653
- --json Machine-readable report on stdout
654
-
655
- Lint options:
656
- -i, --input <path> design-system.json or site config (default: ./design-system.json)
657
- --theme <name> Theme to judge classes against, when --input is a site config
658
- -f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
659
- Responsive md-* classes are real in css/bricks, inert in tailwind.
660
- -s, --src <dir> Directory to scan (default: ./src)
661
- --strict Also fail on suggestions (reuse hints), not just on
662
- classes that resolve to nothing
663
-
664
- Legal options:
665
- -i, --input <path> Site config: .json, or a .js/.ts module with a default
666
- export (default: ./site.json)
667
- -d, --doc <name> privacy | terms | cookies (repeatable; default: those
668
- enabled in the config)
669
- -f, --format <fmt> md | html | portable-text (default: md)
670
- html suits WordPress/Bricks and plain sites;
671
- portable-text suits EmDash
672
- -o, --out <dir> Write files here (default: print to stdout)
673
- --site-env <env> Environment whose A/B variant applies (default: production)
674
-
675
- Generated from your config — a starting point, not legal advice. Review before
676
- publishing, and make sure the config describes what your site actually does.
677
-
678
- Search subcommands:
679
- vitops search setup [opts] Onboard site.searchConsole domains into GSC
680
- vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)
681
-
682
- Search setup options:
683
- -i, --input <path> Site config carrying site.searchConsole (default: ./site.json)
684
- --site-env <env> Environment whose A/B variant applies (default: production)
685
- --domain <name> Scope to a single site.searchConsole entry
686
- --dry Print the plan and exit. Changes nothing.
687
- --check Report drift and exit non-zero if any domain is not fully
688
- onboarded. Mutates nothing.
689
-
690
- For each domain it ensures the apex verification TXT in Cloudflare, verifies
691
- ownership (DNS_TXT, retried with backoff while DNS propagates — a still-
692
- unverified domain is reported PENDING, not failed), adds the sc-domain: property,
693
- and adds any delegatedOwners to the web resource. DNS is only ever created,
694
- never edited or deleted. Credentials come from the environment:
695
- CLOUDFLARE_API_TOKEN (Zone:DNS:Edit), and VITOPS_GOOGLE_CLIENT_ID /
696
- VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token
697
- scoped to siteverification + webmasters). Granting a Google Group Full-User
698
- access has no API and is surfaced as a reminder.
699
-
700
- Search notify options:
701
- -i, --input <path> Site config carrying seo.indexing (default: ./site.json)
702
- --site-env <env> Environment to notify for (default: production). An
703
- environment whose robots policy says noindex is refused.
704
- --sitemap <src> Sitemap URL or local path (default: from the config)
705
- --urls <list> Comma-separated URLs to submit, skipping the diff
706
- --all Submit every URL in the sitemap, not just what changed
707
- --dry Print the plan and exit. Makes no requests.
708
- --check Read-only: ask Google whether seo.indexing.priorityUrls
709
- are indexed. Exits non-zero if one is not.
710
- --new-key Print a fresh IndexNow key and exit
711
- --write-key <dir> Write the IndexNow key file into <dir> (for stacks with
712
- no Astro integration to do it — Bricks, WordPress)
713
- --snapshot <path> Changed-URL state file (default: .vitops/sitemap-snapshot.json).
714
- Persist it between runs (a CI cache) or every run submits
715
- everything.
716
-
717
- What notify can and cannot do: Google exposes no "request indexing" API, and its
718
- sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
719
- through the Search Console API and verifies the result with --check. IndexNow
720
- reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
721
- Search Console takes either credential, whichever you already have, added as an
722
- owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
723
- JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth
724
- credential "search setup" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /
725
- _REFRESH_TOKEN). The service account is preferred when both are set — this runs
726
- on every deploy, and it does not expire.
727
-
728
- Media options:
729
- --raw <dir> Directory of unprocessed video, walked recursively
730
- (default: ./raw)
731
- -o, --out <dir> Where the encoded outputs go (default: ./src/assets/processed).
732
- Subdirectories under --raw are preserved.
733
- --max-width <px> Cap the output width, keeping aspect ratio (default: 1920;
734
- 0 disables scaling)
735
- --crf <n> Quality on VP9's scale, 0-63, lower is better (default: 32).
736
- The MP4 fallback uses the equivalent on H.264's scale.
737
- --max-bitrate <r> Optional ceiling, e.g. 2M or 800k (default: none, which is
738
- constant quality rather than constrained)
739
- --audio Keep the audio track (default: dropped — the common case is
740
- a muted autoplay loop)
741
- --poster-time <s> Timestamp the poster frame is taken from (default: 0, which
742
- is often black on a clip that fades in)
743
- --outputs <list> Comma-separated: webm | mp4 | poster (default: all three)
744
- --manifest <path> Cache file (default: .vitops/media-manifest.json)
745
- --force Re-encode everything, ignoring the cache
746
- --dry Print the plan and exit. Encodes nothing.
747
-
748
- Needs ffmpeg on PATH — it is an external tool, not an npm dependency, and this
749
- command fails rather than quietly skipping. Commit the outputs and the manifest:
750
- ffmpeg output is not reproducible across versions, so re-encoding in CI churns
751
- the diff on every toolchain bump. Use --force when you mean to re-encode.
752
-
753
- Common:
754
- -h, --help Show this help
755
- `;
756
- const SEARCH_HELP = `vitops search — Google Search Console
757
-
758
- vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
759
- vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
1325
+ severity: "error",
1326
+ reason: `\`${font[1]}\` is not a typography role`,
1327
+ suggestion: `font-${near}`
1328
+ };
1329
+ }
1330
+ const ds = /^drop-shadow-(.+)$/.exec(base);
1331
+ if (ds && v.shadows.length && !v.shadows.includes(ds[1])) return {
1332
+ cls: raw,
1333
+ severity: "error",
1334
+ reason: `\`${ds[1]}\` is not a defined shadow`,
1335
+ suggestion: `drop-shadow-${v.shadows[0]}`
1336
+ };
1337
+ return null;
1338
+ }
1339
+ const CLASS_ATTR = /(?:class|className|:class)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
1340
+ /** Extract class tokens from a source file, with line numbers. */
1341
+ function extractClasses(src) {
1342
+ const out = [];
1343
+ for (const m of src.matchAll(CLASS_ATTR)) {
1344
+ const value = m[1] ?? m[2] ?? m[3] ?? "";
1345
+ const line = src.slice(0, m.index).split("\n").length;
1346
+ for (const tok of value.split(/\s+/)) if (tok && !tok.includes("${") && !tok.includes("{") && /^[@\w:[\]().%/-]+$/.test(tok)) out.push({
1347
+ cls: tok,
1348
+ line
1349
+ });
1350
+ }
1351
+ return out;
1352
+ }
1353
+ function lintSource(files, v, format) {
1354
+ const findings = [];
1355
+ const seen = /* @__PURE__ */ new Set();
1356
+ for (const f of files) for (const { cls, line } of extractClasses(f.text)) {
1357
+ const verdict = judge(cls, v, format);
1358
+ if (!verdict) continue;
1359
+ const key = `${f.path}:${line}:${cls}`;
1360
+ if (seen.has(key)) continue;
1361
+ seen.add(key);
1362
+ findings.push({
1363
+ file: f.path,
1364
+ line,
1365
+ ...verdict
1366
+ });
1367
+ }
1368
+ return findings;
1369
+ }
760
1370
 
761
- Run \`vitops --help\` for the full option list for each subcommand.
762
- `;
1371
+ //#endregion
1372
+ //#region src/cli.ts
1373
+ /**
1374
+ * `vitops` — CLI for the Vitops design-system generator.
1375
+ *
1376
+ * vitops generate --input <json> --format <bricks|css|tailwind|design[,…]> --out <dir>
1377
+ * vitops init [--out design-system.json] [--force]
1378
+ * vitops validate <design-system.json|site.json>
1379
+ * vitops favicon --input <svg|png> --out <dir> [--low-res <svg|png>] [--background <hex>]
1380
+ * vitops agents [--input <json>] [--out AGENTS.md] [--docs-dir <dir>]
1381
+ * vitops docs [topic] [--input <json>] [--all]
1382
+ * vitops lint [--input <json>] [--format <fmt>] [--src <dir>]
1383
+ * vitops legal [--input <site.json>] [--doc <name>] [--format <md|html|portable-text>] [--out <dir>]
1384
+ * vitops search setup [--domain <name>] [--dry] [--check] | notify [--dry] [--all] [--check]
1385
+ * vitops ads setup [--provider <name>] [--dry] [--check] | tags | lint
1386
+ * vitops media [--raw <dir>] [--out <dir>] [--force] [--dry]
1387
+ *
1388
+ * Thin wrapper over @getvitops/generator (generation) and @getvitops/utils (favicons).
1389
+ * Every client brings their own consumer-editable design-system.json.
1390
+ */
1391
+ const FORMATS = new Set([
1392
+ "bricks",
1393
+ "css",
1394
+ "tailwind",
1395
+ "design"
1396
+ ]);
1397
+ const LINT_FORMATS = new Set([
1398
+ "bricks",
1399
+ "css",
1400
+ "tailwind"
1401
+ ]);
1402
+ const LEGAL_DOCS = new Set([
1403
+ "privacy",
1404
+ "terms",
1405
+ "cookies"
1406
+ ]);
1407
+ const LEGAL_OUTPUTS = new Set([
1408
+ "md",
1409
+ "html",
1410
+ "portable-text"
1411
+ ]);
763
1412
  function fail(msg) {
764
1413
  console.error(`✖ ${msg}`);
765
1414
  process.exit(1);
@@ -830,7 +1479,7 @@ function cmdInit(argv) {
830
1479
  writeFileSync(out, JSON.stringify(defaultConfig(), null, 2) + "\n");
831
1480
  console.log(`✓ wrote ${out}`);
832
1481
  console.log(` Edit it, then: vitops generate --input ${values.out} --format tailwind --out src/styles`);
833
- console.log(` Editors read the "$schema" (${SCHEMA_URL}) for autocomplete + validation.`);
1482
+ console.log(` Editors read the "$schema" (${SCHEMA_LOCAL_PATH}) for autocomplete + validation — the installed copy, so it matches the toolchain you build with.`);
834
1483
  }
835
1484
  /**
836
1485
  * Validate a config against the schema its shape says it is.
@@ -1012,7 +1661,7 @@ async function cmdMedia(argv) {
1012
1661
  onProgress: (message) => console.error(` … ${message}`)
1013
1662
  });
1014
1663
  if (values.dry) {
1015
- console.log(formatPlan$2(result.plan));
1664
+ console.log(formatPlan$3(result.plan));
1016
1665
  return;
1017
1666
  }
1018
1667
  console.log(`✓ media → ${values.out} (${result.written.length} written, ${result.skipped} unchanged)`);
@@ -1255,15 +1904,30 @@ async function cmdLegal(argv) {
1255
1904
  docs,
1256
1905
  output
1257
1906
  });
1907
+ /**
1908
+ * A processor placed nowhere cannot appear in the cross-border disclosure — there
1909
+ * is nothing true to say about it — so it drops out of that section silently,
1910
+ * which is the failure that looks tidy. Reported here rather than in the document:
1911
+ * a policy must not editorialise about its own gaps.
1912
+ *
1913
+ * On **stderr** because stdout is the document and gets piped or redirected.
1914
+ */
1915
+ if (docs.includes("privacy")) for (const name of processorsMissingLocation(site)) console.error(` ! ${name}: no country, storage or operatorCountry declared — it will not appear in the cross-border transfer disclosure. Add one, or state storage: [{ country: "${JURISDICTION_COUNTRIES[site.site.legal?.jurisdiction ?? "ca"]}" }] to say it does not leave the country.`);
1916
+ const REVIEW = " These are generated from your config and are not legal advice — review them before publishing.";
1258
1917
  if (!values.out) {
1259
1918
  process.stdout.write(Object.values(files).join("\n"));
1919
+ console.error(REVIEW);
1260
1920
  return;
1261
1921
  }
1262
1922
  const outDir = resolve(values.out);
1263
1923
  mkdirSync(outDir, { recursive: true });
1924
+ const isNew = (name) => !existsSync(join(outDir, name));
1925
+ const added = Object.keys(files).filter(isNew);
1264
1926
  for (const [name, content] of Object.entries(files)) writeFileSync(join(outDir, name), content);
1265
- console.log(`✓ legal → ${outDir} (${Object.keys(files).length} files)`);
1266
- console.log(" These are generated from your config and are not legal advice — review them before publishing.");
1927
+ console.log(`✓ legal → ${outDir}`);
1928
+ for (const name of Object.keys(files)) console.log(` ${name}${added.includes(name) ? " (new)" : ""}`);
1929
+ if (added.length) console.log(added.length === 1 ? `\n That document is new. It needs somewhere to live: a page or route that renders it, and a link to it${added[0]?.startsWith("cookie") ? " — including the URL your consent banner points at" : ""}.` : `\n Those ${added.length} documents are new. Each needs somewhere to live: a page or route that renders it, and a link to it — for a cookie notice, including the URL your consent banner points at.`);
1930
+ console.log(REVIEW);
1267
1931
  }
1268
1932
  /**
1269
1933
  * Adapt a `Config` to the indexing module's own option shape.
@@ -1613,7 +2277,20 @@ async function cmdSearchSetup(argv) {
1613
2277
  if (domains.length === 0) fail("no domains in site.searchConsole — add domains to onboard (keyed by bare hostname)");
1614
2278
  const cfToken = loadCloudflareToken();
1615
2279
  const oauth = loadGoogleOAuth();
1616
- if (!cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token) — reading and writing DNS needs it");
2280
+ if (values.dry && (!cfToken || !oauth)) {
2281
+ const blank = {
2282
+ zoneId: "",
2283
+ txtPresent: false,
2284
+ verified: false,
2285
+ currentOwners: [],
2286
+ propertyExists: false
2287
+ };
2288
+ console.log(formatPlan$1(plan$1({ domains }, new Map(domains.map((d) => [d.domain, blank])))));
2289
+ const missing = [...cfToken ? [] : ["CLOUDFLARE_API_TOKEN (Zone:DNS:Edit + Zone:Read)"], ...oauth ? [] : ["VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN"]];
2290
+ console.log(`\n(--dry, and no credentials set: planned from scratch rather than from live state, so nothing here is reported as already done.\n A real run needs ${missing.join(" and ")}.)`);
2291
+ return;
2292
+ }
2293
+ if (!cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token — the standard \"Edit zone DNS\" template, which also carries the Zone:Read that the zone-by-name lookup needs)");
1617
2294
  if (!oauth) fail("set VITOPS_GOOGLE_CLIENT_ID / VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN — a user OAuth token scoped to siteverification + webmasters");
1618
2295
  const token = await refreshTokenGrant(oauth);
1619
2296
  const states = /* @__PURE__ */ new Map();
@@ -1729,6 +2406,320 @@ async function cmdSearchSetup(argv) {
1729
2406
  console.log(`\n${formatSummary(results)}`);
1730
2407
  if (failed) process.exit(1);
1731
2408
  }
2409
+ /**
2410
+ * Adapt a `Config` to the ads module's own option shape.
2411
+ *
2412
+ * The same flat field map as `toSearchConsoleSetup` and `toIndexingConfig`, for the
2413
+ * same reason — utils cannot import the generator. The domain default is the one
2414
+ * derived value: a platform verifies the host the site is published at, so
2415
+ * `domains.canonical` answers it, and only an ad account verified against some
2416
+ * other host needs `site.ads.<provider>.domain` stated.
2417
+ */
2418
+ function toAdProperties(cfg, providerFilter) {
2419
+ const canonicalHost = (() => {
2420
+ const url = cfg.site.domains?.canonical;
2421
+ if (!url) return void 0;
2422
+ try {
2423
+ return new URL(url).hostname;
2424
+ } catch {
2425
+ return;
2426
+ }
2427
+ })();
2428
+ const entries = cfg.site.ads ?? {};
2429
+ const properties = AD_PROVIDER_KEYS.filter((p) => entries[p] != null).map((provider) => {
2430
+ const e = entries[provider];
2431
+ const domain = e.domain ?? canonicalHost;
2432
+ return {
2433
+ provider,
2434
+ ...e.accountId ? { accountId: e.accountId } : {},
2435
+ ...e.pixelId ? { pixelId: e.pixelId } : {},
2436
+ ...e.conversionLabel ? { conversionLabel: e.conversionLabel } : {},
2437
+ ...domain ? { domain } : {},
2438
+ ...e.domainVerification ? { domainVerification: e.domainVerification } : {},
2439
+ ...e.category ? { category: e.category } : {},
2440
+ ...e.enabled != null ? { enabled: e.enabled } : {}
2441
+ };
2442
+ });
2443
+ if (providerFilter) {
2444
+ if (!isAdProvider(providerFilter)) fail(`--provider "${providerFilter}" is not a known ad platform (expected: ${AD_PROVIDER_KEYS.join(" | ")})`);
2445
+ const one = properties.find((p) => p.provider === providerFilter);
2446
+ if (!one) fail(`--provider "${providerFilter}" is not in site.ads (have: ${properties.map((p) => p.provider).join(", ") || "none"})`);
2447
+ return [one];
2448
+ }
2449
+ return properties;
2450
+ }
2451
+ /**
2452
+ * `vitops ads` — everything that links this site to an ad platform.
2453
+ *
2454
+ * Three subcommands, split by what they touch: `setup` writes DNS, `tags` writes
2455
+ * nothing and prints markup, `lint` reads and reports.
2456
+ */
2457
+ async function cmdAds(argv) {
2458
+ const [sub, ...rest] = argv;
2459
+ switch (sub) {
2460
+ case "setup": return cmdAdsSetup(rest);
2461
+ case "tags": return cmdAdsTags(rest);
2462
+ case "lint": return cmdAdsLint(rest);
2463
+ case void 0:
2464
+ case "-h":
2465
+ case "--help":
2466
+ console.log(ADS_HELP);
2467
+ return;
2468
+ default: fail(`unknown "ads" subcommand "${sub}" (expected: setup | tags | lint). Try: vitops --help`);
2469
+ }
2470
+ }
2471
+ /**
2472
+ * Link each configured ad property: ensure the platform's domain-verification TXT,
2473
+ * report what only a human can finish.
2474
+ *
2475
+ * Idempotent by construction — the planner decides each step from live DNS, so a
2476
+ * re-run of a linked property is all skips. `--check` reports drift and exits
2477
+ * non-zero without mutating; `--dry` prints the plan and stops. Neither is offline:
2478
+ * the DNS observation runs before the planner either way.
2479
+ *
2480
+ * The one thing this does that `search setup` does not is **ask**. A first run has
2481
+ * no verification token, because the token doesn't exist until someone fetches it
2482
+ * from the platform UI — so a blocked step becomes a prompt, the answer is folded
2483
+ * into the setup, and the planner runs again on it. That re-plan matters: what gets
2484
+ * executed is always a plan the pure planner produced, never a special case built
2485
+ * out of an answer.
2486
+ */
2487
+ async function cmdAdsSetup(argv) {
2488
+ const { values } = parseArgs({
2489
+ args: argv,
2490
+ options: {
2491
+ input: {
2492
+ type: "string",
2493
+ short: "i",
2494
+ default: "site.json"
2495
+ },
2496
+ "site-env": {
2497
+ type: "string",
2498
+ default: "production"
2499
+ },
2500
+ provider: { type: "string" },
2501
+ check: {
2502
+ type: "boolean",
2503
+ default: false
2504
+ },
2505
+ dry: {
2506
+ type: "boolean",
2507
+ default: false
2508
+ },
2509
+ "no-prompt": {
2510
+ type: "boolean",
2511
+ default: false
2512
+ },
2513
+ "no-write": {
2514
+ type: "boolean",
2515
+ default: false
2516
+ }
2517
+ },
2518
+ allowPositionals: false
2519
+ });
2520
+ const input = resolve(values.input);
2521
+ let properties = toAdProperties(await loadConfig(input, values["site-env"]), values.provider);
2522
+ if (properties.length === 0) fail("no properties in site.ads — add the ad platforms this site is linked to");
2523
+ const dry = values.dry;
2524
+ const check = values.check;
2525
+ const needsDns = properties.some((p) => AD_PLATFORMS[p.provider].verification.method === "dns-txt");
2526
+ const cfToken = loadCloudflareToken();
2527
+ if (needsDns && !cfToken && values.dry !== true) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token — the standard \"Edit zone DNS\" template, which also carries the Zone:Read that the zone-by-name lookup needs)");
2528
+ /** Observe live DNS per domain. Cached by domain: several platforms share one. */
2529
+ const observe = async () => {
2530
+ const states = /* @__PURE__ */ new Map();
2531
+ if (!cfToken) return states;
2532
+ for (const p of properties) {
2533
+ const domain = p.domain;
2534
+ if (!domain || states.has(domain)) continue;
2535
+ if (AD_PLATFORMS[p.provider].verification.method !== "dns-txt") continue;
2536
+ const zone = await findZoneId(cfToken, domain);
2537
+ if (!zone.ok || !zone.zoneId) fail(`${domain}: ${zone.message}`);
2538
+ const txt = await listApexTxt(cfToken, zone.zoneId, domain);
2539
+ if (!txt.ok) fail(`${domain}: ${txt.message}`);
2540
+ states.set(domain, {
2541
+ zoneId: zone.zoneId,
2542
+ txtContents: txt.contents
2543
+ });
2544
+ }
2545
+ return states;
2546
+ };
2547
+ let states = await observe();
2548
+ let adsPlan = plan$2({ properties }, states);
2549
+ const interactive = canPrompt({
2550
+ dry,
2551
+ check,
2552
+ noPrompt: values["no-prompt"]
2553
+ });
2554
+ const gaps = missingFields(adsPlan);
2555
+ if (gaps.length && interactive) {
2556
+ const answers = [];
2557
+ for (const gap of gaps) {
2558
+ const value = await ask(questionFor(gap.provider, gap.needs));
2559
+ if (value) answers.push({
2560
+ provider: gap.provider,
2561
+ needs: gap.needs,
2562
+ value
2563
+ });
2564
+ }
2565
+ if (answers.length) {
2566
+ properties = properties.map((p) => {
2567
+ return answers.filter((a) => a.provider === p.provider).reduce((acc, a) => ({
2568
+ ...acc,
2569
+ [a.needs]: a.value
2570
+ }), p);
2571
+ });
2572
+ adsPlan = plan$2({ properties }, states);
2573
+ if (!values["no-write"]) for (const a of answers) {
2574
+ const res = writeConfigPatch(input, a.provider, a.needs, a.value);
2575
+ if (res.written) console.log(`✓ wrote site.ads.${a.provider}.${a.needs} to ${input}`);
2576
+ else console.error(`! ${res.reason}`);
2577
+ }
2578
+ }
2579
+ states = await observe();
2580
+ adsPlan = plan$2({ properties }, states);
2581
+ } else if (gaps.length && !dry && !check) for (const gap of gaps) console.error(`! ${missingFieldMessage(gap.provider, gap.needs)}`);
2582
+ if (dry || check) {
2583
+ console.log(formatPlan$2(adsPlan));
2584
+ if (check && hasDrift$1(adsPlan)) {
2585
+ console.error("\n✖ ad properties are not fully linked");
2586
+ process.exit(1);
2587
+ }
2588
+ return;
2589
+ }
2590
+ const results = [];
2591
+ let failed = false;
2592
+ for (const p of adsPlan.properties) {
2593
+ const result = {
2594
+ provider: p.provider,
2595
+ domain: p.domain ?? "—",
2596
+ txt: "n/a",
2597
+ tag: p.tag.action === "skip" ? "ready" : "blocked",
2598
+ reminders: p.reminders
2599
+ };
2600
+ if (p.txt.action === "blocked" || p.tag.action === "blocked") failed = true;
2601
+ if (p.txt.action === "blocked") result.txt = "blocked";
2602
+ else if (p.txt.action === "skip") result.txt = AD_PLATFORMS[p.provider].verification.method === "dns-txt" ? "present" : "n/a";
2603
+ else {
2604
+ const zoneId = p.domain ? states.get(p.domain)?.zoneId : void 0;
2605
+ if (!cfToken || !zoneId || !p.domain || !p.txtContent) {
2606
+ console.error(`✖ ${p.provider}: no Cloudflare zone for ${p.domain ?? "(no domain)"}`);
2607
+ result.txt = "failed";
2608
+ failed = true;
2609
+ } else {
2610
+ const r = await createApexTxt(cfToken, zoneId, p.domain, p.txtContent);
2611
+ if (!r.ok) {
2612
+ console.error(`✖ ${p.provider} TXT: ${r.message}`);
2613
+ result.txt = "failed";
2614
+ failed = true;
2615
+ } else {
2616
+ console.log(`✓ ${p.provider}: created TXT on ${p.domain}`);
2617
+ result.txt = "created";
2618
+ }
2619
+ }
2620
+ }
2621
+ results.push(result);
2622
+ }
2623
+ console.log(`\n${formatSummary$1(results)}`);
2624
+ if (results.some((r) => r.txt === "created")) console.log("\n DNS takes minutes to propagate before a platform can see the record.");
2625
+ if (results.some((r) => r.reminders.length)) console.log(" Finish the reminders above in the platform UI — no API covers those.");
2626
+ if (failed) process.exit(1);
2627
+ }
2628
+ /**
2629
+ * Print each configured pixel as an inert, consent-gated `<script>`.
2630
+ *
2631
+ * The universal delivery path, the same reasoning as `vitops legal`: every consumer
2632
+ * has this CLI whatever their stack, so Bricks, WordPress and a hand-built site all
2633
+ * get the correct gated markup without integration code.
2634
+ */
2635
+ async function cmdAdsTags(argv) {
2636
+ const { values } = parseArgs({
2637
+ args: argv,
2638
+ options: {
2639
+ input: {
2640
+ type: "string",
2641
+ short: "i",
2642
+ default: "site.json"
2643
+ },
2644
+ "site-env": {
2645
+ type: "string",
2646
+ default: "production"
2647
+ },
2648
+ provider: { type: "string" },
2649
+ strategy: {
2650
+ type: "string",
2651
+ default: "idle"
2652
+ }
2653
+ },
2654
+ allowPositionals: false
2655
+ });
2656
+ const strategy = values.strategy;
2657
+ if (![
2658
+ "idle",
2659
+ "async",
2660
+ "interaction"
2661
+ ].includes(strategy)) fail(`--strategy "${strategy}" is not one of: idle | async | interaction`);
2662
+ const properties = toAdProperties(await loadConfig(resolve(values.input), values["site-env"]), values.provider);
2663
+ if (properties.length === 0) fail("no properties in site.ads — nothing to emit");
2664
+ const out = [];
2665
+ for (const p of properties) {
2666
+ if (p.enabled === false) continue;
2667
+ const tag = renderTag(p, strategy);
2668
+ if (!tag) {
2669
+ console.error(`! ${p.provider}: no ${AD_PLATFORMS[p.provider].tag.needs} — skipped (run \`vitops ads setup\` to be prompted for it)`);
2670
+ continue;
2671
+ }
2672
+ out.push(tag);
2673
+ }
2674
+ if (out.length === 0) fail("no ad property has a tag id yet");
2675
+ console.log(out.join("\n\n"));
2676
+ }
2677
+ /**
2678
+ * Report the ad-property gaps that are invisible at runtime.
2679
+ *
2680
+ * Each finding is a silent failure, which is the bar for being here at all: an
2681
+ * uncaptured click ID looks exactly like organic traffic, and a pixel on a site
2682
+ * with tracking off produces conversions no campaign gets credit for.
2683
+ */
2684
+ async function cmdAdsLint(argv) {
2685
+ const { values } = parseArgs({
2686
+ args: argv,
2687
+ options: {
2688
+ input: {
2689
+ type: "string",
2690
+ short: "i",
2691
+ default: "site.json"
2692
+ },
2693
+ "site-env": {
2694
+ type: "string",
2695
+ default: "production"
2696
+ }
2697
+ },
2698
+ allowPositionals: false
2699
+ });
2700
+ const cfg = await loadConfig(resolve(values.input), values["site-env"]);
2701
+ const properties = toAdProperties(cfg, void 0);
2702
+ if (properties.length === 0) {
2703
+ console.log("· no site.ads properties — nothing to check");
2704
+ return;
2705
+ }
2706
+ const findings = [];
2707
+ const trackingOn = cfg.site.tracking?.enabled === true;
2708
+ for (const p of properties) {
2709
+ const platform = AD_PLATFORMS[p.provider];
2710
+ if (p.enabled === false) continue;
2711
+ if (!tagId(p)) findings.push(`${p.provider}: no ${platform.tag.needs} — the property is on record but no tag can be emitted`);
2712
+ const uncaptured = platform.clickIdParams.filter((param) => !(param in PLATFORM_PARAMS));
2713
+ if (uncaptured.length) findings.push(`${p.provider}: click ID ${uncaptured.join(", ")} is not in the capture vocabulary — every conversion from this platform will arrive unattributed`);
2714
+ if (!trackingOn) findings.push(`${p.provider}: site.tracking.enabled is not true, so ${platform.clickIdParams.join("/")} is never captured — conversions will be unattributed`);
2715
+ }
2716
+ if (findings.length === 0) {
2717
+ console.log(`✓ ${properties.length} ad propert${properties.length === 1 ? "y" : "ies"} check out`);
2718
+ return;
2719
+ }
2720
+ for (const f of findings) console.error(`✖ ${f}`);
2721
+ process.exit(1);
2722
+ }
1732
2723
  function cliVersion() {
1733
2724
  try {
1734
2725
  return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0";
@@ -1771,7 +2762,7 @@ function cmdDocs(argv) {
1771
2762
  process.stdout.write(paths.map((p) => docs[p]).join("\n"));
1772
2763
  }
1773
2764
  function cmdLint(argv) {
1774
- const { values } = parseArgs({
2765
+ const { values, positionals } = parseArgs({
1775
2766
  args: argv,
1776
2767
  options: {
1777
2768
  input: {
@@ -1793,15 +2784,19 @@ function cmdLint(argv) {
1793
2784
  strict: {
1794
2785
  type: "boolean",
1795
2786
  default: false
2787
+ },
2788
+ fix: {
2789
+ type: "boolean",
2790
+ default: false
1796
2791
  }
1797
2792
  },
1798
- allowPositionals: false
2793
+ allowPositionals: true
1799
2794
  });
1800
2795
  const format = values.format;
1801
2796
  if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
1802
2797
  const ds = loadDesignSystem(resolve(values.input), values.theme);
1803
2798
  const srcDir = resolve(values.src);
1804
- if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
2799
+ if (!positionals.length && !existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
1805
2800
  const palette = expandPalette(ds.colors.palette);
1806
2801
  const roleClasses = roleColorUtilities(Object.entries(ds.colors.roles).map(([role, spec]) => functionalRole(role, roleHue(spec), palette[roleHue(spec)], roleKind(spec))), ds.colors.utilities ?? [
1807
2802
  "bg",
@@ -1809,11 +2804,56 @@ function cmdLint(argv) {
1809
2804
  "icon",
1810
2805
  "border"
1811
2806
  ]).map((u) => u.cls);
1812
- const files = scanFiles(srcDir);
1813
- const cssFiles = [...files, ...scanFiles(srcDir, { exts: [".css"] })];
1814
- const findings = [...lintSource(files, vocabulary(ds, roleClasses), format), ...lintCss(cssFiles, format)];
2807
+ const LINTABLE = new Set([
2808
+ ".astro",
2809
+ ".html",
2810
+ ".js",
2811
+ ".jsx",
2812
+ ".ts",
2813
+ ".tsx",
2814
+ ".vue",
2815
+ ".svelte"
2816
+ ]);
2817
+ const named = positionals.filter((p) => LINTABLE.has(extname(p)) || extname(p) === ".css").flatMap((p) => {
2818
+ try {
2819
+ return [{
2820
+ path: p,
2821
+ text: readFileSync(resolve(p), "utf8")
2822
+ }];
2823
+ } catch {
2824
+ return [];
2825
+ }
2826
+ });
2827
+ const files = positionals.length ? named.filter((f) => extname(f.path) !== ".css") : scanFiles(srcDir);
2828
+ const cssFiles = positionals.length ? named : [...files, ...scanFiles(srcDir, { exts: [".css"] })];
2829
+ if (positionals.length && !cssFiles.length) {
2830
+ console.log("✓ no lintable files among the paths given");
2831
+ return;
2832
+ }
2833
+ const renames = movedTokens(ds.colors.roles);
2834
+ const tokenFindings = lintTokens(cssFiles, renames);
2835
+ const findings = [
2836
+ ...lintSource(files, vocabulary(ds, roleClasses), format),
2837
+ ...lintCss(cssFiles, format),
2838
+ ...lintMarkup(files),
2839
+ ...tokenFindings
2840
+ ];
2841
+ if (values.fix === true) {
2842
+ const byFile = /* @__PURE__ */ new Map();
2843
+ for (const f of tokenFindings) (byFile.get(f.file) ?? byFile.set(f.file, /* @__PURE__ */ new Set()).get(f.file)).add(f.fix.from);
2844
+ for (const [file, names] of byFile) {
2845
+ const subset = Object.fromEntries([...names].map((n) => [n, renames[n]]));
2846
+ writeFileSync(file, applyRenames(readFileSync(file, "utf8"), subset));
2847
+ }
2848
+ const remaining = findings.length - tokenFindings.length;
2849
+ console.log(`✓ rewrote ${tokenFindings.length} token reference${tokenFindings.length === 1 ? "" : "s"} in ${byFile.size} file${byFile.size === 1 ? "" : "s"}` + (remaining ? `\n! ${remaining} other finding${remaining === 1 ? "" : "s"} need a decision — re-run without --fix to see ${remaining === 1 ? "it" : "them"}` : ""));
2850
+ if (remaining) process.exitCode = 1;
2851
+ return;
2852
+ }
2853
+ const scope = positionals.length ? "the files given" : values.src;
2854
+ const scanned = positionals.length ? cssFiles.length : files.length;
1815
2855
  if (!findings.length) {
1816
- console.log(`✓ no unresolvable framework classes in ${values.src} (${files.length} file${files.length === 1 ? "" : "s"} scanned)`);
2856
+ console.log(`✓ no unresolvable framework classes or missed primitives in ${scope} (${scanned} file${scanned === 1 ? "" : "s"} checked)`);
1817
2857
  return;
1818
2858
  }
1819
2859
  const errors = findings.filter((f) => f.severity === "error");
@@ -1828,9 +2868,35 @@ function cmdLint(argv) {
1828
2868
  if (errors.length) parts.push(count(errors.length, "unresolvable class", "unresolvable classes"));
1829
2869
  if (suggestions.length) parts.push(count(suggestions.length, "suggestion", "suggestions"));
1830
2870
  const failing = errors.length > 0 || values.strict === true && suggestions.length > 0;
1831
- console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${files.length} file${files.length === 1 ? "" : "s"}` + (suggestions.length && !values.strict ? " (suggestions do not fail; use --strict)" : ""));
2871
+ console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${scanned} file${scanned === 1 ? "" : "s"}` + (suggestions.length && !values.strict ? " (suggestions do not fail; use --strict)" : ""));
1832
2872
  if (failing) process.exit(1);
1833
2873
  }
2874
+ /**
2875
+ * Find the config when `design-system.json` isn't there.
2876
+ *
2877
+ * Keyed to SHAPE, not just to the filename — `resolveInput` already tells a
2878
+ * design system from the config that embeds one, and consumers name that file
2879
+ * whatever they like (`company.json`, `site.json`). The candidate list is
2880
+ * ordered, and a tie is resolved by taking the first: this only ever supplies a
2881
+ * default that the run then prints, so a wrong guess is visible rather than
2882
+ * silent, and `-i` overrides it.
2883
+ */
2884
+ function discoverConfig(cwd) {
2885
+ for (const name of [
2886
+ "design-system.json",
2887
+ "vitops.json",
2888
+ "site.json",
2889
+ "company.json",
2890
+ "organization.json"
2891
+ ]) {
2892
+ const p = resolve(cwd, name);
2893
+ if (!existsSync(p)) continue;
2894
+ try {
2895
+ const raw = JSON.parse(readFileSync(p, "utf8"));
2896
+ if (isConfig(raw) || raw && typeof raw === "object" && "colors" in raw) return name;
2897
+ } catch {}
2898
+ }
2899
+ }
1834
2900
  function cmdAgents(argv) {
1835
2901
  const { values } = parseArgs({
1836
2902
  args: argv,
@@ -1850,7 +2916,15 @@ function cmdAgents(argv) {
1850
2916
  },
1851
2917
  allowPositionals: false
1852
2918
  });
1853
- const inputRel = values.input;
2919
+ const explicitInput = argv.includes("-i") || argv.includes("--input");
2920
+ let inputRel = values.input;
2921
+ if (!explicitInput && !existsSync(resolve(inputRel))) {
2922
+ const found = discoverConfig(process.cwd());
2923
+ if (found) {
2924
+ inputRel = found;
2925
+ console.warn(` ⚠ no design-system.json here — using ${found} (pass -i to choose another)`);
2926
+ } else fail(`config not found: ${resolve(inputRel)}\n This command writes the path into ${values.out}, so it will not guess one. Pass -i <path> if your tokens live elsewhere (a site config works), or run \`vitops init\`.`);
2927
+ }
1854
2928
  let ds = null;
1855
2929
  if (existsSync(resolve(inputRel))) ds = loadDesignSystem(resolve(inputRel), values.theme);
1856
2930
  else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
@@ -1898,6 +2972,7 @@ function cmdAgents(argv) {
1898
2972
  ...Object.entries(TOPICS).map(([name, t]) => `- \`vitops docs ${name}\` — ${t.summary}`)
1899
2973
  ];
1900
2974
  }
2975
+ const inputFlag = inputRel === "design-system.json" ? "" : ` --input ${inputRel}`;
1901
2976
  const block = [
1902
2977
  AGENTS_START,
1903
2978
  "## Vitops design system",
@@ -1905,11 +2980,11 @@ function cmdAgents(argv) {
1905
2980
  `Styled with the Vitops design system (\`@getvitops/*\`); tokens live in \`${inputRel}\`.`,
1906
2981
  "Generate output with the CLI:",
1907
2982
  "",
1908
- "- `vitops generate --format tailwind --out src/styles` — Tailwind v4 / Astro",
1909
- "- `vitops generate --format css --out dist` — standalone CSS",
1910
- "- `vitops generate --format bricks --out <theme>/dist` — WordPress / Bricks",
1911
- "- `vitops generate --format design --out .` — `DESIGN.md`, the agent-facing brief",
1912
- "- `vitops init` · `vitops validate` · `vitops favicon`",
2983
+ `- \`vitops generate${inputFlag} --format tailwind --out src/styles\` — Tailwind v4 / Astro`,
2984
+ `- \`vitops generate${inputFlag} --format css --out dist\` — standalone CSS`,
2985
+ `- \`vitops generate${inputFlag} --format bricks --out <theme>/dist\` — WordPress / Bricks`,
2986
+ `- \`vitops generate${inputFlag} --format design --out .\` — \`DESIGN.md\`, the agent-facing brief`,
2987
+ `- \`vitops init\` · \`vitops validate${inputFlag}\` · \`vitops favicon\``,
1913
2988
  "",
1914
2989
  ...pointer,
1915
2990
  "",
@@ -1944,6 +3019,10 @@ async function main() {
1944
3019
  console.log(HELP);
1945
3020
  return;
1946
3021
  }
3022
+ if (wantsHelp(rest)) {
3023
+ console.log(helpFor(command, rest));
3024
+ return;
3025
+ }
1947
3026
  switch (command) {
1948
3027
  case "generate": return cmdGenerate(rest);
1949
3028
  case "init": return cmdInit(rest);
@@ -1955,8 +3034,9 @@ async function main() {
1955
3034
  case "legal": return cmdLegal(rest);
1956
3035
  case "icons": return cmdIcons(rest);
1957
3036
  case "search": return cmdSearch(rest);
3037
+ case "ads": return cmdAds(rest);
1958
3038
  case "media": return cmdMedia(rest);
1959
- default: fail(`unknown command "${command}" (expected: generate | init | validate | favicon | agents | docs | lint | legal | icons | search | media). Try: vitops --help`);
3039
+ default: fail(`unknown command "${command}" (expected: ${COMMANDS.join(" | ")}). Try: vitops --help`);
1960
3040
  }
1961
3041
  }
1962
3042
  main().catch((err) => fail(err.message));