@domandigital/craft 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -866,10 +866,14 @@ var COPY_TELLS = [
866
866
  surface: "copy",
867
867
  why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
868
868
  fix: "Write one sentence that says which of the three is true, and how you know.",
869
- detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
869
+ // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
870
+ detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
870
871
  fixtures: {
871
- flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
872
- pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
872
+ flag: [f("content/home.md", "No fuss. No jargon. Just results."), f("content/home.md", "No fuss.\nNo jargon.\nJust results.")],
873
+ pass: [
874
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
875
+ f("content/guide.md", "Your competitor appears in the top three. You don't. Again.\n\nIt's frustrating.")
876
+ ]
873
877
  }
874
878
  },
875
879
  {
@@ -888,6 +892,14 @@ var COPY_TELLS = [
888
892
  }
889
893
  ];
890
894
  var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
895
+ var PROMPT_CONTEXT = [
896
+ /\b(?:the|this|that)\s+(?:attached|uploaded|provided)\s+(?:(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\s+)?(?:research|research file|research pack|source file|sources)\b/i,
897
+ /\b(?:attached|uploaded)\s+(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\b/i,
898
+ /\bthe\s+(?:attached|uploaded|provided)\s+brief\s+(?:asks|says|wants|requests|suggests|mentions)\b/i
899
+ ];
900
+ function gerundList(block, _match, index) {
901
+ return /(?:^|[(,;:]\s*)\w+ing\b[^,.;()!?]{0,40}$/.test(block.text.slice(Math.max(0, index - 60), index));
902
+ }
891
903
  var VAGUE_ATTRIBUTION = [
892
904
  /\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
893
905
  /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
@@ -973,8 +985,10 @@ function inlineLabelHits(ctx) {
973
985
  run = [];
974
986
  };
975
987
  for (const line of lines) {
976
- const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
977
- if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
988
+ const m = /^\s*[-*+]\s+\*\*([^*]{1,40}?):?\*\*:?\s+(\S.*)$/.exec(line);
989
+ const data = m && /[\d£$€%`]|https?:|^\*\*/.test(m[2]);
990
+ const named = m && /\S[A-Z]|[./]/.test(m[1]);
991
+ if (m && !data && !named && m[2].trim().split(/\s+/).length <= 6) run.push(offset);
978
992
  else flush();
979
993
  offset += line.length + 1;
980
994
  }
@@ -1012,6 +1026,29 @@ var RESEARCH_TELLS = [
1012
1026
  ]
1013
1027
  }
1014
1028
  },
1029
+ {
1030
+ id: "prompt-context",
1031
+ name: "Prompt context",
1032
+ generation: 1,
1033
+ severity: "warn",
1034
+ surface: "copy",
1035
+ why: "'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell.",
1036
+ fix: "Delete the reference to the research or brief. Where it carried a claim, name the real source or cut the claim.",
1037
+ detect: phrases(PROMPT_CONTEXT, quoted),
1038
+ fixtures: {
1039
+ flag: [
1040
+ f("content/guide.md", "This article uses the attached Perplexity research file as the primary source."),
1041
+ f("content/guide.md", "The attached research is right to say these platforms suit early-stage businesses."),
1042
+ f("content/guide.md", "That is exactly how the uploaded research frames the category."),
1043
+ f("content/guide.md", "The attached brief asks for the point that most tradespeople miss.")
1044
+ ],
1045
+ pass: [
1046
+ f("content/guide.md", "We research every quote before we send it."),
1047
+ f("app/email.tsx", `export const Invoice = () => <p>Your invoice is attached.</p>;`),
1048
+ f("content/guide.md", "BrightLocal's research shows that reviews drive 20% of local ranking.")
1049
+ ]
1050
+ }
1051
+ },
1015
1052
  {
1016
1053
  id: "placeholder",
1017
1054
  name: "Unfilled placeholder",
@@ -1064,7 +1101,11 @@ var RESEARCH_TELLS = [
1064
1101
  pass: [
1065
1102
  f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
1066
1103
  f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
1067
- f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n")
1104
+ f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n"),
1105
+ f("content/guide.md", "- **Per page**: \xA3100-\xA3200 per page\n- **Hourly**: \xA350-\xA375/hour\n- **Day rate**: \xA3350-\xA3500\n"),
1106
+ f("content/guide.md", "- **Law Society Find a Solicitor:** For solicitors\n- **AccountingWEB / ICAEW directories:** For accountants\n- **NHS.uk:** For NHS services\n"),
1107
+ f("content/guide.md", "1. **Visit PageSpeed Insights** and paste the address\n2. **Enter their URL** and run it\n3. **Note their scores** for later\n"),
1108
+ f("content/guide.md", "- **Structural problems:** **REBUILD.**\n- **Messaging issues:** **REFRESH.**\n- **Neither:** **LEAVE IT.**\n")
1068
1109
  ]
1069
1110
  }
1070
1111
  },
@@ -1076,7 +1117,7 @@ var RESEARCH_TELLS = [
1076
1117
  surface: "copy",
1077
1118
  why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
1078
1119
  fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
1079
- detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
1120
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, "")), void 0, gerundList),
1080
1121
  fixtures: {
1081
1122
  flag: [
1082
1123
  f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
@@ -1084,7 +1125,8 @@ var RESEARCH_TELLS = [
1084
1125
  ],
1085
1126
  pass: [
1086
1127
  f("content/home.md", "We open at 7am, so you can drop the car off before work."),
1087
- f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
1128
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers."),
1129
+ f("content/guide.md", "Many items (claiming your GBP, ensuring NAP consistency, writing title tags) are straightforward.")
1088
1130
  ]
1089
1131
  }
1090
1132
  },
@@ -1334,6 +1376,7 @@ function repeatHits(ctx) {
1334
1376
  for (const s of sentences(doc2)) {
1335
1377
  const words = normal(s.text);
1336
1378
  if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
1379
+ if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
1337
1380
  const key = words.join(" ");
1338
1381
  const earlier = whole.get(key);
1339
1382
  if (earlier) {
@@ -1531,7 +1574,15 @@ var DENSITY_TELLS = [
1531
1574
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
1532
1575
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
1533
1576
  ],
1534
- pass: [f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet."))]
1577
+ pass: [
1578
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet.")),
1579
+ f2(
1580
+ "content/guide.md",
1581
+ doc(
1582
+ "The Independent Healthcare Providers Network Private Patient Insight Report 2024 found that private demand rose.[^1]\n\n[^1]: Independent Healthcare Providers Network, Private Patient Insight Report 2024, published online."
1583
+ )
1584
+ )
1585
+ ]
1535
1586
  }
1536
1587
  },
1537
1588
  {
@@ -2644,6 +2695,9 @@ var HOUSE = {
2644
2695
  "closing-summary": { tier: "review", label: "closing summary" },
2645
2696
  "false-range": { tier: "review", label: "false range" },
2646
2697
  "placeholder": { tier: "review", label: "unfilled placeholder" },
2698
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
2699
+ // a proposal can honestly say "the attached research". Review until measured there.
2700
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
2647
2701
  "question-reveal": { tier: "review", label: "staged reveal" },
2648
2702
  "inline-label-list": { tier: "review", label: "bold-label bullets" },
2649
2703
  "phrase-density": { tier: "review", label: "density" },
@@ -2992,7 +3046,7 @@ var RENDERED_PATHS = {
2992
3046
  };
2993
3047
 
2994
3048
  // src/character/check.ts
2995
- var CATALOGUE_VERSION = "2026.09.7";
3049
+ var CATALOGUE_VERSION = "2026.09.8";
2996
3050
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
2997
3051
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
2998
3052
  );
@@ -1,10 +1,11 @@
1
1
  import {
2
- parseFlags
3
- } from "./chunk-YLQE35PN.js";
2
+ parseFlags,
3
+ toJson
4
+ } from "./chunk-2AJB27BJ.js";
4
5
  import {
5
6
  fingerprint,
6
7
  fingerprintDistance
7
- } from "./chunk-EHNEGKFR.js";
8
+ } from "./chunk-QMYERCBB.js";
8
9
  import {
9
10
  CATALOGUE_VERSION,
10
11
  REFLEX_FONTS_1,
@@ -18,7 +19,7 @@ import {
18
19
  isCream,
19
20
  oklchToRgb,
20
21
  scanSource
21
- } from "./chunk-ZT3WLN7R.js";
22
+ } from "./chunk-3NGWXZZE.js";
22
23
 
23
24
  // src/null/cli.ts
24
25
  import { spawn } from "child_process";
@@ -352,7 +353,7 @@ async function build(flags, io) {
352
353
  const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
353
354
  if (toShoot.length) {
354
355
  io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
355
- const { snapshotUrls } = await import("./audit-5P65UMFL.js");
356
+ const { snapshotUrls } = await import("./audit-QIPXL7E4.js");
356
357
  const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
357
358
  results.forEach((r, i) => {
358
359
  const id = toShoot[i];
@@ -439,7 +440,7 @@ function runHarvest(args, io) {
439
440
  const minShare = flags.share === void 0 ? void 0 : Number(flags.share);
440
441
  if (minShare !== void 0 && !(minShare > 0 && minShare <= 1)) throw new Error("--share needs a number above 0 and at most 1");
441
442
  const candidates = harvest(models, { minShare, copyTell });
442
- io.out(flags.json ? JSON.stringify({ briefs: models.map((m) => m.brief), candidates }, null, 2) : formatHarvest(candidates, models));
443
+ io.out(flags.json ? toJson({ briefs: models.map((m) => m.brief), candidates }) : formatHarvest(candidates, models));
443
444
  return 0;
444
445
  }
445
446
 
@@ -3,18 +3,19 @@ import {
3
3
  hueName,
4
4
  loadNull,
5
5
  shapeName
6
- } from "./chunk-VKQDOUHO.js";
6
+ } from "./chunk-3OZQ4C5R.js";
7
7
  import {
8
- parseFlags
9
- } from "./chunk-YLQE35PN.js";
8
+ parseFlags,
9
+ toJson
10
+ } from "./chunk-2AJB27BJ.js";
10
11
  import {
11
12
  fingerprint,
12
13
  fingerprintDistance
13
- } from "./chunk-EHNEGKFR.js";
14
+ } from "./chunk-QMYERCBB.js";
14
15
  import {
15
16
  SNAPSHOT_VERSION,
16
17
  auditSnapshot
17
- } from "./chunk-ZT3WLN7R.js";
18
+ } from "./chunk-3NGWXZZE.js";
18
19
 
19
20
  // src/estate/cli.ts
20
21
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -92,7 +93,7 @@ async function snapshotOf(target, cwd) {
92
93
  return snap;
93
94
  }
94
95
  if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
95
- const { snapshotUrl } = await import("./audit-5P65UMFL.js");
96
+ const { snapshotUrl } = await import("./audit-QIPXL7E4.js");
96
97
  return snapshotUrl(target);
97
98
  }
98
99
  var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
@@ -137,13 +138,13 @@ async function runEstate(args, io) {
137
138
  if (sub === "compare") {
138
139
  if (!target) {
139
140
  const pairs = estatePairs(register, at);
140
- io.out(flags.json ? JSON.stringify({ siblingAt: at, pairs }, null, 2) : formatPairs(pairs, at, register.sites.length));
141
+ io.out(flags.json ? toJson({ siblingAt: at, pairs }) : formatPairs(pairs, at, register.sites.length));
141
142
  return flags.strict && pairs.some((p) => p.sibling) ? 1 : 0;
142
143
  }
143
144
  const known = register.sites.find((s) => s.id === target);
144
145
  const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
145
146
  const matches = compareToEstate(fp, register, { exclude: known?.id, siblingAt: at });
146
- io.out(flags.json ? JSON.stringify({ siblingAt: at, matches }, null, 2) : formatMatches(known?.id ?? target, matches, at));
147
+ io.out(flags.json ? toJson({ siblingAt: at, matches }) : formatMatches(known?.id ?? target, matches, at));
147
148
  return flags.strict && matches.some((m) => m.sibling) ? 1 : 0;
148
149
  }
149
150
  throw new Error(USAGE);
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  fingerprint
3
- } from "./chunk-EHNEGKFR.js";
3
+ } from "./chunk-QMYERCBB.js";
4
4
  import {
5
5
  SNAPSHOT_VERSION,
6
6
  auditSnapshot
7
- } from "./chunk-ZT3WLN7R.js";
7
+ } from "./chunk-3NGWXZZE.js";
8
8
 
9
9
  // src/audit/collect.ts
10
10
  function collectInPage() {
@@ -390,7 +390,9 @@ async function snapshotUrls(urls, options = {}) {
390
390
  for (const url of urls) {
391
391
  const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
392
392
  try {
393
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
393
+ const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
394
+ const status = typeof response?.status === "function" ? response.status() : 200;
395
+ if (status >= 400) throw new Error(`HTTP ${status}`);
394
396
  out.push({ url, snapshot: await snapshotPage(page, url, options) });
395
397
  } catch (error) {
396
398
  out.push({ url, error: error.message });
@@ -6,7 +6,7 @@ import {
6
6
  isCream,
7
7
  normaliseFamily,
8
8
  parseColour
9
- } from "./chunk-ZT3WLN7R.js";
9
+ } from "./chunk-3NGWXZZE.js";
10
10
 
11
11
  // src/direction/types.ts
12
12
  var DIRECTION_VERSION = 1;
@@ -0,0 +1,206 @@
1
+ import {
2
+ snapshotUrl,
3
+ snapshotUrls
4
+ } from "./chunk-JP75P2DU.js";
5
+ import {
6
+ describeTypicality,
7
+ loadNull,
8
+ typicality
9
+ } from "./chunk-3OZQ4C5R.js";
10
+ import {
11
+ SOURCE_FILE,
12
+ finish,
13
+ loadConfig,
14
+ loadExceptions,
15
+ parseFlags,
16
+ prepareReport,
17
+ readPaths,
18
+ toJson
19
+ } from "./chunk-2AJB27BJ.js";
20
+ import {
21
+ fingerprint
22
+ } from "./chunk-QMYERCBB.js";
23
+ import {
24
+ SNAPSHOT_VERSION,
25
+ auditSnapshot,
26
+ scanSource
27
+ } from "./chunk-3NGWXZZE.js";
28
+
29
+ // src/audit/cli.ts
30
+ import { existsSync, readFileSync, writeFileSync } from "fs";
31
+ import { resolve } from "path";
32
+
33
+ // src/audit/pages.ts
34
+ var decode = (s) => s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'");
35
+ function parseSitemap(xml) {
36
+ const locs = [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => decode(m[1]));
37
+ return /<sitemapindex[\s>]/i.test(xml) ? { urls: [], sitemaps: locs } : { urls: locs, sitemaps: [] };
38
+ }
39
+ function parseUrlList(text) {
40
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
41
+ }
42
+ function parseViewports(value) {
43
+ const widths = value.split(",").map((w) => w.trim());
44
+ return widths.map((w) => {
45
+ const width = Number(w);
46
+ if (!Number.isInteger(width) || width < 240 || width > 3840) throw new Error(`--viewport: "${w}" is not a width in px between 240 and 3840`);
47
+ return { width, height: width < 600 ? 844 : width < 1024 ? 1024 : 900 };
48
+ });
49
+ }
50
+
51
+ // src/audit/cli.ts
52
+ var fmt = (o) => o ? `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` : "none";
53
+ function describeFingerprint(fp) {
54
+ return [
55
+ "Fingerprint",
56
+ ` accent ${fmt(fp.accent)}`,
57
+ ` ground ${fmt(fp.ground)}`,
58
+ ` display ${fp.display.family} (${fp.display.class})`,
59
+ ` body ${fp.body.family} (${fp.body.class})`,
60
+ ` roundness ${fp.roundness === null ? "no buttons" : fp.roundness.toFixed(2)}`,
61
+ ` motion ${Math.round(fp.motion * 100)}% of sections below the fold reveal on scroll`,
62
+ ` effects ${fp.effects.join(", ") || "none"}`,
63
+ ` layout ${fp.layout.join(" > ") || "none"}`
64
+ ].join("\n");
65
+ }
66
+ function merge(a, b) {
67
+ const byTell = { ...a.summary.byTell };
68
+ for (const [k, v] of Object.entries(b.summary.byTell)) byTell[k] = (byTell[k] ?? 0) + v;
69
+ return {
70
+ catalogueVersion: a.catalogueVersion,
71
+ findings: [...a.findings, ...b.findings],
72
+ rejectedExceptions: a.rejectedExceptions,
73
+ excepted: [...a.excepted, ...b.excepted],
74
+ suppressed: [...a.suppressed, ...b.suppressed],
75
+ summary: {
76
+ files: a.summary.files + b.summary.files,
77
+ findings: a.summary.findings + b.summary.findings,
78
+ byGeneration: { 1: a.summary.byGeneration[1] + b.summary.byGeneration[1], 2: a.summary.byGeneration[2] + b.summary.byGeneration[2], 3: a.summary.byGeneration[3] + b.summary.byGeneration[3] },
79
+ byTell,
80
+ blocking: a.summary.blocking + b.summary.blocking
81
+ }
82
+ };
83
+ }
84
+ function readSnapshot(path) {
85
+ const data = JSON.parse(readFileSync(path, "utf8"));
86
+ if (data.version !== SNAPSHOT_VERSION) throw new Error(`${path} is snapshot version ${String(data.version)}; this craft reads ${SNAPSHOT_VERSION}`);
87
+ return data;
88
+ }
89
+ async function readText(source, cwd) {
90
+ if (/^https?:\/\//i.test(source)) {
91
+ const res = await fetch(source);
92
+ if (!res.ok) throw new Error(`${source}: HTTP ${res.status}`);
93
+ return res.text();
94
+ }
95
+ const path = resolve(cwd, source);
96
+ if (!existsSync(path)) throw new Error(`no such file: ${source}`);
97
+ return readFileSync(path, "utf8");
98
+ }
99
+ async function resolvePages(source, cwd) {
100
+ const text = await readText(source, cwd);
101
+ if (!/^\s*<(?:\?xml|urlset|sitemapindex)/i.test(text)) return parseUrlList(text);
102
+ const { urls, sitemaps } = parseSitemap(text);
103
+ const nested = await Promise.all(sitemaps.map(async (s) => parseSitemap(await readText(s, cwd)).urls));
104
+ const all = [...urls, ...nested.flat()];
105
+ if (all.length === 0) throw new Error(`${source}: no <loc> entries`);
106
+ return [...new Set(all)];
107
+ }
108
+ function viewportsOf(flags) {
109
+ if (flags.viewport) return parseViewports(flags.viewport);
110
+ if (flags.width || flags.height) return [{ width: Number(flags.width ?? 1440), height: Number(flags.height ?? 900) }];
111
+ return [void 0];
112
+ }
113
+ async function runAudit(command, args, io) {
114
+ try {
115
+ const flags = parseFlags(args);
116
+ if (typeof flags === "string") throw new Error(flags);
117
+ const target = flags.positional[0];
118
+ if (command === "snapshot" && (flags.pages || flags.viewport)) throw new Error("craft snapshot takes one page; use --width and --height");
119
+ if (flags.pages && target) throw new Error("give a page or --pages, not both");
120
+ if (!target && !flags.pages) throw new Error(`usage: craft ${command} <url${command === "audit" ? " | snapshot.json> | --pages <sitemap.xml | urls.txt>" : ">"}`);
121
+ const viewports = viewportsOf(flags);
122
+ const audited = [];
123
+ const failed = [];
124
+ const local = target ? resolve(io.cwd, target) : "";
125
+ if (target && command === "audit" && /\.json$/i.test(target) && existsSync(local)) {
126
+ audited.push({ url: target, viewport: null, snapshot: readSnapshot(local) });
127
+ } else if (target && viewports.length === 1) {
128
+ const snapshot = await snapshotUrl(target, { viewport: viewports[0] });
129
+ audited.push({ url: target, viewport: viewports[0] ?? null, snapshot });
130
+ } else {
131
+ const urls = flags.pages ? await resolvePages(flags.pages, io.cwd) : [target];
132
+ for (const viewport of viewports) {
133
+ for (const r of await snapshotUrls(urls, { viewport })) {
134
+ if (r.snapshot) audited.push({ url: r.url, viewport: viewport ?? null, snapshot: r.snapshot });
135
+ else failed.push({ url: r.url, viewport: viewport ?? null, error: r.error ?? "no snapshot" });
136
+ }
137
+ }
138
+ }
139
+ if (audited.length === 0) throw new Error(`no page could be measured:
140
+ ${failed.map((f) => ` ${f.url}: ${f.error}`).join("\n")}`);
141
+ const single = audited.length === 1 && failed.length === 0;
142
+ if (flags.out) {
143
+ if (!single) throw new Error("--out saves one snapshot; drop --pages and --viewport, or snapshot each page");
144
+ writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(audited[0].snapshot, null, 2)}
145
+ `);
146
+ }
147
+ if (command === "snapshot") {
148
+ const { snapshot } = audited[0];
149
+ if (!flags.out) io.out(JSON.stringify(snapshot, null, 2));
150
+ else io.out(`craft snapshot: ${snapshot.url} saved to ${flags.out}`);
151
+ return 0;
152
+ }
153
+ const root = flags.repo ? resolve(io.cwd, flags.repo) : io.cwd;
154
+ const exceptions = loadExceptions(flags, root);
155
+ const config = loadConfig(flags, root);
156
+ const label = (a) => viewports.length > 1 && a.viewport ? `${a.snapshot.url} @ ${a.viewport.width}px` : a.snapshot.url;
157
+ let report = audited.map((a) => {
158
+ const r = auditSnapshot(a.snapshot, { exceptions });
159
+ return { ...r, findings: r.findings.map((f) => ({ ...f, path: label(a) })) };
160
+ }).reduce((acc, r) => merge(acc, r));
161
+ if (flags.repo) {
162
+ report = merge(scanSource(readPaths(["."], root, SOURCE_FILE, void 0, config.ignore), { exceptions }), report);
163
+ }
164
+ const prepared = prepareReport(report, flags, io, config);
165
+ if (!prepared) return 0;
166
+ const nulls = flags.null ? flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd)) : [];
167
+ const pooled = nulls.flatMap((m, i) => m.runs.map((r) => nulls.length > 1 ? { ...r, id: `${i + 1}/${r.id}` } : r));
168
+ const pages = audited.map((a) => {
169
+ const fp = fingerprint(a.snapshot);
170
+ return { url: a.snapshot.url, viewport: a.viewport, fingerprint: fp, typicality: nulls.length ? typicality(fp, pooled) : null };
171
+ });
172
+ const unmeasured = failed.length > 0 && flags.strict ? 1 : 0;
173
+ if (flags.json) {
174
+ const extra = single ? { fingerprint: pages[0].fingerprint, typicality: pages[0].typicality, snapshot: flags.out ?? null } : {};
175
+ io.out(toJson({ report: prepared, ...extra, pages, failed }));
176
+ return Math.max(prepared.summary.blocking > 0 || flags.strict && prepared.summary.findings > 0 ? 1 : 0, unmeasured);
177
+ }
178
+ const title = single ? `craft audit ${audited[0].snapshot.url}` : `craft audit: ${audited.length} page${audited.length === 1 ? "" : "s"}`;
179
+ const code = finish(prepared, { ...flags, json: false }, title, io);
180
+ for (const page of pages) {
181
+ io.out("");
182
+ if (!single) io.out(`${page.url}${page.viewport ? ` @ ${page.viewport.width}px` : ""}`);
183
+ io.out(describeFingerprint(page.fingerprint));
184
+ if (page.typicality) {
185
+ io.out("");
186
+ io.out(describeTypicality(page.typicality, pooled.length, nulls.length));
187
+ }
188
+ }
189
+ if (failed.length) {
190
+ io.out("");
191
+ io.out("Not measured (never counts as a pass):");
192
+ for (const f of failed) io.out(` ${f.url}${f.viewport ? ` @ ${f.viewport.width}px` : ""}: ${f.error}`);
193
+ }
194
+ return Math.max(code, unmeasured);
195
+ } catch (error) {
196
+ io.err(`craft: ${error.message}`);
197
+ return 2;
198
+ }
199
+ }
200
+
201
+ export {
202
+ describeFingerprint,
203
+ merge,
204
+ resolvePages,
205
+ runAudit
206
+ };
@@ -3,7 +3,7 @@ import {
3
3
  fontClass,
4
4
  normaliseFamily,
5
5
  parseColour
6
- } from "./chunk-ZT3WLN7R.js";
6
+ } from "./chunk-3NGWXZZE.js";
7
7
 
8
8
  // src/fingerprint/index.ts
9
9
  var FINGERPRINT_VERSION = 1;
@@ -6,10 +6,10 @@ import {
6
6
  nullPath,
7
7
  runHarvest,
8
8
  runNull
9
- } from "./chunk-VKQDOUHO.js";
10
- import "./chunk-YLQE35PN.js";
11
- import "./chunk-EHNEGKFR.js";
12
- import "./chunk-ZT3WLN7R.js";
9
+ } from "./chunk-3OZQ4C5R.js";
10
+ import "./chunk-2AJB27BJ.js";
11
+ import "./chunk-QMYERCBB.js";
12
+ import "./chunk-3NGWXZZE.js";
13
13
  export {
14
14
  describeTypicality,
15
15
  formatHarvest,
@@ -1,37 +1,40 @@
1
1
  import {
2
2
  validateDirection
3
- } from "./chunk-JRMU6GYX.js";
3
+ } from "./chunk-JWHC67ZS.js";
4
4
  import {
5
5
  merge
6
- } from "./chunk-DAENYJUO.js";
7
- import "./chunk-BYY32NIN.js";
6
+ } from "./chunk-MSQ57PJU.js";
7
+ import "./chunk-JP75P2DU.js";
8
8
  import {
9
9
  SIBLING_AT,
10
10
  compareToEstate,
11
11
  loadEstate
12
- } from "./chunk-5LI2VFN5.js";
12
+ } from "./chunk-HOHHQB4N.js";
13
13
  import {
14
14
  loadNull,
15
15
  typicality
16
- } from "./chunk-VKQDOUHO.js";
16
+ } from "./chunk-3OZQ4C5R.js";
17
17
  import {
18
18
  COPY_FILE,
19
19
  NOT_COPY_DIR,
20
20
  SOURCE_FILE,
21
+ applySeverity,
22
+ loadConfig,
21
23
  loadExceptions,
22
24
  parseFlags,
23
- readPaths
24
- } from "./chunk-YLQE35PN.js";
25
+ readPaths,
26
+ toJson
27
+ } from "./chunk-2AJB27BJ.js";
25
28
  import {
26
29
  fingerprint
27
- } from "./chunk-EHNEGKFR.js";
30
+ } from "./chunk-QMYERCBB.js";
28
31
  import {
29
32
  CATALOGUE,
30
33
  SNAPSHOT_VERSION,
31
34
  auditSnapshot,
32
35
  checkCopy,
33
36
  scanSource
34
- } from "./chunk-ZT3WLN7R.js";
37
+ } from "./chunk-3NGWXZZE.js";
35
38
 
36
39
  // src/report/cli.ts
37
40
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -259,17 +262,20 @@ async function runReport(command, args, io) {
259
262
  snap = JSON.parse(readFileSync(local, "utf8"));
260
263
  if (snap.version !== SNAPSHOT_VERSION) throw new Error(`${target2} is not a snapshot`);
261
264
  } else {
262
- const { snapshotUrl } = await import("./audit-5P65UMFL.js");
265
+ const { snapshotUrl } = await import("./audit-QIPXL7E4.js");
263
266
  snap = await snapshotUrl(target2);
264
267
  }
265
268
  const fp = fingerprint(snap);
266
269
  const repo = flags.repo ? resolve(io.cwd, flags.repo) : null;
267
270
  const exceptions = loadExceptions(flags, repo ?? io.cwd);
271
+ const config = loadConfig(flags, repo ?? io.cwd);
268
272
  let findings = auditSnapshot(snap, { exceptions });
269
273
  if (repo) {
270
- findings = merge(scanSource(readPaths(["."], repo, SOURCE_FILE), { exceptions }), findings);
271
- findings = merge(findings, checkCopy(readPaths(["."], repo, COPY_FILE, (d) => NOT_COPY_DIR(d) || d === "docs"), { exceptions }));
274
+ findings = merge(scanSource(readPaths(["."], repo, SOURCE_FILE, void 0, config.ignore), { exceptions }), findings);
275
+ const copyPaths = config.copyPaths ?? ["."];
276
+ findings = merge(findings, checkCopy(readPaths(copyPaths, repo, COPY_FILE, (d) => NOT_COPY_DIR(d) || d === "docs", config.ignore), { exceptions }));
272
277
  }
278
+ findings = applySeverity(findings, config);
273
279
  const nulls = flags.null ? flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd)) : [];
274
280
  const typical = nulls.length ? typicality(fp, nulls.flatMap((m, i) => m.runs.map((r) => nulls.length > 1 ? { ...r, id: `${i + 1}/${r.id}` } : r))) : null;
275
281
  let estate = null;
@@ -297,8 +303,8 @@ async function runReport(command, args, io) {
297
303
  } else io.out(plan);
298
304
  return 0;
299
305
  }
300
- io.out(flags.json ? JSON.stringify(report, null, 2) : formatCharacter(report));
301
- if (flags.out) writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(report, null, 2)}
306
+ io.out(flags.json ? toJson(report) : formatCharacter(report));
307
+ if (flags.out) writeFileSync(resolve(io.cwd, flags.out), `${toJson(report)}
302
308
  `);
303
309
  return flags.strict && report.verdict !== "decided" ? 1 : 0;
304
310
  } catch (error) {
@@ -0,0 +1,17 @@
1
+ import {
2
+ describeFingerprint,
3
+ merge,
4
+ resolvePages,
5
+ runAudit
6
+ } from "./chunk-MSQ57PJU.js";
7
+ import "./chunk-JP75P2DU.js";
8
+ import "./chunk-3OZQ4C5R.js";
9
+ import "./chunk-2AJB27BJ.js";
10
+ import "./chunk-QMYERCBB.js";
11
+ import "./chunk-3NGWXZZE.js";
12
+ export {
13
+ describeFingerprint,
14
+ merge,
15
+ resolvePages,
16
+ runAudit
17
+ };
@@ -1,13 +1,14 @@
1
1
  import {
2
2
  DIRECTION_VERSION,
3
3
  validateDirection
4
- } from "./chunk-JRMU6GYX.js";
4
+ } from "./chunk-JWHC67ZS.js";
5
5
  import {
6
- parseFlags
7
- } from "./chunk-YLQE35PN.js";
6
+ parseFlags,
7
+ toJson
8
+ } from "./chunk-2AJB27BJ.js";
8
9
  import {
9
10
  fingerprint
10
- } from "./chunk-EHNEGKFR.js";
11
+ } from "./chunk-QMYERCBB.js";
11
12
  import {
12
13
  deltaEOk,
13
14
  formatHex,
@@ -16,7 +17,7 @@ import {
16
17
  isCream,
17
18
  oklchToRgb,
18
19
  parseColour
19
- } from "./chunk-ZT3WLN7R.js";
20
+ } from "./chunk-3NGWXZZE.js";
20
21
 
21
22
  // src/direction/cli.ts
22
23
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
@@ -287,7 +288,7 @@ function runDirection(args, io) {
287
288
  fingerprint: current,
288
289
  pathExists: (p) => existsSync(resolve(dirname(file), p))
289
290
  });
290
- if (flags.json) io.out(JSON.stringify(report, null, 2));
291
+ if (flags.json) io.out(toJson(report));
291
292
  else {
292
293
  for (const p of report.problems) io.out(` ${p.severity === "error" ? "error" : "warn "} ${p.at || "(file)"} ${p.message}`);
293
294
  io.out(`craft direction: ${report.valid ? "valid" : "not valid"}, ${report.decided} of 7 choices decided with a reason.`);