@domandigital/craft 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/CHARACTER.md +59 -11
- package/COPY.md +5 -0
- package/dist/{audit-5TYDYRXV.js → audit-6WHR7ZTV.js} +3 -2
- package/dist/audit.cjs +669 -92
- package/dist/audit.d.cts +15 -1
- package/dist/audit.d.ts +15 -1
- package/dist/audit.js +669 -92
- package/dist/{chunk-3VTPI7BU.js → chunk-3OTKMKJQ.js} +65 -91
- package/dist/{chunk-IMB4V34S.js → chunk-7T5WR74C.js} +7 -7
- package/dist/{chunk-GMVXADJ6.js → chunk-GQVEYTBY.js} +698 -89
- package/dist/{chunk-ZZ5CKOJP.js → chunk-OOZOEWGD.js} +1 -1
- package/dist/{chunk-K2BZN4GA.js → chunk-PBK6SYH5.js} +7 -7
- package/dist/{chunk-ZLDA2SYB.js → chunk-Q7ILL2HV.js} +47 -6
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-CZDOMIVS.js → chunk-WUACK2HX.js} +2 -1
- package/dist/{chunk-ESCVTKPN.js → chunk-XVBR2UPH.js} +6 -6
- package/dist/{cli-BKJMLD3H.js → cli-3CGUKXY4.js} +6 -5
- package/dist/{cli-DRKAMFAH.js → cli-O3CDSONO.js} +7 -6
- package/dist/{cli-J5ECRCD4.js → cli-WG3UJNUF.js} +12 -11
- package/dist/{cli-N2IDXH7W.js → cli-WSHPSWVU.js} +7 -6
- package/dist/{cli-ILH7OO5G.js → cli-Z2IQG3LC.js} +5 -4
- package/dist/cli.js +3 -2
- package/dist/index.cjs +780 -225
- package/dist/index.d.cts +165 -3
- package/dist/index.d.ts +165 -3
- package/dist/index.js +763 -225
- package/dist/migrate-VIFAYIBM.js +9 -0
- package/package.json +1 -1
- package/word-lists.json +1 -1
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": {
|
|
@@ -3071,7 +3607,7 @@ var picture = (patch = {}) => ({
|
|
|
3071
3607
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3072
3608
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3073
3609
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3074
|
-
var
|
|
3610
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3075
3611
|
var IMAGERY_TELLS = [
|
|
3076
3612
|
{
|
|
3077
3613
|
id: "stock-photo",
|
|
@@ -3086,7 +3622,7 @@ var IMAGERY_TELLS = [
|
|
|
3086
3622
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3087
3623
|
if (found.length === 0) return [];
|
|
3088
3624
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3089
|
-
return [hit3(s, `${
|
|
3625
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3090
3626
|
},
|
|
3091
3627
|
fixtures: {
|
|
3092
3628
|
flag: [
|
|
@@ -3111,7 +3647,7 @@ var IMAGERY_TELLS = [
|
|
|
3111
3647
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3112
3648
|
if (found.length === 0) return [];
|
|
3113
3649
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3114
|
-
return [hit3(s, `${
|
|
3650
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3115
3651
|
},
|
|
3116
3652
|
fixtures: {
|
|
3117
3653
|
flag: [
|
|
@@ -3136,7 +3672,7 @@ var IMAGERY_TELLS = [
|
|
|
3136
3672
|
if (found.length === 0) return [];
|
|
3137
3673
|
const first = found[0].provenance;
|
|
3138
3674
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3139
|
-
return [hit3(s, `${
|
|
3675
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3140
3676
|
},
|
|
3141
3677
|
fixtures: {
|
|
3142
3678
|
flag: [
|
|
@@ -3161,7 +3697,7 @@ var IMAGERY_TELLS = [
|
|
|
3161
3697
|
if (!s.images || s.sections.length < 4) return [];
|
|
3162
3698
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3163
3699
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3164
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
3700
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3165
3701
|
},
|
|
3166
3702
|
fixtures: {
|
|
3167
3703
|
flag: [
|
|
@@ -3176,11 +3712,13 @@ var IMAGERY_TELLS = [
|
|
|
3176
3712
|
];
|
|
3177
3713
|
|
|
3178
3714
|
// src/character/check.ts
|
|
3179
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
3715
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
3180
3716
|
var CATALOGUE = [
|
|
3181
3717
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3718
|
+
...SPECIFICITY_COPY_TELLS,
|
|
3182
3719
|
...RENDERED_TELLS,
|
|
3183
|
-
...IMAGERY_TELLS
|
|
3720
|
+
...IMAGERY_TELLS,
|
|
3721
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
3184
3722
|
];
|
|
3185
3723
|
function tellById(id) {
|
|
3186
3724
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -3257,7 +3795,7 @@ function report(tells, files, run, options) {
|
|
|
3257
3795
|
catalogueVersion: CATALOGUE_VERSION,
|
|
3258
3796
|
findings,
|
|
3259
3797
|
rejectedExceptions: rejected,
|
|
3260
|
-
excepted: [...excepted].map(([e,
|
|
3798
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
3261
3799
|
suppressed,
|
|
3262
3800
|
summary: {
|
|
3263
3801
|
files: files.length,
|
|
@@ -3458,16 +3996,16 @@ function collectInPage() {
|
|
|
3458
3996
|
if (row.length < 3) continue;
|
|
3459
3997
|
const w = row.map((b) => b.width);
|
|
3460
3998
|
if (Math.max(...w) > Math.min(...w) * 1.35 || Math.max(...w) > vw * 0.45) continue;
|
|
3461
|
-
const
|
|
3462
|
-
if (
|
|
3463
|
-
const iconCards =
|
|
3999
|
+
const withText2 = kids.filter((k) => (k.textContent ?? "").trim().length > 12);
|
|
4000
|
+
if (withText2.length < 3) continue;
|
|
4001
|
+
const iconCards = withText2.filter((k) => {
|
|
3464
4002
|
const first = k.querySelector("svg, img");
|
|
3465
4003
|
if (!first) return false;
|
|
3466
4004
|
const fb = box(first);
|
|
3467
4005
|
const kb = box(k);
|
|
3468
4006
|
return fb.width <= 72 && fb.height <= 72 && fb.top - kb.top < 120;
|
|
3469
4007
|
}).length;
|
|
3470
|
-
if (
|
|
4008
|
+
if (withText2.length > best.cards) best = { cards: withText2.length, iconCards };
|
|
3471
4009
|
}
|
|
3472
4010
|
return best;
|
|
3473
4011
|
};
|
|
@@ -3486,7 +4024,7 @@ function collectInPage() {
|
|
|
3486
4024
|
let covered = 0;
|
|
3487
4025
|
let minX = Infinity;
|
|
3488
4026
|
let maxX = -Infinity;
|
|
3489
|
-
let
|
|
4027
|
+
let textBlocks2 = 0;
|
|
3490
4028
|
let centred = 0;
|
|
3491
4029
|
for (const n of leaves) {
|
|
3492
4030
|
const r = box(n);
|
|
@@ -3498,14 +4036,14 @@ function collectInPage() {
|
|
|
3498
4036
|
left += split * r.height;
|
|
3499
4037
|
right += (r.width - split) * r.height;
|
|
3500
4038
|
if (ownText(n).length > 1) {
|
|
3501
|
-
|
|
4039
|
+
textBlocks2 += 1;
|
|
3502
4040
|
const mid = r.left + r.width / 2;
|
|
3503
4041
|
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
3504
4042
|
}
|
|
3505
4043
|
}
|
|
3506
4044
|
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
3507
4045
|
return {
|
|
3508
|
-
centredShare:
|
|
4046
|
+
centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
|
|
3509
4047
|
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
3510
4048
|
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
3511
4049
|
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
@@ -3570,6 +4108,43 @@ function collectInPage() {
|
|
|
3570
4108
|
});
|
|
3571
4109
|
return { badges, avatars, carousel };
|
|
3572
4110
|
};
|
|
4111
|
+
const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
|
|
4112
|
+
const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
|
|
4113
|
+
const readable = (el) => {
|
|
4114
|
+
if (!shown(el)) return false;
|
|
4115
|
+
const r = el.getBoundingClientRect();
|
|
4116
|
+
return r.width > 2 && r.height > 2;
|
|
4117
|
+
};
|
|
4118
|
+
const textBlocks = (scope, limit, keep = () => true) => {
|
|
4119
|
+
const out = [];
|
|
4120
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4121
|
+
const add = (el) => {
|
|
4122
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
4123
|
+
if (t.length < 2 || seen.has(t)) return;
|
|
4124
|
+
seen.add(t);
|
|
4125
|
+
out.push(t);
|
|
4126
|
+
};
|
|
4127
|
+
for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
|
|
4128
|
+
if (out.length >= limit) break;
|
|
4129
|
+
if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
|
|
4130
|
+
add(el);
|
|
4131
|
+
}
|
|
4132
|
+
for (const el of Array.from(scope.querySelectorAll("div, span"))) {
|
|
4133
|
+
if (out.length >= limit) break;
|
|
4134
|
+
if (ownText(el).split(" ").length < 3) continue;
|
|
4135
|
+
if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
|
|
4136
|
+
add(el);
|
|
4137
|
+
}
|
|
4138
|
+
return out;
|
|
4139
|
+
};
|
|
4140
|
+
const inChromeHeader = (el) => {
|
|
4141
|
+
const h = el.closest("header");
|
|
4142
|
+
return Boolean(h && !h.querySelector("h1"));
|
|
4143
|
+
};
|
|
4144
|
+
const firstScreenText = textBlocks(document.body, 12, (el) => {
|
|
4145
|
+
const b = box(el);
|
|
4146
|
+
return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
|
|
4147
|
+
});
|
|
3573
4148
|
const kept = blocks.slice(0, 40);
|
|
3574
4149
|
const sections = kept.map((el, index) => {
|
|
3575
4150
|
const b = box(el);
|
|
@@ -3598,7 +4173,8 @@ function collectInPage() {
|
|
|
3598
4173
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3599
4174
|
geometry,
|
|
3600
4175
|
...parts2,
|
|
3601
|
-
figures: numbers
|
|
4176
|
+
figures: numbers,
|
|
4177
|
+
text: textBlocks(el, 40)
|
|
3602
4178
|
};
|
|
3603
4179
|
});
|
|
3604
4180
|
if (header && sections.length === 0) {
|
|
@@ -3775,6 +4351,7 @@ function collectInPage() {
|
|
|
3775
4351
|
})(),
|
|
3776
4352
|
effects,
|
|
3777
4353
|
images: images.slice(0, 60),
|
|
4354
|
+
firstScreenText,
|
|
3778
4355
|
motion: {
|
|
3779
4356
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
3780
4357
|
sections: sections.length,
|