@domandigital/craft 0.6.0 → 0.8.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 +36 -0
- package/CHARACTER.md +280 -20
- package/COPY.md +280 -0
- package/README.md +12 -0
- package/dist/audit-W57YDZ2K.js +20 -0
- package/dist/audit.cjs +901 -103
- package/dist/audit.d.cts +16 -1
- package/dist/audit.d.ts +16 -1
- package/dist/audit.js +899 -102
- package/dist/chunk-35OX6C4H.js +112 -0
- package/dist/{chunk-GWMSSRIQ.js → chunk-CZHRNPDD.js} +1521 -934
- package/dist/chunk-K253ZXB3.js +458 -0
- package/dist/{chunk-HYGFP3LB.js → chunk-OGSYVCMU.js} +40 -3
- package/dist/chunk-OVOH7KZY.js +163 -0
- package/dist/{cli-7ME4VIRG.js → chunk-Q4MN3OXF.js} +50 -101
- package/dist/chunk-ULYKASLA.js +175 -0
- package/dist/chunk-X65RDW65.js +364 -0
- package/dist/cli-F7ONSKZS.js +21 -0
- package/dist/cli-FKJOMOOF.js +15 -0
- package/dist/{cli-F7W4F3ER.js → cli-KA3JDNDH.js} +14 -168
- package/dist/cli-LZ7R6KJM.js +312 -0
- package/dist/cli-SHLNRHK6.js +16 -0
- package/dist/cli.js +2 -1
- package/dist/index.cjs +1528 -99
- package/dist/index.d.cts +365 -4
- package/dist/index.d.ts +365 -4
- package/dist/index.js +1493 -96
- package/package.json +4 -2
- package/word-lists.json +164 -0
|
@@ -1,3 +1,334 @@
|
|
|
1
|
+
// src/character/parse.ts
|
|
2
|
+
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
3
|
+
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
4
|
+
var CSS = /\.(css|scss|sass|less|pcss)$/i;
|
|
5
|
+
var PROSE = /\.(md|markdown|txt)$/i;
|
|
6
|
+
function fileKind(path) {
|
|
7
|
+
if (MARKUP.test(path)) return "markup";
|
|
8
|
+
if (CSS.test(path)) return "css";
|
|
9
|
+
if (SCRIPT.test(path)) return "script";
|
|
10
|
+
if (PROSE.test(path)) return "prose";
|
|
11
|
+
return "other";
|
|
12
|
+
}
|
|
13
|
+
function lineAt(text, offset) {
|
|
14
|
+
let line = 1;
|
|
15
|
+
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
16
|
+
if (text.charCodeAt(i) === 10) line += 1;
|
|
17
|
+
}
|
|
18
|
+
return line;
|
|
19
|
+
}
|
|
20
|
+
function excerptAt(text, offset, max = 140) {
|
|
21
|
+
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
22
|
+
const endRaw = text.indexOf("\n", offset);
|
|
23
|
+
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
24
|
+
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
25
|
+
}
|
|
26
|
+
var LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
|
|
27
|
+
function stringLiterals(text, base = 0) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const m of text.matchAll(LITERAL)) {
|
|
30
|
+
out.push({ value: m[0].slice(1, -1), offset: base + (m.index ?? 0) });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function matchBracket(text, open) {
|
|
35
|
+
const pairs = { "{": "}", "(": ")", "[": "]" };
|
|
36
|
+
const close = pairs[text[open]];
|
|
37
|
+
const opener = text[open];
|
|
38
|
+
let depth = 0;
|
|
39
|
+
for (let i = open; i < text.length; i += 1) {
|
|
40
|
+
const ch = text[i];
|
|
41
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
42
|
+
const end = skipString(text, i);
|
|
43
|
+
i = end - 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === opener) depth += 1;
|
|
47
|
+
else if (ch === close) {
|
|
48
|
+
depth -= 1;
|
|
49
|
+
if (depth === 0) return i + 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return text.length;
|
|
53
|
+
}
|
|
54
|
+
function skipString(text, start) {
|
|
55
|
+
const quote = text[start];
|
|
56
|
+
for (let i = start + 1; i < text.length; i += 1) {
|
|
57
|
+
if (text[i] === "\\") {
|
|
58
|
+
i += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (text[i] === quote) return i + 1;
|
|
62
|
+
if (text[i] === "\n" && quote !== "`") return i + 1;
|
|
63
|
+
}
|
|
64
|
+
return text.length;
|
|
65
|
+
}
|
|
66
|
+
function split(classes) {
|
|
67
|
+
return classes.split(/\s+/).filter((c) => c.length > 0 && !c.includes("${"));
|
|
68
|
+
}
|
|
69
|
+
function classUnits(text) {
|
|
70
|
+
const units = [];
|
|
71
|
+
const covered = [];
|
|
72
|
+
const attr = /\b(?:className|class|:class)\s*=\s*/g;
|
|
73
|
+
for (const m of text.matchAll(attr)) {
|
|
74
|
+
const at = (m.index ?? 0) + m[0].length;
|
|
75
|
+
const ch = text[at];
|
|
76
|
+
if (ch === '"' || ch === "'") {
|
|
77
|
+
const end = skipString(text, at);
|
|
78
|
+
units.push({ classes: split(text.slice(at + 1, end - 1)), offset: m.index ?? 0 });
|
|
79
|
+
} else if (ch === "{") {
|
|
80
|
+
const end = matchBracket(text, at);
|
|
81
|
+
const joined = stringLiterals(text.slice(at, end)).map((l) => l.value).join(" ");
|
|
82
|
+
units.push({ classes: split(joined), offset: m.index ?? 0 });
|
|
83
|
+
covered.push([at, end]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const call = /\b(cn|clsx|twMerge|cx|cva|tv)\s*\(/g;
|
|
87
|
+
for (const m of text.matchAll(call)) {
|
|
88
|
+
const start = m.index ?? 0;
|
|
89
|
+
if (covered.some(([a, b]) => start >= a && start < b)) continue;
|
|
90
|
+
const open = start + m[0].length - 1;
|
|
91
|
+
const end = matchBracket(text, open);
|
|
92
|
+
const literals = stringLiterals(text.slice(open, end), open);
|
|
93
|
+
if (m[1] === "cva" || m[1] === "tv") {
|
|
94
|
+
for (const l of literals) units.push({ classes: split(l.value), offset: l.offset });
|
|
95
|
+
} else {
|
|
96
|
+
units.push({ classes: split(literals.map((l) => l.value).join(" ")), offset: start });
|
|
97
|
+
}
|
|
98
|
+
covered.push([open, end]);
|
|
99
|
+
}
|
|
100
|
+
return units.filter((u) => u.classes.length > 0);
|
|
101
|
+
}
|
|
102
|
+
function blankComments(css) {
|
|
103
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
|
|
104
|
+
}
|
|
105
|
+
function cssDeclarations(css, base = 0) {
|
|
106
|
+
const src = blankComments(css);
|
|
107
|
+
const out = [];
|
|
108
|
+
const stack = [];
|
|
109
|
+
let segStart = 0;
|
|
110
|
+
let parens = 0;
|
|
111
|
+
const flush = (end) => {
|
|
112
|
+
const seg = src.slice(segStart, end);
|
|
113
|
+
const colon = seg.indexOf(":");
|
|
114
|
+
if (stack.length === 0 || colon === -1) return;
|
|
115
|
+
const property = seg.slice(0, colon).trim();
|
|
116
|
+
if (!/^(--)?[a-zA-Z-]+$/.test(property)) return;
|
|
117
|
+
const lead = seg.length - seg.trimStart().length;
|
|
118
|
+
out.push({
|
|
119
|
+
property: property.toLowerCase(),
|
|
120
|
+
value: seg.slice(colon + 1).trim(),
|
|
121
|
+
selector: stack[stack.length - 1],
|
|
122
|
+
offset: base + segStart + lead
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
126
|
+
const ch = src[i];
|
|
127
|
+
if (ch === '"' || ch === "'") {
|
|
128
|
+
i = skipString(src, i) - 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === "(") parens += 1;
|
|
132
|
+
else if (ch === ")") parens = Math.max(0, parens - 1);
|
|
133
|
+
if (parens > 0) continue;
|
|
134
|
+
if (ch === "{") {
|
|
135
|
+
stack.push(src.slice(segStart, i).trim());
|
|
136
|
+
segStart = i + 1;
|
|
137
|
+
} else if (ch === ";") {
|
|
138
|
+
flush(i);
|
|
139
|
+
segStart = i + 1;
|
|
140
|
+
} else if (ch === "}") {
|
|
141
|
+
flush(i);
|
|
142
|
+
stack.pop();
|
|
143
|
+
segStart = i + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function styleBlocks(text) {
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
151
|
+
const bodyStart = (m.index ?? 0) + m[0].indexOf(">") + 1;
|
|
152
|
+
out.push(...cssDeclarations(m[1], bodyStart));
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function parseFile(file) {
|
|
157
|
+
const kind = fileKind(file.path);
|
|
158
|
+
return {
|
|
159
|
+
...file,
|
|
160
|
+
kind,
|
|
161
|
+
classUnits: kind === "markup" || kind === "script" ? classUnits(file.text) : [],
|
|
162
|
+
declarations: kind === "css" ? cssDeclarations(file.text) : kind === "markup" ? styleBlocks(file.text) : []
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/character/prose.ts
|
|
167
|
+
var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
168
|
+
function readsAsProse(value) {
|
|
169
|
+
if (value.length < 12 || !/\s/.test(value)) return false;
|
|
170
|
+
if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
|
|
171
|
+
const words = withoutMarkup(value);
|
|
172
|
+
if (looksLikeCode(words) || /[<>]/.test(words)) return false;
|
|
173
|
+
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words);
|
|
174
|
+
}
|
|
175
|
+
function withoutMarkup(value) {
|
|
176
|
+
return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
|
|
177
|
+
}
|
|
178
|
+
function looksLikeCode(words) {
|
|
179
|
+
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words);
|
|
180
|
+
}
|
|
181
|
+
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)$/;
|
|
182
|
+
function looksLikeClasses(value) {
|
|
183
|
+
const tokens = value.trim().split(/\s+/);
|
|
184
|
+
if (tokens.length === 0 || !tokens.every((t) => /^[!\w@:/[\]().#%,>&*+=~-]+$/.test(t))) return false;
|
|
185
|
+
const classy = tokens.filter((t) => /[-:[\]/]/.test(t) || UTILITY.test(t)).length;
|
|
186
|
+
return classy / tokens.length >= 0.6 && /[a-z]-|:|\[/.test(value);
|
|
187
|
+
}
|
|
188
|
+
function looksLikeAddress(value) {
|
|
189
|
+
return /^(?:\.{0,2}\/|@\/|https?:|mailto:|tel:)/.test(value);
|
|
190
|
+
}
|
|
191
|
+
function readsAsText(value) {
|
|
192
|
+
if (value.trim().length === 0) return false;
|
|
193
|
+
if (looksLikeAddress(value) || looksLikeClasses(value)) return false;
|
|
194
|
+
return /[^\s\w]|[a-zA-Z]/.test(value);
|
|
195
|
+
}
|
|
196
|
+
function literalIsText(value) {
|
|
197
|
+
return readsAsText(value) && !looksLikeCode(withoutMarkup(value));
|
|
198
|
+
}
|
|
199
|
+
function maskScriptComments(text) {
|
|
200
|
+
let out = "";
|
|
201
|
+
let i = 0;
|
|
202
|
+
while (i < text.length) {
|
|
203
|
+
const ch = text[i];
|
|
204
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
205
|
+
const start = i;
|
|
206
|
+
i += 1;
|
|
207
|
+
while (i < text.length) {
|
|
208
|
+
if (text[i] === "\\") {
|
|
209
|
+
i += 2;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (text[i] === ch) {
|
|
213
|
+
i += 1;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (text[i] === "\n" && ch !== "`") break;
|
|
217
|
+
i += 1;
|
|
218
|
+
}
|
|
219
|
+
out += text.slice(start, i);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
223
|
+
const end = text.indexOf("\n", i);
|
|
224
|
+
const stop = end === -1 ? text.length : end;
|
|
225
|
+
out += " ".repeat(stop - i);
|
|
226
|
+
i = stop;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
230
|
+
const end = text.indexOf("*/", i + 2);
|
|
231
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
232
|
+
out += blank(text.slice(i, stop));
|
|
233
|
+
i = stop;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
out += ch;
|
|
237
|
+
i += 1;
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
function maskMarkupComments(text) {
|
|
242
|
+
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);
|
|
243
|
+
}
|
|
244
|
+
function markupParts(file) {
|
|
245
|
+
const masked = maskMarkupComments(file.text);
|
|
246
|
+
const textNodes = [];
|
|
247
|
+
let rest = masked;
|
|
248
|
+
for (const m of masked.matchAll(/([>}])([^<>{}]+)(?=[<{])/g)) {
|
|
249
|
+
const body = m[2];
|
|
250
|
+
const before = masked[(m.index ?? 0) - 1] ?? "";
|
|
251
|
+
const after = masked[(m.index ?? 0) + m[0].length];
|
|
252
|
+
if (m[1] === ">" && /[=-]/.test(before)) continue;
|
|
253
|
+
if (m[1] === "}" && (/^\s*(?:[)\],;:]|(?:else|catch|finally|return|from|as|if|const|let|export|function)\b)/.test(body) || /;\s*$/.test(body))) continue;
|
|
254
|
+
if (m[1] === "}" && after === "{" && (/[=()`'"]/.test(body) || !/[a-zA-Z]{2,}|[^\s\w,|&?:]/.test(body))) continue;
|
|
255
|
+
if (body.trim().length === 0) continue;
|
|
256
|
+
if (m[1] === ">" && after === "<" && /^[\s—–-]*$/.test(body)) continue;
|
|
257
|
+
const at = (m.index ?? 0) + 1;
|
|
258
|
+
textNodes.push({ path: file.path, text: body, offset: at });
|
|
259
|
+
rest = rest.slice(0, at) + blank(body) + rest.slice(at + body.length);
|
|
260
|
+
}
|
|
261
|
+
return { textNodes, literals: splitHtml(stringLiterals(rest)) };
|
|
262
|
+
}
|
|
263
|
+
function textNodeIsProse(body) {
|
|
264
|
+
return /[a-zA-Z]{2,}/.test(body) && /\s/.test(body.trim()) || /[a-zA-Z]{4,}[.!?]/.test(body);
|
|
265
|
+
}
|
|
266
|
+
function proseBlocks(file) {
|
|
267
|
+
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);
|
|
268
|
+
return [{ path: file.path, text: masked, offset: 0 }];
|
|
269
|
+
}
|
|
270
|
+
function isData(path) {
|
|
271
|
+
return /\.jsonl?$/i.test(path);
|
|
272
|
+
}
|
|
273
|
+
function scriptLiterals(file) {
|
|
274
|
+
return splitHtml(stringLiterals(isData(file.path) ? file.text : maskScriptComments(file.text)));
|
|
275
|
+
}
|
|
276
|
+
function splitHtml(literals) {
|
|
277
|
+
const out = [];
|
|
278
|
+
for (const l of literals) {
|
|
279
|
+
if (!/<[a-zA-Z][^<>]*>/.test(l.value)) {
|
|
280
|
+
out.push(l);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
for (const m of l.value.matchAll(/(?:^|>)([^<>]+)(?=<|$)/g)) {
|
|
284
|
+
const body = m[1];
|
|
285
|
+
if (!body.trim()) continue;
|
|
286
|
+
const lead = m[0].length - body.length;
|
|
287
|
+
out.push({ value: body, offset: l.offset + (m.index ?? 0) + lead });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
function extractCopy(file) {
|
|
293
|
+
const kind = fileKind(file.path);
|
|
294
|
+
if (kind === "prose" || file.path.endsWith(".mdx")) return proseBlocks(file);
|
|
295
|
+
if (kind === "markup") {
|
|
296
|
+
const { textNodes, literals } = markupParts(file);
|
|
297
|
+
return [
|
|
298
|
+
...textNodes.filter((b) => textNodeIsProse(b.text)),
|
|
299
|
+
...literals.filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
if (kind === "script" || isData(file.path)) {
|
|
303
|
+
return scriptLiterals(file).filter((l) => readsAsProse(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
|
|
304
|
+
}
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
function extractStrings(file) {
|
|
308
|
+
const kind = fileKind(file.path);
|
|
309
|
+
if (kind === "prose" || file.path.endsWith(".mdx")) {
|
|
310
|
+
const [block] = proseBlocks(file);
|
|
311
|
+
const out = [];
|
|
312
|
+
let offset = 0;
|
|
313
|
+
for (const line of block.text.split("\n")) {
|
|
314
|
+
if (line.trim()) out.push({ path: file.path, text: line, offset });
|
|
315
|
+
offset += line.length + 1;
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
if (kind === "markup") {
|
|
320
|
+
const { textNodes, literals } = markupParts(file);
|
|
321
|
+
return [
|
|
322
|
+
...textNodes.filter((b) => readsAsText(b.text)),
|
|
323
|
+
...literals.filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
|
|
324
|
+
];
|
|
325
|
+
}
|
|
326
|
+
if (kind === "script" || isData(file.path)) {
|
|
327
|
+
return scriptLiterals(file).filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
|
|
328
|
+
}
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
|
|
1
332
|
// src/snapshot/types.ts
|
|
2
333
|
var SNAPSHOT_VERSION = 1;
|
|
3
334
|
|
|
@@ -7,15 +338,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
|
7
338
|
function allText(ctx) {
|
|
8
339
|
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
9
340
|
}
|
|
341
|
+
var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
|
|
342
|
+
function linkSpans(text) {
|
|
343
|
+
return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
344
|
+
}
|
|
345
|
+
var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
|
|
10
346
|
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
11
347
|
return (ctx) => {
|
|
12
348
|
const hits = [];
|
|
13
349
|
for (const block of source(ctx)) {
|
|
14
350
|
const spans = [];
|
|
351
|
+
const links = linkSpans(block.text);
|
|
15
352
|
for (const pattern of patterns) {
|
|
16
353
|
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
17
354
|
for (const m of block.text.matchAll(global)) {
|
|
18
355
|
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
356
|
+
if (insideLink(links, m.index ?? 0)) continue;
|
|
19
357
|
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
20
358
|
}
|
|
21
359
|
}
|
|
@@ -56,7 +394,14 @@ var AI_WORDS = [
|
|
|
56
394
|
"moreover",
|
|
57
395
|
"navigating",
|
|
58
396
|
"ever-evolving",
|
|
59
|
-
"unparalleled"
|
|
397
|
+
"unparalleled",
|
|
398
|
+
// Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
|
|
399
|
+
// above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
|
|
400
|
+
"underscore",
|
|
401
|
+
"underscores",
|
|
402
|
+
"underscoring",
|
|
403
|
+
"showcasing",
|
|
404
|
+
"pivotal"
|
|
60
405
|
];
|
|
61
406
|
var STOCK_PHRASES = [
|
|
62
407
|
"in today's digital age",
|
|
@@ -70,7 +415,11 @@ var STOCK_PHRASES = [
|
|
|
70
415
|
"curated",
|
|
71
416
|
"crafted with care",
|
|
72
417
|
"tailored to your",
|
|
73
|
-
"peace of mind"
|
|
418
|
+
"peace of mind",
|
|
419
|
+
"so here's",
|
|
420
|
+
"in an era where",
|
|
421
|
+
"the future looks bright",
|
|
422
|
+
"only time will tell"
|
|
74
423
|
];
|
|
75
424
|
var AI_PHRASES = [
|
|
76
425
|
"let's dive in",
|
|
@@ -203,7 +552,7 @@ function lineAround(text, index) {
|
|
|
203
552
|
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);
|
|
204
553
|
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
205
554
|
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
206
|
-
var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
|
|
555
|
+
var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
|
|
207
556
|
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
208
557
|
function badgeHits(ctx) {
|
|
209
558
|
const hits = [];
|
|
@@ -216,309 +565,1014 @@ function badgeHits(ctx) {
|
|
|
216
565
|
if (bullet) lead += bullet[0].length;
|
|
217
566
|
text = text.slice(lead);
|
|
218
567
|
}
|
|
219
|
-
const candidate = text.trim();
|
|
220
|
-
if (NO_X_BADGE.test(candidate) && !emptyState(block, candidate)) {
|
|
221
|
-
hits.push({ path: block.path, offset: block.offset + lead, message: quoted(candidate) });
|
|
568
|
+
const candidate = text.trim();
|
|
569
|
+
if (NO_X_BADGE.test(candidate) && !emptyState(block, candidate)) {
|
|
570
|
+
hits.push({ path: block.path, offset: block.offset + lead, message: quoted(candidate) });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return hits;
|
|
574
|
+
}
|
|
575
|
+
var COPY_TELLS = [
|
|
576
|
+
{
|
|
577
|
+
id: "ai-vocabulary",
|
|
578
|
+
name: "AI vocabulary",
|
|
579
|
+
generation: 1,
|
|
580
|
+
severity: "warn",
|
|
581
|
+
surface: "copy",
|
|
582
|
+
why: "Tapestry, elevate, nestled, unparalleled: words that appear in generated copy far more than in anything a business owner writes.",
|
|
583
|
+
fix: "Say the plain thing. 'Elevate your look' becomes what actually happens: 'Gel that lasts three weeks'.",
|
|
584
|
+
detect: phrases([wordList(AI_WORDS), /\bunlock\b(?!\s+(?:your|the power))/i], quoted),
|
|
585
|
+
fixtures: {
|
|
586
|
+
flag: [f("content/home.md", "Elevate your nails in our bustling studio."), f("content/home.md", "We unlock it for you.")],
|
|
587
|
+
pass: [f("content/home.md", "Gel nails that last three weeks. Book online in a minute.")]
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
id: "stock-phrase",
|
|
592
|
+
name: "Stock phrase",
|
|
593
|
+
generation: 1,
|
|
594
|
+
severity: "warn",
|
|
595
|
+
surface: "copy",
|
|
596
|
+
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
597
|
+
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
598
|
+
detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
|
|
599
|
+
fixtures: {
|
|
600
|
+
flag: [
|
|
601
|
+
f("content/home.md", "Take your plumbing to the next level with peace of mind."),
|
|
602
|
+
f("content/home.md", "We meet each practice where it is in its journey.")
|
|
603
|
+
],
|
|
604
|
+
pass: [
|
|
605
|
+
f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
|
|
606
|
+
f("content/home.md", "We mapped the patient journey from booking to aftercare.")
|
|
607
|
+
]
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
id: "ai-phrase",
|
|
612
|
+
name: "AI phrase",
|
|
613
|
+
generation: 1,
|
|
614
|
+
severity: "warn",
|
|
615
|
+
surface: "copy",
|
|
616
|
+
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.",
|
|
617
|
+
fix: "Cut the phrase and start with the point it was introducing. 'Seamless booking' becomes 'Book in three taps'.",
|
|
618
|
+
detect: phrases(
|
|
619
|
+
[
|
|
620
|
+
wordList(AI_PHRASES),
|
|
621
|
+
/\bin today['’]?s (?:rapidly )?(?:evolving|changing|fast-paced)\b/i,
|
|
622
|
+
/\bwhether you['’]re an? [^.,;]{2,30} or\b/i
|
|
623
|
+
],
|
|
624
|
+
quoted
|
|
625
|
+
),
|
|
626
|
+
fixtures: {
|
|
627
|
+
flag: [
|
|
628
|
+
f("content/home.md", "Let's dive in to what we do."),
|
|
629
|
+
f("content/home.md", "In today's rapidly evolving market, we help."),
|
|
630
|
+
f("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
631
|
+
],
|
|
632
|
+
pass: [
|
|
633
|
+
f("content/home.md", "Book in three taps. We confirm by text within the hour."),
|
|
634
|
+
f("content/home.md", "Book at https://example.com/seamless-booking today."),
|
|
635
|
+
f("content/home.md", "[Book a slot](/seamless-booking) today.")
|
|
636
|
+
]
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "plainer-word",
|
|
641
|
+
name: "A plainer word exists",
|
|
642
|
+
generation: 1,
|
|
643
|
+
severity: "warn",
|
|
644
|
+
surface: "copy",
|
|
645
|
+
why: "Empower, leverage, harness, delve: each stands in for a plainer verb, and the swap is a reliable sign nobody chose the word.",
|
|
646
|
+
fix: "Name the mechanism instead. 'Empower your team' becomes 'Your team can publish without us'.",
|
|
647
|
+
detect: phrases([wordList(PLAINER_WORDS)], quoted),
|
|
648
|
+
fixtures: {
|
|
649
|
+
flag: [f("content/home.md", "We empower small firms to grow.")],
|
|
650
|
+
pass: [f("content/home.md", "Small firms can publish without waiting for us.")]
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
id: "plain-english",
|
|
655
|
+
name: "'Plain English'",
|
|
656
|
+
generation: 2,
|
|
657
|
+
severity: "warn",
|
|
658
|
+
surface: "copy",
|
|
659
|
+
why: "Told to avoid jargon, a model announces that it is avoiding jargon. 'Explained plainly' swaps a synonym and keeps the tell.",
|
|
660
|
+
fix: "Use the house replacement, 'properly explained', or drop the claim and let the copy show it.",
|
|
661
|
+
detect: phrases([/\b(?:plain english|explained plainly|plainly explained)\b/i], quoted),
|
|
662
|
+
fixtures: {
|
|
663
|
+
flag: [f("content/home.md", "Your options, in plain English.")],
|
|
664
|
+
pass: [f("content/home.md", "Your options, properly explained.")]
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
id: "buzzword",
|
|
669
|
+
name: "Buzzword",
|
|
670
|
+
generation: 1,
|
|
671
|
+
severity: "warn",
|
|
672
|
+
surface: "copy",
|
|
673
|
+
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.",
|
|
674
|
+
fix: "Say what is actually true. 'World-class service' becomes 'We answer the phone until 8pm'. 'Reach out' becomes 'get in touch'.",
|
|
675
|
+
detect: phrases([wordList(BUZZWORDS)], quoted),
|
|
676
|
+
fixtures: {
|
|
677
|
+
flag: [f("content/home.md", "Reach out for world-class plumbing.")],
|
|
678
|
+
pass: [f("content/home.md", "Get in touch. We answer until 8pm.")]
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
id: "negative-reassurance",
|
|
683
|
+
name: "Negative reassurance",
|
|
684
|
+
generation: 1,
|
|
685
|
+
severity: "warn",
|
|
686
|
+
surface: "copy",
|
|
687
|
+
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.",
|
|
688
|
+
fix: "Say what they get: 'The price on the quote is the price you pay', 'Cancel any month'.",
|
|
689
|
+
detect: phrases([wordList(NEGATIVE_REASSURANCE)], quoted),
|
|
690
|
+
fixtures: {
|
|
691
|
+
flag: [f("content/home.md", "Clear pricing with no hidden fees.")],
|
|
692
|
+
pass: [f("content/home.md", "The price on the quote is the price you pay.")]
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
id: "vague-word",
|
|
697
|
+
name: "Vague word",
|
|
698
|
+
generation: 1,
|
|
699
|
+
severity: "warn",
|
|
700
|
+
surface: "copy",
|
|
701
|
+
why: "Innovative, scalable, end-to-end, solutions: each can be true, and each is used where the writer had nothing specific to say.",
|
|
702
|
+
fix: "Replace it with the specific thing. 'Innovative solutions' becomes the actual product and what it does.",
|
|
703
|
+
detect: phrases([wordList(VAGUE_WORDS)], quoted),
|
|
704
|
+
fixtures: {
|
|
705
|
+
flag: [f("content/home.md", "Innovative solutions for every business.")],
|
|
706
|
+
pass: [f("content/home.md", "Rewires, fuse boards and EV chargers across Northamptonshire.")]
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
id: "review-phrase",
|
|
711
|
+
name: "Review-tier phrase",
|
|
712
|
+
generation: 1,
|
|
713
|
+
severity: "warn",
|
|
714
|
+
surface: "copy",
|
|
715
|
+
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.",
|
|
716
|
+
fix: "Keep the one that lands hardest and cut the rest. 'What is genuinely excellent' reads better as 'what is excellent'.",
|
|
717
|
+
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),
|
|
718
|
+
fixtures: {
|
|
719
|
+
flag: [f("content/home.md", "We are truly proud of it.")],
|
|
720
|
+
pass: [f("content/home.md", "We have fitted 400 boilers since 2009.")]
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
id: "hollow-imperative",
|
|
725
|
+
name: "Hollow imperative",
|
|
726
|
+
generation: 1,
|
|
727
|
+
severity: "warn",
|
|
728
|
+
surface: "copy",
|
|
729
|
+
why: "'Discover', 'Experience', 'Transform your' as the opening verb of a heading or button is a call to action with nothing in it.",
|
|
730
|
+
fix: "Name the action and the outcome: 'Book a 45-minute facial', not 'Experience the difference'.",
|
|
731
|
+
detect: phrases(
|
|
732
|
+
[/(?:^|[.!?]\s+|\n\s*|>\s*)(?:Discover|Experience|Explore|Transform|Indulge in|Embrace|Unlock|Elevate)\s+(?:the|your|our|a|true|real)\b[^.!?\n<]{0,40}/],
|
|
733
|
+
(m) => `"${m.replace(/^[.!?>\s]+/, "")}"`
|
|
734
|
+
),
|
|
735
|
+
fixtures: {
|
|
736
|
+
flag: [f("content/home.md", "Experience the difference today.")],
|
|
737
|
+
pass: [f("content/home.md", "Book a 45-minute facial.")]
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
id: "rhetorical-opener",
|
|
742
|
+
name: "Rhetorical question opener",
|
|
743
|
+
generation: 1,
|
|
744
|
+
severity: "warn",
|
|
745
|
+
surface: "copy",
|
|
746
|
+
why: "'Looking for...?', 'Tired of...?', 'Ready to...?' opens with the model guessing at the reader instead of telling them something.",
|
|
747
|
+
fix: "Open with the answer.",
|
|
748
|
+
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]+/, "")}"`),
|
|
749
|
+
fixtures: {
|
|
750
|
+
flag: [f("content/home.md", "Looking for a reliable plumber?")],
|
|
751
|
+
pass: [f("content/home.md", "A plumber in Brackley, on call until 10pm.")]
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
id: "em-dash",
|
|
756
|
+
name: "Em dash",
|
|
757
|
+
generation: 2,
|
|
758
|
+
severity: "warn",
|
|
759
|
+
surface: "copy",
|
|
760
|
+
why: "Generated copy leans on the em dash to join clauses. House copy does not use it.",
|
|
761
|
+
fix: "Use a full stop, a comma, or a colon. Two sentences are clearer than one sentence with a dash in it.",
|
|
762
|
+
detect: phrases([/\S{0,20}\s?—\s?\S{0,20}/], quoted, (ctx) => ctx.strings, (block) => placeholderDash(block)),
|
|
763
|
+
fixtures: {
|
|
764
|
+
flag: [
|
|
765
|
+
f("content/home.md", "Walk-ins welcome \u2014 just ring first."),
|
|
766
|
+
f("app/hours.tsx", `export const hours = { weekdays: "Mon \u2014 Fri" };`)
|
|
767
|
+
],
|
|
768
|
+
pass: [
|
|
769
|
+
f("content/home.md", "Walk-ins welcome. Ring first. Open 9-5."),
|
|
770
|
+
f("app/row.tsx", `export const Cell = ({ v }) => <td>{v ?? "\u2014"}</td>;`),
|
|
771
|
+
f("app/row.tsx", `export const Empty = () => <td className="muted">\u2014</td>;`)
|
|
772
|
+
]
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
id: "emoji",
|
|
777
|
+
name: "Emoji in copy",
|
|
778
|
+
generation: 1,
|
|
779
|
+
severity: "warn",
|
|
780
|
+
surface: "copy",
|
|
781
|
+
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.",
|
|
782
|
+
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
783
|
+
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
784
|
+
fixtures: {
|
|
785
|
+
flag: [
|
|
786
|
+
f("content/home.md", "New treatments this month \u2728\u{1F680}"),
|
|
787
|
+
f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
|
|
788
|
+
f("content/home.md", "New treatments this month \u2728"),
|
|
789
|
+
f("content/home.md", "Same-day callouts \u2705"),
|
|
790
|
+
f("content/home.md", "Open Sundays \u2600\uFE0F")
|
|
791
|
+
],
|
|
792
|
+
pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
id: "not-just-but",
|
|
797
|
+
name: "Contrastive negation",
|
|
798
|
+
generation: 2,
|
|
799
|
+
severity: "warn",
|
|
800
|
+
surface: "copy",
|
|
801
|
+
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.",
|
|
802
|
+
fix: "Say the positive claim and drop the contrast.",
|
|
803
|
+
detect: phrases(NEGATION, quoted, allText),
|
|
804
|
+
fixtures: {
|
|
805
|
+
flag: [
|
|
806
|
+
f("content/home.md", "It's not just a haircut, it's an experience."),
|
|
807
|
+
f("content/home.md", "It's not a website, it's a growth engine."),
|
|
808
|
+
f("content/home.md", "I don't teach the treatment you'd choose: I teach the standard."),
|
|
809
|
+
f("content/home.md", "Hairdressing isn't a job, it's art."),
|
|
810
|
+
f("content/home.md", "It is less about the tools and more about the eye."),
|
|
811
|
+
f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
|
|
812
|
+
f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
|
|
813
|
+
],
|
|
814
|
+
pass: [
|
|
815
|
+
f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
|
|
816
|
+
f("content/home.md", "They are cosmetics, not medicines."),
|
|
817
|
+
f("content/home.md", "The box arrived red, not led astray by a dented corner.")
|
|
818
|
+
]
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
id: "no-x-no-y",
|
|
823
|
+
name: "'No X, no Y' list",
|
|
824
|
+
generation: 2,
|
|
825
|
+
severity: "warn",
|
|
826
|
+
surface: "copy",
|
|
827
|
+
why: "'No obligation, no spam.' Defining the business by what it is not, itemised, is the same template as contrastive negation.",
|
|
828
|
+
fix: "Say what they get: 'We reply within a day and only about your enquiry'.",
|
|
829
|
+
detect: phrases([NO_X_NO_Y], quoted, allText, emptyState),
|
|
830
|
+
fixtures: {
|
|
831
|
+
flag: [
|
|
832
|
+
f("content/home.md", "We reply within a day. No obligation, no spam."),
|
|
833
|
+
f("app/hero.tsx", `export const Hero = () => <p className="mt-4">We reply within a day. No
|
|
834
|
+
obligation, no spam.</p>;`)
|
|
835
|
+
],
|
|
836
|
+
pass: [
|
|
837
|
+
f("content/home.md", "We reply within a day, and only about your enquiry."),
|
|
838
|
+
f("app/admin/list.tsx", `export const Empty = () => <p>No clients, no bookings.</p>;`),
|
|
839
|
+
f("app/list.tsx", `export const Empty = () => <p>No orders yet, no invoices found.</p>;`)
|
|
840
|
+
]
|
|
222
841
|
}
|
|
223
|
-
}
|
|
224
|
-
return hits;
|
|
225
|
-
}
|
|
226
|
-
var COPY_TELLS = [
|
|
842
|
+
},
|
|
227
843
|
{
|
|
228
|
-
id: "
|
|
229
|
-
name: "
|
|
230
|
-
generation:
|
|
844
|
+
id: "no-x-badge",
|
|
845
|
+
name: "'No X' badge",
|
|
846
|
+
generation: 2,
|
|
231
847
|
severity: "warn",
|
|
232
848
|
surface: "copy",
|
|
233
|
-
why: "
|
|
234
|
-
fix: "
|
|
235
|
-
detect:
|
|
849
|
+
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.",
|
|
850
|
+
fix: "Replace it with the positive fact it hints at, or cut it.",
|
|
851
|
+
detect: badgeHits,
|
|
236
852
|
fixtures: {
|
|
237
|
-
flag: [f("
|
|
238
|
-
pass: [
|
|
853
|
+
flag: [f("app/badge.tsx", `export const kicker = "No catch.";`), f("content/home.md", "Straight answers.\n\nNo jargon guide\n")],
|
|
854
|
+
pass: [
|
|
855
|
+
f("app/list.tsx", `export const empty = "No results found.";`),
|
|
856
|
+
f("app/portal/list.tsx", `export const empty = "No bookings";`),
|
|
857
|
+
f("content/home.md", "### No clarity on ideal client\n")
|
|
858
|
+
]
|
|
239
859
|
}
|
|
240
860
|
},
|
|
241
861
|
{
|
|
242
|
-
id: "
|
|
243
|
-
name: "
|
|
244
|
-
generation:
|
|
862
|
+
id: "staccato-triplet",
|
|
863
|
+
name: "Staccato triplet",
|
|
864
|
+
generation: 2,
|
|
245
865
|
severity: "warn",
|
|
246
866
|
surface: "copy",
|
|
247
|
-
why: "
|
|
248
|
-
fix: "
|
|
249
|
-
detect: phrases([
|
|
867
|
+
why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
|
|
868
|
+
fix: "Write one sentence that says which of the three is true, and how you know.",
|
|
869
|
+
detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
|
|
250
870
|
fixtures: {
|
|
251
|
-
flag: [f("content/home.md", "
|
|
252
|
-
pass: [f("content/home.md", "
|
|
871
|
+
flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
|
|
872
|
+
pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
|
|
253
873
|
}
|
|
254
874
|
},
|
|
255
875
|
{
|
|
256
|
-
id: "
|
|
257
|
-
name: "
|
|
876
|
+
id: "where-x-meets-y",
|
|
877
|
+
name: "'Where X meets Y'",
|
|
878
|
+
generation: 2,
|
|
879
|
+
severity: "warn",
|
|
880
|
+
surface: "copy",
|
|
881
|
+
why: "'Where luxury meets comfort' is a tagline shape that fits every business and so describes none.",
|
|
882
|
+
fix: "Say what the place is and where it is.",
|
|
883
|
+
detect: phrases([/\bwhere\s+[\w'’-]+(?:\s+[\w'’-]+)?\s+meets?\s+[\w'’-]+/i], quoted),
|
|
884
|
+
fixtures: {
|
|
885
|
+
flag: [f("content/home.md", "Where luxury meets comfort.")],
|
|
886
|
+
pass: [f("content/home.md", "A two-chair studio above the bakery on Market Place.")]
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
];
|
|
890
|
+
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;
|
|
891
|
+
var VAGUE_ATTRIBUTION = [
|
|
892
|
+
/\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,
|
|
893
|
+
/\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
|
|
894
|
+
/\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
|
|
895
|
+
];
|
|
896
|
+
function sourced(block, _match, index) {
|
|
897
|
+
const before = block.text.slice(Math.max(0, index - 40), index);
|
|
898
|
+
if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
|
|
899
|
+
const rest = block.text.slice(index, index + 400);
|
|
900
|
+
const end = rest.search(/[.!?](?:\s|$)/);
|
|
901
|
+
const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
|
|
902
|
+
return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
|
|
903
|
+
}
|
|
904
|
+
var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
|
|
905
|
+
var FALSE_RANGE = [
|
|
906
|
+
/\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
|
|
907
|
+
/\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
|
|
908
|
+
];
|
|
909
|
+
var RESIDUE = [
|
|
910
|
+
/\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
|
|
911
|
+
/(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
|
|
912
|
+
/\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
|
|
913
|
+
/\blet me know if you(?:['’]d| would) like me to\b/i,
|
|
914
|
+
/\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
|
|
915
|
+
/\bI hope this helps\b/i,
|
|
916
|
+
// Citation tokens a chat tool leaves in copied text.
|
|
917
|
+
// A run of them is one paste: "citeturn11search1turn10view0" is one finding.
|
|
918
|
+
// ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
|
|
919
|
+
// which survive a copy and paste into a CMS: found live, 2026-09-23.
|
|
920
|
+
/[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
|
|
921
|
+
/:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
|
|
922
|
+
/【\d+(?:[:†][^】]{0,40})?】/
|
|
923
|
+
];
|
|
924
|
+
var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
|
|
925
|
+
function residueHits(ctx) {
|
|
926
|
+
const hits = phrases(RESIDUE, quoted, allText)(ctx);
|
|
927
|
+
for (const file of ctx.files) {
|
|
928
|
+
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]}"` });
|
|
929
|
+
}
|
|
930
|
+
return hits;
|
|
931
|
+
}
|
|
932
|
+
var PLACEHOLDER = [
|
|
933
|
+
/\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
|
|
934
|
+
/\blorem ipsum\b/i,
|
|
935
|
+
/\bTODO_PLACEHOLDER\b/
|
|
936
|
+
];
|
|
937
|
+
var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
|
|
938
|
+
function insideQuotes(block, _match, index) {
|
|
939
|
+
const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
|
|
940
|
+
const before = block.text.slice(lineStart, index);
|
|
941
|
+
const straight = (before.match(/"/g) ?? []).length;
|
|
942
|
+
const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
|
|
943
|
+
return straight % 2 === 1 || curly > 0;
|
|
944
|
+
}
|
|
945
|
+
function placeholderHits(ctx) {
|
|
946
|
+
const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
|
|
947
|
+
const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
|
|
948
|
+
for (const file of ctx.files) {
|
|
949
|
+
if (isProsePath(file.path)) continue;
|
|
950
|
+
for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
|
|
951
|
+
const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
|
|
952
|
+
if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return hits;
|
|
956
|
+
}
|
|
957
|
+
var QUESTION_REVEAL = [
|
|
958
|
+
/\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]/,
|
|
959
|
+
/\?\s+It means\b/
|
|
960
|
+
];
|
|
961
|
+
function inlineLabelHits(ctx) {
|
|
962
|
+
const hits = [];
|
|
963
|
+
for (const file of ctx.files) {
|
|
964
|
+
if (!isProsePath(file.path)) continue;
|
|
965
|
+
const lines = file.text.split("\n");
|
|
966
|
+
let run = [];
|
|
967
|
+
let offset = 0;
|
|
968
|
+
const flush = () => {
|
|
969
|
+
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` });
|
|
970
|
+
run = [];
|
|
971
|
+
};
|
|
972
|
+
for (const line of lines) {
|
|
973
|
+
const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
|
|
974
|
+
if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
|
|
975
|
+
else flush();
|
|
976
|
+
offset += line.length + 1;
|
|
977
|
+
}
|
|
978
|
+
flush();
|
|
979
|
+
}
|
|
980
|
+
return hits;
|
|
981
|
+
}
|
|
982
|
+
var RESEARCH_TELLS = [
|
|
983
|
+
{
|
|
984
|
+
id: "chatbot-residue",
|
|
985
|
+
name: "Chatbot residue",
|
|
258
986
|
generation: 1,
|
|
259
987
|
severity: "warn",
|
|
260
988
|
surface: "copy",
|
|
261
|
-
why: "
|
|
262
|
-
fix: "
|
|
263
|
-
detect:
|
|
264
|
-
[
|
|
265
|
-
wordList(AI_PHRASES),
|
|
266
|
-
/\bin today['’]?s (?:rapidly )?(?:evolving|changing|fast-paced)\b/i,
|
|
267
|
-
/\bwhether you['’]re an? [^.,;]{2,30} or\b/i
|
|
268
|
-
],
|
|
269
|
-
quoted
|
|
270
|
-
),
|
|
989
|
+
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.",
|
|
990
|
+
fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
|
|
991
|
+
detect: residueHits,
|
|
271
992
|
fixtures: {
|
|
272
993
|
flag: [
|
|
273
|
-
f("content/home.md", "
|
|
274
|
-
f("content/home.md", "
|
|
275
|
-
f("content/home.md", "
|
|
994
|
+
f("content/home.md", "Certainly! Here's a revised version of your home page."),
|
|
995
|
+
f("content/home.md", "As an AI language model, I cannot visit your salon."),
|
|
996
|
+
f("content/home.md", "Let me know if you'd like me to shorten it."),
|
|
997
|
+
f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
|
|
998
|
+
f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
|
|
999
|
+
f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
|
|
1000
|
+
f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
|
|
1001
|
+
f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
|
|
1002
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
|
|
1003
|
+
f("content/home.md", "Here's a more concise version for the hero.")
|
|
276
1004
|
],
|
|
277
|
-
pass: [
|
|
1005
|
+
pass: [
|
|
1006
|
+
f("content/home.md", "Certainly the busiest week of the year: book early."),
|
|
1007
|
+
f("content/home.md", "Here's the price list for 2026."),
|
|
1008
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
|
|
1009
|
+
]
|
|
278
1010
|
}
|
|
279
1011
|
},
|
|
280
1012
|
{
|
|
281
|
-
id: "
|
|
282
|
-
name: "
|
|
1013
|
+
id: "placeholder",
|
|
1014
|
+
name: "Unfilled placeholder",
|
|
283
1015
|
generation: 1,
|
|
284
1016
|
severity: "warn",
|
|
285
1017
|
surface: "copy",
|
|
286
|
-
why: "
|
|
287
|
-
fix: "
|
|
288
|
-
detect:
|
|
1018
|
+
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.",
|
|
1019
|
+
fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
|
|
1020
|
+
detect: placeholderHits,
|
|
289
1021
|
fixtures: {
|
|
290
|
-
flag: [
|
|
291
|
-
|
|
1022
|
+
flag: [
|
|
1023
|
+
f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
|
|
1024
|
+
f("content/home.md", "Lorem ipsum dolor sit amet."),
|
|
1025
|
+
f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
|
|
1026
|
+
f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
|
|
1027
|
+
],
|
|
1028
|
+
pass: [
|
|
1029
|
+
f("content/home.md", "Manage bookings in [your account](/account)."),
|
|
1030
|
+
f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
|
|
1031
|
+
f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
|
|
1032
|
+
f("content/home.md", "Rated 4.9 from 212 Google reviews.")
|
|
1033
|
+
]
|
|
292
1034
|
}
|
|
293
1035
|
},
|
|
294
1036
|
{
|
|
295
|
-
id: "
|
|
296
|
-
name: "
|
|
1037
|
+
id: "question-reveal",
|
|
1038
|
+
name: "Staged reveal",
|
|
297
1039
|
generation: 2,
|
|
298
1040
|
severity: "warn",
|
|
299
1041
|
surface: "copy",
|
|
300
|
-
why: "
|
|
301
|
-
fix: "
|
|
302
|
-
detect: phrases(
|
|
1042
|
+
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.",
|
|
1043
|
+
fix: "State the answer: 'Most customers book again within six weeks.'",
|
|
1044
|
+
detect: phrases(QUESTION_REVEAL, quoted),
|
|
303
1045
|
fixtures: {
|
|
304
|
-
flag: [f("content/home.md", "
|
|
305
|
-
pass: [f("content/home.md", "
|
|
1046
|
+
flag: [f("content/home.md", "We rebuilt the booking flow. The result? Twice the bookings."), f("content/home.md", "What does this mean for you? It means fewer missed calls.")],
|
|
1047
|
+
pass: [f("content/faq.md", "How long does a set last? About three weeks with gel."), f("content/home.md", "We rebuilt the booking flow, and bookings doubled in a month.")]
|
|
306
1048
|
}
|
|
307
1049
|
},
|
|
308
1050
|
{
|
|
309
|
-
id: "
|
|
310
|
-
name: "
|
|
311
|
-
generation:
|
|
1051
|
+
id: "inline-label-list",
|
|
1052
|
+
name: "Bold-label bullets",
|
|
1053
|
+
generation: 2,
|
|
312
1054
|
severity: "warn",
|
|
313
1055
|
surface: "copy",
|
|
314
|
-
why: "
|
|
315
|
-
fix: "
|
|
316
|
-
detect:
|
|
1056
|
+
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.",
|
|
1057
|
+
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.",
|
|
1058
|
+
detect: inlineLabelHits,
|
|
317
1059
|
fixtures: {
|
|
318
|
-
flag: [f("content/home.md", "
|
|
319
|
-
pass: [
|
|
1060
|
+
flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
|
|
1061
|
+
pass: [
|
|
1062
|
+
f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
|
|
1063
|
+
f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
|
|
1064
|
+
f("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")
|
|
1065
|
+
]
|
|
320
1066
|
}
|
|
321
1067
|
},
|
|
322
1068
|
{
|
|
323
|
-
id: "
|
|
324
|
-
name: "
|
|
325
|
-
generation:
|
|
1069
|
+
id: "ing-tail",
|
|
1070
|
+
name: "Empty -ing tail",
|
|
1071
|
+
generation: 2,
|
|
326
1072
|
severity: "warn",
|
|
327
1073
|
surface: "copy",
|
|
328
|
-
why: "'
|
|
329
|
-
fix: "
|
|
330
|
-
detect: phrases([
|
|
1074
|
+
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.",
|
|
1075
|
+
fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
|
|
1076
|
+
detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
|
|
331
1077
|
fixtures: {
|
|
332
|
-
flag: [
|
|
333
|
-
|
|
1078
|
+
flag: [
|
|
1079
|
+
f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
|
|
1080
|
+
f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
|
|
1081
|
+
],
|
|
1082
|
+
pass: [
|
|
1083
|
+
f("content/home.md", "We open at 7am, so you can drop the car off before work."),
|
|
1084
|
+
f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
|
|
1085
|
+
]
|
|
334
1086
|
}
|
|
335
1087
|
},
|
|
336
1088
|
{
|
|
337
|
-
id: "vague-
|
|
338
|
-
name: "
|
|
1089
|
+
id: "vague-attribution",
|
|
1090
|
+
name: "Unnamed source",
|
|
339
1091
|
generation: 1,
|
|
340
1092
|
severity: "warn",
|
|
341
1093
|
surface: "copy",
|
|
342
|
-
why: "
|
|
343
|
-
fix: "
|
|
344
|
-
detect: phrases(
|
|
1094
|
+
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.",
|
|
1095
|
+
fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
|
|
1096
|
+
detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
|
|
345
1097
|
fixtures: {
|
|
346
|
-
flag: [
|
|
347
|
-
|
|
1098
|
+
flag: [
|
|
1099
|
+
f("content/home.md", "Studies show that regular servicing saves money."),
|
|
1100
|
+
f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
|
|
1101
|
+
f("content/home.md", "It is widely known that gel damages nails.")
|
|
1102
|
+
],
|
|
1103
|
+
pass: [
|
|
1104
|
+
f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
|
|
1105
|
+
f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
|
|
1106
|
+
f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
|
|
1107
|
+
f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
|
|
1108
|
+
]
|
|
348
1109
|
}
|
|
349
1110
|
},
|
|
350
1111
|
{
|
|
351
|
-
id: "
|
|
352
|
-
name: "
|
|
1112
|
+
id: "closing-summary",
|
|
1113
|
+
name: "Closing summary",
|
|
353
1114
|
generation: 1,
|
|
354
1115
|
severity: "warn",
|
|
355
1116
|
surface: "copy",
|
|
356
|
-
why: "
|
|
357
|
-
fix: "
|
|
358
|
-
detect: phrases([
|
|
1117
|
+
why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
|
|
1118
|
+
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'.",
|
|
1119
|
+
detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
|
|
359
1120
|
fixtures: {
|
|
360
|
-
flag: [
|
|
361
|
-
|
|
1121
|
+
flag: [
|
|
1122
|
+
f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
|
|
1123
|
+
f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
|
|
1124
|
+
],
|
|
1125
|
+
pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
|
|
362
1126
|
}
|
|
363
1127
|
},
|
|
364
1128
|
{
|
|
365
|
-
id: "
|
|
366
|
-
name: "
|
|
1129
|
+
id: "false-range",
|
|
1130
|
+
name: "False range",
|
|
367
1131
|
generation: 1,
|
|
368
1132
|
severity: "warn",
|
|
369
1133
|
surface: "copy",
|
|
370
|
-
why: "'
|
|
371
|
-
fix: "
|
|
372
|
-
detect: phrases(
|
|
373
|
-
[/(?:^|[.!?]\s+|\n\s*|>\s*)(?:Discover|Experience|Explore|Transform|Indulge in|Embrace|Unlock|Elevate)\s+(?:the|your|our|a|true|real)\b[^.!?\n<]{0,40}/],
|
|
374
|
-
(m) => `"${m.replace(/^[.!?>\s]+/, "")}"`
|
|
375
|
-
),
|
|
1134
|
+
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.",
|
|
1135
|
+
fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
|
|
1136
|
+
detect: phrases(FALSE_RANGE, quoted),
|
|
376
1137
|
fixtures: {
|
|
377
|
-
flag: [
|
|
378
|
-
|
|
1138
|
+
flag: [
|
|
1139
|
+
f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
|
|
1140
|
+
f("content/home.md", "Everyone from students to retirees loves our cakes.")
|
|
1141
|
+
],
|
|
1142
|
+
pass: [
|
|
1143
|
+
f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
|
|
1144
|
+
f("content/home.md", "We take on everything from small repairs to full rewires.")
|
|
1145
|
+
]
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
];
|
|
1149
|
+
|
|
1150
|
+
// src/character/tells/density.ts
|
|
1151
|
+
var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
|
|
1152
|
+
var MIN_WORDS = 400;
|
|
1153
|
+
var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
1154
|
+
var countWords = (text) => (text.match(WORD) ?? []).length;
|
|
1155
|
+
function documents(ctx) {
|
|
1156
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1157
|
+
for (const block of ctx.blocks) {
|
|
1158
|
+
if (!DOCUMENT.test(block.path) || block.literal) continue;
|
|
1159
|
+
const list = byPath.get(block.path) ?? [];
|
|
1160
|
+
list.push(block);
|
|
1161
|
+
byPath.set(block.path, list);
|
|
1162
|
+
}
|
|
1163
|
+
const docs = [];
|
|
1164
|
+
for (const [path, blocks] of byPath) {
|
|
1165
|
+
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
1166
|
+
const words = countWords(text);
|
|
1167
|
+
if (words >= MIN_WORDS) docs.push({ path, blocks, words });
|
|
1168
|
+
}
|
|
1169
|
+
return docs;
|
|
1170
|
+
}
|
|
1171
|
+
var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
|
|
1172
|
+
var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
|
|
1173
|
+
var thousands = (n) => n.toLocaleString("en-GB");
|
|
1174
|
+
function sentences(doc2) {
|
|
1175
|
+
const out = [];
|
|
1176
|
+
for (const block of doc2.blocks) {
|
|
1177
|
+
const text = withoutHeadings(block.text);
|
|
1178
|
+
for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
|
|
1179
|
+
const body = para[0];
|
|
1180
|
+
const start = para.index ?? 0;
|
|
1181
|
+
for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
|
|
1182
|
+
const raw = m[0];
|
|
1183
|
+
const trimmed = raw.trim();
|
|
1184
|
+
if (countWords(trimmed) === 0) continue;
|
|
1185
|
+
const lead = raw.length - raw.trimStart().length;
|
|
1186
|
+
out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return out;
|
|
1191
|
+
}
|
|
1192
|
+
var RATES = [
|
|
1193
|
+
{ label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
|
|
1194
|
+
{ label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
|
|
1195
|
+
{ label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
|
|
1196
|
+
{ label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
|
|
1197
|
+
{ label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
|
|
1198
|
+
{
|
|
1199
|
+
label: "the single ...est",
|
|
1200
|
+
pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
|
|
1201
|
+
floor: 1,
|
|
1202
|
+
per1000: 0.15,
|
|
1203
|
+
why: "if three things are the single most important, none is"
|
|
1204
|
+
},
|
|
1205
|
+
{ label: "That is / This is opener", pattern: /^(?:That\s+is|That['’]s|This\s+is)\b/, opener: true, floor: 4, per1000: 0.7, why: "pointing back instead of moving on" },
|
|
1206
|
+
{ label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
|
|
1207
|
+
{
|
|
1208
|
+
label: "Worth noting opener",
|
|
1209
|
+
pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
|
|
1210
|
+
opener: true,
|
|
1211
|
+
floor: 3,
|
|
1212
|
+
per1000: 0.5,
|
|
1213
|
+
why: "a hedge that lets a sentence start without committing"
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
label: "negation-led opener",
|
|
1217
|
+
pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
|
|
1218
|
+
opener: true,
|
|
1219
|
+
floor: 5,
|
|
1220
|
+
per1000: 1,
|
|
1221
|
+
why: "each earns its place once or twice; at twenty the document has one move"
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
label: "participle triad",
|
|
1225
|
+
pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
|
|
1226
|
+
floor: 3,
|
|
1227
|
+
per1000: 0.6,
|
|
1228
|
+
why: "'researched, written and referenced' is fine once and a cadence in bulk"
|
|
1229
|
+
}
|
|
1230
|
+
];
|
|
1231
|
+
function rateHits(ctx) {
|
|
1232
|
+
const hits = [];
|
|
1233
|
+
for (const doc2 of documents(ctx)) {
|
|
1234
|
+
const list = sentences(doc2);
|
|
1235
|
+
for (const rate of RATES) {
|
|
1236
|
+
const found = [];
|
|
1237
|
+
const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
|
|
1238
|
+
for (const s of list) {
|
|
1239
|
+
if (rate.opener) {
|
|
1240
|
+
if (rate.pattern.test(s.text)) found.push(s);
|
|
1241
|
+
} else {
|
|
1242
|
+
const n = (s.text.match(global) ?? []).length;
|
|
1243
|
+
for (let k = 0; k < n; k += 1) found.push(s);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const limit = allowed(doc2, rate.floor, rate.per1000);
|
|
1247
|
+
if (found.length > limit) {
|
|
1248
|
+
hits.push({
|
|
1249
|
+
path: doc2.path,
|
|
1250
|
+
offset: found[limit].offset,
|
|
1251
|
+
message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
|
|
1252
|
+
excerpt: found[limit].text.slice(0, 120)
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return hits;
|
|
1258
|
+
}
|
|
1259
|
+
function aphorismHits(ctx) {
|
|
1260
|
+
const hits = [];
|
|
1261
|
+
for (const doc2 of documents(ctx)) {
|
|
1262
|
+
const found = [];
|
|
1263
|
+
for (const block of doc2.blocks) {
|
|
1264
|
+
let at = 0;
|
|
1265
|
+
for (const line of withoutHeadings(block.text).split("\n")) {
|
|
1266
|
+
const text = line.trim().replace(/^[-*>]\s+/, "");
|
|
1267
|
+
const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
1268
|
+
if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
|
|
1269
|
+
found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
|
|
1270
|
+
}
|
|
1271
|
+
at += line.length + 1;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
const limit = allowed(doc2, 5, 1.2);
|
|
1275
|
+
if (found.length > limit) {
|
|
1276
|
+
hits.push({
|
|
1277
|
+
path: doc2.path,
|
|
1278
|
+
offset: found[limit].offset,
|
|
1279
|
+
message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
|
|
1280
|
+
excerpt: found[limit].text
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return hits;
|
|
1285
|
+
}
|
|
1286
|
+
var CONTRACTION = /\b(?:\w+n['’]t|\w+['’](?:re|ve|ll|m|d)|(?:it|that|there|here|what|who|he|she|let|where)['’]s)\b/gi;
|
|
1287
|
+
function contractionHits(ctx) {
|
|
1288
|
+
const hits = [];
|
|
1289
|
+
for (const doc2 of documents(ctx)) {
|
|
1290
|
+
if (doc2.words < 800) continue;
|
|
1291
|
+
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
1292
|
+
const count2 = (text.match(CONTRACTION) ?? []).length;
|
|
1293
|
+
const rate = count2 * 1e3 / doc2.words;
|
|
1294
|
+
if (rate < 1) {
|
|
1295
|
+
const first = sentences(doc2)[0];
|
|
1296
|
+
hits.push({
|
|
1297
|
+
path: doc2.path,
|
|
1298
|
+
offset: first?.offset ?? 0,
|
|
1299
|
+
message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return hits;
|
|
1304
|
+
}
|
|
1305
|
+
function rhythmHits(ctx) {
|
|
1306
|
+
const hits = [];
|
|
1307
|
+
for (const doc2 of documents(ctx)) {
|
|
1308
|
+
const list = sentences(doc2);
|
|
1309
|
+
if (list.length < 40) continue;
|
|
1310
|
+
const lengths = list.map((s) => countWords(s.text));
|
|
1311
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
1312
|
+
const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
|
|
1313
|
+
const cv = sd / mean;
|
|
1314
|
+
if (cv < 0.42) {
|
|
1315
|
+
hits.push({
|
|
1316
|
+
path: doc2.path,
|
|
1317
|
+
offset: list[0].offset,
|
|
1318
|
+
message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return hits;
|
|
1323
|
+
}
|
|
1324
|
+
var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
|
|
1325
|
+
function repeatHits(ctx) {
|
|
1326
|
+
const hits = [];
|
|
1327
|
+
const RUN = 9;
|
|
1328
|
+
for (const doc2 of documents(ctx)) {
|
|
1329
|
+
const whole = /* @__PURE__ */ new Map();
|
|
1330
|
+
const runs = /* @__PURE__ */ new Map();
|
|
1331
|
+
for (const s of sentences(doc2)) {
|
|
1332
|
+
const words = normal(s.text);
|
|
1333
|
+
if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
1334
|
+
const key = words.join(" ");
|
|
1335
|
+
const earlier = whole.get(key);
|
|
1336
|
+
if (earlier) {
|
|
1337
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
whole.set(key, s);
|
|
1341
|
+
let reported = false;
|
|
1342
|
+
for (let i = 0; i + RUN <= words.length && !reported; i += 1) {
|
|
1343
|
+
const run = words.slice(i, i + RUN).join(" ");
|
|
1344
|
+
const seen = runs.get(run);
|
|
1345
|
+
if (seen && seen.key !== key) {
|
|
1346
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `shares a ${RUN}-word run with an earlier sentence: "${run}"`, excerpt: s.text.slice(0, 120) });
|
|
1347
|
+
reported = true;
|
|
1348
|
+
} else if (!seen) {
|
|
1349
|
+
runs.set(run, { sentence: s, key });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return hits;
|
|
1355
|
+
}
|
|
1356
|
+
function headings(file) {
|
|
1357
|
+
const out = [];
|
|
1358
|
+
for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
1359
|
+
out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
1360
|
+
}
|
|
1361
|
+
for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
|
|
1362
|
+
const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
1363
|
+
if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
1364
|
+
}
|
|
1365
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
1366
|
+
}
|
|
1367
|
+
function longFiles(ctx) {
|
|
1368
|
+
const docs = new Map(documents(ctx).map((d) => [d.path, d]));
|
|
1369
|
+
return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
|
|
1370
|
+
}
|
|
1371
|
+
var HEADING_SHAPES = [
|
|
1372
|
+
["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
|
|
1373
|
+
["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
|
|
1374
|
+
["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
|
|
1375
|
+
];
|
|
1376
|
+
function headingShapeHits(ctx) {
|
|
1377
|
+
const hits = [];
|
|
1378
|
+
for (const { file, doc: doc2 } of longFiles(ctx)) {
|
|
1379
|
+
const found = [];
|
|
1380
|
+
for (const heading of headings(file)) {
|
|
1381
|
+
const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
|
|
1382
|
+
if (shape) found.push({ heading, shape: shape[0] });
|
|
1383
|
+
}
|
|
1384
|
+
const limit = allowed(doc2, 3, 0.5);
|
|
1385
|
+
if (found.length > limit) {
|
|
1386
|
+
hits.push({
|
|
1387
|
+
path: file.path,
|
|
1388
|
+
offset: found[limit].heading.offset,
|
|
1389
|
+
message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
|
|
1390
|
+
excerpt: found[limit].heading.text
|
|
1391
|
+
});
|
|
379
1392
|
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
1393
|
+
}
|
|
1394
|
+
return hits;
|
|
1395
|
+
}
|
|
1396
|
+
function headingEchoHits(ctx) {
|
|
1397
|
+
const hits = [];
|
|
1398
|
+
for (const { file } of longFiles(ctx)) {
|
|
1399
|
+
for (const heading of headings(file)) {
|
|
1400
|
+
const words = normal(heading.text);
|
|
1401
|
+
if (words.length < 4) continue;
|
|
1402
|
+
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
1403
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words.length);
|
|
1404
|
+
if (opening.join(" ") === words.join(" ")) {
|
|
1405
|
+
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
1406
|
+
}
|
|
393
1407
|
}
|
|
394
|
-
}
|
|
1408
|
+
}
|
|
1409
|
+
return hits;
|
|
1410
|
+
}
|
|
1411
|
+
var FILLER_WORDS = "boiler van kitchen garden studio market street window roof tile paint brush ladder hedge lawn fence gate shop counter oven bread flour kettle chair table shelf lamp cable socket switch meter valve pipe tap sink drain gutter brick mortar timber plank screw bolt hinge lock key door frame glass mirror towel basin morning evening winter summer spring autumn monday tuesday friday weekend hour minute week month year fitted checked cleaned painted mended booked quoted delivered collected measured sanded sealed wired local quiet small large early late busy bright warm dry".split(" ");
|
|
1412
|
+
function filler(words, seed = 1, options = {}) {
|
|
1413
|
+
const { contractions = true, uniform } = options;
|
|
1414
|
+
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
1415
|
+
let state = seed;
|
|
1416
|
+
const next = (n) => {
|
|
1417
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
1418
|
+
return state % n;
|
|
1419
|
+
};
|
|
1420
|
+
const out = [];
|
|
1421
|
+
let count2 = 0;
|
|
1422
|
+
let i = 0;
|
|
1423
|
+
while (count2 < words) {
|
|
1424
|
+
const length = uniform ?? shape[i % shape.length];
|
|
1425
|
+
const s = [];
|
|
1426
|
+
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
1427
|
+
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
1428
|
+
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
1429
|
+
out.push(`${s.join(" ")}.`);
|
|
1430
|
+
count2 += s.length;
|
|
1431
|
+
i += 1;
|
|
1432
|
+
}
|
|
1433
|
+
const paragraphs = [];
|
|
1434
|
+
for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
|
|
1435
|
+
return paragraphs.join("\n\n");
|
|
1436
|
+
}
|
|
1437
|
+
var f2 = (path, text) => ({ path, text });
|
|
1438
|
+
var doc = (extra, seed = 1) => `${filler(520, seed)}
|
|
1439
|
+
|
|
1440
|
+
${extra}
|
|
1441
|
+
`;
|
|
1442
|
+
var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
|
|
1443
|
+
var DENSITY_TELLS = [
|
|
395
1444
|
{
|
|
396
|
-
id: "
|
|
397
|
-
name: "
|
|
1445
|
+
id: "phrase-density",
|
|
1446
|
+
name: "Leaned-on phrase",
|
|
398
1447
|
generation: 2,
|
|
399
1448
|
severity: "warn",
|
|
400
1449
|
surface: "copy",
|
|
401
|
-
why: "
|
|
402
|
-
fix: "
|
|
403
|
-
detect:
|
|
1450
|
+
why: "'Rather than', 'actually', 'That is...' openers, participle triads: each is ordinary English once, and a document that uses one far above its rate has one move and repeats it.",
|
|
1451
|
+
fix: "Keep the instances that land hardest and vary or cut the rest. Never delete every one: the count is the problem, not the phrase.",
|
|
1452
|
+
detect: rateHits,
|
|
404
1453
|
fixtures: {
|
|
405
1454
|
flag: [
|
|
406
|
-
|
|
407
|
-
|
|
1455
|
+
f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
1456
|
+
f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
|
|
1457
|
+
f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
|
|
1458
|
+
f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
|
|
1459
|
+
f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
|
|
1460
|
+
f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
|
|
1461
|
+
f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
|
|
1462
|
+
f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
|
|
1463
|
+
f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
|
|
1464
|
+
f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
|
|
1465
|
+
f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
|
|
1466
|
+
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>`)
|
|
408
1467
|
],
|
|
409
1468
|
pass: [
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
1469
|
+
f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
1470
|
+
f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
|
|
1471
|
+
f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
|
|
413
1472
|
]
|
|
414
1473
|
}
|
|
415
1474
|
},
|
|
416
1475
|
{
|
|
417
|
-
id: "
|
|
418
|
-
name: "
|
|
419
|
-
generation:
|
|
1476
|
+
id: "aphorism-density",
|
|
1477
|
+
name: "Aphorism cadence",
|
|
1478
|
+
generation: 2,
|
|
420
1479
|
severity: "warn",
|
|
421
1480
|
surface: "copy",
|
|
422
|
-
why: "
|
|
423
|
-
fix: "
|
|
424
|
-
detect:
|
|
1481
|
+
why: "Two clipped sentences alone on a line ('All of this already exists. The job is making it findable.') land once. Closing every section, they are a cadence a reader learns to hear.",
|
|
1482
|
+
fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
|
|
1483
|
+
detect: aphorismHits,
|
|
425
1484
|
fixtures: {
|
|
426
|
-
flag: [
|
|
427
|
-
pass: [
|
|
1485
|
+
flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
|
|
1486
|
+
pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
|
|
428
1487
|
}
|
|
429
1488
|
},
|
|
430
1489
|
{
|
|
431
|
-
id: "
|
|
432
|
-
name: "
|
|
433
|
-
generation:
|
|
1490
|
+
id: "contraction-scarcity",
|
|
1491
|
+
name: "No contractions",
|
|
1492
|
+
generation: 1,
|
|
434
1493
|
severity: "warn",
|
|
435
1494
|
surface: "copy",
|
|
436
|
-
why: "
|
|
437
|
-
fix: "
|
|
438
|
-
detect:
|
|
1495
|
+
why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
|
|
1496
|
+
fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
|
|
1497
|
+
detect: contractionHits,
|
|
439
1498
|
fixtures: {
|
|
440
|
-
flag: [
|
|
441
|
-
|
|
442
|
-
f("content/home.md", "It's not a website, it's a growth engine."),
|
|
443
|
-
f("content/home.md", "I don't teach the treatment you'd choose: I teach the standard."),
|
|
444
|
-
f("content/home.md", "Hairdressing isn't a job, it's art."),
|
|
445
|
-
f("content/home.md", "It is less about the tools and more about the eye."),
|
|
446
|
-
f("content/home.md", "Order today and it's dispatched from here, not shipped in from Seoul."),
|
|
447
|
-
f("app/offer.tsx", `export const outcome = "The whole path, not just the ads.";`)
|
|
448
|
-
],
|
|
449
|
-
pass: [
|
|
450
|
-
f("content/home.md", "A haircut, a hot towel and a straight-razor finish."),
|
|
451
|
-
f("content/home.md", "They are cosmetics, not medicines."),
|
|
452
|
-
f("content/home.md", "The box arrived red, not led astray by a dented corner.")
|
|
453
|
-
]
|
|
1499
|
+
flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
|
|
1500
|
+
pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
|
|
454
1501
|
}
|
|
455
1502
|
},
|
|
456
1503
|
{
|
|
457
|
-
id: "
|
|
458
|
-
name: "
|
|
1504
|
+
id: "sentence-rhythm",
|
|
1505
|
+
name: "Metronomic rhythm",
|
|
459
1506
|
generation: 2,
|
|
460
1507
|
severity: "warn",
|
|
461
1508
|
surface: "copy",
|
|
462
|
-
why: "
|
|
463
|
-
fix: "
|
|
464
|
-
detect:
|
|
1509
|
+
why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
|
|
1510
|
+
fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
|
|
1511
|
+
detect: rhythmHits,
|
|
465
1512
|
fixtures: {
|
|
466
|
-
flag: [
|
|
467
|
-
|
|
468
|
-
f("app/hero.tsx", `export const Hero = () => <p className="mt-4">We reply within a day. No
|
|
469
|
-
obligation, no spam.</p>;`)
|
|
470
|
-
],
|
|
471
|
-
pass: [
|
|
472
|
-
f("content/home.md", "We reply within a day, and only about your enquiry."),
|
|
473
|
-
f("app/admin/list.tsx", `export const Empty = () => <p>No clients, no bookings.</p>;`),
|
|
474
|
-
f("app/list.tsx", `export const Empty = () => <p>No orders yet, no invoices found.</p>;`)
|
|
475
|
-
]
|
|
1513
|
+
flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
|
|
1514
|
+
pass: [f2("content/proposal.md", filler(600, 7))]
|
|
476
1515
|
}
|
|
477
1516
|
},
|
|
478
1517
|
{
|
|
479
|
-
id: "
|
|
480
|
-
name: "
|
|
481
|
-
generation:
|
|
1518
|
+
id: "repeated-sentence",
|
|
1519
|
+
name: "Repeated sentence",
|
|
1520
|
+
generation: 1,
|
|
482
1521
|
severity: "warn",
|
|
483
1522
|
surface: "copy",
|
|
484
|
-
why: "
|
|
485
|
-
fix: "
|
|
486
|
-
detect:
|
|
1523
|
+
why: "The same sentence on two pages, or the same nine words lightly edited, is one of the strongest signs a document was assembled rather than written.",
|
|
1524
|
+
fix: "Say it once, where it matters most, and write the second place fresh.",
|
|
1525
|
+
detect: repeatHits,
|
|
487
1526
|
fixtures: {
|
|
488
|
-
flag: [
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
]
|
|
1527
|
+
flag: [
|
|
1528
|
+
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.")),
|
|
1529
|
+
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."))
|
|
1530
|
+
],
|
|
1531
|
+
pass: [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."))]
|
|
494
1532
|
}
|
|
495
1533
|
},
|
|
496
1534
|
{
|
|
497
|
-
id: "
|
|
498
|
-
name: "
|
|
1535
|
+
id: "heading-shape",
|
|
1536
|
+
name: "One heading shape",
|
|
499
1537
|
generation: 2,
|
|
500
1538
|
severity: "warn",
|
|
501
1539
|
surface: "copy",
|
|
502
|
-
why: "
|
|
503
|
-
fix: "Write
|
|
504
|
-
detect:
|
|
1540
|
+
why: "Headings that all share one shape ('X, and Y', 'X, because Y', 'Keep, rewrite, consolidate, retire') read as a template filled in section by section.",
|
|
1541
|
+
fix: "Write each heading for its section. Most should be a plain noun phrase.",
|
|
1542
|
+
detect: headingShapeHits,
|
|
505
1543
|
fixtures: {
|
|
506
|
-
flag: [
|
|
507
|
-
|
|
1544
|
+
flag: [
|
|
1545
|
+
f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
|
|
1546
|
+
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>`),
|
|
1547
|
+
f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
|
|
1548
|
+
],
|
|
1549
|
+
pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
|
|
508
1550
|
}
|
|
509
1551
|
},
|
|
510
1552
|
{
|
|
511
|
-
id: "
|
|
512
|
-
name: "
|
|
1553
|
+
id: "heading-echo",
|
|
1554
|
+
name: "Heading echoed",
|
|
513
1555
|
generation: 2,
|
|
514
1556
|
severity: "warn",
|
|
515
1557
|
surface: "copy",
|
|
516
|
-
why: "
|
|
517
|
-
fix: "
|
|
518
|
-
detect:
|
|
1558
|
+
why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
|
|
1559
|
+
fix: "Let the first sentence say something the heading did not.",
|
|
1560
|
+
detect: headingEchoHits,
|
|
519
1561
|
fixtures: {
|
|
520
|
-
flag: [
|
|
521
|
-
|
|
1562
|
+
flag: [
|
|
1563
|
+
f2("content/proposal.md", `## We fix boilers the same day
|
|
1564
|
+
|
|
1565
|
+
We fix boilers the same day across Brackley.
|
|
1566
|
+
|
|
1567
|
+
${filler(520, 11)}`),
|
|
1568
|
+
f2("site/index.html", `<h2>We fix boilers the same day</h2>
|
|
1569
|
+
<p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
|
|
1570
|
+
],
|
|
1571
|
+
pass: [f2("content/proposal.md", `## We fix boilers the same day
|
|
1572
|
+
|
|
1573
|
+
Call before noon and an engineer is with you by six.
|
|
1574
|
+
|
|
1575
|
+
${filler(520, 11)}`)]
|
|
522
1576
|
}
|
|
523
1577
|
}
|
|
524
1578
|
];
|
|
@@ -618,8 +1672,8 @@ function parts(fn) {
|
|
|
618
1672
|
function hslToRgb(h, s, l) {
|
|
619
1673
|
const k = (n) => (n + h / 30) % 12;
|
|
620
1674
|
const a = s * Math.min(l, 1 - l);
|
|
621
|
-
const
|
|
622
|
-
return { r:
|
|
1675
|
+
const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
1676
|
+
return { r: f4(0), g: f4(8), b: f4(4) };
|
|
623
1677
|
}
|
|
624
1678
|
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
625
1679
|
function parseColour(raw) {
|
|
@@ -731,7 +1785,7 @@ function firstMatch(text, pattern) {
|
|
|
731
1785
|
return m ? m.index : -1;
|
|
732
1786
|
}
|
|
733
1787
|
var FRAMEWORK_PALETTE = /^--(?:color-)?(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+$|^--tw-/;
|
|
734
|
-
var
|
|
1788
|
+
var f3 = (path, text) => ({ path, text });
|
|
735
1789
|
function familyNames(value) {
|
|
736
1790
|
return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
737
1791
|
}
|
|
@@ -1196,14 +2250,14 @@ var SOURCE_TELLS = [
|
|
|
1196
2250
|
detect: fontTell(REFLEX_FONTS_1),
|
|
1197
2251
|
fixtures: {
|
|
1198
2252
|
flag: [
|
|
1199
|
-
|
|
2253
|
+
f3("app/layout.tsx", `import { Inter } from "next/font/google";
|
|
1200
2254
|
const inter = Inter({ subsets: ["latin"] });`),
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
2255
|
+
f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
|
|
2256
|
+
f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
|
|
2257
|
+
f3("main.ts", `import "@fontsource/montserrat";`),
|
|
2258
|
+
f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
|
|
1205
2259
|
],
|
|
1206
|
-
pass: [
|
|
2260
|
+
pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
|
|
1207
2261
|
}
|
|
1208
2262
|
},
|
|
1209
2263
|
{
|
|
@@ -1213,13 +2267,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1213
2267
|
severity: "warn",
|
|
1214
2268
|
surface: "source",
|
|
1215
2269
|
why: "Ban Inter and a model reaches for Inter Tight, DM Sans, Manrope, Space Grotesk or an Instrument/Fraunces serif. A swap inside the reflex list is not a decision.",
|
|
1216
|
-
fix: "
|
|
2270
|
+
fix: "Pick the display face from the client's world (signage, packaging, the trade's lettering) and record why in art-direction.json. If this face really is the right one, declare an exception with the reason and the evidence.",
|
|
1217
2271
|
detect: fontTell(REFLEX_FONTS_2),
|
|
1218
2272
|
fixtures: {
|
|
1219
|
-
flag: [
|
|
2273
|
+
flag: [f3("src/styles.css", `@theme {
|
|
1220
2274
|
--font-sans: "DM Sans", system-ui, sans-serif;
|
|
1221
2275
|
}`)],
|
|
1222
|
-
pass: [
|
|
2276
|
+
pass: [f3("src/styles.css", `@theme {
|
|
1223
2277
|
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
|
1224
2278
|
}`)]
|
|
1225
2279
|
}
|
|
@@ -1235,10 +2289,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1235
2289
|
detect: violetHits,
|
|
1236
2290
|
fixtures: {
|
|
1237
2291
|
flag: [
|
|
1238
|
-
|
|
1239
|
-
|
|
2292
|
+
f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
|
|
2293
|
+
f3("app/globals.css", `:root { --accent: #5a35d1; }`)
|
|
1240
2294
|
],
|
|
1241
|
-
pass: [
|
|
2295
|
+
pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
|
|
1242
2296
|
}
|
|
1243
2297
|
},
|
|
1244
2298
|
{
|
|
@@ -1252,10 +2306,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1252
2306
|
detect: gradientHits,
|
|
1253
2307
|
fixtures: {
|
|
1254
2308
|
flag: [
|
|
1255
|
-
|
|
1256
|
-
|
|
2309
|
+
f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
|
|
2310
|
+
f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
|
|
1257
2311
|
],
|
|
1258
|
-
pass: [
|
|
2312
|
+
pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
|
|
1259
2313
|
}
|
|
1260
2314
|
},
|
|
1261
2315
|
{
|
|
@@ -1269,10 +2323,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1269
2323
|
detect: gradientTextHits,
|
|
1270
2324
|
fixtures: {
|
|
1271
2325
|
flag: [
|
|
1272
|
-
|
|
1273
|
-
|
|
2326
|
+
f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
|
|
2327
|
+
f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
|
|
1274
2328
|
],
|
|
1275
|
-
pass: [
|
|
2329
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
|
|
1276
2330
|
}
|
|
1277
2331
|
},
|
|
1278
2332
|
{
|
|
@@ -1286,10 +2340,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1286
2340
|
detect: glassHits,
|
|
1287
2341
|
fixtures: {
|
|
1288
2342
|
flag: [
|
|
1289
|
-
|
|
1290
|
-
|
|
2343
|
+
f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
|
|
2344
|
+
f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
|
|
1291
2345
|
],
|
|
1292
|
-
pass: [
|
|
2346
|
+
pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
|
|
1293
2347
|
}
|
|
1294
2348
|
},
|
|
1295
2349
|
{
|
|
@@ -1302,10 +2356,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1302
2356
|
fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
|
|
1303
2357
|
detect: heroThenProofHits,
|
|
1304
2358
|
fixtures: {
|
|
1305
|
-
flag: [
|
|
2359
|
+
flag: [f3("app/page.tsx", `export default function Page() {
|
|
1306
2360
|
return (<main><Hero /><TrustStrip /><Services /></main>);
|
|
1307
2361
|
}`)],
|
|
1308
|
-
pass: [
|
|
2362
|
+
pass: [f3("app/page.tsx", `export default function Page() {
|
|
1309
2363
|
return (<main><Hero /><Workshop /><TrustStrip /></main>);
|
|
1310
2364
|
}`)]
|
|
1311
2365
|
}
|
|
@@ -1321,7 +2375,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1321
2375
|
detect: iconGridHits,
|
|
1322
2376
|
fixtures: {
|
|
1323
2377
|
flag: [
|
|
1324
|
-
|
|
2378
|
+
f3(
|
|
1325
2379
|
"components/Features.tsx",
|
|
1326
2380
|
`import { Heart } from "lucide-react";
|
|
1327
2381
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
@@ -1329,7 +2383,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1329
2383
|
</div>`
|
|
1330
2384
|
)
|
|
1331
2385
|
],
|
|
1332
|
-
pass: [
|
|
2386
|
+
pass: [f3("components/Features.tsx", `<ul className="divide-y">
|
|
1333
2387
|
<li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
|
|
1334
2388
|
</ul>`)]
|
|
1335
2389
|
}
|
|
@@ -1344,8 +2398,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1344
2398
|
fix: `Keep one gated signature moment per page (STANDARD section 7) and let the rest arrive still. The limit here is ${REVEAL_LIMIT} per scan.`,
|
|
1345
2399
|
detect: revealHits,
|
|
1346
2400
|
fixtures: {
|
|
1347
|
-
flag: [
|
|
1348
|
-
pass: [
|
|
2401
|
+
flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
|
|
2402
|
+
pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
|
|
1349
2403
|
<Block />
|
|
1350
2404
|
<Block />`)]
|
|
1351
2405
|
}
|
|
@@ -1356,536 +2410,251 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1356
2410
|
generation: 1,
|
|
1357
2411
|
severity: "warn",
|
|
1358
2412
|
surface: "source",
|
|
1359
|
-
why: "rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language.",
|
|
1360
|
-
fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
|
|
1361
|
-
detect: pillHits,
|
|
1362
|
-
fixtures: {
|
|
1363
|
-
flag: [f2("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
1364
|
-
pass: [f2("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
1365
|
-
<a className="rounded-sm px-4">Book</a>`)]
|
|
1366
|
-
}
|
|
1367
|
-
},
|
|
1368
|
-
{
|
|
1369
|
-
id: "shadcn-dump",
|
|
1370
|
-
name: "Stock component dump",
|
|
1371
|
-
generation: 1,
|
|
1372
|
-
severity: "warn",
|
|
1373
|
-
surface: "source",
|
|
1374
|
-
why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets.",
|
|
1375
|
-
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
1376
|
-
detect: shadcnHits,
|
|
1377
|
-
fixtures: {
|
|
1378
|
-
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f2(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
1379
|
-
pass: [f2("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
1380
|
-
}
|
|
1381
|
-
},
|
|
1382
|
-
{
|
|
1383
|
-
id: "cream-palette",
|
|
1384
|
-
name: "Cream ground",
|
|
1385
|
-
generation: 2,
|
|
1386
|
-
severity: "warn",
|
|
1387
|
-
surface: "source",
|
|
1388
|
-
why: "Once white-and-violet was named, models moved to a warm off-white: parchment, linen, oat. It reads as 'tasteful' in the same way everywhere.",
|
|
1389
|
-
fix: "Take the ground from the client's material world (paper stock, wall colour, tile). If it really is cream, say why.",
|
|
1390
|
-
detect: creamHits,
|
|
1391
|
-
fixtures: {
|
|
1392
|
-
flag: [
|
|
1393
|
-
f2("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
1394
|
-
f2("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
1395
|
-
f2("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
1396
|
-
],
|
|
1397
|
-
pass: [f2("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
1398
|
-
}
|
|
1399
|
-
},
|
|
1400
|
-
{
|
|
1401
|
-
id: "italic-serif-display",
|
|
1402
|
-
name: "Italic serif display",
|
|
1403
|
-
generation: 2,
|
|
1404
|
-
severity: "warn",
|
|
1405
|
-
surface: "source",
|
|
1406
|
-
why: "A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move.",
|
|
1407
|
-
fix: "Use emphasis only where the sentence needs it, and set it the way the display face wants, not by reflex.",
|
|
1408
|
-
detect: italicSerifHits,
|
|
1409
|
-
fixtures: {
|
|
1410
|
-
flag: [
|
|
1411
|
-
f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
1412
|
-
f2("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
1413
|
-
f2("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
1414
|
-
],
|
|
1415
|
-
pass: [f2("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
1416
|
-
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
1417
|
-
}
|
|
1418
|
-
},
|
|
1419
|
-
{
|
|
1420
|
-
id: "hero-eyebrow-chip",
|
|
1421
|
-
name: "Eyebrow chip above the hero",
|
|
1422
|
-
generation: 2,
|
|
1423
|
-
severity: "warn",
|
|
1424
|
-
surface: "source",
|
|
1425
|
-
why: "A small bordered pill ('New \xB7 Now booking') above the headline is in almost every generated hero.",
|
|
1426
|
-
fix: "Drop it. If the fact matters, put it in the headline or the first line of copy.",
|
|
1427
|
-
detect: eyebrowChipHits,
|
|
1428
|
-
fixtures: {
|
|
1429
|
-
flag: [
|
|
1430
|
-
f2("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
1431
|
-
<h1>Nails in Brackley</h1>`),
|
|
1432
|
-
f2("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
1433
|
-
<h1>Nails</h1>`)
|
|
1434
|
-
],
|
|
1435
|
-
pass: [f2("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
1436
|
-
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
1437
|
-
}
|
|
1438
|
-
},
|
|
1439
|
-
{
|
|
1440
|
-
id: "icon-tile-stack",
|
|
1441
|
-
name: "Icon in a tinted tile",
|
|
1442
|
-
generation: 2,
|
|
1443
|
-
severity: "warn",
|
|
1444
|
-
surface: "source",
|
|
1445
|
-
why: "A library icon in a soft rounded square, stacked above a heading, is the stock card header.",
|
|
1446
|
-
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
1447
|
-
detect: iconTileStackHits,
|
|
1448
|
-
fixtures: {
|
|
1449
|
-
flag: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
1450
|
-
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
1451
|
-
pass: [f2("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
1452
|
-
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
1453
|
-
}
|
|
1454
|
-
},
|
|
1455
|
-
{
|
|
1456
|
-
id: "bento-grid",
|
|
1457
|
-
name: "Bento grid",
|
|
1458
|
-
generation: 2,
|
|
1459
|
-
severity: "warn",
|
|
1460
|
-
surface: "source",
|
|
1461
|
-
why: "Mixed-size tiles in a grid became the default 'features' layout of the second wave.",
|
|
1462
|
-
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
1463
|
-
detect: bentoHits,
|
|
1464
|
-
fixtures: {
|
|
1465
|
-
flag: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
1466
|
-
pass: [f2("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
1467
|
-
}
|
|
1468
|
-
},
|
|
1469
|
-
{
|
|
1470
|
-
id: "radial-spotlight-glow",
|
|
1471
|
-
name: "Radial glow",
|
|
1472
|
-
generation: 2,
|
|
1473
|
-
severity: "warn",
|
|
1474
|
-
surface: "source",
|
|
1475
|
-
why: "A soft blurred blob or radial spotlight behind the hero is atmosphere without a subject.",
|
|
1476
|
-
fix: "Light the page with real imagery, or leave the ground flat.",
|
|
1477
|
-
detect: glowHits,
|
|
1478
|
-
fixtures: {
|
|
1479
|
-
flag: [
|
|
1480
|
-
f2("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
1481
|
-
f2("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
1482
|
-
],
|
|
1483
|
-
pass: [f2("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
1484
|
-
}
|
|
1485
|
-
},
|
|
1486
|
-
{
|
|
1487
|
-
id: "grid-background",
|
|
1488
|
-
name: "Graph-paper background",
|
|
1489
|
-
generation: 2,
|
|
1490
|
-
severity: "warn",
|
|
1491
|
-
surface: "source",
|
|
1492
|
-
why: "A faint 1px line or dot grid behind the hero is the developer-tool look, borrowed by everyone.",
|
|
1493
|
-
fix: "Remove it. If the brand has a pattern, draw it from the brand.",
|
|
1494
|
-
detect: gridBackgroundHits,
|
|
1495
|
-
fixtures: {
|
|
1496
|
-
flag: [
|
|
1497
|
-
f2(
|
|
1498
|
-
"app/globals.css",
|
|
1499
|
-
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
1500
|
-
),
|
|
1501
|
-
f2("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
1502
|
-
],
|
|
1503
|
-
pass: [f2("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
1504
|
-
}
|
|
1505
|
-
},
|
|
1506
|
-
{
|
|
1507
|
-
id: "marquee",
|
|
1508
|
-
name: "Scrolling marquee",
|
|
1509
|
-
generation: 2,
|
|
1510
|
-
severity: "warn",
|
|
1511
|
-
surface: "source",
|
|
1512
|
-
why: "An endless strip of logos or words is the second wave's trust strip. It moves so it looks alive.",
|
|
1513
|
-
fix: "Show proof still and in context: one named review, one accreditation, where the doubt arises.",
|
|
1514
|
-
detect: marqueeHits,
|
|
2413
|
+
why: "rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language.",
|
|
2414
|
+
fix: `Choose the shape language on purpose (and record it), then use the pill only where it means something. The limit here is ${PILL_LIMIT} per scan.`,
|
|
2415
|
+
detect: pillHits,
|
|
1515
2416
|
fixtures: {
|
|
1516
|
-
flag: [
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
],
|
|
1520
|
-
pass: [f2("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
2417
|
+
flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
2418
|
+
pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
2419
|
+
<a className="rounded-sm px-4">Book</a>`)]
|
|
1521
2420
|
}
|
|
1522
2421
|
},
|
|
1523
2422
|
{
|
|
1524
|
-
id: "
|
|
1525
|
-
name: "
|
|
1526
|
-
generation:
|
|
2423
|
+
id: "shadcn-dump",
|
|
2424
|
+
name: "Stock component dump",
|
|
2425
|
+
generation: 1,
|
|
1527
2426
|
severity: "warn",
|
|
1528
2427
|
surface: "source",
|
|
1529
|
-
why: "A
|
|
1530
|
-
fix: "
|
|
1531
|
-
detect:
|
|
2428
|
+
why: "A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets.",
|
|
2429
|
+
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
2430
|
+
detect: shadcnHits,
|
|
1532
2431
|
fixtures: {
|
|
1533
|
-
flag: [
|
|
1534
|
-
|
|
1535
|
-
f2("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
1536
|
-
],
|
|
1537
|
-
pass: [f2("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
2432
|
+
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
2433
|
+
pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
1538
2434
|
}
|
|
1539
2435
|
},
|
|
1540
2436
|
{
|
|
1541
|
-
id: "
|
|
1542
|
-
name: "
|
|
2437
|
+
id: "cream-palette",
|
|
2438
|
+
name: "Cream ground",
|
|
1543
2439
|
generation: 2,
|
|
1544
2440
|
severity: "warn",
|
|
1545
2441
|
surface: "source",
|
|
1546
|
-
why: "
|
|
1547
|
-
fix: "
|
|
1548
|
-
detect:
|
|
1549
|
-
fixtures: {
|
|
1550
|
-
flag: [
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
],
|
|
1555
|
-
pass: [
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
];
|
|
1559
|
-
|
|
1560
|
-
// src/character/parse.ts
|
|
1561
|
-
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
1562
|
-
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
1563
|
-
var CSS = /\.(css|scss|sass|less|pcss)$/i;
|
|
1564
|
-
var PROSE = /\.(md|markdown|txt)$/i;
|
|
1565
|
-
function fileKind(path) {
|
|
1566
|
-
if (MARKUP.test(path)) return "markup";
|
|
1567
|
-
if (CSS.test(path)) return "css";
|
|
1568
|
-
if (SCRIPT.test(path)) return "script";
|
|
1569
|
-
if (PROSE.test(path)) return "prose";
|
|
1570
|
-
return "other";
|
|
1571
|
-
}
|
|
1572
|
-
function lineAt(text, offset) {
|
|
1573
|
-
let line = 1;
|
|
1574
|
-
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
1575
|
-
if (text.charCodeAt(i) === 10) line += 1;
|
|
1576
|
-
}
|
|
1577
|
-
return line;
|
|
1578
|
-
}
|
|
1579
|
-
function excerptAt(text, offset, max = 140) {
|
|
1580
|
-
const start = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
|
|
1581
|
-
const endRaw = text.indexOf("\n", offset);
|
|
1582
|
-
const line = text.slice(start, endRaw === -1 ? text.length : endRaw).trim();
|
|
1583
|
-
return line.length > max ? `${line.slice(0, max - 1)}\u2026` : line;
|
|
1584
|
-
}
|
|
1585
|
-
var LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
|
|
1586
|
-
function stringLiterals(text, base = 0) {
|
|
1587
|
-
const out = [];
|
|
1588
|
-
for (const m of text.matchAll(LITERAL)) {
|
|
1589
|
-
out.push({ value: m[0].slice(1, -1), offset: base + (m.index ?? 0) });
|
|
1590
|
-
}
|
|
1591
|
-
return out;
|
|
1592
|
-
}
|
|
1593
|
-
function matchBracket(text, open) {
|
|
1594
|
-
const pairs = { "{": "}", "(": ")", "[": "]" };
|
|
1595
|
-
const close = pairs[text[open]];
|
|
1596
|
-
const opener = text[open];
|
|
1597
|
-
let depth = 0;
|
|
1598
|
-
for (let i = open; i < text.length; i += 1) {
|
|
1599
|
-
const ch = text[i];
|
|
1600
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
1601
|
-
const end = skipString(text, i);
|
|
1602
|
-
i = end - 1;
|
|
1603
|
-
continue;
|
|
1604
|
-
}
|
|
1605
|
-
if (ch === opener) depth += 1;
|
|
1606
|
-
else if (ch === close) {
|
|
1607
|
-
depth -= 1;
|
|
1608
|
-
if (depth === 0) return i + 1;
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
return text.length;
|
|
1612
|
-
}
|
|
1613
|
-
function skipString(text, start) {
|
|
1614
|
-
const quote = text[start];
|
|
1615
|
-
for (let i = start + 1; i < text.length; i += 1) {
|
|
1616
|
-
if (text[i] === "\\") {
|
|
1617
|
-
i += 1;
|
|
1618
|
-
continue;
|
|
1619
|
-
}
|
|
1620
|
-
if (text[i] === quote) return i + 1;
|
|
1621
|
-
if (text[i] === "\n" && quote !== "`") return i + 1;
|
|
1622
|
-
}
|
|
1623
|
-
return text.length;
|
|
1624
|
-
}
|
|
1625
|
-
function split(classes) {
|
|
1626
|
-
return classes.split(/\s+/).filter((c) => c.length > 0 && !c.includes("${"));
|
|
1627
|
-
}
|
|
1628
|
-
function classUnits(text) {
|
|
1629
|
-
const units = [];
|
|
1630
|
-
const covered = [];
|
|
1631
|
-
const attr = /\b(?:className|class|:class)\s*=\s*/g;
|
|
1632
|
-
for (const m of text.matchAll(attr)) {
|
|
1633
|
-
const at = (m.index ?? 0) + m[0].length;
|
|
1634
|
-
const ch = text[at];
|
|
1635
|
-
if (ch === '"' || ch === "'") {
|
|
1636
|
-
const end = skipString(text, at);
|
|
1637
|
-
units.push({ classes: split(text.slice(at + 1, end - 1)), offset: m.index ?? 0 });
|
|
1638
|
-
} else if (ch === "{") {
|
|
1639
|
-
const end = matchBracket(text, at);
|
|
1640
|
-
const joined = stringLiterals(text.slice(at, end)).map((l) => l.value).join(" ");
|
|
1641
|
-
units.push({ classes: split(joined), offset: m.index ?? 0 });
|
|
1642
|
-
covered.push([at, end]);
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
const call = /\b(cn|clsx|twMerge|cx|cva|tv)\s*\(/g;
|
|
1646
|
-
for (const m of text.matchAll(call)) {
|
|
1647
|
-
const start = m.index ?? 0;
|
|
1648
|
-
if (covered.some(([a, b]) => start >= a && start < b)) continue;
|
|
1649
|
-
const open = start + m[0].length - 1;
|
|
1650
|
-
const end = matchBracket(text, open);
|
|
1651
|
-
const literals = stringLiterals(text.slice(open, end), open);
|
|
1652
|
-
if (m[1] === "cva" || m[1] === "tv") {
|
|
1653
|
-
for (const l of literals) units.push({ classes: split(l.value), offset: l.offset });
|
|
1654
|
-
} else {
|
|
1655
|
-
units.push({ classes: split(literals.map((l) => l.value).join(" ")), offset: start });
|
|
1656
|
-
}
|
|
1657
|
-
covered.push([open, end]);
|
|
1658
|
-
}
|
|
1659
|
-
return units.filter((u) => u.classes.length > 0);
|
|
1660
|
-
}
|
|
1661
|
-
function blankComments(css) {
|
|
1662
|
-
return css.replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, " "));
|
|
1663
|
-
}
|
|
1664
|
-
function cssDeclarations(css, base = 0) {
|
|
1665
|
-
const src = blankComments(css);
|
|
1666
|
-
const out = [];
|
|
1667
|
-
const stack = [];
|
|
1668
|
-
let segStart = 0;
|
|
1669
|
-
let parens = 0;
|
|
1670
|
-
const flush = (end) => {
|
|
1671
|
-
const seg = src.slice(segStart, end);
|
|
1672
|
-
const colon = seg.indexOf(":");
|
|
1673
|
-
if (stack.length === 0 || colon === -1) return;
|
|
1674
|
-
const property = seg.slice(0, colon).trim();
|
|
1675
|
-
if (!/^(--)?[a-zA-Z-]+$/.test(property)) return;
|
|
1676
|
-
const lead = seg.length - seg.trimStart().length;
|
|
1677
|
-
out.push({
|
|
1678
|
-
property: property.toLowerCase(),
|
|
1679
|
-
value: seg.slice(colon + 1).trim(),
|
|
1680
|
-
selector: stack[stack.length - 1],
|
|
1681
|
-
offset: base + segStart + lead
|
|
1682
|
-
});
|
|
1683
|
-
};
|
|
1684
|
-
for (let i = 0; i < src.length; i += 1) {
|
|
1685
|
-
const ch = src[i];
|
|
1686
|
-
if (ch === '"' || ch === "'") {
|
|
1687
|
-
i = skipString(src, i) - 1;
|
|
1688
|
-
continue;
|
|
1689
|
-
}
|
|
1690
|
-
if (ch === "(") parens += 1;
|
|
1691
|
-
else if (ch === ")") parens = Math.max(0, parens - 1);
|
|
1692
|
-
if (parens > 0) continue;
|
|
1693
|
-
if (ch === "{") {
|
|
1694
|
-
stack.push(src.slice(segStart, i).trim());
|
|
1695
|
-
segStart = i + 1;
|
|
1696
|
-
} else if (ch === ";") {
|
|
1697
|
-
flush(i);
|
|
1698
|
-
segStart = i + 1;
|
|
1699
|
-
} else if (ch === "}") {
|
|
1700
|
-
flush(i);
|
|
1701
|
-
stack.pop();
|
|
1702
|
-
segStart = i + 1;
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
return out;
|
|
1706
|
-
}
|
|
1707
|
-
function styleBlocks(text) {
|
|
1708
|
-
const out = [];
|
|
1709
|
-
for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
1710
|
-
const bodyStart = (m.index ?? 0) + m[0].indexOf(">") + 1;
|
|
1711
|
-
out.push(...cssDeclarations(m[1], bodyStart));
|
|
1712
|
-
}
|
|
1713
|
-
return out;
|
|
1714
|
-
}
|
|
1715
|
-
function parseFile(file) {
|
|
1716
|
-
const kind = fileKind(file.path);
|
|
1717
|
-
return {
|
|
1718
|
-
...file,
|
|
1719
|
-
kind,
|
|
1720
|
-
classUnits: kind === "markup" || kind === "script" ? classUnits(file.text) : [],
|
|
1721
|
-
declarations: kind === "css" ? cssDeclarations(file.text) : kind === "markup" ? styleBlocks(file.text) : []
|
|
1722
|
-
};
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
// src/character/prose.ts
|
|
1726
|
-
var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
1727
|
-
function readsAsProse(value) {
|
|
1728
|
-
if (value.length < 12 || !/\s/.test(value)) return false;
|
|
1729
|
-
if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
|
|
1730
|
-
const words = withoutMarkup(value);
|
|
1731
|
-
if (looksLikeCode(words) || /[<>]/.test(words)) return false;
|
|
1732
|
-
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words);
|
|
1733
|
-
}
|
|
1734
|
-
function withoutMarkup(value) {
|
|
1735
|
-
return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
|
|
1736
|
-
}
|
|
1737
|
-
function looksLikeCode(words) {
|
|
1738
|
-
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words);
|
|
1739
|
-
}
|
|
1740
|
-
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)$/;
|
|
1741
|
-
function looksLikeClasses(value) {
|
|
1742
|
-
const tokens = value.trim().split(/\s+/);
|
|
1743
|
-
if (tokens.length === 0 || !tokens.every((t) => /^[!\w@:/[\]().#%,>&*+=~-]+$/.test(t))) return false;
|
|
1744
|
-
const classy = tokens.filter((t) => /[-:[\]/]/.test(t) || UTILITY.test(t)).length;
|
|
1745
|
-
return classy / tokens.length >= 0.6 && /[a-z]-|:|\[/.test(value);
|
|
1746
|
-
}
|
|
1747
|
-
function looksLikeAddress(value) {
|
|
1748
|
-
return /^(?:\.{0,2}\/|@\/|https?:|mailto:|tel:)/.test(value);
|
|
1749
|
-
}
|
|
1750
|
-
function readsAsText(value) {
|
|
1751
|
-
if (value.trim().length === 0) return false;
|
|
1752
|
-
if (looksLikeAddress(value) || looksLikeClasses(value)) return false;
|
|
1753
|
-
return /[^\s\w]|[a-zA-Z]/.test(value);
|
|
1754
|
-
}
|
|
1755
|
-
function literalIsText(value) {
|
|
1756
|
-
return readsAsText(value) && !looksLikeCode(withoutMarkup(value));
|
|
1757
|
-
}
|
|
1758
|
-
function maskScriptComments(text) {
|
|
1759
|
-
let out = "";
|
|
1760
|
-
let i = 0;
|
|
1761
|
-
while (i < text.length) {
|
|
1762
|
-
const ch = text[i];
|
|
1763
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
1764
|
-
const start = i;
|
|
1765
|
-
i += 1;
|
|
1766
|
-
while (i < text.length) {
|
|
1767
|
-
if (text[i] === "\\") {
|
|
1768
|
-
i += 2;
|
|
1769
|
-
continue;
|
|
1770
|
-
}
|
|
1771
|
-
if (text[i] === ch) {
|
|
1772
|
-
i += 1;
|
|
1773
|
-
break;
|
|
1774
|
-
}
|
|
1775
|
-
if (text[i] === "\n" && ch !== "`") break;
|
|
1776
|
-
i += 1;
|
|
1777
|
-
}
|
|
1778
|
-
out += text.slice(start, i);
|
|
1779
|
-
continue;
|
|
2442
|
+
why: "Once white-and-violet was named, models moved to a warm off-white: parchment, linen, oat. It reads as 'tasteful' in the same way everywhere.",
|
|
2443
|
+
fix: "Take the ground from the client's material world (paper stock, wall colour, tile). If it really is cream, say why.",
|
|
2444
|
+
detect: creamHits,
|
|
2445
|
+
fixtures: {
|
|
2446
|
+
flag: [
|
|
2447
|
+
f3("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
2448
|
+
f3("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
2449
|
+
f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
2450
|
+
],
|
|
2451
|
+
pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
1780
2452
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2453
|
+
},
|
|
2454
|
+
{
|
|
2455
|
+
id: "italic-serif-display",
|
|
2456
|
+
name: "Italic serif display",
|
|
2457
|
+
generation: 2,
|
|
2458
|
+
severity: "warn",
|
|
2459
|
+
surface: "source",
|
|
2460
|
+
why: "A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move.",
|
|
2461
|
+
fix: "Use emphasis only where the sentence needs it, and set it the way the display face wants, not by reflex.",
|
|
2462
|
+
detect: italicSerifHits,
|
|
2463
|
+
fixtures: {
|
|
2464
|
+
flag: [
|
|
2465
|
+
f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
2466
|
+
f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
2467
|
+
f3("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
2468
|
+
],
|
|
2469
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
2470
|
+
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
1787
2471
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2472
|
+
},
|
|
2473
|
+
{
|
|
2474
|
+
id: "hero-eyebrow-chip",
|
|
2475
|
+
name: "Eyebrow chip above the hero",
|
|
2476
|
+
generation: 2,
|
|
2477
|
+
severity: "warn",
|
|
2478
|
+
surface: "source",
|
|
2479
|
+
why: "A small bordered pill ('New \xB7 Now booking') above the headline is in almost every generated hero.",
|
|
2480
|
+
fix: "Drop it. If the fact matters, put it in the headline or the first line of copy.",
|
|
2481
|
+
detect: eyebrowChipHits,
|
|
2482
|
+
fixtures: {
|
|
2483
|
+
flag: [
|
|
2484
|
+
f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
2485
|
+
<h1>Nails in Brackley</h1>`),
|
|
2486
|
+
f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
2487
|
+
<h1>Nails</h1>`)
|
|
2488
|
+
],
|
|
2489
|
+
pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
2490
|
+
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
1794
2491
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
const after = masked[(m.index ?? 0) + m[0].length];
|
|
1811
|
-
if (m[1] === ">" && /[=-]/.test(before)) continue;
|
|
1812
|
-
if (m[1] === "}" && (/^\s*(?:[)\],;:]|(?:else|catch|finally|return|from|as|if|const|let|export|function)\b)/.test(body) || /;\s*$/.test(body))) continue;
|
|
1813
|
-
if (m[1] === "}" && after === "{" && (/[=()`'"]/.test(body) || !/[a-zA-Z]{2,}|[^\s\w,|&?:]/.test(body))) continue;
|
|
1814
|
-
if (body.trim().length === 0) continue;
|
|
1815
|
-
if (m[1] === ">" && after === "<" && /^[\s—–-]*$/.test(body)) continue;
|
|
1816
|
-
const at = (m.index ?? 0) + 1;
|
|
1817
|
-
textNodes.push({ path: file.path, text: body, offset: at });
|
|
1818
|
-
rest = rest.slice(0, at) + blank(body) + rest.slice(at + body.length);
|
|
1819
|
-
}
|
|
1820
|
-
return { textNodes, literals: splitHtml(stringLiterals(rest)) };
|
|
1821
|
-
}
|
|
1822
|
-
function textNodeIsProse(body) {
|
|
1823
|
-
return /[a-zA-Z]{2,}/.test(body) && /\s/.test(body.trim()) || /[a-zA-Z]{4,}[.!?]/.test(body);
|
|
1824
|
-
}
|
|
1825
|
-
function proseBlocks(file) {
|
|
1826
|
-
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);
|
|
1827
|
-
return [{ path: file.path, text: masked, offset: 0 }];
|
|
1828
|
-
}
|
|
1829
|
-
function isData(path) {
|
|
1830
|
-
return /\.jsonl?$/i.test(path);
|
|
1831
|
-
}
|
|
1832
|
-
function scriptLiterals(file) {
|
|
1833
|
-
return splitHtml(stringLiterals(isData(file.path) ? file.text : maskScriptComments(file.text)));
|
|
1834
|
-
}
|
|
1835
|
-
function splitHtml(literals) {
|
|
1836
|
-
const out = [];
|
|
1837
|
-
for (const l of literals) {
|
|
1838
|
-
if (!/<[a-zA-Z][^<>]*>/.test(l.value)) {
|
|
1839
|
-
out.push(l);
|
|
1840
|
-
continue;
|
|
2492
|
+
},
|
|
2493
|
+
{
|
|
2494
|
+
id: "icon-tile-stack",
|
|
2495
|
+
name: "Icon in a tinted tile",
|
|
2496
|
+
generation: 2,
|
|
2497
|
+
severity: "warn",
|
|
2498
|
+
surface: "source",
|
|
2499
|
+
why: "A library icon in a soft rounded square, stacked above a heading, is the stock card header.",
|
|
2500
|
+
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
2501
|
+
detect: iconTileStackHits,
|
|
2502
|
+
fixtures: {
|
|
2503
|
+
flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2504
|
+
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
2505
|
+
pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2506
|
+
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
1841
2507
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
2508
|
+
},
|
|
2509
|
+
{
|
|
2510
|
+
id: "bento-grid",
|
|
2511
|
+
name: "Bento grid",
|
|
2512
|
+
generation: 2,
|
|
2513
|
+
severity: "warn",
|
|
2514
|
+
surface: "source",
|
|
2515
|
+
why: "Mixed-size tiles in a grid became the default 'features' layout of the second wave.",
|
|
2516
|
+
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
2517
|
+
detect: bentoHits,
|
|
2518
|
+
fixtures: {
|
|
2519
|
+
flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
2520
|
+
pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
1847
2521
|
}
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2522
|
+
},
|
|
2523
|
+
{
|
|
2524
|
+
id: "radial-spotlight-glow",
|
|
2525
|
+
name: "Radial glow",
|
|
2526
|
+
generation: 2,
|
|
2527
|
+
severity: "warn",
|
|
2528
|
+
surface: "source",
|
|
2529
|
+
why: "A soft blurred blob or radial spotlight behind the hero is atmosphere without a subject.",
|
|
2530
|
+
fix: "Light the page with real imagery, or leave the ground flat.",
|
|
2531
|
+
detect: glowHits,
|
|
2532
|
+
fixtures: {
|
|
2533
|
+
flag: [
|
|
2534
|
+
f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
2535
|
+
f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
2536
|
+
],
|
|
2537
|
+
pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
2538
|
+
}
|
|
2539
|
+
},
|
|
2540
|
+
{
|
|
2541
|
+
id: "grid-background",
|
|
2542
|
+
name: "Graph-paper background",
|
|
2543
|
+
generation: 2,
|
|
2544
|
+
severity: "warn",
|
|
2545
|
+
surface: "source",
|
|
2546
|
+
why: "A faint 1px line or dot grid behind the hero is the developer-tool look, borrowed by everyone.",
|
|
2547
|
+
fix: "Remove it. If the brand has a pattern, draw it from the brand.",
|
|
2548
|
+
detect: gridBackgroundHits,
|
|
2549
|
+
fixtures: {
|
|
2550
|
+
flag: [
|
|
2551
|
+
f3(
|
|
2552
|
+
"app/globals.css",
|
|
2553
|
+
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
2554
|
+
),
|
|
2555
|
+
f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
2556
|
+
],
|
|
2557
|
+
pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
2558
|
+
}
|
|
2559
|
+
},
|
|
2560
|
+
{
|
|
2561
|
+
id: "marquee",
|
|
2562
|
+
name: "Scrolling marquee",
|
|
2563
|
+
generation: 2,
|
|
2564
|
+
severity: "warn",
|
|
2565
|
+
surface: "source",
|
|
2566
|
+
why: "An endless strip of logos or words is the second wave's trust strip. It moves so it looks alive.",
|
|
2567
|
+
fix: "Show proof still and in context: one named review, one accreditation, where the doubt arises.",
|
|
2568
|
+
detect: marqueeHits,
|
|
2569
|
+
fixtures: {
|
|
2570
|
+
flag: [
|
|
2571
|
+
f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
|
|
2572
|
+
f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
|
|
2573
|
+
],
|
|
2574
|
+
pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
2575
|
+
}
|
|
2576
|
+
},
|
|
2577
|
+
{
|
|
2578
|
+
id: "thin-border-wide-shadow",
|
|
2579
|
+
name: "Hairline border, wide shadow",
|
|
2580
|
+
generation: 2,
|
|
2581
|
+
severity: "warn",
|
|
2582
|
+
surface: "source",
|
|
2583
|
+
why: "A near-invisible border under a large soft shadow is the stock 'elevated card'.",
|
|
2584
|
+
fix: "Pick one: a border that is visible, or a shadow the light source explains. Keep within the four-shadow budget.",
|
|
2585
|
+
detect: thinBorderWideShadowHits,
|
|
2586
|
+
fixtures: {
|
|
2587
|
+
flag: [
|
|
2588
|
+
f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
|
|
2589
|
+
f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
2590
|
+
],
|
|
2591
|
+
pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
2592
|
+
}
|
|
2593
|
+
},
|
|
2594
|
+
{
|
|
2595
|
+
id: "intro-cinematic",
|
|
2596
|
+
name: "Intro cinematic",
|
|
2597
|
+
generation: 2,
|
|
2598
|
+
severity: "warn",
|
|
2599
|
+
surface: "source",
|
|
2600
|
+
why: "A logo animation that runs before the page is something a model adds to make a site feel premium. Three estate sites open with one. It delays the page for every visitor.",
|
|
2601
|
+
fix: "Remove it. Spend the signature moment on something the visitor came for.",
|
|
2602
|
+
detect: introHits,
|
|
2603
|
+
fixtures: {
|
|
2604
|
+
flag: [
|
|
2605
|
+
f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
|
|
2606
|
+
export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
2607
|
+
f3("app/page.tsx", `<><Preloader /><Hero /></>`)
|
|
2608
|
+
],
|
|
2609
|
+
pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
|
|
1875
2610
|
}
|
|
1876
|
-
return out;
|
|
1877
|
-
}
|
|
1878
|
-
if (kind === "markup") {
|
|
1879
|
-
const { textNodes, literals } = markupParts(file);
|
|
1880
|
-
return [
|
|
1881
|
-
...textNodes.filter((b) => readsAsText(b.text)),
|
|
1882
|
-
...literals.filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }))
|
|
1883
|
-
];
|
|
1884
|
-
}
|
|
1885
|
-
if (kind === "script" || isData(file.path)) {
|
|
1886
|
-
return scriptLiterals(file).filter((l) => literalIsText(l.value)).map((l) => ({ path: file.path, text: l.value, offset: l.offset + 1, literal: true }));
|
|
1887
2611
|
}
|
|
1888
|
-
|
|
2612
|
+
];
|
|
2613
|
+
|
|
2614
|
+
// src/character/house.ts
|
|
2615
|
+
var HOUSE = {
|
|
2616
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
2617
|
+
emoji: { tier: "block", label: "emoji" },
|
|
2618
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
2619
|
+
"no-x-no-y": {
|
|
2620
|
+
tier: "block",
|
|
2621
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
2622
|
+
},
|
|
2623
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
2624
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
2625
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
2626
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
2627
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
2628
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
2629
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
2630
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
2631
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
2632
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
2633
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
2634
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
2635
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
2636
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
2637
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
2638
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
2639
|
+
"false-range": { tier: "review", label: "false range" },
|
|
2640
|
+
// The first candidate for block: evidence of a pasted chat reply, not a
|
|
2641
|
+
// matter of style. Blocks once its estate hits have been read (ROADMAP.md).
|
|
2642
|
+
"chatbot-residue": { tier: "review", label: "chatbot residue" },
|
|
2643
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
2644
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
2645
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
2646
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
2647
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
2648
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
2649
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
2650
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
2651
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
2652
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
2653
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
2654
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
2655
|
+
};
|
|
2656
|
+
function houseRule(id, name = id) {
|
|
2657
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
1889
2658
|
}
|
|
1890
2659
|
|
|
1891
2660
|
// src/snapshot/fixture.ts
|
|
@@ -1958,15 +2727,16 @@ function makeSnapshot(patch = {}) {
|
|
|
1958
2727
|
}
|
|
1959
2728
|
|
|
1960
2729
|
// src/snapshot/fonts.ts
|
|
2730
|
+
var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
|
|
1961
2731
|
function normaliseFamily(raw) {
|
|
1962
2732
|
let name = raw.replace(/["']/g, "").trim();
|
|
1963
2733
|
const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
|
|
1964
2734
|
if (next) name = next[1].replace(/_/g, " ");
|
|
1965
2735
|
name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
|
|
2736
|
+
if (SYSTEM.test(name)) return "system-ui";
|
|
1966
2737
|
if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
|
|
1967
2738
|
name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
|
|
1968
2739
|
}
|
|
1969
|
-
if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
|
|
1970
2740
|
return name;
|
|
1971
2741
|
}
|
|
1972
2742
|
var KNOWN = {
|
|
@@ -2017,8 +2787,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
|
2017
2787
|
function reflexFonts(list) {
|
|
2018
2788
|
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
2019
2789
|
return (s) => {
|
|
2020
|
-
const total = s.fonts.reduce((n,
|
|
2021
|
-
const display = s.fonts.reduce((n,
|
|
2790
|
+
const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
|
|
2791
|
+
const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
|
|
2022
2792
|
const hits = [];
|
|
2023
2793
|
const seen = /* @__PURE__ */ new Set();
|
|
2024
2794
|
const h1 = s.headings.find((h) => h.level === 1);
|
|
@@ -2219,8 +2989,8 @@ var RENDERED_PATHS = {
|
|
|
2219
2989
|
};
|
|
2220
2990
|
|
|
2221
2991
|
// src/character/check.ts
|
|
2222
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
2223
|
-
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
|
|
2992
|
+
var CATALOGUE_VERSION = "2026.09.6";
|
|
2993
|
+
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
|
|
2224
2994
|
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
2225
2995
|
);
|
|
2226
2996
|
function tellById(id) {
|
|
@@ -2255,10 +3025,11 @@ function toFinding(tell, hit2, texts) {
|
|
|
2255
3025
|
line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
|
|
2256
3026
|
excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
|
|
2257
3027
|
message: hit2.message,
|
|
2258
|
-
fix: tell.fix
|
|
3028
|
+
fix: tell.fix,
|
|
3029
|
+
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
2259
3030
|
};
|
|
2260
3031
|
}
|
|
2261
|
-
function report(tells, files,
|
|
3032
|
+
function report(tells, files, run, options) {
|
|
2262
3033
|
const { applied, rejected } = validateExceptions(options.exceptions ?? []);
|
|
2263
3034
|
const texts = new Map(files.map((file) => [file.path, file.text]));
|
|
2264
3035
|
const selected = options.only ? tells.filter((t) => options.only.includes(t.id)) : tells;
|
|
@@ -2272,7 +3043,7 @@ function report(tells, files, run2, options) {
|
|
|
2272
3043
|
};
|
|
2273
3044
|
const excepted = /* @__PURE__ */ new Map();
|
|
2274
3045
|
for (const tell of selected) {
|
|
2275
|
-
for (const hit2 of
|
|
3046
|
+
for (const hit2 of run(tell)) {
|
|
2276
3047
|
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit2.path.includes(e.path)));
|
|
2277
3048
|
if (exception) {
|
|
2278
3049
|
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
@@ -2308,6 +3079,10 @@ function report(tells, files, run2, options) {
|
|
|
2308
3079
|
}
|
|
2309
3080
|
};
|
|
2310
3081
|
}
|
|
3082
|
+
function applyHouseGate(result) {
|
|
3083
|
+
const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
|
|
3084
|
+
return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
|
|
3085
|
+
}
|
|
2311
3086
|
function scanSource(files, options = {}) {
|
|
2312
3087
|
const ctx = { files: files.map(parseFile) };
|
|
2313
3088
|
const tells = CATALOGUE.filter((t) => t.surface === "source");
|
|
@@ -2328,196 +3103,6 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
2328
3103
|
return result;
|
|
2329
3104
|
}
|
|
2330
3105
|
|
|
2331
|
-
// src/character/cli.ts
|
|
2332
|
-
import { execFileSync } from "child_process";
|
|
2333
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
2334
|
-
import { join, relative, resolve } from "path";
|
|
2335
|
-
|
|
2336
|
-
// src/character/format.ts
|
|
2337
|
-
function formatReport(report2, title) {
|
|
2338
|
-
const lines = [];
|
|
2339
|
-
let current = "";
|
|
2340
|
-
for (const f3 of report2.findings) {
|
|
2341
|
-
if (f3.path !== current) {
|
|
2342
|
-
current = f3.path;
|
|
2343
|
-
lines.push("", f3.path);
|
|
2344
|
-
}
|
|
2345
|
-
lines.push(` ${String(f3.line).padStart(4)} ${f3.tell} (gen ${f3.generation}, ${f3.severity}) ${f3.message}`);
|
|
2346
|
-
lines.push(` ${f3.excerpt}`);
|
|
2347
|
-
}
|
|
2348
|
-
const fixes = new Map(report2.findings.map((f3) => [f3.tell, f3.fix]));
|
|
2349
|
-
if (fixes.size > 0) {
|
|
2350
|
-
lines.push("", "What to do instead:");
|
|
2351
|
-
for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
|
|
2352
|
-
}
|
|
2353
|
-
for (const r of report2.rejectedExceptions) {
|
|
2354
|
-
lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
|
|
2355
|
-
}
|
|
2356
|
-
for (const e of report2.excepted) {
|
|
2357
|
-
lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
|
|
2358
|
-
}
|
|
2359
|
-
if (report2.suppressed.length > 0) {
|
|
2360
|
-
lines.push("", `Suppressed: ${report2.suppressed.length} marked copy-ok or craft-ok (${report2.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
|
|
2361
|
-
}
|
|
2362
|
-
const { summary } = report2;
|
|
2363
|
-
const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
|
|
2364
|
-
lines.push(
|
|
2365
|
-
"",
|
|
2366
|
-
`${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report2.catalogueVersion}.`
|
|
2367
|
-
);
|
|
2368
|
-
return lines.join("\n").replace(/^\n/, "");
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
// src/character/cli.ts
|
|
2372
|
-
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".turbo", ".vercel", "coverage", "vendor", ".cache"]);
|
|
2373
|
-
var MAX_BYTES = 1e6;
|
|
2374
|
-
var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)(?:\.[a-z]+)?$/i;
|
|
2375
|
-
var HELP = `craft: find the AI look and say what to do instead.
|
|
2376
|
-
|
|
2377
|
-
Usage
|
|
2378
|
-
craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>]
|
|
2379
|
-
craft copy <paths...> [--json] [--strict] [--direction <file>]
|
|
2380
|
-
craft tells list [--json]
|
|
2381
|
-
craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
|
|
2382
|
-
craft audit <url | snapshot.json> [--repo <dir>] [--out <file>] [--json] [--strict]
|
|
2383
|
-
craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
|
|
2384
|
-
craft direction validate [--snapshot <file>] [--direction <file>] [--json]
|
|
2385
|
-
craft direction propose [--snapshot <file>] [--estate <dir>] [--out <file>]
|
|
2386
|
-
|
|
2387
|
-
scan Markup, component code and stylesheets (source and compiled CSS).
|
|
2388
|
-
--staged reads the git index, for a pre-commit hook.
|
|
2389
|
-
copy The prose in Markdown, markup and content files.
|
|
2390
|
-
tells The catalogue this build judges against.
|
|
2391
|
-
snapshot Render a page in a browser and save what it looks like, as JSON.
|
|
2392
|
-
audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
|
|
2393
|
-
--repo also scans that site's source, so one report covers both.
|
|
2394
|
-
snapshot and audit need Playwright: npm i -D playwright.
|
|
2395
|
-
direction The site's art-direction.json: every choice with a reason from the
|
|
2396
|
-
client's world. init writes today's choices with empty reasons;
|
|
2397
|
-
validate applies the reason rule; propose reads colours off the
|
|
2398
|
-
sources' PNG photos and drafts choices for a person to confirm.
|
|
2399
|
-
|
|
2400
|
-
Exceptions come from art-direction.json in the working directory, or --direction.
|
|
2401
|
-
Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.`;
|
|
2402
|
-
var VALUE_FLAGS = {
|
|
2403
|
-
"--direction": "direction",
|
|
2404
|
-
"--out": "out",
|
|
2405
|
-
"--repo": "repo",
|
|
2406
|
-
"--width": "width",
|
|
2407
|
-
"--height": "height",
|
|
2408
|
-
"--snapshot": "snapshot",
|
|
2409
|
-
"--estate": "estate",
|
|
2410
|
-
"--client": "client"
|
|
2411
|
-
};
|
|
2412
|
-
function parseFlags(args) {
|
|
2413
|
-
const flags = { positional: [], json: false, strict: false, staged: false };
|
|
2414
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
2415
|
-
const a = args[i];
|
|
2416
|
-
if (a === "--json") flags.json = true;
|
|
2417
|
-
else if (a === "--strict") flags.strict = true;
|
|
2418
|
-
else if (a === "--staged") flags.staged = true;
|
|
2419
|
-
else if (a in VALUE_FLAGS) {
|
|
2420
|
-
const value = args[i + 1];
|
|
2421
|
-
i += 1;
|
|
2422
|
-
if (!value || value.startsWith("--")) return `${a} needs a value`;
|
|
2423
|
-
flags[VALUE_FLAGS[a]] = value;
|
|
2424
|
-
} else if (a.startsWith("--")) return `unknown option ${a}`;
|
|
2425
|
-
else flags.positional.push(a);
|
|
2426
|
-
}
|
|
2427
|
-
return flags;
|
|
2428
|
-
}
|
|
2429
|
-
function walk(root, cwd, into, wanted, skipDir = (_) => false) {
|
|
2430
|
-
const stat = statSync(root);
|
|
2431
|
-
if (stat.isFile()) {
|
|
2432
|
-
if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
|
|
2433
|
-
return;
|
|
2434
|
-
}
|
|
2435
|
-
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
2436
|
-
if (entry.isDirectory()) {
|
|
2437
|
-
if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(join(root, entry.name), cwd, into, wanted, skipDir);
|
|
2438
|
-
} else if (entry.isFile() && wanted(entry.name)) {
|
|
2439
|
-
walk(join(root, entry.name), cwd, into, wanted, skipDir);
|
|
2440
|
-
}
|
|
2441
|
-
}
|
|
2442
|
-
}
|
|
2443
|
-
function readPaths(paths, cwd, wanted, skipDir) {
|
|
2444
|
-
const files = [];
|
|
2445
|
-
for (const p of paths) {
|
|
2446
|
-
const abs = resolve(cwd, p);
|
|
2447
|
-
if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
|
|
2448
|
-
walk(abs, cwd, files, wanted, skipDir);
|
|
2449
|
-
}
|
|
2450
|
-
return files;
|
|
2451
|
-
}
|
|
2452
|
-
function readStaged(cwd, wanted) {
|
|
2453
|
-
const names = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, encoding: "utf8" }).split("\0").filter((p) => p && wanted(p));
|
|
2454
|
-
return names.map((path) => ({
|
|
2455
|
-
path,
|
|
2456
|
-
// The index, not the working tree: the commit is what gets checked.
|
|
2457
|
-
text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
|
|
2458
|
-
}));
|
|
2459
|
-
}
|
|
2460
|
-
function loadExceptions(flags, cwd) {
|
|
2461
|
-
const path = resolve(cwd, flags.direction ?? "art-direction.json");
|
|
2462
|
-
if (!existsSync(path)) {
|
|
2463
|
-
if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
|
|
2464
|
-
return [];
|
|
2465
|
-
}
|
|
2466
|
-
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
2467
|
-
return Array.isArray(data.exceptions) ? data.exceptions : [];
|
|
2468
|
-
}
|
|
2469
|
-
function finish(report2, flags, title, io) {
|
|
2470
|
-
io.out(flags.json ? JSON.stringify(report2, null, 2) : formatReport(report2, title));
|
|
2471
|
-
if (report2.summary.blocking > 0) return 1;
|
|
2472
|
-
if (flags.strict && report2.summary.findings > 0) return 1;
|
|
2473
|
-
return 0;
|
|
2474
|
-
}
|
|
2475
|
-
var SOURCE_FILE = (p) => ["markup", "script", "css"].includes(fileKind(p)) && !/\.d\.ts$|\.min\.js$/.test(p);
|
|
2476
|
-
var COPY_FILE = (p) => (["markup", "prose", "script"].includes(fileKind(p)) || /\.jsonl?$/i.test(p)) && !NOT_SITE_COPY.test(p.split("/").pop() ?? p) && !/(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|art-direction)\.json$/.test(p);
|
|
2477
|
-
var NOT_COPY_DIR = (name) => name.startsWith("_");
|
|
2478
|
-
function run(argv, io) {
|
|
2479
|
-
const [command, ...rest] = argv;
|
|
2480
|
-
if (command === "direction") {
|
|
2481
|
-
return import("./cli-F7W4F3ER.js").then((m) => m.runDirection(rest, io));
|
|
2482
|
-
}
|
|
2483
|
-
if (command === "snapshot" || command === "audit") {
|
|
2484
|
-
return import("./cli-7ME4VIRG.js").then((m) => m.runAudit(command, rest, io));
|
|
2485
|
-
}
|
|
2486
|
-
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
2487
|
-
io.out(HELP);
|
|
2488
|
-
return command ? 0 : 2;
|
|
2489
|
-
}
|
|
2490
|
-
try {
|
|
2491
|
-
if (command === "tells") {
|
|
2492
|
-
const flags = parseFlags(rest);
|
|
2493
|
-
if (typeof flags === "string") throw new Error(flags);
|
|
2494
|
-
if (flags.positional[0] !== "list") throw new Error("usage: craft tells list [--json]");
|
|
2495
|
-
if (flags.json) {
|
|
2496
|
-
const data = CATALOGUE.map(({ id, name, generation, surface, severity, why, fix }) => ({ id, name, generation, surface, severity, why, fix }));
|
|
2497
|
-
io.out(JSON.stringify({ version: CATALOGUE_VERSION, tells: data }, null, 2));
|
|
2498
|
-
} else {
|
|
2499
|
-
io.out(`Catalogue ${CATALOGUE_VERSION}`);
|
|
2500
|
-
for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
|
|
2501
|
-
}
|
|
2502
|
-
return 0;
|
|
2503
|
-
}
|
|
2504
|
-
if (command === "scan" || command === "copy") {
|
|
2505
|
-
const flags = parseFlags(rest);
|
|
2506
|
-
if (typeof flags === "string") throw new Error(flags);
|
|
2507
|
-
const wanted = command === "scan" ? SOURCE_FILE : COPY_FILE;
|
|
2508
|
-
if (command === "copy" && flags.positional.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...>");
|
|
2509
|
-
const files = flags.staged ? readStaged(io.cwd, wanted) : readPaths(flags.positional.length > 0 ? flags.positional : ["."], io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0);
|
|
2510
|
-
const exceptions = loadExceptions(flags, io.cwd);
|
|
2511
|
-
const report2 = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
|
|
2512
|
-
return finish(report2, flags, command === "scan" ? "craft scan" : "craft copy", io);
|
|
2513
|
-
}
|
|
2514
|
-
throw new Error(`unknown command "${command}". Run craft --help.`);
|
|
2515
|
-
} catch (error) {
|
|
2516
|
-
io.err(`craft: ${error.message}`);
|
|
2517
|
-
return 2;
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
3106
|
export {
|
|
2522
3107
|
formatHex,
|
|
2523
3108
|
hexToOklch,
|
|
@@ -2528,15 +3113,17 @@ export {
|
|
|
2528
3113
|
isCream,
|
|
2529
3114
|
REFLEX_FONTS_1,
|
|
2530
3115
|
REFLEX_FONTS_2,
|
|
3116
|
+
houseRule,
|
|
3117
|
+
fileKind,
|
|
3118
|
+
extractCopy,
|
|
3119
|
+
extractStrings,
|
|
2531
3120
|
SNAPSHOT_VERSION,
|
|
2532
3121
|
normaliseFamily,
|
|
2533
3122
|
fontClass,
|
|
3123
|
+
CATALOGUE_VERSION,
|
|
3124
|
+
CATALOGUE,
|
|
3125
|
+
applyHouseGate,
|
|
2534
3126
|
scanSource,
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
readPaths,
|
|
2538
|
-
loadExceptions,
|
|
2539
|
-
finish,
|
|
2540
|
-
SOURCE_FILE,
|
|
2541
|
-
run
|
|
3127
|
+
checkCopy,
|
|
3128
|
+
auditSnapshot
|
|
2542
3129
|
};
|