@domandigital/craft 0.7.0 → 0.8.1
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 +17 -0
- package/CHARACTER.md +26 -9
- package/COPY.md +280 -0
- package/README.md +12 -0
- package/dist/{audit-YNODYB3U.js → audit-C62GULQR.js} +3 -3
- package/dist/audit.cjs +859 -86
- package/dist/audit.d.cts +2 -0
- package/dist/audit.d.ts +2 -0
- package/dist/audit.js +859 -86
- package/dist/{chunk-CU5TZHOV.js → chunk-2VLN6JLN.js} +5 -5
- package/dist/{chunk-IQTZWNEK.js → chunk-6LPMM752.js} +5 -5
- package/dist/{chunk-Q54JSNC5.js → chunk-DWZY5MWD.js} +1 -1
- package/dist/{chunk-J2BOAIHJ.js → chunk-JNFHQGKR.js} +1 -1
- package/dist/{chunk-3AP4PJMX.js → chunk-OBVWDPQQ.js} +857 -77
- package/dist/{chunk-OA7SGPN4.js → chunk-UVBVYM5D.js} +2 -2
- package/dist/{chunk-YYC4EGER.js → chunk-X6PHP22P.js} +126 -11
- package/dist/{chunk-GV3QOTQU.js → chunk-XEH4GK5K.js} +4 -4
- package/dist/{cli-6Y5E32FS.js → cli-BWERSVPW.js} +4 -4
- package/dist/{cli-MW3JAGX3.js → cli-C5RLL7AW.js} +4 -4
- package/dist/cli-D6VYGCOE.js +16 -0
- package/dist/cli-I4TQMCUP.js +15 -0
- package/dist/{cli-TM2HWYN7.js → cli-UMQNCHAQ.js} +9 -9
- package/dist/cli.js +2 -2
- package/dist/index.cjs +977 -105
- package/dist/index.d.cts +80 -4
- package/dist/index.d.ts +80 -4
- package/dist/index.js +970 -105
- package/package.json +2 -1
- package/word-lists.json +12 -3
- package/dist/cli-IQT6QZ6U.js +0 -16
- package/dist/cli-PD43TWX7.js +0 -15
package/dist/index.js
CHANGED
|
@@ -930,7 +930,7 @@ function checkRestraint(input) {
|
|
|
930
930
|
);
|
|
931
931
|
}
|
|
932
932
|
const families = distinctValues(css, "font-family", props, unresolved).filter(
|
|
933
|
-
(
|
|
933
|
+
(f4) => !/\bmonospace\b|\bmono\b/.test(f4)
|
|
934
934
|
);
|
|
935
935
|
if (families.length > budget.maxFontFamilies) {
|
|
936
936
|
push(
|
|
@@ -1254,15 +1254,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
|
1254
1254
|
function allText(ctx) {
|
|
1255
1255
|
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
1256
1256
|
}
|
|
1257
|
+
var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
|
|
1258
|
+
function linkSpans(text) {
|
|
1259
|
+
return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
1260
|
+
}
|
|
1261
|
+
var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
|
|
1257
1262
|
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
1258
1263
|
return (ctx) => {
|
|
1259
1264
|
const hits = [];
|
|
1260
1265
|
for (const block of source(ctx)) {
|
|
1261
1266
|
const spans = [];
|
|
1267
|
+
const links = linkSpans(block.text);
|
|
1262
1268
|
for (const pattern of patterns) {
|
|
1263
1269
|
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
1264
1270
|
for (const m of block.text.matchAll(global)) {
|
|
1265
1271
|
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
1272
|
+
if (insideLink(links, m.index ?? 0)) continue;
|
|
1266
1273
|
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
1267
1274
|
}
|
|
1268
1275
|
}
|
|
@@ -1303,7 +1310,14 @@ var AI_WORDS = [
|
|
|
1303
1310
|
"moreover",
|
|
1304
1311
|
"navigating",
|
|
1305
1312
|
"ever-evolving",
|
|
1306
|
-
"unparalleled"
|
|
1313
|
+
"unparalleled",
|
|
1314
|
+
// Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
|
|
1315
|
+
// above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
|
|
1316
|
+
"underscore",
|
|
1317
|
+
"underscores",
|
|
1318
|
+
"underscoring",
|
|
1319
|
+
"showcasing",
|
|
1320
|
+
"pivotal"
|
|
1307
1321
|
];
|
|
1308
1322
|
var STOCK_PHRASES = [
|
|
1309
1323
|
"in today's digital age",
|
|
@@ -1317,7 +1331,11 @@ var STOCK_PHRASES = [
|
|
|
1317
1331
|
"curated",
|
|
1318
1332
|
"crafted with care",
|
|
1319
1333
|
"tailored to your",
|
|
1320
|
-
"peace of mind"
|
|
1334
|
+
"peace of mind",
|
|
1335
|
+
"so here's",
|
|
1336
|
+
"in an era where",
|
|
1337
|
+
"the future looks bright",
|
|
1338
|
+
"only time will tell"
|
|
1321
1339
|
];
|
|
1322
1340
|
var AI_PHRASES = [
|
|
1323
1341
|
"let's dive in",
|
|
@@ -1450,7 +1468,7 @@ function lineAround(text, index) {
|
|
|
1450
1468
|
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);
|
|
1451
1469
|
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
1452
1470
|
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
1453
|
-
var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
|
|
1471
|
+
var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
|
|
1454
1472
|
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
1455
1473
|
function badgeHits(ctx) {
|
|
1456
1474
|
const hits = [];
|
|
@@ -1493,10 +1511,16 @@ var COPY_TELLS = [
|
|
|
1493
1511
|
surface: "copy",
|
|
1494
1512
|
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
1495
1513
|
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
1496
|
-
detect: phrases([wordList(STOCK_PHRASES)], quoted),
|
|
1514
|
+
detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
|
|
1497
1515
|
fixtures: {
|
|
1498
|
-
flag: [
|
|
1499
|
-
|
|
1516
|
+
flag: [
|
|
1517
|
+
f("content/home.md", "Take your plumbing to the next level with peace of mind."),
|
|
1518
|
+
f("content/home.md", "We meet each practice where it is in its journey.")
|
|
1519
|
+
],
|
|
1520
|
+
pass: [
|
|
1521
|
+
f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
|
|
1522
|
+
f("content/home.md", "We mapped the patient journey from booking to aftercare.")
|
|
1523
|
+
]
|
|
1500
1524
|
}
|
|
1501
1525
|
},
|
|
1502
1526
|
{
|
|
@@ -1521,7 +1545,11 @@ var COPY_TELLS = [
|
|
|
1521
1545
|
f("content/home.md", "In today's rapidly evolving market, we help."),
|
|
1522
1546
|
f("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
1523
1547
|
],
|
|
1524
|
-
pass: [
|
|
1548
|
+
pass: [
|
|
1549
|
+
f("content/home.md", "Book in three taps. We confirm by text within the hour."),
|
|
1550
|
+
f("content/home.md", "Book at https://example.com/seamless-booking today."),
|
|
1551
|
+
f("content/home.md", "[Book a slot](/seamless-booking) today.")
|
|
1552
|
+
]
|
|
1525
1553
|
}
|
|
1526
1554
|
},
|
|
1527
1555
|
{
|
|
@@ -1670,8 +1698,14 @@ var COPY_TELLS = [
|
|
|
1670
1698
|
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
1671
1699
|
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
1672
1700
|
fixtures: {
|
|
1673
|
-
flag: [
|
|
1674
|
-
|
|
1701
|
+
flag: [
|
|
1702
|
+
f("content/home.md", "New treatments this month \u2728\u{1F680}"),
|
|
1703
|
+
f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
|
|
1704
|
+
f("content/home.md", "New treatments this month \u2728"),
|
|
1705
|
+
f("content/home.md", "Same-day callouts \u2705"),
|
|
1706
|
+
f("content/home.md", "Open Sundays \u2600\uFE0F")
|
|
1707
|
+
],
|
|
1708
|
+
pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
|
|
1675
1709
|
}
|
|
1676
1710
|
},
|
|
1677
1711
|
{
|
|
@@ -1769,6 +1803,698 @@ var COPY_TELLS = [
|
|
|
1769
1803
|
}
|
|
1770
1804
|
}
|
|
1771
1805
|
];
|
|
1806
|
+
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;
|
|
1807
|
+
var VAGUE_ATTRIBUTION = [
|
|
1808
|
+
/\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,
|
|
1809
|
+
/\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
|
|
1810
|
+
/\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
|
|
1811
|
+
];
|
|
1812
|
+
function sourced(block, _match, index) {
|
|
1813
|
+
const before = block.text.slice(Math.max(0, index - 40), index);
|
|
1814
|
+
if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
|
|
1815
|
+
const rest = block.text.slice(index, index + 400);
|
|
1816
|
+
const end = rest.search(/[.!?](?:\s|$)/);
|
|
1817
|
+
const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
|
|
1818
|
+
return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
|
|
1819
|
+
}
|
|
1820
|
+
var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
|
|
1821
|
+
var FALSE_RANGE = [
|
|
1822
|
+
/\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
|
|
1823
|
+
/\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
|
|
1824
|
+
];
|
|
1825
|
+
var RESIDUE = [
|
|
1826
|
+
/\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
|
|
1827
|
+
/(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
|
|
1828
|
+
/\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
|
|
1829
|
+
/\blet me know if you(?:['’]d| would) like me to\b/i,
|
|
1830
|
+
/\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
|
|
1831
|
+
/\bI hope this helps\b/i,
|
|
1832
|
+
// Citation tokens a chat tool leaves in copied text.
|
|
1833
|
+
// A run of them is one paste: "citeturn11search1turn10view0" is one finding.
|
|
1834
|
+
// ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
|
|
1835
|
+
// which survive a copy and paste into a CMS: found live, 2026-09-23.
|
|
1836
|
+
/[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
|
|
1837
|
+
/:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
|
|
1838
|
+
// Entity markers from the same paste: entity["company","Bark","services
|
|
1839
|
+
// marketplace"] shows on the page where the plain name "Bark" belongs.
|
|
1840
|
+
/[\uE200-\uE2FF]*entity[\uE200-\uE2FF]*\["[a-z_]+"\s*,\s*"[^"\n]{1,80}"(?:\s*,\s*"[^"\n]{0,160}")*\][\uE200-\uE2FF]*/,
|
|
1841
|
+
/【\d+(?:[:†][^】]{0,40})?】/
|
|
1842
|
+
];
|
|
1843
|
+
var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
|
|
1844
|
+
function residueHits(ctx) {
|
|
1845
|
+
const hits = phrases(RESIDUE, quoted, allText)(ctx);
|
|
1846
|
+
for (const file of ctx.files) {
|
|
1847
|
+
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]}"` });
|
|
1848
|
+
}
|
|
1849
|
+
return hits;
|
|
1850
|
+
}
|
|
1851
|
+
var PLACEHOLDER = [
|
|
1852
|
+
/\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
|
|
1853
|
+
/\blorem ipsum\b/i,
|
|
1854
|
+
/\bTODO_PLACEHOLDER\b/
|
|
1855
|
+
];
|
|
1856
|
+
var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
|
|
1857
|
+
function insideQuotes(block, _match, index) {
|
|
1858
|
+
const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
|
|
1859
|
+
const before = block.text.slice(lineStart, index);
|
|
1860
|
+
const straight = (before.match(/"/g) ?? []).length;
|
|
1861
|
+
const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
|
|
1862
|
+
return straight % 2 === 1 || curly > 0;
|
|
1863
|
+
}
|
|
1864
|
+
function placeholderHits(ctx) {
|
|
1865
|
+
const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
|
|
1866
|
+
const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
|
|
1867
|
+
for (const file of ctx.files) {
|
|
1868
|
+
if (isProsePath(file.path)) continue;
|
|
1869
|
+
for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
|
|
1870
|
+
const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
|
|
1871
|
+
if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return hits;
|
|
1875
|
+
}
|
|
1876
|
+
var QUESTION_REVEAL = [
|
|
1877
|
+
/\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]/,
|
|
1878
|
+
/\?\s+It means\b/
|
|
1879
|
+
];
|
|
1880
|
+
function inlineLabelHits(ctx) {
|
|
1881
|
+
const hits = [];
|
|
1882
|
+
for (const file of ctx.files) {
|
|
1883
|
+
if (!isProsePath(file.path)) continue;
|
|
1884
|
+
const lines = file.text.split("\n");
|
|
1885
|
+
let run = [];
|
|
1886
|
+
let offset = 0;
|
|
1887
|
+
const flush = () => {
|
|
1888
|
+
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` });
|
|
1889
|
+
run = [];
|
|
1890
|
+
};
|
|
1891
|
+
for (const line of lines) {
|
|
1892
|
+
const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
|
|
1893
|
+
if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
|
|
1894
|
+
else flush();
|
|
1895
|
+
offset += line.length + 1;
|
|
1896
|
+
}
|
|
1897
|
+
flush();
|
|
1898
|
+
}
|
|
1899
|
+
return hits;
|
|
1900
|
+
}
|
|
1901
|
+
var RESEARCH_TELLS = [
|
|
1902
|
+
{
|
|
1903
|
+
id: "chatbot-residue",
|
|
1904
|
+
name: "Chatbot residue",
|
|
1905
|
+
generation: 1,
|
|
1906
|
+
severity: "warn",
|
|
1907
|
+
surface: "copy",
|
|
1908
|
+
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.",
|
|
1909
|
+
fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
|
|
1910
|
+
detect: residueHits,
|
|
1911
|
+
fixtures: {
|
|
1912
|
+
flag: [
|
|
1913
|
+
f("content/home.md", "Certainly! Here's a revised version of your home page."),
|
|
1914
|
+
f("content/home.md", "As an AI language model, I cannot visit your salon."),
|
|
1915
|
+
f("content/home.md", "Let me know if you'd like me to shorten it."),
|
|
1916
|
+
f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
|
|
1917
|
+
f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
|
|
1918
|
+
f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
|
|
1919
|
+
f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
|
|
1920
|
+
f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
|
|
1921
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
|
|
1922
|
+
f("content/home.md", "Here's a more concise version for the hero.")
|
|
1923
|
+
],
|
|
1924
|
+
pass: [
|
|
1925
|
+
f("content/home.md", "Certainly the busiest week of the year: book early."),
|
|
1926
|
+
f("content/home.md", "Here's the price list for 2026."),
|
|
1927
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
|
|
1928
|
+
]
|
|
1929
|
+
}
|
|
1930
|
+
},
|
|
1931
|
+
{
|
|
1932
|
+
id: "placeholder",
|
|
1933
|
+
name: "Unfilled placeholder",
|
|
1934
|
+
generation: 1,
|
|
1935
|
+
severity: "warn",
|
|
1936
|
+
surface: "copy",
|
|
1937
|
+
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.",
|
|
1938
|
+
fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
|
|
1939
|
+
detect: placeholderHits,
|
|
1940
|
+
fixtures: {
|
|
1941
|
+
flag: [
|
|
1942
|
+
f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
|
|
1943
|
+
f("content/home.md", "Lorem ipsum dolor sit amet."),
|
|
1944
|
+
f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
|
|
1945
|
+
f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
|
|
1946
|
+
],
|
|
1947
|
+
pass: [
|
|
1948
|
+
f("content/home.md", "Manage bookings in [your account](/account)."),
|
|
1949
|
+
f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
|
|
1950
|
+
f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
|
|
1951
|
+
f("content/home.md", "Rated 4.9 from 212 Google reviews.")
|
|
1952
|
+
]
|
|
1953
|
+
}
|
|
1954
|
+
},
|
|
1955
|
+
{
|
|
1956
|
+
id: "question-reveal",
|
|
1957
|
+
name: "Staged reveal",
|
|
1958
|
+
generation: 2,
|
|
1959
|
+
severity: "warn",
|
|
1960
|
+
surface: "copy",
|
|
1961
|
+
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.",
|
|
1962
|
+
fix: "State the answer: 'Most customers book again within six weeks.'",
|
|
1963
|
+
detect: phrases(QUESTION_REVEAL, quoted),
|
|
1964
|
+
fixtures: {
|
|
1965
|
+
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.")],
|
|
1966
|
+
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.")]
|
|
1967
|
+
}
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
id: "inline-label-list",
|
|
1971
|
+
name: "Bold-label bullets",
|
|
1972
|
+
generation: 2,
|
|
1973
|
+
severity: "warn",
|
|
1974
|
+
surface: "copy",
|
|
1975
|
+
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.",
|
|
1976
|
+
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.",
|
|
1977
|
+
detect: inlineLabelHits,
|
|
1978
|
+
fixtures: {
|
|
1979
|
+
flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
|
|
1980
|
+
pass: [
|
|
1981
|
+
f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
|
|
1982
|
+
f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
|
|
1983
|
+
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")
|
|
1984
|
+
]
|
|
1985
|
+
}
|
|
1986
|
+
},
|
|
1987
|
+
{
|
|
1988
|
+
id: "ing-tail",
|
|
1989
|
+
name: "Empty -ing tail",
|
|
1990
|
+
generation: 2,
|
|
1991
|
+
severity: "warn",
|
|
1992
|
+
surface: "copy",
|
|
1993
|
+
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.",
|
|
1994
|
+
fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
|
|
1995
|
+
detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
|
|
1996
|
+
fixtures: {
|
|
1997
|
+
flag: [
|
|
1998
|
+
f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
|
|
1999
|
+
f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
|
|
2000
|
+
],
|
|
2001
|
+
pass: [
|
|
2002
|
+
f("content/home.md", "We open at 7am, so you can drop the car off before work."),
|
|
2003
|
+
f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
|
|
2004
|
+
]
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
{
|
|
2008
|
+
id: "vague-attribution",
|
|
2009
|
+
name: "Unnamed source",
|
|
2010
|
+
generation: 1,
|
|
2011
|
+
severity: "warn",
|
|
2012
|
+
surface: "copy",
|
|
2013
|
+
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.",
|
|
2014
|
+
fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
|
|
2015
|
+
detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
|
|
2016
|
+
fixtures: {
|
|
2017
|
+
flag: [
|
|
2018
|
+
f("content/home.md", "Studies show that regular servicing saves money."),
|
|
2019
|
+
f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
|
|
2020
|
+
f("content/home.md", "It is widely known that gel damages nails.")
|
|
2021
|
+
],
|
|
2022
|
+
pass: [
|
|
2023
|
+
f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
|
|
2024
|
+
f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
|
|
2025
|
+
f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
|
|
2026
|
+
f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
|
|
2027
|
+
]
|
|
2028
|
+
}
|
|
2029
|
+
},
|
|
2030
|
+
{
|
|
2031
|
+
id: "closing-summary",
|
|
2032
|
+
name: "Closing summary",
|
|
2033
|
+
generation: 1,
|
|
2034
|
+
severity: "warn",
|
|
2035
|
+
surface: "copy",
|
|
2036
|
+
why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
|
|
2037
|
+
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'.",
|
|
2038
|
+
detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
|
|
2039
|
+
fixtures: {
|
|
2040
|
+
flag: [
|
|
2041
|
+
f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
|
|
2042
|
+
f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
|
|
2043
|
+
],
|
|
2044
|
+
pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
|
|
2045
|
+
}
|
|
2046
|
+
},
|
|
2047
|
+
{
|
|
2048
|
+
id: "false-range",
|
|
2049
|
+
name: "False range",
|
|
2050
|
+
generation: 1,
|
|
2051
|
+
severity: "warn",
|
|
2052
|
+
surface: "copy",
|
|
2053
|
+
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.",
|
|
2054
|
+
fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
|
|
2055
|
+
detect: phrases(FALSE_RANGE, quoted),
|
|
2056
|
+
fixtures: {
|
|
2057
|
+
flag: [
|
|
2058
|
+
f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
|
|
2059
|
+
f("content/home.md", "Everyone from students to retirees loves our cakes.")
|
|
2060
|
+
],
|
|
2061
|
+
pass: [
|
|
2062
|
+
f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
|
|
2063
|
+
f("content/home.md", "We take on everything from small repairs to full rewires.")
|
|
2064
|
+
]
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
];
|
|
2068
|
+
|
|
2069
|
+
// src/character/tells/density.ts
|
|
2070
|
+
var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
|
|
2071
|
+
var MIN_WORDS = 400;
|
|
2072
|
+
var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
2073
|
+
var countWords = (text) => (text.match(WORD) ?? []).length;
|
|
2074
|
+
function documents(ctx) {
|
|
2075
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2076
|
+
for (const block of ctx.blocks) {
|
|
2077
|
+
if (!DOCUMENT.test(block.path) || block.literal) continue;
|
|
2078
|
+
const list = byPath.get(block.path) ?? [];
|
|
2079
|
+
list.push(block);
|
|
2080
|
+
byPath.set(block.path, list);
|
|
2081
|
+
}
|
|
2082
|
+
const docs = [];
|
|
2083
|
+
for (const [path, blocks] of byPath) {
|
|
2084
|
+
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2085
|
+
const words2 = countWords(text);
|
|
2086
|
+
if (words2 >= MIN_WORDS) docs.push({ path, blocks, words: words2 });
|
|
2087
|
+
}
|
|
2088
|
+
return docs;
|
|
2089
|
+
}
|
|
2090
|
+
var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
|
|
2091
|
+
var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
|
|
2092
|
+
var thousands = (n) => n.toLocaleString("en-GB");
|
|
2093
|
+
function sentences(doc2) {
|
|
2094
|
+
const out = [];
|
|
2095
|
+
for (const block of doc2.blocks) {
|
|
2096
|
+
const text = withoutHeadings(block.text);
|
|
2097
|
+
for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
|
|
2098
|
+
const body = para[0];
|
|
2099
|
+
const start = para.index ?? 0;
|
|
2100
|
+
for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
|
|
2101
|
+
const raw = m[0];
|
|
2102
|
+
const trimmed = raw.trim();
|
|
2103
|
+
if (countWords(trimmed) === 0) continue;
|
|
2104
|
+
const lead = raw.length - raw.trimStart().length;
|
|
2105
|
+
out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
return out;
|
|
2110
|
+
}
|
|
2111
|
+
var RATES = [
|
|
2112
|
+
{ label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
|
|
2113
|
+
{ label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
|
|
2114
|
+
{ label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
|
|
2115
|
+
{ label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
|
|
2116
|
+
{ label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
|
|
2117
|
+
{
|
|
2118
|
+
label: "the single ...est",
|
|
2119
|
+
pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
|
|
2120
|
+
floor: 1,
|
|
2121
|
+
per1000: 0.15,
|
|
2122
|
+
why: "if three things are the single most important, none is"
|
|
2123
|
+
},
|
|
2124
|
+
{ 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" },
|
|
2125
|
+
{ label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
|
|
2126
|
+
{
|
|
2127
|
+
label: "Worth noting opener",
|
|
2128
|
+
pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
|
|
2129
|
+
opener: true,
|
|
2130
|
+
floor: 3,
|
|
2131
|
+
per1000: 0.5,
|
|
2132
|
+
why: "a hedge that lets a sentence start without committing"
|
|
2133
|
+
},
|
|
2134
|
+
{
|
|
2135
|
+
label: "negation-led opener",
|
|
2136
|
+
pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
|
|
2137
|
+
opener: true,
|
|
2138
|
+
floor: 5,
|
|
2139
|
+
per1000: 1,
|
|
2140
|
+
why: "each earns its place once or twice; at twenty the document has one move"
|
|
2141
|
+
},
|
|
2142
|
+
{
|
|
2143
|
+
label: "participle triad",
|
|
2144
|
+
pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
|
|
2145
|
+
floor: 3,
|
|
2146
|
+
per1000: 0.6,
|
|
2147
|
+
why: "'researched, written and referenced' is fine once and a cadence in bulk"
|
|
2148
|
+
}
|
|
2149
|
+
];
|
|
2150
|
+
function rateHits(ctx) {
|
|
2151
|
+
const hits = [];
|
|
2152
|
+
for (const doc2 of documents(ctx)) {
|
|
2153
|
+
const list = sentences(doc2);
|
|
2154
|
+
for (const rate of RATES) {
|
|
2155
|
+
const found = [];
|
|
2156
|
+
const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
|
|
2157
|
+
for (const s of list) {
|
|
2158
|
+
if (rate.opener) {
|
|
2159
|
+
if (rate.pattern.test(s.text)) found.push(s);
|
|
2160
|
+
} else {
|
|
2161
|
+
const n = (s.text.match(global) ?? []).length;
|
|
2162
|
+
for (let k = 0; k < n; k += 1) found.push(s);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
const limit = allowed(doc2, rate.floor, rate.per1000);
|
|
2166
|
+
if (found.length > limit) {
|
|
2167
|
+
hits.push({
|
|
2168
|
+
path: doc2.path,
|
|
2169
|
+
offset: found[limit].offset,
|
|
2170
|
+
message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
|
|
2171
|
+
excerpt: found[limit].text.slice(0, 120)
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
return hits;
|
|
2177
|
+
}
|
|
2178
|
+
function aphorismHits(ctx) {
|
|
2179
|
+
const hits = [];
|
|
2180
|
+
for (const doc2 of documents(ctx)) {
|
|
2181
|
+
const found = [];
|
|
2182
|
+
for (const block of doc2.blocks) {
|
|
2183
|
+
let at = 0;
|
|
2184
|
+
for (const line of withoutHeadings(block.text).split("\n")) {
|
|
2185
|
+
const text = line.trim().replace(/^[-*>]\s+/, "");
|
|
2186
|
+
const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
2187
|
+
if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
|
|
2188
|
+
found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
|
|
2189
|
+
}
|
|
2190
|
+
at += line.length + 1;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
const limit = allowed(doc2, 5, 1.2);
|
|
2194
|
+
if (found.length > limit) {
|
|
2195
|
+
hits.push({
|
|
2196
|
+
path: doc2.path,
|
|
2197
|
+
offset: found[limit].offset,
|
|
2198
|
+
message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
|
|
2199
|
+
excerpt: found[limit].text
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
return hits;
|
|
2204
|
+
}
|
|
2205
|
+
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;
|
|
2206
|
+
function contractionHits(ctx) {
|
|
2207
|
+
const hits = [];
|
|
2208
|
+
for (const doc2 of documents(ctx)) {
|
|
2209
|
+
if (doc2.words < 800) continue;
|
|
2210
|
+
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2211
|
+
const count2 = (text.match(CONTRACTION) ?? []).length;
|
|
2212
|
+
const rate = count2 * 1e3 / doc2.words;
|
|
2213
|
+
if (rate < 1) {
|
|
2214
|
+
const first = sentences(doc2)[0];
|
|
2215
|
+
hits.push({
|
|
2216
|
+
path: doc2.path,
|
|
2217
|
+
offset: first?.offset ?? 0,
|
|
2218
|
+
message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
return hits;
|
|
2223
|
+
}
|
|
2224
|
+
function rhythmHits(ctx) {
|
|
2225
|
+
const hits = [];
|
|
2226
|
+
for (const doc2 of documents(ctx)) {
|
|
2227
|
+
const list = sentences(doc2);
|
|
2228
|
+
if (list.length < 40) continue;
|
|
2229
|
+
const lengths = list.map((s) => countWords(s.text));
|
|
2230
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
2231
|
+
const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
|
|
2232
|
+
const cv = sd / mean;
|
|
2233
|
+
if (cv < 0.42) {
|
|
2234
|
+
hits.push({
|
|
2235
|
+
path: doc2.path,
|
|
2236
|
+
offset: list[0].offset,
|
|
2237
|
+
message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
return hits;
|
|
2242
|
+
}
|
|
2243
|
+
var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
|
|
2244
|
+
function repeatHits(ctx) {
|
|
2245
|
+
const hits = [];
|
|
2246
|
+
const RUN = 9;
|
|
2247
|
+
for (const doc2 of documents(ctx)) {
|
|
2248
|
+
const whole = /* @__PURE__ */ new Map();
|
|
2249
|
+
const runs = /* @__PURE__ */ new Map();
|
|
2250
|
+
for (const s of sentences(doc2)) {
|
|
2251
|
+
const words2 = normal(s.text);
|
|
2252
|
+
if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
2253
|
+
const key = words2.join(" ");
|
|
2254
|
+
const earlier = whole.get(key);
|
|
2255
|
+
if (earlier) {
|
|
2256
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words2.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
2257
|
+
continue;
|
|
2258
|
+
}
|
|
2259
|
+
whole.set(key, s);
|
|
2260
|
+
let reported = false;
|
|
2261
|
+
for (let i = 0; i + RUN <= words2.length && !reported; i += 1) {
|
|
2262
|
+
const run = words2.slice(i, i + RUN).join(" ");
|
|
2263
|
+
const seen = runs.get(run);
|
|
2264
|
+
if (seen && seen.key !== key) {
|
|
2265
|
+
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) });
|
|
2266
|
+
reported = true;
|
|
2267
|
+
} else if (!seen) {
|
|
2268
|
+
runs.set(run, { sentence: s, key });
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
return hits;
|
|
2274
|
+
}
|
|
2275
|
+
function headings(file) {
|
|
2276
|
+
const out = [];
|
|
2277
|
+
for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
2278
|
+
out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
2279
|
+
}
|
|
2280
|
+
for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
|
|
2281
|
+
const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
2282
|
+
if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
2283
|
+
}
|
|
2284
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
2285
|
+
}
|
|
2286
|
+
function longFiles(ctx) {
|
|
2287
|
+
const docs = new Map(documents(ctx).map((d) => [d.path, d]));
|
|
2288
|
+
return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
|
|
2289
|
+
}
|
|
2290
|
+
var HEADING_SHAPES = [
|
|
2291
|
+
["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
|
|
2292
|
+
["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
|
|
2293
|
+
["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
|
|
2294
|
+
];
|
|
2295
|
+
function headingShapeHits(ctx) {
|
|
2296
|
+
const hits = [];
|
|
2297
|
+
for (const { file, doc: doc2 } of longFiles(ctx)) {
|
|
2298
|
+
const found = [];
|
|
2299
|
+
for (const heading of headings(file)) {
|
|
2300
|
+
const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
|
|
2301
|
+
if (shape) found.push({ heading, shape: shape[0] });
|
|
2302
|
+
}
|
|
2303
|
+
const limit = allowed(doc2, 3, 0.5);
|
|
2304
|
+
if (found.length > limit) {
|
|
2305
|
+
hits.push({
|
|
2306
|
+
path: file.path,
|
|
2307
|
+
offset: found[limit].heading.offset,
|
|
2308
|
+
message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
|
|
2309
|
+
excerpt: found[limit].heading.text
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
return hits;
|
|
2314
|
+
}
|
|
2315
|
+
function headingEchoHits(ctx) {
|
|
2316
|
+
const hits = [];
|
|
2317
|
+
for (const { file } of longFiles(ctx)) {
|
|
2318
|
+
for (const heading of headings(file)) {
|
|
2319
|
+
const words2 = normal(heading.text);
|
|
2320
|
+
if (words2.length < 4) continue;
|
|
2321
|
+
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
2322
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words2.length);
|
|
2323
|
+
if (opening.join(" ") === words2.join(" ")) {
|
|
2324
|
+
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
return hits;
|
|
2329
|
+
}
|
|
2330
|
+
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(" ");
|
|
2331
|
+
function filler(words2, seed = 1, options = {}) {
|
|
2332
|
+
const { contractions = true, uniform } = options;
|
|
2333
|
+
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
2334
|
+
let state = seed;
|
|
2335
|
+
const next = (n) => {
|
|
2336
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
2337
|
+
return state % n;
|
|
2338
|
+
};
|
|
2339
|
+
const out = [];
|
|
2340
|
+
let count2 = 0;
|
|
2341
|
+
let i = 0;
|
|
2342
|
+
while (count2 < words2) {
|
|
2343
|
+
const length = uniform ?? shape[i % shape.length];
|
|
2344
|
+
const s = [];
|
|
2345
|
+
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2346
|
+
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2347
|
+
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2348
|
+
out.push(`${s.join(" ")}.`);
|
|
2349
|
+
count2 += s.length;
|
|
2350
|
+
i += 1;
|
|
2351
|
+
}
|
|
2352
|
+
const paragraphs = [];
|
|
2353
|
+
for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
|
|
2354
|
+
return paragraphs.join("\n\n");
|
|
2355
|
+
}
|
|
2356
|
+
var f2 = (path, text) => ({ path, text });
|
|
2357
|
+
var doc = (extra, seed = 1) => `${filler(520, seed)}
|
|
2358
|
+
|
|
2359
|
+
${extra}
|
|
2360
|
+
`;
|
|
2361
|
+
var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
|
|
2362
|
+
var DENSITY_TELLS = [
|
|
2363
|
+
{
|
|
2364
|
+
id: "phrase-density",
|
|
2365
|
+
name: "Leaned-on phrase",
|
|
2366
|
+
generation: 2,
|
|
2367
|
+
severity: "warn",
|
|
2368
|
+
surface: "copy",
|
|
2369
|
+
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.",
|
|
2370
|
+
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.",
|
|
2371
|
+
detect: rateHits,
|
|
2372
|
+
fixtures: {
|
|
2373
|
+
flag: [
|
|
2374
|
+
f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
2375
|
+
f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
|
|
2376
|
+
f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
|
|
2377
|
+
f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
|
|
2378
|
+
f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
|
|
2379
|
+
f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
|
|
2380
|
+
f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
|
|
2381
|
+
f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
|
|
2382
|
+
f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
|
|
2383
|
+
f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
|
|
2384
|
+
f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
|
|
2385
|
+
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>`)
|
|
2386
|
+
],
|
|
2387
|
+
pass: [
|
|
2388
|
+
f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
2389
|
+
f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
|
|
2390
|
+
f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
|
|
2391
|
+
]
|
|
2392
|
+
}
|
|
2393
|
+
},
|
|
2394
|
+
{
|
|
2395
|
+
id: "aphorism-density",
|
|
2396
|
+
name: "Aphorism cadence",
|
|
2397
|
+
generation: 2,
|
|
2398
|
+
severity: "warn",
|
|
2399
|
+
surface: "copy",
|
|
2400
|
+
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.",
|
|
2401
|
+
fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
|
|
2402
|
+
detect: aphorismHits,
|
|
2403
|
+
fixtures: {
|
|
2404
|
+
flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
|
|
2405
|
+
pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
|
|
2406
|
+
}
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
id: "contraction-scarcity",
|
|
2410
|
+
name: "No contractions",
|
|
2411
|
+
generation: 1,
|
|
2412
|
+
severity: "warn",
|
|
2413
|
+
surface: "copy",
|
|
2414
|
+
why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
|
|
2415
|
+
fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
|
|
2416
|
+
detect: contractionHits,
|
|
2417
|
+
fixtures: {
|
|
2418
|
+
flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
|
|
2419
|
+
pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
|
|
2420
|
+
}
|
|
2421
|
+
},
|
|
2422
|
+
{
|
|
2423
|
+
id: "sentence-rhythm",
|
|
2424
|
+
name: "Metronomic rhythm",
|
|
2425
|
+
generation: 2,
|
|
2426
|
+
severity: "warn",
|
|
2427
|
+
surface: "copy",
|
|
2428
|
+
why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
|
|
2429
|
+
fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
|
|
2430
|
+
detect: rhythmHits,
|
|
2431
|
+
fixtures: {
|
|
2432
|
+
flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
|
|
2433
|
+
pass: [f2("content/proposal.md", filler(600, 7))]
|
|
2434
|
+
}
|
|
2435
|
+
},
|
|
2436
|
+
{
|
|
2437
|
+
id: "repeated-sentence",
|
|
2438
|
+
name: "Repeated sentence",
|
|
2439
|
+
generation: 1,
|
|
2440
|
+
severity: "warn",
|
|
2441
|
+
surface: "copy",
|
|
2442
|
+
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.",
|
|
2443
|
+
fix: "Say it once, where it matters most, and write the second place fresh.",
|
|
2444
|
+
detect: repeatHits,
|
|
2445
|
+
fixtures: {
|
|
2446
|
+
flag: [
|
|
2447
|
+
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.")),
|
|
2448
|
+
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."))
|
|
2449
|
+
],
|
|
2450
|
+
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."))]
|
|
2451
|
+
}
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
id: "heading-shape",
|
|
2455
|
+
name: "One heading shape",
|
|
2456
|
+
generation: 2,
|
|
2457
|
+
severity: "warn",
|
|
2458
|
+
surface: "copy",
|
|
2459
|
+
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.",
|
|
2460
|
+
fix: "Write each heading for its section. Most should be a plain noun phrase.",
|
|
2461
|
+
detect: headingShapeHits,
|
|
2462
|
+
fixtures: {
|
|
2463
|
+
flag: [
|
|
2464
|
+
f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
|
|
2465
|
+
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>`),
|
|
2466
|
+
f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
|
|
2467
|
+
],
|
|
2468
|
+
pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
|
|
2469
|
+
}
|
|
2470
|
+
},
|
|
2471
|
+
{
|
|
2472
|
+
id: "heading-echo",
|
|
2473
|
+
name: "Heading echoed",
|
|
2474
|
+
generation: 2,
|
|
2475
|
+
severity: "warn",
|
|
2476
|
+
surface: "copy",
|
|
2477
|
+
why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
|
|
2478
|
+
fix: "Let the first sentence say something the heading did not.",
|
|
2479
|
+
detect: headingEchoHits,
|
|
2480
|
+
fixtures: {
|
|
2481
|
+
flag: [
|
|
2482
|
+
f2("content/proposal.md", `## We fix boilers the same day
|
|
2483
|
+
|
|
2484
|
+
We fix boilers the same day across Brackley.
|
|
2485
|
+
|
|
2486
|
+
${filler(520, 11)}`),
|
|
2487
|
+
f2("site/index.html", `<h2>We fix boilers the same day</h2>
|
|
2488
|
+
<p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
|
|
2489
|
+
],
|
|
2490
|
+
pass: [f2("content/proposal.md", `## We fix boilers the same day
|
|
2491
|
+
|
|
2492
|
+
Call before noon and an engineer is with you by six.
|
|
2493
|
+
|
|
2494
|
+
${filler(520, 11)}`)]
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
];
|
|
1772
2498
|
|
|
1773
2499
|
// src/character/color.ts
|
|
1774
2500
|
var COLOUR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3,4}\b|\b(?:rgba?|hsla?|oklch|oklab)\([^)]*\)|\bcolor\(\s*srgb\b[^)]*\)/g;
|
|
@@ -1789,8 +2515,8 @@ function parts(fn) {
|
|
|
1789
2515
|
function hslToRgb(h, s, l) {
|
|
1790
2516
|
const k = (n) => (n + h / 30) % 12;
|
|
1791
2517
|
const a = s * Math.min(l, 1 - l);
|
|
1792
|
-
const
|
|
1793
|
-
return { r:
|
|
2518
|
+
const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
2519
|
+
return { r: f4(0), g: f4(8), b: f4(4) };
|
|
1794
2520
|
}
|
|
1795
2521
|
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
1796
2522
|
function parseColour(raw) {
|
|
@@ -1902,7 +2628,7 @@ function firstMatch(text, pattern) {
|
|
|
1902
2628
|
return m ? m.index : -1;
|
|
1903
2629
|
}
|
|
1904
2630
|
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-/;
|
|
1905
|
-
var
|
|
2631
|
+
var f3 = (path, text) => ({ path, text });
|
|
1906
2632
|
function familyNames(value) {
|
|
1907
2633
|
return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
1908
2634
|
}
|
|
@@ -2367,14 +3093,14 @@ var SOURCE_TELLS = [
|
|
|
2367
3093
|
detect: fontTell(REFLEX_FONTS_1),
|
|
2368
3094
|
fixtures: {
|
|
2369
3095
|
flag: [
|
|
2370
|
-
|
|
3096
|
+
f3("app/layout.tsx", `import { Inter } from "next/font/google";
|
|
2371
3097
|
const inter = Inter({ subsets: ["latin"] });`),
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
3098
|
+
f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
|
|
3099
|
+
f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
|
|
3100
|
+
f3("main.ts", `import "@fontsource/montserrat";`),
|
|
3101
|
+
f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
|
|
2376
3102
|
],
|
|
2377
|
-
pass: [
|
|
3103
|
+
pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
|
|
2378
3104
|
}
|
|
2379
3105
|
},
|
|
2380
3106
|
{
|
|
@@ -2387,10 +3113,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2387
3113
|
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.",
|
|
2388
3114
|
detect: fontTell(REFLEX_FONTS_2),
|
|
2389
3115
|
fixtures: {
|
|
2390
|
-
flag: [
|
|
3116
|
+
flag: [f3("src/styles.css", `@theme {
|
|
2391
3117
|
--font-sans: "DM Sans", system-ui, sans-serif;
|
|
2392
3118
|
}`)],
|
|
2393
|
-
pass: [
|
|
3119
|
+
pass: [f3("src/styles.css", `@theme {
|
|
2394
3120
|
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
|
2395
3121
|
}`)]
|
|
2396
3122
|
}
|
|
@@ -2406,10 +3132,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2406
3132
|
detect: violetHits,
|
|
2407
3133
|
fixtures: {
|
|
2408
3134
|
flag: [
|
|
2409
|
-
|
|
2410
|
-
|
|
3135
|
+
f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
|
|
3136
|
+
f3("app/globals.css", `:root { --accent: #5a35d1; }`)
|
|
2411
3137
|
],
|
|
2412
|
-
pass: [
|
|
3138
|
+
pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
|
|
2413
3139
|
}
|
|
2414
3140
|
},
|
|
2415
3141
|
{
|
|
@@ -2423,10 +3149,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2423
3149
|
detect: gradientHits,
|
|
2424
3150
|
fixtures: {
|
|
2425
3151
|
flag: [
|
|
2426
|
-
|
|
2427
|
-
|
|
3152
|
+
f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
|
|
3153
|
+
f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
|
|
2428
3154
|
],
|
|
2429
|
-
pass: [
|
|
3155
|
+
pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
|
|
2430
3156
|
}
|
|
2431
3157
|
},
|
|
2432
3158
|
{
|
|
@@ -2440,10 +3166,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2440
3166
|
detect: gradientTextHits,
|
|
2441
3167
|
fixtures: {
|
|
2442
3168
|
flag: [
|
|
2443
|
-
|
|
2444
|
-
|
|
3169
|
+
f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
|
|
3170
|
+
f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
|
|
2445
3171
|
],
|
|
2446
|
-
pass: [
|
|
3172
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
|
|
2447
3173
|
}
|
|
2448
3174
|
},
|
|
2449
3175
|
{
|
|
@@ -2457,10 +3183,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2457
3183
|
detect: glassHits,
|
|
2458
3184
|
fixtures: {
|
|
2459
3185
|
flag: [
|
|
2460
|
-
|
|
2461
|
-
|
|
3186
|
+
f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
|
|
3187
|
+
f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
|
|
2462
3188
|
],
|
|
2463
|
-
pass: [
|
|
3189
|
+
pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
|
|
2464
3190
|
}
|
|
2465
3191
|
},
|
|
2466
3192
|
{
|
|
@@ -2473,10 +3199,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2473
3199
|
fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
|
|
2474
3200
|
detect: heroThenProofHits,
|
|
2475
3201
|
fixtures: {
|
|
2476
|
-
flag: [
|
|
3202
|
+
flag: [f3("app/page.tsx", `export default function Page() {
|
|
2477
3203
|
return (<main><Hero /><TrustStrip /><Services /></main>);
|
|
2478
3204
|
}`)],
|
|
2479
|
-
pass: [
|
|
3205
|
+
pass: [f3("app/page.tsx", `export default function Page() {
|
|
2480
3206
|
return (<main><Hero /><Workshop /><TrustStrip /></main>);
|
|
2481
3207
|
}`)]
|
|
2482
3208
|
}
|
|
@@ -2492,7 +3218,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2492
3218
|
detect: iconGridHits,
|
|
2493
3219
|
fixtures: {
|
|
2494
3220
|
flag: [
|
|
2495
|
-
|
|
3221
|
+
f3(
|
|
2496
3222
|
"components/Features.tsx",
|
|
2497
3223
|
`import { Heart } from "lucide-react";
|
|
2498
3224
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
@@ -2500,7 +3226,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2500
3226
|
</div>`
|
|
2501
3227
|
)
|
|
2502
3228
|
],
|
|
2503
|
-
pass: [
|
|
3229
|
+
pass: [f3("components/Features.tsx", `<ul className="divide-y">
|
|
2504
3230
|
<li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
|
|
2505
3231
|
</ul>`)]
|
|
2506
3232
|
}
|
|
@@ -2515,8 +3241,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2515
3241
|
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.`,
|
|
2516
3242
|
detect: revealHits,
|
|
2517
3243
|
fixtures: {
|
|
2518
|
-
flag: [
|
|
2519
|
-
pass: [
|
|
3244
|
+
flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
|
|
3245
|
+
pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
|
|
2520
3246
|
<Block />
|
|
2521
3247
|
<Block />`)]
|
|
2522
3248
|
}
|
|
@@ -2531,8 +3257,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2531
3257
|
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.`,
|
|
2532
3258
|
detect: pillHits,
|
|
2533
3259
|
fixtures: {
|
|
2534
|
-
flag: [
|
|
2535
|
-
pass: [
|
|
3260
|
+
flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
3261
|
+
pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
2536
3262
|
<a className="rounded-sm px-4">Book</a>`)]
|
|
2537
3263
|
}
|
|
2538
3264
|
},
|
|
@@ -2546,8 +3272,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2546
3272
|
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
2547
3273
|
detect: shadcnHits,
|
|
2548
3274
|
fixtures: {
|
|
2549
|
-
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) =>
|
|
2550
|
-
pass: [
|
|
3275
|
+
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
3276
|
+
pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
2551
3277
|
}
|
|
2552
3278
|
},
|
|
2553
3279
|
{
|
|
@@ -2561,11 +3287,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2561
3287
|
detect: creamHits,
|
|
2562
3288
|
fixtures: {
|
|
2563
3289
|
flag: [
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
3290
|
+
f3("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
3291
|
+
f3("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
3292
|
+
f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
2567
3293
|
],
|
|
2568
|
-
pass: [
|
|
3294
|
+
pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
2569
3295
|
}
|
|
2570
3296
|
},
|
|
2571
3297
|
{
|
|
@@ -2579,11 +3305,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2579
3305
|
detect: italicSerifHits,
|
|
2580
3306
|
fixtures: {
|
|
2581
3307
|
flag: [
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
3308
|
+
f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
3309
|
+
f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
3310
|
+
f3("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
2585
3311
|
],
|
|
2586
|
-
pass: [
|
|
3312
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
2587
3313
|
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
2588
3314
|
}
|
|
2589
3315
|
},
|
|
@@ -2598,12 +3324,12 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2598
3324
|
detect: eyebrowChipHits,
|
|
2599
3325
|
fixtures: {
|
|
2600
3326
|
flag: [
|
|
2601
|
-
|
|
3327
|
+
f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
2602
3328
|
<h1>Nails in Brackley</h1>`),
|
|
2603
|
-
|
|
3329
|
+
f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
2604
3330
|
<h1>Nails</h1>`)
|
|
2605
3331
|
],
|
|
2606
|
-
pass: [
|
|
3332
|
+
pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
2607
3333
|
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
2608
3334
|
}
|
|
2609
3335
|
},
|
|
@@ -2617,9 +3343,9 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2617
3343
|
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
2618
3344
|
detect: iconTileStackHits,
|
|
2619
3345
|
fixtures: {
|
|
2620
|
-
flag: [
|
|
3346
|
+
flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2621
3347
|
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
2622
|
-
pass: [
|
|
3348
|
+
pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
2623
3349
|
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
2624
3350
|
}
|
|
2625
3351
|
},
|
|
@@ -2633,8 +3359,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2633
3359
|
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
2634
3360
|
detect: bentoHits,
|
|
2635
3361
|
fixtures: {
|
|
2636
|
-
flag: [
|
|
2637
|
-
pass: [
|
|
3362
|
+
flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
3363
|
+
pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
2638
3364
|
}
|
|
2639
3365
|
},
|
|
2640
3366
|
{
|
|
@@ -2648,10 +3374,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2648
3374
|
detect: glowHits,
|
|
2649
3375
|
fixtures: {
|
|
2650
3376
|
flag: [
|
|
2651
|
-
|
|
2652
|
-
|
|
3377
|
+
f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
3378
|
+
f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
2653
3379
|
],
|
|
2654
|
-
pass: [
|
|
3380
|
+
pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
2655
3381
|
}
|
|
2656
3382
|
},
|
|
2657
3383
|
{
|
|
@@ -2665,13 +3391,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2665
3391
|
detect: gridBackgroundHits,
|
|
2666
3392
|
fixtures: {
|
|
2667
3393
|
flag: [
|
|
2668
|
-
|
|
3394
|
+
f3(
|
|
2669
3395
|
"app/globals.css",
|
|
2670
3396
|
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
2671
3397
|
),
|
|
2672
|
-
|
|
3398
|
+
f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
2673
3399
|
],
|
|
2674
|
-
pass: [
|
|
3400
|
+
pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
2675
3401
|
}
|
|
2676
3402
|
},
|
|
2677
3403
|
{
|
|
@@ -2685,10 +3411,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2685
3411
|
detect: marqueeHits,
|
|
2686
3412
|
fixtures: {
|
|
2687
3413
|
flag: [
|
|
2688
|
-
|
|
2689
|
-
|
|
3414
|
+
f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
|
|
3415
|
+
f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
|
|
2690
3416
|
],
|
|
2691
|
-
pass: [
|
|
3417
|
+
pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
2692
3418
|
}
|
|
2693
3419
|
},
|
|
2694
3420
|
{
|
|
@@ -2702,10 +3428,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2702
3428
|
detect: thinBorderWideShadowHits,
|
|
2703
3429
|
fixtures: {
|
|
2704
3430
|
flag: [
|
|
2705
|
-
|
|
2706
|
-
|
|
3431
|
+
f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
|
|
3432
|
+
f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
2707
3433
|
],
|
|
2708
|
-
pass: [
|
|
3434
|
+
pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
2709
3435
|
}
|
|
2710
3436
|
},
|
|
2711
3437
|
{
|
|
@@ -2719,15 +3445,61 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2719
3445
|
detect: introHits,
|
|
2720
3446
|
fixtures: {
|
|
2721
3447
|
flag: [
|
|
2722
|
-
|
|
3448
|
+
f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
|
|
2723
3449
|
export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
2724
|
-
|
|
3450
|
+
f3("app/page.tsx", `<><Preloader /><Hero /></>`)
|
|
2725
3451
|
],
|
|
2726
|
-
pass: [
|
|
3452
|
+
pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
|
|
2727
3453
|
}
|
|
2728
3454
|
}
|
|
2729
3455
|
];
|
|
2730
3456
|
|
|
3457
|
+
// src/character/house.ts
|
|
3458
|
+
var HOUSE = {
|
|
3459
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
3460
|
+
emoji: { tier: "block", label: "emoji" },
|
|
3461
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
3462
|
+
"no-x-no-y": {
|
|
3463
|
+
tier: "block",
|
|
3464
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
3465
|
+
},
|
|
3466
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
3467
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
3468
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
3469
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
3470
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
3471
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
3472
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
3473
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
3474
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
3475
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
3476
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
3477
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
3478
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
3479
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
3480
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
3481
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
3482
|
+
"false-range": { tier: "review", label: "false range" },
|
|
3483
|
+
// The first candidate for block: evidence of a pasted chat reply, not a
|
|
3484
|
+
// matter of style. Blocks once its estate hits have been read (ROADMAP.md).
|
|
3485
|
+
"chatbot-residue": { tier: "review", label: "chatbot residue" },
|
|
3486
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
3487
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
3488
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
3489
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
3490
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
3491
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
3492
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
3493
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
3494
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
3495
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
3496
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
3497
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
3498
|
+
};
|
|
3499
|
+
function houseRule(id, name = id) {
|
|
3500
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
3501
|
+
}
|
|
3502
|
+
|
|
2731
3503
|
// src/character/parse.ts
|
|
2732
3504
|
var MARKUP = /\.(tsx|jsx|html?|astro|vue|svelte|mdx)$/i;
|
|
2733
3505
|
var SCRIPT = /\.(ts|js|mjs|cjs)$/i;
|
|
@@ -3192,8 +3964,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
|
3192
3964
|
function reflexFonts(list) {
|
|
3193
3965
|
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
3194
3966
|
return (s) => {
|
|
3195
|
-
const total = s.fonts.reduce((n,
|
|
3196
|
-
const display = s.fonts.reduce((n,
|
|
3967
|
+
const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
|
|
3968
|
+
const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
|
|
3197
3969
|
const hits = [];
|
|
3198
3970
|
const seen = /* @__PURE__ */ new Set();
|
|
3199
3971
|
const h1 = s.headings.find((h) => h.level === 1);
|
|
@@ -3394,8 +4166,8 @@ var RENDERED_PATHS = {
|
|
|
3394
4166
|
};
|
|
3395
4167
|
|
|
3396
4168
|
// src/character/check.ts
|
|
3397
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
3398
|
-
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
|
|
4169
|
+
var CATALOGUE_VERSION = "2026.09.7";
|
|
4170
|
+
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
|
|
3399
4171
|
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
3400
4172
|
);
|
|
3401
4173
|
function tellById(id) {
|
|
@@ -3430,7 +4202,8 @@ function toFinding(tell, hit2, texts) {
|
|
|
3430
4202
|
line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
|
|
3431
4203
|
excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
|
|
3432
4204
|
message: hit2.message,
|
|
3433
|
-
fix: tell.fix
|
|
4205
|
+
fix: tell.fix,
|
|
4206
|
+
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
3434
4207
|
};
|
|
3435
4208
|
}
|
|
3436
4209
|
function report(tells, files, run, options) {
|
|
@@ -3483,6 +4256,10 @@ function report(tells, files, run, options) {
|
|
|
3483
4256
|
}
|
|
3484
4257
|
};
|
|
3485
4258
|
}
|
|
4259
|
+
function applyHouseGate(result) {
|
|
4260
|
+
const findings = result.findings.map((f4) => f4.house === "block" ? { ...f4, severity: "block" } : f4);
|
|
4261
|
+
return { ...result, findings, summary: { ...result.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
|
|
4262
|
+
}
|
|
3486
4263
|
function scanSource(files, options = {}) {
|
|
3487
4264
|
const ctx = { files: files.map(parseFile) };
|
|
3488
4265
|
const tells = CATALOGUE.filter((t) => t.surface === "source");
|
|
@@ -3542,15 +4319,15 @@ function wordListsJson() {
|
|
|
3542
4319
|
function formatReport(report2, title) {
|
|
3543
4320
|
const lines = [];
|
|
3544
4321
|
let current = "";
|
|
3545
|
-
for (const
|
|
3546
|
-
if (
|
|
3547
|
-
current =
|
|
3548
|
-
lines.push("",
|
|
4322
|
+
for (const f4 of report2.findings) {
|
|
4323
|
+
if (f4.path !== current) {
|
|
4324
|
+
current = f4.path;
|
|
4325
|
+
lines.push("", f4.path);
|
|
3549
4326
|
}
|
|
3550
|
-
lines.push(` ${String(
|
|
3551
|
-
lines.push(` ${
|
|
4327
|
+
lines.push(` ${String(f4.line).padStart(4)} ${f4.tell} (gen ${f4.generation}, ${f4.severity}) ${f4.message}`);
|
|
4328
|
+
lines.push(` ${f4.excerpt}`);
|
|
3552
4329
|
}
|
|
3553
|
-
const fixes = new Map(report2.findings.map((
|
|
4330
|
+
const fixes = new Map(report2.findings.map((f4) => [f4.tell, f4.fix]));
|
|
3554
4331
|
if (fixes.size > 0) {
|
|
3555
4332
|
lines.push("", "What to do instead:");
|
|
3556
4333
|
for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
|
|
@@ -3573,6 +4350,87 @@ function formatReport(report2, title) {
|
|
|
3573
4350
|
return lines.join("\n").replace(/^\n/, "");
|
|
3574
4351
|
}
|
|
3575
4352
|
|
|
4353
|
+
// src/character/facts.ts
|
|
4354
|
+
var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
|
|
4355
|
+
var PATTERNS = [
|
|
4356
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4357
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4358
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4359
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4360
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4361
|
+
["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
|
|
4362
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4363
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4364
|
+
];
|
|
4365
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4366
|
+
var normalise = (kind, value) => {
|
|
4367
|
+
const v = value.trim();
|
|
4368
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4369
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4370
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4371
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4372
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4373
|
+
};
|
|
4374
|
+
function visibleText(file) {
|
|
4375
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4376
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4377
|
+
}
|
|
4378
|
+
function protectedFacts(text) {
|
|
4379
|
+
const facts = [];
|
|
4380
|
+
let rest = text;
|
|
4381
|
+
const mask = (start, length) => {
|
|
4382
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4383
|
+
};
|
|
4384
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
4385
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
4386
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4387
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4388
|
+
mask(m.index ?? 0, m[0].length);
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
const lines = rest.split("\n");
|
|
4392
|
+
for (const line of lines) {
|
|
4393
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4394
|
+
for (const m of line.matchAll(NAME)) {
|
|
4395
|
+
const at = m.index ?? 0;
|
|
4396
|
+
const before = line.slice(0, at).trimEnd();
|
|
4397
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4398
|
+
const word = m[0];
|
|
4399
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4400
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4401
|
+
if (word === "I") continue;
|
|
4402
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
return facts;
|
|
4406
|
+
}
|
|
4407
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4408
|
+
function compareFacts(before, after) {
|
|
4409
|
+
const a = unique(protectedFacts(before));
|
|
4410
|
+
const b = unique(protectedFacts(after));
|
|
4411
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
4412
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
4413
|
+
return {
|
|
4414
|
+
before: a.length,
|
|
4415
|
+
after: b.length,
|
|
4416
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4417
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4421
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4422
|
+
if (result.lost.length) {
|
|
4423
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4424
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4425
|
+
}
|
|
4426
|
+
if (result.added.length) {
|
|
4427
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4428
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4429
|
+
}
|
|
4430
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4431
|
+
return lines.join("\n");
|
|
4432
|
+
}
|
|
4433
|
+
|
|
3576
4434
|
// src/fingerprint/index.ts
|
|
3577
4435
|
var FINGERPRINT_VERSION = 1;
|
|
3578
4436
|
var CHROMATIC = 0.03;
|
|
@@ -3708,8 +4566,8 @@ function tellFor(key, value) {
|
|
|
3708
4566
|
}
|
|
3709
4567
|
if (key === "display" || key === "body") {
|
|
3710
4568
|
const name = normaliseFamily(value).toLowerCase();
|
|
3711
|
-
if (REFLEX_FONTS_1.some((
|
|
3712
|
-
if (REFLEX_FONTS_2.some((
|
|
4569
|
+
if (REFLEX_FONTS_1.some((f4) => f4.toLowerCase() === name)) return "reflex-font";
|
|
4570
|
+
if (REFLEX_FONTS_2.some((f4) => f4.toLowerCase() === name)) return "reflex-font-2";
|
|
3713
4571
|
}
|
|
3714
4572
|
return null;
|
|
3715
4573
|
}
|
|
@@ -4076,11 +4934,11 @@ function sharedChoices(target2, runs) {
|
|
|
4076
4934
|
}
|
|
4077
4935
|
return out.sort((a, b) => b.runs - a.runs);
|
|
4078
4936
|
}
|
|
4079
|
-
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((
|
|
4080
|
-
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((
|
|
4937
|
+
var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f4) => f4.toLowerCase()));
|
|
4938
|
+
var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f4) => f4.toLowerCase()));
|
|
4081
4939
|
function knownFor(kind, value, sample) {
|
|
4082
4940
|
if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
|
|
4083
|
-
if (kind === "accent" && value === "violet") return sample.some((
|
|
4941
|
+
if (kind === "accent" && value === "violet") return sample.some((f4) => f4.accent && isAiViolet(f4.accent)) ? "ai-violet" : null;
|
|
4084
4942
|
if (kind === "ground" && value === "cream") return "cream-palette";
|
|
4085
4943
|
if (kind === "shape" && value === "pill") return "pill-everything";
|
|
4086
4944
|
if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
|
|
@@ -4163,7 +5021,7 @@ function harvest(models, options = {}) {
|
|
|
4163
5021
|
};
|
|
4164
5022
|
runs.forEach((r, i) => {
|
|
4165
5023
|
const c = choicesOf(r.fingerprint);
|
|
4166
|
-
for (const
|
|
5024
|
+
for (const f4 of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f4, i, r.brief);
|
|
4167
5025
|
add("accent", c.accent[0], i, r.brief);
|
|
4168
5026
|
add("ground", c.ground[0], i, r.brief);
|
|
4169
5027
|
add("shape", c.shape[0], i, r.brief);
|
|
@@ -4302,30 +5160,30 @@ var PART_AREA = {
|
|
|
4302
5160
|
};
|
|
4303
5161
|
var DEFAULTS = /^(?:ground )?(?:white|grey)(?: ground)?$|no scroll reveals|no reveals|accent (?:none|neutral)|^no buttons|shape no buttons/;
|
|
4304
5162
|
function designFindings(findings, copyTells) {
|
|
4305
|
-
return (findings?.findings ?? []).filter((
|
|
5163
|
+
return (findings?.findings ?? []).filter((f4) => !copyTells.has(f4.tell));
|
|
4306
5164
|
}
|
|
4307
5165
|
function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
4308
5166
|
const actions = [];
|
|
4309
5167
|
const signals = [];
|
|
4310
5168
|
if (input.findings) {
|
|
4311
5169
|
const design = designFindings(input.findings, copyTells);
|
|
4312
|
-
const distinct = [...new Set(design.map((
|
|
5170
|
+
const distinct = [...new Set(design.map((f4) => f4.tell))];
|
|
4313
5171
|
signals.push({
|
|
4314
5172
|
id: "tells",
|
|
4315
5173
|
raised: distinct.length >= TELL_HEAVY,
|
|
4316
5174
|
detail: `${distinct.length} distinct design tell${distinct.length === 1 ? "" : "s"}${distinct.length ? `: ${distinct.join(", ")}` : ""}`
|
|
4317
5175
|
});
|
|
4318
5176
|
const seen = /* @__PURE__ */ new Set();
|
|
4319
|
-
for (const
|
|
4320
|
-
if (seen.has(
|
|
4321
|
-
seen.add(
|
|
4322
|
-
const map = TELL_AREA[
|
|
4323
|
-
const both = design.some((g) => g.tell ===
|
|
4324
|
-
actions.push({ area: map.area, what:
|
|
4325
|
-
}
|
|
4326
|
-
const copy = (input.findings.findings ?? []).filter((
|
|
5177
|
+
for (const f4 of design) {
|
|
5178
|
+
if (seen.has(f4.tell)) continue;
|
|
5179
|
+
seen.add(f4.tell);
|
|
5180
|
+
const map = TELL_AREA[f4.tell] ?? { area: "effects" };
|
|
5181
|
+
const both = design.some((g) => g.tell === f4.tell && g.line === 0) && design.some((g) => g.tell === f4.tell && g.line > 0);
|
|
5182
|
+
actions.push({ area: map.area, what: f4.fix, why: `${f4.name} (${f4.tell}, gen ${f4.generation})${both ? ", in source and on the page" : ""}`, priority: both ? 1 : 2, choice: map.choice });
|
|
5183
|
+
}
|
|
5184
|
+
const copy = (input.findings.findings ?? []).filter((f4) => copyTells.has(f4.tell));
|
|
4327
5185
|
const byTell = /* @__PURE__ */ new Map();
|
|
4328
|
-
for (const
|
|
5186
|
+
for (const f4 of copy) byTell.set(f4.tell, [...byTell.get(f4.tell) ?? [], f4]);
|
|
4329
5187
|
for (const [tell, list] of byTell) {
|
|
4330
5188
|
actions.push({ area: "copy", what: list[0].fix, why: `${list[0].name} (${tell}), ${list.length} place${list.length === 1 ? "" : "s"}, first at ${list[0].path}${list[0].line ? `:${list[0].line}` : ""}`, priority: 3 });
|
|
4331
5189
|
}
|
|
@@ -4390,9 +5248,9 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
|
4390
5248
|
same.priority = Math.min(same.priority, a.priority);
|
|
4391
5249
|
} else merged.push({ ...a });
|
|
4392
5250
|
}
|
|
4393
|
-
const
|
|
4394
|
-
|
|
4395
|
-
return { site: input.site, verdict, summary, signals, actions:
|
|
5251
|
+
const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
|
|
5252
|
+
unique2.sort((a, b) => a.priority - b.priority || order.indexOf(a.area) - order.indexOf(b.area));
|
|
5253
|
+
return { site: input.site, verdict, summary, signals, actions: unique2 };
|
|
4396
5254
|
}
|
|
4397
5255
|
function areaOfShared(part) {
|
|
4398
5256
|
if (/headline|body$/.test(part)) return "type";
|
|
@@ -4482,6 +5340,7 @@ export {
|
|
|
4482
5340
|
ESTATE_VERSION,
|
|
4483
5341
|
EXIT_RATIO,
|
|
4484
5342
|
FINGERPRINT_VERSION,
|
|
5343
|
+
HOUSE,
|
|
4485
5344
|
HOUSE_BUDGET,
|
|
4486
5345
|
HOUSE_TYPE,
|
|
4487
5346
|
LIGHTNESS_CURVE,
|
|
@@ -4514,6 +5373,7 @@ export {
|
|
|
4514
5373
|
accentFork,
|
|
4515
5374
|
addSite,
|
|
4516
5375
|
apcaContrast,
|
|
5376
|
+
applyHouseGate,
|
|
4517
5377
|
auditSnapshot,
|
|
4518
5378
|
catalogueTable,
|
|
4519
5379
|
characterReport,
|
|
@@ -4521,6 +5381,7 @@ export {
|
|
|
4521
5381
|
checkPair,
|
|
4522
5382
|
checkRestraint,
|
|
4523
5383
|
choicesOf,
|
|
5384
|
+
compareFacts,
|
|
4524
5385
|
compareToEstate,
|
|
4525
5386
|
craftTokens,
|
|
4526
5387
|
deltaEOk,
|
|
@@ -4539,11 +5400,13 @@ export {
|
|
|
4539
5400
|
fluidSpace,
|
|
4540
5401
|
fluidType,
|
|
4541
5402
|
fontClass,
|
|
5403
|
+
formatComparison,
|
|
4542
5404
|
formatHex,
|
|
4543
5405
|
formatReport,
|
|
4544
5406
|
groundName,
|
|
4545
5407
|
harvest,
|
|
4546
5408
|
hexToOklch,
|
|
5409
|
+
houseRule,
|
|
4547
5410
|
hueName,
|
|
4548
5411
|
hueSwatch,
|
|
4549
5412
|
inP3Gamut,
|
|
@@ -4562,6 +5425,7 @@ export {
|
|
|
4562
5425
|
parseColour,
|
|
4563
5426
|
parseHex,
|
|
4564
5427
|
propose,
|
|
5428
|
+
protectedFacts,
|
|
4565
5429
|
ramp,
|
|
4566
5430
|
rampFromAnchors,
|
|
4567
5431
|
retrofitPlan,
|
|
@@ -4580,6 +5444,7 @@ export {
|
|
|
4580
5444
|
typeFeatureTokens,
|
|
4581
5445
|
typicality,
|
|
4582
5446
|
validateDirection,
|
|
5447
|
+
visibleText,
|
|
4583
5448
|
wcagContrast,
|
|
4584
5449
|
wordListsJson
|
|
4585
5450
|
};
|