@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.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": {
|
|
@@ -2794,6 +3330,11 @@ function fingerprint(s) {
|
|
|
2794
3330
|
} : {}
|
|
2795
3331
|
};
|
|
2796
3332
|
}
|
|
3333
|
+
var ACCENT_SCALE = 0.3;
|
|
3334
|
+
var GROUND_SCALE = 0.15;
|
|
3335
|
+
var SHARED_PART = 0.2;
|
|
3336
|
+
var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
|
|
3337
|
+
var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
|
|
2797
3338
|
|
|
2798
3339
|
// src/fingerprint/component.ts
|
|
2799
3340
|
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
@@ -3036,7 +3577,7 @@ var picture = (patch = {}) => ({
|
|
|
3036
3577
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3037
3578
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3038
3579
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3039
|
-
var
|
|
3580
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3040
3581
|
var IMAGERY_TELLS = [
|
|
3041
3582
|
{
|
|
3042
3583
|
id: "stock-photo",
|
|
@@ -3051,7 +3592,7 @@ var IMAGERY_TELLS = [
|
|
|
3051
3592
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3052
3593
|
if (found.length === 0) return [];
|
|
3053
3594
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3054
|
-
return [hit3(s, `${
|
|
3595
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3055
3596
|
},
|
|
3056
3597
|
fixtures: {
|
|
3057
3598
|
flag: [
|
|
@@ -3076,7 +3617,7 @@ var IMAGERY_TELLS = [
|
|
|
3076
3617
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3077
3618
|
if (found.length === 0) return [];
|
|
3078
3619
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3079
|
-
return [hit3(s, `${
|
|
3620
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3080
3621
|
},
|
|
3081
3622
|
fixtures: {
|
|
3082
3623
|
flag: [
|
|
@@ -3101,7 +3642,7 @@ var IMAGERY_TELLS = [
|
|
|
3101
3642
|
if (found.length === 0) return [];
|
|
3102
3643
|
const first = found[0].provenance;
|
|
3103
3644
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3104
|
-
return [hit3(s, `${
|
|
3645
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3105
3646
|
},
|
|
3106
3647
|
fixtures: {
|
|
3107
3648
|
flag: [
|
|
@@ -3126,7 +3667,7 @@ var IMAGERY_TELLS = [
|
|
|
3126
3667
|
if (!s.images || s.sections.length < 4) return [];
|
|
3127
3668
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3128
3669
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3129
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
3670
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3130
3671
|
},
|
|
3131
3672
|
fixtures: {
|
|
3132
3673
|
flag: [
|
|
@@ -3141,11 +3682,13 @@ var IMAGERY_TELLS = [
|
|
|
3141
3682
|
];
|
|
3142
3683
|
|
|
3143
3684
|
// src/character/check.ts
|
|
3144
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
3685
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
3145
3686
|
var CATALOGUE = [
|
|
3146
3687
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3688
|
+
...SPECIFICITY_COPY_TELLS,
|
|
3147
3689
|
...RENDERED_TELLS,
|
|
3148
|
-
...IMAGERY_TELLS
|
|
3690
|
+
...IMAGERY_TELLS,
|
|
3691
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
3149
3692
|
];
|
|
3150
3693
|
function tellById(id) {
|
|
3151
3694
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -3222,7 +3765,7 @@ function report(tells, files, run, options) {
|
|
|
3222
3765
|
catalogueVersion: CATALOGUE_VERSION,
|
|
3223
3766
|
findings,
|
|
3224
3767
|
rejectedExceptions: rejected,
|
|
3225
|
-
excepted: [...excepted].map(([e,
|
|
3768
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
3226
3769
|
suppressed,
|
|
3227
3770
|
summary: {
|
|
3228
3771
|
files: files.length,
|
|
@@ -3423,16 +3966,16 @@ function collectInPage() {
|
|
|
3423
3966
|
if (row.length < 3) continue;
|
|
3424
3967
|
const w = row.map((b) => b.width);
|
|
3425
3968
|
if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
|
|
3426
|
-
const
|
|
3427
|
-
if (
|
|
3428
|
-
const iconCards =
|
|
3969
|
+
const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
|
|
3970
|
+
if (withText2.length < 3) continue;
|
|
3971
|
+
const iconCards = withText2.filter((k) => {
|
|
3429
3972
|
const first = k.querySelector("svg, img");
|
|
3430
3973
|
if (!first) return false;
|
|
3431
3974
|
const fb = box(first);
|
|
3432
3975
|
const kb = box(k);
|
|
3433
3976
|
return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
|
|
3434
3977
|
}).length;
|
|
3435
|
-
if (
|
|
3978
|
+
if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
|
|
3436
3979
|
}
|
|
3437
3980
|
return best;
|
|
3438
3981
|
};
|
|
@@ -3451,7 +3994,7 @@ function collectInPage() {
|
|
|
3451
3994
|
let covered = 0;
|
|
3452
3995
|
let minX = Infinity;
|
|
3453
3996
|
let maxX = -Infinity;
|
|
3454
|
-
let
|
|
3997
|
+
let textBlocks2 = 0;
|
|
3455
3998
|
let centred = 0;
|
|
3456
3999
|
for (const n of leaves) {
|
|
3457
4000
|
const r = box(n);
|
|
@@ -3463,14 +4006,14 @@ function collectInPage() {
|
|
|
3463
4006
|
left += split * r.height;
|
|
3464
4007
|
right += (r.width - split) * r.height;
|
|
3465
4008
|
if (ownText(n).length > 1) {
|
|
3466
|
-
|
|
4009
|
+
textBlocks2 += 1;
|
|
3467
4010
|
const mid = r.left + r.width / 2;
|
|
3468
4011
|
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
3469
4012
|
}
|
|
3470
4013
|
}
|
|
3471
4014
|
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
3472
4015
|
return {
|
|
3473
|
-
centredShare:
|
|
4016
|
+
centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
|
|
3474
4017
|
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
3475
4018
|
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
3476
4019
|
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
@@ -3535,6 +4078,43 @@ function collectInPage() {
|
|
|
3535
4078
|
});
|
|
3536
4079
|
return { badges, avatars, carousel };
|
|
3537
4080
|
};
|
|
4081
|
+
const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
|
|
4082
|
+
const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
|
|
4083
|
+
const readable = (el) => {
|
|
4084
|
+
if (!shown(el)) return false;
|
|
4085
|
+
const r = el.getBoundingClientRect();
|
|
4086
|
+
return r.width > 2 && r.height > 2;
|
|
4087
|
+
};
|
|
4088
|
+
const textBlocks = (scope, limit, keep = () => true) => {
|
|
4089
|
+
const out = [];
|
|
4090
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4091
|
+
const add = (el) => {
|
|
4092
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
4093
|
+
if (t.length < 2 || seen.has(t)) return;
|
|
4094
|
+
seen.add(t);
|
|
4095
|
+
out.push(t);
|
|
4096
|
+
};
|
|
4097
|
+
for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
|
|
4098
|
+
if (out.length >= limit) break;
|
|
4099
|
+
if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
|
|
4100
|
+
add(el);
|
|
4101
|
+
}
|
|
4102
|
+
for (const el of Array.from(scope.querySelectorAll("div, span"))) {
|
|
4103
|
+
if (out.length >= limit) break;
|
|
4104
|
+
if (ownText(el).split(" ").length < 3) continue;
|
|
4105
|
+
if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
|
|
4106
|
+
add(el);
|
|
4107
|
+
}
|
|
4108
|
+
return out;
|
|
4109
|
+
};
|
|
4110
|
+
const inChromeHeader = (el) => {
|
|
4111
|
+
const h = el.closest("header");
|
|
4112
|
+
return Boolean(h && !h.querySelector("h1"));
|
|
4113
|
+
};
|
|
4114
|
+
const firstScreenText = textBlocks(document.body, 12, (el) => {
|
|
4115
|
+
const b = box(el);
|
|
4116
|
+
return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
|
|
4117
|
+
});
|
|
3538
4118
|
const kept = blocks.slice(0, 40);
|
|
3539
4119
|
const sections = kept.map((el, index) => {
|
|
3540
4120
|
const b = box(el);
|
|
@@ -3563,7 +4143,8 @@ function collectInPage() {
|
|
|
3563
4143
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3564
4144
|
geometry,
|
|
3565
4145
|
...parts2,
|
|
3566
|
-
figures: numbers
|
|
4146
|
+
figures: numbers,
|
|
4147
|
+
text: textBlocks(el, 40)
|
|
3567
4148
|
};
|
|
3568
4149
|
});
|
|
3569
4150
|
if (header && sections.length === 0) {
|
|
@@ -3740,6 +4321,7 @@ function collectInPage() {
|
|
|
3740
4321
|
})(),
|
|
3741
4322
|
effects,
|
|
3742
4323
|
images: images.slice(0, 60),
|
|
4324
|
+
firstScreenText,
|
|
3743
4325
|
motion: {
|
|
3744
4326
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
3745
4327
|
sections: sections.length,
|