@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.cjs
CHANGED
|
@@ -43,6 +43,8 @@ __export(index_exports, {
|
|
|
43
43
|
ESTATE_VERSION: () => ESTATE_VERSION,
|
|
44
44
|
EXIT_RATIO: () => EXIT_RATIO,
|
|
45
45
|
FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
|
|
46
|
+
HOME_FILE: () => HOME_FILE,
|
|
47
|
+
HOME_URL: () => HOME_URL,
|
|
46
48
|
HOUSE: () => HOUSE,
|
|
47
49
|
HOUSE_BUDGET: () => HOUSE_BUDGET,
|
|
48
50
|
HOUSE_LOCKED: () => HOUSE_LOCKED,
|
|
@@ -58,6 +60,7 @@ __export(index_exports, {
|
|
|
58
60
|
PILL_LIMIT: () => PILL_LIMIT,
|
|
59
61
|
PLAINER_WORDS: () => PLAINER_WORDS,
|
|
60
62
|
PROMO_BADGE: () => PROMO_BADGE,
|
|
63
|
+
PROOF_NAMES: () => PROOF_NAMES,
|
|
61
64
|
PROPOSED: () => PROPOSED,
|
|
62
65
|
PROVENANCE_SCAN_BYTES: () => PROVENANCE_SCAN_BYTES,
|
|
63
66
|
RAMP_STEPS: () => RAMP_STEPS,
|
|
@@ -75,11 +78,16 @@ __export(index_exports, {
|
|
|
75
78
|
SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
|
|
76
79
|
SOURCE_KINDS: () => SOURCE_KINDS,
|
|
77
80
|
SPACE_STEPS: () => SPACE_STEPS,
|
|
81
|
+
SPECIFICITY_COPY_TELLS: () => SPECIFICITY_COPY_TELLS,
|
|
82
|
+
SPECIFICITY_RENDERED_TELLS: () => SPECIFICITY_RENDERED_TELLS,
|
|
78
83
|
SPRING: () => SPRING,
|
|
84
|
+
STANDING_CLAIM: () => STANDING_CLAIM,
|
|
79
85
|
STOCK_AVATAR_HOSTS: () => STOCK_AVATAR_HOSTS,
|
|
86
|
+
STOCK_FIGURES: () => STOCK_FIGURES,
|
|
80
87
|
STOCK_PHOTO_HOSTS: () => STOCK_PHOTO_HOSTS,
|
|
81
88
|
STOCK_PHRASES: () => STOCK_PHRASES,
|
|
82
89
|
TELL_HEAVY: () => TELL_HEAVY,
|
|
90
|
+
TRADE_NOUNS: () => TRADE_NOUNS,
|
|
83
91
|
TYPE_STEPS: () => TYPE_STEPS,
|
|
84
92
|
TYPICAL_AT: () => TYPICAL_AT,
|
|
85
93
|
VAGUE_WORDS: () => VAGUE_WORDS,
|
|
@@ -90,6 +98,7 @@ __export(index_exports, {
|
|
|
90
98
|
applyHouseGate: () => applyHouseGate,
|
|
91
99
|
applySeverity: () => applySeverity,
|
|
92
100
|
auditSnapshot: () => auditSnapshot,
|
|
101
|
+
briefTerms: () => briefTerms,
|
|
93
102
|
catalogueTable: () => catalogueTable,
|
|
94
103
|
characterReport: () => characterReport,
|
|
95
104
|
checkCopy: () => checkCopy,
|
|
@@ -97,6 +106,7 @@ __export(index_exports, {
|
|
|
97
106
|
checkRestraint: () => checkRestraint,
|
|
98
107
|
choicesOf: () => choicesOf,
|
|
99
108
|
closestComponents: () => closestComponents,
|
|
109
|
+
compareCompetitor: () => compareCompetitor,
|
|
100
110
|
compareFacts: () => compareFacts,
|
|
101
111
|
compareToEstate: () => compareToEstate,
|
|
102
112
|
componentDistance: () => componentDistance,
|
|
@@ -104,6 +114,7 @@ __export(index_exports, {
|
|
|
104
114
|
componentTypicalities: () => componentTypicalities,
|
|
105
115
|
componentTypicality: () => componentTypicality,
|
|
106
116
|
componentsOf: () => componentsOf,
|
|
117
|
+
countWords: () => countWords2,
|
|
107
118
|
craftTokens: () => craftTokens,
|
|
108
119
|
createBaseline: () => createBaseline,
|
|
109
120
|
deltaEOk: () => deltaEOk,
|
|
@@ -128,6 +139,7 @@ __export(index_exports, {
|
|
|
128
139
|
fontClass: () => fontClass,
|
|
129
140
|
formatClaims: () => formatClaims,
|
|
130
141
|
formatComparison: () => formatComparison,
|
|
142
|
+
formatCompetitor: () => formatCompetitor,
|
|
131
143
|
formatHex: () => formatHex,
|
|
132
144
|
formatReport: () => formatReport,
|
|
133
145
|
globToRegExp: () => globToRegExp,
|
|
@@ -154,6 +166,7 @@ __export(index_exports, {
|
|
|
154
166
|
nullPrompt: () => nullPrompt,
|
|
155
167
|
oklchToHex: () => oklchToHex,
|
|
156
168
|
oklchToRgb: () => oklchToRgb,
|
|
169
|
+
pageCopy: () => pageCopy,
|
|
157
170
|
paletteFromPixels: () => paletteFromPixels,
|
|
158
171
|
parseBaseline: () => parseBaseline,
|
|
159
172
|
parseColour: () => parseColour,
|
|
@@ -170,11 +183,14 @@ __export(index_exports, {
|
|
|
170
183
|
scanSource: () => scanSource,
|
|
171
184
|
sectionRhythm: () => sectionRhythm,
|
|
172
185
|
semantic: () => semantic,
|
|
186
|
+
sentencesOf: () => sentencesOf,
|
|
173
187
|
severityExceptions: () => severityExceptions,
|
|
174
188
|
shapeName: () => shapeName,
|
|
175
189
|
sharedParts: () => sharedParts,
|
|
176
190
|
shouldAnimate: () => shouldAnimate,
|
|
177
191
|
siblingLine: () => siblingLine,
|
|
192
|
+
specificity: () => specificity,
|
|
193
|
+
specifics: () => specifics,
|
|
178
194
|
stockAvatarSource: () => stockAvatarSource,
|
|
179
195
|
stockSource: () => stockSource,
|
|
180
196
|
tailwindV3Preset: () => tailwindV3Preset,
|
|
@@ -185,6 +201,7 @@ __export(index_exports, {
|
|
|
185
201
|
toSarif: () => toSarif,
|
|
186
202
|
typeFeatureTokens: () => typeFeatureTokens,
|
|
187
203
|
typicality: () => typicality,
|
|
204
|
+
unprovenClaims: () => unprovenClaims,
|
|
188
205
|
validateDirection: () => validateDirection,
|
|
189
206
|
visibleText: () => visibleText,
|
|
190
207
|
visualMeasures: () => visualMeasures,
|
|
@@ -2445,14 +2462,14 @@ function contractionHits(ctx) {
|
|
|
2445
2462
|
for (const doc2 of documents(ctx)) {
|
|
2446
2463
|
if (doc2.words < 800) continue;
|
|
2447
2464
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2448
|
-
const
|
|
2449
|
-
const rate =
|
|
2465
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
2466
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
2450
2467
|
if (rate < 1) {
|
|
2451
2468
|
const first = sentences(doc2)[0];
|
|
2452
2469
|
hits.push({
|
|
2453
2470
|
path: doc2.path,
|
|
2454
2471
|
offset: first?.offset ?? 0,
|
|
2455
|
-
message: `${
|
|
2472
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2456
2473
|
});
|
|
2457
2474
|
}
|
|
2458
2475
|
}
|
|
@@ -2575,16 +2592,16 @@ function filler(words2, seed = 1, options = {}) {
|
|
|
2575
2592
|
return state % n;
|
|
2576
2593
|
};
|
|
2577
2594
|
const out = [];
|
|
2578
|
-
let
|
|
2595
|
+
let count4 = 0;
|
|
2579
2596
|
let i = 0;
|
|
2580
|
-
while (
|
|
2597
|
+
while (count4 < words2) {
|
|
2581
2598
|
const length = uniform ?? shape[i % shape.length];
|
|
2582
2599
|
const s = [];
|
|
2583
2600
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2584
2601
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2585
2602
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2586
2603
|
out.push(`${s.join(" ")}.`);
|
|
2587
|
-
|
|
2604
|
+
count4 += s.length;
|
|
2588
2605
|
i += 1;
|
|
2589
2606
|
}
|
|
2590
2607
|
const paragraphs = [];
|
|
@@ -3724,64 +3741,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
3724
3741
|
}
|
|
3725
3742
|
];
|
|
3726
3743
|
|
|
3727
|
-
// src/
|
|
3728
|
-
var
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3744
|
+
// src/snapshot/types.ts
|
|
3745
|
+
var SNAPSHOT_VERSION = 2;
|
|
3746
|
+
|
|
3747
|
+
// src/snapshot/fixture.ts
|
|
3748
|
+
function makeSnapshot(patch = {}) {
|
|
3749
|
+
const base = {
|
|
3750
|
+
version: SNAPSHOT_VERSION,
|
|
3751
|
+
url: "https://example.test/",
|
|
3752
|
+
capturedAt: "2026-09-23T00:00:00.000Z",
|
|
3753
|
+
viewport: { width: 1440, height: 900 },
|
|
3754
|
+
pageHeight: 4200,
|
|
3755
|
+
ground: "rgb(255, 255, 255)",
|
|
3756
|
+
colours: {
|
|
3757
|
+
backgrounds: [
|
|
3758
|
+
{ value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
|
|
3759
|
+
{ value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
|
|
3760
|
+
],
|
|
3761
|
+
text: [
|
|
3762
|
+
{ value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
|
|
3763
|
+
{ value: "rgb(31, 58, 46)", area: 0, chars: 300 }
|
|
3764
|
+
]
|
|
3765
|
+
},
|
|
3766
|
+
fonts: [
|
|
3767
|
+
{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
|
|
3768
|
+
{ family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
|
|
3769
|
+
],
|
|
3770
|
+
headings: [
|
|
3771
|
+
{ level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
|
|
3772
|
+
{ level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
|
|
3773
|
+
],
|
|
3774
|
+
controls: [
|
|
3775
|
+
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
3776
|
+
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
3777
|
+
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
3778
|
+
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
3779
|
+
],
|
|
3780
|
+
sections: [
|
|
3781
|
+
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3782
|
+
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3783
|
+
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
3784
|
+
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3785
|
+
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
3786
|
+
],
|
|
3787
|
+
effects: {
|
|
3788
|
+
glass: 0,
|
|
3789
|
+
gradientText: 0,
|
|
3790
|
+
gradients: [],
|
|
3791
|
+
glows: 0,
|
|
3792
|
+
gridBackgrounds: 0,
|
|
3793
|
+
marquees: 0,
|
|
3794
|
+
hairlineShadowCards: 0,
|
|
3795
|
+
eyebrowChip: false
|
|
3796
|
+
},
|
|
3797
|
+
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3798
|
+
metrics: {
|
|
3799
|
+
fontSizes: [14, 16, 20, 36, 64],
|
|
3800
|
+
fontWeights: [400, 500],
|
|
3801
|
+
radii: ["4px"],
|
|
3802
|
+
shadows: [],
|
|
3803
|
+
spacingOnScalePct: 96,
|
|
3804
|
+
bodyLineLengthCh: 68,
|
|
3805
|
+
bodyFontSizePx: 17
|
|
3806
|
+
}
|
|
3784
3807
|
};
|
|
3808
|
+
const out = { ...base };
|
|
3809
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3810
|
+
const current = base[key];
|
|
3811
|
+
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3812
|
+
}
|
|
3813
|
+
return out;
|
|
3785
3814
|
}
|
|
3786
3815
|
|
|
3787
3816
|
// src/character/parse.ts
|
|
@@ -4115,76 +4144,664 @@ function extractStrings(file) {
|
|
|
4115
4144
|
return [];
|
|
4116
4145
|
}
|
|
4117
4146
|
|
|
4118
|
-
// src/
|
|
4119
|
-
var
|
|
4147
|
+
// src/character/facts.ts
|
|
4148
|
+
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)?)";
|
|
4149
|
+
var PATTERNS = [
|
|
4150
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4151
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4152
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4153
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4154
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4155
|
+
["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")],
|
|
4156
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4157
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4158
|
+
];
|
|
4159
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4160
|
+
var normalise = (kind, value) => {
|
|
4161
|
+
const v = value.trim();
|
|
4162
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4163
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4164
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4165
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4166
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4167
|
+
};
|
|
4168
|
+
function visibleText(file) {
|
|
4169
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4170
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4171
|
+
}
|
|
4172
|
+
function protectedFacts(text) {
|
|
4173
|
+
const facts = [];
|
|
4174
|
+
let rest = text;
|
|
4175
|
+
const mask = (start, length) => {
|
|
4176
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4177
|
+
};
|
|
4178
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
4179
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
4180
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4181
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4182
|
+
mask(m.index ?? 0, m[0].length);
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
const lines = rest.split("\n");
|
|
4186
|
+
for (const line of lines) {
|
|
4187
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4188
|
+
for (const m of line.matchAll(NAME)) {
|
|
4189
|
+
const at = m.index ?? 0;
|
|
4190
|
+
const before = line.slice(0, at).trimEnd();
|
|
4191
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4192
|
+
const word = m[0];
|
|
4193
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4194
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4195
|
+
if (word === "I") continue;
|
|
4196
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
return facts;
|
|
4200
|
+
}
|
|
4201
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4202
|
+
function compareFacts(before, after) {
|
|
4203
|
+
const a = unique(protectedFacts(before));
|
|
4204
|
+
const b = unique(protectedFacts(after));
|
|
4205
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
4206
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
4207
|
+
return {
|
|
4208
|
+
before: a.length,
|
|
4209
|
+
after: b.length,
|
|
4210
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4211
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
4214
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4215
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4216
|
+
if (result.lost.length) {
|
|
4217
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4218
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4219
|
+
}
|
|
4220
|
+
if (result.added.length) {
|
|
4221
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4222
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4223
|
+
}
|
|
4224
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4225
|
+
return lines.join("\n");
|
|
4226
|
+
}
|
|
4120
4227
|
|
|
4121
|
-
// src/
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4228
|
+
// src/character/specificity.ts
|
|
4229
|
+
var TRADE_NOUNS = [
|
|
4230
|
+
// heating, plumbing, electrical
|
|
4231
|
+
"boiler",
|
|
4232
|
+
"radiator",
|
|
4233
|
+
"combi",
|
|
4234
|
+
"cylinder",
|
|
4235
|
+
"thermostat",
|
|
4236
|
+
"underfloor heating",
|
|
4237
|
+
"heat pump",
|
|
4238
|
+
"solar panel",
|
|
4239
|
+
"plumbing",
|
|
4240
|
+
"plumber",
|
|
4241
|
+
"leak",
|
|
4242
|
+
"toilet",
|
|
4243
|
+
"shower",
|
|
4244
|
+
"bathroom",
|
|
4245
|
+
"wet room",
|
|
4246
|
+
"drain",
|
|
4247
|
+
"fuse board",
|
|
4248
|
+
"fuseboard",
|
|
4249
|
+
"consumer unit",
|
|
4250
|
+
"rewire",
|
|
4251
|
+
"socket",
|
|
4252
|
+
"ev charger",
|
|
4253
|
+
"electrician",
|
|
4254
|
+
"gas engineer",
|
|
4255
|
+
"power flush",
|
|
4256
|
+
"landlord certificate",
|
|
4257
|
+
"eicr",
|
|
4258
|
+
// building and outside
|
|
4259
|
+
"extension",
|
|
4260
|
+
"loft conversion",
|
|
4261
|
+
"loft",
|
|
4262
|
+
"kitchen",
|
|
4263
|
+
"roof",
|
|
4264
|
+
"roofer",
|
|
4265
|
+
"gutter",
|
|
4266
|
+
"chimney",
|
|
4267
|
+
"driveway",
|
|
4268
|
+
"patio",
|
|
4269
|
+
"fence",
|
|
4270
|
+
"fencing",
|
|
4271
|
+
"decking",
|
|
4272
|
+
"window",
|
|
4273
|
+
"conservatory",
|
|
4274
|
+
"plastering",
|
|
4275
|
+
"plasterer",
|
|
4276
|
+
"render",
|
|
4277
|
+
"brickwork",
|
|
4278
|
+
"bricklayer",
|
|
4279
|
+
"tiling",
|
|
4280
|
+
"tiler",
|
|
4281
|
+
"flooring",
|
|
4282
|
+
"carpet",
|
|
4283
|
+
"joinery",
|
|
4284
|
+
"joiner",
|
|
4285
|
+
"carpenter",
|
|
4286
|
+
"staircase",
|
|
4287
|
+
"damp",
|
|
4288
|
+
"scaffolding",
|
|
4289
|
+
"garage conversion",
|
|
4290
|
+
"lawn",
|
|
4291
|
+
"hedge",
|
|
4292
|
+
"tree surgery",
|
|
4293
|
+
"stump",
|
|
4294
|
+
"landscaping",
|
|
4295
|
+
"gardener",
|
|
4296
|
+
"gate",
|
|
4297
|
+
"locksmith",
|
|
4298
|
+
"pest control",
|
|
4299
|
+
"wasp",
|
|
4300
|
+
"cleaning",
|
|
4301
|
+
"oven",
|
|
4302
|
+
"end of tenancy",
|
|
4303
|
+
// vehicles
|
|
4304
|
+
"mot",
|
|
4305
|
+
"tyre",
|
|
4306
|
+
"brake",
|
|
4307
|
+
"clutch",
|
|
4308
|
+
"cambelt",
|
|
4309
|
+
"exhaust",
|
|
4310
|
+
"gearbox",
|
|
4311
|
+
"valeting",
|
|
4312
|
+
// personal and health
|
|
4313
|
+
"haircut",
|
|
4314
|
+
"skin fade",
|
|
4315
|
+
"beard",
|
|
4316
|
+
"barber",
|
|
4317
|
+
"balayage",
|
|
4318
|
+
"nails",
|
|
4319
|
+
"lashes",
|
|
4320
|
+
"massage",
|
|
4321
|
+
"physio",
|
|
4322
|
+
"physiotherapy",
|
|
4323
|
+
"osteopath",
|
|
4324
|
+
"chiropractor",
|
|
4325
|
+
"dentist",
|
|
4326
|
+
"dental",
|
|
4327
|
+
"implant",
|
|
4328
|
+
"brace",
|
|
4329
|
+
"invisalign",
|
|
4330
|
+
"hygienist",
|
|
4331
|
+
"optician",
|
|
4332
|
+
"eye test",
|
|
4333
|
+
"vet",
|
|
4334
|
+
"grooming",
|
|
4335
|
+
"dog walking",
|
|
4336
|
+
"personal trainer",
|
|
4337
|
+
"yoga",
|
|
4338
|
+
"pilates",
|
|
4339
|
+
// professional
|
|
4340
|
+
"accountant",
|
|
4341
|
+
"tax return",
|
|
4342
|
+
"bookkeeping",
|
|
4343
|
+
"payroll",
|
|
4344
|
+
"vat",
|
|
4345
|
+
"self assessment",
|
|
4346
|
+
"solicitor",
|
|
4347
|
+
"conveyancing",
|
|
4348
|
+
"probate",
|
|
4349
|
+
"divorce",
|
|
4350
|
+
"lasting power of attorney",
|
|
4351
|
+
"mortgage",
|
|
4352
|
+
"surveyor",
|
|
4353
|
+
"architect",
|
|
4354
|
+
"planning permission",
|
|
4355
|
+
"building regulations",
|
|
4356
|
+
// web and digital, for the agency's own pages
|
|
4357
|
+
"website",
|
|
4358
|
+
"web design",
|
|
4359
|
+
"seo",
|
|
4360
|
+
"hosting",
|
|
4361
|
+
"google business profile",
|
|
4362
|
+
"online shop",
|
|
4363
|
+
"ecommerce",
|
|
4364
|
+
// food and hospitality
|
|
4365
|
+
"breakfast",
|
|
4366
|
+
"lunch",
|
|
4367
|
+
"dinner",
|
|
4368
|
+
"coffee",
|
|
4369
|
+
"bakery",
|
|
4370
|
+
"cake",
|
|
4371
|
+
"catering",
|
|
4372
|
+
"wedding",
|
|
4373
|
+
"venue"
|
|
4374
|
+
];
|
|
4375
|
+
var STOCK_FIGURES = [
|
|
4376
|
+
/\b24\s?\/\s?7\b/gi,
|
|
4377
|
+
/\b24[\s-]hours?\b/gi,
|
|
4378
|
+
/\b1[01]0\s?%/g,
|
|
4379
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
4380
|
+
/#\s?1\b/g,
|
|
4381
|
+
/\bno\.?\s?1\b/gi,
|
|
4382
|
+
/\b365\s+days\b/gi
|
|
4383
|
+
];
|
|
4384
|
+
var NOT_NAMES = new Set(
|
|
4385
|
+
"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(" ")
|
|
4386
|
+
);
|
|
4387
|
+
var BRIEF_STOP = new Set(
|
|
4388
|
+
"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(" ")
|
|
4389
|
+
);
|
|
4390
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
4391
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
4392
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4393
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
4394
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
4395
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
4396
|
+
function caseless(line) {
|
|
4397
|
+
const words2 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
4398
|
+
const letters = words2.filter((w) => w.length >= 2);
|
|
4399
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
4400
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
4401
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
4402
|
+
return null;
|
|
4403
|
+
}
|
|
4404
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
4405
|
+
function briefTerms(brief) {
|
|
4406
|
+
const terms = /* @__PURE__ */ new Set();
|
|
4407
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
4408
|
+
const lower = w.toLowerCase();
|
|
4409
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
|
|
4410
|
+
}
|
|
4411
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
4412
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
4413
|
+
return [...terms];
|
|
4414
|
+
}
|
|
4415
|
+
function specifics(text, brief) {
|
|
4416
|
+
const found = /* @__PURE__ */ new Map();
|
|
4417
|
+
const add = (kind, value) => {
|
|
4418
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
4419
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
4420
|
+
};
|
|
4421
|
+
for (const raw of text.split("\n")) {
|
|
4422
|
+
const line = maskStock(raw);
|
|
4423
|
+
if (!line.trim()) continue;
|
|
4424
|
+
const capitals = caseless(line);
|
|
4425
|
+
for (const f4 of protectedFacts(line)) {
|
|
4426
|
+
if (f4.kind === "name") {
|
|
4427
|
+
if (capitals === "shout") continue;
|
|
4428
|
+
if (capitals === "title") {
|
|
4429
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
4430
|
+
continue;
|
|
4431
|
+
}
|
|
4432
|
+
const words2 = f4.value.split(/\s+/);
|
|
4433
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
4434
|
+
const first = words2.findIndex(named);
|
|
4435
|
+
if (first === -1) continue;
|
|
4436
|
+
const last = words2.length - 1 - [...words2].reverse().findIndex(named);
|
|
4437
|
+
add("name", words2.slice(first, last + 1).join(" "));
|
|
4438
|
+
continue;
|
|
4439
|
+
}
|
|
4440
|
+
add(f4.kind, f4.value);
|
|
4441
|
+
}
|
|
4442
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
4443
|
+
const m = re.exec(line);
|
|
4444
|
+
if (m) add("trade", term);
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
if (brief) {
|
|
4448
|
+
for (const term of briefTerms(brief)) {
|
|
4449
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
4450
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
return [...found.values()];
|
|
4454
|
+
}
|
|
4455
|
+
function specificity(text, brief) {
|
|
4456
|
+
const list = specifics(text, brief);
|
|
4457
|
+
const words2 = countWords2(text);
|
|
4458
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
4459
|
+
const hard = list.length - trade;
|
|
4460
|
+
return {
|
|
4461
|
+
words: words2,
|
|
4462
|
+
specifics: list,
|
|
4463
|
+
hard,
|
|
4464
|
+
trade,
|
|
4465
|
+
perHundred: words2 ? Math.round(list.length / words2 * 1e3) / 10 : 0,
|
|
4466
|
+
generic: hard === 0 && trade < 2
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
|
|
4470
|
+
var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
|
|
4471
|
+
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;
|
|
4472
|
+
function sentencesOf(blocks) {
|
|
4473
|
+
const out = [];
|
|
4474
|
+
for (const block of blocks) {
|
|
4475
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
4476
|
+
const s = m[0].trim();
|
|
4477
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
return [...new Set(out)];
|
|
4481
|
+
}
|
|
4482
|
+
function pageCopy(snapshot) {
|
|
4483
|
+
const withText2 = snapshot.sections.filter((s) => s.text);
|
|
4484
|
+
if (!snapshot.firstScreenText && withText2.length === 0) return null;
|
|
4485
|
+
const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
|
|
4486
|
+
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 ?? []);
|
|
4487
|
+
const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
|
|
4488
|
+
return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
|
|
4489
|
+
}
|
|
4490
|
+
function side(page, other, brief) {
|
|
4491
|
+
const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
|
|
4492
|
+
const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
|
|
4493
|
+
const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
|
|
4494
|
+
return {
|
|
4495
|
+
url: page.url,
|
|
4496
|
+
sentences: sentences3.length,
|
|
4497
|
+
interchangeable,
|
|
4498
|
+
share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
|
|
4499
|
+
perHundred: specificity(sentences3.join("\n"), brief).perHundred
|
|
4500
|
+
};
|
|
4501
|
+
}
|
|
4502
|
+
function compareCompetitor(ours, competitor, brief) {
|
|
4503
|
+
return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
|
|
4504
|
+
}
|
|
4505
|
+
var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
|
|
4506
|
+
function formatCompetitor(result) {
|
|
4507
|
+
const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
|
|
4508
|
+
const lines = [
|
|
4509
|
+
`craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
|
|
4510
|
+
" Hero and service sentences holding nothing the other page does not also say:",
|
|
4511
|
+
row("ours", result.ours),
|
|
4512
|
+
row("competitor", result.competitor)
|
|
4513
|
+
];
|
|
4514
|
+
if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
|
|
4515
|
+
if (result.ours.interchangeable.length) {
|
|
4516
|
+
lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
|
|
4517
|
+
for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
|
|
4518
|
+
if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
|
|
4519
|
+
} else {
|
|
4520
|
+
lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
|
|
4521
|
+
}
|
|
4522
|
+
return lines.join("\n");
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
// src/character/tells/specificity.ts
|
|
4526
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4527
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
4528
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
4529
|
+
function literalText(inner) {
|
|
4530
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
4531
|
+
if (/[{}]/.test(t)) return null;
|
|
4532
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
4533
|
+
}
|
|
4534
|
+
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;
|
|
4535
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
4536
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
4537
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
4538
|
+
function headlines(ctx) {
|
|
4539
|
+
const out = [];
|
|
4540
|
+
for (const file of ctx.files) {
|
|
4541
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
4542
|
+
for (const m of file.text.matchAll(H1)) {
|
|
4543
|
+
const h1 = literalText(m[1]);
|
|
4544
|
+
if (!h1) continue;
|
|
4545
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
4546
|
+
const p = NEXT_P.exec(after);
|
|
4547
|
+
const sub = p ? literalText(p[1]) : null;
|
|
4548
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
return out;
|
|
4552
|
+
}
|
|
4553
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
4554
|
+
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;
|
|
4555
|
+
var PROOF_NAMES = [
|
|
4556
|
+
"Gas Safe",
|
|
4557
|
+
"NICEIC",
|
|
4558
|
+
"NAPIT",
|
|
4559
|
+
"ELECSA",
|
|
4560
|
+
"Stroma",
|
|
4561
|
+
"TrustMark",
|
|
4562
|
+
"Which? Trusted Trader",
|
|
4563
|
+
"Checkatrade",
|
|
4564
|
+
"TrustATrader",
|
|
4565
|
+
"Rated People",
|
|
4566
|
+
"MyBuilder",
|
|
4567
|
+
"Federation of Master Builders",
|
|
4568
|
+
"FMB",
|
|
4569
|
+
"Guild of Master Craftsmen",
|
|
4570
|
+
"OFTEC",
|
|
4571
|
+
"HETAS",
|
|
4572
|
+
"CIPHE",
|
|
4573
|
+
"APHC",
|
|
4574
|
+
"CHAS",
|
|
4575
|
+
"SafeContractor",
|
|
4576
|
+
"Constructionline",
|
|
4577
|
+
"FENSA",
|
|
4578
|
+
"CERTASS",
|
|
4579
|
+
"MCS",
|
|
4580
|
+
"RECC",
|
|
4581
|
+
"BPEC",
|
|
4582
|
+
"City & Guilds",
|
|
4583
|
+
"NVQ",
|
|
4584
|
+
"CSCS",
|
|
4585
|
+
"Worcester Bosch",
|
|
4586
|
+
"Vaillant",
|
|
4587
|
+
"Viessmann",
|
|
4588
|
+
"BAFE",
|
|
4589
|
+
"NSI",
|
|
4590
|
+
"SSAIB",
|
|
4591
|
+
"BPCA",
|
|
4592
|
+
"RICS",
|
|
4593
|
+
"CIOB",
|
|
4594
|
+
"RIBA",
|
|
4595
|
+
"ICAEW",
|
|
4596
|
+
"ACCA",
|
|
4597
|
+
"CIMA",
|
|
4598
|
+
"SRA",
|
|
4599
|
+
"Law Society",
|
|
4600
|
+
"GDC",
|
|
4601
|
+
"GMC",
|
|
4602
|
+
"HCPC",
|
|
4603
|
+
"CQC",
|
|
4604
|
+
"Ofsted",
|
|
4605
|
+
"ISO 9001",
|
|
4606
|
+
"ISO 27001",
|
|
4607
|
+
"Cyber Essentials",
|
|
4608
|
+
"Companies House",
|
|
4609
|
+
"FCA",
|
|
4610
|
+
"Trustpilot",
|
|
4611
|
+
"Feefo",
|
|
4612
|
+
"Reviews.io",
|
|
4613
|
+
"Yell",
|
|
4614
|
+
"Houzz"
|
|
4615
|
+
];
|
|
4616
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4617
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
4618
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
4619
|
+
var PROOF_PATTERNS = [
|
|
4620
|
+
PROOF_NAME,
|
|
4621
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
4622
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
4623
|
+
/\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,
|
|
4624
|
+
// A registration or licence number.
|
|
4625
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
4626
|
+
];
|
|
4627
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
4628
|
+
function sectionHasProof(s, section, index) {
|
|
4629
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
4630
|
+
const text = section.text ?? [];
|
|
4631
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
4632
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
4633
|
+
}
|
|
4634
|
+
function unprovenClaims(s) {
|
|
4635
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
4636
|
+
const out = [];
|
|
4637
|
+
s.sections.forEach((section, i) => {
|
|
4638
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
4639
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
4640
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
4641
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
4642
|
+
}
|
|
4643
|
+
});
|
|
4644
|
+
return out;
|
|
4645
|
+
}
|
|
4646
|
+
var withText = (texts, patch = () => ({})) => {
|
|
4647
|
+
const base = makeSnapshot();
|
|
4648
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
4649
|
+
};
|
|
4650
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
4651
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
4652
|
+
{
|
|
4653
|
+
id: "generic-hero-claim",
|
|
4654
|
+
name: "Headline that fits any business",
|
|
4655
|
+
generation: 1,
|
|
4656
|
+
severity: "warn",
|
|
4657
|
+
surface: "copy",
|
|
4658
|
+
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.",
|
|
4659
|
+
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".',
|
|
4660
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
4661
|
+
fixtures: {
|
|
4662
|
+
flag: [
|
|
4663
|
+
{ 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>' },
|
|
4664
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
4665
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
4134
4666
|
],
|
|
4135
|
-
|
|
4136
|
-
{
|
|
4137
|
-
|
|
4667
|
+
pass: [
|
|
4668
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
4669
|
+
// A stock headline rescued by the line under it.
|
|
4670
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
4671
|
+
// Two trade nouns say what the business does.
|
|
4672
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
4673
|
+
// The words come from data: nothing to read.
|
|
4674
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
4675
|
+
// Another page's headline is a label, not the site's claim.
|
|
4676
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
4138
4677
|
]
|
|
4139
4678
|
},
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
gradientText: 0,
|
|
4164
|
-
gradients: [],
|
|
4165
|
-
glows: 0,
|
|
4166
|
-
gridBackgrounds: 0,
|
|
4167
|
-
marquees: 0,
|
|
4168
|
-
hairlineShadowCards: 0,
|
|
4169
|
-
eyebrowChip: false
|
|
4170
|
-
},
|
|
4171
|
-
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
4172
|
-
metrics: {
|
|
4173
|
-
fontSizes: [14, 16, 20, 36, 64],
|
|
4174
|
-
fontWeights: [400, 500],
|
|
4175
|
-
radii: ["4px"],
|
|
4176
|
-
shadows: [],
|
|
4177
|
-
spacingOnScalePct: 96,
|
|
4178
|
-
bodyLineLengthCh: 68,
|
|
4179
|
-
bodyFontSizePx: 17
|
|
4679
|
+
rendered: {
|
|
4680
|
+
detect: (s) => {
|
|
4681
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
4682
|
+
const text = s.firstScreenText.join("\n");
|
|
4683
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
4684
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
4685
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
4686
|
+
},
|
|
4687
|
+
fixtures: {
|
|
4688
|
+
flag: [
|
|
4689
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
4690
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
4691
|
+
],
|
|
4692
|
+
pass: [
|
|
4693
|
+
makeSnapshot(),
|
|
4694
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
4695
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
4696
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
4697
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
4698
|
+
// Nor is any page but the home page.
|
|
4699
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
4700
|
+
]
|
|
4701
|
+
}
|
|
4180
4702
|
}
|
|
4181
|
-
};
|
|
4182
|
-
const out = { ...base };
|
|
4183
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
4184
|
-
const current = base[key];
|
|
4185
|
-
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
4186
4703
|
}
|
|
4187
|
-
|
|
4704
|
+
];
|
|
4705
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
4706
|
+
{
|
|
4707
|
+
id: "unproven-claim",
|
|
4708
|
+
name: "Claim with no proof next to it",
|
|
4709
|
+
generation: 1,
|
|
4710
|
+
severity: "warn",
|
|
4711
|
+
surface: "rendered",
|
|
4712
|
+
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.`,
|
|
4713
|
+
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.",
|
|
4714
|
+
rendered: {
|
|
4715
|
+
detect: (s) => {
|
|
4716
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
4717
|
+
const found = unprovenClaims(s);
|
|
4718
|
+
if (found.length === 0) return [];
|
|
4719
|
+
const first = found[0];
|
|
4720
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
4721
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
4722
|
+
},
|
|
4723
|
+
fixtures: {
|
|
4724
|
+
flag: [
|
|
4725
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4726
|
+
// Proof two sections away is not next to the claim.
|
|
4727
|
+
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" } : {})
|
|
4728
|
+
],
|
|
4729
|
+
pass: [
|
|
4730
|
+
makeSnapshot(),
|
|
4731
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
4732
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
4733
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
4734
|
+
{
|
|
4735
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4736
|
+
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 }]
|
|
4737
|
+
}
|
|
4738
|
+
]
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
];
|
|
4743
|
+
|
|
4744
|
+
// src/character/house.ts
|
|
4745
|
+
var HOUSE = {
|
|
4746
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
4747
|
+
emoji: { tier: "block", label: "emoji" },
|
|
4748
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
4749
|
+
"no-x-no-y": {
|
|
4750
|
+
tier: "block",
|
|
4751
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
4752
|
+
},
|
|
4753
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
4754
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
4755
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
4756
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
4757
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
4758
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
4759
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
4760
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
4761
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
4762
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
4763
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
4764
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
4765
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
4766
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
4767
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
4768
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
4769
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
4770
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
4771
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
4772
|
+
"false-range": { tier: "review", label: "false range" },
|
|
4773
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
4774
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
4775
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
4776
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
4777
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
4778
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
4779
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
4780
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
4781
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
4782
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
4783
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
4784
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
4785
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
4786
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
4787
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
4788
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
4789
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
4790
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
4791
|
+
};
|
|
4792
|
+
function houseRule(id, name = id) {
|
|
4793
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
4794
|
+
}
|
|
4795
|
+
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";
|
|
4796
|
+
function holdHouseBlocks(exceptions, severity) {
|
|
4797
|
+
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
4798
|
+
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
4799
|
+
return {
|
|
4800
|
+
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
4801
|
+
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
4802
|
+
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
4803
|
+
refusedSeverity
|
|
4804
|
+
};
|
|
4188
4805
|
}
|
|
4189
4806
|
|
|
4190
4807
|
// src/snapshot/fonts.ts
|
|
@@ -4304,7 +4921,7 @@ function blueToPurple(s) {
|
|
|
4304
4921
|
}
|
|
4305
4922
|
return hits;
|
|
4306
4923
|
}
|
|
4307
|
-
var
|
|
4924
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4308
4925
|
var RENDERED_PATHS = {
|
|
4309
4926
|
"reflex-font": {
|
|
4310
4927
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -4342,11 +4959,11 @@ var RENDERED_PATHS = {
|
|
|
4342
4959
|
}
|
|
4343
4960
|
},
|
|
4344
4961
|
"gradient-text": {
|
|
4345
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
4962
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
4346
4963
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
4347
4964
|
},
|
|
4348
4965
|
"glass-panel": {
|
|
4349
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
4966
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
4350
4967
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
4351
4968
|
},
|
|
4352
4969
|
"cream-palette": {
|
|
@@ -4424,7 +5041,7 @@ var RENDERED_PATHS = {
|
|
|
4424
5041
|
detect: (s) => {
|
|
4425
5042
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
4426
5043
|
const n = s.effects.glows + radial;
|
|
4427
|
-
return n > 0 ? [hit(s, `${
|
|
5044
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
4428
5045
|
},
|
|
4429
5046
|
fixtures: {
|
|
4430
5047
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -4432,11 +5049,11 @@ var RENDERED_PATHS = {
|
|
|
4432
5049
|
}
|
|
4433
5050
|
},
|
|
4434
5051
|
"grid-background": {
|
|
4435
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
5052
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
4436
5053
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
4437
5054
|
},
|
|
4438
5055
|
marquee: {
|
|
4439
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
5056
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
4440
5057
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
4441
5058
|
},
|
|
4442
5059
|
"thin-border-wide-shadow": {
|
|
@@ -4667,8 +5284,8 @@ function sharedChoices(target2, runs) {
|
|
|
4667
5284
|
const out = [];
|
|
4668
5285
|
for (const [part, values] of Object.entries(mine)) {
|
|
4669
5286
|
for (const value of values) {
|
|
4670
|
-
const
|
|
4671
|
-
if (
|
|
5287
|
+
const count4 = all.filter((c) => c[part].includes(value)).length;
|
|
5288
|
+
if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
|
|
4672
5289
|
}
|
|
4673
5290
|
}
|
|
4674
5291
|
return out.sort((a, b) => b.runs - a.runs);
|
|
@@ -5142,7 +5759,7 @@ var picture = (patch = {}) => ({
|
|
|
5142
5759
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
5143
5760
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
5144
5761
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
5145
|
-
var
|
|
5762
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
5146
5763
|
var IMAGERY_TELLS = [
|
|
5147
5764
|
{
|
|
5148
5765
|
id: "stock-photo",
|
|
@@ -5157,7 +5774,7 @@ var IMAGERY_TELLS = [
|
|
|
5157
5774
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
5158
5775
|
if (found.length === 0) return [];
|
|
5159
5776
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5160
|
-
return [hit3(s, `${
|
|
5777
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
5161
5778
|
},
|
|
5162
5779
|
fixtures: {
|
|
5163
5780
|
flag: [
|
|
@@ -5182,7 +5799,7 @@ var IMAGERY_TELLS = [
|
|
|
5182
5799
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
5183
5800
|
if (found.length === 0) return [];
|
|
5184
5801
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5185
|
-
return [hit3(s, `${
|
|
5802
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
5186
5803
|
},
|
|
5187
5804
|
fixtures: {
|
|
5188
5805
|
flag: [
|
|
@@ -5207,7 +5824,7 @@ var IMAGERY_TELLS = [
|
|
|
5207
5824
|
if (found.length === 0) return [];
|
|
5208
5825
|
const first = found[0].provenance;
|
|
5209
5826
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
5210
|
-
return [hit3(s, `${
|
|
5827
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
5211
5828
|
},
|
|
5212
5829
|
fixtures: {
|
|
5213
5830
|
flag: [
|
|
@@ -5232,7 +5849,7 @@ var IMAGERY_TELLS = [
|
|
|
5232
5849
|
if (!s.images || s.sections.length < 4) return [];
|
|
5233
5850
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
5234
5851
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
5235
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
5852
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
5236
5853
|
},
|
|
5237
5854
|
fixtures: {
|
|
5238
5855
|
flag: [
|
|
@@ -5247,11 +5864,13 @@ var IMAGERY_TELLS = [
|
|
|
5247
5864
|
];
|
|
5248
5865
|
|
|
5249
5866
|
// src/character/check.ts
|
|
5250
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
5867
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
5251
5868
|
var CATALOGUE = [
|
|
5252
5869
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
5870
|
+
...SPECIFICITY_COPY_TELLS,
|
|
5253
5871
|
...RENDERED_TELLS,
|
|
5254
|
-
...IMAGERY_TELLS
|
|
5872
|
+
...IMAGERY_TELLS,
|
|
5873
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
5255
5874
|
];
|
|
5256
5875
|
function tellById(id) {
|
|
5257
5876
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -5328,7 +5947,7 @@ function report(tells, files, run, options) {
|
|
|
5328
5947
|
catalogueVersion: CATALOGUE_VERSION,
|
|
5329
5948
|
findings,
|
|
5330
5949
|
rejectedExceptions: rejected,
|
|
5331
|
-
excepted: [...excepted].map(([e,
|
|
5950
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
5332
5951
|
suppressed,
|
|
5333
5952
|
summary: {
|
|
5334
5953
|
files: files.length,
|
|
@@ -5369,7 +5988,7 @@ function runTell(tell, files) {
|
|
|
5369
5988
|
}
|
|
5370
5989
|
function catalogueTable() {
|
|
5371
5990
|
const rows = CATALOGUE.map(
|
|
5372
|
-
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5991
|
+
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5373
5992
|
);
|
|
5374
5993
|
return [
|
|
5375
5994
|
`Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
|
|
@@ -5434,87 +6053,6 @@ function formatReport(report2, title) {
|
|
|
5434
6053
|
return lines.join("\n").replace(/^\n/, "");
|
|
5435
6054
|
}
|
|
5436
6055
|
|
|
5437
|
-
// src/character/facts.ts
|
|
5438
|
-
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)?)";
|
|
5439
|
-
var PATTERNS = [
|
|
5440
|
-
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
5441
|
-
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
5442
|
-
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
5443
|
-
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
5444
|
-
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
5445
|
-
["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")],
|
|
5446
|
-
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
5447
|
-
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
5448
|
-
];
|
|
5449
|
-
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
5450
|
-
var normalise = (kind, value) => {
|
|
5451
|
-
const v = value.trim();
|
|
5452
|
-
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
5453
|
-
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
5454
|
-
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
5455
|
-
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
5456
|
-
return v.replace(/\s+/g, " ").toLowerCase();
|
|
5457
|
-
};
|
|
5458
|
-
function visibleText(file) {
|
|
5459
|
-
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
5460
|
-
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
5461
|
-
}
|
|
5462
|
-
function protectedFacts(text) {
|
|
5463
|
-
const facts = [];
|
|
5464
|
-
let rest = text;
|
|
5465
|
-
const mask = (start, length) => {
|
|
5466
|
-
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
5467
|
-
};
|
|
5468
|
-
for (const [kind, pattern] of PATTERNS) {
|
|
5469
|
-
for (const m of [...rest.matchAll(pattern)]) {
|
|
5470
|
-
const value = m[0].replace(/[.,;:]+$/, "");
|
|
5471
|
-
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
5472
|
-
mask(m.index ?? 0, m[0].length);
|
|
5473
|
-
}
|
|
5474
|
-
}
|
|
5475
|
-
const lines = rest.split("\n");
|
|
5476
|
-
for (const line of lines) {
|
|
5477
|
-
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
5478
|
-
for (const m of line.matchAll(NAME)) {
|
|
5479
|
-
const at = m.index ?? 0;
|
|
5480
|
-
const before = line.slice(0, at).trimEnd();
|
|
5481
|
-
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
5482
|
-
const word = m[0];
|
|
5483
|
-
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
5484
|
-
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
5485
|
-
if (word === "I") continue;
|
|
5486
|
-
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
5487
|
-
}
|
|
5488
|
-
}
|
|
5489
|
-
return facts;
|
|
5490
|
-
}
|
|
5491
|
-
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
5492
|
-
function compareFacts(before, after) {
|
|
5493
|
-
const a = unique(protectedFacts(before));
|
|
5494
|
-
const b = unique(protectedFacts(after));
|
|
5495
|
-
const inA = new Set(a.map((f4) => f4.key));
|
|
5496
|
-
const inB = new Set(b.map((f4) => f4.key));
|
|
5497
|
-
return {
|
|
5498
|
-
before: a.length,
|
|
5499
|
-
after: b.length,
|
|
5500
|
-
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
5501
|
-
added: b.filter((f4) => !inA.has(f4.key))
|
|
5502
|
-
};
|
|
5503
|
-
}
|
|
5504
|
-
function formatComparison(result, beforePath, afterPath) {
|
|
5505
|
-
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
5506
|
-
if (result.lost.length) {
|
|
5507
|
-
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
5508
|
-
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5509
|
-
}
|
|
5510
|
-
if (result.added.length) {
|
|
5511
|
-
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
5512
|
-
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5513
|
-
}
|
|
5514
|
-
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
5515
|
-
return lines.join("\n");
|
|
5516
|
-
}
|
|
5517
|
-
|
|
5518
6056
|
// src/character/claims.ts
|
|
5519
6057
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
|
|
5520
6058
|
var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
|
|
@@ -6630,6 +7168,8 @@ function retrofitPlan(report2, direction) {
|
|
|
6630
7168
|
ESTATE_VERSION,
|
|
6631
7169
|
EXIT_RATIO,
|
|
6632
7170
|
FINGERPRINT_VERSION,
|
|
7171
|
+
HOME_FILE,
|
|
7172
|
+
HOME_URL,
|
|
6633
7173
|
HOUSE,
|
|
6634
7174
|
HOUSE_BUDGET,
|
|
6635
7175
|
HOUSE_LOCKED,
|
|
@@ -6645,6 +7185,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6645
7185
|
PILL_LIMIT,
|
|
6646
7186
|
PLAINER_WORDS,
|
|
6647
7187
|
PROMO_BADGE,
|
|
7188
|
+
PROOF_NAMES,
|
|
6648
7189
|
PROPOSED,
|
|
6649
7190
|
PROVENANCE_SCAN_BYTES,
|
|
6650
7191
|
RAMP_STEPS,
|
|
@@ -6662,11 +7203,16 @@ function retrofitPlan(report2, direction) {
|
|
|
6662
7203
|
SNAPSHOT_VERSION,
|
|
6663
7204
|
SOURCE_KINDS,
|
|
6664
7205
|
SPACE_STEPS,
|
|
7206
|
+
SPECIFICITY_COPY_TELLS,
|
|
7207
|
+
SPECIFICITY_RENDERED_TELLS,
|
|
6665
7208
|
SPRING,
|
|
7209
|
+
STANDING_CLAIM,
|
|
6666
7210
|
STOCK_AVATAR_HOSTS,
|
|
7211
|
+
STOCK_FIGURES,
|
|
6667
7212
|
STOCK_PHOTO_HOSTS,
|
|
6668
7213
|
STOCK_PHRASES,
|
|
6669
7214
|
TELL_HEAVY,
|
|
7215
|
+
TRADE_NOUNS,
|
|
6670
7216
|
TYPE_STEPS,
|
|
6671
7217
|
TYPICAL_AT,
|
|
6672
7218
|
VAGUE_WORDS,
|
|
@@ -6677,6 +7223,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6677
7223
|
applyHouseGate,
|
|
6678
7224
|
applySeverity,
|
|
6679
7225
|
auditSnapshot,
|
|
7226
|
+
briefTerms,
|
|
6680
7227
|
catalogueTable,
|
|
6681
7228
|
characterReport,
|
|
6682
7229
|
checkCopy,
|
|
@@ -6684,6 +7231,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6684
7231
|
checkRestraint,
|
|
6685
7232
|
choicesOf,
|
|
6686
7233
|
closestComponents,
|
|
7234
|
+
compareCompetitor,
|
|
6687
7235
|
compareFacts,
|
|
6688
7236
|
compareToEstate,
|
|
6689
7237
|
componentDistance,
|
|
@@ -6691,6 +7239,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6691
7239
|
componentTypicalities,
|
|
6692
7240
|
componentTypicality,
|
|
6693
7241
|
componentsOf,
|
|
7242
|
+
countWords,
|
|
6694
7243
|
craftTokens,
|
|
6695
7244
|
createBaseline,
|
|
6696
7245
|
deltaEOk,
|
|
@@ -6715,6 +7264,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6715
7264
|
fontClass,
|
|
6716
7265
|
formatClaims,
|
|
6717
7266
|
formatComparison,
|
|
7267
|
+
formatCompetitor,
|
|
6718
7268
|
formatHex,
|
|
6719
7269
|
formatReport,
|
|
6720
7270
|
globToRegExp,
|
|
@@ -6741,6 +7291,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6741
7291
|
nullPrompt,
|
|
6742
7292
|
oklchToHex,
|
|
6743
7293
|
oklchToRgb,
|
|
7294
|
+
pageCopy,
|
|
6744
7295
|
paletteFromPixels,
|
|
6745
7296
|
parseBaseline,
|
|
6746
7297
|
parseColour,
|
|
@@ -6757,11 +7308,14 @@ function retrofitPlan(report2, direction) {
|
|
|
6757
7308
|
scanSource,
|
|
6758
7309
|
sectionRhythm,
|
|
6759
7310
|
semantic,
|
|
7311
|
+
sentencesOf,
|
|
6760
7312
|
severityExceptions,
|
|
6761
7313
|
shapeName,
|
|
6762
7314
|
sharedParts,
|
|
6763
7315
|
shouldAnimate,
|
|
6764
7316
|
siblingLine,
|
|
7317
|
+
specificity,
|
|
7318
|
+
specifics,
|
|
6765
7319
|
stockAvatarSource,
|
|
6766
7320
|
stockSource,
|
|
6767
7321
|
tailwindV3Preset,
|
|
@@ -6772,6 +7326,7 @@ function retrofitPlan(report2, direction) {
|
|
|
6772
7326
|
toSarif,
|
|
6773
7327
|
typeFeatureTokens,
|
|
6774
7328
|
typicality,
|
|
7329
|
+
unprovenClaims,
|
|
6775
7330
|
validateDirection,
|
|
6776
7331
|
visibleText,
|
|
6777
7332
|
visualMeasures,
|