@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/index.js
CHANGED
|
@@ -2250,14 +2250,14 @@ function contractionHits(ctx) {
|
|
|
2250
2250
|
for (const doc2 of documents(ctx)) {
|
|
2251
2251
|
if (doc2.words < 800) continue;
|
|
2252
2252
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2253
|
-
const
|
|
2254
|
-
const rate =
|
|
2253
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
2254
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
2255
2255
|
if (rate < 1) {
|
|
2256
2256
|
const first = sentences(doc2)[0];
|
|
2257
2257
|
hits.push({
|
|
2258
2258
|
path: doc2.path,
|
|
2259
2259
|
offset: first?.offset ?? 0,
|
|
2260
|
-
message: `${
|
|
2260
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2261
2261
|
});
|
|
2262
2262
|
}
|
|
2263
2263
|
}
|
|
@@ -2380,16 +2380,16 @@ function filler(words2, seed = 1, options = {}) {
|
|
|
2380
2380
|
return state % n;
|
|
2381
2381
|
};
|
|
2382
2382
|
const out = [];
|
|
2383
|
-
let
|
|
2383
|
+
let count4 = 0;
|
|
2384
2384
|
let i = 0;
|
|
2385
|
-
while (
|
|
2385
|
+
while (count4 < words2) {
|
|
2386
2386
|
const length = uniform ?? shape[i % shape.length];
|
|
2387
2387
|
const s = [];
|
|
2388
2388
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2389
2389
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2390
2390
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2391
2391
|
out.push(`${s.join(" ")}.`);
|
|
2392
|
-
|
|
2392
|
+
count4 += s.length;
|
|
2393
2393
|
i += 1;
|
|
2394
2394
|
}
|
|
2395
2395
|
const paragraphs = [];
|
|
@@ -3529,64 +3529,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
3529
3529
|
}
|
|
3530
3530
|
];
|
|
3531
3531
|
|
|
3532
|
-
// src/
|
|
3533
|
-
var
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3532
|
+
// src/snapshot/types.ts
|
|
3533
|
+
var SNAPSHOT_VERSION = 2;
|
|
3534
|
+
|
|
3535
|
+
// src/snapshot/fixture.ts
|
|
3536
|
+
function makeSnapshot(patch = {}) {
|
|
3537
|
+
const base = {
|
|
3538
|
+
version: SNAPSHOT_VERSION,
|
|
3539
|
+
url: "https://example.test/",
|
|
3540
|
+
capturedAt: "2026-09-23T00:00:00.000Z",
|
|
3541
|
+
viewport: { width: 1440, height: 900 },
|
|
3542
|
+
pageHeight: 4200,
|
|
3543
|
+
ground: "rgb(255, 255, 255)",
|
|
3544
|
+
colours: {
|
|
3545
|
+
backgrounds: [
|
|
3546
|
+
{ value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
|
|
3547
|
+
{ value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
|
|
3548
|
+
],
|
|
3549
|
+
text: [
|
|
3550
|
+
{ value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
|
|
3551
|
+
{ value: "rgb(31, 58, 46)", area: 0, chars: 300 }
|
|
3552
|
+
]
|
|
3553
|
+
},
|
|
3554
|
+
fonts: [
|
|
3555
|
+
{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
|
|
3556
|
+
{ family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
|
|
3557
|
+
],
|
|
3558
|
+
headings: [
|
|
3559
|
+
{ level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
|
|
3560
|
+
{ level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
|
|
3561
|
+
],
|
|
3562
|
+
controls: [
|
|
3563
|
+
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
3564
|
+
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
3565
|
+
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
3566
|
+
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
3567
|
+
],
|
|
3568
|
+
sections: [
|
|
3569
|
+
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3570
|
+
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3571
|
+
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
3572
|
+
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3573
|
+
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
3574
|
+
],
|
|
3575
|
+
effects: {
|
|
3576
|
+
glass: 0,
|
|
3577
|
+
gradientText: 0,
|
|
3578
|
+
gradients: [],
|
|
3579
|
+
glows: 0,
|
|
3580
|
+
gridBackgrounds: 0,
|
|
3581
|
+
marquees: 0,
|
|
3582
|
+
hairlineShadowCards: 0,
|
|
3583
|
+
eyebrowChip: false
|
|
3584
|
+
},
|
|
3585
|
+
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3586
|
+
metrics: {
|
|
3587
|
+
fontSizes: [14, 16, 20, 36, 64],
|
|
3588
|
+
fontWeights: [400, 500],
|
|
3589
|
+
radii: ["4px"],
|
|
3590
|
+
shadows: [],
|
|
3591
|
+
spacingOnScalePct: 96,
|
|
3592
|
+
bodyLineLengthCh: 68,
|
|
3593
|
+
bodyFontSizePx: 17
|
|
3594
|
+
}
|
|
3589
3595
|
};
|
|
3596
|
+
const out = { ...base };
|
|
3597
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3598
|
+
const current = base[key];
|
|
3599
|
+
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3600
|
+
}
|
|
3601
|
+
return out;
|
|
3590
3602
|
}
|
|
3591
3603
|
|
|
3592
3604
|
// src/character/parse.ts
|
|
@@ -3920,76 +3932,664 @@ function extractStrings(file) {
|
|
|
3920
3932
|
return [];
|
|
3921
3933
|
}
|
|
3922
3934
|
|
|
3923
|
-
// src/
|
|
3924
|
-
var
|
|
3935
|
+
// src/character/facts.ts
|
|
3936
|
+
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)?)";
|
|
3937
|
+
var PATTERNS = [
|
|
3938
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
3939
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
3940
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
3941
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
3942
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
3943
|
+
["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")],
|
|
3944
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
3945
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
3946
|
+
];
|
|
3947
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
3948
|
+
var normalise = (kind, value) => {
|
|
3949
|
+
const v = value.trim();
|
|
3950
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
3951
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
3952
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
3953
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
3954
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
3955
|
+
};
|
|
3956
|
+
function visibleText(file) {
|
|
3957
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
3958
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
3959
|
+
}
|
|
3960
|
+
function protectedFacts(text) {
|
|
3961
|
+
const facts = [];
|
|
3962
|
+
let rest = text;
|
|
3963
|
+
const mask = (start, length) => {
|
|
3964
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
3965
|
+
};
|
|
3966
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
3967
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
3968
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
3969
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
3970
|
+
mask(m.index ?? 0, m[0].length);
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
const lines = rest.split("\n");
|
|
3974
|
+
for (const line of lines) {
|
|
3975
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
3976
|
+
for (const m of line.matchAll(NAME)) {
|
|
3977
|
+
const at = m.index ?? 0;
|
|
3978
|
+
const before = line.slice(0, at).trimEnd();
|
|
3979
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
3980
|
+
const word = m[0];
|
|
3981
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
3982
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
3983
|
+
if (word === "I") continue;
|
|
3984
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
return facts;
|
|
3988
|
+
}
|
|
3989
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
3990
|
+
function compareFacts(before, after) {
|
|
3991
|
+
const a = unique(protectedFacts(before));
|
|
3992
|
+
const b = unique(protectedFacts(after));
|
|
3993
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
3994
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
3995
|
+
return {
|
|
3996
|
+
before: a.length,
|
|
3997
|
+
after: b.length,
|
|
3998
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
3999
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4003
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4004
|
+
if (result.lost.length) {
|
|
4005
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4006
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4007
|
+
}
|
|
4008
|
+
if (result.added.length) {
|
|
4009
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4010
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4011
|
+
}
|
|
4012
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4013
|
+
return lines.join("\n");
|
|
4014
|
+
}
|
|
3925
4015
|
|
|
3926
|
-
// src/
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
4016
|
+
// src/character/specificity.ts
|
|
4017
|
+
var TRADE_NOUNS = [
|
|
4018
|
+
// heating, plumbing, electrical
|
|
4019
|
+
"boiler",
|
|
4020
|
+
"radiator",
|
|
4021
|
+
"combi",
|
|
4022
|
+
"cylinder",
|
|
4023
|
+
"thermostat",
|
|
4024
|
+
"underfloor heating",
|
|
4025
|
+
"heat pump",
|
|
4026
|
+
"solar panel",
|
|
4027
|
+
"plumbing",
|
|
4028
|
+
"plumber",
|
|
4029
|
+
"leak",
|
|
4030
|
+
"toilet",
|
|
4031
|
+
"shower",
|
|
4032
|
+
"bathroom",
|
|
4033
|
+
"wet room",
|
|
4034
|
+
"drain",
|
|
4035
|
+
"fuse board",
|
|
4036
|
+
"fuseboard",
|
|
4037
|
+
"consumer unit",
|
|
4038
|
+
"rewire",
|
|
4039
|
+
"socket",
|
|
4040
|
+
"ev charger",
|
|
4041
|
+
"electrician",
|
|
4042
|
+
"gas engineer",
|
|
4043
|
+
"power flush",
|
|
4044
|
+
"landlord certificate",
|
|
4045
|
+
"eicr",
|
|
4046
|
+
// building and outside
|
|
4047
|
+
"extension",
|
|
4048
|
+
"loft conversion",
|
|
4049
|
+
"loft",
|
|
4050
|
+
"kitchen",
|
|
4051
|
+
"roof",
|
|
4052
|
+
"roofer",
|
|
4053
|
+
"gutter",
|
|
4054
|
+
"chimney",
|
|
4055
|
+
"driveway",
|
|
4056
|
+
"patio",
|
|
4057
|
+
"fence",
|
|
4058
|
+
"fencing",
|
|
4059
|
+
"decking",
|
|
4060
|
+
"window",
|
|
4061
|
+
"conservatory",
|
|
4062
|
+
"plastering",
|
|
4063
|
+
"plasterer",
|
|
4064
|
+
"render",
|
|
4065
|
+
"brickwork",
|
|
4066
|
+
"bricklayer",
|
|
4067
|
+
"tiling",
|
|
4068
|
+
"tiler",
|
|
4069
|
+
"flooring",
|
|
4070
|
+
"carpet",
|
|
4071
|
+
"joinery",
|
|
4072
|
+
"joiner",
|
|
4073
|
+
"carpenter",
|
|
4074
|
+
"staircase",
|
|
4075
|
+
"damp",
|
|
4076
|
+
"scaffolding",
|
|
4077
|
+
"garage conversion",
|
|
4078
|
+
"lawn",
|
|
4079
|
+
"hedge",
|
|
4080
|
+
"tree surgery",
|
|
4081
|
+
"stump",
|
|
4082
|
+
"landscaping",
|
|
4083
|
+
"gardener",
|
|
4084
|
+
"gate",
|
|
4085
|
+
"locksmith",
|
|
4086
|
+
"pest control",
|
|
4087
|
+
"wasp",
|
|
4088
|
+
"cleaning",
|
|
4089
|
+
"oven",
|
|
4090
|
+
"end of tenancy",
|
|
4091
|
+
// vehicles
|
|
4092
|
+
"mot",
|
|
4093
|
+
"tyre",
|
|
4094
|
+
"brake",
|
|
4095
|
+
"clutch",
|
|
4096
|
+
"cambelt",
|
|
4097
|
+
"exhaust",
|
|
4098
|
+
"gearbox",
|
|
4099
|
+
"valeting",
|
|
4100
|
+
// personal and health
|
|
4101
|
+
"haircut",
|
|
4102
|
+
"skin fade",
|
|
4103
|
+
"beard",
|
|
4104
|
+
"barber",
|
|
4105
|
+
"balayage",
|
|
4106
|
+
"nails",
|
|
4107
|
+
"lashes",
|
|
4108
|
+
"massage",
|
|
4109
|
+
"physio",
|
|
4110
|
+
"physiotherapy",
|
|
4111
|
+
"osteopath",
|
|
4112
|
+
"chiropractor",
|
|
4113
|
+
"dentist",
|
|
4114
|
+
"dental",
|
|
4115
|
+
"implant",
|
|
4116
|
+
"brace",
|
|
4117
|
+
"invisalign",
|
|
4118
|
+
"hygienist",
|
|
4119
|
+
"optician",
|
|
4120
|
+
"eye test",
|
|
4121
|
+
"vet",
|
|
4122
|
+
"grooming",
|
|
4123
|
+
"dog walking",
|
|
4124
|
+
"personal trainer",
|
|
4125
|
+
"yoga",
|
|
4126
|
+
"pilates",
|
|
4127
|
+
// professional
|
|
4128
|
+
"accountant",
|
|
4129
|
+
"tax return",
|
|
4130
|
+
"bookkeeping",
|
|
4131
|
+
"payroll",
|
|
4132
|
+
"vat",
|
|
4133
|
+
"self assessment",
|
|
4134
|
+
"solicitor",
|
|
4135
|
+
"conveyancing",
|
|
4136
|
+
"probate",
|
|
4137
|
+
"divorce",
|
|
4138
|
+
"lasting power of attorney",
|
|
4139
|
+
"mortgage",
|
|
4140
|
+
"surveyor",
|
|
4141
|
+
"architect",
|
|
4142
|
+
"planning permission",
|
|
4143
|
+
"building regulations",
|
|
4144
|
+
// web and digital, for the agency's own pages
|
|
4145
|
+
"website",
|
|
4146
|
+
"web design",
|
|
4147
|
+
"seo",
|
|
4148
|
+
"hosting",
|
|
4149
|
+
"google business profile",
|
|
4150
|
+
"online shop",
|
|
4151
|
+
"ecommerce",
|
|
4152
|
+
// food and hospitality
|
|
4153
|
+
"breakfast",
|
|
4154
|
+
"lunch",
|
|
4155
|
+
"dinner",
|
|
4156
|
+
"coffee",
|
|
4157
|
+
"bakery",
|
|
4158
|
+
"cake",
|
|
4159
|
+
"catering",
|
|
4160
|
+
"wedding",
|
|
4161
|
+
"venue"
|
|
4162
|
+
];
|
|
4163
|
+
var STOCK_FIGURES = [
|
|
4164
|
+
/\b24\s?\/\s?7\b/gi,
|
|
4165
|
+
/\b24[\s-]hours?\b/gi,
|
|
4166
|
+
/\b1[01]0\s?%/g,
|
|
4167
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
4168
|
+
/#\s?1\b/g,
|
|
4169
|
+
/\bno\.?\s?1\b/gi,
|
|
4170
|
+
/\b365\s+days\b/gi
|
|
4171
|
+
];
|
|
4172
|
+
var NOT_NAMES = new Set(
|
|
4173
|
+
"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(" ")
|
|
4174
|
+
);
|
|
4175
|
+
var BRIEF_STOP = new Set(
|
|
4176
|
+
"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(" ")
|
|
4177
|
+
);
|
|
4178
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
4179
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
4180
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4181
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
4182
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
4183
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
4184
|
+
function caseless(line) {
|
|
4185
|
+
const words2 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
4186
|
+
const letters = words2.filter((w) => w.length >= 2);
|
|
4187
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
4188
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
4189
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
4190
|
+
return null;
|
|
4191
|
+
}
|
|
4192
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
4193
|
+
function briefTerms(brief) {
|
|
4194
|
+
const terms = /* @__PURE__ */ new Set();
|
|
4195
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
4196
|
+
const lower = w.toLowerCase();
|
|
4197
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
|
|
4198
|
+
}
|
|
4199
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
4200
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
4201
|
+
return [...terms];
|
|
4202
|
+
}
|
|
4203
|
+
function specifics(text, brief) {
|
|
4204
|
+
const found = /* @__PURE__ */ new Map();
|
|
4205
|
+
const add = (kind, value) => {
|
|
4206
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
4207
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
4208
|
+
};
|
|
4209
|
+
for (const raw of text.split("\n")) {
|
|
4210
|
+
const line = maskStock(raw);
|
|
4211
|
+
if (!line.trim()) continue;
|
|
4212
|
+
const capitals = caseless(line);
|
|
4213
|
+
for (const f4 of protectedFacts(line)) {
|
|
4214
|
+
if (f4.kind === "name") {
|
|
4215
|
+
if (capitals === "shout") continue;
|
|
4216
|
+
if (capitals === "title") {
|
|
4217
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
4218
|
+
continue;
|
|
4219
|
+
}
|
|
4220
|
+
const words2 = f4.value.split(/\s+/);
|
|
4221
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
4222
|
+
const first = words2.findIndex(named);
|
|
4223
|
+
if (first === -1) continue;
|
|
4224
|
+
const last = words2.length - 1 - [...words2].reverse().findIndex(named);
|
|
4225
|
+
add("name", words2.slice(first, last + 1).join(" "));
|
|
4226
|
+
continue;
|
|
4227
|
+
}
|
|
4228
|
+
add(f4.kind, f4.value);
|
|
4229
|
+
}
|
|
4230
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
4231
|
+
const m = re.exec(line);
|
|
4232
|
+
if (m) add("trade", term);
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
if (brief) {
|
|
4236
|
+
for (const term of briefTerms(brief)) {
|
|
4237
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
4238
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
return [...found.values()];
|
|
4242
|
+
}
|
|
4243
|
+
function specificity(text, brief) {
|
|
4244
|
+
const list = specifics(text, brief);
|
|
4245
|
+
const words2 = countWords2(text);
|
|
4246
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
4247
|
+
const hard = list.length - trade;
|
|
4248
|
+
return {
|
|
4249
|
+
words: words2,
|
|
4250
|
+
specifics: list,
|
|
4251
|
+
hard,
|
|
4252
|
+
trade,
|
|
4253
|
+
perHundred: words2 ? Math.round(list.length / words2 * 1e3) / 10 : 0,
|
|
4254
|
+
generic: hard === 0 && trade < 2
|
|
4255
|
+
};
|
|
4256
|
+
}
|
|
4257
|
+
var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
|
|
4258
|
+
var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
|
|
4259
|
+
var SERVICE_LABEL = /\bservices?\b|\bwhat we (?:do|fix|offer|make|build)\b|\bhow we\b|\bour work\b|\bwe (?:fix|fit|install|repair|build|offer|make)\b/i;
|
|
4260
|
+
function sentencesOf(blocks) {
|
|
4261
|
+
const out = [];
|
|
4262
|
+
for (const block of blocks) {
|
|
4263
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
4264
|
+
const s = m[0].trim();
|
|
4265
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
return [...new Set(out)];
|
|
4269
|
+
}
|
|
4270
|
+
function pageCopy(snapshot) {
|
|
4271
|
+
const withText2 = snapshot.sections.filter((s) => s.text);
|
|
4272
|
+
if (!snapshot.firstScreenText && withText2.length === 0) return null;
|
|
4273
|
+
const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
|
|
4274
|
+
const services = snapshot.sections.filter((s) => s.kind !== "hero" && !NOT_SERVICE_ROLES.has(s.role ?? s.kind)).filter((s) => SERVICE_ROLES.has(s.role ?? s.kind) || SERVICE_LABEL.test(s.label)).flatMap((s) => s.text ?? []);
|
|
4275
|
+
const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
|
|
4276
|
+
return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
|
|
4277
|
+
}
|
|
4278
|
+
function side(page, other, brief) {
|
|
4279
|
+
const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
|
|
4280
|
+
const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
|
|
4281
|
+
const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
|
|
4282
|
+
return {
|
|
4283
|
+
url: page.url,
|
|
4284
|
+
sentences: sentences3.length,
|
|
4285
|
+
interchangeable,
|
|
4286
|
+
share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
|
|
4287
|
+
perHundred: specificity(sentences3.join("\n"), brief).perHundred
|
|
4288
|
+
};
|
|
4289
|
+
}
|
|
4290
|
+
function compareCompetitor(ours, competitor, brief) {
|
|
4291
|
+
return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
|
|
4292
|
+
}
|
|
4293
|
+
var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
|
|
4294
|
+
function formatCompetitor(result) {
|
|
4295
|
+
const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
|
|
4296
|
+
const lines = [
|
|
4297
|
+
`craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
|
|
4298
|
+
" Hero and service sentences holding nothing the other page does not also say:",
|
|
4299
|
+
row("ours", result.ours),
|
|
4300
|
+
row("competitor", result.competitor)
|
|
4301
|
+
];
|
|
4302
|
+
if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
|
|
4303
|
+
if (result.ours.interchangeable.length) {
|
|
4304
|
+
lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
|
|
4305
|
+
for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
|
|
4306
|
+
if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
|
|
4307
|
+
} else {
|
|
4308
|
+
lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
|
|
4309
|
+
}
|
|
4310
|
+
return lines.join("\n");
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
// src/character/tells/specificity.ts
|
|
4314
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4315
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
4316
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
4317
|
+
function literalText(inner) {
|
|
4318
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
4319
|
+
if (/[{}]/.test(t)) return null;
|
|
4320
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
4321
|
+
}
|
|
4322
|
+
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;
|
|
4323
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
4324
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
4325
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
4326
|
+
function headlines(ctx) {
|
|
4327
|
+
const out = [];
|
|
4328
|
+
for (const file of ctx.files) {
|
|
4329
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
4330
|
+
for (const m of file.text.matchAll(H1)) {
|
|
4331
|
+
const h1 = literalText(m[1]);
|
|
4332
|
+
if (!h1) continue;
|
|
4333
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
4334
|
+
const p = NEXT_P.exec(after);
|
|
4335
|
+
const sub = p ? literalText(p[1]) : null;
|
|
4336
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
return out;
|
|
4340
|
+
}
|
|
4341
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
4342
|
+
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;
|
|
4343
|
+
var PROOF_NAMES = [
|
|
4344
|
+
"Gas Safe",
|
|
4345
|
+
"NICEIC",
|
|
4346
|
+
"NAPIT",
|
|
4347
|
+
"ELECSA",
|
|
4348
|
+
"Stroma",
|
|
4349
|
+
"TrustMark",
|
|
4350
|
+
"Which? Trusted Trader",
|
|
4351
|
+
"Checkatrade",
|
|
4352
|
+
"TrustATrader",
|
|
4353
|
+
"Rated People",
|
|
4354
|
+
"MyBuilder",
|
|
4355
|
+
"Federation of Master Builders",
|
|
4356
|
+
"FMB",
|
|
4357
|
+
"Guild of Master Craftsmen",
|
|
4358
|
+
"OFTEC",
|
|
4359
|
+
"HETAS",
|
|
4360
|
+
"CIPHE",
|
|
4361
|
+
"APHC",
|
|
4362
|
+
"CHAS",
|
|
4363
|
+
"SafeContractor",
|
|
4364
|
+
"Constructionline",
|
|
4365
|
+
"FENSA",
|
|
4366
|
+
"CERTASS",
|
|
4367
|
+
"MCS",
|
|
4368
|
+
"RECC",
|
|
4369
|
+
"BPEC",
|
|
4370
|
+
"City & Guilds",
|
|
4371
|
+
"NVQ",
|
|
4372
|
+
"CSCS",
|
|
4373
|
+
"Worcester Bosch",
|
|
4374
|
+
"Vaillant",
|
|
4375
|
+
"Viessmann",
|
|
4376
|
+
"BAFE",
|
|
4377
|
+
"NSI",
|
|
4378
|
+
"SSAIB",
|
|
4379
|
+
"BPCA",
|
|
4380
|
+
"RICS",
|
|
4381
|
+
"CIOB",
|
|
4382
|
+
"RIBA",
|
|
4383
|
+
"ICAEW",
|
|
4384
|
+
"ACCA",
|
|
4385
|
+
"CIMA",
|
|
4386
|
+
"SRA",
|
|
4387
|
+
"Law Society",
|
|
4388
|
+
"GDC",
|
|
4389
|
+
"GMC",
|
|
4390
|
+
"HCPC",
|
|
4391
|
+
"CQC",
|
|
4392
|
+
"Ofsted",
|
|
4393
|
+
"ISO 9001",
|
|
4394
|
+
"ISO 27001",
|
|
4395
|
+
"Cyber Essentials",
|
|
4396
|
+
"Companies House",
|
|
4397
|
+
"FCA",
|
|
4398
|
+
"Trustpilot",
|
|
4399
|
+
"Feefo",
|
|
4400
|
+
"Reviews.io",
|
|
4401
|
+
"Yell",
|
|
4402
|
+
"Houzz"
|
|
4403
|
+
];
|
|
4404
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4405
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
4406
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
4407
|
+
var PROOF_PATTERNS = [
|
|
4408
|
+
PROOF_NAME,
|
|
4409
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
4410
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
4411
|
+
/\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,
|
|
4412
|
+
// A registration or licence number.
|
|
4413
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
4414
|
+
];
|
|
4415
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
4416
|
+
function sectionHasProof(s, section, index) {
|
|
4417
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
4418
|
+
const text = section.text ?? [];
|
|
4419
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
4420
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
4421
|
+
}
|
|
4422
|
+
function unprovenClaims(s) {
|
|
4423
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
4424
|
+
const out = [];
|
|
4425
|
+
s.sections.forEach((section, i) => {
|
|
4426
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
4427
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
4428
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
4429
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
4432
|
+
return out;
|
|
4433
|
+
}
|
|
4434
|
+
var withText = (texts, patch = () => ({})) => {
|
|
4435
|
+
const base = makeSnapshot();
|
|
4436
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
4437
|
+
};
|
|
4438
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
4439
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
4440
|
+
{
|
|
4441
|
+
id: "generic-hero-claim",
|
|
4442
|
+
name: "Headline that fits any business",
|
|
4443
|
+
generation: 1,
|
|
4444
|
+
severity: "warn",
|
|
4445
|
+
surface: "copy",
|
|
4446
|
+
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.",
|
|
4447
|
+
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".',
|
|
4448
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
4449
|
+
fixtures: {
|
|
4450
|
+
flag: [
|
|
4451
|
+
{ 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>' },
|
|
4452
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
4453
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
3939
4454
|
],
|
|
3940
|
-
|
|
3941
|
-
{
|
|
3942
|
-
|
|
4455
|
+
pass: [
|
|
4456
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
4457
|
+
// A stock headline rescued by the line under it.
|
|
4458
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
4459
|
+
// Two trade nouns say what the business does.
|
|
4460
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
4461
|
+
// The words come from data: nothing to read.
|
|
4462
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
4463
|
+
// Another page's headline is a label, not the site's claim.
|
|
4464
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
3943
4465
|
]
|
|
3944
4466
|
},
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
gradientText: 0,
|
|
3969
|
-
gradients: [],
|
|
3970
|
-
glows: 0,
|
|
3971
|
-
gridBackgrounds: 0,
|
|
3972
|
-
marquees: 0,
|
|
3973
|
-
hairlineShadowCards: 0,
|
|
3974
|
-
eyebrowChip: false
|
|
3975
|
-
},
|
|
3976
|
-
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3977
|
-
metrics: {
|
|
3978
|
-
fontSizes: [14, 16, 20, 36, 64],
|
|
3979
|
-
fontWeights: [400, 500],
|
|
3980
|
-
radii: ["4px"],
|
|
3981
|
-
shadows: [],
|
|
3982
|
-
spacingOnScalePct: 96,
|
|
3983
|
-
bodyLineLengthCh: 68,
|
|
3984
|
-
bodyFontSizePx: 17
|
|
4467
|
+
rendered: {
|
|
4468
|
+
detect: (s) => {
|
|
4469
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
4470
|
+
const text = s.firstScreenText.join("\n");
|
|
4471
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
4472
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
4473
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
4474
|
+
},
|
|
4475
|
+
fixtures: {
|
|
4476
|
+
flag: [
|
|
4477
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
4478
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
4479
|
+
],
|
|
4480
|
+
pass: [
|
|
4481
|
+
makeSnapshot(),
|
|
4482
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
4483
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
4484
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
4485
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
4486
|
+
// Nor is any page but the home page.
|
|
4487
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
4488
|
+
]
|
|
4489
|
+
}
|
|
3985
4490
|
}
|
|
3986
|
-
};
|
|
3987
|
-
const out = { ...base };
|
|
3988
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
3989
|
-
const current = base[key];
|
|
3990
|
-
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3991
4491
|
}
|
|
3992
|
-
|
|
4492
|
+
];
|
|
4493
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
4494
|
+
{
|
|
4495
|
+
id: "unproven-claim",
|
|
4496
|
+
name: "Claim with no proof next to it",
|
|
4497
|
+
generation: 1,
|
|
4498
|
+
severity: "warn",
|
|
4499
|
+
surface: "rendered",
|
|
4500
|
+
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.`,
|
|
4501
|
+
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.",
|
|
4502
|
+
rendered: {
|
|
4503
|
+
detect: (s) => {
|
|
4504
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
4505
|
+
const found = unprovenClaims(s);
|
|
4506
|
+
if (found.length === 0) return [];
|
|
4507
|
+
const first = found[0];
|
|
4508
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
4509
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
4510
|
+
},
|
|
4511
|
+
fixtures: {
|
|
4512
|
+
flag: [
|
|
4513
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4514
|
+
// Proof two sections away is not next to the claim.
|
|
4515
|
+
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" } : {})
|
|
4516
|
+
],
|
|
4517
|
+
pass: [
|
|
4518
|
+
makeSnapshot(),
|
|
4519
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
4520
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
4521
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
4522
|
+
{
|
|
4523
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4524
|
+
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 }]
|
|
4525
|
+
}
|
|
4526
|
+
]
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
];
|
|
4531
|
+
|
|
4532
|
+
// src/character/house.ts
|
|
4533
|
+
var HOUSE = {
|
|
4534
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
4535
|
+
emoji: { tier: "block", label: "emoji" },
|
|
4536
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
4537
|
+
"no-x-no-y": {
|
|
4538
|
+
tier: "block",
|
|
4539
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
4540
|
+
},
|
|
4541
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
4542
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
4543
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
4544
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
4545
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
4546
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
4547
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
4548
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
4549
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
4550
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
4551
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
4552
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
4553
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
4554
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
4555
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
4556
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
4557
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
4558
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
4559
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
4560
|
+
"false-range": { tier: "review", label: "false range" },
|
|
4561
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
4562
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
4563
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
4564
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
4565
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
4566
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
4567
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
4568
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
4569
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
4570
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
4571
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
4572
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
4573
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
4574
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
4575
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
4576
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
4577
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
4578
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
4579
|
+
};
|
|
4580
|
+
function houseRule(id, name = id) {
|
|
4581
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
4582
|
+
}
|
|
4583
|
+
var HOUSE_LOCKED = "the house blocking tier cannot be lowered by a repo. A genuine one-off takes copy-ok on its line; a rule that is wrong is changed in craft's house.ts and COPY.md";
|
|
4584
|
+
function holdHouseBlocks(exceptions, severity) {
|
|
4585
|
+
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
4586
|
+
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
4587
|
+
return {
|
|
4588
|
+
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
4589
|
+
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
4590
|
+
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
4591
|
+
refusedSeverity
|
|
4592
|
+
};
|
|
3993
4593
|
}
|
|
3994
4594
|
|
|
3995
4595
|
// src/snapshot/fonts.ts
|
|
@@ -4109,7 +4709,7 @@ function blueToPurple(s) {
|
|
|
4109
4709
|
}
|
|
4110
4710
|
return hits;
|
|
4111
4711
|
}
|
|
4112
|
-
var
|
|
4712
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4113
4713
|
var RENDERED_PATHS = {
|
|
4114
4714
|
"reflex-font": {
|
|
4115
4715
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -4147,11 +4747,11 @@ var RENDERED_PATHS = {
|
|
|
4147
4747
|
}
|
|
4148
4748
|
},
|
|
4149
4749
|
"gradient-text": {
|
|
4150
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
4750
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
4151
4751
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
4152
4752
|
},
|
|
4153
4753
|
"glass-panel": {
|
|
4154
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
4754
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
4155
4755
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
4156
4756
|
},
|
|
4157
4757
|
"cream-palette": {
|
|
@@ -4229,7 +4829,7 @@ var RENDERED_PATHS = {
|
|
|
4229
4829
|
detect: (s) => {
|
|
4230
4830
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
4231
4831
|
const n = s.effects.glows + radial;
|
|
4232
|
-
return n > 0 ? [hit(s, `${
|
|
4832
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
4233
4833
|
},
|
|
4234
4834
|
fixtures: {
|
|
4235
4835
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -4237,11 +4837,11 @@ var RENDERED_PATHS = {
|
|
|
4237
4837
|
}
|
|
4238
4838
|
},
|
|
4239
4839
|
"grid-background": {
|
|
4240
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
4840
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
4241
4841
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
4242
4842
|
},
|
|
4243
4843
|
marquee: {
|
|
4244
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
4844
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
4245
4845
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
4246
4846
|
},
|
|
4247
4847
|
"thin-border-wide-shadow": {
|
|
@@ -4472,8 +5072,8 @@ function sharedChoices(target2, runs) {
|
|
|
4472
5072
|
const out = [];
|
|
4473
5073
|
for (const [part, values] of Object.entries(mine)) {
|
|
4474
5074
|
for (const value of values) {
|
|
4475
|
-
const
|
|
4476
|
-
if (
|
|
5075
|
+
const count4 = all.filter((c) => c[part].includes(value)).length;
|
|
5076
|
+
if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
|
|
4477
5077
|
}
|
|
4478
5078
|
}
|
|
4479
5079
|
return out.sort((a, b) => b.runs - a.runs);
|
|
@@ -4947,7 +5547,7 @@ var picture = (patch = {}) => ({
|
|
|
4947
5547
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
4948
5548
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
4949
5549
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
4950
|
-
var
|
|
5550
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4951
5551
|
var IMAGERY_TELLS = [
|
|
4952
5552
|
{
|
|
4953
5553
|
id: "stock-photo",
|
|
@@ -4962,7 +5562,7 @@ var IMAGERY_TELLS = [
|
|
|
4962
5562
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
4963
5563
|
if (found.length === 0) return [];
|
|
4964
5564
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
4965
|
-
return [hit3(s, `${
|
|
5565
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
4966
5566
|
},
|
|
4967
5567
|
fixtures: {
|
|
4968
5568
|
flag: [
|
|
@@ -4987,7 +5587,7 @@ var IMAGERY_TELLS = [
|
|
|
4987
5587
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
4988
5588
|
if (found.length === 0) return [];
|
|
4989
5589
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
4990
|
-
return [hit3(s, `${
|
|
5590
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
4991
5591
|
},
|
|
4992
5592
|
fixtures: {
|
|
4993
5593
|
flag: [
|
|
@@ -5012,7 +5612,7 @@ var IMAGERY_TELLS = [
|
|
|
5012
5612
|
if (found.length === 0) return [];
|
|
5013
5613
|
const first = found[0].provenance;
|
|
5014
5614
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
5015
|
-
return [hit3(s, `${
|
|
5615
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
5016
5616
|
},
|
|
5017
5617
|
fixtures: {
|
|
5018
5618
|
flag: [
|
|
@@ -5037,7 +5637,7 @@ var IMAGERY_TELLS = [
|
|
|
5037
5637
|
if (!s.images || s.sections.length < 4) return [];
|
|
5038
5638
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
5039
5639
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
5040
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
5640
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
5041
5641
|
},
|
|
5042
5642
|
fixtures: {
|
|
5043
5643
|
flag: [
|
|
@@ -5052,11 +5652,13 @@ var IMAGERY_TELLS = [
|
|
|
5052
5652
|
];
|
|
5053
5653
|
|
|
5054
5654
|
// src/character/check.ts
|
|
5055
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
5655
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
5056
5656
|
var CATALOGUE = [
|
|
5057
5657
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
5658
|
+
...SPECIFICITY_COPY_TELLS,
|
|
5058
5659
|
...RENDERED_TELLS,
|
|
5059
|
-
...IMAGERY_TELLS
|
|
5660
|
+
...IMAGERY_TELLS,
|
|
5661
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
5060
5662
|
];
|
|
5061
5663
|
function tellById(id) {
|
|
5062
5664
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -5133,7 +5735,7 @@ function report(tells, files, run, options) {
|
|
|
5133
5735
|
catalogueVersion: CATALOGUE_VERSION,
|
|
5134
5736
|
findings,
|
|
5135
5737
|
rejectedExceptions: rejected,
|
|
5136
|
-
excepted: [...excepted].map(([e,
|
|
5738
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
5137
5739
|
suppressed,
|
|
5138
5740
|
summary: {
|
|
5139
5741
|
files: files.length,
|
|
@@ -5174,7 +5776,7 @@ function runTell(tell, files) {
|
|
|
5174
5776
|
}
|
|
5175
5777
|
function catalogueTable() {
|
|
5176
5778
|
const rows = CATALOGUE.map(
|
|
5177
|
-
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5779
|
+
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5178
5780
|
);
|
|
5179
5781
|
return [
|
|
5180
5782
|
`Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
|
|
@@ -5239,87 +5841,6 @@ function formatReport(report2, title) {
|
|
|
5239
5841
|
return lines.join("\n").replace(/^\n/, "");
|
|
5240
5842
|
}
|
|
5241
5843
|
|
|
5242
|
-
// src/character/facts.ts
|
|
5243
|
-
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)?)";
|
|
5244
|
-
var PATTERNS = [
|
|
5245
|
-
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
5246
|
-
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
5247
|
-
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
5248
|
-
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
5249
|
-
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
5250
|
-
["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")],
|
|
5251
|
-
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
5252
|
-
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
5253
|
-
];
|
|
5254
|
-
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
5255
|
-
var normalise = (kind, value) => {
|
|
5256
|
-
const v = value.trim();
|
|
5257
|
-
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
5258
|
-
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
5259
|
-
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
5260
|
-
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
5261
|
-
return v.replace(/\s+/g, " ").toLowerCase();
|
|
5262
|
-
};
|
|
5263
|
-
function visibleText(file) {
|
|
5264
|
-
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
5265
|
-
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
5266
|
-
}
|
|
5267
|
-
function protectedFacts(text) {
|
|
5268
|
-
const facts = [];
|
|
5269
|
-
let rest = text;
|
|
5270
|
-
const mask = (start, length) => {
|
|
5271
|
-
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
5272
|
-
};
|
|
5273
|
-
for (const [kind, pattern] of PATTERNS) {
|
|
5274
|
-
for (const m of [...rest.matchAll(pattern)]) {
|
|
5275
|
-
const value = m[0].replace(/[.,;:]+$/, "");
|
|
5276
|
-
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
5277
|
-
mask(m.index ?? 0, m[0].length);
|
|
5278
|
-
}
|
|
5279
|
-
}
|
|
5280
|
-
const lines = rest.split("\n");
|
|
5281
|
-
for (const line of lines) {
|
|
5282
|
-
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
5283
|
-
for (const m of line.matchAll(NAME)) {
|
|
5284
|
-
const at = m.index ?? 0;
|
|
5285
|
-
const before = line.slice(0, at).trimEnd();
|
|
5286
|
-
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
5287
|
-
const word = m[0];
|
|
5288
|
-
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
5289
|
-
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
5290
|
-
if (word === "I") continue;
|
|
5291
|
-
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
5292
|
-
}
|
|
5293
|
-
}
|
|
5294
|
-
return facts;
|
|
5295
|
-
}
|
|
5296
|
-
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
5297
|
-
function compareFacts(before, after) {
|
|
5298
|
-
const a = unique(protectedFacts(before));
|
|
5299
|
-
const b = unique(protectedFacts(after));
|
|
5300
|
-
const inA = new Set(a.map((f4) => f4.key));
|
|
5301
|
-
const inB = new Set(b.map((f4) => f4.key));
|
|
5302
|
-
return {
|
|
5303
|
-
before: a.length,
|
|
5304
|
-
after: b.length,
|
|
5305
|
-
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
5306
|
-
added: b.filter((f4) => !inA.has(f4.key))
|
|
5307
|
-
};
|
|
5308
|
-
}
|
|
5309
|
-
function formatComparison(result, beforePath, afterPath) {
|
|
5310
|
-
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
5311
|
-
if (result.lost.length) {
|
|
5312
|
-
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
5313
|
-
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5314
|
-
}
|
|
5315
|
-
if (result.added.length) {
|
|
5316
|
-
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
5317
|
-
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5318
|
-
}
|
|
5319
|
-
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
5320
|
-
return lines.join("\n");
|
|
5321
|
-
}
|
|
5322
|
-
|
|
5323
5844
|
// src/character/claims.ts
|
|
5324
5845
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
|
|
5325
5846
|
var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
|
|
@@ -6434,6 +6955,8 @@ export {
|
|
|
6434
6955
|
ESTATE_VERSION,
|
|
6435
6956
|
EXIT_RATIO,
|
|
6436
6957
|
FINGERPRINT_VERSION,
|
|
6958
|
+
HOME_FILE,
|
|
6959
|
+
HOME_URL,
|
|
6437
6960
|
HOUSE,
|
|
6438
6961
|
HOUSE_BUDGET,
|
|
6439
6962
|
HOUSE_LOCKED,
|
|
@@ -6449,6 +6972,7 @@ export {
|
|
|
6449
6972
|
PILL_LIMIT,
|
|
6450
6973
|
PLAINER_WORDS,
|
|
6451
6974
|
PROMO_BADGE,
|
|
6975
|
+
PROOF_NAMES,
|
|
6452
6976
|
PROPOSED,
|
|
6453
6977
|
PROVENANCE_SCAN_BYTES,
|
|
6454
6978
|
RAMP_STEPS,
|
|
@@ -6466,11 +6990,16 @@ export {
|
|
|
6466
6990
|
SNAPSHOT_VERSION,
|
|
6467
6991
|
SOURCE_KINDS,
|
|
6468
6992
|
SPACE_STEPS,
|
|
6993
|
+
SPECIFICITY_COPY_TELLS,
|
|
6994
|
+
SPECIFICITY_RENDERED_TELLS,
|
|
6469
6995
|
SPRING,
|
|
6996
|
+
STANDING_CLAIM,
|
|
6470
6997
|
STOCK_AVATAR_HOSTS,
|
|
6998
|
+
STOCK_FIGURES,
|
|
6471
6999
|
STOCK_PHOTO_HOSTS,
|
|
6472
7000
|
STOCK_PHRASES,
|
|
6473
7001
|
TELL_HEAVY,
|
|
7002
|
+
TRADE_NOUNS,
|
|
6474
7003
|
TYPE_STEPS,
|
|
6475
7004
|
TYPICAL_AT,
|
|
6476
7005
|
VAGUE_WORDS,
|
|
@@ -6481,6 +7010,7 @@ export {
|
|
|
6481
7010
|
applyHouseGate,
|
|
6482
7011
|
applySeverity,
|
|
6483
7012
|
auditSnapshot,
|
|
7013
|
+
briefTerms,
|
|
6484
7014
|
catalogueTable,
|
|
6485
7015
|
characterReport,
|
|
6486
7016
|
checkCopy,
|
|
@@ -6488,6 +7018,7 @@ export {
|
|
|
6488
7018
|
checkRestraint,
|
|
6489
7019
|
choicesOf,
|
|
6490
7020
|
closestComponents,
|
|
7021
|
+
compareCompetitor,
|
|
6491
7022
|
compareFacts,
|
|
6492
7023
|
compareToEstate,
|
|
6493
7024
|
componentDistance,
|
|
@@ -6495,6 +7026,7 @@ export {
|
|
|
6495
7026
|
componentTypicalities,
|
|
6496
7027
|
componentTypicality,
|
|
6497
7028
|
componentsOf,
|
|
7029
|
+
countWords2 as countWords,
|
|
6498
7030
|
craftTokens,
|
|
6499
7031
|
createBaseline,
|
|
6500
7032
|
deltaEOk,
|
|
@@ -6519,6 +7051,7 @@ export {
|
|
|
6519
7051
|
fontClass,
|
|
6520
7052
|
formatClaims,
|
|
6521
7053
|
formatComparison,
|
|
7054
|
+
formatCompetitor,
|
|
6522
7055
|
formatHex,
|
|
6523
7056
|
formatReport,
|
|
6524
7057
|
globToRegExp,
|
|
@@ -6545,6 +7078,7 @@ export {
|
|
|
6545
7078
|
nullPrompt,
|
|
6546
7079
|
oklchToHex,
|
|
6547
7080
|
oklchToRgb,
|
|
7081
|
+
pageCopy,
|
|
6548
7082
|
paletteFromPixels,
|
|
6549
7083
|
parseBaseline,
|
|
6550
7084
|
parseColour,
|
|
@@ -6561,11 +7095,14 @@ export {
|
|
|
6561
7095
|
scanSource,
|
|
6562
7096
|
sectionRhythm,
|
|
6563
7097
|
semantic,
|
|
7098
|
+
sentencesOf,
|
|
6564
7099
|
severityExceptions,
|
|
6565
7100
|
shapeName,
|
|
6566
7101
|
sharedParts,
|
|
6567
7102
|
shouldAnimate,
|
|
6568
7103
|
siblingLine,
|
|
7104
|
+
specificity,
|
|
7105
|
+
specifics,
|
|
6569
7106
|
stockAvatarSource,
|
|
6570
7107
|
stockSource,
|
|
6571
7108
|
tailwindV3Preset,
|
|
@@ -6576,6 +7113,7 @@ export {
|
|
|
6576
7113
|
toSarif,
|
|
6577
7114
|
typeFeatureTokens,
|
|
6578
7115
|
typicality,
|
|
7116
|
+
unprovenClaims,
|
|
6579
7117
|
validateDirection,
|
|
6580
7118
|
visibleText,
|
|
6581
7119
|
visualMeasures,
|