@domandigital/craft 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/CHARACTER.md +140 -1
- package/COPY.md +5 -0
- package/dist/{audit-5QWFLZ6L.js → audit-6WHR7ZTV.js} +7 -3
- package/dist/audit.cjs +1267 -165
- package/dist/audit.d.cts +95 -1
- package/dist/audit.d.ts +95 -1
- package/dist/audit.js +1265 -165
- package/dist/{chunk-J3QUROJ5.js → chunk-3OTKMKJQ.js} +66 -91
- package/dist/{chunk-PU5G5DUC.js → chunk-7T5WR74C.js} +27 -14
- package/dist/{chunk-AL6MRG6V.js → chunk-GQVEYTBY.js} +3592 -2173
- package/dist/{chunk-S3NZAVU6.js → chunk-OOZOEWGD.js} +1 -1
- package/dist/{chunk-CDAJUS5U.js → chunk-PBK6SYH5.js} +51 -16
- package/dist/{chunk-ULPI335S.js → chunk-Q7ILL2HV.js} +211 -10
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-ZKVJ7K6X.js → chunk-WUACK2HX.js} +2 -1
- package/dist/chunk-XVBR2UPH.js +217 -0
- package/dist/cli-3CGUKXY4.js +19 -0
- package/dist/{cli-ZPJIWVEP.js → cli-O3CDSONO.js} +7 -8
- package/dist/{cli-ZB2VJ7CY.js → cli-WG3UJNUF.js} +15 -16
- package/dist/cli-WSHPSWVU.js +21 -0
- package/dist/{cli-C3JLFXMG.js → cli-Z2IQG3LC.js} +5 -5
- package/dist/cli.js +3 -2
- package/dist/index.cjs +1719 -636
- package/dist/index.d.cts +570 -196
- package/dist/index.d.ts +570 -196
- package/dist/index.js +1684 -636
- package/dist/migrate-VIFAYIBM.js +9 -0
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-LHGY43LL.js +0 -462
- package/dist/chunk-PZ4MF2VV.js +0 -117
- package/dist/cli-2DY3V3SF.js +0 -17
- package/dist/cli-QM2AQQMF.js +0 -19
package/dist/audit.js
CHANGED
|
@@ -1000,14 +1000,14 @@ function contractionHits(ctx) {
|
|
|
1000
1000
|
for (const doc2 of documents(ctx)) {
|
|
1001
1001
|
if (doc2.words < 800) continue;
|
|
1002
1002
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
1003
|
-
const
|
|
1004
|
-
const rate =
|
|
1003
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
1004
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
1005
1005
|
if (rate < 1) {
|
|
1006
1006
|
const first = sentences(doc2)[0];
|
|
1007
1007
|
hits.push({
|
|
1008
1008
|
path: doc2.path,
|
|
1009
1009
|
offset: first?.offset ?? 0,
|
|
1010
|
-
message: `${
|
|
1010
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
1011
1011
|
});
|
|
1012
1012
|
}
|
|
1013
1013
|
}
|
|
@@ -1130,16 +1130,16 @@ function filler(words, seed = 1, options = {}) {
|
|
|
1130
1130
|
return state % n;
|
|
1131
1131
|
};
|
|
1132
1132
|
const out = [];
|
|
1133
|
-
let
|
|
1133
|
+
let count4 = 0;
|
|
1134
1134
|
let i = 0;
|
|
1135
|
-
while (
|
|
1135
|
+
while (count4 < words) {
|
|
1136
1136
|
const length = uniform ?? shape[i % shape.length];
|
|
1137
1137
|
const s = [];
|
|
1138
1138
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
1139
1139
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
1140
1140
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
1141
1141
|
out.push(`${s.join(" ")}.`);
|
|
1142
|
-
|
|
1142
|
+
count4 += s.length;
|
|
1143
1143
|
i += 1;
|
|
1144
1144
|
}
|
|
1145
1145
|
const paragraphs = [];
|
|
@@ -2322,70 +2322,6 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
2322
2322
|
}
|
|
2323
2323
|
];
|
|
2324
2324
|
|
|
2325
|
-
// src/character/house.ts
|
|
2326
|
-
var HOUSE = {
|
|
2327
|
-
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
2328
|
-
emoji: { tier: "block", label: "emoji" },
|
|
2329
|
-
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
2330
|
-
"no-x-no-y": {
|
|
2331
|
-
tier: "block",
|
|
2332
|
-
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
2333
|
-
},
|
|
2334
|
-
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
2335
|
-
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
2336
|
-
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
2337
|
-
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
2338
|
-
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
2339
|
-
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
2340
|
-
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
2341
|
-
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
2342
|
-
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
2343
|
-
"review-phrase": { tier: "review", label: "review tier" },
|
|
2344
|
-
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
2345
|
-
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
2346
|
-
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
2347
|
-
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
2348
|
-
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
2349
|
-
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
2350
|
-
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
2351
|
-
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
2352
|
-
"closing-summary": { tier: "review", label: "closing summary" },
|
|
2353
|
-
"false-range": { tier: "review", label: "false range" },
|
|
2354
|
-
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
2355
|
-
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
2356
|
-
// a proposal can honestly say "the attached research". Review until measured there.
|
|
2357
|
-
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
2358
|
-
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
2359
|
-
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
2360
|
-
"phrase-density": { tier: "review", label: "density" },
|
|
2361
|
-
"aphorism-density": { tier: "review", label: "density" },
|
|
2362
|
-
"contraction-scarcity": { tier: "review", label: "density" },
|
|
2363
|
-
"sentence-rhythm": { tier: "review", label: "density" },
|
|
2364
|
-
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
2365
|
-
"heading-shape": { tier: "review", label: "headings" },
|
|
2366
|
-
"heading-echo": { tier: "review", label: "headings" },
|
|
2367
|
-
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
2368
|
-
"vague-word": { tier: "explicit", label: "vague" }
|
|
2369
|
-
};
|
|
2370
|
-
function houseRule(id, name = id) {
|
|
2371
|
-
return HOUSE[id] ?? { tier: "review", label: name };
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
|
-
// src/character/parse.ts
|
|
2375
|
-
function lineAt(text, offset) {
|
|
2376
|
-
let line = 1;
|
|
2377
|
-
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
2378
|
-
if (text.charCodeAt(i) === 10) line += 1;
|
|
2379
|
-
}
|
|
2380
|
-
return line;
|
|
2381
|
-
}
|
|
2382
|
-
function excerptAt(text, offset, max = 140) {
|
|
2383
|
-
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
2384
|
-
const endRaw = text.indexOf("\n", offset);
|
|
2385
|
-
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
2386
|
-
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
2325
|
// src/snapshot/types.ts
|
|
2390
2326
|
var SNAPSHOT_VERSION = 2;
|
|
2391
2327
|
|
|
@@ -2458,6 +2394,606 @@ function makeSnapshot(patch = {}) {
|
|
|
2458
2394
|
return out;
|
|
2459
2395
|
}
|
|
2460
2396
|
|
|
2397
|
+
// src/character/parse.ts
|
|
2398
|
+
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
2399
|
+
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
2400
|
+
var CSS = /\.(css|scss|sass|less|pcss)$/i;
|
|
2401
|
+
var PROSE = /\.(md|markdown|txt)$/i;
|
|
2402
|
+
function fileKind(path) {
|
|
2403
|
+
if (MARKUP.test(path)) return "markup";
|
|
2404
|
+
if (CSS.test(path)) return "css";
|
|
2405
|
+
if (SCRIPT.test(path)) return "script";
|
|
2406
|
+
if (PROSE.test(path)) return "prose";
|
|
2407
|
+
return "other";
|
|
2408
|
+
}
|
|
2409
|
+
function lineAt(text, offset) {
|
|
2410
|
+
let line = 1;
|
|
2411
|
+
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
2412
|
+
if (text.charCodeAt(i) === 10) line += 1;
|
|
2413
|
+
}
|
|
2414
|
+
return line;
|
|
2415
|
+
}
|
|
2416
|
+
function excerptAt(text, offset, max = 140) {
|
|
2417
|
+
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
2418
|
+
const endRaw = text.indexOf("\n", offset);
|
|
2419
|
+
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
2420
|
+
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// src/character/facts.ts
|
|
2424
|
+
var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
|
|
2425
|
+
var PATTERNS = [
|
|
2426
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
2427
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
2428
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
2429
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
2430
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
2431
|
+
["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
|
|
2432
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
2433
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
2434
|
+
];
|
|
2435
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
2436
|
+
var normalise = (kind, value) => {
|
|
2437
|
+
const v = value.trim();
|
|
2438
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
2439
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
2440
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
2441
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
2442
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
2443
|
+
};
|
|
2444
|
+
function protectedFacts(text) {
|
|
2445
|
+
const facts = [];
|
|
2446
|
+
let rest = text;
|
|
2447
|
+
const mask = (start, length) => {
|
|
2448
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
2449
|
+
};
|
|
2450
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
2451
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
2452
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
2453
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
2454
|
+
mask(m.index ?? 0, m[0].length);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
const lines = rest.split("\n");
|
|
2458
|
+
for (const line of lines) {
|
|
2459
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
2460
|
+
for (const m of line.matchAll(NAME)) {
|
|
2461
|
+
const at = m.index ?? 0;
|
|
2462
|
+
const before = line.slice(0, at).trimEnd();
|
|
2463
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
2464
|
+
const word = m[0];
|
|
2465
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
2466
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
2467
|
+
if (word === "I") continue;
|
|
2468
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
return facts;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// src/character/specificity.ts
|
|
2475
|
+
var TRADE_NOUNS = [
|
|
2476
|
+
// heating, plumbing, electrical
|
|
2477
|
+
"boiler",
|
|
2478
|
+
"radiator",
|
|
2479
|
+
"combi",
|
|
2480
|
+
"cylinder",
|
|
2481
|
+
"thermostat",
|
|
2482
|
+
"underfloor heating",
|
|
2483
|
+
"heat pump",
|
|
2484
|
+
"solar panel",
|
|
2485
|
+
"plumbing",
|
|
2486
|
+
"plumber",
|
|
2487
|
+
"leak",
|
|
2488
|
+
"toilet",
|
|
2489
|
+
"shower",
|
|
2490
|
+
"bathroom",
|
|
2491
|
+
"wet room",
|
|
2492
|
+
"drain",
|
|
2493
|
+
"fuse board",
|
|
2494
|
+
"fuseboard",
|
|
2495
|
+
"consumer unit",
|
|
2496
|
+
"rewire",
|
|
2497
|
+
"socket",
|
|
2498
|
+
"ev charger",
|
|
2499
|
+
"electrician",
|
|
2500
|
+
"gas engineer",
|
|
2501
|
+
"power flush",
|
|
2502
|
+
"landlord certificate",
|
|
2503
|
+
"eicr",
|
|
2504
|
+
// building and outside
|
|
2505
|
+
"extension",
|
|
2506
|
+
"loft conversion",
|
|
2507
|
+
"loft",
|
|
2508
|
+
"kitchen",
|
|
2509
|
+
"roof",
|
|
2510
|
+
"roofer",
|
|
2511
|
+
"gutter",
|
|
2512
|
+
"chimney",
|
|
2513
|
+
"driveway",
|
|
2514
|
+
"patio",
|
|
2515
|
+
"fence",
|
|
2516
|
+
"fencing",
|
|
2517
|
+
"decking",
|
|
2518
|
+
"window",
|
|
2519
|
+
"conservatory",
|
|
2520
|
+
"plastering",
|
|
2521
|
+
"plasterer",
|
|
2522
|
+
"render",
|
|
2523
|
+
"brickwork",
|
|
2524
|
+
"bricklayer",
|
|
2525
|
+
"tiling",
|
|
2526
|
+
"tiler",
|
|
2527
|
+
"flooring",
|
|
2528
|
+
"carpet",
|
|
2529
|
+
"joinery",
|
|
2530
|
+
"joiner",
|
|
2531
|
+
"carpenter",
|
|
2532
|
+
"staircase",
|
|
2533
|
+
"damp",
|
|
2534
|
+
"scaffolding",
|
|
2535
|
+
"garage conversion",
|
|
2536
|
+
"lawn",
|
|
2537
|
+
"hedge",
|
|
2538
|
+
"tree surgery",
|
|
2539
|
+
"stump",
|
|
2540
|
+
"landscaping",
|
|
2541
|
+
"gardener",
|
|
2542
|
+
"gate",
|
|
2543
|
+
"locksmith",
|
|
2544
|
+
"pest control",
|
|
2545
|
+
"wasp",
|
|
2546
|
+
"cleaning",
|
|
2547
|
+
"oven",
|
|
2548
|
+
"end of tenancy",
|
|
2549
|
+
// vehicles
|
|
2550
|
+
"mot",
|
|
2551
|
+
"tyre",
|
|
2552
|
+
"brake",
|
|
2553
|
+
"clutch",
|
|
2554
|
+
"cambelt",
|
|
2555
|
+
"exhaust",
|
|
2556
|
+
"gearbox",
|
|
2557
|
+
"valeting",
|
|
2558
|
+
// personal and health
|
|
2559
|
+
"haircut",
|
|
2560
|
+
"skin fade",
|
|
2561
|
+
"beard",
|
|
2562
|
+
"barber",
|
|
2563
|
+
"balayage",
|
|
2564
|
+
"nails",
|
|
2565
|
+
"lashes",
|
|
2566
|
+
"massage",
|
|
2567
|
+
"physio",
|
|
2568
|
+
"physiotherapy",
|
|
2569
|
+
"osteopath",
|
|
2570
|
+
"chiropractor",
|
|
2571
|
+
"dentist",
|
|
2572
|
+
"dental",
|
|
2573
|
+
"implant",
|
|
2574
|
+
"brace",
|
|
2575
|
+
"invisalign",
|
|
2576
|
+
"hygienist",
|
|
2577
|
+
"optician",
|
|
2578
|
+
"eye test",
|
|
2579
|
+
"vet",
|
|
2580
|
+
"grooming",
|
|
2581
|
+
"dog walking",
|
|
2582
|
+
"personal trainer",
|
|
2583
|
+
"yoga",
|
|
2584
|
+
"pilates",
|
|
2585
|
+
// professional
|
|
2586
|
+
"accountant",
|
|
2587
|
+
"tax return",
|
|
2588
|
+
"bookkeeping",
|
|
2589
|
+
"payroll",
|
|
2590
|
+
"vat",
|
|
2591
|
+
"self assessment",
|
|
2592
|
+
"solicitor",
|
|
2593
|
+
"conveyancing",
|
|
2594
|
+
"probate",
|
|
2595
|
+
"divorce",
|
|
2596
|
+
"lasting power of attorney",
|
|
2597
|
+
"mortgage",
|
|
2598
|
+
"surveyor",
|
|
2599
|
+
"architect",
|
|
2600
|
+
"planning permission",
|
|
2601
|
+
"building regulations",
|
|
2602
|
+
// web and digital, for the agency's own pages
|
|
2603
|
+
"website",
|
|
2604
|
+
"web design",
|
|
2605
|
+
"seo",
|
|
2606
|
+
"hosting",
|
|
2607
|
+
"google business profile",
|
|
2608
|
+
"online shop",
|
|
2609
|
+
"ecommerce",
|
|
2610
|
+
// food and hospitality
|
|
2611
|
+
"breakfast",
|
|
2612
|
+
"lunch",
|
|
2613
|
+
"dinner",
|
|
2614
|
+
"coffee",
|
|
2615
|
+
"bakery",
|
|
2616
|
+
"cake",
|
|
2617
|
+
"catering",
|
|
2618
|
+
"wedding",
|
|
2619
|
+
"venue"
|
|
2620
|
+
];
|
|
2621
|
+
var STOCK_FIGURES = [
|
|
2622
|
+
/\b24\s?\/\s?7\b/gi,
|
|
2623
|
+
/\b24[\s-]hours?\b/gi,
|
|
2624
|
+
/\b1[01]0\s?%/g,
|
|
2625
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
2626
|
+
/#\s?1\b/g,
|
|
2627
|
+
/\bno\.?\s?1\b/gi,
|
|
2628
|
+
/\b365\s+days\b/gi
|
|
2629
|
+
];
|
|
2630
|
+
var NOT_NAMES = new Set(
|
|
2631
|
+
"a an the and of for to in on at by with your you our we us it get free quote quotes call now today book booking contact learn more read see view explore discover start started home about services service quality trusted trust expert experts expertise professional professionals best solutions solution digital team welcome why choose how works local reliable affordable fast friendly premium leading excellence excellent results success growth business businesses customer customers client clients care support help new our story mission vision values innovation innovative modern simple easy smart better great amazing perfect ultimate transform elevate empower unlock experience experienced dedicated passionate committed tailored bespoke seamless peace mind can do all every everything more less here there this that what who where when need needs make difference satisfaction guaranteed".split(" ")
|
|
2632
|
+
);
|
|
2633
|
+
var BRIEF_STOP = new Set(
|
|
2634
|
+
"a an the and or of for to in on at by with from who that which what their they them its it is are was were be been being has have had do does did can will would should could may might must this these those there here as into over under business company small local family run based owned services service provides provide offering offers people customers clients home homes house houses area areas around near across nearby town city county village uk".split(" ")
|
|
2635
|
+
);
|
|
2636
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
2637
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
2638
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2639
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
2640
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
2641
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
2642
|
+
function caseless(line) {
|
|
2643
|
+
const words = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
2644
|
+
const letters = words.filter((w) => w.length >= 2);
|
|
2645
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
2646
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
2647
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
2648
|
+
return null;
|
|
2649
|
+
}
|
|
2650
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
2651
|
+
function briefTerms(brief) {
|
|
2652
|
+
const terms = /* @__PURE__ */ new Set();
|
|
2653
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
2654
|
+
const lower = w.toLowerCase();
|
|
2655
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
|
|
2656
|
+
}
|
|
2657
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
2658
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
2659
|
+
return [...terms];
|
|
2660
|
+
}
|
|
2661
|
+
function specifics(text, brief) {
|
|
2662
|
+
const found = /* @__PURE__ */ new Map();
|
|
2663
|
+
const add = (kind, value) => {
|
|
2664
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2665
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
2666
|
+
};
|
|
2667
|
+
for (const raw of text.split("\n")) {
|
|
2668
|
+
const line = maskStock(raw);
|
|
2669
|
+
if (!line.trim()) continue;
|
|
2670
|
+
const capitals = caseless(line);
|
|
2671
|
+
for (const f4 of protectedFacts(line)) {
|
|
2672
|
+
if (f4.kind === "name") {
|
|
2673
|
+
if (capitals === "shout") continue;
|
|
2674
|
+
if (capitals === "title") {
|
|
2675
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2678
|
+
const words = f4.value.split(/\s+/);
|
|
2679
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
2680
|
+
const first = words.findIndex(named);
|
|
2681
|
+
if (first === -1) continue;
|
|
2682
|
+
const last = words.length - 1 - [...words].reverse().findIndex(named);
|
|
2683
|
+
add("name", words.slice(first, last + 1).join(" "));
|
|
2684
|
+
continue;
|
|
2685
|
+
}
|
|
2686
|
+
add(f4.kind, f4.value);
|
|
2687
|
+
}
|
|
2688
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
2689
|
+
const m = re.exec(line);
|
|
2690
|
+
if (m) add("trade", term);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
if (brief) {
|
|
2694
|
+
for (const term of briefTerms(brief)) {
|
|
2695
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
2696
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
return [...found.values()];
|
|
2700
|
+
}
|
|
2701
|
+
function specificity(text, brief) {
|
|
2702
|
+
const list = specifics(text, brief);
|
|
2703
|
+
const words = countWords2(text);
|
|
2704
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
2705
|
+
const hard = list.length - trade;
|
|
2706
|
+
return {
|
|
2707
|
+
words,
|
|
2708
|
+
specifics: list,
|
|
2709
|
+
hard,
|
|
2710
|
+
trade,
|
|
2711
|
+
perHundred: words ? Math.round(list.length / words * 1e3) / 10 : 0,
|
|
2712
|
+
generic: hard === 0 && trade < 2
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
function sentencesOf(blocks) {
|
|
2716
|
+
const out = [];
|
|
2717
|
+
for (const block of blocks) {
|
|
2718
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
2719
|
+
const s = m[0].trim();
|
|
2720
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
return [...new Set(out)];
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// src/character/tells/specificity.ts
|
|
2727
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
2728
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
2729
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
2730
|
+
function literalText(inner) {
|
|
2731
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
2732
|
+
if (/[{}]/.test(t)) return null;
|
|
2733
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
2734
|
+
}
|
|
2735
|
+
var HOME_FILE = /(?:^|\/)app\/(?:\([^/]+\)\/)*page\.(?:tsx|jsx|mdx)$|(?:^|\/)pages\/index\.(?:tsx|jsx|astro|vue|mdx)$|(?:^|\/)routes\/(?:\([^/]+\)\/)*\+page\.svelte$|^(?:(?:public|src|site|www)\/)?index\.html?$|(?:^|\/)[\w-]*(?:hero|home)[\w-]*\.(?:tsx|jsx|astro|vue|svelte|html?)$/i;
|
|
2736
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
2737
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
2738
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
2739
|
+
function headlines(ctx) {
|
|
2740
|
+
const out = [];
|
|
2741
|
+
for (const file of ctx.files) {
|
|
2742
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
2743
|
+
for (const m of file.text.matchAll(H1)) {
|
|
2744
|
+
const h1 = literalText(m[1]);
|
|
2745
|
+
if (!h1) continue;
|
|
2746
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
2747
|
+
const p = NEXT_P.exec(after);
|
|
2748
|
+
const sub = p ? literalText(p[1]) : null;
|
|
2749
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return out;
|
|
2753
|
+
}
|
|
2754
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
2755
|
+
var STANDING_CLAIM = /(?<![\w#])(?:trusted|reliable|reputable|award[- ]winning|top[- ]rated|best[- ]rated|highly (?:rated|recommended|experienced|skilled|qualified)|fully (?:qualified|certified|accredited|licensed|insured)|(?:qualified|certified|accredited|approved|registered|experienced|expert) (?:engineers?|electricians?|plumbers?|builders?|technicians?|installers?|team|tradesmen|professionals?|staff)|(?:years|decades) of experience|(?:5|five)[- ]star (?:service|rated|reviews?)|(?:happy|satisfied) (?:customers|clients)|(?:customers|clients) (?:love|trust|recommend)|number one|no\.? ?1|#1)(?![\w])/i;
|
|
2756
|
+
var PROOF_NAMES = [
|
|
2757
|
+
"Gas Safe",
|
|
2758
|
+
"NICEIC",
|
|
2759
|
+
"NAPIT",
|
|
2760
|
+
"ELECSA",
|
|
2761
|
+
"Stroma",
|
|
2762
|
+
"TrustMark",
|
|
2763
|
+
"Which? Trusted Trader",
|
|
2764
|
+
"Checkatrade",
|
|
2765
|
+
"TrustATrader",
|
|
2766
|
+
"Rated People",
|
|
2767
|
+
"MyBuilder",
|
|
2768
|
+
"Federation of Master Builders",
|
|
2769
|
+
"FMB",
|
|
2770
|
+
"Guild of Master Craftsmen",
|
|
2771
|
+
"OFTEC",
|
|
2772
|
+
"HETAS",
|
|
2773
|
+
"CIPHE",
|
|
2774
|
+
"APHC",
|
|
2775
|
+
"CHAS",
|
|
2776
|
+
"SafeContractor",
|
|
2777
|
+
"Constructionline",
|
|
2778
|
+
"FENSA",
|
|
2779
|
+
"CERTASS",
|
|
2780
|
+
"MCS",
|
|
2781
|
+
"RECC",
|
|
2782
|
+
"BPEC",
|
|
2783
|
+
"City & Guilds",
|
|
2784
|
+
"NVQ",
|
|
2785
|
+
"CSCS",
|
|
2786
|
+
"Worcester Bosch",
|
|
2787
|
+
"Vaillant",
|
|
2788
|
+
"Viessmann",
|
|
2789
|
+
"BAFE",
|
|
2790
|
+
"NSI",
|
|
2791
|
+
"SSAIB",
|
|
2792
|
+
"BPCA",
|
|
2793
|
+
"RICS",
|
|
2794
|
+
"CIOB",
|
|
2795
|
+
"RIBA",
|
|
2796
|
+
"ICAEW",
|
|
2797
|
+
"ACCA",
|
|
2798
|
+
"CIMA",
|
|
2799
|
+
"SRA",
|
|
2800
|
+
"Law Society",
|
|
2801
|
+
"GDC",
|
|
2802
|
+
"GMC",
|
|
2803
|
+
"HCPC",
|
|
2804
|
+
"CQC",
|
|
2805
|
+
"Ofsted",
|
|
2806
|
+
"ISO 9001",
|
|
2807
|
+
"ISO 27001",
|
|
2808
|
+
"Cyber Essentials",
|
|
2809
|
+
"Companies House",
|
|
2810
|
+
"FCA",
|
|
2811
|
+
"Trustpilot",
|
|
2812
|
+
"Feefo",
|
|
2813
|
+
"Reviews.io",
|
|
2814
|
+
"Yell",
|
|
2815
|
+
"Houzz"
|
|
2816
|
+
];
|
|
2817
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2818
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
2819
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
2820
|
+
var PROOF_PATTERNS = [
|
|
2821
|
+
PROOF_NAME,
|
|
2822
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
2823
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
2824
|
+
/\b[1-5](?:\.\d)?\s?(?:\/\s?5|out of 5)\b|\brated\s+[1-5](?:\.\d)?\b|★{3,}|\b\d[\d,]*\+?\s+(?:verified\s+)?(?:reviews|ratings)\b/i,
|
|
2825
|
+
// A registration or licence number.
|
|
2826
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
2827
|
+
];
|
|
2828
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
2829
|
+
function sectionHasProof(s, section, index) {
|
|
2830
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
2831
|
+
const text = section.text ?? [];
|
|
2832
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
2833
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
2834
|
+
}
|
|
2835
|
+
function unprovenClaims(s) {
|
|
2836
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
2837
|
+
const out = [];
|
|
2838
|
+
s.sections.forEach((section, i) => {
|
|
2839
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
2840
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
2841
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
2842
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
return out;
|
|
2846
|
+
}
|
|
2847
|
+
var withText = (texts, patch = () => ({})) => {
|
|
2848
|
+
const base = makeSnapshot();
|
|
2849
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
2850
|
+
};
|
|
2851
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
2852
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
2853
|
+
{
|
|
2854
|
+
id: "generic-hero-claim",
|
|
2855
|
+
name: "Headline that fits any business",
|
|
2856
|
+
generation: 1,
|
|
2857
|
+
severity: "warn",
|
|
2858
|
+
surface: "copy",
|
|
2859
|
+
why: "The first thing a visitor reads on the home page names no place, no person, no price and no job, so it would fit every competitor's site unchanged. A model writes it when it has nothing particular to say.",
|
|
2860
|
+
fix: 'Put what only this business can say on the first screen: the job, the town, a price, the name. "Boilers fixed the same day in Brackley" beats "Quality you can trust".',
|
|
2861
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
2862
|
+
fixtures: {
|
|
2863
|
+
flag: [
|
|
2864
|
+
{ path: "app/page.tsx", text: '<section>\n <h1 className="text-6xl">Quality you can trust</h1>\n <p>We deliver reliable solutions tailored to your needs.</p>\n</section>' },
|
|
2865
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
2866
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
2867
|
+
],
|
|
2868
|
+
pass: [
|
|
2869
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
2870
|
+
// A stock headline rescued by the line under it.
|
|
2871
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
2872
|
+
// Two trade nouns say what the business does.
|
|
2873
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
2874
|
+
// The words come from data: nothing to read.
|
|
2875
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
2876
|
+
// Another page's headline is a label, not the site's claim.
|
|
2877
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
2878
|
+
]
|
|
2879
|
+
},
|
|
2880
|
+
rendered: {
|
|
2881
|
+
detect: (s) => {
|
|
2882
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
2883
|
+
const text = s.firstScreenText.join("\n");
|
|
2884
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
2885
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
2886
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
2887
|
+
},
|
|
2888
|
+
fixtures: {
|
|
2889
|
+
flag: [
|
|
2890
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
2891
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
2892
|
+
],
|
|
2893
|
+
pass: [
|
|
2894
|
+
makeSnapshot(),
|
|
2895
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
2896
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
2897
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
2898
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
2899
|
+
// Nor is any page but the home page.
|
|
2900
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
2901
|
+
]
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
];
|
|
2906
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
2907
|
+
{
|
|
2908
|
+
id: "unproven-claim",
|
|
2909
|
+
name: "Claim with no proof next to it",
|
|
2910
|
+
generation: 1,
|
|
2911
|
+
severity: "warn",
|
|
2912
|
+
surface: "rendered",
|
|
2913
|
+
why: `"Trusted", "fully qualified" and "award-winning" ask the visitor to take the business's word for it. A page built with no reviews and no registration number makes the claim anyway, with nothing beside it.`,
|
|
2914
|
+
fix: "Put the proof where the claim is: the Gas Safe or NICEIC number, the named review with its town, the rating and where it was left. Or cut the claim.",
|
|
2915
|
+
rendered: {
|
|
2916
|
+
detect: (s) => {
|
|
2917
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
2918
|
+
const found = unprovenClaims(s);
|
|
2919
|
+
if (found.length === 0) return [];
|
|
2920
|
+
const first = found[0];
|
|
2921
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
2922
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
2923
|
+
},
|
|
2924
|
+
fixtures: {
|
|
2925
|
+
flag: [
|
|
2926
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
2927
|
+
// Proof two sections away is not next to the claim.
|
|
2928
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["Recent jobs"], ["\u201CThey turned up on time and fixed the boiler in an hour.\u201D", "Sam, Brackley"]], (i) => i === 3 ? { role: "testimonials" } : {})
|
|
2929
|
+
],
|
|
2930
|
+
pass: [
|
|
2931
|
+
makeSnapshot(),
|
|
2932
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
2933
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
2934
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
2935
|
+
{
|
|
2936
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
2937
|
+
images: [{ src: "https://example.test/img/gas-safe-register.svg", host: "example.test", width: 96, height: 96, top: 1200, role: "logo", alt: "Gas Safe Register", decorative: false, section: 1, background: false }]
|
|
2938
|
+
}
|
|
2939
|
+
]
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
];
|
|
2944
|
+
|
|
2945
|
+
// src/character/house.ts
|
|
2946
|
+
var HOUSE = {
|
|
2947
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
2948
|
+
emoji: { tier: "block", label: "emoji" },
|
|
2949
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
2950
|
+
"no-x-no-y": {
|
|
2951
|
+
tier: "block",
|
|
2952
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
2953
|
+
},
|
|
2954
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
2955
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
2956
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
2957
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
2958
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
2959
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
2960
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
2961
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
2962
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
2963
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
2964
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
2965
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
2966
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
2967
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
2968
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
2969
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
2970
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
2971
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
2972
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
2973
|
+
"false-range": { tier: "review", label: "false range" },
|
|
2974
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
2975
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
2976
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
2977
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
2978
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
2979
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
2980
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
2981
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
2982
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
2983
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
2984
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
2985
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
2986
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
2987
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
2988
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
2989
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
2990
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
2991
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
2992
|
+
};
|
|
2993
|
+
function houseRule(id, name = id) {
|
|
2994
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2461
2997
|
// src/snapshot/fonts.ts
|
|
2462
2998
|
var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
|
|
2463
2999
|
function normaliseFamily(raw) {
|
|
@@ -2575,7 +3111,7 @@ function blueToPurple(s) {
|
|
|
2575
3111
|
}
|
|
2576
3112
|
return hits;
|
|
2577
3113
|
}
|
|
2578
|
-
var
|
|
3114
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
2579
3115
|
var RENDERED_PATHS = {
|
|
2580
3116
|
"reflex-font": {
|
|
2581
3117
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -2613,11 +3149,11 @@ var RENDERED_PATHS = {
|
|
|
2613
3149
|
}
|
|
2614
3150
|
},
|
|
2615
3151
|
"gradient-text": {
|
|
2616
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
3152
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
2617
3153
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
2618
3154
|
},
|
|
2619
3155
|
"glass-panel": {
|
|
2620
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
3156
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
2621
3157
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
2622
3158
|
},
|
|
2623
3159
|
"cream-palette": {
|
|
@@ -2695,7 +3231,7 @@ var RENDERED_PATHS = {
|
|
|
2695
3231
|
detect: (s) => {
|
|
2696
3232
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
2697
3233
|
const n = s.effects.glows + radial;
|
|
2698
|
-
return n > 0 ? [hit(s, `${
|
|
3234
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
2699
3235
|
},
|
|
2700
3236
|
fixtures: {
|
|
2701
3237
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -2703,11 +3239,11 @@ var RENDERED_PATHS = {
|
|
|
2703
3239
|
}
|
|
2704
3240
|
},
|
|
2705
3241
|
"grid-background": {
|
|
2706
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
3242
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
2707
3243
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
2708
3244
|
},
|
|
2709
3245
|
marquee: {
|
|
2710
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
3246
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
2711
3247
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
2712
3248
|
},
|
|
2713
3249
|
"thin-border-wide-shadow": {
|
|
@@ -2720,11 +3256,435 @@ var RENDERED_PATHS = {
|
|
|
2720
3256
|
}
|
|
2721
3257
|
};
|
|
2722
3258
|
|
|
3259
|
+
// src/fingerprint/index.ts
|
|
3260
|
+
var FINGERPRINT_VERSION = 2;
|
|
3261
|
+
var CHROMATIC = 0.03;
|
|
3262
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
3263
|
+
var oklch = (value) => {
|
|
3264
|
+
const parsed = parseColour(value);
|
|
3265
|
+
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
3266
|
+
};
|
|
3267
|
+
function accentOf(s) {
|
|
3268
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
3269
|
+
for (const c of s.controls) {
|
|
3270
|
+
const o = oklch(c.background);
|
|
3271
|
+
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
3272
|
+
}
|
|
3273
|
+
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
3274
|
+
if (topButton) return oklch(topButton[0]);
|
|
3275
|
+
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
|
|
3276
|
+
if (bg) return bg.o;
|
|
3277
|
+
const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
3278
|
+
return text ? text.o : null;
|
|
3279
|
+
}
|
|
3280
|
+
function faces(s) {
|
|
3281
|
+
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
3282
|
+
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
3283
|
+
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
3284
|
+
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
3285
|
+
return {
|
|
3286
|
+
display: { family: displayName, class: fontClass(displayName) },
|
|
3287
|
+
body: { family: bodyName, class: fontClass(bodyName) }
|
|
3288
|
+
};
|
|
3289
|
+
}
|
|
3290
|
+
function median(values) {
|
|
3291
|
+
if (values.length === 0) return null;
|
|
3292
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
3293
|
+
const mid = Math.floor(sorted.length / 2);
|
|
3294
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
3295
|
+
}
|
|
3296
|
+
function fingerprint(s) {
|
|
3297
|
+
const effects = [];
|
|
3298
|
+
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
3299
|
+
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
3300
|
+
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
3301
|
+
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
3302
|
+
if (s.effects.marquees > 0) effects.push("marquee");
|
|
3303
|
+
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
3304
|
+
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
3305
|
+
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
3306
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
3307
|
+
return {
|
|
3308
|
+
version: FINGERPRINT_VERSION,
|
|
3309
|
+
accent: accentOf(s),
|
|
3310
|
+
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
3311
|
+
...faces(s),
|
|
3312
|
+
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
3313
|
+
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
3314
|
+
effects: effects.sort(),
|
|
3315
|
+
layout: s.sections.map((x) => x.kind),
|
|
3316
|
+
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
|
|
3317
|
+
sections: s.sections.map((x) => ({
|
|
3318
|
+
role: x.role,
|
|
3319
|
+
centred: x.geometry.centredShare,
|
|
3320
|
+
width: x.geometry.contentWidthRatio,
|
|
3321
|
+
symmetry: x.geometry.mirrorSymmetry,
|
|
3322
|
+
controls: x.geometry.controls,
|
|
3323
|
+
cards: x.cards,
|
|
3324
|
+
background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
|
|
3325
|
+
badges: (x.badges ?? []).map((b) => b.toLowerCase()),
|
|
3326
|
+
avatars: x.avatars ?? 0,
|
|
3327
|
+
carousel: x.carousel ?? false,
|
|
3328
|
+
figures: x.figures ?? 0
|
|
3329
|
+
}))
|
|
3330
|
+
} : {}
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// src/fingerprint/component.ts
|
|
3335
|
+
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
3336
|
+
|
|
3337
|
+
// src/snapshot/rendered-tells.ts
|
|
3338
|
+
var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
3339
|
+
var GROUND = "rgb(255, 255, 255)";
|
|
3340
|
+
var BAND = "rgb(31, 58, 46)";
|
|
3341
|
+
var geo = (patch = {}) => ({
|
|
3342
|
+
centredShare: 0.1,
|
|
3343
|
+
mirrorSymmetry: 0.6,
|
|
3344
|
+
whitespaceRatio: 0.7,
|
|
3345
|
+
contentWidthRatio: 0.85,
|
|
3346
|
+
background: GROUND,
|
|
3347
|
+
controls: 0,
|
|
3348
|
+
...patch
|
|
3349
|
+
});
|
|
3350
|
+
function withSection(index, patch, roles = []) {
|
|
3351
|
+
const base = makeSnapshot();
|
|
3352
|
+
return {
|
|
3353
|
+
...base,
|
|
3354
|
+
sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
|
|
3355
|
+
};
|
|
3356
|
+
}
|
|
3357
|
+
var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
|
|
3358
|
+
var RENDERED_TELLS = [
|
|
3359
|
+
{
|
|
3360
|
+
id: "cta-band-stock",
|
|
3361
|
+
name: "Stock call-to-action band",
|
|
3362
|
+
generation: 1,
|
|
3363
|
+
severity: "warn",
|
|
3364
|
+
surface: "rendered",
|
|
3365
|
+
why: "A full-width coloured band with a centred heading, one line and one or two buttons is what a model closes every section run with. It asks for the booking the same way on every site.",
|
|
3366
|
+
fix: "Keep the ask, change the band: put it where the decision is made (next to the prices, after the proof), in the client's own words, laid out like the rest of the page rather than as a centred slab.",
|
|
3367
|
+
rendered: {
|
|
3368
|
+
detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
|
|
3369
|
+
const g = x.geometry;
|
|
3370
|
+
return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
|
|
3371
|
+
}).map((x) => hit2(s, `a centred ${x.role === "footer-cta" ? "closing " : ""}band on its own colour with ${x.geometry.controls === 1 ? "one button" : "two buttons"}`, x.label)),
|
|
3372
|
+
fixtures: {
|
|
3373
|
+
flag: [
|
|
3374
|
+
withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
|
|
3375
|
+
withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
|
|
3376
|
+
],
|
|
3377
|
+
pass: [
|
|
3378
|
+
makeSnapshot(),
|
|
3379
|
+
// The same ask, laid out like the page: left-aligned on the ground.
|
|
3380
|
+
withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
|
|
3381
|
+
]
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
},
|
|
3385
|
+
{
|
|
3386
|
+
id: "pricing-trio-popular",
|
|
3387
|
+
name: "Three-tier pricing with a popular badge",
|
|
3388
|
+
generation: 1,
|
|
3389
|
+
severity: "warn",
|
|
3390
|
+
surface: "rendered",
|
|
3391
|
+
why: "Three price cards with the middle one badged 'Most popular' is the SaaS pricing page, applied to a plumber or a salon whether or not anyone chose the middle option.",
|
|
3392
|
+
fix: "Keep the prices. Show them the way the trade quotes (a price list, per job, per visit), and drop the badge unless it is true and the client can say how many people chose it.",
|
|
3393
|
+
rendered: {
|
|
3394
|
+
detect: (s) => s.sections.filter((x) => measured(x) && x.role === "pricing" && x.cards === 3).filter((x) => (x.badges ?? []).some((b) => PROMO_BADGE.test(b))).map((x) => hit2(s, `three price cards, one badged "${(x.badges ?? []).find((b) => PROMO_BADGE.test(b))}"`, x.label)),
|
|
3395
|
+
fixtures: {
|
|
3396
|
+
flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
|
|
3397
|
+
pass: [
|
|
3398
|
+
makeSnapshot(),
|
|
3399
|
+
withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
|
|
3400
|
+
withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
|
|
3401
|
+
]
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
},
|
|
3405
|
+
{
|
|
3406
|
+
id: "testimonial-avatar-carousel",
|
|
3407
|
+
name: "Avatar testimonial carousel",
|
|
3408
|
+
generation: 1,
|
|
3409
|
+
severity: "warn",
|
|
3410
|
+
surface: "rendered",
|
|
3411
|
+
why: "Round headshots over quotes in a sliding carousel is the stock testimonial block. Most visitors see the first slide only, and stock or AI faces make every quote read as invented.",
|
|
3412
|
+
fix: "Show the reviews all at once, where they come from (the Google rating, the named job, the town), with real photos of the work rather than of the reviewer.",
|
|
3413
|
+
rendered: {
|
|
3414
|
+
detect: (s) => s.sections.filter((x) => measured(x) && x.role === "testimonials" && x.carousel === true && (x.avatars ?? 0) >= 2).map((x) => hit2(s, `${x.avatars} round portraits in a sliding testimonial carousel`, x.label)),
|
|
3415
|
+
fixtures: {
|
|
3416
|
+
flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
|
|
3417
|
+
pass: [
|
|
3418
|
+
makeSnapshot(),
|
|
3419
|
+
withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
|
|
3420
|
+
withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
|
|
3421
|
+
]
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
},
|
|
3425
|
+
{
|
|
3426
|
+
id: "faq-accordion-closer",
|
|
3427
|
+
name: "FAQ accordion as the closer",
|
|
3428
|
+
generation: 2,
|
|
3429
|
+
severity: "warn",
|
|
3430
|
+
surface: "rendered",
|
|
3431
|
+
why: "Ending the page on a collapsed FAQ, often followed only by a band, is the model's default page ending. The answers a buyer needs are hidden behind clicks at the point they decide.",
|
|
3432
|
+
fix: "Answer the questions that block a booking where they arise (price next to prices, area next to the map), and end on the call to action or the proof.",
|
|
3433
|
+
rendered: {
|
|
3434
|
+
detect: (s) => {
|
|
3435
|
+
const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
|
|
3436
|
+
const last = body[body.length - 1];
|
|
3437
|
+
return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
|
|
3438
|
+
},
|
|
3439
|
+
fixtures: {
|
|
3440
|
+
flag: [
|
|
3441
|
+
withSection(4, { role: "faq", label: "Common questions" }),
|
|
3442
|
+
{
|
|
3443
|
+
...withSection(3, { role: "faq", label: "FAQ" }),
|
|
3444
|
+
sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
|
|
3445
|
+
}
|
|
3446
|
+
],
|
|
3447
|
+
pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
},
|
|
3451
|
+
{
|
|
3452
|
+
id: "stats-row",
|
|
3453
|
+
name: "Row of big numbers",
|
|
3454
|
+
generation: 1,
|
|
3455
|
+
severity: "warn",
|
|
3456
|
+
surface: "rendered",
|
|
3457
|
+
why: "Three or four large figures in a row (500+ clients, 98% satisfaction, 10 years) is the stock proof block, and the numbers are often round, unsourced or invented.",
|
|
3458
|
+
fix: "Keep a figure only if it is true and sourced, and put it in a sentence next to what it proves. One real number in context beats four in a row.",
|
|
3459
|
+
rendered: {
|
|
3460
|
+
detect: (s) => s.sections.filter((x, i) => measured(x) && (x.role === "stats" || x.kind === "stats") && (x.figures ?? 0) >= 3 && (x.figures ?? 0) <= 6 && !(i === 1 && s.sections[0]?.kind === "hero")).map((x) => hit2(s, `${x.figures} large figures in a row`, x.label)),
|
|
3461
|
+
fixtures: {
|
|
3462
|
+
flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
|
|
3463
|
+
pass: [
|
|
3464
|
+
makeSnapshot(),
|
|
3465
|
+
withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
|
|
3466
|
+
withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
|
|
3467
|
+
]
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
},
|
|
3471
|
+
{
|
|
3472
|
+
id: "centred-everything",
|
|
3473
|
+
name: "Every section centred",
|
|
3474
|
+
generation: 2,
|
|
3475
|
+
severity: "warn",
|
|
3476
|
+
surface: "rendered",
|
|
3477
|
+
why: "When almost every section stacks a centred heading over centred text, the page has no reading line and every block looks like the one before. It is the layout nothing was decided for.",
|
|
3478
|
+
fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
|
|
3479
|
+
rendered: {
|
|
3480
|
+
detect: (s) => {
|
|
3481
|
+
const m = s.sections.filter(measured);
|
|
3482
|
+
if (m.length < 4) return [];
|
|
3483
|
+
const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
|
|
3484
|
+
return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
|
|
3485
|
+
},
|
|
3486
|
+
fixtures: {
|
|
3487
|
+
flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
|
|
3488
|
+
pass: [
|
|
3489
|
+
makeSnapshot(),
|
|
3490
|
+
{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
|
|
3491
|
+
]
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
];
|
|
3496
|
+
|
|
3497
|
+
// src/snapshot/imagery-tells.ts
|
|
3498
|
+
var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
3499
|
+
var STOCK_PHOTO_HOSTS = {
|
|
3500
|
+
"unsplash.com": "Unsplash",
|
|
3501
|
+
"pexels.com": "Pexels",
|
|
3502
|
+
"pixabay.com": "Pixabay",
|
|
3503
|
+
"istockphoto.com": "iStock",
|
|
3504
|
+
"gettyimages.com": "Getty Images",
|
|
3505
|
+
"shutterstock.com": "Shutterstock",
|
|
3506
|
+
"ftcdn.net": "Adobe Stock",
|
|
3507
|
+
"dreamstime.com": "Dreamstime",
|
|
3508
|
+
"depositphotos.com": "Depositphotos",
|
|
3509
|
+
"freepik.com": "Freepik",
|
|
3510
|
+
"rawpixel.com": "rawpixel",
|
|
3511
|
+
"stocksnap.io": "StockSnap",
|
|
3512
|
+
"burst.shopifycdn.com": "Burst",
|
|
3513
|
+
"picsum.photos": "Lorem Picsum",
|
|
3514
|
+
"loremflickr.com": "LoremFlickr",
|
|
3515
|
+
"placehold.co": "a placeholder service",
|
|
3516
|
+
"placeholder.com": "a placeholder service",
|
|
3517
|
+
"dummyimage.com": "a placeholder service"
|
|
3518
|
+
};
|
|
3519
|
+
var STOCK_AVATAR_HOSTS = {
|
|
3520
|
+
"randomuser.me": "randomuser.me",
|
|
3521
|
+
"pravatar.cc": "Pravatar",
|
|
3522
|
+
"dicebear.com": "DiceBear",
|
|
3523
|
+
"xsgames.co": "a random-face service",
|
|
3524
|
+
"thispersondoesnotexist.com": "a generated face",
|
|
3525
|
+
"generated.photos": "a generated face"
|
|
3526
|
+
};
|
|
3527
|
+
var STOCK_FILENAMES = [
|
|
3528
|
+
[/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
|
|
3529
|
+
[/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
|
|
3530
|
+
[/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
|
|
3531
|
+
[/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
|
|
3532
|
+
[/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
|
|
3533
|
+
[/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
|
|
3534
|
+
[/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
|
|
3535
|
+
[/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
|
|
3536
|
+
// Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
|
|
3537
|
+
[/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
|
|
3538
|
+
];
|
|
3539
|
+
var byHost = (host, table) => {
|
|
3540
|
+
if (!host) return null;
|
|
3541
|
+
const h = host.toLowerCase();
|
|
3542
|
+
for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
|
|
3543
|
+
return null;
|
|
3544
|
+
};
|
|
3545
|
+
function stockSource(image) {
|
|
3546
|
+
const host = byHost(image.host, STOCK_PHOTO_HOSTS);
|
|
3547
|
+
if (host) return host;
|
|
3548
|
+
let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
|
|
3549
|
+
try {
|
|
3550
|
+
path = decodeURIComponent(path);
|
|
3551
|
+
} catch {
|
|
3552
|
+
}
|
|
3553
|
+
for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
|
|
3554
|
+
return null;
|
|
3555
|
+
}
|
|
3556
|
+
function stockAvatarSource(image) {
|
|
3557
|
+
return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
|
|
3558
|
+
}
|
|
3559
|
+
var picture = (patch = {}) => ({
|
|
3560
|
+
src: "https://example.test/images/boiler-swap-hinton.jpg",
|
|
3561
|
+
host: "example.test",
|
|
3562
|
+
width: 640,
|
|
3563
|
+
height: 480,
|
|
3564
|
+
top: 1800,
|
|
3565
|
+
role: "photo",
|
|
3566
|
+
alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
|
|
3567
|
+
decorative: false,
|
|
3568
|
+
section: 2,
|
|
3569
|
+
background: false,
|
|
3570
|
+
...patch
|
|
3571
|
+
});
|
|
3572
|
+
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3573
|
+
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3574
|
+
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3575
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3576
|
+
var IMAGERY_TELLS = [
|
|
3577
|
+
{
|
|
3578
|
+
id: "stock-photo",
|
|
3579
|
+
name: "Stock photography",
|
|
3580
|
+
generation: 1,
|
|
3581
|
+
severity: "warn",
|
|
3582
|
+
surface: "rendered",
|
|
3583
|
+
why: "A picture from a stock library is the picture every other site in the trade can use. It shows a job the client did not do, in a kitchen that is not theirs, and a visitor who has seen it before stops believing the rest.",
|
|
3584
|
+
fix: "Use a photo of the client's own work, premises or people, even a plain one taken on a phone. Where there is none yet, use fewer pictures rather than a library one.",
|
|
3585
|
+
rendered: {
|
|
3586
|
+
detect: (s) => {
|
|
3587
|
+
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3588
|
+
if (found.length === 0) return [];
|
|
3589
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3590
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3591
|
+
},
|
|
3592
|
+
fixtures: {
|
|
3593
|
+
flag: [
|
|
3594
|
+
withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
|
|
3595
|
+
// Downloaded and self-hosted, found by the library's own file name.
|
|
3596
|
+
withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
|
|
3597
|
+
],
|
|
3598
|
+
pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
},
|
|
3602
|
+
{
|
|
3603
|
+
id: "stock-avatar",
|
|
3604
|
+
name: "Stock or placeholder faces",
|
|
3605
|
+
generation: 1,
|
|
3606
|
+
severity: "warn",
|
|
3607
|
+
surface: "rendered",
|
|
3608
|
+
why: "Round portraits from a placeholder-face service or a stock library next to reviews make every quote read as invented, and some of those faces are generated people who do not exist.",
|
|
3609
|
+
fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
|
|
3610
|
+
rendered: {
|
|
3611
|
+
detect: (s) => {
|
|
3612
|
+
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3613
|
+
if (found.length === 0) return [];
|
|
3614
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3615
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3616
|
+
},
|
|
3617
|
+
fixtures: {
|
|
3618
|
+
flag: [
|
|
3619
|
+
withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
|
|
3620
|
+
withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
|
|
3621
|
+
],
|
|
3622
|
+
pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
},
|
|
3626
|
+
{
|
|
3627
|
+
id: "ai-image",
|
|
3628
|
+
name: "Generated image",
|
|
3629
|
+
generation: 2,
|
|
3630
|
+
severity: "warn",
|
|
3631
|
+
surface: "rendered",
|
|
3632
|
+
why: "The file itself says a model made it (its XMP or Content Credentials name a trained-model source). A generated picture stands in for work the client did not photograph, and anyone can read the label.",
|
|
3633
|
+
fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
|
|
3634
|
+
rendered: {
|
|
3635
|
+
detect: (s) => {
|
|
3636
|
+
const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
|
|
3637
|
+
if (found.length === 0) return [];
|
|
3638
|
+
const first = found[0].provenance;
|
|
3639
|
+
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3640
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3641
|
+
},
|
|
3642
|
+
fixtures: {
|
|
3643
|
+
flag: [
|
|
3644
|
+
withImages([picture({ provenance: AI_XMP })]),
|
|
3645
|
+
withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
|
|
3646
|
+
],
|
|
3647
|
+
// Credentials that say nothing about a model, and an image never read, are not findings.
|
|
3648
|
+
pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
},
|
|
3652
|
+
{
|
|
3653
|
+
id: "no-real-imagery",
|
|
3654
|
+
name: "No photograph on the page",
|
|
3655
|
+
generation: 1,
|
|
3656
|
+
severity: "warn",
|
|
3657
|
+
surface: "rendered",
|
|
3658
|
+
why: "A page with no photograph at all, only icons and illustrations, is the page a model builds when it has no assets. Icon tiles stand in for the evidence a buyer looks for: the work, the premises, the people.",
|
|
3659
|
+
fix: "Add one real photo of the work where the claim is made: a finished job next to the service, the team next to the booking. One plain photo beats any number of icons.",
|
|
3660
|
+
rendered: {
|
|
3661
|
+
detect: (s) => {
|
|
3662
|
+
if (!s.images || s.sections.length < 4) return [];
|
|
3663
|
+
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3664
|
+
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3665
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3666
|
+
},
|
|
3667
|
+
fixtures: {
|
|
3668
|
+
flag: [
|
|
3669
|
+
withImages([picture({ role: "icon", width: 48, height: 48, src: "https://example.test/icons/wrench.svg" }), picture({ role: "illustration", width: 480, height: 360, src: "https://example.test/hero.svg" })]),
|
|
3670
|
+
withImages([])
|
|
3671
|
+
],
|
|
3672
|
+
// A snapshot with no image record is never judged; nor is a page too short to be a site.
|
|
3673
|
+
pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
];
|
|
3678
|
+
|
|
2723
3679
|
// src/character/check.ts
|
|
2724
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
2725
|
-
var CATALOGUE = [
|
|
2726
|
-
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
2727
|
-
|
|
3680
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
3681
|
+
var CATALOGUE = [
|
|
3682
|
+
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3683
|
+
...SPECIFICITY_COPY_TELLS,
|
|
3684
|
+
...RENDERED_TELLS,
|
|
3685
|
+
...IMAGERY_TELLS,
|
|
3686
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
3687
|
+
];
|
|
2728
3688
|
function tellById(id) {
|
|
2729
3689
|
return CATALOGUE.find((t) => t.id === id);
|
|
2730
3690
|
}
|
|
@@ -2746,17 +3706,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
|
|
|
2746
3706
|
function marked(lines, line) {
|
|
2747
3707
|
return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
|
|
2748
3708
|
}
|
|
2749
|
-
function toFinding(tell,
|
|
2750
|
-
const text = texts.get(
|
|
3709
|
+
function toFinding(tell, hit4, texts) {
|
|
3710
|
+
const text = texts.get(hit4.path) ?? "";
|
|
2751
3711
|
return {
|
|
2752
3712
|
tell: tell.id,
|
|
2753
3713
|
name: tell.name,
|
|
2754
3714
|
generation: tell.generation,
|
|
2755
3715
|
severity: tell.severity,
|
|
2756
|
-
path:
|
|
2757
|
-
line: texts.has(
|
|
2758
|
-
excerpt:
|
|
2759
|
-
message:
|
|
3716
|
+
path: hit4.path,
|
|
3717
|
+
line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
|
|
3718
|
+
excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
|
|
3719
|
+
message: hit4.message,
|
|
2760
3720
|
fix: tell.fix,
|
|
2761
3721
|
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
2762
3722
|
};
|
|
@@ -2775,14 +3735,14 @@ function report(tells, files, run, options) {
|
|
|
2775
3735
|
};
|
|
2776
3736
|
const excepted = /* @__PURE__ */ new Map();
|
|
2777
3737
|
for (const tell of selected) {
|
|
2778
|
-
for (const
|
|
2779
|
-
const exception = applied.find((e) => e.tell === tell.id && (!e.path ||
|
|
3738
|
+
for (const hit4 of run(tell)) {
|
|
3739
|
+
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
|
|
2780
3740
|
if (exception) {
|
|
2781
3741
|
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
2782
3742
|
continue;
|
|
2783
3743
|
}
|
|
2784
|
-
const finding = toFinding(tell,
|
|
2785
|
-
if (marked(linesOf(
|
|
3744
|
+
const finding = toFinding(tell, hit4, texts);
|
|
3745
|
+
if (marked(linesOf(hit4.path), finding.line)) {
|
|
2786
3746
|
suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
|
|
2787
3747
|
continue;
|
|
2788
3748
|
}
|
|
@@ -2800,7 +3760,7 @@ function report(tells, files, run, options) {
|
|
|
2800
3760
|
catalogueVersion: CATALOGUE_VERSION,
|
|
2801
3761
|
findings,
|
|
2802
3762
|
rejectedExceptions: rejected,
|
|
2803
|
-
excepted: [...excepted].map(([e,
|
|
3763
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
2804
3764
|
suppressed,
|
|
2805
3765
|
summary: {
|
|
2806
3766
|
files: files.length,
|
|
@@ -2818,67 +3778,6 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
2818
3778
|
return result;
|
|
2819
3779
|
}
|
|
2820
3780
|
|
|
2821
|
-
// src/fingerprint/index.ts
|
|
2822
|
-
var FINGERPRINT_VERSION = 2;
|
|
2823
|
-
var CHROMATIC = 0.03;
|
|
2824
|
-
var MIN_ACCENT_AREA = 2e4;
|
|
2825
|
-
var oklch = (value) => {
|
|
2826
|
-
const parsed = parseColour(value);
|
|
2827
|
-
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
2828
|
-
};
|
|
2829
|
-
function accentOf(s) {
|
|
2830
|
-
const buttons = /* @__PURE__ */ new Map();
|
|
2831
|
-
for (const c of s.controls) {
|
|
2832
|
-
const o = oklch(c.background);
|
|
2833
|
-
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
2834
|
-
}
|
|
2835
|
-
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2836
|
-
if (topButton) return oklch(topButton[0]);
|
|
2837
|
-
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
|
|
2838
|
-
if (bg) return bg.o;
|
|
2839
|
-
const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
2840
|
-
return text ? text.o : null;
|
|
2841
|
-
}
|
|
2842
|
-
function faces(s) {
|
|
2843
|
-
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
2844
|
-
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
2845
|
-
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
2846
|
-
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
2847
|
-
return {
|
|
2848
|
-
display: { family: displayName, class: fontClass(displayName) },
|
|
2849
|
-
body: { family: bodyName, class: fontClass(bodyName) }
|
|
2850
|
-
};
|
|
2851
|
-
}
|
|
2852
|
-
function median(values) {
|
|
2853
|
-
if (values.length === 0) return null;
|
|
2854
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
2855
|
-
const mid = Math.floor(sorted.length / 2);
|
|
2856
|
-
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
2857
|
-
}
|
|
2858
|
-
function fingerprint(s) {
|
|
2859
|
-
const effects = [];
|
|
2860
|
-
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
2861
|
-
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
2862
|
-
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
2863
|
-
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
2864
|
-
if (s.effects.marquees > 0) effects.push("marquee");
|
|
2865
|
-
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
2866
|
-
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
2867
|
-
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
2868
|
-
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
2869
|
-
return {
|
|
2870
|
-
version: FINGERPRINT_VERSION,
|
|
2871
|
-
accent: accentOf(s),
|
|
2872
|
-
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
2873
|
-
...faces(s),
|
|
2874
|
-
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2875
|
-
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2876
|
-
effects: effects.sort(),
|
|
2877
|
-
layout: s.sections.map((x) => x.kind),
|
|
2878
|
-
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
|
|
2879
|
-
};
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
3781
|
// src/audit/collect.ts
|
|
2883
3782
|
function collectInPage() {
|
|
2884
3783
|
const vw = window.innerWidth;
|
|
@@ -3062,16 +3961,16 @@ function collectInPage() {
|
|
|
3062
3961
|
if (row.length < 3) continue;
|
|
3063
3962
|
const w = row.map((b) => b.width);
|
|
3064
3963
|
if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
|
|
3065
|
-
const
|
|
3066
|
-
if (
|
|
3067
|
-
const iconCards =
|
|
3964
|
+
const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
|
|
3965
|
+
if (withText2.length < 3) continue;
|
|
3966
|
+
const iconCards = withText2.filter((k) => {
|
|
3068
3967
|
const first = k.querySelector("svg, img");
|
|
3069
3968
|
if (!first) return false;
|
|
3070
3969
|
const fb = box(first);
|
|
3071
3970
|
const kb = box(k);
|
|
3072
3971
|
return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
|
|
3073
3972
|
}).length;
|
|
3074
|
-
if (
|
|
3973
|
+
if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
|
|
3075
3974
|
}
|
|
3076
3975
|
return best;
|
|
3077
3976
|
};
|
|
@@ -3090,7 +3989,7 @@ function collectInPage() {
|
|
|
3090
3989
|
let covered = 0;
|
|
3091
3990
|
let minX = Infinity;
|
|
3092
3991
|
let maxX = -Infinity;
|
|
3093
|
-
let
|
|
3992
|
+
let textBlocks2 = 0;
|
|
3094
3993
|
let centred = 0;
|
|
3095
3994
|
for (const n of leaves) {
|
|
3096
3995
|
const r = box(n);
|
|
@@ -3102,14 +4001,14 @@ function collectInPage() {
|
|
|
3102
4001
|
left += split * r.height;
|
|
3103
4002
|
right += (r.width - split) * r.height;
|
|
3104
4003
|
if (ownText(n).length > 1) {
|
|
3105
|
-
|
|
4004
|
+
textBlocks2 += 1;
|
|
3106
4005
|
const mid = r.left + r.width / 2;
|
|
3107
4006
|
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
3108
4007
|
}
|
|
3109
4008
|
}
|
|
3110
4009
|
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
3111
4010
|
return {
|
|
3112
|
-
centredShare:
|
|
4011
|
+
centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
|
|
3113
4012
|
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
3114
4013
|
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
3115
4014
|
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
@@ -3118,7 +4017,7 @@ function collectInPage() {
|
|
|
3118
4017
|
};
|
|
3119
4018
|
};
|
|
3120
4019
|
const CURRENCY = /[£$€]\s?\d/;
|
|
3121
|
-
const roleOf = (el, kind,
|
|
4020
|
+
const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
|
|
3122
4021
|
if (kind === "marquee" || kind === "hero") return kind;
|
|
3123
4022
|
const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
|
|
3124
4023
|
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
|
|
@@ -3142,27 +4041,92 @@ function collectInPage() {
|
|
|
3142
4041
|
if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
|
|
3143
4042
|
if (kind === "stats" || kind === "logos") return kind;
|
|
3144
4043
|
const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
|
|
3145
|
-
const setApart =
|
|
3146
|
-
if (setApart && heading && b.height <= vh * 0.9 &&
|
|
4044
|
+
const setApart = geo2.background !== ground || geo2.centredShare >= 0.6;
|
|
4045
|
+
if (setApart && heading && b.height <= vh * 0.9 && geo2.controls >= 1 && geo2.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
|
|
3147
4046
|
if (cards >= 3 && iconCards >= 2) return "features";
|
|
3148
4047
|
return kind;
|
|
3149
4048
|
};
|
|
4049
|
+
const partsOf = (el) => {
|
|
4050
|
+
const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
|
|
4051
|
+
const badges = [];
|
|
4052
|
+
for (const n of inside) {
|
|
4053
|
+
const t = ownText(n);
|
|
4054
|
+
if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
|
|
4055
|
+
const cs = style(n);
|
|
4056
|
+
const r = box(n);
|
|
4057
|
+
const radius = px(cs.borderTopLeftRadius);
|
|
4058
|
+
const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
|
|
4059
|
+
if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
|
|
4060
|
+
}
|
|
4061
|
+
const avatars = inside.filter((n) => {
|
|
4062
|
+
if (n.tagName !== "IMG") return false;
|
|
4063
|
+
const r = box(n);
|
|
4064
|
+
const rad = style(n).borderTopLeftRadius;
|
|
4065
|
+
const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
|
|
4066
|
+
return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
|
|
4067
|
+
}).length;
|
|
4068
|
+
const carousel = inside.some((n) => {
|
|
4069
|
+
const cs = style(n);
|
|
4070
|
+
if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
|
|
4071
|
+
if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
|
|
4072
|
+
return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
|
|
4073
|
+
});
|
|
4074
|
+
return { badges, avatars, carousel };
|
|
4075
|
+
};
|
|
4076
|
+
const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
|
|
4077
|
+
const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
|
|
4078
|
+
const readable = (el) => {
|
|
4079
|
+
if (!shown(el)) return false;
|
|
4080
|
+
const r = el.getBoundingClientRect();
|
|
4081
|
+
return r.width > 2 && r.height > 2;
|
|
4082
|
+
};
|
|
4083
|
+
const textBlocks = (scope, limit, keep = () => true) => {
|
|
4084
|
+
const out = [];
|
|
4085
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4086
|
+
const add = (el) => {
|
|
4087
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
4088
|
+
if (t.length < 2 || seen.has(t)) return;
|
|
4089
|
+
seen.add(t);
|
|
4090
|
+
out.push(t);
|
|
4091
|
+
};
|
|
4092
|
+
for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
|
|
4093
|
+
if (out.length >= limit) break;
|
|
4094
|
+
if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
|
|
4095
|
+
add(el);
|
|
4096
|
+
}
|
|
4097
|
+
for (const el of Array.from(scope.querySelectorAll("div, span"))) {
|
|
4098
|
+
if (out.length >= limit) break;
|
|
4099
|
+
if (ownText(el).split(" ").length < 3) continue;
|
|
4100
|
+
if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
|
|
4101
|
+
add(el);
|
|
4102
|
+
}
|
|
4103
|
+
return out;
|
|
4104
|
+
};
|
|
4105
|
+
const inChromeHeader = (el) => {
|
|
4106
|
+
const h = el.closest("header");
|
|
4107
|
+
return Boolean(h && !h.querySelector("h1"));
|
|
4108
|
+
};
|
|
4109
|
+
const firstScreenText = textBlocks(document.body, 12, (el) => {
|
|
4110
|
+
const b = box(el);
|
|
4111
|
+
return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
|
|
4112
|
+
});
|
|
3150
4113
|
const kept = blocks.slice(0, 40);
|
|
3151
4114
|
const sections = kept.map((el, index) => {
|
|
3152
4115
|
const b = box(el);
|
|
3153
4116
|
const heading = el.querySelector("h1, h2, h3");
|
|
3154
4117
|
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3155
|
-
const
|
|
4118
|
+
const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
|
|
3156
4119
|
const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
|
|
3157
4120
|
const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
|
|
3158
4121
|
const { cards, iconCards } = cardsIn(el);
|
|
3159
4122
|
let kind = "text";
|
|
3160
4123
|
if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
|
|
3161
4124
|
else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
|
|
3162
|
-
else if (
|
|
4125
|
+
else if (images2.length >= 4 && textChars < 300) kind = "logos";
|
|
3163
4126
|
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
3164
4127
|
else if (cards >= 3) kind = "cards";
|
|
3165
4128
|
const geometry = geometryOf(el, b);
|
|
4129
|
+
const parts2 = partsOf(el);
|
|
3166
4130
|
return {
|
|
3167
4131
|
top: Math.round(b.top),
|
|
3168
4132
|
height: Math.round(b.height),
|
|
@@ -3172,7 +4136,10 @@ function collectInPage() {
|
|
|
3172
4136
|
iconCards,
|
|
3173
4137
|
hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
|
|
3174
4138
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3175
|
-
geometry
|
|
4139
|
+
geometry,
|
|
4140
|
+
...parts2,
|
|
4141
|
+
figures: numbers,
|
|
4142
|
+
text: textBlocks(el, 40)
|
|
3176
4143
|
};
|
|
3177
4144
|
});
|
|
3178
4145
|
if (header && sections.length === 0) {
|
|
@@ -3254,6 +4221,81 @@ function collectInPage() {
|
|
|
3254
4221
|
const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
|
|
3255
4222
|
if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
|
|
3256
4223
|
}
|
|
4224
|
+
const unwrap = (raw) => {
|
|
4225
|
+
if (raw.startsWith("data:")) return raw.slice(0, 64);
|
|
4226
|
+
try {
|
|
4227
|
+
const u = new URL(raw, location.href);
|
|
4228
|
+
const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
|
|
4229
|
+
if (inner) return new URL(inner, location.href).href;
|
|
4230
|
+
const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
|
|
4231
|
+
if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
|
|
4232
|
+
return u.href;
|
|
4233
|
+
} catch {
|
|
4234
|
+
return raw.slice(0, 200);
|
|
4235
|
+
}
|
|
4236
|
+
};
|
|
4237
|
+
const roundBox = (el, w) => {
|
|
4238
|
+
const rad = style(el).borderTopLeftRadius;
|
|
4239
|
+
return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
|
|
4240
|
+
};
|
|
4241
|
+
const sectionOf = (el) => {
|
|
4242
|
+
const i = kept.findIndex((k) => k.contains(el));
|
|
4243
|
+
return i === -1 ? null : i;
|
|
4244
|
+
};
|
|
4245
|
+
const roleOfImage = (el, src, w, h, alt) => {
|
|
4246
|
+
const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
|
|
4247
|
+
const square = Math.abs(w - h) <= w * 0.15;
|
|
4248
|
+
if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
|
|
4249
|
+
const section = sectionOf(el);
|
|
4250
|
+
const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
|
|
4251
|
+
if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
|
|
4252
|
+
if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
|
|
4253
|
+
if (vector) return "illustration";
|
|
4254
|
+
return w >= 160 && h >= 120 ? "photo" : "icon";
|
|
4255
|
+
};
|
|
4256
|
+
const images = [];
|
|
4257
|
+
const seenImage = /* @__PURE__ */ new Set();
|
|
4258
|
+
const addImage = (el, raw, alt, decorative, background) => {
|
|
4259
|
+
const b = box(el);
|
|
4260
|
+
if (!raw || b.width < 8 || b.height < 8) return;
|
|
4261
|
+
const src = unwrap(raw);
|
|
4262
|
+
const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
|
|
4263
|
+
if (seenImage.has(key)) return;
|
|
4264
|
+
seenImage.add(key);
|
|
4265
|
+
let host = null;
|
|
4266
|
+
try {
|
|
4267
|
+
host = src.startsWith("data:") ? null : new URL(src).hostname;
|
|
4268
|
+
} catch {
|
|
4269
|
+
host = null;
|
|
4270
|
+
}
|
|
4271
|
+
images.push({
|
|
4272
|
+
src,
|
|
4273
|
+
host,
|
|
4274
|
+
width: Math.round(b.width),
|
|
4275
|
+
height: Math.round(b.height),
|
|
4276
|
+
top: Math.round(b.top),
|
|
4277
|
+
role: roleOfImage(el, src, b.width, b.height, alt),
|
|
4278
|
+
alt,
|
|
4279
|
+
decorative,
|
|
4280
|
+
section: sectionOf(el),
|
|
4281
|
+
background
|
|
4282
|
+
});
|
|
4283
|
+
};
|
|
4284
|
+
for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
|
|
4285
|
+
if (!shown(img)) continue;
|
|
4286
|
+
const alt = img.getAttribute("alt");
|
|
4287
|
+
const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
|
|
4288
|
+
addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
|
|
4289
|
+
}
|
|
4290
|
+
for (const el of all) {
|
|
4291
|
+
const bgImage = style(el).backgroundImage || "";
|
|
4292
|
+
if (!bgImage.includes("url(")) continue;
|
|
4293
|
+
const b = box(el);
|
|
4294
|
+
if (b.width < 160 || b.height < 120 || !shown(el)) continue;
|
|
4295
|
+
const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
|
|
4296
|
+
if (m) addImage(el, m[1], null, true, true);
|
|
4297
|
+
}
|
|
4298
|
+
images.sort((a, b) => b.width * b.height - a.width * a.height);
|
|
3257
4299
|
const toList = (m) => Array.from(m, ([value, v]) => ({ value, area: Math.round(v.area), chars: v.chars })).sort((a, b) => b.area - a.area || b.chars - a.chars);
|
|
3258
4300
|
const nums = (s) => Array.from(s).sort((a, b) => a - b);
|
|
3259
4301
|
return {
|
|
@@ -3273,6 +4315,8 @@ function collectInPage() {
|
|
|
3273
4315
|
return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
|
|
3274
4316
|
})(),
|
|
3275
4317
|
effects,
|
|
4318
|
+
images: images.slice(0, 60),
|
|
4319
|
+
firstScreenText,
|
|
3276
4320
|
motion: {
|
|
3277
4321
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
3278
4322
|
sections: sections.length,
|
|
@@ -3436,7 +4480,60 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
3436
4480
|
};
|
|
3437
4481
|
}
|
|
3438
4482
|
|
|
4483
|
+
// src/snapshot/provenance.ts
|
|
4484
|
+
var PROVENANCE_SCAN_BYTES = 256 * 1024;
|
|
4485
|
+
var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
|
|
4486
|
+
var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
|
|
4487
|
+
var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
|
|
4488
|
+
var C2PA_PNG = /caBX/;
|
|
4489
|
+
function latin1(bytes) {
|
|
4490
|
+
let out = "";
|
|
4491
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
4492
|
+
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
4493
|
+
}
|
|
4494
|
+
return out;
|
|
4495
|
+
}
|
|
4496
|
+
function xmpPackets(text) {
|
|
4497
|
+
const out = [];
|
|
4498
|
+
const open = /<x:xmpmeta\b|<\?xpacket begin/g;
|
|
4499
|
+
let m;
|
|
4500
|
+
while (m = open.exec(text)) {
|
|
4501
|
+
const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
|
|
4502
|
+
out.push([m.index, close === -1 ? text.length : close]);
|
|
4503
|
+
if (close === -1) break;
|
|
4504
|
+
open.lastIndex = close;
|
|
4505
|
+
}
|
|
4506
|
+
return out;
|
|
4507
|
+
}
|
|
4508
|
+
function scanProvenance(bytes) {
|
|
4509
|
+
const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
|
|
4510
|
+
const text = latin1(head);
|
|
4511
|
+
const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
|
|
4512
|
+
const m = MARKER2.exec(text);
|
|
4513
|
+
if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
|
|
4514
|
+
const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
|
|
4515
|
+
return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
|
|
4516
|
+
}
|
|
4517
|
+
|
|
3439
4518
|
// src/audit/index.ts
|
|
4519
|
+
var PROVENANCE_MAX_IMAGES = 24;
|
|
4520
|
+
var PROVENANCE_BUDGET_MS = 2e4;
|
|
4521
|
+
async function readProvenance(page, images, timeoutMs) {
|
|
4522
|
+
if (!page.request) return;
|
|
4523
|
+
const deadline = Date.now() + PROVENANCE_BUDGET_MS;
|
|
4524
|
+
const wanted = [...new Set(images.filter((i) => i.role !== "icon" && i.role !== "logo" && /^https?:\/\//.test(i.src)).map((i) => i.src))].slice(0, PROVENANCE_MAX_IMAGES);
|
|
4525
|
+
for (const src of wanted) {
|
|
4526
|
+
const left = deadline - Date.now();
|
|
4527
|
+
if (left < 500) break;
|
|
4528
|
+
try {
|
|
4529
|
+
const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
|
|
4530
|
+
if (!res.ok()) continue;
|
|
4531
|
+
const provenance = scanProvenance(await res.body());
|
|
4532
|
+
for (const image of images) if (image.src === src) image.provenance = provenance;
|
|
4533
|
+
} catch {
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
}
|
|
3440
4537
|
var VISUAL_MAX_HEIGHT = 6e3;
|
|
3441
4538
|
async function loadPlaywright() {
|
|
3442
4539
|
for (const name of ["playwright", "playwright-core"]) {
|
|
@@ -3455,6 +4552,7 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
3455
4552
|
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
3456
4553
|
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
3457
4554
|
const data = await page.evaluate(collectInPage);
|
|
4555
|
+
if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
|
|
3458
4556
|
let visual;
|
|
3459
4557
|
if (page.screenshot && options.visual !== false) {
|
|
3460
4558
|
try {
|
|
@@ -3517,6 +4615,8 @@ async function audit(url, options = {}) {
|
|
|
3517
4615
|
return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
|
|
3518
4616
|
}
|
|
3519
4617
|
export {
|
|
4618
|
+
PROVENANCE_BUDGET_MS,
|
|
4619
|
+
PROVENANCE_MAX_IMAGES,
|
|
3520
4620
|
VISUAL_MAX_HEIGHT,
|
|
3521
4621
|
audit,
|
|
3522
4622
|
collectInPage,
|