@domandigital/craft 0.13.0 → 0.15.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 +24 -0
- package/CHARACTER.md +272 -39
- package/COPY.md +5 -0
- package/README.md +19 -1
- package/art-direction.schema.json +428 -25
- package/dist/{audit-5TYDYRXV.js → audit-MTUC24XU.js} +3 -2
- package/dist/audit.cjs +674 -92
- package/dist/audit.d.cts +15 -1
- package/dist/audit.d.ts +15 -1
- package/dist/audit.js +674 -92
- package/dist/{chunk-IMB4V34S.js → chunk-2IQ5OXFR.js} +7 -7
- package/dist/{chunk-GMVXADJ6.js → chunk-LFNSB6W7.js} +711 -92
- package/dist/{chunk-3VTPI7BU.js → chunk-MUVFECFC.js} +74 -96
- package/dist/chunk-OQXTQQAU.js +27 -0
- package/dist/{chunk-ZLDA2SYB.js → chunk-OUQ25LY2.js} +47 -6
- package/dist/{chunk-K2BZN4GA.js → chunk-QU6Q2QZZ.js} +49 -14
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-ESCVTKPN.js → chunk-RXXRXV3V.js} +9 -26
- package/dist/{chunk-CZDOMIVS.js → chunk-WUACK2HX.js} +2 -1
- package/dist/chunk-YDURAOYR.js +711 -0
- package/dist/cli-22TSWAX6.js +21 -0
- package/dist/cli-5ACKICBO.js +22 -0
- package/dist/{cli-DRKAMFAH.js → cli-7OZCYOQY.js} +89 -22
- package/dist/{cli-J5ECRCD4.js → cli-FCKHYAVZ.js} +40 -16
- package/dist/{cli-ILH7OO5G.js → cli-FLG3ZCAF.js} +6 -4
- package/dist/cli.js +3 -2
- package/dist/generate.js +6 -0
- package/dist/index.cjs +1618 -328
- package/dist/index.d.cts +619 -58
- package/dist/index.d.ts +619 -58
- package/dist/index.js +1555 -328
- package/dist/migrate-VIFAYIBM.js +9 -0
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-ZZ5CKOJP.js +0 -295
- package/dist/cli-BKJMLD3H.js +0 -18
- package/dist/cli-N2IDXH7W.js +0 -20
package/dist/audit.cjs
CHANGED
|
@@ -1035,14 +1035,14 @@ function contractionHits(ctx) {
|
|
|
1035
1035
|
for (const doc2 of documents(ctx)) {
|
|
1036
1036
|
if (doc2.words < 800) continue;
|
|
1037
1037
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
1038
|
-
const
|
|
1039
|
-
const rate =
|
|
1038
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
1039
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
1040
1040
|
if (rate < 1) {
|
|
1041
1041
|
const first = sentences(doc2)[0];
|
|
1042
1042
|
hits.push({
|
|
1043
1043
|
path: doc2.path,
|
|
1044
1044
|
offset: first?.offset ?? 0,
|
|
1045
|
-
message: `${
|
|
1045
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
1046
1046
|
});
|
|
1047
1047
|
}
|
|
1048
1048
|
}
|
|
@@ -1165,16 +1165,16 @@ function filler(words, seed = 1, options = {}) {
|
|
|
1165
1165
|
return state % n;
|
|
1166
1166
|
};
|
|
1167
1167
|
const out = [];
|
|
1168
|
-
let
|
|
1168
|
+
let count4 = 0;
|
|
1169
1169
|
let i = 0;
|
|
1170
|
-
while (
|
|
1170
|
+
while (count4 < words) {
|
|
1171
1171
|
const length = uniform ?? shape[i % shape.length];
|
|
1172
1172
|
const s = [];
|
|
1173
1173
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
1174
1174
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
1175
1175
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
1176
1176
|
out.push(`${s.join(" ")}.`);
|
|
1177
|
-
|
|
1177
|
+
count4 += s.length;
|
|
1178
1178
|
i += 1;
|
|
1179
1179
|
}
|
|
1180
1180
|
const paragraphs = [];
|
|
@@ -2357,70 +2357,6 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
2357
2357
|
}
|
|
2358
2358
|
];
|
|
2359
2359
|
|
|
2360
|
-
// src/character/house.ts
|
|
2361
|
-
var HOUSE = {
|
|
2362
|
-
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
2363
|
-
emoji: { tier: "block", label: "emoji" },
|
|
2364
|
-
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
2365
|
-
"no-x-no-y": {
|
|
2366
|
-
tier: "block",
|
|
2367
|
-
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
2368
|
-
},
|
|
2369
|
-
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
2370
|
-
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
2371
|
-
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
2372
|
-
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
2373
|
-
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
2374
|
-
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
2375
|
-
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
2376
|
-
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
2377
|
-
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
2378
|
-
"review-phrase": { tier: "review", label: "review tier" },
|
|
2379
|
-
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
2380
|
-
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
2381
|
-
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
2382
|
-
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
2383
|
-
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
2384
|
-
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
2385
|
-
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
2386
|
-
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
2387
|
-
"closing-summary": { tier: "review", label: "closing summary" },
|
|
2388
|
-
"false-range": { tier: "review", label: "false range" },
|
|
2389
|
-
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
2390
|
-
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
2391
|
-
// a proposal can honestly say "the attached research". Review until measured there.
|
|
2392
|
-
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
2393
|
-
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
2394
|
-
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
2395
|
-
"phrase-density": { tier: "review", label: "density" },
|
|
2396
|
-
"aphorism-density": { tier: "review", label: "density" },
|
|
2397
|
-
"contraction-scarcity": { tier: "review", label: "density" },
|
|
2398
|
-
"sentence-rhythm": { tier: "review", label: "density" },
|
|
2399
|
-
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
2400
|
-
"heading-shape": { tier: "review", label: "headings" },
|
|
2401
|
-
"heading-echo": { tier: "review", label: "headings" },
|
|
2402
|
-
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
2403
|
-
"vague-word": { tier: "explicit", label: "vague" }
|
|
2404
|
-
};
|
|
2405
|
-
function houseRule(id, name = id) {
|
|
2406
|
-
return HOUSE[id] ?? { tier: "review", label: name };
|
|
2407
|
-
}
|
|
2408
|
-
|
|
2409
|
-
// src/character/parse.ts
|
|
2410
|
-
function lineAt(text, offset) {
|
|
2411
|
-
let line = 1;
|
|
2412
|
-
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
2413
|
-
if (text.charCodeAt(i) === 10) line += 1;
|
|
2414
|
-
}
|
|
2415
|
-
return line;
|
|
2416
|
-
}
|
|
2417
|
-
function excerptAt(text, offset, max = 140) {
|
|
2418
|
-
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
2419
|
-
const endRaw = text.indexOf("\n", offset);
|
|
2420
|
-
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
2421
|
-
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
2422
|
-
}
|
|
2423
|
-
|
|
2424
2360
|
// src/snapshot/types.ts
|
|
2425
2361
|
var SNAPSHOT_VERSION = 2;
|
|
2426
2362
|
|
|
@@ -2493,6 +2429,606 @@ function makeSnapshot(patch = {}) {
|
|
|
2493
2429
|
return out;
|
|
2494
2430
|
}
|
|
2495
2431
|
|
|
2432
|
+
// src/character/parse.ts
|
|
2433
|
+
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
2434
|
+
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
2435
|
+
var CSS = /\.(css|scss|sass|less|pcss)$/i;
|
|
2436
|
+
var PROSE = /\.(md|markdown|txt)$/i;
|
|
2437
|
+
function fileKind(path) {
|
|
2438
|
+
if (MARKUP.test(path)) return "markup";
|
|
2439
|
+
if (CSS.test(path)) return "css";
|
|
2440
|
+
if (SCRIPT.test(path)) return "script";
|
|
2441
|
+
if (PROSE.test(path)) return "prose";
|
|
2442
|
+
return "other";
|
|
2443
|
+
}
|
|
2444
|
+
function lineAt(text, offset) {
|
|
2445
|
+
let line = 1;
|
|
2446
|
+
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
2447
|
+
if (text.charCodeAt(i) === 10) line += 1;
|
|
2448
|
+
}
|
|
2449
|
+
return line;
|
|
2450
|
+
}
|
|
2451
|
+
function excerptAt(text, offset, max = 140) {
|
|
2452
|
+
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
2453
|
+
const endRaw = text.indexOf("\n", offset);
|
|
2454
|
+
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
2455
|
+
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// src/character/facts.ts
|
|
2459
|
+
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)?)";
|
|
2460
|
+
var PATTERNS = [
|
|
2461
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
2462
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
2463
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
2464
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
2465
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
2466
|
+
["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")],
|
|
2467
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
2468
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
2469
|
+
];
|
|
2470
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
2471
|
+
var normalise = (kind, value) => {
|
|
2472
|
+
const v = value.trim();
|
|
2473
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
2474
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
2475
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
2476
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
2477
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
2478
|
+
};
|
|
2479
|
+
function protectedFacts(text) {
|
|
2480
|
+
const facts = [];
|
|
2481
|
+
let rest = text;
|
|
2482
|
+
const mask = (start, length) => {
|
|
2483
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
2484
|
+
};
|
|
2485
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
2486
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
2487
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
2488
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
2489
|
+
mask(m.index ?? 0, m[0].length);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
const lines = rest.split("\n");
|
|
2493
|
+
for (const line of lines) {
|
|
2494
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
2495
|
+
for (const m of line.matchAll(NAME)) {
|
|
2496
|
+
const at = m.index ?? 0;
|
|
2497
|
+
const before = line.slice(0, at).trimEnd();
|
|
2498
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
2499
|
+
const word = m[0];
|
|
2500
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
2501
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
2502
|
+
if (word === "I") continue;
|
|
2503
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
return facts;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
// src/character/specificity.ts
|
|
2510
|
+
var TRADE_NOUNS = [
|
|
2511
|
+
// heating, plumbing, electrical
|
|
2512
|
+
"boiler",
|
|
2513
|
+
"radiator",
|
|
2514
|
+
"combi",
|
|
2515
|
+
"cylinder",
|
|
2516
|
+
"thermostat",
|
|
2517
|
+
"underfloor heating",
|
|
2518
|
+
"heat pump",
|
|
2519
|
+
"solar panel",
|
|
2520
|
+
"plumbing",
|
|
2521
|
+
"plumber",
|
|
2522
|
+
"leak",
|
|
2523
|
+
"toilet",
|
|
2524
|
+
"shower",
|
|
2525
|
+
"bathroom",
|
|
2526
|
+
"wet room",
|
|
2527
|
+
"drain",
|
|
2528
|
+
"fuse board",
|
|
2529
|
+
"fuseboard",
|
|
2530
|
+
"consumer unit",
|
|
2531
|
+
"rewire",
|
|
2532
|
+
"socket",
|
|
2533
|
+
"ev charger",
|
|
2534
|
+
"electrician",
|
|
2535
|
+
"gas engineer",
|
|
2536
|
+
"power flush",
|
|
2537
|
+
"landlord certificate",
|
|
2538
|
+
"eicr",
|
|
2539
|
+
// building and outside
|
|
2540
|
+
"extension",
|
|
2541
|
+
"loft conversion",
|
|
2542
|
+
"loft",
|
|
2543
|
+
"kitchen",
|
|
2544
|
+
"roof",
|
|
2545
|
+
"roofer",
|
|
2546
|
+
"gutter",
|
|
2547
|
+
"chimney",
|
|
2548
|
+
"driveway",
|
|
2549
|
+
"patio",
|
|
2550
|
+
"fence",
|
|
2551
|
+
"fencing",
|
|
2552
|
+
"decking",
|
|
2553
|
+
"window",
|
|
2554
|
+
"conservatory",
|
|
2555
|
+
"plastering",
|
|
2556
|
+
"plasterer",
|
|
2557
|
+
"render",
|
|
2558
|
+
"brickwork",
|
|
2559
|
+
"bricklayer",
|
|
2560
|
+
"tiling",
|
|
2561
|
+
"tiler",
|
|
2562
|
+
"flooring",
|
|
2563
|
+
"carpet",
|
|
2564
|
+
"joinery",
|
|
2565
|
+
"joiner",
|
|
2566
|
+
"carpenter",
|
|
2567
|
+
"staircase",
|
|
2568
|
+
"damp",
|
|
2569
|
+
"scaffolding",
|
|
2570
|
+
"garage conversion",
|
|
2571
|
+
"lawn",
|
|
2572
|
+
"hedge",
|
|
2573
|
+
"tree surgery",
|
|
2574
|
+
"stump",
|
|
2575
|
+
"landscaping",
|
|
2576
|
+
"gardener",
|
|
2577
|
+
"gate",
|
|
2578
|
+
"locksmith",
|
|
2579
|
+
"pest control",
|
|
2580
|
+
"wasp",
|
|
2581
|
+
"cleaning",
|
|
2582
|
+
"oven",
|
|
2583
|
+
"end of tenancy",
|
|
2584
|
+
// vehicles
|
|
2585
|
+
"mot",
|
|
2586
|
+
"tyre",
|
|
2587
|
+
"brake",
|
|
2588
|
+
"clutch",
|
|
2589
|
+
"cambelt",
|
|
2590
|
+
"exhaust",
|
|
2591
|
+
"gearbox",
|
|
2592
|
+
"valeting",
|
|
2593
|
+
// personal and health
|
|
2594
|
+
"haircut",
|
|
2595
|
+
"skin fade",
|
|
2596
|
+
"beard",
|
|
2597
|
+
"barber",
|
|
2598
|
+
"balayage",
|
|
2599
|
+
"nails",
|
|
2600
|
+
"lashes",
|
|
2601
|
+
"massage",
|
|
2602
|
+
"physio",
|
|
2603
|
+
"physiotherapy",
|
|
2604
|
+
"osteopath",
|
|
2605
|
+
"chiropractor",
|
|
2606
|
+
"dentist",
|
|
2607
|
+
"dental",
|
|
2608
|
+
"implant",
|
|
2609
|
+
"brace",
|
|
2610
|
+
"invisalign",
|
|
2611
|
+
"hygienist",
|
|
2612
|
+
"optician",
|
|
2613
|
+
"eye test",
|
|
2614
|
+
"vet",
|
|
2615
|
+
"grooming",
|
|
2616
|
+
"dog walking",
|
|
2617
|
+
"personal trainer",
|
|
2618
|
+
"yoga",
|
|
2619
|
+
"pilates",
|
|
2620
|
+
// professional
|
|
2621
|
+
"accountant",
|
|
2622
|
+
"tax return",
|
|
2623
|
+
"bookkeeping",
|
|
2624
|
+
"payroll",
|
|
2625
|
+
"vat",
|
|
2626
|
+
"self assessment",
|
|
2627
|
+
"solicitor",
|
|
2628
|
+
"conveyancing",
|
|
2629
|
+
"probate",
|
|
2630
|
+
"divorce",
|
|
2631
|
+
"lasting power of attorney",
|
|
2632
|
+
"mortgage",
|
|
2633
|
+
"surveyor",
|
|
2634
|
+
"architect",
|
|
2635
|
+
"planning permission",
|
|
2636
|
+
"building regulations",
|
|
2637
|
+
// web and digital, for the agency's own pages
|
|
2638
|
+
"website",
|
|
2639
|
+
"web design",
|
|
2640
|
+
"seo",
|
|
2641
|
+
"hosting",
|
|
2642
|
+
"google business profile",
|
|
2643
|
+
"online shop",
|
|
2644
|
+
"ecommerce",
|
|
2645
|
+
// food and hospitality
|
|
2646
|
+
"breakfast",
|
|
2647
|
+
"lunch",
|
|
2648
|
+
"dinner",
|
|
2649
|
+
"coffee",
|
|
2650
|
+
"bakery",
|
|
2651
|
+
"cake",
|
|
2652
|
+
"catering",
|
|
2653
|
+
"wedding",
|
|
2654
|
+
"venue"
|
|
2655
|
+
];
|
|
2656
|
+
var STOCK_FIGURES = [
|
|
2657
|
+
/\b24\s?\/\s?7\b/gi,
|
|
2658
|
+
/\b24[\s-]hours?\b/gi,
|
|
2659
|
+
/\b1[01]0\s?%/g,
|
|
2660
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
2661
|
+
/#\s?1\b/g,
|
|
2662
|
+
/\bno\.?\s?1\b/gi,
|
|
2663
|
+
/\b365\s+days\b/gi
|
|
2664
|
+
];
|
|
2665
|
+
var NOT_NAMES = new Set(
|
|
2666
|
+
"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(" ")
|
|
2667
|
+
);
|
|
2668
|
+
var BRIEF_STOP = new Set(
|
|
2669
|
+
"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(" ")
|
|
2670
|
+
);
|
|
2671
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
2672
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
2673
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2674
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
2675
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
2676
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
2677
|
+
function caseless(line) {
|
|
2678
|
+
const words = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
2679
|
+
const letters = words.filter((w) => w.length >= 2);
|
|
2680
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
2681
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
2682
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
2683
|
+
return null;
|
|
2684
|
+
}
|
|
2685
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
2686
|
+
function briefTerms(brief) {
|
|
2687
|
+
const terms = /* @__PURE__ */ new Set();
|
|
2688
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
2689
|
+
const lower = w.toLowerCase();
|
|
2690
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
|
|
2691
|
+
}
|
|
2692
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
2693
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
2694
|
+
return [...terms];
|
|
2695
|
+
}
|
|
2696
|
+
function specifics(text, brief) {
|
|
2697
|
+
const found = /* @__PURE__ */ new Map();
|
|
2698
|
+
const add = (kind, value) => {
|
|
2699
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
2700
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
2701
|
+
};
|
|
2702
|
+
for (const raw of text.split("\n")) {
|
|
2703
|
+
const line = maskStock(raw);
|
|
2704
|
+
if (!line.trim()) continue;
|
|
2705
|
+
const capitals = caseless(line);
|
|
2706
|
+
for (const f4 of protectedFacts(line)) {
|
|
2707
|
+
if (f4.kind === "name") {
|
|
2708
|
+
if (capitals === "shout") continue;
|
|
2709
|
+
if (capitals === "title") {
|
|
2710
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
2711
|
+
continue;
|
|
2712
|
+
}
|
|
2713
|
+
const words = f4.value.split(/\s+/);
|
|
2714
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
2715
|
+
const first = words.findIndex(named);
|
|
2716
|
+
if (first === -1) continue;
|
|
2717
|
+
const last = words.length - 1 - [...words].reverse().findIndex(named);
|
|
2718
|
+
add("name", words.slice(first, last + 1).join(" "));
|
|
2719
|
+
continue;
|
|
2720
|
+
}
|
|
2721
|
+
add(f4.kind, f4.value);
|
|
2722
|
+
}
|
|
2723
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
2724
|
+
const m = re.exec(line);
|
|
2725
|
+
if (m) add("trade", term);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
if (brief) {
|
|
2729
|
+
for (const term of briefTerms(brief)) {
|
|
2730
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
2731
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return [...found.values()];
|
|
2735
|
+
}
|
|
2736
|
+
function specificity(text, brief) {
|
|
2737
|
+
const list = specifics(text, brief);
|
|
2738
|
+
const words = countWords2(text);
|
|
2739
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
2740
|
+
const hard = list.length - trade;
|
|
2741
|
+
return {
|
|
2742
|
+
words,
|
|
2743
|
+
specifics: list,
|
|
2744
|
+
hard,
|
|
2745
|
+
trade,
|
|
2746
|
+
perHundred: words ? Math.round(list.length / words * 1e3) / 10 : 0,
|
|
2747
|
+
generic: hard === 0 && trade < 2
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
function sentencesOf(blocks) {
|
|
2751
|
+
const out = [];
|
|
2752
|
+
for (const block of blocks) {
|
|
2753
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
2754
|
+
const s = m[0].trim();
|
|
2755
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
return [...new Set(out)];
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// src/character/tells/specificity.ts
|
|
2762
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
2763
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
2764
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
2765
|
+
function literalText(inner) {
|
|
2766
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
2767
|
+
if (/[{}]/.test(t)) return null;
|
|
2768
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
2769
|
+
}
|
|
2770
|
+
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;
|
|
2771
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
2772
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
2773
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
2774
|
+
function headlines(ctx) {
|
|
2775
|
+
const out = [];
|
|
2776
|
+
for (const file of ctx.files) {
|
|
2777
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
2778
|
+
for (const m of file.text.matchAll(H1)) {
|
|
2779
|
+
const h1 = literalText(m[1]);
|
|
2780
|
+
if (!h1) continue;
|
|
2781
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
2782
|
+
const p = NEXT_P.exec(after);
|
|
2783
|
+
const sub = p ? literalText(p[1]) : null;
|
|
2784
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
return out;
|
|
2788
|
+
}
|
|
2789
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
2790
|
+
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;
|
|
2791
|
+
var PROOF_NAMES = [
|
|
2792
|
+
"Gas Safe",
|
|
2793
|
+
"NICEIC",
|
|
2794
|
+
"NAPIT",
|
|
2795
|
+
"ELECSA",
|
|
2796
|
+
"Stroma",
|
|
2797
|
+
"TrustMark",
|
|
2798
|
+
"Which? Trusted Trader",
|
|
2799
|
+
"Checkatrade",
|
|
2800
|
+
"TrustATrader",
|
|
2801
|
+
"Rated People",
|
|
2802
|
+
"MyBuilder",
|
|
2803
|
+
"Federation of Master Builders",
|
|
2804
|
+
"FMB",
|
|
2805
|
+
"Guild of Master Craftsmen",
|
|
2806
|
+
"OFTEC",
|
|
2807
|
+
"HETAS",
|
|
2808
|
+
"CIPHE",
|
|
2809
|
+
"APHC",
|
|
2810
|
+
"CHAS",
|
|
2811
|
+
"SafeContractor",
|
|
2812
|
+
"Constructionline",
|
|
2813
|
+
"FENSA",
|
|
2814
|
+
"CERTASS",
|
|
2815
|
+
"MCS",
|
|
2816
|
+
"RECC",
|
|
2817
|
+
"BPEC",
|
|
2818
|
+
"City & Guilds",
|
|
2819
|
+
"NVQ",
|
|
2820
|
+
"CSCS",
|
|
2821
|
+
"Worcester Bosch",
|
|
2822
|
+
"Vaillant",
|
|
2823
|
+
"Viessmann",
|
|
2824
|
+
"BAFE",
|
|
2825
|
+
"NSI",
|
|
2826
|
+
"SSAIB",
|
|
2827
|
+
"BPCA",
|
|
2828
|
+
"RICS",
|
|
2829
|
+
"CIOB",
|
|
2830
|
+
"RIBA",
|
|
2831
|
+
"ICAEW",
|
|
2832
|
+
"ACCA",
|
|
2833
|
+
"CIMA",
|
|
2834
|
+
"SRA",
|
|
2835
|
+
"Law Society",
|
|
2836
|
+
"GDC",
|
|
2837
|
+
"GMC",
|
|
2838
|
+
"HCPC",
|
|
2839
|
+
"CQC",
|
|
2840
|
+
"Ofsted",
|
|
2841
|
+
"ISO 9001",
|
|
2842
|
+
"ISO 27001",
|
|
2843
|
+
"Cyber Essentials",
|
|
2844
|
+
"Companies House",
|
|
2845
|
+
"FCA",
|
|
2846
|
+
"Trustpilot",
|
|
2847
|
+
"Feefo",
|
|
2848
|
+
"Reviews.io",
|
|
2849
|
+
"Yell",
|
|
2850
|
+
"Houzz"
|
|
2851
|
+
];
|
|
2852
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2853
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
2854
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
2855
|
+
var PROOF_PATTERNS = [
|
|
2856
|
+
PROOF_NAME,
|
|
2857
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
2858
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
2859
|
+
/\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,
|
|
2860
|
+
// A registration or licence number.
|
|
2861
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
2862
|
+
];
|
|
2863
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
2864
|
+
function sectionHasProof(s, section, index) {
|
|
2865
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
2866
|
+
const text = section.text ?? [];
|
|
2867
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
2868
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
2869
|
+
}
|
|
2870
|
+
function unprovenClaims(s) {
|
|
2871
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
2872
|
+
const out = [];
|
|
2873
|
+
s.sections.forEach((section, i) => {
|
|
2874
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
2875
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
2876
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
2877
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
return out;
|
|
2881
|
+
}
|
|
2882
|
+
var withText = (texts, patch = () => ({})) => {
|
|
2883
|
+
const base = makeSnapshot();
|
|
2884
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
2885
|
+
};
|
|
2886
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
2887
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
2888
|
+
{
|
|
2889
|
+
id: "generic-hero-claim",
|
|
2890
|
+
name: "Headline that fits any business",
|
|
2891
|
+
generation: 1,
|
|
2892
|
+
severity: "warn",
|
|
2893
|
+
surface: "copy",
|
|
2894
|
+
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.",
|
|
2895
|
+
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".',
|
|
2896
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
2897
|
+
fixtures: {
|
|
2898
|
+
flag: [
|
|
2899
|
+
{ 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>' },
|
|
2900
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
2901
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
2902
|
+
],
|
|
2903
|
+
pass: [
|
|
2904
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
2905
|
+
// A stock headline rescued by the line under it.
|
|
2906
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
2907
|
+
// Two trade nouns say what the business does.
|
|
2908
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
2909
|
+
// The words come from data: nothing to read.
|
|
2910
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
2911
|
+
// Another page's headline is a label, not the site's claim.
|
|
2912
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
2913
|
+
]
|
|
2914
|
+
},
|
|
2915
|
+
rendered: {
|
|
2916
|
+
detect: (s) => {
|
|
2917
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
2918
|
+
const text = s.firstScreenText.join("\n");
|
|
2919
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
2920
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
2921
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
2922
|
+
},
|
|
2923
|
+
fixtures: {
|
|
2924
|
+
flag: [
|
|
2925
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
2926
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
2927
|
+
],
|
|
2928
|
+
pass: [
|
|
2929
|
+
makeSnapshot(),
|
|
2930
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
2931
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
2932
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
2933
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
2934
|
+
// Nor is any page but the home page.
|
|
2935
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
2936
|
+
]
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
];
|
|
2941
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
2942
|
+
{
|
|
2943
|
+
id: "unproven-claim",
|
|
2944
|
+
name: "Claim with no proof next to it",
|
|
2945
|
+
generation: 1,
|
|
2946
|
+
severity: "warn",
|
|
2947
|
+
surface: "rendered",
|
|
2948
|
+
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.`,
|
|
2949
|
+
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.",
|
|
2950
|
+
rendered: {
|
|
2951
|
+
detect: (s) => {
|
|
2952
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
2953
|
+
const found = unprovenClaims(s);
|
|
2954
|
+
if (found.length === 0) return [];
|
|
2955
|
+
const first = found[0];
|
|
2956
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
2957
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
2958
|
+
},
|
|
2959
|
+
fixtures: {
|
|
2960
|
+
flag: [
|
|
2961
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
2962
|
+
// Proof two sections away is not next to the claim.
|
|
2963
|
+
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" } : {})
|
|
2964
|
+
],
|
|
2965
|
+
pass: [
|
|
2966
|
+
makeSnapshot(),
|
|
2967
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
2968
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
2969
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
2970
|
+
{
|
|
2971
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
2972
|
+
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 }]
|
|
2973
|
+
}
|
|
2974
|
+
]
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
];
|
|
2979
|
+
|
|
2980
|
+
// src/character/house.ts
|
|
2981
|
+
var HOUSE = {
|
|
2982
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
2983
|
+
emoji: { tier: "block", label: "emoji" },
|
|
2984
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
2985
|
+
"no-x-no-y": {
|
|
2986
|
+
tier: "block",
|
|
2987
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
2988
|
+
},
|
|
2989
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
2990
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
2991
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
2992
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
2993
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
2994
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
2995
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
2996
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
2997
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
2998
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
2999
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3000
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3001
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
3002
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
3003
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
3004
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
3005
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
3006
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
3007
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
3008
|
+
"false-range": { tier: "review", label: "false range" },
|
|
3009
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
3010
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
3011
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
3012
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
3013
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
3014
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
3015
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
3016
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
3017
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
3018
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
3019
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
3020
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
3021
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
3022
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
3023
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
3024
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
3025
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
3026
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
3027
|
+
};
|
|
3028
|
+
function houseRule(id, name = id) {
|
|
3029
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2496
3032
|
// src/snapshot/fonts.ts
|
|
2497
3033
|
var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
|
|
2498
3034
|
function normaliseFamily(raw) {
|
|
@@ -2610,7 +3146,7 @@ function blueToPurple(s) {
|
|
|
2610
3146
|
}
|
|
2611
3147
|
return hits;
|
|
2612
3148
|
}
|
|
2613
|
-
var
|
|
3149
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
2614
3150
|
var RENDERED_PATHS = {
|
|
2615
3151
|
"reflex-font": {
|
|
2616
3152
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -2648,11 +3184,11 @@ var RENDERED_PATHS = {
|
|
|
2648
3184
|
}
|
|
2649
3185
|
},
|
|
2650
3186
|
"gradient-text": {
|
|
2651
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
3187
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
2652
3188
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
2653
3189
|
},
|
|
2654
3190
|
"glass-panel": {
|
|
2655
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
3191
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
2656
3192
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
2657
3193
|
},
|
|
2658
3194
|
"cream-palette": {
|
|
@@ -2730,7 +3266,7 @@ var RENDERED_PATHS = {
|
|
|
2730
3266
|
detect: (s) => {
|
|
2731
3267
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
2732
3268
|
const n = s.effects.glows + radial;
|
|
2733
|
-
return n > 0 ? [hit(s, `${
|
|
3269
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
2734
3270
|
},
|
|
2735
3271
|
fixtures: {
|
|
2736
3272
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -2738,11 +3274,11 @@ var RENDERED_PATHS = {
|
|
|
2738
3274
|
}
|
|
2739
3275
|
},
|
|
2740
3276
|
"grid-background": {
|
|
2741
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
3277
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
2742
3278
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
2743
3279
|
},
|
|
2744
3280
|
marquee: {
|
|
2745
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
3281
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
2746
3282
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
2747
3283
|
},
|
|
2748
3284
|
"thin-border-wide-shadow": {
|
|
@@ -2829,6 +3365,11 @@ function fingerprint(s) {
|
|
|
2829
3365
|
} : {}
|
|
2830
3366
|
};
|
|
2831
3367
|
}
|
|
3368
|
+
var ACCENT_SCALE = 0.3;
|
|
3369
|
+
var GROUND_SCALE = 0.15;
|
|
3370
|
+
var SHARED_PART = 0.2;
|
|
3371
|
+
var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
|
|
3372
|
+
var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
|
|
2832
3373
|
|
|
2833
3374
|
// src/fingerprint/component.ts
|
|
2834
3375
|
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
@@ -3071,7 +3612,7 @@ var picture = (patch = {}) => ({
|
|
|
3071
3612
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3072
3613
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3073
3614
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3074
|
-
var
|
|
3615
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3075
3616
|
var IMAGERY_TELLS = [
|
|
3076
3617
|
{
|
|
3077
3618
|
id: "stock-photo",
|
|
@@ -3086,7 +3627,7 @@ var IMAGERY_TELLS = [
|
|
|
3086
3627
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3087
3628
|
if (found.length === 0) return [];
|
|
3088
3629
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3089
|
-
return [hit3(s, `${
|
|
3630
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3090
3631
|
},
|
|
3091
3632
|
fixtures: {
|
|
3092
3633
|
flag: [
|
|
@@ -3111,7 +3652,7 @@ var IMAGERY_TELLS = [
|
|
|
3111
3652
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3112
3653
|
if (found.length === 0) return [];
|
|
3113
3654
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3114
|
-
return [hit3(s, `${
|
|
3655
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3115
3656
|
},
|
|
3116
3657
|
fixtures: {
|
|
3117
3658
|
flag: [
|
|
@@ -3136,7 +3677,7 @@ var IMAGERY_TELLS = [
|
|
|
3136
3677
|
if (found.length === 0) return [];
|
|
3137
3678
|
const first = found[0].provenance;
|
|
3138
3679
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3139
|
-
return [hit3(s, `${
|
|
3680
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3140
3681
|
},
|
|
3141
3682
|
fixtures: {
|
|
3142
3683
|
flag: [
|
|
@@ -3161,7 +3702,7 @@ var IMAGERY_TELLS = [
|
|
|
3161
3702
|
if (!s.images || s.sections.length < 4) return [];
|
|
3162
3703
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3163
3704
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3164
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
3705
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3165
3706
|
},
|
|
3166
3707
|
fixtures: {
|
|
3167
3708
|
flag: [
|
|
@@ -3176,11 +3717,13 @@ var IMAGERY_TELLS = [
|
|
|
3176
3717
|
];
|
|
3177
3718
|
|
|
3178
3719
|
// src/character/check.ts
|
|
3179
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
3720
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
3180
3721
|
var CATALOGUE = [
|
|
3181
3722
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3723
|
+
...SPECIFICITY_COPY_TELLS,
|
|
3182
3724
|
...RENDERED_TELLS,
|
|
3183
|
-
...IMAGERY_TELLS
|
|
3725
|
+
...IMAGERY_TELLS,
|
|
3726
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
3184
3727
|
];
|
|
3185
3728
|
function tellById(id) {
|
|
3186
3729
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -3257,7 +3800,7 @@ function report(tells, files, run, options) {
|
|
|
3257
3800
|
catalogueVersion: CATALOGUE_VERSION,
|
|
3258
3801
|
findings,
|
|
3259
3802
|
rejectedExceptions: rejected,
|
|
3260
|
-
excepted: [...excepted].map(([e,
|
|
3803
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
3261
3804
|
suppressed,
|
|
3262
3805
|
summary: {
|
|
3263
3806
|
files: files.length,
|
|
@@ -3458,16 +4001,16 @@ function collectInPage() {
|
|
|
3458
4001
|
if (row.length < 3) continue;
|
|
3459
4002
|
const w = row.map((b) => b.width);
|
|
3460
4003
|
if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
|
|
3461
|
-
const
|
|
3462
|
-
if (
|
|
3463
|
-
const iconCards =
|
|
4004
|
+
const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
|
|
4005
|
+
if (withText2.length < 3) continue;
|
|
4006
|
+
const iconCards = withText2.filter((k) => {
|
|
3464
4007
|
const first = k.querySelector("svg, img");
|
|
3465
4008
|
if (!first) return false;
|
|
3466
4009
|
const fb = box(first);
|
|
3467
4010
|
const kb = box(k);
|
|
3468
4011
|
return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
|
|
3469
4012
|
}).length;
|
|
3470
|
-
if (
|
|
4013
|
+
if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
|
|
3471
4014
|
}
|
|
3472
4015
|
return best;
|
|
3473
4016
|
};
|
|
@@ -3486,7 +4029,7 @@ function collectInPage() {
|
|
|
3486
4029
|
let covered = 0;
|
|
3487
4030
|
let minX = Infinity;
|
|
3488
4031
|
let maxX = -Infinity;
|
|
3489
|
-
let
|
|
4032
|
+
let textBlocks2 = 0;
|
|
3490
4033
|
let centred = 0;
|
|
3491
4034
|
for (const n of leaves) {
|
|
3492
4035
|
const r = box(n);
|
|
@@ -3498,14 +4041,14 @@ function collectInPage() {
|
|
|
3498
4041
|
left += split * r.height;
|
|
3499
4042
|
right += (r.width - split) * r.height;
|
|
3500
4043
|
if (ownText(n).length > 1) {
|
|
3501
|
-
|
|
4044
|
+
textBlocks2 += 1;
|
|
3502
4045
|
const mid = r.left + r.width / 2;
|
|
3503
4046
|
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
3504
4047
|
}
|
|
3505
4048
|
}
|
|
3506
4049
|
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
3507
4050
|
return {
|
|
3508
|
-
centredShare:
|
|
4051
|
+
centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
|
|
3509
4052
|
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
3510
4053
|
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
3511
4054
|
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
@@ -3570,6 +4113,43 @@ function collectInPage() {
|
|
|
3570
4113
|
});
|
|
3571
4114
|
return { badges, avatars, carousel };
|
|
3572
4115
|
};
|
|
4116
|
+
const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
|
|
4117
|
+
const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
|
|
4118
|
+
const readable = (el) => {
|
|
4119
|
+
if (!shown(el)) return false;
|
|
4120
|
+
const r = el.getBoundingClientRect();
|
|
4121
|
+
return r.width > 2 && r.height > 2;
|
|
4122
|
+
};
|
|
4123
|
+
const textBlocks = (scope, limit, keep = () => true) => {
|
|
4124
|
+
const out = [];
|
|
4125
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4126
|
+
const add = (el) => {
|
|
4127
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
4128
|
+
if (t.length < 2 || seen.has(t)) return;
|
|
4129
|
+
seen.add(t);
|
|
4130
|
+
out.push(t);
|
|
4131
|
+
};
|
|
4132
|
+
for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
|
|
4133
|
+
if (out.length >= limit) break;
|
|
4134
|
+
if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
|
|
4135
|
+
add(el);
|
|
4136
|
+
}
|
|
4137
|
+
for (const el of Array.from(scope.querySelectorAll("div, span"))) {
|
|
4138
|
+
if (out.length >= limit) break;
|
|
4139
|
+
if (ownText(el).split(" ").length < 3) continue;
|
|
4140
|
+
if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
|
|
4141
|
+
add(el);
|
|
4142
|
+
}
|
|
4143
|
+
return out;
|
|
4144
|
+
};
|
|
4145
|
+
const inChromeHeader = (el) => {
|
|
4146
|
+
const h = el.closest("header");
|
|
4147
|
+
return Boolean(h && !h.querySelector("h1"));
|
|
4148
|
+
};
|
|
4149
|
+
const firstScreenText = textBlocks(document.body, 12, (el) => {
|
|
4150
|
+
const b = box(el);
|
|
4151
|
+
return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
|
|
4152
|
+
});
|
|
3573
4153
|
const kept = blocks.slice(0, 40);
|
|
3574
4154
|
const sections = kept.map((el, index) => {
|
|
3575
4155
|
const b = box(el);
|
|
@@ -3598,7 +4178,8 @@ function collectInPage() {
|
|
|
3598
4178
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3599
4179
|
geometry,
|
|
3600
4180
|
...parts2,
|
|
3601
|
-
figures: numbers
|
|
4181
|
+
figures: numbers,
|
|
4182
|
+
text: textBlocks(el, 40)
|
|
3602
4183
|
};
|
|
3603
4184
|
});
|
|
3604
4185
|
if (header && sections.length === 0) {
|
|
@@ -3775,6 +4356,7 @@ function collectInPage() {
|
|
|
3775
4356
|
})(),
|
|
3776
4357
|
effects,
|
|
3777
4358
|
images: images.slice(0, 60),
|
|
4359
|
+
firstScreenText,
|
|
3778
4360
|
motion: {
|
|
3779
4361
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
3780
4362
|
sections: sections.length,
|