@domandigital/craft 0.15.0 → 0.17.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 +18 -0
- package/CHARACTER.md +51 -0
- package/COPY.md +5 -0
- package/README.md +8 -0
- package/REGISTERS.md +163 -0
- package/dist/{audit-MTUC24XU.js → audit-4SFSLCKY.js} +3 -2
- package/dist/audit.cjs +1 -1
- package/dist/audit.d.cts +7 -0
- package/dist/audit.d.ts +7 -0
- package/dist/audit.js +1 -1
- package/dist/chunk-4QWEAL4G.js +1595 -0
- package/dist/{chunk-YDURAOYR.js → chunk-6TSDYETQ.js} +4 -2
- package/dist/{chunk-2IQ5OXFR.js → chunk-DARKHE6A.js} +7 -5
- package/dist/{cli-FCKHYAVZ.js → chunk-NTIPJ5U7.js} +2 -185
- package/dist/{chunk-MUVFECFC.js → chunk-O5B5ELJH.js} +46 -21
- package/dist/chunk-QIEDBL4J.js +9 -0
- package/dist/{chunk-QU6Q2QZZ.js → chunk-RJVG5KA6.js} +8 -6
- package/dist/{chunk-OUQ25LY2.js → chunk-SL5VZJ2J.js} +1 -1
- package/dist/{chunk-LFNSB6W7.js → chunk-WFB6F3BN.js} +66 -1633
- package/dist/{chunk-RXXRXV3V.js → chunk-ZUYERAFM.js} +178 -18
- package/dist/{cli-FLG3ZCAF.js → cli-BN4IT6N2.js} +13 -5
- package/dist/{cli-22TSWAX6.js → cli-GXIW7F3E.js} +7 -5
- package/dist/cli-JB4X5LAG.js +1573 -0
- package/dist/{cli-5ACKICBO.js → cli-JZRQBCD4.js} +7 -5
- package/dist/cli-MDW7JTEF.js +246 -0
- package/dist/cli-V44QEVS6.js +193 -0
- package/dist/{cli-7OZCYOQY.js → cli-Y7Y5IROK.js} +8 -5
- package/dist/cli.js +4 -2
- package/dist/index.cjs +1597 -101
- package/dist/index.d.cts +311 -2
- package/dist/index.d.ts +311 -2
- package/dist/index.js +1569 -101
- package/package.json +3 -2
|
@@ -1,338 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COPY_TELLS,
|
|
3
|
+
RESEARCH_TELLS,
|
|
4
|
+
excerptAt,
|
|
5
|
+
extractCopy,
|
|
6
|
+
extractStrings,
|
|
7
|
+
fileKind,
|
|
8
|
+
lineAt,
|
|
9
|
+
parseFile,
|
|
10
|
+
sentencesOf,
|
|
11
|
+
specificity
|
|
12
|
+
} from "./chunk-4QWEAL4G.js";
|
|
1
13
|
import {
|
|
2
14
|
SNAPSHOT_VERSION
|
|
3
15
|
} from "./chunk-QYBAVU3P.js";
|
|
4
16
|
|
|
5
|
-
// src/character/parse.ts
|
|
6
|
-
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
7
|
-
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
8
|
-
var CSS = /\.(css|scss|sass|less|pcss)$/i;
|
|
9
|
-
var PROSE = /\.(md|markdown|txt)$/i;
|
|
10
|
-
function fileKind(path) {
|
|
11
|
-
if (MARKUP.test(path)) return "markup";
|
|
12
|
-
if (CSS.test(path)) return "css";
|
|
13
|
-
if (SCRIPT.test(path)) return "script";
|
|
14
|
-
if (PROSE.test(path)) return "prose";
|
|
15
|
-
return "other";
|
|
16
|
-
}
|
|
17
|
-
function lineAt(text, offset) {
|
|
18
|
-
let line = 1;
|
|
19
|
-
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
20
|
-
if (text.charCodeAt(i) === 10) line += 1;
|
|
21
|
-
}
|
|
22
|
-
return line;
|
|
23
|
-
}
|
|
24
|
-
function excerptAt(text, offset, max = 140) {
|
|
25
|
-
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
26
|
-
const endRaw = text.indexOf("\n", offset);
|
|
27
|
-
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
28
|
-
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
29
|
-
}
|
|
30
|
-
var LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
|
|
31
|
-
function stringLiterals(text, base = 0) {
|
|
32
|
-
const out = [];
|
|
33
|
-
for (const m of text.matchAll(LITERAL)) {
|
|
34
|
-
out.push({ value: m[0].slice(1, -1), offset: base + (m.index ?? 0) });
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
function matchBracket(text, open) {
|
|
39
|
-
const pairs = { "{": "}", "(": ")", "[": "]" };
|
|
40
|
-
const close = pairs[text[open]];
|
|
41
|
-
const opener = text[open];
|
|
42
|
-
let depth = 0;
|
|
43
|
-
for (let i = open; i < text.length; i += 1) {
|
|
44
|
-
const ch = text[i];
|
|
45
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
46
|
-
const end = skipString(text, i);
|
|
47
|
-
i = end - 1;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (ch === opener) depth += 1;
|
|
51
|
-
else if (ch === close) {
|
|
52
|
-
depth -= 1;
|
|
53
|
-
if (depth === 0) return i + 1;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return text.length;
|
|
57
|
-
}
|
|
58
|
-
function skipString(text, start) {
|
|
59
|
-
const quote = text[start];
|
|
60
|
-
for (let i = start + 1; i < text.length; i += 1) {
|
|
61
|
-
if (text[i] === "\\") {
|
|
62
|
-
i += 1;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (text[i] === quote) return i + 1;
|
|
66
|
-
if (text[i] === "\n" && quote !== "`") return i + 1;
|
|
67
|
-
}
|
|
68
|
-
return text.length;
|
|
69
|
-
}
|
|
70
|
-
function split(classes) {
|
|
71
|
-
return classes.split(/\s+/).filter((c) => c.length > 0 && !c.includes("${"));
|
|
72
|
-
}
|
|
73
|
-
function classUnits(text) {
|
|
74
|
-
const units = [];
|
|
75
|
-
const covered = [];
|
|
76
|
-
const attr = /\b(?:className|class|:class)\s*=\s*/g;
|
|
77
|
-
for (const m of text.matchAll(attr)) {
|
|
78
|
-
const at = (m.index ?? 0) + m[0].length;
|
|
79
|
-
const ch = text[at];
|
|
80
|
-
if (ch === '"' || ch === "'") {
|
|
81
|
-
const end = skipString(text, at);
|
|
82
|
-
units.push({ classes: split(text.slice(at + 1, end - 1)), offset: m.index ?? 0 });
|
|
83
|
-
} else if (ch === "{") {
|
|
84
|
-
const end = matchBracket(text, at);
|
|
85
|
-
const joined = stringLiterals(text.slice(at, end)).map((l) => l.value).join(" ");
|
|
86
|
-
units.push({ classes: split(joined), offset: m.index ?? 0 });
|
|
87
|
-
covered.push([at, end]);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const call = /\b(cn|clsx|twMerge|cx|cva|tv)\s*\(/g;
|
|
91
|
-
for (const m of text.matchAll(call)) {
|
|
92
|
-
const start = m.index ?? 0;
|
|
93
|
-
if (covered.some(([a, b]) => start >= a && start < b)) continue;
|
|
94
|
-
const open = start + m[0].length - 1;
|
|
95
|
-
const end = matchBracket(text, open);
|
|
96
|
-
const literals = stringLiterals(text.slice(open, end), open);
|
|
97
|
-
if (m[1] === "cva" || m[1] === "tv") {
|
|
98
|
-
for (const l of literals) units.push({ classes: split(l.value), offset: l.offset });
|
|
99
|
-
} else {
|
|
100
|
-
units.push({ classes: split(literals.map((l) => l.value).join(" ")), offset: start });
|
|
101
|
-
}
|
|
102
|
-
covered.push([open, end]);
|
|
103
|
-
}
|
|
104
|
-
return units.filter((u) => u.classes.length > 0);
|
|
105
|
-
}
|
|
106
|
-
function blankComments(css) {
|
|
107
|
-
return css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
|
|
108
|
-
}
|
|
109
|
-
function cssDeclarations(css, base = 0) {
|
|
110
|
-
const src = blankComments(css);
|
|
111
|
-
const out = [];
|
|
112
|
-
const stack = [];
|
|
113
|
-
let segStart = 0;
|
|
114
|
-
let parens = 0;
|
|
115
|
-
const flush = (end) => {
|
|
116
|
-
const seg = src.slice(segStart, end);
|
|
117
|
-
const colon = seg.indexOf(":");
|
|
118
|
-
if (stack.length === 0 || colon === -1) return;
|
|
119
|
-
const property = seg.slice(0, colon).trim();
|
|
120
|
-
if (!/^(--)?[a-zA-Z-]+$/.test(property)) return;
|
|
121
|
-
const lead = seg.length - seg.trimStart().length;
|
|
122
|
-
out.push({
|
|
123
|
-
property: property.toLowerCase(),
|
|
124
|
-
value: seg.slice(colon + 1).trim(),
|
|
125
|
-
selector: stack[stack.length - 1],
|
|
126
|
-
offset: base + segStart + lead
|
|
127
|
-
});
|
|
128
|
-
};
|
|
129
|
-
for (let i = 0; i < src.length; i += 1) {
|
|
130
|
-
const ch = src[i];
|
|
131
|
-
if (ch === '"' || ch === "'") {
|
|
132
|
-
i = skipString(src, i) - 1;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (ch === "(") parens += 1;
|
|
136
|
-
else if (ch === ")") parens = Math.max(0, parens - 1);
|
|
137
|
-
if (parens > 0) continue;
|
|
138
|
-
if (ch === "{") {
|
|
139
|
-
stack.push(src.slice(segStart, i).trim());
|
|
140
|
-
segStart = i + 1;
|
|
141
|
-
} else if (ch === ";") {
|
|
142
|
-
flush(i);
|
|
143
|
-
segStart = i + 1;
|
|
144
|
-
} else if (ch === "}") {
|
|
145
|
-
flush(i);
|
|
146
|
-
stack.pop();
|
|
147
|
-
segStart = i + 1;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return out;
|
|
151
|
-
}
|
|
152
|
-
function styleBlocks(text) {
|
|
153
|
-
const out = [];
|
|
154
|
-
for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
155
|
-
const bodyStart = (m.index ?? 0) + m[0].indexOf(">") + 1;
|
|
156
|
-
out.push(...cssDeclarations(m[1], bodyStart));
|
|
157
|
-
}
|
|
158
|
-
return out;
|
|
159
|
-
}
|
|
160
|
-
function parseFile(file) {
|
|
161
|
-
const kind = fileKind(file.path);
|
|
162
|
-
return {
|
|
163
|
-
...file,
|
|
164
|
-
kind,
|
|
165
|
-
classUnits: kind === "markup" || kind === "script" ? classUnits(file.text) : [],
|
|
166
|
-
declarations: kind === "css" ? cssDeclarations(file.text) : kind === "markup" ? styleBlocks(file.text) : []
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// src/character/prose.ts
|
|
171
|
-
var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
172
|
-
function readsAsProse(value) {
|
|
173
|
-
if (value.length < 12 || !/\s/.test(value)) return false;
|
|
174
|
-
if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
|
|
175
|
-
const words = withoutMarkup(value);
|
|
176
|
-
if (looksLikeCode(words) || /[<>]/.test(words)) return false;
|
|
177
|
-
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words);
|
|
178
|
-
}
|
|
179
|
-
function withoutMarkup(value) {
|
|
180
|
-
return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
|
|
181
|
-
}
|
|
182
|
-
function looksLikeCode(words) {
|
|
183
|
-
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words);
|
|
184
|
-
}
|
|
185
|
-
var UTILITY = /^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|rounded|border|shadow|underline|italic|uppercase|lowercase|capitalize|truncate|container|group|peer|transition|antialiased|prose|sr-only|outline|ring|grow|shrink|isolate|invisible|visible|collapse)$/;
|
|
186
|
-
function looksLikeClasses(value) {
|
|
187
|
-
const tokens = value.trim().split(/\s+/);
|
|
188
|
-
if (tokens.length === 0 || !tokens.every((t) => /^[!\w@:/[\]().#%,>&*+=~-]+$/.test(t))) return false;
|
|
189
|
-
const classy = tokens.filter((t) => /[-:[\]/]/.test(t) || UTILITY.test(t)).length;
|
|
190
|
-
return classy / tokens.length >= 0.6 && /[a-z]-|:|\[/.test(value);
|
|
191
|
-
}
|
|
192
|
-
function looksLikeAddress(value) {
|
|
193
|
-
return /^(?:\.{0,2}\/|@\/|https?:|mailto:|tel:)/.test(value);
|
|
194
|
-
}
|
|
195
|
-
function readsAsText(value) {
|
|
196
|
-
if (value.trim().length === 0) return false;
|
|
197
|
-
if (looksLikeAddress(value) || looksLikeClasses(value)) return false;
|
|
198
|
-
return /[^\s\w]|[a-zA-Z]/.test(value);
|
|
199
|
-
}
|
|
200
|
-
function literalIsText(value) {
|
|
201
|
-
return readsAsText(value) && !looksLikeCode(withoutMarkup(value));
|
|
202
|
-
}
|
|
203
|
-
function maskScriptComments(text) {
|
|
204
|
-
let out = "";
|
|
205
|
-
let i = 0;
|
|
206
|
-
while (i < text.length) {
|
|
207
|
-
const ch = text[i];
|
|
208
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
209
|
-
const start = i;
|
|
210
|
-
i += 1;
|
|
211
|
-
while (i < text.length) {
|
|
212
|
-
if (text[i] === "\\") {
|
|
213
|
-
i += 2;
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
if (text[i] === ch) {
|
|
217
|
-
i += 1;
|
|
218
|
-
break;
|
|
219
|
-
}
|
|
220
|
-
if (text[i] === "\n" && ch !== "`") break;
|
|
221
|
-
i += 1;
|
|
222
|
-
}
|
|
223
|
-
out += text.slice(start, i);
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
if (ch === "/" && text[i + 1] === "/") {
|
|
227
|
-
const end = text.indexOf("\n", i);
|
|
228
|
-
const stop = end === -1 ? text.length : end;
|
|
229
|
-
out += " ".repeat(stop - i);
|
|
230
|
-
i = stop;
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
if (ch === "/" && text[i + 1] === "*") {
|
|
234
|
-
const end = text.indexOf("*/", i + 2);
|
|
235
|
-
const stop = end === -1 ? text.length : end + 2;
|
|
236
|
-
out += blank(text.slice(i, stop));
|
|
237
|
-
i = stop;
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
out += ch;
|
|
241
|
-
i += 1;
|
|
242
|
-
}
|
|
243
|
-
return out;
|
|
244
|
-
}
|
|
245
|
-
function maskMarkupComments(text) {
|
|
246
|
-
return text.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, blank).replace(/\{\/\*[\s\S]*?\*\/\}|<!--[\s\S]*?-->/g, blank).replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, blank).replace(/^[ \t]*\/\/[^\n]*/gm, blank).replace(/([;,{}()[\]])([ \t]+)\/\/[^\n]*/g, (m, p, s) => p + s + " ".repeat(m.length - p.length - s.length)).replace(/^\s*import\b[^\n]*$/gm, blank);
|
|
247
|
-
}
|
|
248
|
-
function markupParts(file) {
|
|
249
|
-
const masked = maskMarkupComments(file.text);
|
|
250
|
-
const textNodes = [];
|
|
251
|
-
let rest = masked;
|
|
252
|
-
for (const m of masked.matchAll(/([>}])([^<>{}]+)(?=[<{])/g)) {
|
|
253
|
-
const body = m[2];
|
|
254
|
-
const before = masked[(m.index ?? 0) - 1] ?? "";
|
|
255
|
-
const after = masked[(m.index ?? 0) + m[0].length];
|
|
256
|
-
if (m[1] === ">" && /[=-]/.test(before)) continue;
|
|
257
|
-
if (m[1] === "}" && (/^\s*(?:[)\],;:]|(?:else|catch|finally|return|from|as|if|const|let|export|function)\b)/.test(body) || /;\s*$/.test(body))) continue;
|
|
258
|
-
if (m[1] === "}" && after === "{" && (/[=()`'"]/.test(body) || !/[a-zA-Z]{2,}|[^\s\w,|&?:]/.test(body))) continue;
|
|
259
|
-
if (body.trim().length === 0) continue;
|
|
260
|
-
if (m[1] === ">" && after === "<" && /^[\s—–-]*$/.test(body)) continue;
|
|
261
|
-
const at = (m.index ?? 0) + 1;
|
|
262
|
-
textNodes.push({ path: file.path, text: body, offset: at });
|
|
263
|
-
rest = rest.slice(0, at) + blank(body) + rest.slice(at + body.length);
|
|
264
|
-
}
|
|
265
|
-
return { textNodes, literals: splitHtml(stringLiterals(rest)) };
|
|
266
|
-
}
|
|
267
|
-
function textNodeIsProse(body) {
|
|
268
|
-
return /[a-zA-Z]{2,}/.test(body) && /\s/.test(body.trim()) || /[a-zA-Z]{4,}[.!?]/.test(body);
|
|
269
|
-
}
|
|
270
|
-
function proseBlocks(file) {
|
|
271
|
-
const masked = file.text.replace(/^(```|~~~)[\s\S]*?^\1/gm, blank).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length)).replace(/^---\n[\s\S]*?\n---\n/, blank).replace(/<!--[\s\S]*?-->/g, blank);
|
|
272
|
-
return [{ path: file.path, text: masked, offset: 0 }];
|
|
273
|
-
}
|
|
274
|
-
function isData(path) {
|
|
275
|
-
return /\.jsonl?$/i.test(path);
|
|
276
|
-
}
|
|
277
|
-
function scriptLiterals(file) {
|
|
278
|
-
return splitHtml(stringLiterals(isData(file.path) ? file.text : maskScriptComments(file.text)));
|
|
279
|
-
}
|
|
280
|
-
function splitHtml(literals) {
|
|
281
|
-
const out = [];
|
|
282
|
-
for (const l of literals) {
|
|
283
|
-
if (!/<[a-zA-Z][^<>]*>/.test(l.value)) {
|
|
284
|
-
out.push(l);
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
for (const m of l.value.matchAll(/(?:^|>)([^<>]+)(?=<|$)/g)) {
|
|
288
|
-
const body = m[1];
|
|
289
|
-
if (!body.trim()) continue;
|
|
290
|
-
const lead = m[0].length - body.length;
|
|
291
|
-
out.push({ value: body, offset: l.offset + (m.index ?? 0) + lead });
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return out;
|
|
295
|
-
}
|
|
296
|
-
function extractCopy(file) {
|
|
297
|
-
const kind = fileKind(file.path);
|
|
298
|
-
if (kind === "prose" || file.path.endsWith(".mdx")) return proseBlocks(file);
|
|
299
|
-
if (kind === "markup") {
|
|
300
|
-
const { textNodes, literals } = markupParts(file);
|
|
301
|
-
return [
|
|
302
|
-
...textNodes.filter((b) => textNodeIsProse(b.text)),
|
|
303
|
-
...literals.filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
|
|
304
|
-
];
|
|
305
|
-
}
|
|
306
|
-
if (kind === "script" || isData(file.path)) {
|
|
307
|
-
return scriptLiterals(file).filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
|
|
308
|
-
}
|
|
309
|
-
return [];
|
|
310
|
-
}
|
|
311
|
-
function extractStrings(file) {
|
|
312
|
-
const kind = fileKind(file.path);
|
|
313
|
-
if (kind === "prose" || file.path.endsWith(".mdx")) {
|
|
314
|
-
const [block] = proseBlocks(file);
|
|
315
|
-
const out = [];
|
|
316
|
-
let offset = 0;
|
|
317
|
-
for (const line of block.text.split("\n")) {
|
|
318
|
-
if (line.trim()) out.push({ path: file.path, text: line, offset });
|
|
319
|
-
offset += line.length + 1;
|
|
320
|
-
}
|
|
321
|
-
return out;
|
|
322
|
-
}
|
|
323
|
-
if (kind === "markup") {
|
|
324
|
-
const { textNodes, literals } = markupParts(file);
|
|
325
|
-
return [
|
|
326
|
-
...textNodes.filter((b) => readsAsText(b.text)),
|
|
327
|
-
...literals.filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
|
|
328
|
-
];
|
|
329
|
-
}
|
|
330
|
-
if (kind === "script" || isData(file.path)) {
|
|
331
|
-
return scriptLiterals(file).filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
|
|
332
|
-
}
|
|
333
|
-
return [];
|
|
334
|
-
}
|
|
335
|
-
|
|
336
17
|
// src/color/oklch.ts
|
|
337
18
|
var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
338
19
|
function srgbToLinear(c) {
|
|
@@ -428,8 +109,8 @@ function parts(fn) {
|
|
|
428
109
|
function hslToRgb(h, s, l) {
|
|
429
110
|
const k = (n) => (n + h / 30) % 12;
|
|
430
111
|
const a = s * Math.min(l, 1 - l);
|
|
431
|
-
const
|
|
432
|
-
return { r:
|
|
112
|
+
const f3 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
113
|
+
return { r: f3(0), g: f3(8), b: f3(4) };
|
|
433
114
|
}
|
|
434
115
|
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
435
116
|
function parseColour(raw) {
|
|
@@ -1671,11 +1352,11 @@ function sharedChoices(target, runs) {
|
|
|
1671
1352
|
}
|
|
1672
1353
|
return out.sort((a, b) => b.runs - a.runs);
|
|
1673
1354
|
}
|
|
1674
|
-
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((
|
|
1675
|
-
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((
|
|
1355
|
+
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f3) => f3.toLowerCase()));
|
|
1356
|
+
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f3) => f3.toLowerCase()));
|
|
1676
1357
|
function knownFor(kind, value, sample) {
|
|
1677
1358
|
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
1678
|
-
if (kind === "accent" && value === "violet") return sample.some((
|
|
1359
|
+
if (kind === "accent" && value === "violet") return sample.some((f3) => f3.accent && isAiViolet(f3.accent)) ? "ai-violet" : null;
|
|
1679
1360
|
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
1680
1361
|
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
1681
1362
|
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
@@ -1758,7 +1439,7 @@ function harvest(models, options = {}) {
|
|
|
1758
1439
|
};
|
|
1759
1440
|
runs.forEach((r, i) => {
|
|
1760
1441
|
const c = choicesOf(r.fingerprint);
|
|
1761
|
-
for (const
|
|
1442
|
+
for (const f3 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f3, i, r.brief);
|
|
1762
1443
|
add("accent", c.accent[0], i, r.brief);
|
|
1763
1444
|
add("ground", c.ground[0], i, r.brief);
|
|
1764
1445
|
add("shape", c.shape[0], i, r.brief);
|
|
@@ -1777,19 +1458,19 @@ function harvest(models, options = {}) {
|
|
|
1777
1458
|
}
|
|
1778
1459
|
const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
|
|
1779
1460
|
const names = models.map((m) => properNouns(m.brief));
|
|
1780
|
-
const
|
|
1461
|
+
const phrases = /* @__PURE__ */ new Map();
|
|
1781
1462
|
runs.forEach((r, i) => {
|
|
1782
1463
|
for (const text of r.copy) {
|
|
1783
1464
|
for (const g of textGrams(text)) {
|
|
1784
1465
|
if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
|
|
1785
|
-
const p =
|
|
1466
|
+
const p = phrases.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
|
|
1786
1467
|
p.runs.add(i);
|
|
1787
1468
|
p.briefs.add(r.brief);
|
|
1788
|
-
|
|
1469
|
+
phrases.set(g, p);
|
|
1789
1470
|
}
|
|
1790
1471
|
}
|
|
1791
1472
|
});
|
|
1792
|
-
const kept = [...
|
|
1473
|
+
const kept = [...phrases].filter(([, p]) => p.runs.size >= phraseMin && (!multiBrief || p.briefs.size >= 2)).sort((a, b) => b[1].runs.size - a[1].runs.size || b[0].length - a[0].length);
|
|
1793
1474
|
const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
|
|
1794
1475
|
for (const [g, p] of maximal.slice(0, 40)) {
|
|
1795
1476
|
out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
|
|
@@ -1832,7 +1513,7 @@ function componentDistance(a, b) {
|
|
|
1832
1513
|
function componentShared(a, b) {
|
|
1833
1514
|
if (a.role !== b.role) return [];
|
|
1834
1515
|
const out = [];
|
|
1835
|
-
const both = (
|
|
1516
|
+
const both = (f3) => f3(a) && f3(b);
|
|
1836
1517
|
if (both((s) => s.centred >= 0.7)) out.push("centred");
|
|
1837
1518
|
else if (both((s) => s.centred < 0.3)) out.push("left-aligned");
|
|
1838
1519
|
if (both((s) => s.width <= 0.6)) out.push("narrow column");
|
|
@@ -1902,866 +1583,6 @@ function componentTypicalities(fp, runs, k = 3) {
|
|
|
1902
1583
|
return out;
|
|
1903
1584
|
}
|
|
1904
1585
|
|
|
1905
|
-
// src/character/tells/copy.ts
|
|
1906
|
-
var f2 = (path, text) => ({ path, text });
|
|
1907
|
-
var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
1908
|
-
function allText(ctx) {
|
|
1909
|
-
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
1910
|
-
}
|
|
1911
|
-
var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
|
|
1912
|
-
function linkSpans(text) {
|
|
1913
|
-
return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
1914
|
-
}
|
|
1915
|
-
var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
|
|
1916
|
-
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
1917
|
-
return (ctx) => {
|
|
1918
|
-
const hits = [];
|
|
1919
|
-
for (const block of source(ctx)) {
|
|
1920
|
-
const spans = [];
|
|
1921
|
-
const links = linkSpans(block.text);
|
|
1922
|
-
for (const pattern of patterns) {
|
|
1923
|
-
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
1924
|
-
for (const m of block.text.matchAll(global)) {
|
|
1925
|
-
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
1926
|
-
if (insideLink(links, m.index ?? 0)) continue;
|
|
1927
|
-
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
1928
|
-
}
|
|
1929
|
-
}
|
|
1930
|
-
spans.sort((a, b) => a[0] - b[0]);
|
|
1931
|
-
let end = -1;
|
|
1932
|
-
for (const [start, stop, text] of spans) {
|
|
1933
|
-
if (start < end) continue;
|
|
1934
|
-
end = stop;
|
|
1935
|
-
const lead = text.length - text.trimStart().length;
|
|
1936
|
-
hits.push({ path: block.path, offset: block.offset + start + lead, message: label(text.trim()) });
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
1939
|
-
return hits;
|
|
1940
|
-
};
|
|
1941
|
-
}
|
|
1942
|
-
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
|
|
1943
|
-
var wordList = (words) => new RegExp(`\\b(?:${[...words].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
|
|
1944
|
-
var quoted = (m) => `"${m}"`;
|
|
1945
|
-
var AI_WORDS = [
|
|
1946
|
-
"tapestry",
|
|
1947
|
-
"testament",
|
|
1948
|
-
"realm",
|
|
1949
|
-
"embark",
|
|
1950
|
-
"embarking",
|
|
1951
|
-
"unleash",
|
|
1952
|
-
"unlocks",
|
|
1953
|
-
"unlocking",
|
|
1954
|
-
"elevate",
|
|
1955
|
-
"elevates",
|
|
1956
|
-
"elevated",
|
|
1957
|
-
"elevating",
|
|
1958
|
-
"bustling",
|
|
1959
|
-
"nestled",
|
|
1960
|
-
"meticulous",
|
|
1961
|
-
"meticulously",
|
|
1962
|
-
"paramount",
|
|
1963
|
-
"furthermore",
|
|
1964
|
-
"moreover",
|
|
1965
|
-
"navigating",
|
|
1966
|
-
"ever-evolving",
|
|
1967
|
-
"unparalleled",
|
|
1968
|
-
// Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
|
|
1969
|
-
// above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
|
|
1970
|
-
"underscore",
|
|
1971
|
-
"underscores",
|
|
1972
|
-
"underscoring",
|
|
1973
|
-
"showcasing",
|
|
1974
|
-
"pivotal"
|
|
1975
|
-
];
|
|
1976
|
-
var STOCK_PHRASES = [
|
|
1977
|
-
"in today's digital age",
|
|
1978
|
-
"to the next level",
|
|
1979
|
-
"one-stop shop",
|
|
1980
|
-
"second to none",
|
|
1981
|
-
"we pride ourselves",
|
|
1982
|
-
"passionate about",
|
|
1983
|
-
"your journey",
|
|
1984
|
-
"our journey",
|
|
1985
|
-
"curated",
|
|
1986
|
-
"crafted with care",
|
|
1987
|
-
"tailored to your",
|
|
1988
|
-
"peace of mind",
|
|
1989
|
-
"so here's",
|
|
1990
|
-
"in an era where",
|
|
1991
|
-
"the future looks bright",
|
|
1992
|
-
"only time will tell"
|
|
1993
|
-
];
|
|
1994
|
-
var AI_PHRASES = [
|
|
1995
|
-
"let's dive in",
|
|
1996
|
-
"dive into",
|
|
1997
|
-
"let's unpack",
|
|
1998
|
-
"let's break this down",
|
|
1999
|
-
"let's break it down",
|
|
2000
|
-
"here's the thing",
|
|
2001
|
-
"it's important to note",
|
|
2002
|
-
"it's important to remember",
|
|
2003
|
-
"it is worth noting that",
|
|
2004
|
-
"at its core",
|
|
2005
|
-
"the key takeaway",
|
|
2006
|
-
"the reality is",
|
|
2007
|
-
"the truth is",
|
|
2008
|
-
"increasingly digital",
|
|
2009
|
-
"seamless",
|
|
2010
|
-
"seamlessly",
|
|
2011
|
-
"unlock your",
|
|
2012
|
-
"unlock the power",
|
|
2013
|
-
"meaningful impact",
|
|
2014
|
-
"drive meaningful",
|
|
2015
|
-
"foster collaboration",
|
|
2016
|
-
"fosters collaboration",
|
|
2017
|
-
"fostering collaboration",
|
|
2018
|
-
"comprehensive guide to",
|
|
2019
|
-
"look no further",
|
|
2020
|
-
"in the event that",
|
|
2021
|
-
"at no additional cost to you",
|
|
2022
|
-
"rest assured",
|
|
2023
|
-
"when it comes to",
|
|
2024
|
-
"we have got you covered",
|
|
2025
|
-
"we've got you covered"
|
|
2026
|
-
];
|
|
2027
|
-
var PLAINER_WORDS = [
|
|
2028
|
-
"empower",
|
|
2029
|
-
"empowers",
|
|
2030
|
-
"empowered",
|
|
2031
|
-
"empowering",
|
|
2032
|
-
"harness",
|
|
2033
|
-
"harnesses",
|
|
2034
|
-
"harnessed",
|
|
2035
|
-
"harnessing",
|
|
2036
|
-
"delve",
|
|
2037
|
-
"delves",
|
|
2038
|
-
"delved",
|
|
2039
|
-
"delving",
|
|
2040
|
-
"leverage",
|
|
2041
|
-
"leverages",
|
|
2042
|
-
"leveraged",
|
|
2043
|
-
"leveraging"
|
|
2044
|
-
];
|
|
2045
|
-
var BUZZWORDS = [
|
|
2046
|
-
"synergy",
|
|
2047
|
-
"synergise",
|
|
2048
|
-
"synergize",
|
|
2049
|
-
"cutting-edge",
|
|
2050
|
-
"cutting edge",
|
|
2051
|
-
"best-in-class",
|
|
2052
|
-
"best in class",
|
|
2053
|
-
"best of breed",
|
|
2054
|
-
"game-changer",
|
|
2055
|
-
"game changer",
|
|
2056
|
-
"paradigm shift",
|
|
2057
|
-
"world-class",
|
|
2058
|
-
"state of the art",
|
|
2059
|
-
"state-of-the-art",
|
|
2060
|
-
"mission-critical",
|
|
2061
|
-
"low-hanging fruit",
|
|
2062
|
-
"move the needle",
|
|
2063
|
-
"circle back",
|
|
2064
|
-
"touch base",
|
|
2065
|
-
"reach out",
|
|
2066
|
-
"value-add",
|
|
2067
|
-
"robust",
|
|
2068
|
-
"turnkey",
|
|
2069
|
-
"holistic",
|
|
2070
|
-
"disruptive"
|
|
2071
|
-
];
|
|
2072
|
-
var NEGATIVE_REASSURANCE = [
|
|
2073
|
-
"no risk",
|
|
2074
|
-
"never locked in",
|
|
2075
|
-
"nothing goes wrong",
|
|
2076
|
-
"no hidden fees",
|
|
2077
|
-
"never pay",
|
|
2078
|
-
"without the hassle",
|
|
2079
|
-
"no surprises"
|
|
2080
|
-
];
|
|
2081
|
-
var VAGUE_WORDS = ["innovative", "scalable", "end-to-end", "streamline", "solutions"];
|
|
2082
|
-
var REVIEW_PHRASES = [
|
|
2083
|
-
"great question",
|
|
2084
|
-
"of course!",
|
|
2085
|
-
"absolutely!",
|
|
2086
|
-
"absolutely,",
|
|
2087
|
-
"truly",
|
|
2088
|
-
"genuinely",
|
|
2089
|
-
"deeply",
|
|
2090
|
-
"ultimately",
|
|
2091
|
-
"one-size-fits-all",
|
|
2092
|
-
"one size fits all",
|
|
2093
|
-
"let me know if you'd",
|
|
2094
|
-
"the single most",
|
|
2095
|
-
"by far the most",
|
|
2096
|
-
"hands down the",
|
|
2097
|
-
"arguably the best",
|
|
2098
|
-
"bar none"
|
|
2099
|
-
];
|
|
2100
|
-
var NEGATION = [
|
|
2101
|
-
// "It's not just a haircut, it's an experience."
|
|
2102
|
-
/\b(?:it['’]?s|it is|this is|this isn['’]t|we['’]re|we are|they['’]re)\s+(?:not|more than)\s+(?:just|only|merely|simply)?\s*[^.!?\n]{1,60}?[,;.—-]\s*(?:it['’]?s|it is|this is|we['’]re|we are|they['’]re)\b/i,
|
|
2103
|
-
/\bmore\s+than\s+just\b/i,
|
|
2104
|
-
// copular: "It's not a website, it's a growth engine"
|
|
2105
|
-
/\b(?:it|that|this|you|we|they|she|he)(?:’s|'s|’re|'re)\s+not\s+[^.;:]{2,50}[,:]\s*(?:it|that|this|you|we|they|she|he)(?:’s|'s|’re|'re)/i,
|
|
2106
|
-
// verbal: "I don't teach the treatment you'd choose: I teach the standard"
|
|
2107
|
-
/\b(i|we|you|they|she|he)\s+(?:do|does|did|would|will|ca|wo)(?:n’t|n't)\s+(\w+)\b[^.;:]{2,60}[,:]\s*(?:i|we|you|they|she|he)\s+\2\b/i,
|
|
2108
|
-
/\bnot\s+(?:just|merely|simply)\b/i,
|
|
2109
|
-
// "X isn't a job, it's art"
|
|
2110
|
-
/\b(?:is|are|was|were)(?:n’t|n't)\b[^.;]{2,60},\s*(?:it|they|that|this)(?:’s|'s|’re|'re)\b/i,
|
|
2111
|
-
/\bless\s+about\b[^.;]{2,50}\bmore\s+about\b/i,
|
|
2112
|
-
// parallel participle, no repeated subject: "dispatched from here, not shipped in from Seoul"
|
|
2113
|
-
/\b\w{3,}ed\b[^,.;:]{0,40},\s*not\s+\w{3,}ed\b/i
|
|
2114
|
-
];
|
|
2115
|
-
var DATA_ABSENCE = /\b(?:yet|found|available|configured|selected|assigned|scheduled|recorded|on file|to show|to display|results|match|matches|matching|data|in this period)\b/i;
|
|
2116
|
-
var SIGNED_IN = /(?:^|\/)(?:admin|portal|dashboard|embedded)\//;
|
|
2117
|
-
function lineAround(text, index) {
|
|
2118
|
-
const start = text.lastIndexOf("\n", index) + 1;
|
|
2119
|
-
const end = text.indexOf("\n", index);
|
|
2120
|
-
return text.slice(start, end === -1 ? text.length : end);
|
|
2121
|
-
}
|
|
2122
|
-
var emptyState = (block, match, index = 0) => SIGNED_IN.test(block.path) || DATA_ABSENCE.test(isProsePath(block.path) ? lineAround(block.text, index) : block.text) || DATA_ABSENCE.test(match);
|
|
2123
|
-
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
2124
|
-
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
2125
|
-
var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
|
|
2126
|
-
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
2127
|
-
function badgeHits(ctx) {
|
|
2128
|
-
const hits = [];
|
|
2129
|
-
for (const block of ctx.strings) {
|
|
2130
|
-
let text = block.text;
|
|
2131
|
-
let lead = text.length - text.trimStart().length;
|
|
2132
|
-
if (isProsePath(block.path)) {
|
|
2133
|
-
if (/^\s*#{1,6}\s/.test(text) || /[<>{}=;]/.test(text)) continue;
|
|
2134
|
-
const bullet = text.trimStart().match(/^[-*]\s+/);
|
|
2135
|
-
if (bullet) lead += bullet[0].length;
|
|
2136
|
-
text = text.slice(lead);
|
|
2137
|
-
}
|
|
2138
|
-
const candidate = text.trim();
|
|
2139
|
-
if (NO_X_BADGE.test(candidate) && !emptyState(block, candidate)) {
|
|
2140
|
-
hits.push({ path: block.path, offset: block.offset + lead, message: quoted(candidate) });
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
return hits;
|
|
2144
|
-
}
|
|
2145
|
-
var COPY_TELLS = [
|
|
2146
|
-
{
|
|
2147
|
-
id: "ai-vocabulary",
|
|
2148
|
-
name: "AI vocabulary",
|
|
2149
|
-
generation: 1,
|
|
2150
|
-
severity: "warn",
|
|
2151
|
-
surface: "copy",
|
|
2152
|
-
why: "Tapestry, elevate, nestled, unparalleled: words that appear in generated copy far more than in anything a business owner writes.",
|
|
2153
|
-
fix: "Say the plain thing. 'Elevate your look' becomes what actually happens: 'Gel that lasts three weeks'.",
|
|
2154
|
-
detect: phrases([wordList(AI_WORDS), /\bunlock\b(?!\s+(?:your|the power))/i], quoted),
|
|
2155
|
-
fixtures: {
|
|
2156
|
-
flag: [f2("content/home.md", "Elevate your nails in our bustling studio."), f2("content/home.md", "We unlock it for you.")],
|
|
2157
|
-
pass: [f2("content/home.md", "Gel nails that last three weeks. Book online in a minute.")]
|
|
2158
|
-
}
|
|
2159
|
-
},
|
|
2160
|
-
{
|
|
2161
|
-
id: "stock-phrase",
|
|
2162
|
-
name: "Stock phrase",
|
|
2163
|
-
generation: 1,
|
|
2164
|
-
severity: "warn",
|
|
2165
|
-
surface: "copy",
|
|
2166
|
-
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
2167
|
-
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
2168
|
-
detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
|
|
2169
|
-
fixtures: {
|
|
2170
|
-
flag: [
|
|
2171
|
-
f2("content/home.md", "Take your plumbing to the next level with peace of mind."),
|
|
2172
|
-
f2("content/home.md", "We meet each practice where it is in its journey.")
|
|
2173
|
-
],
|
|
2174
|
-
pass: [
|
|
2175
|
-
f2("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
|
|
2176
|
-
f2("content/home.md", "We mapped the patient journey from booking to aftercare.")
|
|
2177
|
-
]
|
|
2178
|
-
}
|
|
2179
|
-
},
|
|
2180
|
-
{
|
|
2181
|
-
id: "ai-phrase",
|
|
2182
|
-
name: "AI phrase",
|
|
2183
|
-
generation: 1,
|
|
2184
|
-
severity: "warn",
|
|
2185
|
-
surface: "copy",
|
|
2186
|
-
why: "Let's dive in, here's the thing, at its core, seamless, rest assured: the phrase list of the house copy rules. A reader has seen each one in a thousand generated pages.",
|
|
2187
|
-
fix: "Cut the phrase and start with the point it was introducing. 'Seamless booking' becomes 'Book in three taps'.",
|
|
2188
|
-
detect: phrases(
|
|
2189
|
-
[
|
|
2190
|
-
wordList(AI_PHRASES),
|
|
2191
|
-
/\bin today['’]?s (?:rapidly )?(?:evolving|changing|fast-paced)\b/i,
|
|
2192
|
-
/\bwhether you['’]re an? [^.,;]{2,30} or\b/i
|
|
2193
|
-
],
|
|
2194
|
-
quoted
|
|
2195
|
-
),
|
|
2196
|
-
fixtures: {
|
|
2197
|
-
flag: [
|
|
2198
|
-
f2("content/home.md", "Let's dive in to what we do."),
|
|
2199
|
-
f2("content/home.md", "In today's rapidly evolving market, we help."),
|
|
2200
|
-
f2("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
2201
|
-
],
|
|
2202
|
-
pass: [
|
|
2203
|
-
f2("content/home.md", "Book in three taps. We confirm by text within the hour."),
|
|
2204
|
-
f2("content/home.md", "Book at https://example.com/seamless-booking today."),
|
|
2205
|
-
f2("content/home.md", "[Book a slot](/seamless-booking) today.")
|
|
2206
|
-
]
|
|
2207
|
-
}
|
|
2208
|
-
},
|
|
2209
|
-
{
|
|
2210
|
-
id: "plainer-word",
|
|
2211
|
-
name: "A plainer word exists",
|
|
2212
|
-
generation: 1,
|
|
2213
|
-
severity: "warn",
|
|
2214
|
-
surface: "copy",
|
|
2215
|
-
why: "Empower, leverage, harness, delve: each stands in for a plainer verb, and the swap is a reliable sign nobody chose the word.",
|
|
2216
|
-
fix: "Name the mechanism instead. 'Empower your team' becomes 'Your team can publish without us'.",
|
|
2217
|
-
detect: phrases([wordList(PLAINER_WORDS)], quoted),
|
|
2218
|
-
fixtures: {
|
|
2219
|
-
flag: [f2("content/home.md", "We empower small firms to grow.")],
|
|
2220
|
-
pass: [f2("content/home.md", "Small firms can publish without waiting for us.")]
|
|
2221
|
-
}
|
|
2222
|
-
},
|
|
2223
|
-
{
|
|
2224
|
-
id: "plain-english",
|
|
2225
|
-
name: "'Plain English'",
|
|
2226
|
-
generation: 2,
|
|
2227
|
-
severity: "warn",
|
|
2228
|
-
surface: "copy",
|
|
2229
|
-
why: "Told to avoid jargon, a model announces that it is avoiding jargon. 'Explained plainly' swaps a synonym and keeps the tell.",
|
|
2230
|
-
fix: "Use the house replacement, 'properly explained', or drop the claim and let the copy show it.",
|
|
2231
|
-
detect: phrases([/\b(?:plain english|explained plainly|plainly explained)\b/i], quoted),
|
|
2232
|
-
fixtures: {
|
|
2233
|
-
flag: [f2("content/home.md", "Your options, in plain English.")],
|
|
2234
|
-
pass: [f2("content/home.md", "Your options, properly explained.")]
|
|
2235
|
-
}
|
|
2236
|
-
},
|
|
2237
|
-
{
|
|
2238
|
-
id: "buzzword",
|
|
2239
|
-
name: "Buzzword",
|
|
2240
|
-
generation: 1,
|
|
2241
|
-
severity: "warn",
|
|
2242
|
-
surface: "copy",
|
|
2243
|
-
why: "Cutting-edge, world-class, reach out, turnkey: corporate filler older than any model, and still the first thing a model writes about a business it knows nothing about.",
|
|
2244
|
-
fix: "Say what is actually true. 'World-class service' becomes 'We answer the phone until 8pm'. 'Reach out' becomes 'get in touch'.",
|
|
2245
|
-
detect: phrases([wordList(BUZZWORDS)], quoted),
|
|
2246
|
-
fixtures: {
|
|
2247
|
-
flag: [f2("content/home.md", "Reach out for world-class plumbing.")],
|
|
2248
|
-
pass: [f2("content/home.md", "Get in touch. We answer until 8pm.")]
|
|
2249
|
-
}
|
|
2250
|
-
},
|
|
2251
|
-
{
|
|
2252
|
-
id: "negative-reassurance",
|
|
2253
|
-
name: "Negative reassurance",
|
|
2254
|
-
generation: 1,
|
|
2255
|
-
severity: "warn",
|
|
2256
|
-
surface: "copy",
|
|
2257
|
-
why: "'No hidden fees', 'no surprises', 'never locked in' reassure by naming the fear, and plant it in a reader who did not have it.",
|
|
2258
|
-
fix: "Say what they get: 'The price on the quote is the price you pay', 'Cancel any month'.",
|
|
2259
|
-
detect: phrases([wordList(NEGATIVE_REASSURANCE)], quoted),
|
|
2260
|
-
fixtures: {
|
|
2261
|
-
flag: [f2("content/home.md", "Clear pricing with no hidden fees.")],
|
|
2262
|
-
pass: [f2("content/home.md", "The price on the quote is the price you pay.")]
|
|
2263
|
-
}
|
|
2264
|
-
},
|
|
2265
|
-
{
|
|
2266
|
-
id: "vague-word",
|
|
2267
|
-
name: "Vague word",
|
|
2268
|
-
generation: 1,
|
|
2269
|
-
severity: "warn",
|
|
2270
|
-
surface: "copy",
|
|
2271
|
-
why: "Innovative, scalable, end-to-end, solutions: each can be true, and each is used where the writer had nothing specific to say.",
|
|
2272
|
-
fix: "Replace it with the specific thing. 'Innovative solutions' becomes the actual product and what it does.",
|
|
2273
|
-
detect: phrases([wordList(VAGUE_WORDS)], quoted),
|
|
2274
|
-
fixtures: {
|
|
2275
|
-
flag: [f2("content/home.md", "Innovative solutions for every business.")],
|
|
2276
|
-
pass: [f2("content/home.md", "Rewires, fuse boards and EV chargers across Northamptonshire.")]
|
|
2277
|
-
}
|
|
2278
|
-
},
|
|
2279
|
-
{
|
|
2280
|
-
id: "review-phrase",
|
|
2281
|
-
name: "Review-tier phrase",
|
|
2282
|
-
generation: 1,
|
|
2283
|
-
severity: "warn",
|
|
2284
|
-
surface: "copy",
|
|
2285
|
-
why: "Truly, genuinely, ultimately, 'the single most', 'bar none': normal English once, and a tell when they stack. The house rules keep them for review, never blocking.",
|
|
2286
|
-
fix: "Keep the one that lands hardest and cut the rest. 'What is genuinely excellent' reads better as 'what is excellent'.",
|
|
2287
|
-
detect: phrases([new RegExp(`(?:^|\\b)(?:${[...REVIEW_PHRASES].sort((a, b) => b.length - a.length).map((p) => escape(p).replace(/^(\w)/, "\\b$1")).join("|")})`, "i")], quoted),
|
|
2288
|
-
fixtures: {
|
|
2289
|
-
flag: [f2("content/home.md", "We are truly proud of it.")],
|
|
2290
|
-
pass: [f2("content/home.md", "We have fitted 400 boilers since 2009.")]
|
|
2291
|
-
}
|
|
2292
|
-
},
|
|
2293
|
-
{
|
|
2294
|
-
id: "hollow-imperative",
|
|
2295
|
-
name: "Hollow imperative",
|
|
2296
|
-
generation: 1,
|
|
2297
|
-
severity: "warn",
|
|
2298
|
-
surface: "copy",
|
|
2299
|
-
why: "'Discover', 'Experience', 'Transform your' as the opening verb of a heading or button is a call to action with nothing in it.",
|
|
2300
|
-
fix: "Name the action and the outcome: 'Book a 45-minute facial', not 'Experience the difference'.",
|
|
2301
|
-
detect: phrases(
|
|
2302
|
-
[/(?:^|[.!?]\s+|\n\s*|>\s*)(?:Discover|Experience|Explore|Transform|Indulge in|Embrace|Unlock|Elevate)\s+(?:the|your|our|a|true|real)\b[^.!?\n<]{0,40}/],
|
|
2303
|
-
(m) => `"${m.replace(/^[.!?>\s]+/, "")}"`
|
|
2304
|
-
),
|
|
2305
|
-
fixtures: {
|
|
2306
|
-
flag: [f2("content/home.md", "Experience the difference today.")],
|
|
2307
|
-
pass: [f2("content/home.md", "Book a 45-minute facial.")]
|
|
2308
|
-
}
|
|
2309
|
-
},
|
|
2310
|
-
{
|
|
2311
|
-
id: "rhetorical-opener",
|
|
2312
|
-
name: "Rhetorical question opener",
|
|
2313
|
-
generation: 1,
|
|
2314
|
-
severity: "warn",
|
|
2315
|
-
surface: "copy",
|
|
2316
|
-
why: "'Looking for...?', 'Tired of...?', 'Ready to...?' opens with the model guessing at the reader instead of telling them something.",
|
|
2317
|
-
fix: "Open with the answer.",
|
|
2318
|
-
detect: phrases([/(?:^|\n\s*|>\s*)(?:Looking for|Tired of|Ready to|Want to|Struggling with|Need a)\b[^?\n<]{0,80}\?/], (m) => `"${m.replace(/^[>\s]+/, "")}"`),
|
|
2319
|
-
fixtures: {
|
|
2320
|
-
flag: [f2("content/home.md", "Looking for a reliable plumber?")],
|
|
2321
|
-
pass: [f2("content/home.md", "A plumber in Brackley, on call until 10pm.")]
|
|
2322
|
-
}
|
|
2323
|
-
},
|
|
2324
|
-
{
|
|
2325
|
-
id: "em-dash",
|
|
2326
|
-
name: "Em dash",
|
|
2327
|
-
generation: 2,
|
|
2328
|
-
severity: "warn",
|
|
2329
|
-
surface: "copy",
|
|
2330
|
-
why: "Generated copy leans on the em dash to join clauses. House copy does not use it.",
|
|
2331
|
-
fix: "Use a full stop, a comma, or a colon. Two sentences are clearer than one sentence with a dash in it.",
|
|
2332
|
-
detect: phrases([/\S{0,20}\s?—\s?\S{0,20}/], quoted, (ctx) => ctx.strings, (block) => placeholderDash(block)),
|
|
2333
|
-
fixtures: {
|
|
2334
|
-
flag: [
|
|
2335
|
-
f2("content/home.md", "Walk-ins welcome \u2014 just ring first."),
|
|
2336
|
-
f2("app/hours.tsx", `export const hours = { weekdays: "Mon \u2014 Fri" };`)
|
|
2337
|
-
],
|
|
2338
|
-
pass: [
|
|
2339
|
-
f2("content/home.md", "Walk-ins welcome. Ring first. Open 9-5."),
|
|
2340
|
-
f2("app/row.tsx", `export const Cell = ({ v }) => <td>{v ?? "\u2014"}</td>;`),
|
|
2341
|
-
f2("app/row.tsx", `export const Empty = () => <td className="muted">\u2014</td>;`)
|
|
2342
|
-
]
|
|
2343
|
-
}
|
|
2344
|
-
},
|
|
2345
|
-
{
|
|
2346
|
-
id: "emoji",
|
|
2347
|
-
name: "Emoji in copy",
|
|
2348
|
-
generation: 1,
|
|
2349
|
-
severity: "warn",
|
|
2350
|
-
surface: "copy",
|
|
2351
|
-
why: "A sparkle or a rocket beside a heading is decoration a model adds to seem friendly. It dates the page and reads as a social post.",
|
|
2352
|
-
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
2353
|
-
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
2354
|
-
fixtures: {
|
|
2355
|
-
flag: [
|
|
2356
|
-
f2("content/home.md", "New treatments this month \u2728\u{1F680}"),
|
|
2357
|
-
f2("app/cta.tsx", `export const label = "Book \u{1F680}";`),
|
|
2358
|
-
f2("content/home.md", "New treatments this month \u2728"),
|
|
2359
|
-
f2("content/home.md", "Same-day callouts \u2705"),
|
|
2360
|
-
f2("content/home.md", "Open Sundays \u2600\uFE0F")
|
|
2361
|
-
],
|
|
2362
|
-
pass: [f2("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f2("content/home.md", "\u2713 Gas Safe registered")]
|
|
2363
|
-
}
|
|
2364
|
-
},
|
|
2365
|
-
{
|
|
2366
|
-
id: "not-just-but",
|
|
2367
|
-
name: "Contrastive negation",
|
|
2368
|
-
generation: 2,
|
|
2369
|
-
severity: "warn",
|
|
2370
|
-
surface: "copy",
|
|
2371
|
-
why: "'It's not just a haircut, it's an experience', 'dispatched from here, not shipped in': the contrast-and-reveal template is the most recognisable construction in generated copy.",
|
|
2372
|
-
fix: "Say the positive claim and drop the contrast.",
|
|
2373
|
-
detect: phrases(NEGATION, quoted, allText),
|
|
2374
|
-
fixtures: {
|
|
2375
|
-
flag: [
|
|
2376
|
-
f2("content/home.md", "It's not just a haircut, it's an experience."),
|
|
2377
|
-
f2("content/home.md", "It's not a website, it's a growth engine."),
|
|
2378
|
-
f2("content/home.md", "I don't teach the treatment you'd choose: I teach the standard."),
|
|
2379
|
-
f2("content/home.md", "Hairdressing isn't a job, it's art."),
|
|
2380
|
-
f2("content/home.md", "It is less about the tools and more about the eye."),
|
|
2381
|
-
f2("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
|
|
2382
|
-
f2("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
|
|
2383
|
-
],
|
|
2384
|
-
pass: [
|
|
2385
|
-
f2("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
|
|
2386
|
-
f2("content/home.md", "They are cosmetics, not medicines."),
|
|
2387
|
-
f2("content/home.md", "The box arrived red, not led astray by a dented corner.")
|
|
2388
|
-
]
|
|
2389
|
-
}
|
|
2390
|
-
},
|
|
2391
|
-
{
|
|
2392
|
-
id: "no-x-no-y",
|
|
2393
|
-
name: "'No X, no Y' list",
|
|
2394
|
-
generation: 2,
|
|
2395
|
-
severity: "warn",
|
|
2396
|
-
surface: "copy",
|
|
2397
|
-
why: "'No obligation, no spam.' Defining the business by what it is not, itemised, is the same template as contrastive negation.",
|
|
2398
|
-
fix: "Say what they get: 'We reply within a day and only about your enquiry'.",
|
|
2399
|
-
detect: phrases([NO_X_NO_Y], quoted, allText, emptyState),
|
|
2400
|
-
fixtures: {
|
|
2401
|
-
flag: [
|
|
2402
|
-
f2("content/home.md", "We reply within a day. No obligation, no spam."),
|
|
2403
|
-
f2("app/hero.tsx", `export const Hero = () => <p className="mt-4">We reply within a day. No
|
|
2404
|
-
obligation, no spam.</p>;`)
|
|
2405
|
-
],
|
|
2406
|
-
pass: [
|
|
2407
|
-
f2("content/home.md", "We reply within a day, and only about your enquiry."),
|
|
2408
|
-
f2("app/admin/list.tsx", `export const Empty = () => <p>No clients, no bookings.</p>;`),
|
|
2409
|
-
f2("app/list.tsx", `export const Empty = () => <p>No orders yet, no invoices found.</p>;`)
|
|
2410
|
-
]
|
|
2411
|
-
}
|
|
2412
|
-
},
|
|
2413
|
-
{
|
|
2414
|
-
id: "no-x-badge",
|
|
2415
|
-
name: "'No X' badge",
|
|
2416
|
-
generation: 2,
|
|
2417
|
-
severity: "warn",
|
|
2418
|
-
surface: "copy",
|
|
2419
|
-
why: "'No catch.', 'NO JARGON GUIDE': a short standalone 'No X' reads as a slapped-on kicker, and worse when the same one is reused across pieces.",
|
|
2420
|
-
fix: "Replace it with the positive fact it hints at, or cut it.",
|
|
2421
|
-
detect: badgeHits,
|
|
2422
|
-
fixtures: {
|
|
2423
|
-
flag: [f2("app/badge.tsx", `export const kicker = "No catch.";`), f2("content/home.md", "Straight answers.\n\nNo jargon guide\n")],
|
|
2424
|
-
pass: [
|
|
2425
|
-
f2("app/list.tsx", `export const empty = "No results found.";`),
|
|
2426
|
-
f2("app/portal/list.tsx", `export const empty = "No bookings";`),
|
|
2427
|
-
f2("content/home.md", "### No clarity on ideal client\n")
|
|
2428
|
-
]
|
|
2429
|
-
}
|
|
2430
|
-
},
|
|
2431
|
-
{
|
|
2432
|
-
id: "staccato-triplet",
|
|
2433
|
-
name: "Staccato triplet",
|
|
2434
|
-
generation: 2,
|
|
2435
|
-
severity: "warn",
|
|
2436
|
-
surface: "copy",
|
|
2437
|
-
why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
|
|
2438
|
-
fix: "Write one sentence that says which of the three is true, and how you know.",
|
|
2439
|
-
// The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
|
|
2440
|
-
detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
|
|
2441
|
-
fixtures: {
|
|
2442
|
-
flag: [f2("content/home.md", "No fuss. No jargon. Just results."), f2("content/home.md", "No fuss.\nNo jargon.\nJust results.")],
|
|
2443
|
-
pass: [
|
|
2444
|
-
f2("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
|
|
2445
|
-
f2("content/guide.md", "Your competitor appears in the top three. You don't. Again.\n\nIt's frustrating.")
|
|
2446
|
-
]
|
|
2447
|
-
}
|
|
2448
|
-
},
|
|
2449
|
-
{
|
|
2450
|
-
id: "where-x-meets-y",
|
|
2451
|
-
name: "'Where X meets Y'",
|
|
2452
|
-
generation: 2,
|
|
2453
|
-
severity: "warn",
|
|
2454
|
-
surface: "copy",
|
|
2455
|
-
why: "'Where luxury meets comfort' is a tagline shape that fits every business and so describes none.",
|
|
2456
|
-
fix: "Say what the place is and where it is.",
|
|
2457
|
-
detect: phrases([/\bwhere\s+[\w'’-]+(?:\s+[\w'’-]+)?\s+meets?\s+[\w'’-]+/i], quoted),
|
|
2458
|
-
fixtures: {
|
|
2459
|
-
flag: [f2("content/home.md", "Where luxury meets comfort.")],
|
|
2460
|
-
pass: [f2("content/home.md", "A two-chair studio above the bakery on Market Place.")]
|
|
2461
|
-
}
|
|
2462
|
-
}
|
|
2463
|
-
];
|
|
2464
|
-
var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
|
|
2465
|
-
var PROMPT_CONTEXT = [
|
|
2466
|
-
/\b(?:the|this|that)\s+(?:attached|uploaded|provided)\s+(?:(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\s+)?(?:research|research file|research pack|source file|sources)\b/i,
|
|
2467
|
-
/\b(?:attached|uploaded)\s+(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\b/i,
|
|
2468
|
-
/\bthe\s+(?:attached|uploaded|provided)\s+brief\s+(?:asks|says|wants|requests|suggests|mentions)\b/i
|
|
2469
|
-
];
|
|
2470
|
-
function gerundList(block, _match, index) {
|
|
2471
|
-
return /(?:^|[(,;:]\s*)\w+ing\b[^,.;()!?]{0,40}$/.test(block.text.slice(Math.max(0, index - 60), index));
|
|
2472
|
-
}
|
|
2473
|
-
var VAGUE_ATTRIBUTION = [
|
|
2474
|
-
/\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
|
|
2475
|
-
/\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
|
|
2476
|
-
/\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
|
|
2477
|
-
];
|
|
2478
|
-
function sourced(block, _match, index) {
|
|
2479
|
-
const before = block.text.slice(Math.max(0, index - 40), index);
|
|
2480
|
-
if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
|
|
2481
|
-
const rest = block.text.slice(index, index + 400);
|
|
2482
|
-
const end = rest.search(/[.!?](?:\s|$)/);
|
|
2483
|
-
const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
|
|
2484
|
-
return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
|
|
2485
|
-
}
|
|
2486
|
-
var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
|
|
2487
|
-
var FALSE_RANGE = [
|
|
2488
|
-
/\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
|
|
2489
|
-
/\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
|
|
2490
|
-
];
|
|
2491
|
-
var RESIDUE = [
|
|
2492
|
-
/\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
|
|
2493
|
-
/(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
|
|
2494
|
-
/\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
|
|
2495
|
-
/\blet me know if you(?:['’]d| would) like me to\b/i,
|
|
2496
|
-
/\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
|
|
2497
|
-
/\bI hope this helps\b/i,
|
|
2498
|
-
// Citation tokens a chat tool leaves in copied text.
|
|
2499
|
-
// A run of them is one paste: "citeturn11search1turn10view0" is one finding.
|
|
2500
|
-
// ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
|
|
2501
|
-
// which survive a copy and paste into a CMS: found live, 2026-09-23.
|
|
2502
|
-
/[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
|
|
2503
|
-
/:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
|
|
2504
|
-
// Entity markers from the same paste: entity["company","Bark","services
|
|
2505
|
-
// marketplace"] shows on the page where the plain name "Bark" belongs.
|
|
2506
|
-
/[\uE200-\uE2FF]*entity[\uE200-\uE2FF]*\["[a-z_]+"\s*,\s*"[^"\n]{1,80}"(?:\s*,\s*"[^"\n]{0,160}")*\][\uE200-\uE2FF]*/,
|
|
2507
|
-
/【\d+(?:[:†][^】]{0,40})?】/
|
|
2508
|
-
];
|
|
2509
|
-
var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
|
|
2510
|
-
function residueHits(ctx) {
|
|
2511
|
-
const hits = phrases(RESIDUE, quoted, allText)(ctx);
|
|
2512
|
-
for (const file of ctx.files) {
|
|
2513
|
-
for (const m of file.text.matchAll(AI_UTM)) hits.push({ path: file.path, offset: m.index ?? 0, message: `link tracked as copied from a chat assistant: "${m[0]}"` });
|
|
2514
|
-
}
|
|
2515
|
-
return hits;
|
|
2516
|
-
}
|
|
2517
|
-
var PLACEHOLDER = [
|
|
2518
|
-
/\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
|
|
2519
|
-
/\blorem ipsum\b/i,
|
|
2520
|
-
/\bTODO_PLACEHOLDER\b/
|
|
2521
|
-
];
|
|
2522
|
-
var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
|
|
2523
|
-
function insideQuotes(block, _match, index) {
|
|
2524
|
-
const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
|
|
2525
|
-
const before = block.text.slice(lineStart, index);
|
|
2526
|
-
const straight = (before.match(/"/g) ?? []).length;
|
|
2527
|
-
const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
|
|
2528
|
-
return straight % 2 === 1 || curly > 0;
|
|
2529
|
-
}
|
|
2530
|
-
function placeholderHits(ctx) {
|
|
2531
|
-
const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
|
|
2532
|
-
const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
|
|
2533
|
-
for (const file of ctx.files) {
|
|
2534
|
-
if (isProsePath(file.path)) continue;
|
|
2535
|
-
for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
|
|
2536
|
-
const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
|
|
2537
|
-
if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
|
|
2538
|
-
}
|
|
2539
|
-
}
|
|
2540
|
-
return hits;
|
|
2541
|
-
}
|
|
2542
|
-
var QUESTION_REVEAL = [
|
|
2543
|
-
/\b(?:The|Our|What['’]s the|Here['’]s the)\s+(?:result|catch|secret|answer|difference|best part|twist|kicker|bottom line|upshot)\?\s+[A-Z0-9]/,
|
|
2544
|
-
/\?\s+It means\b/
|
|
2545
|
-
];
|
|
2546
|
-
function inlineLabelHits(ctx) {
|
|
2547
|
-
const hits = [];
|
|
2548
|
-
for (const file of ctx.files) {
|
|
2549
|
-
if (!isProsePath(file.path)) continue;
|
|
2550
|
-
const lines = file.text.split("\n");
|
|
2551
|
-
let run = [];
|
|
2552
|
-
let offset = 0;
|
|
2553
|
-
const flush = () => {
|
|
2554
|
-
if (run.length >= 3) hits.push({ path: file.path, offset: run[0], message: `${run.length} bullets in a row open with a bold label and a colon` });
|
|
2555
|
-
run = [];
|
|
2556
|
-
};
|
|
2557
|
-
for (const line of lines) {
|
|
2558
|
-
const m = /^\s*[-*+]\s+\*\*([^*]{1,40}?):?\*\*:?\s+(\S.*)$/.exec(line);
|
|
2559
|
-
const data = m && /[\d£$€%`]|https?:|^\*\*/.test(m[2]);
|
|
2560
|
-
const named = m && /\S[A-Z]|[./]/.test(m[1]);
|
|
2561
|
-
if (m && !data && !named && m[2].trim().split(/\s+/).length <= 6) run.push(offset);
|
|
2562
|
-
else flush();
|
|
2563
|
-
offset += line.length + 1;
|
|
2564
|
-
}
|
|
2565
|
-
flush();
|
|
2566
|
-
}
|
|
2567
|
-
return hits;
|
|
2568
|
-
}
|
|
2569
|
-
var RESEARCH_TELLS = [
|
|
2570
|
-
{
|
|
2571
|
-
id: "chatbot-residue",
|
|
2572
|
-
name: "Chatbot residue",
|
|
2573
|
-
generation: 1,
|
|
2574
|
-
severity: "warn",
|
|
2575
|
-
surface: "copy",
|
|
2576
|
-
why: "'Certainly! Here's a revised version', 'as an AI language model', a citeturn0search0 token, a link tracked utm_source=chatgpt.com: text that exists only because a chat reply was pasted whole. This is evidence, not style.",
|
|
2577
|
-
fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
|
|
2578
|
-
detect: residueHits,
|
|
2579
|
-
fixtures: {
|
|
2580
|
-
flag: [
|
|
2581
|
-
f2("content/home.md", "Certainly! Here's a revised version of your home page."),
|
|
2582
|
-
f2("content/home.md", "As an AI language model, I cannot visit your salon."),
|
|
2583
|
-
f2("content/home.md", "Let me know if you'd like me to shorten it."),
|
|
2584
|
-
f2("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
|
|
2585
|
-
f2("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
|
|
2586
|
-
f2("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
|
|
2587
|
-
f2("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
|
|
2588
|
-
f2("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
|
|
2589
|
-
f2("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
|
|
2590
|
-
f2("content/home.md", "Here's a more concise version for the hero.")
|
|
2591
|
-
],
|
|
2592
|
-
pass: [
|
|
2593
|
-
f2("content/home.md", "Certainly the busiest week of the year: book early."),
|
|
2594
|
-
f2("content/home.md", "Here's the price list for 2026."),
|
|
2595
|
-
f2("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
|
|
2596
|
-
]
|
|
2597
|
-
}
|
|
2598
|
-
},
|
|
2599
|
-
{
|
|
2600
|
-
id: "prompt-context",
|
|
2601
|
-
name: "Prompt context",
|
|
2602
|
-
generation: 1,
|
|
2603
|
-
severity: "warn",
|
|
2604
|
-
surface: "copy",
|
|
2605
|
-
why: "'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell.",
|
|
2606
|
-
fix: "Delete the reference to the research or brief. Where it carried a claim, name the real source or cut the claim.",
|
|
2607
|
-
detect: phrases(PROMPT_CONTEXT, quoted),
|
|
2608
|
-
fixtures: {
|
|
2609
|
-
flag: [
|
|
2610
|
-
f2("content/guide.md", "This article uses the attached Perplexity research file as the primary source."),
|
|
2611
|
-
f2("content/guide.md", "The attached research is right to say these platforms suit early-stage businesses."),
|
|
2612
|
-
f2("content/guide.md", "That is exactly how the uploaded research frames the category."),
|
|
2613
|
-
f2("content/guide.md", "The attached brief asks for the point that most tradespeople miss.")
|
|
2614
|
-
],
|
|
2615
|
-
pass: [
|
|
2616
|
-
f2("content/guide.md", "We research every quote before we send it."),
|
|
2617
|
-
f2("app/email.tsx", `export const Invoice = () => <p>Your invoice is attached.</p>;`),
|
|
2618
|
-
f2("content/guide.md", "BrightLocal's research shows that reviews drive 20% of local ranking.")
|
|
2619
|
-
]
|
|
2620
|
-
}
|
|
2621
|
-
},
|
|
2622
|
-
{
|
|
2623
|
-
id: "placeholder",
|
|
2624
|
-
name: "Unfilled placeholder",
|
|
2625
|
-
generation: 1,
|
|
2626
|
-
severity: "warn",
|
|
2627
|
-
surface: "copy",
|
|
2628
|
-
why: "'[Insert testimonial]', '[Your Name]', lorem ipsum, TODO_PLACEHOLDER: scaffolding a reader must never see, and the clearest sign a page shipped before anyone read it.",
|
|
2629
|
-
fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
|
|
2630
|
-
detect: placeholderHits,
|
|
2631
|
-
fixtures: {
|
|
2632
|
-
flag: [
|
|
2633
|
-
f2("content/home.md", "Rated 5 stars by [Insert client name] and others."),
|
|
2634
|
-
f2("content/home.md", "Lorem ipsum dolor sit amet."),
|
|
2635
|
-
f2("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
|
|
2636
|
-
f2("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
|
|
2637
|
-
],
|
|
2638
|
-
pass: [
|
|
2639
|
-
f2("content/home.md", "Manage bookings in [your account](/account)."),
|
|
2640
|
-
f2("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
|
|
2641
|
-
f2("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
|
|
2642
|
-
f2("content/home.md", "Rated 4.9 from 212 Google reviews.")
|
|
2643
|
-
]
|
|
2644
|
-
}
|
|
2645
|
-
},
|
|
2646
|
-
{
|
|
2647
|
-
id: "question-reveal",
|
|
2648
|
-
name: "Staged reveal",
|
|
2649
|
-
generation: 2,
|
|
2650
|
-
severity: "warn",
|
|
2651
|
-
surface: "copy",
|
|
2652
|
-
why: "'The result? Faster growth.', 'What does this mean for you? It means...': the writer asks the reader's question for them and answers it. An infomercial hook, and a favourite of generated copy.",
|
|
2653
|
-
fix: "State the answer: 'Most customers book again within six weeks.'",
|
|
2654
|
-
detect: phrases(QUESTION_REVEAL, quoted),
|
|
2655
|
-
fixtures: {
|
|
2656
|
-
flag: [f2("content/home.md", "We rebuilt the booking flow. The result? Twice the bookings."), f2("content/home.md", "What does this mean for you? It means fewer missed calls.")],
|
|
2657
|
-
pass: [f2("content/faq.md", "How long does a set last? About three weeks with gel."), f2("content/home.md", "We rebuilt the booking flow, and bookings doubled in a month.")]
|
|
2658
|
-
}
|
|
2659
|
-
},
|
|
2660
|
-
{
|
|
2661
|
-
id: "inline-label-list",
|
|
2662
|
-
name: "Bold-label bullets",
|
|
2663
|
-
generation: 2,
|
|
2664
|
-
severity: "warn",
|
|
2665
|
-
surface: "copy",
|
|
2666
|
-
why: "'**Speed:** Faster pages' three times in a row: a short label restated in a few words, the list shape generated copy defaults to. A definition list, with a real explanation after each label, is not this.",
|
|
2667
|
-
fix: "Drop the labels and write each bullet as the fact: 'Pages load in under a second'. Keep labels only where they help a reader navigate, as in a specification.",
|
|
2668
|
-
detect: inlineLabelHits,
|
|
2669
|
-
fixtures: {
|
|
2670
|
-
flag: [f2("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
|
|
2671
|
-
pass: [
|
|
2672
|
-
f2("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
|
|
2673
|
-
f2("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
|
|
2674
|
-
f2("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n"),
|
|
2675
|
-
f2("content/guide.md", "- **Per page**: \xA3100-\xA3200 per page\n- **Hourly**: \xA350-\xA375/hour\n- **Day rate**: \xA3350-\xA3500\n"),
|
|
2676
|
-
f2("content/guide.md", "- **Law Society Find a Solicitor:** For solicitors\n- **AccountingWEB / ICAEW directories:** For accountants\n- **NHS.uk:** For NHS services\n"),
|
|
2677
|
-
f2("content/guide.md", "1. **Visit PageSpeed Insights** and paste the address\n2. **Enter their URL** and run it\n3. **Note their scores** for later\n"),
|
|
2678
|
-
f2("content/guide.md", "- **Structural problems:** **REBUILD.**\n- **Messaging issues:** **REFRESH.**\n- **Neither:** **LEAVE IT.**\n")
|
|
2679
|
-
]
|
|
2680
|
-
}
|
|
2681
|
-
},
|
|
2682
|
-
{
|
|
2683
|
-
id: "ing-tail",
|
|
2684
|
-
name: "Empty -ing tail",
|
|
2685
|
-
generation: 2,
|
|
2686
|
-
severity: "warn",
|
|
2687
|
-
surface: "copy",
|
|
2688
|
-
why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
|
|
2689
|
-
fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
|
|
2690
|
-
detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, "")), void 0, gerundList),
|
|
2691
|
-
fixtures: {
|
|
2692
|
-
flag: [
|
|
2693
|
-
f2("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
|
|
2694
|
-
f2("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
|
|
2695
|
-
],
|
|
2696
|
-
pass: [
|
|
2697
|
-
f2("content/home.md", "We open at 7am, so you can drop the car off before work."),
|
|
2698
|
-
f2("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers."),
|
|
2699
|
-
f2("content/guide.md", "Many items (claiming your GBP, ensuring NAP consistency, writing title tags) are straightforward.")
|
|
2700
|
-
]
|
|
2701
|
-
}
|
|
2702
|
-
},
|
|
2703
|
-
{
|
|
2704
|
-
id: "vague-attribution",
|
|
2705
|
-
name: "Unnamed source",
|
|
2706
|
-
generation: 1,
|
|
2707
|
-
severity: "warn",
|
|
2708
|
-
surface: "copy",
|
|
2709
|
-
why: "'Studies show', 'experts agree', 'it is widely known': a claim credited to nobody. A reader cannot check it, and the house proof rule forbids a claim nobody can check.",
|
|
2710
|
-
fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
|
|
2711
|
-
detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
|
|
2712
|
-
fixtures: {
|
|
2713
|
-
flag: [
|
|
2714
|
-
f2("content/home.md", "Studies show that regular servicing saves money."),
|
|
2715
|
-
f2("content/home.md", "Most experts agree a boiler lasts fifteen years."),
|
|
2716
|
-
f2("content/home.md", "It is widely known that gel damages nails.")
|
|
2717
|
-
],
|
|
2718
|
-
pass: [
|
|
2719
|
-
f2("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
|
|
2720
|
-
f2("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
|
|
2721
|
-
f2("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
|
|
2722
|
-
f2("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
|
|
2723
|
-
]
|
|
2724
|
-
}
|
|
2725
|
-
},
|
|
2726
|
-
{
|
|
2727
|
-
id: "closing-summary",
|
|
2728
|
-
name: "Closing summary",
|
|
2729
|
-
generation: 1,
|
|
2730
|
-
severity: "warn",
|
|
2731
|
-
surface: "copy",
|
|
2732
|
-
why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
|
|
2733
|
-
fix: "Cut the summary. End on the last new fact, or on the next step: 'Book a survey and we'll quote within two days'.",
|
|
2734
|
-
detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
|
|
2735
|
-
fixtures: {
|
|
2736
|
-
flag: [
|
|
2737
|
-
f2("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
|
|
2738
|
-
f2("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
|
|
2739
|
-
],
|
|
2740
|
-
pass: [f2("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
|
|
2741
|
-
}
|
|
2742
|
-
},
|
|
2743
|
-
{
|
|
2744
|
-
id: "false-range",
|
|
2745
|
-
name: "False range",
|
|
2746
|
-
generation: 1,
|
|
2747
|
-
severity: "warn",
|
|
2748
|
-
surface: "copy",
|
|
2749
|
-
why: "'From first-time buyers to seasoned investors alike' names two ends of no real scale, to sound as if it covers everyone. It describes nobody.",
|
|
2750
|
-
fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
|
|
2751
|
-
detect: phrases(FALSE_RANGE, quoted),
|
|
2752
|
-
fixtures: {
|
|
2753
|
-
flag: [
|
|
2754
|
-
f2("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
|
|
2755
|
-
f2("content/home.md", "Everyone from students to retirees loves our cakes.")
|
|
2756
|
-
],
|
|
2757
|
-
pass: [
|
|
2758
|
-
f2("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
|
|
2759
|
-
f2("content/home.md", "We take on everything from small repairs to full rewires.")
|
|
2760
|
-
]
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
];
|
|
2764
|
-
|
|
2765
1586
|
// src/character/tells/density.ts
|
|
2766
1587
|
var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
|
|
2767
1588
|
var MIN_WORDS = 400;
|
|
@@ -2982,7 +1803,7 @@ function headings(file) {
|
|
|
2982
1803
|
}
|
|
2983
1804
|
function longFiles(ctx) {
|
|
2984
1805
|
const docs = new Map(documents(ctx).map((d) => [d.path, d]));
|
|
2985
|
-
return ctx.files.filter((
|
|
1806
|
+
return ctx.files.filter((f3) => docs.has(f3.path)).map((file) => ({ file, doc: docs.get(file.path) }));
|
|
2986
1807
|
}
|
|
2987
1808
|
var HEADING_SHAPES = [
|
|
2988
1809
|
["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
|
|
@@ -3002,7 +1823,7 @@ function headingShapeHits(ctx) {
|
|
|
3002
1823
|
hits.push({
|
|
3003
1824
|
path: file.path,
|
|
3004
1825
|
offset: found[limit].heading.offset,
|
|
3005
|
-
message: `${found.length} headings share one shape (${[...new Set(found.map((
|
|
1826
|
+
message: `${found.length} headings share one shape (${[...new Set(found.map((f3) => f3.shape))].join(", ")}), allows ${limit}`,
|
|
3006
1827
|
excerpt: found[limit].heading.text
|
|
3007
1828
|
});
|
|
3008
1829
|
}
|
|
@@ -3050,7 +1871,7 @@ function filler(words, seed = 1, options = {}) {
|
|
|
3050
1871
|
for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
|
|
3051
1872
|
return paragraphs.join("\n\n");
|
|
3052
1873
|
}
|
|
3053
|
-
var
|
|
1874
|
+
var f2 = (path, text) => ({ path, text });
|
|
3054
1875
|
var doc = (extra, seed = 1) => `${filler(520, seed)}
|
|
3055
1876
|
|
|
3056
1877
|
${extra}
|
|
@@ -3068,23 +1889,23 @@ var DENSITY_TELLS = [
|
|
|
3068
1889
|
detect: rateHits,
|
|
3069
1890
|
fixtures: {
|
|
3070
1891
|
flag: [
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
1892
|
+
f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
1893
|
+
f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
|
|
1894
|
+
f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
|
|
1895
|
+
f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
|
|
1896
|
+
f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
|
|
1897
|
+
f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
|
|
1898
|
+
f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
|
|
1899
|
+
f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
|
|
1900
|
+
f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
|
|
1901
|
+
f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
|
|
1902
|
+
f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
|
|
1903
|
+
f2("site/index.html", `<html><body><p>${filler(520, 3)}</p><p>${times(6, (i) => `We fitted boiler ${i} rather than patching it.`)}</p></body></html>`)
|
|
3083
1904
|
],
|
|
3084
1905
|
pass: [
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
1906
|
+
f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
1907
|
+
f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
|
|
1908
|
+
f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
|
|
3088
1909
|
]
|
|
3089
1910
|
}
|
|
3090
1911
|
},
|
|
@@ -3098,8 +1919,8 @@ var DENSITY_TELLS = [
|
|
|
3098
1919
|
fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
|
|
3099
1920
|
detect: aphorismHits,
|
|
3100
1921
|
fixtures: {
|
|
3101
|
-
flag: [
|
|
3102
|
-
pass: [
|
|
1922
|
+
flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
|
|
1923
|
+
pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
|
|
3103
1924
|
}
|
|
3104
1925
|
},
|
|
3105
1926
|
{
|
|
@@ -3112,8 +1933,8 @@ var DENSITY_TELLS = [
|
|
|
3112
1933
|
fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
|
|
3113
1934
|
detect: contractionHits,
|
|
3114
1935
|
fixtures: {
|
|
3115
|
-
flag: [
|
|
3116
|
-
pass: [
|
|
1936
|
+
flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
|
|
1937
|
+
pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
|
|
3117
1938
|
}
|
|
3118
1939
|
},
|
|
3119
1940
|
{
|
|
@@ -3126,8 +1947,8 @@ var DENSITY_TELLS = [
|
|
|
3126
1947
|
fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
|
|
3127
1948
|
detect: rhythmHits,
|
|
3128
1949
|
fixtures: {
|
|
3129
|
-
flag: [
|
|
3130
|
-
pass: [
|
|
1950
|
+
flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
|
|
1951
|
+
pass: [f2("content/proposal.md", filler(600, 7))]
|
|
3131
1952
|
}
|
|
3132
1953
|
},
|
|
3133
1954
|
{
|
|
@@ -3141,12 +1962,12 @@ var DENSITY_TELLS = [
|
|
|
3141
1962
|
detect: repeatHits,
|
|
3142
1963
|
fixtures: {
|
|
3143
1964
|
flag: [
|
|
3144
|
-
|
|
3145
|
-
|
|
1965
|
+
f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
|
|
1966
|
+
f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
|
|
3146
1967
|
],
|
|
3147
1968
|
pass: [
|
|
3148
|
-
|
|
3149
|
-
|
|
1969
|
+
f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet.")),
|
|
1970
|
+
f2(
|
|
3150
1971
|
"content/guide.md",
|
|
3151
1972
|
doc(
|
|
3152
1973
|
"The Independent Healthcare Providers Network Private Patient Insight Report 2024 found that private demand rose.[^1]\n\n[^1]: Independent Healthcare Providers Network, Private Patient Insight Report 2024, published online."
|
|
@@ -3166,11 +1987,11 @@ var DENSITY_TELLS = [
|
|
|
3166
1987
|
detect: headingShapeHits,
|
|
3167
1988
|
fixtures: {
|
|
3168
1989
|
flag: [
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
1990
|
+
f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
|
|
1991
|
+
f2("site/index.html", `<h2>Fast, because we care</h2><h2>Tidy, because it matters</h2><h2>Early, because you asked</h2><h2>Quiet, because neighbours</h2><p>${filler(520, 9)}</p>`),
|
|
1992
|
+
f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
|
|
3172
1993
|
],
|
|
3173
|
-
pass: [
|
|
1994
|
+
pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
|
|
3174
1995
|
}
|
|
3175
1996
|
},
|
|
3176
1997
|
{
|
|
@@ -3184,15 +2005,15 @@ var DENSITY_TELLS = [
|
|
|
3184
2005
|
detect: headingEchoHits,
|
|
3185
2006
|
fixtures: {
|
|
3186
2007
|
flag: [
|
|
3187
|
-
|
|
2008
|
+
f2("content/proposal.md", `## We fix boilers the same day
|
|
3188
2009
|
|
|
3189
2010
|
We fix boilers the same day across Brackley.
|
|
3190
2011
|
|
|
3191
2012
|
${filler(520, 11)}`),
|
|
3192
|
-
|
|
2013
|
+
f2("site/index.html", `<h2>We fix boilers the same day</h2>
|
|
3193
2014
|
<p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
|
|
3194
2015
|
],
|
|
3195
|
-
pass: [
|
|
2016
|
+
pass: [f2("content/proposal.md", `## We fix boilers the same day
|
|
3196
2017
|
|
|
3197
2018
|
Call before noon and an engineer is with you by six.
|
|
3198
2019
|
|
|
@@ -3270,384 +2091,6 @@ function makeSnapshot(patch = {}) {
|
|
|
3270
2091
|
return out;
|
|
3271
2092
|
}
|
|
3272
2093
|
|
|
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
2094
|
// src/character/tells/specificity.ts
|
|
3652
2095
|
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3653
2096
|
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
@@ -3739,8 +2182,8 @@ var PROOF_NAMES = [
|
|
|
3739
2182
|
"Yell",
|
|
3740
2183
|
"Houzz"
|
|
3741
2184
|
];
|
|
3742
|
-
var
|
|
3743
|
-
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(
|
|
2185
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2186
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape).join("|")})(?![\\w])`);
|
|
3744
2187
|
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
3745
2188
|
var PROOF_PATTERNS = [
|
|
3746
2189
|
PROOF_NAME,
|
|
@@ -3804,7 +2247,7 @@ var SPECIFICITY_COPY_TELLS = [
|
|
|
3804
2247
|
},
|
|
3805
2248
|
rendered: {
|
|
3806
2249
|
detect: (s) => {
|
|
3807
|
-
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
2250
|
+
if (!s.firstScreenText || !(s.home ?? HOME_URL.test(s.url))) return [];
|
|
3808
2251
|
const text = s.firstScreenText.join("\n");
|
|
3809
2252
|
if (!text.trim() || !specificity(text).generic) return [];
|
|
3810
2253
|
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
@@ -3937,8 +2380,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
|
3937
2380
|
function reflexFonts(list) {
|
|
3938
2381
|
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
3939
2382
|
return (s) => {
|
|
3940
|
-
const total = s.fonts.reduce((n,
|
|
3941
|
-
const display = s.fonts.reduce((n,
|
|
2383
|
+
const total = s.fonts.reduce((n, f3) => n + f3.chars, 0) || 1;
|
|
2384
|
+
const display = s.fonts.reduce((n, f3) => n + f3.displayChars, 0) || 1;
|
|
3942
2385
|
const hits = [];
|
|
3943
2386
|
const seen = /* @__PURE__ */ new Set();
|
|
3944
2387
|
const h1 = s.headings.find((h) => h.level === 1);
|
|
@@ -4576,8 +3019,8 @@ function report(tells, files, run, options) {
|
|
|
4576
3019
|
};
|
|
4577
3020
|
}
|
|
4578
3021
|
function applyHouseGate(result) {
|
|
4579
|
-
const findings = result.findings.map((
|
|
4580
|
-
return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((
|
|
3022
|
+
const findings = result.findings.map((f3) => f3.house === "block" ? { ...f3, severity: "block" } : f3);
|
|
3023
|
+
return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f3) => f3.severity === "block").length } };
|
|
4581
3024
|
}
|
|
4582
3025
|
function scanSource(files, options = {}) {
|
|
4583
3026
|
const ctx = { files: files.map(parseFile) };
|
|
@@ -4609,17 +3052,6 @@ export {
|
|
|
4609
3052
|
isCream,
|
|
4610
3053
|
REFLEX_FONTS_1,
|
|
4611
3054
|
REFLEX_FONTS_2,
|
|
4612
|
-
fileKind,
|
|
4613
|
-
extractCopy,
|
|
4614
|
-
extractStrings,
|
|
4615
|
-
visibleText,
|
|
4616
|
-
protectedFacts,
|
|
4617
|
-
compareFacts,
|
|
4618
|
-
formatComparison,
|
|
4619
|
-
TRADE_NOUNS,
|
|
4620
|
-
pageCopy,
|
|
4621
|
-
compareCompetitor,
|
|
4622
|
-
formatCompetitor,
|
|
4623
3055
|
houseRule,
|
|
4624
3056
|
HOUSE_LOCKED,
|
|
4625
3057
|
holdHouseBlocks,
|
|
@@ -4633,6 +3065,7 @@ export {
|
|
|
4633
3065
|
NULL_VERSION,
|
|
4634
3066
|
nullPrompt,
|
|
4635
3067
|
extractHtml,
|
|
3068
|
+
MIN_RUNS,
|
|
4636
3069
|
typicality,
|
|
4637
3070
|
hueName,
|
|
4638
3071
|
groundName,
|