@getvitops/cli 4.1.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/CHANGELOG.md +239 -0
- package/dist/cli.mjs +820 -262
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +121 -5
package/dist/cli.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
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 { JURISDICTION_COUNTRIES, NUMERIC_STEPS,
|
|
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";
|
|
@@ -129,6 +129,324 @@ function linkSkill(linkPath, targetDir) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
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
|
+
|
|
132
450
|
//#endregion
|
|
133
451
|
//#region src/prompt.ts
|
|
134
452
|
/**
|
|
@@ -273,7 +591,7 @@ function writeConfigPatch(path, provider, field, value) {
|
|
|
273
591
|
function stripComments(css) {
|
|
274
592
|
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ""));
|
|
275
593
|
}
|
|
276
|
-
const lineAt = (src, index) => src.slice(0, index).split("\n").length;
|
|
594
|
+
const lineAt$1 = (src, index) => src.slice(0, index).split("\n").length;
|
|
277
595
|
/**
|
|
278
596
|
* Parse declarations from a rule body. Splits on top-level `;` only, so a
|
|
279
597
|
* `var(--x, a; b)` or a data URI cannot end a declaration early.
|
|
@@ -348,7 +666,7 @@ function scanCss(css) {
|
|
|
348
666
|
selector: frame.prelude,
|
|
349
667
|
declarations: parseDeclarations(src.slice(bodyStart, i)),
|
|
350
668
|
conditions: stack.filter((f) => f.prelude.startsWith("@")).map((f) => f.prelude),
|
|
351
|
-
line: lineAt(src, frame.index)
|
|
669
|
+
line: lineAt$1(src, frame.index)
|
|
352
670
|
});
|
|
353
671
|
}
|
|
354
672
|
preludeStart = i + 1;
|
|
@@ -361,7 +679,7 @@ function scanCss(css) {
|
|
|
361
679
|
function extractStyleBlocks(src) {
|
|
362
680
|
const out = [];
|
|
363
681
|
for (const m of src.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
364
|
-
const line = lineAt(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
|
|
682
|
+
const line = lineAt$1(src, (m.index ?? 0) + m[0].indexOf(">") + 1);
|
|
365
683
|
out.push({
|
|
366
684
|
css: m[1] ?? "",
|
|
367
685
|
line
|
|
@@ -383,6 +701,68 @@ const TRACK_WIDTHS = [
|
|
|
383
701
|
"--width-spotlight"
|
|
384
702
|
];
|
|
385
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
|
+
/**
|
|
386
766
|
* Column ratios that correspond to a `split-<a>-<b>` class.
|
|
387
767
|
*
|
|
388
768
|
* `.split` is FLEX, not grid — `display: flex` with `flex: 1` children, and a
|
|
@@ -419,14 +799,21 @@ const responsive = (bp, base, format) => format === "tailwind" ? `@${bp}:${base}
|
|
|
419
799
|
*/
|
|
420
800
|
const RULES = [
|
|
421
801
|
(rule) => {
|
|
422
|
-
const
|
|
802
|
+
const cap = decl(rule, "max-inline-size") ?? decl(rule, "max-width") ?? decl(rule, "width");
|
|
423
803
|
const centred = /\bauto\b/.test(decl(rule, "margin-inline") ?? decl(rule, "margin") ?? "");
|
|
424
|
-
if (!
|
|
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;
|
|
425
812
|
return {
|
|
426
|
-
cls: `${rule.selector} { max-
|
|
813
|
+
cls: `${rule.selector} { ${cap.length > 40 ? "width cap" : `max-width: ${cap}`} }`,
|
|
427
814
|
severity: "suggestion",
|
|
428
|
-
reason:
|
|
429
|
-
suggestion: "use `.centered
|
|
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"
|
|
430
817
|
};
|
|
431
818
|
},
|
|
432
819
|
(rule, format) => {
|
|
@@ -442,6 +829,22 @@ const RULES = [
|
|
|
442
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`
|
|
443
830
|
};
|
|
444
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
|
+
},
|
|
445
848
|
(rule, format) => {
|
|
446
849
|
if (decl(rule, "display") !== "flex") return null;
|
|
447
850
|
if (decl(rule, "align-items") !== "center") return null;
|
|
@@ -501,6 +904,286 @@ function lintCss(files, format) {
|
|
|
501
904
|
return findings;
|
|
502
905
|
}
|
|
503
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
|
+
|
|
504
1187
|
//#endregion
|
|
505
1188
|
//#region src/lint.ts
|
|
506
1189
|
/**
|
|
@@ -726,238 +1409,6 @@ const LEGAL_OUTPUTS = new Set([
|
|
|
726
1409
|
"html",
|
|
727
1410
|
"portable-text"
|
|
728
1411
|
]);
|
|
729
|
-
const HELP = `vitops — generate design-system outputs from a design-system.json
|
|
730
|
-
|
|
731
|
-
Anywhere a command takes --input, that file may be a design-system.json OR the
|
|
732
|
-
larger site config that embeds one (company.json / site.json). The two are told
|
|
733
|
-
apart by shape, and a site config also supplies the site-level facts generation
|
|
734
|
-
reads — its default colour scheme, its legal documents, its icon sprite.
|
|
735
|
-
|
|
736
|
-
Usage:
|
|
737
|
-
vitops generate [options] Generate platform output from a config
|
|
738
|
-
vitops init [options] Scaffold a starter design-system.json
|
|
739
|
-
vitops validate <file> Validate a config against the schema it says it is
|
|
740
|
-
vitops favicon [options] Generate a favicon set from a source image
|
|
741
|
-
vitops agents [options] Link the design-system agent skill + AGENTS.md pointer
|
|
742
|
-
vitops docs [topic] Print live design-system reference docs to stdout
|
|
743
|
-
vitops lint [options] Report framework classes in your source that resolve to nothing
|
|
744
|
-
vitops legal [options] Render legal documents from a site config
|
|
745
|
-
vitops icons [options] Report which icons your source uses, and build the sprite
|
|
746
|
-
vitops search <sub> [opts] Search Console: onboard domains (setup) + notify deploys (notify)
|
|
747
|
-
vitops ads <sub> [opts] Ad properties: verify domains (setup) + emit pixels (tags) + lint
|
|
748
|
-
vitops media [options] Encode raw video into web-ready WebM + MP4 + poster
|
|
749
|
-
|
|
750
|
-
Generate options:
|
|
751
|
-
-i, --input <path> design-system.json or site config (default: ./design-system.json)
|
|
752
|
-
-f, --format <list> bricks | css | tailwind | design (comma-separated; default: bricks)
|
|
753
|
-
design emits DESIGN.md only (the agent-facing brief) — pair it
|
|
754
|
-
with --out . to write it beside AGENTS.md, or compose:
|
|
755
|
-
--format css,design
|
|
756
|
-
-o, --out <dir> Output directory (default: ./dist)
|
|
757
|
-
--theme <name> Which designSystem.themes entry to build, when --input is
|
|
758
|
-
a site config (default: its defaultTheme, else "default")
|
|
759
|
-
--site <path> Site config, when it is a different file from --input.
|
|
760
|
-
Emits legal/*.html into the output directory (see:
|
|
761
|
-
vitops legal). Omitted with a plain design-system.json:
|
|
762
|
-
no legal files.
|
|
763
|
-
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
764
|
-
|
|
765
|
-
Init options:
|
|
766
|
-
-o, --out <path> Where to write (default: ./design-system.json)
|
|
767
|
-
--force Overwrite an existing file
|
|
768
|
-
|
|
769
|
-
Favicon options:
|
|
770
|
-
-i, --input <path> Source SVG or PNG (required)
|
|
771
|
-
-o, --out <dir> Output directory (default: ./public)
|
|
772
|
-
--low-res <path> Optional simplified source for the 16px icon
|
|
773
|
-
--background <hex> Background under the maskable icons, which must be
|
|
774
|
-
opaque (default: #ffffff)
|
|
775
|
-
|
|
776
|
-
Agents options:
|
|
777
|
-
-i, --input <path> design-system.json or site config (default: ./design-system.json;
|
|
778
|
-
validated if present)
|
|
779
|
-
--theme <name> Theme to read, when --input is a site config
|
|
780
|
-
-o, --out <path> Doc file to update, idempotently (default: ./AGENTS.md)
|
|
781
|
-
--docs-dir <dir> Legacy layout: write the docs bundle as files to this dir
|
|
782
|
-
instead of linking the packaged skill
|
|
783
|
-
|
|
784
|
-
Docs options:
|
|
785
|
-
<topic> classes | authoring | formats | color | scales | patterns | elements
|
|
786
|
-
(no topic: list topics with summaries)
|
|
787
|
-
-i, --input <path> design-system.json or site config (default: ./design-system.json)
|
|
788
|
-
--theme <name> Theme to render docs from, when --input is a site config
|
|
789
|
-
--all Print every topic, concatenated
|
|
790
|
-
|
|
791
|
-
Icons options:
|
|
792
|
-
--site <path> Site config carrying the "icons" block (default: ./site.json)
|
|
793
|
-
--src <dir> Source to scan for icon usage (default: ./src)
|
|
794
|
-
--sprite Also build the SVG sprite
|
|
795
|
-
-o, --out <dir> Where to write icons.svg with --sprite (default: ./dist)
|
|
796
|
-
--json Machine-readable report on stdout
|
|
797
|
-
|
|
798
|
-
Lint options:
|
|
799
|
-
-i, --input <path> design-system.json or site config (default: ./design-system.json)
|
|
800
|
-
--theme <name> Theme to judge classes against, when --input is a site config
|
|
801
|
-
-f, --format <fmt> Format you build (bricks | css | tailwind; default: bricks).
|
|
802
|
-
Responsive md-* classes are real in css/bricks, inert in tailwind.
|
|
803
|
-
-s, --src <dir> Directory to scan (default: ./src)
|
|
804
|
-
--strict Also fail on suggestions (reuse hints), not just on
|
|
805
|
-
classes that resolve to nothing
|
|
806
|
-
|
|
807
|
-
Legal options:
|
|
808
|
-
-i, --input <path> Site config: .json, or a .js/.ts module with a default
|
|
809
|
-
export (default: ./site.json)
|
|
810
|
-
-d, --doc <name> privacy | terms | cookies (repeatable; default: those
|
|
811
|
-
enabled in the config)
|
|
812
|
-
-f, --format <fmt> md | html | portable-text (default: md)
|
|
813
|
-
html suits WordPress/Bricks and plain sites;
|
|
814
|
-
portable-text suits EmDash
|
|
815
|
-
-o, --out <dir> Write files here (default: print to stdout)
|
|
816
|
-
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
817
|
-
|
|
818
|
-
Generated from your config — a starting point, not legal advice. Review before
|
|
819
|
-
publishing, and make sure the config describes what your site actually does.
|
|
820
|
-
|
|
821
|
-
Search subcommands:
|
|
822
|
-
vitops search setup [opts] Onboard site.searchConsole domains into GSC
|
|
823
|
-
vitops search notify [opts] Tell search engines about a deploy (sitemap + IndexNow)
|
|
824
|
-
|
|
825
|
-
Search setup options:
|
|
826
|
-
-i, --input <path> Site config carrying site.searchConsole (default: ./site.json)
|
|
827
|
-
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
828
|
-
--domain <name> Scope to a single site.searchConsole entry
|
|
829
|
-
--dry Print the plan and exit. Changes nothing.
|
|
830
|
-
--check Report drift and exit non-zero if any domain is not fully
|
|
831
|
-
onboarded. Mutates nothing.
|
|
832
|
-
|
|
833
|
-
For each domain it ensures the apex verification TXT in Cloudflare, verifies
|
|
834
|
-
ownership (DNS_TXT, retried with backoff while DNS propagates — a still-
|
|
835
|
-
unverified domain is reported PENDING, not failed), adds the sc-domain: property,
|
|
836
|
-
and adds any delegatedOwners to the web resource. DNS is only ever created,
|
|
837
|
-
never edited or deleted. Credentials come from the environment:
|
|
838
|
-
CLOUDFLARE_API_TOKEN (Zone:DNS:Edit), and VITOPS_GOOGLE_CLIENT_ID /
|
|
839
|
-
VITOPS_GOOGLE_CLIENT_SECRET / VITOPS_GOOGLE_REFRESH_TOKEN (a user OAuth token
|
|
840
|
-
scoped to siteverification + webmasters). Granting a Google Group Full-User
|
|
841
|
-
access has no API and is surfaced as a reminder.
|
|
842
|
-
|
|
843
|
-
Search notify options:
|
|
844
|
-
-i, --input <path> Site config carrying seo.indexing (default: ./site.json)
|
|
845
|
-
--site-env <env> Environment to notify for (default: production). An
|
|
846
|
-
environment whose robots policy says noindex is refused.
|
|
847
|
-
--sitemap <src> Sitemap URL or local path (default: from the config)
|
|
848
|
-
--urls <list> Comma-separated URLs to submit, skipping the diff
|
|
849
|
-
--all Submit every URL in the sitemap, not just what changed
|
|
850
|
-
--dry Print the plan and exit. Makes no requests.
|
|
851
|
-
--check Read-only: ask Google whether seo.indexing.priorityUrls
|
|
852
|
-
are indexed. Exits non-zero if one is not.
|
|
853
|
-
--new-key Print a fresh IndexNow key and exit
|
|
854
|
-
--write-key <dir> Write the IndexNow key file into <dir> (for stacks with
|
|
855
|
-
no Astro integration to do it — Bricks, WordPress)
|
|
856
|
-
--snapshot <path> Changed-URL state file (default: .vitops/sitemap-snapshot.json).
|
|
857
|
-
Persist it between runs (a CI cache) or every run submits
|
|
858
|
-
everything.
|
|
859
|
-
|
|
860
|
-
What notify can and cannot do: Google exposes no "request indexing" API, and its
|
|
861
|
-
sitemap ping endpoint was removed in 2023 — so it resubmits your sitemap
|
|
862
|
-
through the Search Console API and verifies the result with --check. IndexNow
|
|
863
|
-
reaches Bing, Yandex, Naver, Seznam and Yep; Google does not participate.
|
|
864
|
-
Search Console takes either credential, whichever you already have, added as an
|
|
865
|
-
owner of the property: a service account in VITOPS_GSC_SERVICE_ACCOUNT (inline
|
|
866
|
-
JSON) or GOOGLE_APPLICATION_CREDENTIALS (a path), or the same user OAuth
|
|
867
|
-
credential "search setup" uses (VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET /
|
|
868
|
-
_REFRESH_TOKEN). The service account is preferred when both are set — this runs
|
|
869
|
-
on every deploy, and it does not expire.
|
|
870
|
-
|
|
871
|
-
Ads subcommands:
|
|
872
|
-
vitops ads setup [opts] Ensure each platform's domain-verification DNS record
|
|
873
|
-
vitops ads tags [opts] Print the consent-gated pixel snippets
|
|
874
|
-
vitops ads lint [opts] Report ad properties the rest of the config can't see
|
|
875
|
-
|
|
876
|
-
Ads setup options:
|
|
877
|
-
-i, --input <path> Site config carrying site.ads (default: ./site.json)
|
|
878
|
-
--site-env <env> Environment whose A/B variant applies (default: production)
|
|
879
|
-
--provider <name> Scope to one site.ads entry (google | meta | linkedin |
|
|
880
|
-
reddit | tiktok | microsoft | pinterest | snapchat)
|
|
881
|
-
--dry Print the plan and exit. Creates nothing — but it does READ
|
|
882
|
-
live DNS first, so it needs CLOUDFLARE_API_TOKEN.
|
|
883
|
-
--check Report drift and exit non-zero if any property is not
|
|
884
|
-
linked. Mutates nothing, prompts for nothing.
|
|
885
|
-
--no-prompt Never ask for a missing token; fail with the field name
|
|
886
|
-
instead (the default when stdin is not a TTY, e.g. in CI)
|
|
887
|
-
--no-write Don't persist an answered token back into the config
|
|
888
|
-
|
|
889
|
-
Four platforms verify a domain by DNS TXT — Meta, TikTok, Pinterest, Snapchat —
|
|
890
|
-
and that record is the one thing this creates for you (in Cloudflare, via
|
|
891
|
-
CLOUDFLARE_API_TOKEN; created only, never edited or deleted). Google Ads,
|
|
892
|
-
LinkedIn, Reddit and Microsoft Ads have no domain verification at all: linking
|
|
893
|
-
there is the tag and the account ID, and the run says so rather than skipping in
|
|
894
|
-
silence. No platform Marketing API is called — Meta's needs a system-user token
|
|
895
|
-
and Google's needs an approved developer token, so the final "Verify" click stays
|
|
896
|
-
a reminder. The verification token is not a secret (it is published in DNS, which
|
|
897
|
-
is the ownership proof), so it lives in the config and is prompted for on the
|
|
898
|
-
first run.
|
|
899
|
-
|
|
900
|
-
Ads tags options:
|
|
901
|
-
-i, --input <path> Site config carrying site.ads (default: ./site.json)
|
|
902
|
-
--provider <name> Print one platform's tag
|
|
903
|
-
--strategy <s> idle | async | interaction (default: idle)
|
|
904
|
-
|
|
905
|
-
Prints each pixel as an INERT, consent-gated <script>: type="text/plain" with the
|
|
906
|
-
library URL on data-src, so an undecided visitor's page issues no third-party
|
|
907
|
-
request. For stacks with no Astro integration — Bricks, WordPress, Eleventy.
|
|
908
|
-
Paste into your template; @getvitops/core's consent runtime activates them.
|
|
909
|
-
|
|
910
|
-
Ads lint options:
|
|
911
|
-
-i, --input <path> Site config carrying site.ads (default: ./site.json)
|
|
912
|
-
|
|
913
|
-
Reports the gaps that are invisible at runtime: a click-ID parameter this
|
|
914
|
-
platform stamps that attribution doesn't capture, a pixel while site.tracking is
|
|
915
|
-
off (every conversion arrives unattributed), and a configured property with no
|
|
916
|
-
tag ID. Exits non-zero on a finding.
|
|
917
|
-
|
|
918
|
-
Media options:
|
|
919
|
-
--raw <dir> Directory of unprocessed video, walked recursively
|
|
920
|
-
(default: ./raw)
|
|
921
|
-
-o, --out <dir> Where the encoded outputs go (default: ./src/assets/processed).
|
|
922
|
-
Subdirectories under --raw are preserved.
|
|
923
|
-
--max-width <px> Cap the output width, keeping aspect ratio (default: 1920;
|
|
924
|
-
0 disables scaling)
|
|
925
|
-
--crf <n> Quality on VP9's scale, 0-63, lower is better (default: 32).
|
|
926
|
-
The MP4 fallback uses the equivalent on H.264's scale.
|
|
927
|
-
--max-bitrate <r> Optional ceiling, e.g. 2M or 800k (default: none, which is
|
|
928
|
-
constant quality rather than constrained)
|
|
929
|
-
--audio Keep the audio track (default: dropped — the common case is
|
|
930
|
-
a muted autoplay loop)
|
|
931
|
-
--poster-time <s> Timestamp the poster frame is taken from (default: 0, which
|
|
932
|
-
is often black on a clip that fades in)
|
|
933
|
-
--outputs <list> Comma-separated: webm | mp4 | poster (default: all three)
|
|
934
|
-
--manifest <path> Cache file (default: .vitops/media-manifest.json)
|
|
935
|
-
--force Re-encode everything, ignoring the cache
|
|
936
|
-
--dry Print the plan and exit. Encodes nothing.
|
|
937
|
-
|
|
938
|
-
Needs ffmpeg on PATH — it is an external tool, not an npm dependency, and this
|
|
939
|
-
command fails rather than quietly skipping. Commit the outputs and the manifest:
|
|
940
|
-
ffmpeg output is not reproducible across versions, so re-encoding in CI churns
|
|
941
|
-
the diff on every toolchain bump. Use --force when you mean to re-encode.
|
|
942
|
-
|
|
943
|
-
Common:
|
|
944
|
-
-h, --help Show this help
|
|
945
|
-
`;
|
|
946
|
-
const SEARCH_HELP = `vitops search — Google Search Console
|
|
947
|
-
|
|
948
|
-
vitops search setup [opts] Onboard site.searchConsole domains as GSC domain properties
|
|
949
|
-
vitops search notify [opts] Tell search engines a deploy happened (sitemap + IndexNow)
|
|
950
|
-
|
|
951
|
-
Run \`vitops --help\` for the full option list for each subcommand.
|
|
952
|
-
`;
|
|
953
|
-
const ADS_HELP = `vitops ads — link this site to its ad properties
|
|
954
|
-
|
|
955
|
-
vitops ads setup [opts] Ensure each platform's domain-verification DNS record
|
|
956
|
-
vitops ads tags [opts] Print the consent-gated pixel snippets
|
|
957
|
-
vitops ads lint [opts] Report ad properties the rest of the config can't see
|
|
958
|
-
|
|
959
|
-
Run \`vitops --help\` for the full option list for each subcommand.
|
|
960
|
-
`;
|
|
961
1412
|
function fail(msg) {
|
|
962
1413
|
console.error(`✖ ${msg}`);
|
|
963
1414
|
process.exit(1);
|
|
@@ -1028,7 +1479,7 @@ function cmdInit(argv) {
|
|
|
1028
1479
|
writeFileSync(out, JSON.stringify(defaultConfig(), null, 2) + "\n");
|
|
1029
1480
|
console.log(`✓ wrote ${out}`);
|
|
1030
1481
|
console.log(` Edit it, then: vitops generate --input ${values.out} --format tailwind --out src/styles`);
|
|
1031
|
-
console.log(` Editors read the "$schema" (${
|
|
1482
|
+
console.log(` Editors read the "$schema" (${SCHEMA_LOCAL_PATH}) for autocomplete + validation — the installed copy, so it matches the toolchain you build with.`);
|
|
1032
1483
|
}
|
|
1033
1484
|
/**
|
|
1034
1485
|
* Validate a config against the schema its shape says it is.
|
|
@@ -1462,15 +1913,21 @@ async function cmdLegal(argv) {
|
|
|
1462
1913
|
* On **stderr** because stdout is the document and gets piped or redirected.
|
|
1463
1914
|
*/
|
|
1464
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.";
|
|
1465
1917
|
if (!values.out) {
|
|
1466
1918
|
process.stdout.write(Object.values(files).join("\n"));
|
|
1919
|
+
console.error(REVIEW);
|
|
1467
1920
|
return;
|
|
1468
1921
|
}
|
|
1469
1922
|
const outDir = resolve(values.out);
|
|
1470
1923
|
mkdirSync(outDir, { recursive: true });
|
|
1924
|
+
const isNew = (name) => !existsSync(join(outDir, name));
|
|
1925
|
+
const added = Object.keys(files).filter(isNew);
|
|
1471
1926
|
for (const [name, content] of Object.entries(files)) writeFileSync(join(outDir, name), content);
|
|
1472
|
-
console.log(`✓ legal → ${outDir}
|
|
1473
|
-
|
|
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);
|
|
1474
1931
|
}
|
|
1475
1932
|
/**
|
|
1476
1933
|
* Adapt a `Config` to the indexing module's own option shape.
|
|
@@ -1820,7 +2277,20 @@ async function cmdSearchSetup(argv) {
|
|
|
1820
2277
|
if (domains.length === 0) fail("no domains in site.searchConsole — add domains to onboard (keyed by bare hostname)");
|
|
1821
2278
|
const cfToken = loadCloudflareToken();
|
|
1822
2279
|
const oauth = loadGoogleOAuth();
|
|
1823
|
-
if (
|
|
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)");
|
|
1824
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");
|
|
1825
2295
|
const token = await refreshTokenGrant(oauth);
|
|
1826
2296
|
const states = /* @__PURE__ */ new Map();
|
|
@@ -2054,7 +2524,7 @@ async function cmdAdsSetup(argv) {
|
|
|
2054
2524
|
const check = values.check;
|
|
2055
2525
|
const needsDns = properties.some((p) => AD_PLATFORMS[p.provider].verification.method === "dns-txt");
|
|
2056
2526
|
const cfToken = loadCloudflareToken();
|
|
2057
|
-
if (needsDns && !cfToken) fail("set CLOUDFLARE_API_TOKEN (a Zone:DNS:Edit token
|
|
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)");
|
|
2058
2528
|
/** Observe live DNS per domain. Cached by domain: several platforms share one. */
|
|
2059
2529
|
const observe = async () => {
|
|
2060
2530
|
const states = /* @__PURE__ */ new Map();
|
|
@@ -2292,7 +2762,7 @@ function cmdDocs(argv) {
|
|
|
2292
2762
|
process.stdout.write(paths.map((p) => docs[p]).join("\n"));
|
|
2293
2763
|
}
|
|
2294
2764
|
function cmdLint(argv) {
|
|
2295
|
-
const { values } = parseArgs({
|
|
2765
|
+
const { values, positionals } = parseArgs({
|
|
2296
2766
|
args: argv,
|
|
2297
2767
|
options: {
|
|
2298
2768
|
input: {
|
|
@@ -2314,15 +2784,19 @@ function cmdLint(argv) {
|
|
|
2314
2784
|
strict: {
|
|
2315
2785
|
type: "boolean",
|
|
2316
2786
|
default: false
|
|
2787
|
+
},
|
|
2788
|
+
fix: {
|
|
2789
|
+
type: "boolean",
|
|
2790
|
+
default: false
|
|
2317
2791
|
}
|
|
2318
2792
|
},
|
|
2319
|
-
allowPositionals:
|
|
2793
|
+
allowPositionals: true
|
|
2320
2794
|
});
|
|
2321
2795
|
const format = values.format;
|
|
2322
2796
|
if (!LINT_FORMATS.has(format)) fail(`unknown format "${format}" (expected: bricks | css | tailwind)`);
|
|
2323
2797
|
const ds = loadDesignSystem(resolve(values.input), values.theme);
|
|
2324
2798
|
const srcDir = resolve(values.src);
|
|
2325
|
-
if (!existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
|
|
2799
|
+
if (!positionals.length && !existsSync(srcDir)) fail(`source directory not found: ${srcDir}`);
|
|
2326
2800
|
const palette = expandPalette(ds.colors.palette);
|
|
2327
2801
|
const roleClasses = roleColorUtilities(Object.entries(ds.colors.roles).map(([role, spec]) => functionalRole(role, roleHue(spec), palette[roleHue(spec)], roleKind(spec))), ds.colors.utilities ?? [
|
|
2328
2802
|
"bg",
|
|
@@ -2330,11 +2804,56 @@ function cmdLint(argv) {
|
|
|
2330
2804
|
"icon",
|
|
2331
2805
|
"border"
|
|
2332
2806
|
]).map((u) => u.cls);
|
|
2333
|
-
const
|
|
2334
|
-
|
|
2335
|
-
|
|
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;
|
|
2336
2855
|
if (!findings.length) {
|
|
2337
|
-
console.log(`✓ no unresolvable framework classes in ${
|
|
2856
|
+
console.log(`✓ no unresolvable framework classes or missed primitives in ${scope} (${scanned} file${scanned === 1 ? "" : "s"} checked)`);
|
|
2338
2857
|
return;
|
|
2339
2858
|
}
|
|
2340
2859
|
const errors = findings.filter((f) => f.severity === "error");
|
|
@@ -2349,9 +2868,35 @@ function cmdLint(argv) {
|
|
|
2349
2868
|
if (errors.length) parts.push(count(errors.length, "unresolvable class", "unresolvable classes"));
|
|
2350
2869
|
if (suggestions.length) parts.push(count(suggestions.length, "suggestion", "suggestions"));
|
|
2351
2870
|
const failing = errors.length > 0 || values.strict === true && suggestions.length > 0;
|
|
2352
|
-
console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${
|
|
2871
|
+
console.error(`\n${failing ? "✖" : "!"} ${parts.join(", ")} in ${scanned} file${scanned === 1 ? "" : "s"}` + (suggestions.length && !values.strict ? " (suggestions do not fail; use --strict)" : ""));
|
|
2353
2872
|
if (failing) process.exit(1);
|
|
2354
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
|
+
}
|
|
2355
2900
|
function cmdAgents(argv) {
|
|
2356
2901
|
const { values } = parseArgs({
|
|
2357
2902
|
args: argv,
|
|
@@ -2371,7 +2916,15 @@ function cmdAgents(argv) {
|
|
|
2371
2916
|
},
|
|
2372
2917
|
allowPositionals: false
|
|
2373
2918
|
});
|
|
2374
|
-
const
|
|
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
|
+
}
|
|
2375
2928
|
let ds = null;
|
|
2376
2929
|
if (existsSync(resolve(inputRel))) ds = loadDesignSystem(resolve(inputRel), values.theme);
|
|
2377
2930
|
else console.warn(` ⚠ ${inputRel} not found — run \`vitops init\` to scaffold one`);
|
|
@@ -2419,6 +2972,7 @@ function cmdAgents(argv) {
|
|
|
2419
2972
|
...Object.entries(TOPICS).map(([name, t]) => `- \`vitops docs ${name}\` — ${t.summary}`)
|
|
2420
2973
|
];
|
|
2421
2974
|
}
|
|
2975
|
+
const inputFlag = inputRel === "design-system.json" ? "" : ` --input ${inputRel}`;
|
|
2422
2976
|
const block = [
|
|
2423
2977
|
AGENTS_START,
|
|
2424
2978
|
"## Vitops design system",
|
|
@@ -2426,11 +2980,11 @@ function cmdAgents(argv) {
|
|
|
2426
2980
|
`Styled with the Vitops design system (\`@getvitops/*\`); tokens live in \`${inputRel}\`.`,
|
|
2427
2981
|
"Generate output with the CLI:",
|
|
2428
2982
|
"",
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
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\``,
|
|
2434
2988
|
"",
|
|
2435
2989
|
...pointer,
|
|
2436
2990
|
"",
|
|
@@ -2465,6 +3019,10 @@ async function main() {
|
|
|
2465
3019
|
console.log(HELP);
|
|
2466
3020
|
return;
|
|
2467
3021
|
}
|
|
3022
|
+
if (wantsHelp(rest)) {
|
|
3023
|
+
console.log(helpFor(command, rest));
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
2468
3026
|
switch (command) {
|
|
2469
3027
|
case "generate": return cmdGenerate(rest);
|
|
2470
3028
|
case "init": return cmdInit(rest);
|
|
@@ -2478,7 +3036,7 @@ async function main() {
|
|
|
2478
3036
|
case "search": return cmdSearch(rest);
|
|
2479
3037
|
case "ads": return cmdAds(rest);
|
|
2480
3038
|
case "media": return cmdMedia(rest);
|
|
2481
|
-
default: fail(`unknown command "${command}" (expected:
|
|
3039
|
+
default: fail(`unknown command "${command}" (expected: ${COMMANDS.join(" | ")}). Try: vitops --help`);
|
|
2482
3040
|
}
|
|
2483
3041
|
}
|
|
2484
3042
|
main().catch((err) => fail(err.message));
|