@goflag/cli 0.2.5 → 0.2.8
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/README.md +99 -14
- package/dist/{chunk-5FO2Q3II.js → chunk-SKX7UCGC.js} +318 -25
- package/dist/chunk-SKX7UCGC.js.map +1 -0
- package/dist/cli.js +1018 -217
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +64 -4
- package/dist/index.js +1 -1
- package/flags.json +302 -0
- package/package.json +7 -2
- package/rules.json +517 -0
- package/dist/chunk-5FO2Q3II.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -4,12 +4,15 @@ import {
|
|
|
4
4
|
Logger,
|
|
5
5
|
PROFILES,
|
|
6
6
|
PROFILE_NAMES,
|
|
7
|
+
PROSE_RULES,
|
|
8
|
+
RULES,
|
|
9
|
+
SITE_RULES,
|
|
7
10
|
exitCode,
|
|
8
11
|
renderSummaryTerminal,
|
|
9
12
|
renderTerminal,
|
|
10
13
|
runAudit,
|
|
11
14
|
summarize
|
|
12
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-SKX7UCGC.js";
|
|
13
16
|
|
|
14
17
|
// src/report/diff.ts
|
|
15
18
|
function collectFindings(report) {
|
|
@@ -284,83 +287,505 @@ Raise --start-timeout, check the command listens on that URL, or point --start-c
|
|
|
284
287
|
}
|
|
285
288
|
}
|
|
286
289
|
|
|
287
|
-
// src/
|
|
288
|
-
var PROFILE_HELP = PROFILE_NAMES.map(
|
|
289
|
-
(name) => ` ${name} \u2014 ${PROFILES[name].description}`
|
|
290
|
-
).join("\n");
|
|
291
|
-
var HELP = `goflag \u2014 audit a site for broken links, missing translations, and SEO metadata
|
|
292
|
-
|
|
293
|
-
Usage:
|
|
294
|
-
goflag <url> [options]
|
|
295
|
-
|
|
296
|
-
Options:
|
|
297
|
-
--json Print the JSON report to stdout (nothing else).
|
|
298
|
-
--summary, -s Roll findings up (dedup by link/rule/code). Pairs
|
|
299
|
-
with --json for a compact, agent-friendly payload.
|
|
300
|
-
--report <file> Write the (full) JSON report to <file>.
|
|
301
|
-
--depth <n> Crawl depth (0 = entry page only). Default: 2.
|
|
302
|
-
--max-pages <n> Hard cap on pages crawled. Default: 200.
|
|
303
|
-
--coverage <mode> How to choose which pages to audit.
|
|
304
|
-
"structural" (default when a sitemap is found) keeps
|
|
305
|
-
every standalone page and samples families of pages
|
|
306
|
-
built from one template. "all" audits everything the
|
|
307
|
-
sitemap lists, up to --max-pages.
|
|
308
|
-
--include <glob> Only crawl paths matching <glob> (repeatable).
|
|
309
|
-
--exclude <glob> Skip paths matching <glob> (repeatable).
|
|
310
|
-
--locales <list> Comma-separated locales the site serves, e.g.
|
|
311
|
-
"fr,en,pt-br". Authoritative: overrides what the
|
|
312
|
-
sitemap and crawl suggest, and makes a locale the
|
|
313
|
-
site does not serve yet show up as missing.
|
|
314
|
-
--ignore-holes <glob> Route (locale-free) that is deliberately not
|
|
315
|
-
translated everywhere, so its gaps are not reported
|
|
316
|
-
as missing translations (repeatable).
|
|
317
|
-
--no-sitemap Do not discover the sitemap; crawl from <url> only.
|
|
318
|
-
Discovery is on by default because link-only crawling
|
|
319
|
-
cannot find locales a site never links to.
|
|
320
|
-
--profile <name> Policy overlay on the rule set \u2014 it changes how
|
|
321
|
-
loudly a rule fires, never what it observes:
|
|
322
|
-
${PROFILE_HELP}
|
|
323
|
-
--conformance Report EVERY rule's status on every page (pass /
|
|
324
|
-
fail / warn / na), not just the violations. Answers
|
|
325
|
-
"where do we stand against the catalog?"; pairs with
|
|
326
|
-
--json.
|
|
327
|
-
--advisories Attach the prose rules \u2014 the judgment calls goflag
|
|
328
|
-
refuses to fake \u2014 each with the observed facts an
|
|
329
|
-
agent needs to judge it. Never affects the gate.
|
|
330
|
-
--fail-on <level> Exit 1 at or above this severity: warning (default),
|
|
331
|
-
error, or never.
|
|
332
|
-
--regressions-only Weaken the gate: fail only on findings that are NEW
|
|
333
|
-
relative to --baseline. Known findings stop blocking
|
|
334
|
-
the build \u2014 a green run no longer means a clean site.
|
|
335
|
-
Requires --baseline.
|
|
336
|
-
--baseline <file> Stored report to compare against. Use with
|
|
337
|
-
--regressions-only.
|
|
338
|
-
--update-baseline Write this run to --baseline and exit 0, instead of
|
|
339
|
-
judging against it. Use it to capture a baseline, or
|
|
340
|
-
to accept findings you have decided to live with \u2014
|
|
341
|
-
it says what it accepted.
|
|
342
|
-
--max-debt <n> Fail when the site carries more than <n> findings in
|
|
343
|
-
total, whether new or known. Lower it as you fix, to
|
|
344
|
-
stop a baseline from fossilising.
|
|
345
|
-
--start <cmd> Boot <cmd>, wait for <url> to answer, audit, then
|
|
346
|
-
stop it. Use to gate a merge on the built app before
|
|
347
|
-
it ships.
|
|
348
|
-
--start-cwd <dir> Directory to run --start in. Defaults to the current
|
|
349
|
-
one; set it when auditing a monorepo package from the
|
|
350
|
-
repository root.
|
|
351
|
-
--start-timeout <ms> How long to wait for --start to answer. Default: 60000.
|
|
352
|
-
--no-external Do not probe off-origin (external) links.
|
|
353
|
-
--static Static HTML only; never launch headless Chromium.
|
|
354
|
-
--allow-insecure-tls Accept self-signed / invalid TLS (localhost, tunnels).
|
|
355
|
-
--timeout <ms> Per-request timeout in ms. Default: 8000.
|
|
356
|
-
--verbose, -V Log every page as it is analyzed.
|
|
357
|
-
--quiet, -q Suppress the live progress output.
|
|
358
|
-
--no-color Disable coloured output.
|
|
359
|
-
-h, --help Show this help.
|
|
360
|
-
-v, --version Show the version.
|
|
361
|
-
|
|
362
|
-
Exit codes: 0 clean, 1 findings found, 2 fatal error.`;
|
|
290
|
+
// src/lib/flags/registry.ts
|
|
363
291
|
var FAIL_ON_LEVELS = ["warning", "error", "never"];
|
|
292
|
+
function toInt(value, flag) {
|
|
293
|
+
const n = Number(value);
|
|
294
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`${flag} expects a non-negative integer`);
|
|
295
|
+
return n;
|
|
296
|
+
}
|
|
297
|
+
var FLAGS = [
|
|
298
|
+
{
|
|
299
|
+
name: "--json",
|
|
300
|
+
label: "--json",
|
|
301
|
+
group: "output",
|
|
302
|
+
help: ["Print the JSON report to stdout (nothing else)."],
|
|
303
|
+
takesValue: false,
|
|
304
|
+
apply: ({ parsed }) => {
|
|
305
|
+
parsed.json = true;
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "--summary",
|
|
310
|
+
short: "-s",
|
|
311
|
+
label: "--summary, -s",
|
|
312
|
+
group: "output",
|
|
313
|
+
help: [
|
|
314
|
+
"Roll findings up (dedup by link/rule/code). Pairs",
|
|
315
|
+
"with --json for a compact, agent-friendly payload."
|
|
316
|
+
],
|
|
317
|
+
takesValue: false,
|
|
318
|
+
apply: ({ parsed }) => {
|
|
319
|
+
parsed.summary = true;
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "--report",
|
|
324
|
+
arg: "<file>",
|
|
325
|
+
label: "--report <file>",
|
|
326
|
+
group: "output",
|
|
327
|
+
help: ["Write the (full) JSON report to <file>."],
|
|
328
|
+
takesValue: true,
|
|
329
|
+
apply: ({ parsed, value }) => {
|
|
330
|
+
parsed.report = value;
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: "--depth",
|
|
335
|
+
arg: "<n>",
|
|
336
|
+
label: "--depth <n>",
|
|
337
|
+
group: "crawl",
|
|
338
|
+
default: "2",
|
|
339
|
+
help: [
|
|
340
|
+
"How far to follow links out of each page (0 = follow",
|
|
341
|
+
"none). Sitemap URLs are seeded regardless, so --depth 0",
|
|
342
|
+
'alone is not "entry page only" \u2014 add --no-sitemap for',
|
|
343
|
+
"that. Default: 2."
|
|
344
|
+
],
|
|
345
|
+
takesValue: true,
|
|
346
|
+
apply: ({ parsed, value, flag }) => {
|
|
347
|
+
parsed.options.depth = toInt(value, flag);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: "--max-pages",
|
|
352
|
+
arg: "<n>",
|
|
353
|
+
label: "--max-pages <n>",
|
|
354
|
+
group: "crawl",
|
|
355
|
+
default: "200",
|
|
356
|
+
help: [
|
|
357
|
+
"Page budget for the crawl. A hard cap under",
|
|
358
|
+
"--coverage all; under structural coverage the selection",
|
|
359
|
+
"wins and the budget is max(<n>, selected + 5).",
|
|
360
|
+
"Default: 200."
|
|
361
|
+
],
|
|
362
|
+
takesValue: true,
|
|
363
|
+
apply: ({ parsed, value, flag }) => {
|
|
364
|
+
parsed.options.maxPages = toInt(value, flag);
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "--coverage",
|
|
369
|
+
arg: "<mode>",
|
|
370
|
+
label: "--coverage <mode>",
|
|
371
|
+
group: "crawl",
|
|
372
|
+
default: "structural when a sitemap is found, all otherwise",
|
|
373
|
+
help: [
|
|
374
|
+
"How to choose which pages to audit.",
|
|
375
|
+
'"structural" (default when a sitemap is found) keeps',
|
|
376
|
+
"every standalone page and samples families of pages",
|
|
377
|
+
'built from one template. "all" audits everything the',
|
|
378
|
+
"sitemap lists, up to --max-pages."
|
|
379
|
+
],
|
|
380
|
+
takesValue: true,
|
|
381
|
+
apply: ({ parsed, value, flag }) => {
|
|
382
|
+
if (value !== "structural" && value !== "all") {
|
|
383
|
+
throw new Error(`${flag} takes "structural" or "all", not ${JSON.stringify(value)}`);
|
|
384
|
+
}
|
|
385
|
+
parsed.options.coverage = value;
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "--include",
|
|
390
|
+
arg: "<glob>",
|
|
391
|
+
label: "--include <glob>",
|
|
392
|
+
group: "crawl",
|
|
393
|
+
help: ["Only crawl paths matching <glob> (repeatable)."],
|
|
394
|
+
takesValue: true,
|
|
395
|
+
apply: ({ parsed, value }) => {
|
|
396
|
+
parsed.options.include.push(value);
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "--exclude",
|
|
401
|
+
arg: "<glob>",
|
|
402
|
+
label: "--exclude <glob>",
|
|
403
|
+
group: "crawl",
|
|
404
|
+
help: ["Skip paths matching <glob> (repeatable)."],
|
|
405
|
+
takesValue: true,
|
|
406
|
+
apply: ({ parsed, value }) => {
|
|
407
|
+
parsed.options.exclude.push(value);
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
name: "--locales",
|
|
412
|
+
arg: "<list>",
|
|
413
|
+
label: "--locales <list>",
|
|
414
|
+
group: "i18n",
|
|
415
|
+
help: [
|
|
416
|
+
"Comma-separated locales the site serves, e.g.",
|
|
417
|
+
'"fr,en,pt-br". Unioned with what the sitemap shows,',
|
|
418
|
+
"never substituted for it, and the only way to make a",
|
|
419
|
+
"locale the site does not serve yet show up as missing.",
|
|
420
|
+
"Also folds /en/\u2026 and /fr/\u2026 into one route family for",
|
|
421
|
+
"structural coverage, so pass it on any locale-prefixed",
|
|
422
|
+
"site."
|
|
423
|
+
],
|
|
424
|
+
takesValue: true,
|
|
425
|
+
apply: ({ parsed, value, flag }) => {
|
|
426
|
+
const tags = value.split(",").map((t) => t.trim()).filter(Boolean);
|
|
427
|
+
if (tags.length === 0) throw new Error(`${flag} expects at least one locale`);
|
|
428
|
+
parsed.options.locales = [...parsed.options.locales ?? [], ...tags];
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: "--ignore-holes",
|
|
433
|
+
arg: "<glob>",
|
|
434
|
+
label: "--ignore-holes <glob>",
|
|
435
|
+
group: "i18n",
|
|
436
|
+
help: [
|
|
437
|
+
"Route (locale-free) that is deliberately not",
|
|
438
|
+
"translated everywhere, so its gaps are not reported",
|
|
439
|
+
"as missing translations (repeatable)."
|
|
440
|
+
],
|
|
441
|
+
takesValue: true,
|
|
442
|
+
apply: ({ parsed, value }) => {
|
|
443
|
+
parsed.options.ignoreHoles = [...parsed.options.ignoreHoles ?? [], value];
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "--no-sitemap",
|
|
448
|
+
label: "--no-sitemap",
|
|
449
|
+
group: "crawl",
|
|
450
|
+
help: [
|
|
451
|
+
"Do not discover the sitemap; crawl from <url> only.",
|
|
452
|
+
"Discovery is on by default because link-only crawling",
|
|
453
|
+
"cannot find locales a site never links to."
|
|
454
|
+
],
|
|
455
|
+
takesValue: false,
|
|
456
|
+
apply: ({ parsed }) => {
|
|
457
|
+
parsed.options.noSitemap = true;
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: "--profile",
|
|
462
|
+
arg: "<name>",
|
|
463
|
+
label: "--profile <name>",
|
|
464
|
+
group: "gate",
|
|
465
|
+
default: "default",
|
|
466
|
+
help: [
|
|
467
|
+
"Policy overlay on the rule set \u2014 it changes how",
|
|
468
|
+
"loudly a rule fires, never what it observes:"
|
|
469
|
+
],
|
|
470
|
+
dynamicTail: () => PROFILE_NAMES.map((n) => `${n} \u2014 ${PROFILES[n].description}`),
|
|
471
|
+
takesValue: true,
|
|
472
|
+
apply: ({ parsed, value, flag }) => {
|
|
473
|
+
if (!PROFILE_NAMES.includes(value)) {
|
|
474
|
+
throw new Error(`${flag} expects one of: ${PROFILE_NAMES.join(", ")}`);
|
|
475
|
+
}
|
|
476
|
+
parsed.options.profile = value;
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
name: "--conformance",
|
|
481
|
+
label: "--conformance",
|
|
482
|
+
group: "output",
|
|
483
|
+
help: [
|
|
484
|
+
"Report EVERY rule's status on every page (pass /",
|
|
485
|
+
"fail / warn / na), not just the violations. Answers",
|
|
486
|
+
'"where do we stand against the catalog?"; pairs with',
|
|
487
|
+
"--json."
|
|
488
|
+
],
|
|
489
|
+
takesValue: false,
|
|
490
|
+
apply: ({ parsed }) => {
|
|
491
|
+
parsed.options.conformance = true;
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: "--advisories",
|
|
496
|
+
label: "--advisories",
|
|
497
|
+
group: "output",
|
|
498
|
+
help: [
|
|
499
|
+
"Attach the prose rules \u2014 the judgment calls goflag",
|
|
500
|
+
"refuses to fake \u2014 each with the observed facts an",
|
|
501
|
+
"agent needs to judge it. Never affects the gate."
|
|
502
|
+
],
|
|
503
|
+
takesValue: false,
|
|
504
|
+
apply: ({ parsed }) => {
|
|
505
|
+
parsed.options.advisories = true;
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
name: "--fail-on",
|
|
510
|
+
arg: "<level>",
|
|
511
|
+
label: "--fail-on <level>",
|
|
512
|
+
group: "gate",
|
|
513
|
+
default: "warning",
|
|
514
|
+
help: ["Exit 1 at or above this severity: warning (default),", "error, or never."],
|
|
515
|
+
takesValue: true,
|
|
516
|
+
apply: ({ parsed, value, flag }) => {
|
|
517
|
+
const level = value;
|
|
518
|
+
if (!FAIL_ON_LEVELS.includes(level)) {
|
|
519
|
+
throw new Error(`${flag} expects one of: ${FAIL_ON_LEVELS.join(", ")}`);
|
|
520
|
+
}
|
|
521
|
+
parsed.failOn = level;
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
name: "--regressions-only",
|
|
526
|
+
label: "--regressions-only",
|
|
527
|
+
group: "gate",
|
|
528
|
+
requires: "--baseline",
|
|
529
|
+
help: [
|
|
530
|
+
"Weaken the gate: fail only on findings that are NEW",
|
|
531
|
+
"relative to --baseline. Known findings stop blocking",
|
|
532
|
+
"the build \u2014 a green run no longer means a clean site.",
|
|
533
|
+
"Requires --baseline."
|
|
534
|
+
],
|
|
535
|
+
takesValue: false,
|
|
536
|
+
apply: ({ parsed }) => {
|
|
537
|
+
parsed.regressionsOnly = true;
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: "--baseline",
|
|
542
|
+
arg: "<file>",
|
|
543
|
+
label: "--baseline <file>",
|
|
544
|
+
group: "gate",
|
|
545
|
+
help: ["Stored report to compare against. Use with", "--regressions-only."],
|
|
546
|
+
takesValue: true,
|
|
547
|
+
apply: ({ parsed, value }) => {
|
|
548
|
+
parsed.baseline = value;
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: "--update-baseline",
|
|
553
|
+
label: "--update-baseline",
|
|
554
|
+
group: "gate",
|
|
555
|
+
requires: "--baseline",
|
|
556
|
+
help: [
|
|
557
|
+
"Write this run to --baseline and exit 0, instead of",
|
|
558
|
+
"judging against it. Use it to capture a baseline, or",
|
|
559
|
+
"to accept findings you have decided to live with \u2014",
|
|
560
|
+
"it says what it accepted."
|
|
561
|
+
],
|
|
562
|
+
takesValue: false,
|
|
563
|
+
apply: ({ parsed }) => {
|
|
564
|
+
parsed.updateBaseline = true;
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: "--max-debt",
|
|
569
|
+
arg: "<n>",
|
|
570
|
+
label: "--max-debt <n>",
|
|
571
|
+
group: "gate",
|
|
572
|
+
help: [
|
|
573
|
+
"Fail when the site carries more than <n> findings in",
|
|
574
|
+
"total, whether new or known. Lower it as you fix, to",
|
|
575
|
+
"stop a baseline from fossilising."
|
|
576
|
+
],
|
|
577
|
+
takesValue: true,
|
|
578
|
+
apply: ({ parsed, value, flag }) => {
|
|
579
|
+
parsed.maxDebt = toInt(value, flag);
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
name: "--start",
|
|
584
|
+
arg: "<cmd>",
|
|
585
|
+
label: "--start <cmd>",
|
|
586
|
+
group: "boot",
|
|
587
|
+
help: [
|
|
588
|
+
"Boot <cmd>, wait for <url> to answer, audit, then",
|
|
589
|
+
"stop it. Use to gate a merge on the built app before",
|
|
590
|
+
"it ships."
|
|
591
|
+
],
|
|
592
|
+
takesValue: true,
|
|
593
|
+
apply: ({ parsed, value }) => {
|
|
594
|
+
parsed.start = value;
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "--start-cwd",
|
|
599
|
+
arg: "<dir>",
|
|
600
|
+
label: "--start-cwd <dir>",
|
|
601
|
+
group: "boot",
|
|
602
|
+
default: "the current directory",
|
|
603
|
+
help: [
|
|
604
|
+
"Directory to run --start in. Defaults to the current",
|
|
605
|
+
"one; set it when auditing a monorepo package from the",
|
|
606
|
+
"repository root."
|
|
607
|
+
],
|
|
608
|
+
takesValue: true,
|
|
609
|
+
apply: ({ parsed, value }) => {
|
|
610
|
+
parsed.startCwd = value;
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: "--start-timeout",
|
|
615
|
+
arg: "<ms>",
|
|
616
|
+
label: "--start-timeout <ms>",
|
|
617
|
+
group: "boot",
|
|
618
|
+
default: "60000",
|
|
619
|
+
help: ["How long to wait for --start to answer. Default: 60000."],
|
|
620
|
+
takesValue: true,
|
|
621
|
+
apply: ({ parsed, value, flag }) => {
|
|
622
|
+
parsed.startTimeoutMs = toInt(value, flag);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: "--no-external",
|
|
627
|
+
label: "--no-external",
|
|
628
|
+
group: "fetch",
|
|
629
|
+
help: ["Do not probe off-origin (external) links."],
|
|
630
|
+
takesValue: false,
|
|
631
|
+
apply: ({ parsed }) => {
|
|
632
|
+
parsed.options.checkExternal = false;
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
name: "--static",
|
|
637
|
+
label: "--static",
|
|
638
|
+
group: "fetch",
|
|
639
|
+
help: ["Static HTML only; never launch headless Chromium."],
|
|
640
|
+
takesValue: false,
|
|
641
|
+
apply: ({ parsed }) => {
|
|
642
|
+
parsed.options.static = true;
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
name: "--allow-insecure-tls",
|
|
647
|
+
label: "--allow-insecure-tls",
|
|
648
|
+
group: "fetch",
|
|
649
|
+
help: ["Accept self-signed / invalid TLS (localhost, tunnels)."],
|
|
650
|
+
takesValue: false,
|
|
651
|
+
apply: ({ parsed }) => {
|
|
652
|
+
parsed.options.allowInsecureTls = true;
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: "--timeout",
|
|
657
|
+
arg: "<ms>",
|
|
658
|
+
label: "--timeout <ms>",
|
|
659
|
+
group: "fetch",
|
|
660
|
+
default: "8000 for link probes, 15000 for page fetches",
|
|
661
|
+
help: [
|
|
662
|
+
"Per-request timeout in ms, for page fetches and link",
|
|
663
|
+
"probes alike. Unset, the defaults differ: 8000 for link",
|
|
664
|
+
"probes, 15000 for page fetches."
|
|
665
|
+
],
|
|
666
|
+
takesValue: true,
|
|
667
|
+
apply: ({ parsed, value, flag }) => {
|
|
668
|
+
parsed.options.timeoutMs = toInt(value, flag);
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
name: "--verbose",
|
|
673
|
+
short: "-V",
|
|
674
|
+
label: "--verbose, -V",
|
|
675
|
+
group: "terminal",
|
|
676
|
+
help: ["Log every page as it is analyzed."],
|
|
677
|
+
takesValue: false,
|
|
678
|
+
apply: ({ parsed }) => {
|
|
679
|
+
parsed.logMode = "verbose";
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: "--quiet",
|
|
684
|
+
short: "-q",
|
|
685
|
+
label: "--quiet, -q",
|
|
686
|
+
group: "terminal",
|
|
687
|
+
help: ["Suppress the live progress output."],
|
|
688
|
+
takesValue: false,
|
|
689
|
+
apply: ({ parsed }) => {
|
|
690
|
+
parsed.logMode = "quiet";
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "--no-color",
|
|
695
|
+
label: "--no-color",
|
|
696
|
+
group: "terminal",
|
|
697
|
+
default: "colour when stdout is a TTY and NO_COLOR is unset",
|
|
698
|
+
help: ["Disable coloured output."],
|
|
699
|
+
takesValue: false,
|
|
700
|
+
apply: ({ parsed }) => {
|
|
701
|
+
parsed.color = false;
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: "--help",
|
|
706
|
+
short: "-h",
|
|
707
|
+
label: "-h, --help",
|
|
708
|
+
group: "terminal",
|
|
709
|
+
help: ["Show this help."],
|
|
710
|
+
takesValue: false,
|
|
711
|
+
apply: ({ parsed }) => {
|
|
712
|
+
parsed.help = true;
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: "--version",
|
|
717
|
+
short: "-v",
|
|
718
|
+
label: "-v, --version",
|
|
719
|
+
group: "terminal",
|
|
720
|
+
help: ["Show the version."],
|
|
721
|
+
takesValue: false,
|
|
722
|
+
apply: ({ parsed }) => {
|
|
723
|
+
parsed.version = true;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
];
|
|
727
|
+
var FLAGS_BY_TOKEN = new Map(
|
|
728
|
+
FLAGS.flatMap(
|
|
729
|
+
(spec) => spec.short ? [
|
|
730
|
+
[spec.name, spec],
|
|
731
|
+
[spec.short, spec]
|
|
732
|
+
] : [[spec.name, spec]]
|
|
733
|
+
)
|
|
734
|
+
);
|
|
735
|
+
var EXIT_CODES = [
|
|
736
|
+
{ code: 0, label: "clean" },
|
|
737
|
+
{ code: 1, label: "findings found" },
|
|
738
|
+
{ code: 2, label: "fatal error" }
|
|
739
|
+
];
|
|
740
|
+
var COMMANDS = [
|
|
741
|
+
{
|
|
742
|
+
name: "rules",
|
|
743
|
+
help: [
|
|
744
|
+
"Print the rule catalogue as JSON and exit. No crawl",
|
|
745
|
+
"and no network: every rule with its severity, its",
|
|
746
|
+
"rigor and the documents it cites."
|
|
747
|
+
]
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
name: "flags",
|
|
751
|
+
help: [
|
|
752
|
+
"Print this flag reference as JSON and exit. The same",
|
|
753
|
+
"table this help is rendered from, so the two cannot",
|
|
754
|
+
"disagree."
|
|
755
|
+
]
|
|
756
|
+
}
|
|
757
|
+
];
|
|
758
|
+
var DESCRIPTION_COLUMN = 25;
|
|
759
|
+
function block(label, lines) {
|
|
760
|
+
const pad = " ".repeat(DESCRIPTION_COLUMN);
|
|
761
|
+
return lines.map(
|
|
762
|
+
(line, i) => i === 0 ? ` ${label.padEnd(DESCRIPTION_COLUMN - 2)}${line}` : `${pad}${line}`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
function renderHelp() {
|
|
766
|
+
const lines = [
|
|
767
|
+
"goflag \u2014 audit a site for broken links, missing translations, and SEO metadata",
|
|
768
|
+
"",
|
|
769
|
+
"Usage:",
|
|
770
|
+
" goflag <url> [options]"
|
|
771
|
+
];
|
|
772
|
+
for (const command of COMMANDS) {
|
|
773
|
+
const pad = " ".repeat(28);
|
|
774
|
+
command.help.forEach((line, i) => {
|
|
775
|
+
lines.push(i === 0 ? ` goflag ${command.name.padEnd(19)}${line}` : `${pad}${line}`);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
lines.push("", "Options:");
|
|
779
|
+
for (const spec of FLAGS) {
|
|
780
|
+
lines.push(...block(spec.label, [...spec.help, ...spec.dynamicTail?.() ?? []]));
|
|
781
|
+
}
|
|
782
|
+
lines.push("", `Exit codes: ${EXIT_CODES.map((e) => `${e.code} ${e.label}`).join(", ")}.`);
|
|
783
|
+
return lines.join("\n");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/cli-args.ts
|
|
787
|
+
var HELP = renderHelp();
|
|
788
|
+
var COMMAND_NAMES = COMMANDS.map((c) => c.name);
|
|
364
789
|
function parseArgs(argv) {
|
|
365
790
|
const parsed = {
|
|
366
791
|
json: false,
|
|
@@ -382,145 +807,20 @@ function parseArgs(argv) {
|
|
|
382
807
|
};
|
|
383
808
|
for (let i = 0; i < argv.length; i++) {
|
|
384
809
|
const arg = argv[i];
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
break;
|
|
390
|
-
case "-v":
|
|
391
|
-
case "--version":
|
|
392
|
-
parsed.version = true;
|
|
393
|
-
break;
|
|
394
|
-
case "--json":
|
|
395
|
-
parsed.json = true;
|
|
396
|
-
break;
|
|
397
|
-
case "-s":
|
|
398
|
-
case "--summary":
|
|
399
|
-
parsed.summary = true;
|
|
400
|
-
break;
|
|
401
|
-
case "--no-color":
|
|
402
|
-
parsed.color = false;
|
|
403
|
-
break;
|
|
404
|
-
case "-V":
|
|
405
|
-
case "--verbose":
|
|
406
|
-
parsed.logMode = "verbose";
|
|
407
|
-
break;
|
|
408
|
-
case "-q":
|
|
409
|
-
case "--quiet":
|
|
410
|
-
parsed.logMode = "quiet";
|
|
411
|
-
break;
|
|
412
|
-
case "--static":
|
|
413
|
-
parsed.options.static = true;
|
|
414
|
-
break;
|
|
415
|
-
case "--allow-insecure-tls":
|
|
416
|
-
parsed.options.allowInsecureTls = true;
|
|
417
|
-
break;
|
|
418
|
-
case "--no-external":
|
|
419
|
-
parsed.options.checkExternal = false;
|
|
420
|
-
break;
|
|
421
|
-
case "--no-sitemap":
|
|
422
|
-
parsed.options.noSitemap = true;
|
|
423
|
-
break;
|
|
424
|
-
case "--conformance":
|
|
425
|
-
parsed.options.conformance = true;
|
|
426
|
-
break;
|
|
427
|
-
case "--advisories":
|
|
428
|
-
parsed.options.advisories = true;
|
|
429
|
-
break;
|
|
430
|
-
case "--ignore-holes":
|
|
431
|
-
parsed.options.ignoreHoles = [...parsed.options.ignoreHoles ?? [], next(i, arg)];
|
|
432
|
-
i++;
|
|
433
|
-
break;
|
|
434
|
-
case "--locales": {
|
|
435
|
-
const value = next(i, arg);
|
|
436
|
-
const tags = value.split(",").map((t) => t.trim()).filter(Boolean);
|
|
437
|
-
if (tags.length === 0) throw new Error(`${arg} expects at least one locale`);
|
|
438
|
-
parsed.options.locales = [...parsed.options.locales ?? [], ...tags];
|
|
439
|
-
i++;
|
|
440
|
-
break;
|
|
441
|
-
}
|
|
442
|
-
case "--profile": {
|
|
443
|
-
const value = next(i, arg);
|
|
444
|
-
if (!PROFILE_NAMES.includes(value)) {
|
|
445
|
-
throw new Error(`${arg} expects one of: ${PROFILE_NAMES.join(", ")}`);
|
|
446
|
-
}
|
|
447
|
-
parsed.options.profile = value;
|
|
448
|
-
i++;
|
|
449
|
-
break;
|
|
450
|
-
}
|
|
451
|
-
case "--fail-on": {
|
|
452
|
-
const value = next(i, arg);
|
|
453
|
-
if (!FAIL_ON_LEVELS.includes(value)) {
|
|
454
|
-
throw new Error(`${arg} expects one of: ${FAIL_ON_LEVELS.join(", ")}`);
|
|
455
|
-
}
|
|
456
|
-
parsed.failOn = value;
|
|
457
|
-
i++;
|
|
458
|
-
break;
|
|
459
|
-
}
|
|
460
|
-
case "--start":
|
|
461
|
-
parsed.start = next(i, arg);
|
|
462
|
-
i++;
|
|
463
|
-
break;
|
|
464
|
-
case "--start-timeout":
|
|
465
|
-
parsed.startTimeoutMs = toInt(next(i, arg), arg);
|
|
466
|
-
i++;
|
|
467
|
-
break;
|
|
468
|
-
case "--start-cwd":
|
|
469
|
-
parsed.startCwd = next(i, arg);
|
|
470
|
-
i++;
|
|
471
|
-
break;
|
|
472
|
-
case "--report":
|
|
473
|
-
parsed.report = next(i, arg);
|
|
474
|
-
i++;
|
|
475
|
-
break;
|
|
476
|
-
case "--baseline":
|
|
477
|
-
parsed.baseline = next(i, arg);
|
|
810
|
+
const spec = arg === void 0 ? void 0 : FLAGS_BY_TOKEN.get(arg);
|
|
811
|
+
if (spec && arg !== void 0) {
|
|
812
|
+
if (spec.takesValue) {
|
|
813
|
+
spec.apply({ parsed, value: next(i, arg), flag: arg });
|
|
478
814
|
i++;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
parsed.updateBaseline = true;
|
|
482
|
-
break;
|
|
483
|
-
case "--regressions-only":
|
|
484
|
-
parsed.regressionsOnly = true;
|
|
485
|
-
break;
|
|
486
|
-
case "--max-debt":
|
|
487
|
-
parsed.maxDebt = toInt(next(i, arg), arg);
|
|
488
|
-
i++;
|
|
489
|
-
break;
|
|
490
|
-
case "--depth":
|
|
491
|
-
parsed.options.depth = toInt(next(i, arg), arg);
|
|
492
|
-
i++;
|
|
493
|
-
break;
|
|
494
|
-
case "--coverage": {
|
|
495
|
-
const mode = next(i, arg);
|
|
496
|
-
if (mode !== "structural" && mode !== "all") {
|
|
497
|
-
throw new Error(`--coverage takes "structural" or "all", not ${JSON.stringify(mode)}`);
|
|
498
|
-
}
|
|
499
|
-
parsed.options.coverage = mode;
|
|
500
|
-
i += 1;
|
|
501
|
-
break;
|
|
815
|
+
} else {
|
|
816
|
+
spec.apply({ parsed, value: "", flag: arg });
|
|
502
817
|
}
|
|
503
|
-
|
|
504
|
-
parsed.options.maxPages = toInt(next(i, arg), arg);
|
|
505
|
-
i++;
|
|
506
|
-
break;
|
|
507
|
-
case "--timeout":
|
|
508
|
-
parsed.options.timeoutMs = toInt(next(i, arg), arg);
|
|
509
|
-
i++;
|
|
510
|
-
break;
|
|
511
|
-
case "--include":
|
|
512
|
-
parsed.options.include.push(next(i, arg));
|
|
513
|
-
i++;
|
|
514
|
-
break;
|
|
515
|
-
case "--exclude":
|
|
516
|
-
parsed.options.exclude.push(next(i, arg));
|
|
517
|
-
i++;
|
|
518
|
-
break;
|
|
519
|
-
default:
|
|
520
|
-
if (arg && arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
|
|
521
|
-
if (arg && !parsed.url) parsed.url = arg;
|
|
522
|
-
else if (arg) throw new Error(`unexpected argument: ${arg}`);
|
|
818
|
+
continue;
|
|
523
819
|
}
|
|
820
|
+
if (arg && arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
|
|
821
|
+
if (arg && COMMAND_NAMES.includes(arg) && i === 0) parsed.command = arg;
|
|
822
|
+
else if (arg && !parsed.url) parsed.url = arg;
|
|
823
|
+
else if (arg) throw new Error(`unexpected argument: ${arg}`);
|
|
524
824
|
}
|
|
525
825
|
if (parsed.updateBaseline && !parsed.baseline) {
|
|
526
826
|
throw new Error("--update-baseline needs a --baseline <file> to write to");
|
|
@@ -535,10 +835,503 @@ function parseArgs(argv) {
|
|
|
535
835
|
}
|
|
536
836
|
return parsed;
|
|
537
837
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
838
|
+
|
|
839
|
+
// src/lib/rules/sources/index.ts
|
|
840
|
+
var NORMATIVE = [
|
|
841
|
+
{
|
|
842
|
+
id: "whatwg-html-title",
|
|
843
|
+
publisher: "WHATWG",
|
|
844
|
+
rigor: "normative",
|
|
845
|
+
title: "HTML Living Standard \u2014 the title element",
|
|
846
|
+
url: "https://html.spec.whatwg.org/multipage/semantics.html",
|
|
847
|
+
anchor: "the-title-element",
|
|
848
|
+
retrievedAt: "2026-08-06",
|
|
849
|
+
note: "Documents must have exactly one title element in the head, and its text should identify the document even out of context (e.g. in history or bookmarks)."
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
id: "whatwg-html-meta",
|
|
853
|
+
publisher: "WHATWG",
|
|
854
|
+
rigor: "normative",
|
|
855
|
+
title: "HTML Living Standard \u2014 the meta element",
|
|
856
|
+
url: "https://html.spec.whatwg.org/multipage/semantics.html",
|
|
857
|
+
anchor: "the-meta-element",
|
|
858
|
+
retrievedAt: "2026-08-06",
|
|
859
|
+
note: "Defines the syntax of meta: exactly one of name, http-equiv, charset or itemprop must be present, and name/http-equiv variants require a content attribute."
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
id: "whatwg-html-standard-metadata-names",
|
|
863
|
+
publisher: "WHATWG",
|
|
864
|
+
rigor: "normative",
|
|
865
|
+
title: "HTML Living Standard \u2014 standard metadata names",
|
|
866
|
+
url: "https://html.spec.whatwg.org/multipage/semantics.html",
|
|
867
|
+
anchor: "standard-metadata-names",
|
|
868
|
+
retrievedAt: "2026-08-06",
|
|
869
|
+
note: "The registry of standard meta names \u2014 description, viewport, theme-color, robots, generator, \u2026 \u2014 and the conformance requirements for each (e.g. at most one meta description per document)."
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
id: "whatwg-html-link",
|
|
873
|
+
publisher: "WHATWG",
|
|
874
|
+
rigor: "normative",
|
|
875
|
+
title: "HTML Living Standard \u2014 the link element",
|
|
876
|
+
url: "https://html.spec.whatwg.org/multipage/semantics.html",
|
|
877
|
+
anchor: "the-link-element",
|
|
878
|
+
retrievedAt: "2026-08-06",
|
|
879
|
+
note: "Defines the syntax of link: a rel attribute (or itemprop) is required, and href must be a valid non-empty URL potentially surrounded by spaces."
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
id: "whatwg-html-link-types",
|
|
883
|
+
publisher: "WHATWG",
|
|
884
|
+
rigor: "normative",
|
|
885
|
+
title: "HTML Living Standard \u2014 link types",
|
|
886
|
+
url: "https://html.spec.whatwg.org/multipage/links.html",
|
|
887
|
+
anchor: "linkTypes",
|
|
888
|
+
retrievedAt: "2026-08-06",
|
|
889
|
+
note: "The registry of rel keywords \u2014 canonical, alternate, icon, manifest, \u2026 \u2014 including which are allowed on link vs a/area, and the semantics of each (e.g. alternate + hreflang designates a translation)."
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
id: "whatwg-html-lang",
|
|
893
|
+
publisher: "WHATWG",
|
|
894
|
+
rigor: "normative",
|
|
895
|
+
title: "HTML Living Standard \u2014 the lang and xml:lang attributes",
|
|
896
|
+
url: "https://html.spec.whatwg.org/multipage/dom.html",
|
|
897
|
+
anchor: "the-lang-and-xml:lang-attributes",
|
|
898
|
+
retrievedAt: "2026-08-06",
|
|
899
|
+
note: "lang gives the language of the element's contents and must be a valid BCP 47 language tag or the empty string; authors are encouraged to set it on the root html element."
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
id: "whatwg-html-dir",
|
|
903
|
+
publisher: "WHATWG",
|
|
904
|
+
rigor: "normative",
|
|
905
|
+
title: "HTML Living Standard \u2014 the dir attribute",
|
|
906
|
+
url: "https://html.spec.whatwg.org/multipage/dom.html",
|
|
907
|
+
anchor: "the-dir-attribute",
|
|
908
|
+
retrievedAt: "2026-08-06",
|
|
909
|
+
note: "dir declares text directionality: ltr, rtl, or auto. Governs whether an rtl-language page declares its direction explicitly."
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
id: "whatwg-url",
|
|
913
|
+
publisher: "WHATWG",
|
|
914
|
+
rigor: "normative",
|
|
915
|
+
title: "URL Standard",
|
|
916
|
+
url: "https://url.spec.whatwg.org/",
|
|
917
|
+
retrievedAt: "2026-08-06",
|
|
918
|
+
note: "How browsers actually parse URLs \u2014 the parsing/validity contract goflag applies when it checks that a canonical or alternate href is a valid, absolute URL."
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
id: "whatwg-encoding",
|
|
922
|
+
publisher: "WHATWG",
|
|
923
|
+
rigor: "normative",
|
|
924
|
+
title: "Encoding Standard",
|
|
925
|
+
url: "https://encoding.spec.whatwg.org/",
|
|
926
|
+
retrievedAt: "2026-08-06",
|
|
927
|
+
note: "Defines character encodings for the web; UTF-8 is the mandatory encoding for new content, which is what a meta charset declaration should say."
|
|
928
|
+
},
|
|
929
|
+
{
|
|
930
|
+
id: "ietf-rfc3986",
|
|
931
|
+
publisher: "IETF",
|
|
932
|
+
rigor: "normative",
|
|
933
|
+
title: "RFC 3986 \u2014 URI Generic Syntax",
|
|
934
|
+
url: "https://www.rfc-editor.org/rfc/rfc3986",
|
|
935
|
+
retrievedAt: "2026-08-06",
|
|
936
|
+
note: "The generic URI grammar: scheme, authority, path, query, fragment. The formal definition of what an absolute URL is."
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
id: "ietf-rfc3987",
|
|
940
|
+
publisher: "IETF",
|
|
941
|
+
rigor: "normative",
|
|
942
|
+
title: "RFC 3987 \u2014 Internationalized Resource Identifiers (IRIs)",
|
|
943
|
+
url: "https://www.rfc-editor.org/rfc/rfc3987",
|
|
944
|
+
retrievedAt: "2026-08-06",
|
|
945
|
+
note: "Extends URIs to non-ASCII characters and defines the IRI-to-URI mapping \u2014 relevant when localized sites use non-ASCII paths."
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
id: "ietf-bcp47",
|
|
949
|
+
publisher: "IETF",
|
|
950
|
+
rigor: "normative",
|
|
951
|
+
title: "RFC 5646 / BCP 47 \u2014 Tags for Identifying Languages",
|
|
952
|
+
url: "https://www.rfc-editor.org/rfc/rfc5646",
|
|
953
|
+
retrievedAt: "2026-08-06",
|
|
954
|
+
note: "The grammar of language tags used by lang and hreflang (language[-script][-region]\u2026). Tags are case-insensitive; the familiar en-US casing is only a convention."
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
id: "ietf-rfc6596",
|
|
958
|
+
publisher: "IETF",
|
|
959
|
+
rigor: "normative",
|
|
960
|
+
title: "RFC 6596 \u2014 The Canonical Link Relation",
|
|
961
|
+
url: "https://www.rfc-editor.org/rfc/rfc6596",
|
|
962
|
+
retrievedAt: "2026-08-06",
|
|
963
|
+
note: 'Defines rel="canonical": the preferred IRI for duplicative content. The target must not be a 404 and should not vary per duplicate (no self-referencing loops through redirects).'
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
id: "ietf-rfc9309",
|
|
967
|
+
publisher: "IETF",
|
|
968
|
+
rigor: "normative",
|
|
969
|
+
title: "RFC 9309 \u2014 Robots Exclusion Protocol",
|
|
970
|
+
url: "https://www.rfc-editor.org/rfc/rfc9309",
|
|
971
|
+
retrievedAt: "2026-08-06",
|
|
972
|
+
note: "The robots.txt file format and matching rules: group selection by user-agent, longest-match precedence, allow wins ties, and how errors/unavailability must be treated."
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
id: "sitemaps-protocol",
|
|
976
|
+
publisher: "sitemaps.org",
|
|
977
|
+
rigor: "normative",
|
|
978
|
+
title: "Sitemaps XML protocol",
|
|
979
|
+
url: "https://www.sitemaps.org/protocol.html",
|
|
980
|
+
retrievedAt: "2026-08-06",
|
|
981
|
+
note: "The sitemap.xml schema: urlset/url/loc structure, optional lastmod/changefreq/priority, the 50,000-URL / 50 MB limits, and sitemap index files."
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
id: "w3c-appmanifest",
|
|
985
|
+
publisher: "W3C",
|
|
986
|
+
rigor: "normative",
|
|
987
|
+
title: "Web Application Manifest",
|
|
988
|
+
url: "https://www.w3.org/TR/appmanifest/",
|
|
989
|
+
retrievedAt: "2026-08-06",
|
|
990
|
+
note: 'The manifest.json format linked via link rel="manifest": members like name, icons, start_url, display, and how user agents process them.'
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
id: "w3c-wcag22",
|
|
994
|
+
publisher: "W3C",
|
|
995
|
+
rigor: "normative",
|
|
996
|
+
title: "WCAG 2.2",
|
|
997
|
+
url: "https://www.w3.org/TR/WCAG22/",
|
|
998
|
+
retrievedAt: "2026-08-06",
|
|
999
|
+
note: "Accessibility success criteria \u2014 language of page (3.1.1), non-text content / alt (1.1.1), contrast, \u2026 Reserved for the future a11y vertical."
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
id: "w3c-wai-aria12",
|
|
1003
|
+
publisher: "W3C",
|
|
1004
|
+
rigor: "normative",
|
|
1005
|
+
title: "WAI-ARIA 1.2",
|
|
1006
|
+
url: "https://www.w3.org/TR/wai-aria-1.2/",
|
|
1007
|
+
retrievedAt: "2026-08-06",
|
|
1008
|
+
note: "ARIA roles, states and properties. Reserved for the future a11y vertical."
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
id: "w3c-i18n-language-tags",
|
|
1012
|
+
publisher: "W3C",
|
|
1013
|
+
rigor: "guideline",
|
|
1014
|
+
title: "W3C i18n \u2014 Language tags in HTML and XML",
|
|
1015
|
+
url: "https://www.w3.org/International/articles/language-tags/",
|
|
1016
|
+
retrievedAt: "2026-08-06",
|
|
1017
|
+
note: "W3C i18n guidance on applying BCP 47 in practice: keep tags as short as possible, subtag order and casing conventions, when to include script or region."
|
|
1018
|
+
}
|
|
1019
|
+
];
|
|
1020
|
+
var VENDOR = [
|
|
1021
|
+
{
|
|
1022
|
+
id: "ogp",
|
|
1023
|
+
publisher: "ogp.me",
|
|
1024
|
+
rigor: "vendor-spec",
|
|
1025
|
+
title: "The Open Graph protocol",
|
|
1026
|
+
url: "https://ogp.me/",
|
|
1027
|
+
retrievedAt: "2026-08-06",
|
|
1028
|
+
note: "The og:* vocabulary. og:title, og:type, og:image and og:url are required for every page that wants to be a rich object in the social graph; og:description and others are optional."
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
id: "x-cards",
|
|
1032
|
+
publisher: "X (Twitter)",
|
|
1033
|
+
rigor: "vendor-spec",
|
|
1034
|
+
title: "X Cards \u2014 about cards markup",
|
|
1035
|
+
url: "https://developer.x.com/en/docs/twitter-for-websites/cards/overview/abouts-cards",
|
|
1036
|
+
retrievedAt: "2026-08-06",
|
|
1037
|
+
note: "The twitter:* card vocabulary (twitter:card, twitter:title, \u2026) and the fallback behavior: X's crawler falls back to og:* tags when twitter:* equivalents are absent. X has since removed most of this documentation (the URL now redirects to docs.x.com/overview) but the crawler behavior and tag names are unchanged \u2014 the tags stayed twitter:*, not x:*."
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
id: "google-canonicalization",
|
|
1041
|
+
publisher: "Google",
|
|
1042
|
+
rigor: "vendor-spec",
|
|
1043
|
+
title: "Consolidate duplicate URLs (canonicalization)",
|
|
1044
|
+
url: "https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls",
|
|
1045
|
+
retrievedAt: "2026-08-06",
|
|
1046
|
+
note: `How Google treats rel="canonical": a strong hint (not a directive), absolute URLs recommended, don't point to redirecting or 404 targets, one canonical per page.`
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
id: "google-robots-meta",
|
|
1050
|
+
publisher: "Google",
|
|
1051
|
+
rigor: "vendor-spec",
|
|
1052
|
+
title: "Robots meta tag and X-Robots-Tag",
|
|
1053
|
+
url: "https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag",
|
|
1054
|
+
retrievedAt: "2026-08-06",
|
|
1055
|
+
note: "The robots meta / X-Robots-Tag directives (noindex, nofollow, \u2026), how meta and header directives combine, and that the most restrictive directive wins on conflict."
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
id: "google-robots-intro",
|
|
1059
|
+
publisher: "Google",
|
|
1060
|
+
rigor: "vendor-spec",
|
|
1061
|
+
title: "Introduction to robots.txt",
|
|
1062
|
+
url: "https://developers.google.com/search/docs/crawling-indexing/robots/intro",
|
|
1063
|
+
retrievedAt: "2026-08-06",
|
|
1064
|
+
note: "Google's robots.txt behavior on top of RFC 9309 \u2014 notably that robots.txt controls crawling, not indexing: a blocked URL can still be indexed from links, so noindex needs the page to be crawlable."
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
id: "google-hreflang",
|
|
1068
|
+
publisher: "Google",
|
|
1069
|
+
rigor: "vendor-spec",
|
|
1070
|
+
title: "Localized versions of your pages (hreflang)",
|
|
1071
|
+
url: "https://developers.google.com/search/docs/specialty/international/localized-versions",
|
|
1072
|
+
retrievedAt: "2026-08-06",
|
|
1073
|
+
note: "hreflang via link annotations, headers or sitemaps: every language version must list itself and all others (reciprocity), use absolute URLs, and x-default marks the unmatched-language fallback."
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
id: "google-title-link",
|
|
1077
|
+
publisher: "Google",
|
|
1078
|
+
rigor: "guideline",
|
|
1079
|
+
title: "Influence your title links in search results",
|
|
1080
|
+
url: "https://developers.google.com/search/docs/appearance/title-link",
|
|
1081
|
+
retrievedAt: "2026-08-06",
|
|
1082
|
+
note: "Write descriptive, concise, non-boilerplate titles unique to each page. Google may rewrite titles it considers poor; length itself is not a ranking factor."
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
id: "google-snippet",
|
|
1086
|
+
publisher: "Google",
|
|
1087
|
+
rigor: "guideline",
|
|
1088
|
+
title: "Control your snippets in search results",
|
|
1089
|
+
url: "https://developers.google.com/search/docs/appearance/snippet",
|
|
1090
|
+
retrievedAt: "2026-08-06",
|
|
1091
|
+
note: "Meta descriptions feed the search snippet but Google may substitute on-page text; write a unique, accurate summary per page rather than keyword stuffing."
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
id: "google-structured-data",
|
|
1095
|
+
publisher: "Google",
|
|
1096
|
+
rigor: "vendor-spec",
|
|
1097
|
+
title: "Introduction to structured data markup",
|
|
1098
|
+
url: "https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data",
|
|
1099
|
+
retrievedAt: "2026-08-06",
|
|
1100
|
+
note: "How Google consumes structured data (JSON-LD recommended) and what rich-result eligibility requires; the vocabulary itself is schema.org's."
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
id: "google-sitemaps",
|
|
1104
|
+
publisher: "Google",
|
|
1105
|
+
rigor: "guideline",
|
|
1106
|
+
title: "Build and submit a sitemap",
|
|
1107
|
+
url: "https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview",
|
|
1108
|
+
retrievedAt: "2026-08-06",
|
|
1109
|
+
note: "Google's sitemap practice on top of the sitemaps.org protocol: when a sitemap helps, supported formats, and submission via robots.txt or Search Console."
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
id: "schema-org",
|
|
1113
|
+
publisher: "schema.org",
|
|
1114
|
+
rigor: "vendor-spec",
|
|
1115
|
+
title: "schema.org vocabulary",
|
|
1116
|
+
url: "https://schema.org/",
|
|
1117
|
+
retrievedAt: "2026-08-06",
|
|
1118
|
+
note: "The shared vocabulary (types and properties) that structured data \u2014 JSON-LD in particular \u2014 is written in."
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
id: "apple-web-apps",
|
|
1122
|
+
publisher: "Apple",
|
|
1123
|
+
rigor: "vendor-spec",
|
|
1124
|
+
title: "Configuring web applications (Safari Web Content Guide)",
|
|
1125
|
+
url: "https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html",
|
|
1126
|
+
retrievedAt: "2026-08-06",
|
|
1127
|
+
note: "apple-touch-icon and the apple-mobile-web-app-* meta tags: how iOS picks home-screen icons and startup images. Archived but still the reference Apple points to."
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
id: "meta-og-sharing",
|
|
1131
|
+
publisher: "Meta",
|
|
1132
|
+
rigor: "vendor-spec",
|
|
1133
|
+
title: "Sharing \u2014 webmasters (Open Graph usage)",
|
|
1134
|
+
url: "https://developers.facebook.com/docs/sharing/webmasters/",
|
|
1135
|
+
retrievedAt: "2026-08-06",
|
|
1136
|
+
note: "How Meta's crawler builds link previews from og:* tags \u2014 including image size expectations (1200\xD7630 recommended, 200\xD7200 minimum) beyond what ogp.me specifies."
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
id: "bing-webmaster-guidelines",
|
|
1140
|
+
publisher: "Microsoft",
|
|
1141
|
+
rigor: "guideline",
|
|
1142
|
+
title: "Bing Webmaster Guidelines",
|
|
1143
|
+
url: "https://www.bing.com/webmasters/help/webmasters-guidelines-30fba23a",
|
|
1144
|
+
retrievedAt: "2026-08-06",
|
|
1145
|
+
note: "Bing's crawlability and content guidance \u2014 a cross-check that the catalog is not Google-only."
|
|
1146
|
+
}
|
|
1147
|
+
];
|
|
1148
|
+
var PRACTICAL = [
|
|
1149
|
+
{
|
|
1150
|
+
id: "mdn-meta-name",
|
|
1151
|
+
publisher: "MDN / Mozilla",
|
|
1152
|
+
rigor: "guideline",
|
|
1153
|
+
title: "MDN \u2014 <meta> name attribute values",
|
|
1154
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name",
|
|
1155
|
+
retrievedAt: "2026-08-06",
|
|
1156
|
+
note: "Practical reference for standard and de-facto meta names, browser support included."
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
id: "mdn-viewport",
|
|
1160
|
+
publisher: "MDN / Mozilla",
|
|
1161
|
+
rigor: "guideline",
|
|
1162
|
+
title: "MDN \u2014 Viewport meta tag",
|
|
1163
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag",
|
|
1164
|
+
retrievedAt: "2026-08-06",
|
|
1165
|
+
note: "width=device-width, initial-scale=1 and friends: what the viewport meta controls and the accessibility pitfalls (never disable user scaling)."
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
id: "mdn-link-rel",
|
|
1169
|
+
publisher: "MDN / Mozilla",
|
|
1170
|
+
rigor: "guideline",
|
|
1171
|
+
title: "MDN \u2014 rel attribute values",
|
|
1172
|
+
url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel",
|
|
1173
|
+
retrievedAt: "2026-08-06",
|
|
1174
|
+
note: "Practical reference for rel values across link, a and form, with support notes."
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
id: "lighthouse-seo",
|
|
1178
|
+
publisher: "Google",
|
|
1179
|
+
rigor: "guideline",
|
|
1180
|
+
title: "Lighthouse \u2014 SEO audits",
|
|
1181
|
+
url: "https://developer.chrome.com/docs/lighthouse/seo/",
|
|
1182
|
+
retrievedAt: "2026-08-06",
|
|
1183
|
+
note: "The audit definitions Lighthouse ships (document has a title, has a meta description, valid hreflang, valid canonical, \u2026) \u2014 the closest thing to an industry-standard checklist to mirror."
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
id: "axe-core-rules",
|
|
1187
|
+
publisher: "Deque",
|
|
1188
|
+
rigor: "guideline",
|
|
1189
|
+
title: "axe-core \u2014 rule descriptions",
|
|
1190
|
+
url: "https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md",
|
|
1191
|
+
retrievedAt: "2026-08-06",
|
|
1192
|
+
note: "The a11y rule catalog to cross-check against when the a11y vertical lands (html-has-lang, valid-lang, image-alt, \u2026)."
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
id: "w3c-nu-checker",
|
|
1196
|
+
publisher: "W3C",
|
|
1197
|
+
rigor: "guideline",
|
|
1198
|
+
title: "Nu HTML Checker",
|
|
1199
|
+
url: "https://validator.w3.org/nu/",
|
|
1200
|
+
retrievedAt: "2026-08-06",
|
|
1201
|
+
note: "The reference HTML validator \u2014 the cross-check for goflag's document-level syntax rules."
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
id: "moz-title-tag",
|
|
1205
|
+
publisher: "Moz",
|
|
1206
|
+
rigor: "heuristic",
|
|
1207
|
+
title: "Title tag best practices",
|
|
1208
|
+
url: "https://moz.com/learn/seo/title-tag",
|
|
1209
|
+
retrievedAt: "2026-08-06",
|
|
1210
|
+
note: "The ~50\u201360 character title window is display folklore, not spec: it approximates SERP truncation width. Ships as heuristic so agents weight it accordingly."
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
id: "moz-meta-description",
|
|
1214
|
+
publisher: "Moz",
|
|
1215
|
+
rigor: "heuristic",
|
|
1216
|
+
title: "Meta description best practices",
|
|
1217
|
+
url: "https://moz.com/learn/seo/meta-description",
|
|
1218
|
+
retrievedAt: "2026-08-06",
|
|
1219
|
+
note: "The ~50\u2013160 character description window is display folklore, not spec: it approximates snippet truncation. Ships as heuristic so agents weight it accordingly."
|
|
1220
|
+
}
|
|
1221
|
+
];
|
|
1222
|
+
var SOURCES = [...NORMATIVE, ...VENDOR, ...PRACTICAL];
|
|
1223
|
+
function getSource(id) {
|
|
1224
|
+
return SOURCES.find((source) => source.id === id);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/lib/rules/catalog.ts
|
|
1228
|
+
function fixOf(fix) {
|
|
1229
|
+
if (!fix) return void 0;
|
|
1230
|
+
return { title: fix.title, snippet: fix.snippet, language: fix.language ?? "ts" };
|
|
1231
|
+
}
|
|
1232
|
+
function buildRuleCatalog(version) {
|
|
1233
|
+
const rules = [];
|
|
1234
|
+
for (const rule of RULES) {
|
|
1235
|
+
rules.push({
|
|
1236
|
+
id: rule.id,
|
|
1237
|
+
scope: "page",
|
|
1238
|
+
category: rule.category,
|
|
1239
|
+
severity: rule.kind === "boolean" ? rule.severity : rule.severityByBand.acceptable,
|
|
1240
|
+
kind: rule.kind,
|
|
1241
|
+
summary: rule.title,
|
|
1242
|
+
why: rule.why,
|
|
1243
|
+
rigor: rule.rigor,
|
|
1244
|
+
sources: [...rule.sources],
|
|
1245
|
+
reads: [...rule.reads],
|
|
1246
|
+
...rule.relates ? { relates: [...rule.relates] } : {},
|
|
1247
|
+
...rule.expected ? { expected: rule.expected } : {},
|
|
1248
|
+
...fixOf(rule.fix) ? { fix: fixOf(rule.fix) } : {},
|
|
1249
|
+
...rule.kind === "scored" ? { bands: { ...rule.bands }, severityByBand: { ...rule.severityByBand } } : {}
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
for (const rule of SITE_RULES) {
|
|
1253
|
+
rules.push({
|
|
1254
|
+
id: rule.id,
|
|
1255
|
+
scope: "site",
|
|
1256
|
+
severity: rule.severity,
|
|
1257
|
+
kind: "boolean",
|
|
1258
|
+
summary: rule.summary,
|
|
1259
|
+
rigor: null,
|
|
1260
|
+
sources: []
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
for (const rule of PROSE_RULES) {
|
|
1264
|
+
rules.push({
|
|
1265
|
+
id: rule.id,
|
|
1266
|
+
scope: "prose",
|
|
1267
|
+
category: rule.category,
|
|
1268
|
+
severity: null,
|
|
1269
|
+
kind: "prose",
|
|
1270
|
+
summary: rule.title,
|
|
1271
|
+
why: rule.why,
|
|
1272
|
+
rigor: rule.rigor,
|
|
1273
|
+
sources: [...rule.sources],
|
|
1274
|
+
reads: [...rule.reads],
|
|
1275
|
+
prose: rule.prose
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
rules.sort((a, b) => a.id.localeCompare(b.id));
|
|
1279
|
+
const cited = new Set(rules.flatMap((r) => r.sources));
|
|
1280
|
+
const sources = {};
|
|
1281
|
+
for (const id of [...cited].sort()) {
|
|
1282
|
+
const doc = getSource(id);
|
|
1283
|
+
if (doc) {
|
|
1284
|
+
sources[id] = {
|
|
1285
|
+
id: doc.id,
|
|
1286
|
+
publisher: doc.publisher,
|
|
1287
|
+
title: doc.title,
|
|
1288
|
+
rigor: doc.rigor,
|
|
1289
|
+
url: doc.url
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
...version ? { version } : {},
|
|
1295
|
+
counts: {
|
|
1296
|
+
page: RULES.length,
|
|
1297
|
+
site: SITE_RULES.length,
|
|
1298
|
+
prose: PROSE_RULES.length
|
|
1299
|
+
},
|
|
1300
|
+
sources,
|
|
1301
|
+
rules
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
function serialiseCatalog(catalog) {
|
|
1305
|
+
return `${JSON.stringify(catalog, null, 2)}
|
|
1306
|
+
`;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/lib/flags/catalog.ts
|
|
1310
|
+
function buildFlagCatalog(version) {
|
|
1311
|
+
const flags = FLAGS.map((spec) => ({
|
|
1312
|
+
name: spec.name,
|
|
1313
|
+
short: spec.short ?? null,
|
|
1314
|
+
arg: spec.arg ?? null,
|
|
1315
|
+
default: spec.default ?? null,
|
|
1316
|
+
requires: spec.requires ?? null,
|
|
1317
|
+
group: spec.group,
|
|
1318
|
+
description: [...spec.help, ...spec.dynamicTail?.() ?? []].join(" ")
|
|
1319
|
+
}));
|
|
1320
|
+
flags.sort((a, b) => a.name.localeCompare(b.name));
|
|
1321
|
+
return {
|
|
1322
|
+
...version ? { version } : {},
|
|
1323
|
+
flags,
|
|
1324
|
+
exitCodes: EXIT_CODES.map((e) => ({ code: e.code, label: e.label })),
|
|
1325
|
+
counts: {
|
|
1326
|
+
flags: flags.length,
|
|
1327
|
+
withShortForm: flags.filter((f) => f.short !== null).length,
|
|
1328
|
+
takingValue: flags.filter((f) => f.arg !== null).length
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
function serialiseFlags(catalog) {
|
|
1333
|
+
return `${JSON.stringify(catalog, null, 2)}
|
|
1334
|
+
`;
|
|
542
1335
|
}
|
|
543
1336
|
|
|
544
1337
|
// src/cli.ts
|
|
@@ -582,6 +1375,14 @@ ${HELP}
|
|
|
582
1375
|
`);
|
|
583
1376
|
return 0;
|
|
584
1377
|
}
|
|
1378
|
+
if (args.command === "rules") {
|
|
1379
|
+
process.stdout.write(serialiseCatalog(buildRuleCatalog(await readVersion())));
|
|
1380
|
+
return 0;
|
|
1381
|
+
}
|
|
1382
|
+
if (args.command === "flags") {
|
|
1383
|
+
process.stdout.write(serialiseFlags(buildFlagCatalog(await readVersion())));
|
|
1384
|
+
return 0;
|
|
1385
|
+
}
|
|
585
1386
|
if (!args.url) {
|
|
586
1387
|
process.stderr.write(`goflag: missing <url>
|
|
587
1388
|
|