jig-ui 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { readFileSync as readFileSync28 } from "fs";
5
+ import { readFileSync as readFileSync30 } from "fs";
6
6
  import { homedir as homedir2 } from "os";
7
- import { join as join35 } from "path";
7
+ import { join as join37 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -387,8 +387,8 @@ function render(template, vars) {
387
387
  function escapeTableCell(value) {
388
388
  return value.replace(/\|/g, "\\|");
389
389
  }
390
- function renderCommandTable(metadata) {
391
- const rows = Object.entries(metadata).map(([name, meta]) => {
390
+ function renderCommandTable(metadata2) {
391
+ const rows = Object.entries(metadata2).map(([name, meta]) => {
392
392
  const signature = meta.argumentHint ? `${name} ${meta.argumentHint}` : name;
393
393
  const status = meta.status === "planned" ? "planned \u2014 not yet implemented" : meta.status === "agent" ? "agent procedure \u2014 no CLI to run" : "available";
394
394
  return `| \`${escapeTableCell(signature)}\` | ${escapeTableCell(meta.description)} | ${status} |`;
@@ -522,7 +522,7 @@ function bundleFiles(packageRoot2, dir) {
522
522
  var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
523
523
  function buildSkillBody(packageRoot2, rulesPath, version2) {
524
524
  const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
525
- const metadata = JSON.parse(
525
+ const metadata2 = JSON.parse(
526
526
  readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
527
527
  );
528
528
  return render(template, {
@@ -533,16 +533,16 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
533
533
  // — so a reader following the skill could never upgrade.
534
534
  update_path: "npx jig-ui@latest",
535
535
  ask_instruction: ASK_INSTRUCTION,
536
- available_commands: renderCommandTable(metadata),
536
+ available_commands: renderCommandTable(metadata2),
537
537
  config_file: "jig.config.json",
538
538
  rules_path: rulesPath
539
539
  });
540
540
  }
541
541
  function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
542
- const metadata = JSON.parse(
542
+ const metadata2 = JSON.parse(
543
543
  readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
544
544
  );
545
- const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
545
+ const subcommands = Object.entries(metadata2).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
546
546
  const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
547
547
  if (!existsSync5(templatePath)) return { body: "", subcommands };
548
548
  const template = readFileSync4(templatePath, "utf8");
@@ -1823,7 +1823,13 @@ var REPO_DOCUMENTS = /* @__PURE__ */ new Set([
1823
1823
  "agents",
1824
1824
  "claude",
1825
1825
  "gemini",
1826
- "copilot-instructions"
1826
+ "copilot-instructions",
1827
+ // Jig's own: the project's decisions live beside the token layer, in
1828
+ // `jig/DECISIONS.md`, and are written for whoever builds here.
1829
+ "decisions",
1830
+ "reconcile",
1831
+ "coverage",
1832
+ "drift"
1827
1833
  ]);
1828
1834
  function isRepoDocument(file) {
1829
1835
  const base = file.split("/").pop() ?? file;
@@ -3473,6 +3479,149 @@ var semanticElement = {
3473
3479
  }
3474
3480
  };
3475
3481
 
3482
+ // src/check/metadata.ts
3483
+ var QUOTED = `(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`;
3484
+ var HTML_TITLE = /<title[^>]*>([\s\S]*?)<\/title\s*>/i;
3485
+ var HTML_DESCRIPTION = /<meta[^>]+name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/i;
3486
+ var HTML_DESCRIPTION_REVERSED = /<meta[^>]+content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/i;
3487
+ var KEYED_TITLE = new RegExp(`(?:^|[\\s{,])(?:title|ogTitle|og:title)\\s*:\\s*${QUOTED}`, "m");
3488
+ var KEYED_TITLE_ALL = new RegExp(`(?:^|[\\s{,])(?:title|ogTitle|og:title)\\s*:\\s*${QUOTED}`, "gm");
3489
+ var HTML_TITLE_ALL = /<title[^>]*>([\s\S]*?)<\/title\s*>/gi;
3490
+ var KEYED_DESCRIPTION = new RegExp(`(?:^|[\\s{,])(?:description|ogDescription|og:description)\\s*:\\s*${QUOTED}`, "m");
3491
+ var COMPUTED_TITLE = /(?:^|[\s{,])(?:title|ogTitle)\s*:\s*(?!["'`])[A-Za-z_$][\w$.?[\]()]*|(?:^|[\s{,])(?:title|ogTitle)\s*:\s*`[^`]*\$\{/m;
3492
+ var ANY_TITLE = /<title[\s>]|(?:^|[\s{,])(?:title|ogTitle)\s*:|['"]title['"]\s*[,:]|titleTemplate/im;
3493
+ var ANY_DESCRIPTION = /name\s*=\s*["']description["']|(?:^|[\s{,])(?:description|ogDescription)\s*:|['"]description['"]\s*[,:]/im;
3494
+ var DECLARES = /export\s+(?:const|async\s+function|function)\s+(?:metadata|generateMetadata|meta)\b|useSeoMeta\s*\(|useHead\s*\(|defineRouteMeta\s*\(|<svelte:head|<Head\b|next\/head|react-helmet|<title[^>]*>|<meta\b/i;
3495
+ var NOINDEX = [
3496
+ /<meta[^>]+name\s*=\s*["']robots["'][^>]*content\s*=\s*["'][^"']*noindex/i,
3497
+ /robots\s*:\s*{[^}]*index\s*:\s*false/i,
3498
+ /robots\s*:\s*["'][^"']*noindex/i,
3499
+ /["']noindex["']/i,
3500
+ /X-Robots-Tag[^\n]*noindex/i,
3501
+ /noindex\s*:\s*true/i
3502
+ ];
3503
+ function firstGroup(match) {
3504
+ if (!match) return void 0;
3505
+ const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
3506
+ if (!value) return void 0;
3507
+ return { value, index: match.index ?? 0 };
3508
+ }
3509
+ function readMetadata(source) {
3510
+ const htmlTitle = firstGroup(HTML_TITLE.exec(source));
3511
+ const title = htmlTitle ?? firstGroup(KEYED_TITLE.exec(source));
3512
+ const description = firstGroup(HTML_DESCRIPTION.exec(source)) ?? firstGroup(HTML_DESCRIPTION_REVERSED.exec(source)) ?? firstGroup(KEYED_DESCRIPTION.exec(source));
3513
+ const titles = [];
3514
+ for (const re of [HTML_TITLE_ALL, KEYED_TITLE_ALL]) {
3515
+ for (const match of source.matchAll(re)) {
3516
+ const found = firstGroup(match);
3517
+ if (found && !/^\{|\$\{/.test(found.value)) titles.push(found);
3518
+ }
3519
+ }
3520
+ return {
3521
+ titles,
3522
+ title: title && !/^\{|\$\{/.test(title.value) ? title : void 0,
3523
+ description: description && !/^\{|\$\{/.test(description.value) ? description : void 0,
3524
+ hasTitle: ANY_TITLE.test(source),
3525
+ hasDescription: ANY_DESCRIPTION.test(source),
3526
+ declares: DECLARES.test(source),
3527
+ computedTitle: COMPUTED_TITLE.test(source),
3528
+ noindex: NOINDEX.some((re) => re.test(source))
3529
+ };
3530
+ }
3531
+ function isWholeDocument(source) {
3532
+ return /<html[\s>]/i.test(source) && /<head[\s>]/i.test(source);
3533
+ }
3534
+ var TITLE_BUDGET = 60;
3535
+ var DESCRIPTION_BUDGET = 155;
3536
+
3537
+ // src/check/detectors/metadata.ts
3538
+ var INVENTED_DATE = /(?:lastModified|datePublished|dateModified|publishedTime|modifiedTime)\s*:\s*(?:new Date\(\s*\)|Date\.now\(\s*\))/;
3539
+ var metadata = {
3540
+ name: "metadata",
3541
+ appliesTo: (file) => isReaderText(file),
3542
+ run(_source, file, ctx) {
3543
+ const raw = ctx.raw;
3544
+ const found = readMetadata(raw);
3545
+ const starts = buildLineIndex(raw);
3546
+ const findings = [];
3547
+ const at = (rule, offset, message) => {
3548
+ const line = lineForOffset(starts, offset);
3549
+ findings.push({ ...mkFinding(ctx, "metadata", file, line, message, sourceLine(raw, line)), ruleId: rule });
3550
+ };
3551
+ for (const title of found.titles) {
3552
+ if (title.value.length > TITLE_BUDGET) {
3553
+ at("J-122", title.index, `the title is ${title.value.length} characters, past the ${TITLE_BUDGET} a search result shows \u2014 the end is cut, mid-sentence`);
3554
+ }
3555
+ }
3556
+ if (found.description && found.description.value.length > DESCRIPTION_BUDGET) {
3557
+ at("J-122", found.description.index, `the description is ${found.description.value.length} characters, past the ${DESCRIPTION_BUDGET} a search result shows`);
3558
+ }
3559
+ const invented = INVENTED_DATE.exec(raw);
3560
+ if (invented) {
3561
+ at("J-125", invented.index, "a date generated at request time is false on every request, and repeated daily it teaches the crawler to disbelieve the field");
3562
+ }
3563
+ if (isWholeDocument(raw)) {
3564
+ const indexable = ctx.mode !== "product" && ctx.mode !== "operator";
3565
+ if (indexable && !found.hasTitle) {
3566
+ at("J-121", 0, "this page has no <title> \u2014 a search result then shows a truncated URL");
3567
+ }
3568
+ if (indexable && !found.hasDescription) {
3569
+ at("J-121", 0, "this page has no meta description \u2014 the search engine writes one from whatever text it finds first, usually the navigation");
3570
+ }
3571
+ if (!indexable && !found.noindex) {
3572
+ at("J-123", 0, `this surface is ${ctx.mode}, which is what somebody reaches after signing in, and the page carries no noindex \u2014 robots.txt is public and advisory, and is not this`);
3573
+ }
3574
+ }
3575
+ return findings.filter((f) => f.ruleId === ctx.ruleId);
3576
+ }
3577
+ };
3578
+
3579
+ // src/check/detectors/interface-safety.ts
3580
+ var BLANK_LINK = /<a\b[^>]*\btarget\s*=\s*["']_blank["'][^>]*>/gi;
3581
+ var HAS_NOOPENER = /\brel\s*=\s*["'][^"']*\bno(?:opener|referrer)\b/i;
3582
+ var HTML_SINK = /\bdangerouslySetInnerHTML|\bv-html|\binnerHTML\s*=|\{@html\b/g;
3583
+ var IN_SCRIPT = /<script\b[^>]*$/i;
3584
+ var SERIALISED = /JSON\.stringify\s*\(/;
3585
+ var LITERAL_SINK = /dangerouslySetInnerHTML\s*=\s*\{\{\s*__html:\s*["'`]|innerHTML\s*=\s*["'`]/;
3586
+ var CREDENTIAL_FIELD = /<input\b[^>]*\btype\s*=\s*["'](?:password)["'][^>]*>/gi;
3587
+ var AUTOCOMPLETE_OFF = /\bautocomplete\s*=\s*["'](?:off|false)["']/i;
3588
+ var IFRAME = /<iframe\b[^>]*>/gi;
3589
+ var HAS_SANDBOX = /\bsandbox\s*=/i;
3590
+ var EXTERNAL_SRC = /\bsrc\s*=\s*["'](?:https?:)?\/\//i;
3591
+ var interfaceSafety = {
3592
+ name: "interface-safety",
3593
+ appliesTo: (file) => isReaderText(file),
3594
+ run(_source, file, ctx) {
3595
+ const raw = ctx.raw;
3596
+ const starts = buildLineIndex(raw);
3597
+ const findings = [];
3598
+ const at = (rule, offset, message) => {
3599
+ const line = lineForOffset(starts, offset);
3600
+ findings.push({ ...mkFinding(ctx, "interface-safety", file, line, message, sourceLine(raw, line)), ruleId: rule });
3601
+ };
3602
+ for (const link of raw.matchAll(BLANK_LINK)) {
3603
+ if (HAS_NOOPENER.test(link[0])) continue;
3604
+ at("K-128", link.index, 'a link opening a new tab with no rel="noopener" \u2014 the page it opens gets a handle on this window and can navigate it elsewhere');
3605
+ }
3606
+ for (const sink of raw.matchAll(HTML_SINK)) {
3607
+ const context = raw.slice(sink.index, sink.index + 120);
3608
+ if (LITERAL_SINK.test(context) || SERIALISED.test(context)) continue;
3609
+ const before = raw.slice(Math.max(0, sink.index - 400), sink.index);
3610
+ if (IN_SCRIPT.test(before.slice(before.lastIndexOf("<")))) continue;
3611
+ at("K-129", sink.index, `${sink[0].replace(/\s*=$/, "")} writes a value into the page as markup \u2014 render it as text, or sanitise on the way in`);
3612
+ }
3613
+ for (const field of raw.matchAll(CREDENTIAL_FIELD)) {
3614
+ if (!AUTOCOMPLETE_OFF.test(field[0])) continue;
3615
+ at("K-130", field.index, "a password field with autocomplete off \u2014 this stops the password manager, not an attacker, and the reader falls back to one they can remember");
3616
+ }
3617
+ for (const frame of raw.matchAll(IFRAME)) {
3618
+ if (HAS_SANDBOX.test(frame[0]) || !EXTERNAL_SRC.test(frame[0])) continue;
3619
+ at("K-131", frame.index, "a frame of somebody else's page with no sandbox \u2014 give it only the capabilities the embed needs");
3620
+ }
3621
+ return findings.filter((f) => f.ruleId === ctx.ruleId);
3622
+ }
3623
+ };
3624
+
3476
3625
  // src/check/registry.ts
3477
3626
  var DETECTORS = [
3478
3627
  gradientText,
@@ -3492,7 +3641,9 @@ var DETECTORS = [
3492
3641
  menuState,
3493
3642
  undeclaredToken,
3494
3643
  emDash,
3495
- semanticElement
3644
+ semanticElement,
3645
+ metadata,
3646
+ interfaceSafety
3496
3647
  ];
3497
3648
  function getDetector(name) {
3498
3649
  return DETECTORS.find((d) => d.name === name);
@@ -5056,8 +5207,8 @@ Could not write to ${wireTarget}: ${err.message}`);
5056
5207
  }
5057
5208
 
5058
5209
  // src/commands/verdicts.ts
5059
- import { existsSync as existsSync19, readFileSync as readFileSync25 } from "fs";
5060
- import { join as join32, resolve as resolve4 } from "path";
5210
+ import { existsSync as existsSync20, readFileSync as readFileSync26 } from "fs";
5211
+ import { join as join33, resolve as resolve4 } from "path";
5061
5212
 
5062
5213
  // src/rules/citations.ts
5063
5214
  import { join as join28 } from "path";
@@ -5080,7 +5231,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
5080
5231
  import { join as join30, relative as relative3, resolve as resolve3 } from "path";
5081
5232
 
5082
5233
  // src/probe/script.ts
5083
- var PROBE_VERSION = 3;
5234
+ var PROBE_VERSION = 5;
5084
5235
  var PROBE_SCRIPT = `(async () => {
5085
5236
  const doc = document.documentElement;
5086
5237
  const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
@@ -5142,6 +5293,20 @@ var PROBE_SCRIPT = `(async () => {
5142
5293
  }
5143
5294
  }
5144
5295
  }
5296
+ // What a wide screen exposes: a layout with no upper bound, and the lines it
5297
+ // stretches. Measured rather than guessed \u2014 a paragraph's own width and font
5298
+ // size give its length in characters, which is what B-11 is written about.
5299
+ const region = document.querySelector('main') || document.body;
5300
+ const prose = [...document.querySelectorAll('p, li, dd, blockquote')].filter(vis)
5301
+ .map((el) => {
5302
+ const r = el.getBoundingClientRect();
5303
+ const size = parseFloat(getComputedStyle(el).fontSize) || 16;
5304
+ const text = (el.textContent || '').trim();
5305
+ // 0.5em per character is the usual approximation for a text face.
5306
+ return { width: Math.round(r.width), chars: Math.round(r.width / (size * 0.5)), text: text.slice(0, 40), long: text.length > 80 };
5307
+ })
5308
+ .filter((p) => p.long)
5309
+ .sort((a, b) => b.chars - a.chars)[0] || null;
5145
5310
  const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
5146
5311
  // Measured before anything is clicked: what a reader sees on arrival.
5147
5312
  const navAtRest = navLinks();
@@ -5189,6 +5354,17 @@ var PROBE_SCRIPT = `(async () => {
5189
5354
  emDashes: [...new Set((text.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
5190
5355
  brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
5191
5356
  navLinksVisible: navAtRest,
5357
+ head: {
5358
+ title: (document.title || '').trim(),
5359
+ description: (document.querySelector('meta[name=description]') || {}).content || '',
5360
+ canonical: (document.querySelector('link[rel=canonical]') || {}).href || '',
5361
+ robots: (document.querySelector('meta[name=robots]') || {}).content || '',
5362
+ ogTitle: (document.querySelector('meta[property="og:title"]') || {}).content || '',
5363
+ ogImage: (document.querySelector('meta[property="og:image"]') || {}).content || '',
5364
+ },
5365
+ contentWidth: Math.round(region.getBoundingClientRect().width),
5366
+ contentMaxWidth: getComputedStyle(region).maxWidth,
5367
+ longestLine: prose,
5192
5368
  landmarks: landmarks.map(describe),
5193
5369
  orderInversions: inversions.slice(0, 5),
5194
5370
  menu,
@@ -5402,7 +5578,7 @@ function pageFile(projectRoot, url) {
5402
5578
  if (!abs.startsWith(resolve3(projectRoot))) return void 0;
5403
5579
  return existsSync17(abs) ? abs : void 0;
5404
5580
  }
5405
- var PROBE_WIDTHS = [360, 768, 1280];
5581
+ var PROBE_WIDTHS = [360, 768, 1280, 1600];
5406
5582
  async function runAndSaveProbes(opts) {
5407
5583
  const abs = resolve3(opts.projectRoot, opts.page);
5408
5584
  if (!existsSync17(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
@@ -5485,7 +5661,9 @@ function readProbes(projectRoot, dir, errors) {
5485
5661
  }
5486
5662
  return probes;
5487
5663
  }
5488
- function probeContradictions(probes, verdictOf) {
5664
+ var TITLE_BUDGET2 = 60;
5665
+ var DESCRIPTION_BUDGET2 = 155;
5666
+ function probeContradictions(probes, verdictOf, indexable = true) {
5489
5667
  const errors = [];
5490
5668
  const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
5491
5669
  const at = (p) => `probe-${p.width}.json`;
@@ -5505,6 +5683,23 @@ function probeContradictions(probes, verdictOf) {
5505
5683
  if (p.emDashes?.length) {
5506
5684
  errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
5507
5685
  }
5686
+ const line = p.longestLine;
5687
+ if (line && line.chars > 90 && clean("B-11")) {
5688
+ errors.push(`B-11 is "${verdictOf("B-11")}", but ${at(p)} measured a line of about ${line.chars} characters ("${line.text}\u2026") \u2014 past every mode's measure. Cap prose at \`--measure-prose\`.`);
5689
+ }
5690
+ const head = p.head;
5691
+ if (head && p.width === probes[0]?.width) {
5692
+ const noindex = /noindex/i.test(head.robots);
5693
+ if (indexable && !noindex) {
5694
+ if (!head.title) errors.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
5695
+ else if (head.title.length > TITLE_BUDGET2) errors.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
5696
+ if (!head.description) errors.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
5697
+ else if (head.description.length > DESCRIPTION_BUDGET2) errors.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
5698
+ }
5699
+ if (!indexable && !noindex) {
5700
+ errors.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
5701
+ }
5702
+ }
5508
5703
  if (p.junkText.length) {
5509
5704
  errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
5510
5705
  }
@@ -5540,22 +5735,64 @@ function probeContradictions(probes, verdictOf) {
5540
5735
  return errors;
5541
5736
  }
5542
5737
 
5738
+ // src/check/decisions.ts
5739
+ import { existsSync as existsSync19, readFileSync as readFileSync25 } from "fs";
5740
+ import { join as join32 } from "path";
5741
+ var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
5742
+ var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
5743
+ function decisionsFile(projectRoot) {
5744
+ for (const candidate of ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"]) {
5745
+ if (existsSync19(join32(projectRoot, candidate))) return candidate;
5746
+ }
5747
+ return void 0;
5748
+ }
5749
+ function decisionNames(projectRoot) {
5750
+ const path = decisionsFile(projectRoot);
5751
+ if (!path) return [];
5752
+ let body;
5753
+ try {
5754
+ body = readFileSync25(join32(projectRoot, path), "utf8");
5755
+ } catch {
5756
+ return [];
5757
+ }
5758
+ const names = [];
5759
+ for (const match of body.matchAll(HEADING3)) {
5760
+ const name = match[2].replace(/[`*]/g, "").trim();
5761
+ if (!name || NOT_A_DECISION.test(name)) continue;
5762
+ if (!names.includes(name)) names.push(name);
5763
+ }
5764
+ return names;
5765
+ }
5766
+
5543
5767
  // src/commands/verdicts.ts
5544
5768
  var VERDICTS = ["ok", "finding", "n/a"];
5545
5769
  var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
5546
5770
  function readJson(path, errors) {
5547
- if (!existsSync19(path)) return null;
5771
+ if (!existsSync20(path)) return null;
5548
5772
  try {
5549
- return JSON.parse(readFileSync25(path, "utf8"));
5773
+ return JSON.parse(readFileSync26(path, "utf8"));
5550
5774
  } catch (e) {
5551
5775
  errors.push(`${path}: not valid JSON (${e.message})`);
5552
5776
  return {};
5553
5777
  }
5554
5778
  }
5779
+ function specIndexable(projectRoot, surface) {
5780
+ const path = join33(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5781
+ let front = "";
5782
+ try {
5783
+ front = readFileSync26(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5784
+ } catch {
5785
+ }
5786
+ const declared = /^\s*indexable\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5787
+ if (declared === "true" || declared === "yes") return true;
5788
+ if (declared === "false" || declared === "no") return false;
5789
+ const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
5790
+ return mode !== "product" && mode !== "operator";
5791
+ }
5555
5792
  function specNeedsNav(projectRoot, surface) {
5556
- const path = join32(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5557
- if (!existsSync19(path)) return false;
5558
- const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5793
+ const path = join33(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5794
+ if (!existsSync20(path)) return false;
5795
+ const front = readFileSync26(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5559
5796
  const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5560
5797
  const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5561
5798
  return navField || navRegion;
@@ -5603,10 +5840,55 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
5603
5840
  const judged = total - missing.length;
5604
5841
  return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
5605
5842
  }
5843
+ function checkDecisions(projectRoot, dir, errors) {
5844
+ const required = decisionNames(projectRoot);
5845
+ if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
5846
+ const file = readJson(join33(dir, "decisions.json"), errors);
5847
+ if (!file) {
5848
+ errors.push(
5849
+ `decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
5850
+ );
5851
+ return { state: "missing", judged: 0, total: required.length, findings: 0 };
5852
+ }
5853
+ const list = Array.isArray(file.verdicts) ? file.verdicts : [];
5854
+ if (!Array.isArray(file.verdicts)) errors.push('decisions.json has no "verdicts" array.');
5855
+ const seen = /* @__PURE__ */ new Set();
5856
+ let findings = 0;
5857
+ for (const v of list) {
5858
+ const name = typeof v.decision === "string" ? v.decision.trim() : "";
5859
+ if (!name) {
5860
+ errors.push("decisions.json: a verdict names no decision.");
5861
+ continue;
5862
+ }
5863
+ const match = required.find((r) => r.toLowerCase() === name.toLowerCase());
5864
+ if (!match) {
5865
+ errors.push(`decisions.json: "${name}" is not a heading in DECISIONS.md. Judge the decisions the project made, not ones you name yourself.`);
5866
+ continue;
5867
+ }
5868
+ if (seen.has(match)) {
5869
+ errors.push(`decisions.json: "${match}" is judged more than once.`);
5870
+ continue;
5871
+ }
5872
+ seen.add(match);
5873
+ if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
5874
+ errors.push(`decisions.json: "${match}" has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
5875
+ continue;
5876
+ }
5877
+ const reason = typeof v.reason === "string" ? v.reason.trim() : "";
5878
+ if (!reason) errors.push(`decisions.json: "${match}" has no reason. Name what on the page satisfies it, or what does not.`);
5879
+ else if (ABSENCE.test(reason)) errors.push(`decisions.json: "${match}" \u2014 "${reason}" says the decision was not read.`);
5880
+ if (v.verdict === "finding") findings++;
5881
+ }
5882
+ const missing = required.filter((r) => !seen.has(r));
5883
+ if (missing.length) {
5884
+ errors.push(`decisions.json: ${missing.length} of ${required.length} decisions have no verdict: ${missing.join(", ")}.`);
5885
+ }
5886
+ return { state: missing.length ? "incomplete" : "ran", judged: required.length - missing.length, total: required.length, findings };
5887
+ }
5606
5888
  function verifyVerdicts(opts) {
5607
5889
  const root = opts.packageRoot ?? assetRoot();
5608
5890
  const errors = [];
5609
- const index = JSON.parse(readFileSync25(join32(root, "rules.index.json"), "utf8"));
5891
+ const index = JSON.parse(readFileSync26(join33(root, "rules.index.json"), "utf8"));
5610
5892
  const judgment = index.filter((r) => r.bucket === "judgment");
5611
5893
  const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5612
5894
  const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
@@ -5615,16 +5897,16 @@ function verifyVerdicts(opts) {
5615
5897
  ...judgment.map((r) => [r.id, r.pass ?? ""])
5616
5898
  ]);
5617
5899
  const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
5618
- const dir = join32(opts.projectRoot, ".jig", "critique", opts.surface);
5619
- const screenFile = readJson(join32(dir, "screen.json"), errors);
5620
- const codeFile = readJson(join32(dir, "code.json"), errors);
5900
+ const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
5901
+ const screenFile = readJson(join33(dir, "screen.json"), errors);
5902
+ const codeFile = readJson(join33(dir, "code.json"), errors);
5621
5903
  const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5622
5904
  const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5623
5905
  const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5624
5906
  let rendered = false;
5625
5907
  if (screenFile && screenFile.rendered === true) {
5626
5908
  const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5627
- const absent = artefacts.filter((a) => !existsSync19(resolve4(opts.projectRoot, a)));
5909
+ const absent = artefacts.filter((a) => !existsSync20(resolve4(opts.projectRoot, a)));
5628
5910
  if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5629
5911
  else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5630
5912
  else rendered = true;
@@ -5632,7 +5914,7 @@ function verifyVerdicts(opts) {
5632
5914
  const probes = readProbes(opts.projectRoot, dir, errors);
5633
5915
  if (screenFile && screenFile.rendered === true) {
5634
5916
  const widths = new Set(probes.map((p) => p.width));
5635
- const missing = [360, 768, 1280].filter((w) => !widths.has(w));
5917
+ const missing = PROBE_WIDTHS.filter((w) => !widths.has(w));
5636
5918
  if (missing.length) {
5637
5919
  errors.push(`screen.json says rendered: true, but there is no probe at ${missing.join(", ")}px. At each width run \`jig probe\` in the browser and save its output as probe-<width>.json.`);
5638
5920
  rendered = false;
@@ -5643,28 +5925,29 @@ function verifyVerdicts(opts) {
5643
5925
  const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
5644
5926
  return typeof v?.verdict === "string" ? v.verdict : void 0;
5645
5927
  };
5646
- errors.push(...probeContradictions(probes, verdictOf));
5928
+ errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
5647
5929
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
5930
+ const decisions = checkDecisions(opts.projectRoot, dir, errors);
5648
5931
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
5649
- const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings}`;
5650
- return { ok: errors.length === 0, errors, screen, code, rendered, line };
5932
+ const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}`;
5933
+ return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line };
5651
5934
  }
5652
5935
 
5653
5936
  // src/commands/gate.ts
5654
- import { existsSync as existsSync21, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
5937
+ import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync28, writeFileSync as writeFileSync7 } from "fs";
5655
5938
  import { createHash as createHash2 } from "crypto";
5656
- import { join as join34 } from "path";
5939
+ import { join as join35 } from "path";
5657
5940
 
5658
5941
  // src/check/spec-shape.ts
5659
- import { existsSync as existsSync20, readFileSync as readFileSync26, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
5660
- import { join as join33 } from "path";
5942
+ import { existsSync as existsSync21, readFileSync as readFileSync27, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
5943
+ import { join as join34 } from "path";
5661
5944
  function newestSpec(projectRoot) {
5662
- const dir = join33(projectRoot, ".jig", "specs");
5663
- if (!existsSync20(dir)) return void 0;
5945
+ const dir = join34(projectRoot, ".jig", "specs");
5946
+ if (!existsSync21(dir)) return void 0;
5664
5947
  const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
5665
5948
  if (files.length === 0) return void 0;
5666
- const newest = files.map((f) => ({ f, at: statSync2(join33(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5667
- return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join33(dir, newest), "utf8") };
5949
+ const newest = files.map((f) => ({ f, at: statSync2(join34(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5950
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync27(join34(dir, newest), "utf8") };
5668
5951
  }
5669
5952
  function specProblems(spec) {
5670
5953
  const parts = spec.body.split(/^---\s*$/m);
@@ -5678,7 +5961,7 @@ function specProblems(spec) {
5678
5961
  if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
5679
5962
  }
5680
5963
  const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
5681
- for (const size of ["phone", "tablet", "desktop"]) {
5964
+ for (const size of ["phone", "tablet", "desktop", "wide"]) {
5682
5965
  const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
5683
5966
  if (!line) {
5684
5967
  problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
@@ -5719,7 +6002,7 @@ function navProblems(spec) {
5719
6002
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
5720
6003
  const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
5721
6004
  const problems = [];
5722
- for (const size of ["tablet", "desktop"]) {
6005
+ for (const size of ["tablet", "landscape", "desktop", "wide"]) {
5723
6006
  const block = sizeBlock(sizes, size);
5724
6007
  const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
5725
6008
  if (!nav || NONE_RE.test(nav)) continue;
@@ -5733,10 +6016,10 @@ function navProblems(spec) {
5733
6016
  // src/commands/gate.ts
5734
6017
  var MAX_BLOCKS = 3;
5735
6018
  function lastJigCommand(transcriptPath) {
5736
- if (!transcriptPath || !existsSync21(transcriptPath)) return void 0;
6019
+ if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
5737
6020
  let text;
5738
6021
  try {
5739
- text = readFileSync27(transcriptPath, "utf8");
6022
+ text = readFileSync28(transcriptPath, "utf8");
5740
6023
  } catch {
5741
6024
  return void 0;
5742
6025
  }
@@ -5754,10 +6037,10 @@ function commandProblems(root, command) {
5754
6037
  const problems = [];
5755
6038
  const spec = newestSpec(root);
5756
6039
  if (command === "decide") {
5757
- const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join34(root, p)).find((p) => existsSync21(p));
6040
+ const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join35(root, p)).find((p) => existsSync22(p));
5758
6041
  if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
5759
6042
  else {
5760
- const body = readFileSync27(file, "utf8");
6043
+ const body = readFileSync28(file, "utf8");
5761
6044
  if (!/^##\s+Unresolved\s*$/im.test(body)) {
5762
6045
  problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
5763
6046
  }
@@ -5778,15 +6061,18 @@ function commandProblems(root, command) {
5778
6061
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
5779
6062
  if (/^approved/i.test(mockup)) {
5780
6063
  if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
5781
- else if (!/^https?:/i.test(at) && !existsSync21(join34(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
6064
+ else if (!/^https?:/i.test(at) && !existsSync22(join35(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5782
6065
  else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
5783
6066
  }
5784
6067
  }
5785
6068
  if (command === "critique") {
5786
- const dir = join34(root, ".jig", "critique");
5787
- const surfaces = existsSync21(dir) ? readdirSync15(dir).filter((s) => existsSync21(join34(dir, s, "screen.json")) || existsSync21(join34(dir, s, "code.json"))) : [];
6069
+ const dir = join35(root, ".jig", "critique");
6070
+ const surfaces = existsSync22(dir) ? readdirSync15(dir).filter((s) => existsSync22(join35(dir, s, "screen.json")) || existsSync22(join35(dir, s, "code.json"))) : [];
5788
6071
  for (const surface of surfaces) {
5789
6072
  const v = verifyVerdicts({ projectRoot: root, surface });
6073
+ if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
6074
+ problems.push(`${surface}: ${v.decisions.total - v.decisions.judged} of ${v.decisions.total} decisions in DECISIONS.md have no verdict. A page can satisfy every rule and still break what this project decided.`);
6075
+ }
5790
6076
  if (v.ok && v.screen.state === "skipped") {
5791
6077
  problems.push(`${surface}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
5792
6078
  }
@@ -5803,13 +6089,13 @@ function surfacePage(projectRoot, surface) {
5803
6089
  const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
5804
6090
  const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
5805
6091
  for (const candidate of candidates) {
5806
- if (candidate && existsSync21(join34(projectRoot, candidate))) return candidate;
6092
+ if (candidate && existsSync22(join35(projectRoot, candidate))) return candidate;
5807
6093
  }
5808
6094
  return void 0;
5809
6095
  }
5810
6096
  function gate(opts) {
5811
6097
  const root = opts.projectRoot;
5812
- if (!existsSync21(join34(root, "jig.config.json")) && !existsSync21(join34(root, ".jig"))) {
6098
+ if (!existsSync22(join35(root, "jig.config.json")) && !existsSync22(join35(root, ".jig"))) {
5813
6099
  return { block: false, reason: "" };
5814
6100
  }
5815
6101
  const command = lastJigCommand(opts.input.transcript_path);
@@ -5828,11 +6114,11 @@ function gate(opts) {
5828
6114
  );
5829
6115
  }
5830
6116
  }
5831
- const critiqueDir = join34(root, ".jig", "critique");
5832
- if (existsSync21(critiqueDir)) {
6117
+ const critiqueDir = join35(root, ".jig", "critique");
6118
+ if (existsSync22(critiqueDir)) {
5833
6119
  for (const surface of readdirSync15(critiqueDir)) {
5834
- const dir = join34(critiqueDir, surface);
5835
- if (!existsSync21(join34(dir, "screen.json")) && !existsSync21(join34(dir, "code.json"))) continue;
6120
+ const dir = join35(critiqueDir, surface);
6121
+ if (!existsSync22(join35(dir, "screen.json")) && !existsSync22(join35(dir, "code.json"))) continue;
5836
6122
  const v = verifyVerdicts({ projectRoot: root, surface });
5837
6123
  if (!v.ok) {
5838
6124
  const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
@@ -5846,10 +6132,10 @@ function gate(opts) {
5846
6132
  }
5847
6133
  const session = opts.input.session_id ?? "unknown";
5848
6134
  const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
5849
- const stateFile = join34(root, ".jig", "gate.json");
6135
+ const stateFile = join35(root, ".jig", "gate.json");
5850
6136
  let state = {};
5851
6137
  try {
5852
- state = JSON.parse(readFileSync27(stateFile, "utf8"));
6138
+ state = JSON.parse(readFileSync28(stateFile, "utf8"));
5853
6139
  } catch {
5854
6140
  }
5855
6141
  if (problems.length === 0) {
@@ -5879,15 +6165,112 @@ ${problems.join("\n\n")}`
5879
6165
  }
5880
6166
  function save(file, state) {
5881
6167
  try {
5882
- mkdirSync8(join34(file, ".."), { recursive: true });
6168
+ mkdirSync8(join35(file, ".."), { recursive: true });
5883
6169
  writeFileSync7(file, JSON.stringify(state), "utf8");
5884
6170
  } catch {
5885
6171
  }
5886
6172
  }
5887
6173
 
6174
+ // src/commands/seo.ts
6175
+ import { readFileSync as readFileSync29 } from "fs";
6176
+ import { join as join36 } from "path";
6177
+ var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
6178
+ var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
6179
+ var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
6180
+ var TEST_FILE = /(^|\/)(test|tests|__tests__|spec|e2e|fixtures?|stories)\/|\.(test|spec|stories)\.[a-z]+$/i;
6181
+ var ROUTE_DIR = /(?:^|\/)(?:src\/|app\/)?(?:pages|routes)\/(.+)$/;
6182
+ var TEMPLATE = /\.(html?|php|erb|njk|hbs|handlebars|liquid|ejs|twig|jinja2?|j2|mustache)$/i;
6183
+ var PARTS_DIR = /(^|\/)_?(layouts?|components?|partials?|includes?)\//i;
6184
+ var ENDPOINT = /export\s+(?:const|async\s+function|function)\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
6185
+ function isPage(file, raw) {
6186
+ if (TEST_FILE.test(file)) return false;
6187
+ if (/(^|\/)app\/(.+\/)?page\.(tsx|jsx|ts|js|mdx|md)$/.test(file)) return true;
6188
+ const route = ROUTE_DIR.exec(file);
6189
+ if (route) {
6190
+ const rest = route[1];
6191
+ const name = rest.split("/").pop();
6192
+ if (name.startsWith("+")) return name === "+page.svelte";
6193
+ if (/(^|\/)api\//.test(rest) || name.startsWith("_")) return false;
6194
+ if (/\.[a-z]+\.[cm]?[jt]s$/i.test(name)) return false;
6195
+ if (/\.[cm]?[jt]s$/i.test(name)) return /export\s+default\b/.test(raw) && !ENDPOINT.test(raw);
6196
+ return /\.(astro|html?|mdx?|vue|svelte|[jt]sx)$/i.test(name);
6197
+ }
6198
+ return TEMPLATE.test(file) && !PARTS_DIR.test(file) && isWholeDocument(raw);
6199
+ }
6200
+ function routeOf(file) {
6201
+ const path = file.replace(/^(?:src\/pages|app\/pages|app\/routes|src\/routes|pages|routes|app|src|public|content)\//, "").replace(/(^|\/)(index|\+?page)\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "$1").replace(/\.(html?|astro|vue|svelte|[jt]sx?|md|mdx)$/i, "").replace(/\/$/, "");
6202
+ return `/${path}`.replace(/\/+/g, "/");
6203
+ }
6204
+ function seo(opts) {
6205
+ const root = opts.projectRoot;
6206
+ const files = selectFiles(root, true).files;
6207
+ const findings = [];
6208
+ const add = (file, ruleId, severity, message) => findings.push({ file, ruleId, severity, message });
6209
+ const pages = [];
6210
+ let sitemapFiles = [];
6211
+ let robotsFiles = [];
6212
+ const sitemapRoutes = /* @__PURE__ */ new Map();
6213
+ const pathLocs = [];
6214
+ for (const file of files) {
6215
+ let raw;
6216
+ try {
6217
+ raw = readFileSync29(join36(root, file), "utf8");
6218
+ } catch {
6219
+ continue;
6220
+ }
6221
+ if (SITEMAP_FILE.test(file)) {
6222
+ sitemapFiles.push(file);
6223
+ for (const match of raw.matchAll(ROUTE_IN_SITEMAP)) {
6224
+ const value = (match[1] ?? match[2] ?? "").trim();
6225
+ if (!value) continue;
6226
+ if (match[1] && !/^https?:\/\//i.test(value)) pathLocs.push({ file, loc: value });
6227
+ const path = value.replace(/^https?:\/\/[^/]+/i, "") || "/";
6228
+ sitemapRoutes.set(path.replace(/\/$/, "") || "/", file);
6229
+ }
6230
+ continue;
6231
+ }
6232
+ if (ROBOTS_FILE.test(file)) {
6233
+ robotsFiles.push(file);
6234
+ continue;
6235
+ }
6236
+ if (!isPage(file, raw)) continue;
6237
+ const found = readMetadata(raw);
6238
+ const settled = found.titles.length === 1 && !found.computedTitle ? found.titles[0].value : void 0;
6239
+ pages.push({ file, route: routeOf(file), title: settled, noindex: found.noindex });
6240
+ }
6241
+ for (const page of pages.filter((p) => p.noindex)) {
6242
+ const listed = sitemapRoutes.get(page.route) ?? sitemapRoutes.get(`${page.route}/`);
6243
+ if (listed) {
6244
+ add(listed, "J-124", "error", `the sitemap lists ${page.route}, and ${page.file} says noindex. One answer per route: a crawler reading two picks one.`);
6245
+ }
6246
+ }
6247
+ if (pathLocs.length > 0) {
6248
+ const shown = pathLocs.slice(0, 3).map((p) => p.loc).join(", ");
6249
+ add(pathLocs[0].file, "J-124", "error", `${pathLocs.length} sitemap entr${pathLocs.length === 1 ? "y is a path" : "ies are paths"} (${shown}), not a full URL. A crawler drops them; with no origin yet, no sitemap is the honest one.`);
6250
+ }
6251
+ const byTitle = /* @__PURE__ */ new Map();
6252
+ for (const page of pages) {
6253
+ if (!page.title || page.noindex) continue;
6254
+ byTitle.set(page.title, [...byTitle.get(page.title) ?? [], page.file]);
6255
+ }
6256
+ for (const [title, where] of byTitle) {
6257
+ if (where.length > 1) {
6258
+ add(where[1], "J-121", "warning", `"${title}" is the title of ${where.length} pages (${where.join(", ")}). A result page showing the same line twice tells a reader nothing about either.`);
6259
+ }
6260
+ }
6261
+ const indexable = pages.filter((p) => !p.noindex);
6262
+ const publicSurface = opts.mode !== "product" && opts.mode !== "operator";
6263
+ if (publicSurface && indexable.length > 0 && sitemapFiles.length > 0 && sitemapRoutes.size === 0) {
6264
+ add(sitemapFiles[0], "J-127", "error", "the sitemap lists no routes. An empty sitemap is not a missing one: it is a claim that the site has nothing.");
6265
+ }
6266
+ const titleBudget = pages.filter((p) => p.title && p.title.length > TITLE_BUDGET).length;
6267
+ const line = `JIG_SEO: pages=${pages.length} indexable=${indexable.length} noindex=${pages.length - indexable.length} sitemap=${sitemapFiles.length > 0 ? sitemapRoutes.size : "none"} robots=${robotsFiles.length > 0 ? "yes" : "no"} overlong=${titleBudget} findings=${findings.length}`;
6268
+ return { findings, pages: pages.length, indexable: indexable.length, line };
6269
+ }
6270
+
5888
6271
  // src/index.ts
5889
6272
  var packageRoot = getPackageRoot();
5890
- var { version } = JSON.parse(readFileSync28(join35(packageRoot, "package.json"), "utf8"));
6273
+ var { version } = JSON.parse(readFileSync30(join37(packageRoot, "package.json"), "utf8"));
5891
6274
  function warnIfUnpublishedPin() {
5892
6275
  if (isPublishedBuild(packageRoot)) return;
5893
6276
  console.warn(
@@ -6013,6 +6396,24 @@ program.command("check").description("Check the repo against Jig's mechanical +
6013
6396
  process.exit(1);
6014
6397
  }
6015
6398
  });
6399
+ program.command("seo").description("Audit what a search engine and a link preview read, across the whole project.").option("--json", "emit findings as JSON", false).action((opts) => {
6400
+ const projectRoot = findProjectRoot(process.cwd());
6401
+ try {
6402
+ const result = seo({ projectRoot, mode: resolveSurfaceMode(projectRoot) });
6403
+ if (opts.json) {
6404
+ console.log(JSON.stringify(result.findings, null, 2));
6405
+ } else {
6406
+ const symbol = { error: "\u2717", warning: "\u26A0", note: "\xB7" };
6407
+ for (const f of result.findings) console.log(` ${symbol[f.severity]} ${f.ruleId} ${f.message} ${f.file}`);
6408
+ if (result.findings.length === 0) console.log(" Nothing to fix in what a search engine reads.");
6409
+ console.log(` ${result.line}`);
6410
+ }
6411
+ process.exit(result.findings.some((f) => f.severity === "error") ? 1 : 0);
6412
+ } catch (err) {
6413
+ console.error(err.message);
6414
+ process.exit(1);
6415
+ }
6416
+ });
6016
6417
  program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").option("--run <page>", "render this page here, at 360, 768 and 1280, and record each (needs --save)").action(async (opts) => {
6017
6418
  if (opts.run) {
6018
6419
  if (!opts.save) {
@@ -6055,7 +6456,7 @@ program.command("probe").description("Print the render probe. With --save, read
6055
6456
  program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
6056
6457
  let input = {};
6057
6458
  try {
6058
- if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
6459
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync30(0, "utf8") || "{}");
6059
6460
  } catch {
6060
6461
  }
6061
6462
  const cwd = input.cwd ?? process.cwd();
@@ -6092,4 +6493,13 @@ program.command("init").description("Set the project up to use Jig: a brand file
6092
6493
  process.exit(1);
6093
6494
  }
6094
6495
  });
6496
+ function resolveSurfaceMode(projectRoot) {
6497
+ try {
6498
+ const config = JSON.parse(readFileSync30(join37(projectRoot, "jig.config.json"), "utf8"));
6499
+ const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
6500
+ return modes.length === 1 ? modes[0] : void 0;
6501
+ } catch {
6502
+ return void 0;
6503
+ }
6504
+ }
6095
6505
  program.parse();