@domandigital/craft 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/CHARACTER.md +272 -39
- package/COPY.md +5 -0
- package/README.md +19 -1
- package/art-direction.schema.json +428 -25
- package/dist/{audit-5TYDYRXV.js → audit-MTUC24XU.js} +3 -2
- package/dist/audit.cjs +674 -92
- package/dist/audit.d.cts +15 -1
- package/dist/audit.d.ts +15 -1
- package/dist/audit.js +674 -92
- package/dist/{chunk-IMB4V34S.js → chunk-2IQ5OXFR.js} +7 -7
- package/dist/{chunk-GMVXADJ6.js → chunk-LFNSB6W7.js} +711 -92
- package/dist/{chunk-3VTPI7BU.js → chunk-MUVFECFC.js} +74 -96
- package/dist/chunk-OQXTQQAU.js +27 -0
- package/dist/{chunk-ZLDA2SYB.js → chunk-OUQ25LY2.js} +47 -6
- package/dist/{chunk-K2BZN4GA.js → chunk-QU6Q2QZZ.js} +49 -14
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-ESCVTKPN.js → chunk-RXXRXV3V.js} +9 -26
- package/dist/{chunk-CZDOMIVS.js → chunk-WUACK2HX.js} +2 -1
- package/dist/chunk-YDURAOYR.js +711 -0
- package/dist/cli-22TSWAX6.js +21 -0
- package/dist/cli-5ACKICBO.js +22 -0
- package/dist/{cli-DRKAMFAH.js → cli-7OZCYOQY.js} +89 -22
- package/dist/{cli-J5ECRCD4.js → cli-FCKHYAVZ.js} +40 -16
- package/dist/{cli-ILH7OO5G.js → cli-FLG3ZCAF.js} +6 -4
- package/dist/cli.js +3 -2
- package/dist/generate.js +6 -0
- package/dist/index.cjs +1618 -328
- package/dist/index.d.cts +619 -58
- package/dist/index.d.ts +619 -58
- package/dist/index.js +1555 -328
- package/dist/migrate-VIFAYIBM.js +9 -0
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-ZZ5CKOJP.js +0 -295
- package/dist/cli-BKJMLD3H.js +0 -18
- package/dist/cli-N2IDXH7W.js +0 -20
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SNAPSHOT_VERSION
|
|
3
|
+
} from "./chunk-QYBAVU3P.js";
|
|
4
|
+
|
|
1
5
|
// src/character/parse.ts
|
|
2
6
|
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
3
7
|
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
@@ -329,9 +333,6 @@ function extractStrings(file) {
|
|
|
329
333
|
return [];
|
|
330
334
|
}
|
|
331
335
|
|
|
332
|
-
// src/snapshot/types.ts
|
|
333
|
-
var SNAPSHOT_VERSION = 2;
|
|
334
|
-
|
|
335
336
|
// src/color/oklch.ts
|
|
336
337
|
var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
337
338
|
function srgbToLinear(c) {
|
|
@@ -659,9 +660,14 @@ function faceDistance(a, b) {
|
|
|
659
660
|
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
660
661
|
return a.class === b.class ? 0.5 : 1;
|
|
661
662
|
}
|
|
663
|
+
var ACCENT_SCALE = 0.3;
|
|
664
|
+
var GROUND_SCALE = 0.15;
|
|
665
|
+
var SHARED_PART = 0.2;
|
|
666
|
+
var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
|
|
667
|
+
var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
|
|
662
668
|
function fingerprintDistance(a, b) {
|
|
663
|
-
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) /
|
|
664
|
-
const ground = clamp(deltaEOk(a.ground, b.ground) /
|
|
669
|
+
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / ACCENT_SCALE) : a.accent || b.accent ? 1 : 0;
|
|
670
|
+
const ground = clamp(deltaEOk(a.ground, b.ground) / GROUND_SCALE);
|
|
665
671
|
const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
|
|
666
672
|
const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
|
|
667
673
|
const motion = clamp(Math.abs(a.motion - b.motion));
|
|
@@ -1659,8 +1665,8 @@ function sharedChoices(target, runs) {
|
|
|
1659
1665
|
const out = [];
|
|
1660
1666
|
for (const [part, values] of Object.entries(mine)) {
|
|
1661
1667
|
for (const value of values) {
|
|
1662
|
-
const
|
|
1663
|
-
if (
|
|
1668
|
+
const count4 = all.filter((c) => c[part].includes(value)).length;
|
|
1669
|
+
if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
|
|
1664
1670
|
}
|
|
1665
1671
|
}
|
|
1666
1672
|
return out.sort((a, b) => b.runs - a.runs);
|
|
@@ -1853,7 +1859,7 @@ function closestComponents(a, b, role) {
|
|
|
1853
1859
|
}
|
|
1854
1860
|
function estateComponentPairs(register, role) {
|
|
1855
1861
|
const out = [];
|
|
1856
|
-
const s = register.sites;
|
|
1862
|
+
const s = register.sites.filter((site) => site.fingerprint);
|
|
1857
1863
|
for (let i = 0; i < s.length; i += 1) {
|
|
1858
1864
|
for (let j = i + 1; j < s.length; j += 1) {
|
|
1859
1865
|
const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
|
|
@@ -2898,14 +2904,14 @@ function contractionHits(ctx) {
|
|
|
2898
2904
|
for (const doc2 of documents(ctx)) {
|
|
2899
2905
|
if (doc2.words < 800) continue;
|
|
2900
2906
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2901
|
-
const
|
|
2902
|
-
const rate =
|
|
2907
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
2908
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
2903
2909
|
if (rate < 1) {
|
|
2904
2910
|
const first = sentences(doc2)[0];
|
|
2905
2911
|
hits.push({
|
|
2906
2912
|
path: doc2.path,
|
|
2907
2913
|
offset: first?.offset ?? 0,
|
|
2908
|
-
message: `${
|
|
2914
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2909
2915
|
});
|
|
2910
2916
|
}
|
|
2911
2917
|
}
|
|
@@ -3028,16 +3034,16 @@ function filler(words, seed = 1, options = {}) {
|
|
|
3028
3034
|
return state % n;
|
|
3029
3035
|
};
|
|
3030
3036
|
const out = [];
|
|
3031
|
-
let
|
|
3037
|
+
let count4 = 0;
|
|
3032
3038
|
let i = 0;
|
|
3033
|
-
while (
|
|
3039
|
+
while (count4 < words) {
|
|
3034
3040
|
const length = uniform ?? shape[i % shape.length];
|
|
3035
3041
|
const s = [];
|
|
3036
3042
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
3037
3043
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
3038
3044
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
3039
3045
|
out.push(`${s.join(" ")}.`);
|
|
3040
|
-
|
|
3046
|
+
count4 += s.length;
|
|
3041
3047
|
i += 1;
|
|
3042
3048
|
}
|
|
3043
3049
|
const paragraphs = [];
|
|
@@ -3195,66 +3201,6 @@ ${filler(520, 11)}`)]
|
|
|
3195
3201
|
}
|
|
3196
3202
|
];
|
|
3197
3203
|
|
|
3198
|
-
// src/character/house.ts
|
|
3199
|
-
var HOUSE = {
|
|
3200
|
-
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
3201
|
-
emoji: { tier: "block", label: "emoji" },
|
|
3202
|
-
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
3203
|
-
"no-x-no-y": {
|
|
3204
|
-
tier: "block",
|
|
3205
|
-
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
3206
|
-
},
|
|
3207
|
-
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
3208
|
-
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
3209
|
-
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
3210
|
-
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
3211
|
-
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
3212
|
-
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
3213
|
-
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
3214
|
-
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
3215
|
-
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
3216
|
-
"review-phrase": { tier: "review", label: "review tier" },
|
|
3217
|
-
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3218
|
-
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3219
|
-
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
3220
|
-
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
3221
|
-
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
3222
|
-
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
3223
|
-
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
3224
|
-
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
3225
|
-
"closing-summary": { tier: "review", label: "closing summary" },
|
|
3226
|
-
"false-range": { tier: "review", label: "false range" },
|
|
3227
|
-
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
3228
|
-
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
3229
|
-
// a proposal can honestly say "the attached research". Review until measured there.
|
|
3230
|
-
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
3231
|
-
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
3232
|
-
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
3233
|
-
"phrase-density": { tier: "review", label: "density" },
|
|
3234
|
-
"aphorism-density": { tier: "review", label: "density" },
|
|
3235
|
-
"contraction-scarcity": { tier: "review", label: "density" },
|
|
3236
|
-
"sentence-rhythm": { tier: "review", label: "density" },
|
|
3237
|
-
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
3238
|
-
"heading-shape": { tier: "review", label: "headings" },
|
|
3239
|
-
"heading-echo": { tier: "review", label: "headings" },
|
|
3240
|
-
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
3241
|
-
"vague-word": { tier: "explicit", label: "vague" }
|
|
3242
|
-
};
|
|
3243
|
-
function houseRule(id, name = id) {
|
|
3244
|
-
return HOUSE[id] ?? { tier: "review", label: name };
|
|
3245
|
-
}
|
|
3246
|
-
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";
|
|
3247
|
-
function holdHouseBlocks(exceptions, severity) {
|
|
3248
|
-
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
3249
|
-
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
3250
|
-
return {
|
|
3251
|
-
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
3252
|
-
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
3253
|
-
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
3254
|
-
refusedSeverity
|
|
3255
|
-
};
|
|
3256
|
-
}
|
|
3257
|
-
|
|
3258
3204
|
// src/snapshot/fixture.ts
|
|
3259
3205
|
function makeSnapshot(patch = {}) {
|
|
3260
3206
|
const base = {
|
|
@@ -3324,6 +3270,666 @@ function makeSnapshot(patch = {}) {
|
|
|
3324
3270
|
return out;
|
|
3325
3271
|
}
|
|
3326
3272
|
|
|
3273
|
+
// src/character/facts.ts
|
|
3274
|
+
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)?)";
|
|
3275
|
+
var PATTERNS = [
|
|
3276
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
3277
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
3278
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
3279
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
3280
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
3281
|
+
["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")],
|
|
3282
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
3283
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
3284
|
+
];
|
|
3285
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
3286
|
+
var normalise = (kind, value) => {
|
|
3287
|
+
const v = value.trim();
|
|
3288
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
3289
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
3290
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
3291
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
3292
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
3293
|
+
};
|
|
3294
|
+
function visibleText(file) {
|
|
3295
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
3296
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
3297
|
+
}
|
|
3298
|
+
function protectedFacts(text) {
|
|
3299
|
+
const facts = [];
|
|
3300
|
+
let rest = text;
|
|
3301
|
+
const mask = (start, length) => {
|
|
3302
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
3303
|
+
};
|
|
3304
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
3305
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
3306
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
3307
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
3308
|
+
mask(m.index ?? 0, m[0].length);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
const lines = rest.split("\n");
|
|
3312
|
+
for (const line of lines) {
|
|
3313
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
3314
|
+
for (const m of line.matchAll(NAME)) {
|
|
3315
|
+
const at = m.index ?? 0;
|
|
3316
|
+
const before = line.slice(0, at).trimEnd();
|
|
3317
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
3318
|
+
const word = m[0];
|
|
3319
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
3320
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
3321
|
+
if (word === "I") continue;
|
|
3322
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
return facts;
|
|
3326
|
+
}
|
|
3327
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
3328
|
+
function compareFacts(before, after) {
|
|
3329
|
+
const a = unique(protectedFacts(before));
|
|
3330
|
+
const b = unique(protectedFacts(after));
|
|
3331
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
3332
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
3333
|
+
return {
|
|
3334
|
+
before: a.length,
|
|
3335
|
+
after: b.length,
|
|
3336
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
3337
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
3341
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
3342
|
+
if (result.lost.length) {
|
|
3343
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
3344
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
3345
|
+
}
|
|
3346
|
+
if (result.added.length) {
|
|
3347
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
3348
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
3349
|
+
}
|
|
3350
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
3351
|
+
return lines.join("\n");
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// src/character/specificity.ts
|
|
3355
|
+
var TRADE_NOUNS = [
|
|
3356
|
+
// heating, plumbing, electrical
|
|
3357
|
+
"boiler",
|
|
3358
|
+
"radiator",
|
|
3359
|
+
"combi",
|
|
3360
|
+
"cylinder",
|
|
3361
|
+
"thermostat",
|
|
3362
|
+
"underfloor heating",
|
|
3363
|
+
"heat pump",
|
|
3364
|
+
"solar panel",
|
|
3365
|
+
"plumbing",
|
|
3366
|
+
"plumber",
|
|
3367
|
+
"leak",
|
|
3368
|
+
"toilet",
|
|
3369
|
+
"shower",
|
|
3370
|
+
"bathroom",
|
|
3371
|
+
"wet room",
|
|
3372
|
+
"drain",
|
|
3373
|
+
"fuse board",
|
|
3374
|
+
"fuseboard",
|
|
3375
|
+
"consumer unit",
|
|
3376
|
+
"rewire",
|
|
3377
|
+
"socket",
|
|
3378
|
+
"ev charger",
|
|
3379
|
+
"electrician",
|
|
3380
|
+
"gas engineer",
|
|
3381
|
+
"power flush",
|
|
3382
|
+
"landlord certificate",
|
|
3383
|
+
"eicr",
|
|
3384
|
+
// building and outside
|
|
3385
|
+
"extension",
|
|
3386
|
+
"loft conversion",
|
|
3387
|
+
"loft",
|
|
3388
|
+
"kitchen",
|
|
3389
|
+
"roof",
|
|
3390
|
+
"roofer",
|
|
3391
|
+
"gutter",
|
|
3392
|
+
"chimney",
|
|
3393
|
+
"driveway",
|
|
3394
|
+
"patio",
|
|
3395
|
+
"fence",
|
|
3396
|
+
"fencing",
|
|
3397
|
+
"decking",
|
|
3398
|
+
"window",
|
|
3399
|
+
"conservatory",
|
|
3400
|
+
"plastering",
|
|
3401
|
+
"plasterer",
|
|
3402
|
+
"render",
|
|
3403
|
+
"brickwork",
|
|
3404
|
+
"bricklayer",
|
|
3405
|
+
"tiling",
|
|
3406
|
+
"tiler",
|
|
3407
|
+
"flooring",
|
|
3408
|
+
"carpet",
|
|
3409
|
+
"joinery",
|
|
3410
|
+
"joiner",
|
|
3411
|
+
"carpenter",
|
|
3412
|
+
"staircase",
|
|
3413
|
+
"damp",
|
|
3414
|
+
"scaffolding",
|
|
3415
|
+
"garage conversion",
|
|
3416
|
+
"lawn",
|
|
3417
|
+
"hedge",
|
|
3418
|
+
"tree surgery",
|
|
3419
|
+
"stump",
|
|
3420
|
+
"landscaping",
|
|
3421
|
+
"gardener",
|
|
3422
|
+
"gate",
|
|
3423
|
+
"locksmith",
|
|
3424
|
+
"pest control",
|
|
3425
|
+
"wasp",
|
|
3426
|
+
"cleaning",
|
|
3427
|
+
"oven",
|
|
3428
|
+
"end of tenancy",
|
|
3429
|
+
// vehicles
|
|
3430
|
+
"mot",
|
|
3431
|
+
"tyre",
|
|
3432
|
+
"brake",
|
|
3433
|
+
"clutch",
|
|
3434
|
+
"cambelt",
|
|
3435
|
+
"exhaust",
|
|
3436
|
+
"gearbox",
|
|
3437
|
+
"valeting",
|
|
3438
|
+
// personal and health
|
|
3439
|
+
"haircut",
|
|
3440
|
+
"skin fade",
|
|
3441
|
+
"beard",
|
|
3442
|
+
"barber",
|
|
3443
|
+
"balayage",
|
|
3444
|
+
"nails",
|
|
3445
|
+
"lashes",
|
|
3446
|
+
"massage",
|
|
3447
|
+
"physio",
|
|
3448
|
+
"physiotherapy",
|
|
3449
|
+
"osteopath",
|
|
3450
|
+
"chiropractor",
|
|
3451
|
+
"dentist",
|
|
3452
|
+
"dental",
|
|
3453
|
+
"implant",
|
|
3454
|
+
"brace",
|
|
3455
|
+
"invisalign",
|
|
3456
|
+
"hygienist",
|
|
3457
|
+
"optician",
|
|
3458
|
+
"eye test",
|
|
3459
|
+
"vet",
|
|
3460
|
+
"grooming",
|
|
3461
|
+
"dog walking",
|
|
3462
|
+
"personal trainer",
|
|
3463
|
+
"yoga",
|
|
3464
|
+
"pilates",
|
|
3465
|
+
// professional
|
|
3466
|
+
"accountant",
|
|
3467
|
+
"tax return",
|
|
3468
|
+
"bookkeeping",
|
|
3469
|
+
"payroll",
|
|
3470
|
+
"vat",
|
|
3471
|
+
"self assessment",
|
|
3472
|
+
"solicitor",
|
|
3473
|
+
"conveyancing",
|
|
3474
|
+
"probate",
|
|
3475
|
+
"divorce",
|
|
3476
|
+
"lasting power of attorney",
|
|
3477
|
+
"mortgage",
|
|
3478
|
+
"surveyor",
|
|
3479
|
+
"architect",
|
|
3480
|
+
"planning permission",
|
|
3481
|
+
"building regulations",
|
|
3482
|
+
// web and digital, for the agency's own pages
|
|
3483
|
+
"website",
|
|
3484
|
+
"web design",
|
|
3485
|
+
"seo",
|
|
3486
|
+
"hosting",
|
|
3487
|
+
"google business profile",
|
|
3488
|
+
"online shop",
|
|
3489
|
+
"ecommerce",
|
|
3490
|
+
// food and hospitality
|
|
3491
|
+
"breakfast",
|
|
3492
|
+
"lunch",
|
|
3493
|
+
"dinner",
|
|
3494
|
+
"coffee",
|
|
3495
|
+
"bakery",
|
|
3496
|
+
"cake",
|
|
3497
|
+
"catering",
|
|
3498
|
+
"wedding",
|
|
3499
|
+
"venue"
|
|
3500
|
+
];
|
|
3501
|
+
var STOCK_FIGURES = [
|
|
3502
|
+
/\b24\s?\/\s?7\b/gi,
|
|
3503
|
+
/\b24[\s-]hours?\b/gi,
|
|
3504
|
+
/\b1[01]0\s?%/g,
|
|
3505
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
3506
|
+
/#\s?1\b/g,
|
|
3507
|
+
/\bno\.?\s?1\b/gi,
|
|
3508
|
+
/\b365\s+days\b/gi
|
|
3509
|
+
];
|
|
3510
|
+
var NOT_NAMES = new Set(
|
|
3511
|
+
"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(" ")
|
|
3512
|
+
);
|
|
3513
|
+
var BRIEF_STOP = new Set(
|
|
3514
|
+
"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(" ")
|
|
3515
|
+
);
|
|
3516
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
3517
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
3518
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3519
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
3520
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
3521
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
3522
|
+
function caseless(line) {
|
|
3523
|
+
const words = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
3524
|
+
const letters = words.filter((w) => w.length >= 2);
|
|
3525
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
3526
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
3527
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
3528
|
+
return null;
|
|
3529
|
+
}
|
|
3530
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
3531
|
+
function briefTerms(brief) {
|
|
3532
|
+
const terms = /* @__PURE__ */ new Set();
|
|
3533
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
3534
|
+
const lower = w.toLowerCase();
|
|
3535
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower) && !NOT_NAMES.has(lower)) terms.add(lower);
|
|
3536
|
+
}
|
|
3537
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
3538
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
3539
|
+
return [...terms];
|
|
3540
|
+
}
|
|
3541
|
+
function specifics(text, brief) {
|
|
3542
|
+
const found = /* @__PURE__ */ new Map();
|
|
3543
|
+
const add = (kind, value) => {
|
|
3544
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
3545
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
3546
|
+
};
|
|
3547
|
+
for (const raw of text.split("\n")) {
|
|
3548
|
+
const line = maskStock(raw);
|
|
3549
|
+
if (!line.trim()) continue;
|
|
3550
|
+
const capitals = caseless(line);
|
|
3551
|
+
for (const f4 of protectedFacts(line)) {
|
|
3552
|
+
if (f4.kind === "name") {
|
|
3553
|
+
if (capitals === "shout") continue;
|
|
3554
|
+
if (capitals === "title") {
|
|
3555
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
3556
|
+
continue;
|
|
3557
|
+
}
|
|
3558
|
+
const words = f4.value.split(/\s+/);
|
|
3559
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
3560
|
+
const first = words.findIndex(named);
|
|
3561
|
+
if (first === -1) continue;
|
|
3562
|
+
const last = words.length - 1 - [...words].reverse().findIndex(named);
|
|
3563
|
+
add("name", words.slice(first, last + 1).join(" "));
|
|
3564
|
+
continue;
|
|
3565
|
+
}
|
|
3566
|
+
add(f4.kind, f4.value);
|
|
3567
|
+
}
|
|
3568
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
3569
|
+
const m = re.exec(line);
|
|
3570
|
+
if (m) add("trade", term);
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
if (brief) {
|
|
3574
|
+
for (const term of briefTerms(brief)) {
|
|
3575
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
3576
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
return [...found.values()];
|
|
3580
|
+
}
|
|
3581
|
+
function specificity(text, brief) {
|
|
3582
|
+
const list = specifics(text, brief);
|
|
3583
|
+
const words = countWords2(text);
|
|
3584
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
3585
|
+
const hard = list.length - trade;
|
|
3586
|
+
return {
|
|
3587
|
+
words,
|
|
3588
|
+
specifics: list,
|
|
3589
|
+
hard,
|
|
3590
|
+
trade,
|
|
3591
|
+
perHundred: words ? Math.round(list.length / words * 1e3) / 10 : 0,
|
|
3592
|
+
generic: hard === 0 && trade < 2
|
|
3593
|
+
};
|
|
3594
|
+
}
|
|
3595
|
+
var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
|
|
3596
|
+
var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
|
|
3597
|
+
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;
|
|
3598
|
+
function sentencesOf(blocks) {
|
|
3599
|
+
const out = [];
|
|
3600
|
+
for (const block of blocks) {
|
|
3601
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
3602
|
+
const s = m[0].trim();
|
|
3603
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
return [...new Set(out)];
|
|
3607
|
+
}
|
|
3608
|
+
function pageCopy(snapshot) {
|
|
3609
|
+
const withText2 = snapshot.sections.filter((s) => s.text);
|
|
3610
|
+
if (!snapshot.firstScreenText && withText2.length === 0) return null;
|
|
3611
|
+
const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
|
|
3612
|
+
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 ?? []);
|
|
3613
|
+
const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
|
|
3614
|
+
return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
|
|
3615
|
+
}
|
|
3616
|
+
function side(page, other, brief) {
|
|
3617
|
+
const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
|
|
3618
|
+
const sentences2 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
|
|
3619
|
+
const interchangeable = sentences2.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
|
|
3620
|
+
return {
|
|
3621
|
+
url: page.url,
|
|
3622
|
+
sentences: sentences2.length,
|
|
3623
|
+
interchangeable,
|
|
3624
|
+
share: sentences2.length ? Math.round(interchangeable.length / sentences2.length * 1e3) / 1e3 : null,
|
|
3625
|
+
perHundred: specificity(sentences2.join("\n"), brief).perHundred
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
function compareCompetitor(ours, competitor, brief) {
|
|
3629
|
+
return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
|
|
3630
|
+
}
|
|
3631
|
+
var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
|
|
3632
|
+
function formatCompetitor(result) {
|
|
3633
|
+
const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
|
|
3634
|
+
const lines = [
|
|
3635
|
+
`craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
|
|
3636
|
+
" Hero and service sentences holding nothing the other page does not also say:",
|
|
3637
|
+
row("ours", result.ours),
|
|
3638
|
+
row("competitor", result.competitor)
|
|
3639
|
+
];
|
|
3640
|
+
if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
|
|
3641
|
+
if (result.ours.interchangeable.length) {
|
|
3642
|
+
lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
|
|
3643
|
+
for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
|
|
3644
|
+
if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
|
|
3645
|
+
} else {
|
|
3646
|
+
lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
|
|
3647
|
+
}
|
|
3648
|
+
return lines.join("\n");
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
// src/character/tells/specificity.ts
|
|
3652
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3653
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
3654
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
3655
|
+
function literalText(inner) {
|
|
3656
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
3657
|
+
if (/[{}]/.test(t)) return null;
|
|
3658
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
3659
|
+
}
|
|
3660
|
+
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;
|
|
3661
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
3662
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
3663
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
3664
|
+
function headlines(ctx) {
|
|
3665
|
+
const out = [];
|
|
3666
|
+
for (const file of ctx.files) {
|
|
3667
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
3668
|
+
for (const m of file.text.matchAll(H1)) {
|
|
3669
|
+
const h1 = literalText(m[1]);
|
|
3670
|
+
if (!h1) continue;
|
|
3671
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
3672
|
+
const p = NEXT_P.exec(after);
|
|
3673
|
+
const sub = p ? literalText(p[1]) : null;
|
|
3674
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
return out;
|
|
3678
|
+
}
|
|
3679
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
3680
|
+
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;
|
|
3681
|
+
var PROOF_NAMES = [
|
|
3682
|
+
"Gas Safe",
|
|
3683
|
+
"NICEIC",
|
|
3684
|
+
"NAPIT",
|
|
3685
|
+
"ELECSA",
|
|
3686
|
+
"Stroma",
|
|
3687
|
+
"TrustMark",
|
|
3688
|
+
"Which? Trusted Trader",
|
|
3689
|
+
"Checkatrade",
|
|
3690
|
+
"TrustATrader",
|
|
3691
|
+
"Rated People",
|
|
3692
|
+
"MyBuilder",
|
|
3693
|
+
"Federation of Master Builders",
|
|
3694
|
+
"FMB",
|
|
3695
|
+
"Guild of Master Craftsmen",
|
|
3696
|
+
"OFTEC",
|
|
3697
|
+
"HETAS",
|
|
3698
|
+
"CIPHE",
|
|
3699
|
+
"APHC",
|
|
3700
|
+
"CHAS",
|
|
3701
|
+
"SafeContractor",
|
|
3702
|
+
"Constructionline",
|
|
3703
|
+
"FENSA",
|
|
3704
|
+
"CERTASS",
|
|
3705
|
+
"MCS",
|
|
3706
|
+
"RECC",
|
|
3707
|
+
"BPEC",
|
|
3708
|
+
"City & Guilds",
|
|
3709
|
+
"NVQ",
|
|
3710
|
+
"CSCS",
|
|
3711
|
+
"Worcester Bosch",
|
|
3712
|
+
"Vaillant",
|
|
3713
|
+
"Viessmann",
|
|
3714
|
+
"BAFE",
|
|
3715
|
+
"NSI",
|
|
3716
|
+
"SSAIB",
|
|
3717
|
+
"BPCA",
|
|
3718
|
+
"RICS",
|
|
3719
|
+
"CIOB",
|
|
3720
|
+
"RIBA",
|
|
3721
|
+
"ICAEW",
|
|
3722
|
+
"ACCA",
|
|
3723
|
+
"CIMA",
|
|
3724
|
+
"SRA",
|
|
3725
|
+
"Law Society",
|
|
3726
|
+
"GDC",
|
|
3727
|
+
"GMC",
|
|
3728
|
+
"HCPC",
|
|
3729
|
+
"CQC",
|
|
3730
|
+
"Ofsted",
|
|
3731
|
+
"ISO 9001",
|
|
3732
|
+
"ISO 27001",
|
|
3733
|
+
"Cyber Essentials",
|
|
3734
|
+
"Companies House",
|
|
3735
|
+
"FCA",
|
|
3736
|
+
"Trustpilot",
|
|
3737
|
+
"Feefo",
|
|
3738
|
+
"Reviews.io",
|
|
3739
|
+
"Yell",
|
|
3740
|
+
"Houzz"
|
|
3741
|
+
];
|
|
3742
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3743
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
3744
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
3745
|
+
var PROOF_PATTERNS = [
|
|
3746
|
+
PROOF_NAME,
|
|
3747
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
3748
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
3749
|
+
/\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,
|
|
3750
|
+
// A registration or licence number.
|
|
3751
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
3752
|
+
];
|
|
3753
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
3754
|
+
function sectionHasProof(s, section, index) {
|
|
3755
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
3756
|
+
const text = section.text ?? [];
|
|
3757
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
3758
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
3759
|
+
}
|
|
3760
|
+
function unprovenClaims(s) {
|
|
3761
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
3762
|
+
const out = [];
|
|
3763
|
+
s.sections.forEach((section, i) => {
|
|
3764
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
3765
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
3766
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
3767
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
3768
|
+
}
|
|
3769
|
+
});
|
|
3770
|
+
return out;
|
|
3771
|
+
}
|
|
3772
|
+
var withText = (texts, patch = () => ({})) => {
|
|
3773
|
+
const base = makeSnapshot();
|
|
3774
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
3775
|
+
};
|
|
3776
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
3777
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
3778
|
+
{
|
|
3779
|
+
id: "generic-hero-claim",
|
|
3780
|
+
name: "Headline that fits any business",
|
|
3781
|
+
generation: 1,
|
|
3782
|
+
severity: "warn",
|
|
3783
|
+
surface: "copy",
|
|
3784
|
+
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.",
|
|
3785
|
+
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".',
|
|
3786
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
3787
|
+
fixtures: {
|
|
3788
|
+
flag: [
|
|
3789
|
+
{ 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>' },
|
|
3790
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
3791
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
3792
|
+
],
|
|
3793
|
+
pass: [
|
|
3794
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
3795
|
+
// A stock headline rescued by the line under it.
|
|
3796
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
3797
|
+
// Two trade nouns say what the business does.
|
|
3798
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
3799
|
+
// The words come from data: nothing to read.
|
|
3800
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
3801
|
+
// Another page's headline is a label, not the site's claim.
|
|
3802
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
3803
|
+
]
|
|
3804
|
+
},
|
|
3805
|
+
rendered: {
|
|
3806
|
+
detect: (s) => {
|
|
3807
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
3808
|
+
const text = s.firstScreenText.join("\n");
|
|
3809
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
3810
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
3811
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
3812
|
+
},
|
|
3813
|
+
fixtures: {
|
|
3814
|
+
flag: [
|
|
3815
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
3816
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
3817
|
+
],
|
|
3818
|
+
pass: [
|
|
3819
|
+
makeSnapshot(),
|
|
3820
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
3821
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
3822
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
3823
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
3824
|
+
// Nor is any page but the home page.
|
|
3825
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
3826
|
+
]
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
];
|
|
3831
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
3832
|
+
{
|
|
3833
|
+
id: "unproven-claim",
|
|
3834
|
+
name: "Claim with no proof next to it",
|
|
3835
|
+
generation: 1,
|
|
3836
|
+
severity: "warn",
|
|
3837
|
+
surface: "rendered",
|
|
3838
|
+
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.`,
|
|
3839
|
+
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.",
|
|
3840
|
+
rendered: {
|
|
3841
|
+
detect: (s) => {
|
|
3842
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
3843
|
+
const found = unprovenClaims(s);
|
|
3844
|
+
if (found.length === 0) return [];
|
|
3845
|
+
const first = found[0];
|
|
3846
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
3847
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
3848
|
+
},
|
|
3849
|
+
fixtures: {
|
|
3850
|
+
flag: [
|
|
3851
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
3852
|
+
// Proof two sections away is not next to the claim.
|
|
3853
|
+
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" } : {})
|
|
3854
|
+
],
|
|
3855
|
+
pass: [
|
|
3856
|
+
makeSnapshot(),
|
|
3857
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
3858
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
3859
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
3860
|
+
{
|
|
3861
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
3862
|
+
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 }]
|
|
3863
|
+
}
|
|
3864
|
+
]
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
];
|
|
3869
|
+
|
|
3870
|
+
// src/character/house.ts
|
|
3871
|
+
var HOUSE = {
|
|
3872
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
3873
|
+
emoji: { tier: "block", label: "emoji" },
|
|
3874
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
3875
|
+
"no-x-no-y": {
|
|
3876
|
+
tier: "block",
|
|
3877
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
3878
|
+
},
|
|
3879
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
3880
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
3881
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
3882
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
3883
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
3884
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
3885
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
3886
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
3887
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
3888
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
3889
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3890
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3891
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
3892
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
3893
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
3894
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
3895
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
3896
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
3897
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
3898
|
+
"false-range": { tier: "review", label: "false range" },
|
|
3899
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
3900
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
3901
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
3902
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
3903
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
3904
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
3905
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
3906
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
3907
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
3908
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
3909
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
3910
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
3911
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
3912
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
3913
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
3914
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
3915
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
3916
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
3917
|
+
};
|
|
3918
|
+
function houseRule(id, name = id) {
|
|
3919
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
3920
|
+
}
|
|
3921
|
+
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";
|
|
3922
|
+
function holdHouseBlocks(exceptions, severity) {
|
|
3923
|
+
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
3924
|
+
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
3925
|
+
return {
|
|
3926
|
+
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
3927
|
+
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
3928
|
+
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
3929
|
+
refusedSeverity
|
|
3930
|
+
};
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3327
3933
|
// src/snapshot/rendered.ts
|
|
3328
3934
|
var hit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
3329
3935
|
var pct = (n) => `${Math.round(n * 100)}%`;
|
|
@@ -3387,7 +3993,7 @@ function blueToPurple(s) {
|
|
|
3387
3993
|
}
|
|
3388
3994
|
return hits;
|
|
3389
3995
|
}
|
|
3390
|
-
var
|
|
3996
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3391
3997
|
var RENDERED_PATHS = {
|
|
3392
3998
|
"reflex-font": {
|
|
3393
3999
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -3425,11 +4031,11 @@ var RENDERED_PATHS = {
|
|
|
3425
4031
|
}
|
|
3426
4032
|
},
|
|
3427
4033
|
"gradient-text": {
|
|
3428
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
4034
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
3429
4035
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
3430
4036
|
},
|
|
3431
4037
|
"glass-panel": {
|
|
3432
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
4038
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
3433
4039
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
3434
4040
|
},
|
|
3435
4041
|
"cream-palette": {
|
|
@@ -3507,7 +4113,7 @@ var RENDERED_PATHS = {
|
|
|
3507
4113
|
detect: (s) => {
|
|
3508
4114
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
3509
4115
|
const n = s.effects.glows + radial;
|
|
3510
|
-
return n > 0 ? [hit(s, `${
|
|
4116
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
3511
4117
|
},
|
|
3512
4118
|
fixtures: {
|
|
3513
4119
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -3515,11 +4121,11 @@ var RENDERED_PATHS = {
|
|
|
3515
4121
|
}
|
|
3516
4122
|
},
|
|
3517
4123
|
"grid-background": {
|
|
3518
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
4124
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
3519
4125
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
3520
4126
|
},
|
|
3521
4127
|
marquee: {
|
|
3522
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
4128
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
3523
4129
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
3524
4130
|
},
|
|
3525
4131
|
"thin-border-wide-shadow": {
|
|
@@ -3770,7 +4376,7 @@ var picture = (patch = {}) => ({
|
|
|
3770
4376
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3771
4377
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3772
4378
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3773
|
-
var
|
|
4379
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3774
4380
|
var IMAGERY_TELLS = [
|
|
3775
4381
|
{
|
|
3776
4382
|
id: "stock-photo",
|
|
@@ -3785,7 +4391,7 @@ var IMAGERY_TELLS = [
|
|
|
3785
4391
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3786
4392
|
if (found.length === 0) return [];
|
|
3787
4393
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3788
|
-
return [hit3(s, `${
|
|
4394
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3789
4395
|
},
|
|
3790
4396
|
fixtures: {
|
|
3791
4397
|
flag: [
|
|
@@ -3810,7 +4416,7 @@ var IMAGERY_TELLS = [
|
|
|
3810
4416
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3811
4417
|
if (found.length === 0) return [];
|
|
3812
4418
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3813
|
-
return [hit3(s, `${
|
|
4419
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3814
4420
|
},
|
|
3815
4421
|
fixtures: {
|
|
3816
4422
|
flag: [
|
|
@@ -3835,7 +4441,7 @@ var IMAGERY_TELLS = [
|
|
|
3835
4441
|
if (found.length === 0) return [];
|
|
3836
4442
|
const first = found[0].provenance;
|
|
3837
4443
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3838
|
-
return [hit3(s, `${
|
|
4444
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3839
4445
|
},
|
|
3840
4446
|
fixtures: {
|
|
3841
4447
|
flag: [
|
|
@@ -3860,7 +4466,7 @@ var IMAGERY_TELLS = [
|
|
|
3860
4466
|
if (!s.images || s.sections.length < 4) return [];
|
|
3861
4467
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3862
4468
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3863
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
4469
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3864
4470
|
},
|
|
3865
4471
|
fixtures: {
|
|
3866
4472
|
flag: [
|
|
@@ -3875,11 +4481,13 @@ var IMAGERY_TELLS = [
|
|
|
3875
4481
|
];
|
|
3876
4482
|
|
|
3877
4483
|
// src/character/check.ts
|
|
3878
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
4484
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
3879
4485
|
var CATALOGUE = [
|
|
3880
4486
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
4487
|
+
...SPECIFICITY_COPY_TELLS,
|
|
3881
4488
|
...RENDERED_TELLS,
|
|
3882
|
-
...IMAGERY_TELLS
|
|
4489
|
+
...IMAGERY_TELLS,
|
|
4490
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
3883
4491
|
];
|
|
3884
4492
|
function tellById(id) {
|
|
3885
4493
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -3956,7 +4564,7 @@ function report(tells, files, run, options) {
|
|
|
3956
4564
|
catalogueVersion: CATALOGUE_VERSION,
|
|
3957
4565
|
findings,
|
|
3958
4566
|
rejectedExceptions: rejected,
|
|
3959
|
-
excepted: [...excepted].map(([e,
|
|
4567
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
3960
4568
|
suppressed,
|
|
3961
4569
|
summary: {
|
|
3962
4570
|
files: files.length,
|
|
@@ -4001,15 +4609,26 @@ export {
|
|
|
4001
4609
|
isCream,
|
|
4002
4610
|
REFLEX_FONTS_1,
|
|
4003
4611
|
REFLEX_FONTS_2,
|
|
4004
|
-
houseRule,
|
|
4005
|
-
HOUSE_LOCKED,
|
|
4006
|
-
holdHouseBlocks,
|
|
4007
4612
|
fileKind,
|
|
4008
4613
|
extractCopy,
|
|
4009
4614
|
extractStrings,
|
|
4010
|
-
|
|
4615
|
+
visibleText,
|
|
4616
|
+
protectedFacts,
|
|
4617
|
+
compareFacts,
|
|
4618
|
+
formatComparison,
|
|
4619
|
+
TRADE_NOUNS,
|
|
4620
|
+
pageCopy,
|
|
4621
|
+
compareCompetitor,
|
|
4622
|
+
formatCompetitor,
|
|
4623
|
+
houseRule,
|
|
4624
|
+
HOUSE_LOCKED,
|
|
4625
|
+
holdHouseBlocks,
|
|
4011
4626
|
normaliseFamily,
|
|
4012
4627
|
fingerprint,
|
|
4628
|
+
levenshtein,
|
|
4629
|
+
SHARED_PART,
|
|
4630
|
+
ACCENT_SHARED,
|
|
4631
|
+
GROUND_SHARED,
|
|
4013
4632
|
fingerprintDistance,
|
|
4014
4633
|
NULL_VERSION,
|
|
4015
4634
|
nullPrompt,
|