@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
package/dist/audit.cjs
CHANGED
|
@@ -25,7 +25,8 @@ __export(audit_exports, {
|
|
|
25
25
|
loadPlaywright: () => loadPlaywright,
|
|
26
26
|
overlayInPage: () => overlayInPage,
|
|
27
27
|
snapshotPage: () => snapshotPage,
|
|
28
|
-
snapshotUrl: () => snapshotUrl
|
|
28
|
+
snapshotUrl: () => snapshotUrl,
|
|
29
|
+
snapshotUrls: () => snapshotUrls
|
|
29
30
|
});
|
|
30
31
|
module.exports = __toCommonJS(audit_exports);
|
|
31
32
|
|
|
@@ -35,15 +36,22 @@ var isProsePath = (path) => /\.(md|markdown|mdx|txt)$/i.test(path);
|
|
|
35
36
|
function allText(ctx) {
|
|
36
37
|
return [...ctx.blocks.filter((b) => isProsePath(b.path)), ...ctx.strings.filter((b) => !isProsePath(b.path))];
|
|
37
38
|
}
|
|
39
|
+
var LINK = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+|\]\([^)\s]+\)/gi;
|
|
40
|
+
function linkSpans(text) {
|
|
41
|
+
return [...text.matchAll(LINK)].map((m) => [m.index ?? 0, (m.index ?? 0) + m[0].length]);
|
|
42
|
+
}
|
|
43
|
+
var insideLink = (spans, at) => spans.some(([a, b]) => at >= a && at < b);
|
|
38
44
|
function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => false) {
|
|
39
45
|
return (ctx) => {
|
|
40
46
|
const hits = [];
|
|
41
47
|
for (const block of source(ctx)) {
|
|
42
48
|
const spans = [];
|
|
49
|
+
const links = linkSpans(block.text);
|
|
43
50
|
for (const pattern of patterns) {
|
|
44
51
|
const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
45
52
|
for (const m of block.text.matchAll(global)) {
|
|
46
53
|
if (skip(block, m[0], m.index ?? 0)) continue;
|
|
54
|
+
if (insideLink(links, m.index ?? 0)) continue;
|
|
47
55
|
spans.push([m.index ?? 0, (m.index ?? 0) + m[0].length, m[0]]);
|
|
48
56
|
}
|
|
49
57
|
}
|
|
@@ -84,7 +92,14 @@ var AI_WORDS = [
|
|
|
84
92
|
"moreover",
|
|
85
93
|
"navigating",
|
|
86
94
|
"ever-evolving",
|
|
87
|
-
"unparalleled"
|
|
95
|
+
"unparalleled",
|
|
96
|
+
// Measured: Kobak et al. 2024 (arXiv 2406.07016) found these 10 to 14 times
|
|
97
|
+
// above their expected rate in post-ChatGPT abstracts. Academic, so warn only.
|
|
98
|
+
"underscore",
|
|
99
|
+
"underscores",
|
|
100
|
+
"underscoring",
|
|
101
|
+
"showcasing",
|
|
102
|
+
"pivotal"
|
|
88
103
|
];
|
|
89
104
|
var STOCK_PHRASES = [
|
|
90
105
|
"in today's digital age",
|
|
@@ -98,7 +113,11 @@ var STOCK_PHRASES = [
|
|
|
98
113
|
"curated",
|
|
99
114
|
"crafted with care",
|
|
100
115
|
"tailored to your",
|
|
101
|
-
"peace of mind"
|
|
116
|
+
"peace of mind",
|
|
117
|
+
"so here's",
|
|
118
|
+
"in an era where",
|
|
119
|
+
"the future looks bright",
|
|
120
|
+
"only time will tell"
|
|
102
121
|
];
|
|
103
122
|
var AI_PHRASES = [
|
|
104
123
|
"let's dive in",
|
|
@@ -231,7 +250,7 @@ function lineAround(text, index) {
|
|
|
231
250
|
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);
|
|
232
251
|
var NO_X_NO_Y = /\bno\s+[\w'’ ]{2,30},\s*no\s+\w/i;
|
|
233
252
|
var NO_X_BADGE = /^no\s+[\w'’\- ]{2,28}[.!]?$/i;
|
|
234
|
-
var EMOJI = /[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}]+/u;
|
|
253
|
+
var EMOJI = /(?:[\u{1F300}-\u{1FAFF}\u{1F1E6}-\u{1F1FF}\u2728\u2705\u274C\u274E\u2757\u2753\u26A1\u2B50\u2764]|[\u2600-\u27BF]\uFE0F)+/u;
|
|
235
254
|
var placeholderDash = (block) => block.literal === true && /^[\s—]*$/.test(block.text);
|
|
236
255
|
function badgeHits(ctx) {
|
|
237
256
|
const hits = [];
|
|
@@ -274,10 +293,16 @@ var COPY_TELLS = [
|
|
|
274
293
|
surface: "copy",
|
|
275
294
|
why: "Phrases every generated services page uses. They fill space where a fact should be.",
|
|
276
295
|
fix: "Replace the phrase with the fact behind it: a number, a name, a place, a time.",
|
|
277
|
-
detect: phrases([wordList(STOCK_PHRASES)], quoted),
|
|
296
|
+
detect: phrases([wordList(STOCK_PHRASES), /\b(?:in|on|along)\s+(?:its|their|his|her)\s+journey\b/i], quoted),
|
|
278
297
|
fixtures: {
|
|
279
|
-
flag: [
|
|
280
|
-
|
|
298
|
+
flag: [
|
|
299
|
+
f("content/home.md", "Take your plumbing to the next level with peace of mind."),
|
|
300
|
+
f("content/home.md", "We meet each practice where it is in its journey.")
|
|
301
|
+
],
|
|
302
|
+
pass: [
|
|
303
|
+
f("content/home.md", "Gas Safe registered since 2009. Same-day callouts in Brackley."),
|
|
304
|
+
f("content/home.md", "We mapped the patient journey from booking to aftercare.")
|
|
305
|
+
]
|
|
281
306
|
}
|
|
282
307
|
},
|
|
283
308
|
{
|
|
@@ -302,7 +327,11 @@ var COPY_TELLS = [
|
|
|
302
327
|
f("content/home.md", "In today's rapidly evolving market, we help."),
|
|
303
328
|
f("content/home.md", "Whether you're a landlord or a tenant, call us.")
|
|
304
329
|
],
|
|
305
|
-
pass: [
|
|
330
|
+
pass: [
|
|
331
|
+
f("content/home.md", "Book in three taps. We confirm by text within the hour."),
|
|
332
|
+
f("content/home.md", "Book at https://example.com/seamless-booking today."),
|
|
333
|
+
f("content/home.md", "[Book a slot](/seamless-booking) today.")
|
|
334
|
+
]
|
|
306
335
|
}
|
|
307
336
|
},
|
|
308
337
|
{
|
|
@@ -451,8 +480,14 @@ var COPY_TELLS = [
|
|
|
451
480
|
fix: "Remove it. Typographic marks are fine: a star for a rating, a middot as a separator, an arrow in a link.",
|
|
452
481
|
detect: phrases([new RegExp(EMOJI.source, "u")], (m) => `emoji ${m}`, (ctx) => ctx.strings),
|
|
453
482
|
fixtures: {
|
|
454
|
-
flag: [
|
|
455
|
-
|
|
483
|
+
flag: [
|
|
484
|
+
f("content/home.md", "New treatments this month \u2728\u{1F680}"),
|
|
485
|
+
f("app/cta.tsx", `export const label = "Book \u{1F680}";`),
|
|
486
|
+
f("content/home.md", "New treatments this month \u2728"),
|
|
487
|
+
f("content/home.md", "Same-day callouts \u2705"),
|
|
488
|
+
f("content/home.md", "Open Sundays \u2600\uFE0F")
|
|
489
|
+
],
|
|
490
|
+
pass: [f("content/home.md", "Rated \u2605\u2605\u2605\u2605\u2605 \xB7 Book now \u2192"), f("content/home.md", "\u2713 Gas Safe registered")]
|
|
456
491
|
}
|
|
457
492
|
},
|
|
458
493
|
{
|
|
@@ -550,6 +585,695 @@ var COPY_TELLS = [
|
|
|
550
585
|
}
|
|
551
586
|
}
|
|
552
587
|
];
|
|
588
|
+
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;
|
|
589
|
+
var VAGUE_ATTRIBUTION = [
|
|
590
|
+
/\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,
|
|
591
|
+
/\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
|
|
592
|
+
/\bit\s+is\s+(?:widely|generally|commonly)\s+(?:known|accepted|believed|agreed)\b/i
|
|
593
|
+
];
|
|
594
|
+
function sourced(block, _match, index) {
|
|
595
|
+
const before = block.text.slice(Math.max(0, index - 40), index);
|
|
596
|
+
if (/(?:\b[A-Z][\w&]*[’']s|\b[A-Z]{2,}[a-z]*)\s+$/.test(before)) return true;
|
|
597
|
+
const rest = block.text.slice(index, index + 400);
|
|
598
|
+
const end = rest.search(/[.!?](?:\s|$)/);
|
|
599
|
+
const sentence = rest.slice(0, end === -1 ? rest.length : end + 16);
|
|
600
|
+
return /\[\^\w+\]|\]\(https?:|\(source|according to [A-Z]|\bper [A-Z]/.test(sentence);
|
|
601
|
+
}
|
|
602
|
+
var CLOSING_SUMMARY = /(?:^|[.!?]\s+|\n\s*|>\s*)(?:Overall|In conclusion|In summary|To sum up|All in all|In short|To summari[sz]e),/;
|
|
603
|
+
var FALSE_RANGE = [
|
|
604
|
+
/\bfrom\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+alike\b/i,
|
|
605
|
+
/\b(?:everyone|anyone)\s+from\s+(?:[\w'’-]+\s+){0,3}[\w'’-]+\s+to\s+(?:[\w'’-]+\s*){1,4}/i
|
|
606
|
+
];
|
|
607
|
+
var RESIDUE = [
|
|
608
|
+
/\bas an AI(?:\s+language)?\s+(?:model|assistant)\b/i,
|
|
609
|
+
/(?:^|\n|>)\s*(?:Certainly|Sure|Absolutely|Of course|Great question)[!,.]\s+(?:Here(?:['’]s| is| are)|Below)\b/i,
|
|
610
|
+
/\bhere(?:['’]s| is)\s+(?:a|an|the|your)\s+(?:revised|rewritten|updated|polished|refined|improved|draft|more concise)\s+(?:version|draft|copy)\b/i,
|
|
611
|
+
/\blet me know if you(?:['’]d| would) like me to\b/i,
|
|
612
|
+
/\b(?:my|the) (?:knowledge|training) cut-?off\b|\bas of my last (?:update|training)\b/i,
|
|
613
|
+
/\bI hope this helps\b/i,
|
|
614
|
+
// Citation tokens a chat tool leaves in copied text.
|
|
615
|
+
// A run of them is one paste: "citeturn11search1turn10view0" is one finding.
|
|
616
|
+
// ChatGPT wraps them in invisible private-use characters (U+E200 to U+E202),
|
|
617
|
+
// which survive a copy and paste into a CMS: found live, 2026-09-23.
|
|
618
|
+
/[\uE200-\uE2FF]*(?:cite)?[\uE200-\uE2FF]*(?:turn\d+(?:search|news|view|fetch|file|image)\d+[\uE200-\uE2FF]*)+/,
|
|
619
|
+
/:?contentReference\[oaicite:\d+\](?:\{index=\d+\})?/,
|
|
620
|
+
/【\d+(?:[:†][^】]{0,40})?】/
|
|
621
|
+
];
|
|
622
|
+
var AI_UTM = /utm_source=(?:chatgpt\.com|chat\.openai\.com|openai|perplexity(?:\.ai)?|claude\.ai|copilot(?:\.microsoft\.com)?|gemini(?:\.google\.com)?)\b/gi;
|
|
623
|
+
function residueHits(ctx) {
|
|
624
|
+
const hits = phrases(RESIDUE, quoted, allText)(ctx);
|
|
625
|
+
for (const file of ctx.files) {
|
|
626
|
+
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]}"` });
|
|
627
|
+
}
|
|
628
|
+
return hits;
|
|
629
|
+
}
|
|
630
|
+
var PLACEHOLDER = [
|
|
631
|
+
/\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n]{0,40}\](?!\()/i,
|
|
632
|
+
/\blorem ipsum\b/i,
|
|
633
|
+
/\bTODO_PLACEHOLDER\b/
|
|
634
|
+
];
|
|
635
|
+
var PLACEHOLDER_ELEMENT = />\s*(\[(?:insert|your|add|company|client|business|customer|name of|placeholder)\b[^\]\n<]{0,40}\])\s*</gi;
|
|
636
|
+
function insideQuotes(block, _match, index) {
|
|
637
|
+
const lineStart = block.text.lastIndexOf("\n", index - 1) + 1;
|
|
638
|
+
const before = block.text.slice(lineStart, index);
|
|
639
|
+
const straight = (before.match(/"/g) ?? []).length;
|
|
640
|
+
const curly = (before.match(/“/g) ?? []).length - (before.match(/”/g) ?? []).length;
|
|
641
|
+
return straight % 2 === 1 || curly > 0;
|
|
642
|
+
}
|
|
643
|
+
function placeholderHits(ctx) {
|
|
644
|
+
const hits = phrases(PLACEHOLDER, quoted, allText, insideQuotes)(ctx);
|
|
645
|
+
const seen = new Set(hits.map((h) => `${h.path}:${h.offset}`));
|
|
646
|
+
for (const file of ctx.files) {
|
|
647
|
+
if (isProsePath(file.path)) continue;
|
|
648
|
+
for (const m of file.text.matchAll(PLACEHOLDER_ELEMENT)) {
|
|
649
|
+
const offset = (m.index ?? 0) + m[0].indexOf(m[1]);
|
|
650
|
+
if (!seen.has(`${file.path}:${offset}`)) hits.push({ path: file.path, offset, message: quoted(m[1]) });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return hits;
|
|
654
|
+
}
|
|
655
|
+
var QUESTION_REVEAL = [
|
|
656
|
+
/\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]/,
|
|
657
|
+
/\?\s+It means\b/
|
|
658
|
+
];
|
|
659
|
+
function inlineLabelHits(ctx) {
|
|
660
|
+
const hits = [];
|
|
661
|
+
for (const file of ctx.files) {
|
|
662
|
+
if (!isProsePath(file.path)) continue;
|
|
663
|
+
const lines = file.text.split("\n");
|
|
664
|
+
let run = [];
|
|
665
|
+
let offset = 0;
|
|
666
|
+
const flush = () => {
|
|
667
|
+
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` });
|
|
668
|
+
run = [];
|
|
669
|
+
};
|
|
670
|
+
for (const line of lines) {
|
|
671
|
+
const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
|
|
672
|
+
if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
|
|
673
|
+
else flush();
|
|
674
|
+
offset += line.length + 1;
|
|
675
|
+
}
|
|
676
|
+
flush();
|
|
677
|
+
}
|
|
678
|
+
return hits;
|
|
679
|
+
}
|
|
680
|
+
var RESEARCH_TELLS = [
|
|
681
|
+
{
|
|
682
|
+
id: "chatbot-residue",
|
|
683
|
+
name: "Chatbot residue",
|
|
684
|
+
generation: 1,
|
|
685
|
+
severity: "warn",
|
|
686
|
+
surface: "copy",
|
|
687
|
+
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.",
|
|
688
|
+
fix: "Delete the residue. Then read the whole passage again, because the rest of it came from the same paste.",
|
|
689
|
+
detect: residueHits,
|
|
690
|
+
fixtures: {
|
|
691
|
+
flag: [
|
|
692
|
+
f("content/home.md", "Certainly! Here's a revised version of your home page."),
|
|
693
|
+
f("content/home.md", "As an AI language model, I cannot visit your salon."),
|
|
694
|
+
f("content/home.md", "Let me know if you'd like me to shorten it."),
|
|
695
|
+
f("content/home.md", "Prices start at \xA340 citeturn0search3 for a full set."),
|
|
696
|
+
f("content/home.md", "Prices start at \xA340. citeturn11search1turn10view0turn2view3"),
|
|
697
|
+
f("content/home.md", "Prices start at \xA340. \uE200cite\uE202turn11search1\uE202turn10view0\uE201"),
|
|
698
|
+
f("content/home.md", "Prices start at \xA340 :contentReference[oaicite:2]{index=2} for a full set."),
|
|
699
|
+
f("content/home.md", "Prices start at \xA340\u30104\u2020source\u3011 for a full set."),
|
|
700
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=chatgpt.com">Read the guide</a>;`),
|
|
701
|
+
f("content/home.md", "Here's a more concise version for the hero.")
|
|
702
|
+
],
|
|
703
|
+
pass: [
|
|
704
|
+
f("content/home.md", "Certainly the busiest week of the year: book early."),
|
|
705
|
+
f("content/home.md", "Here's the price list for 2026."),
|
|
706
|
+
f("app/guide.tsx", `export const Guide = () => <a href="https://example.com/guide?utm_source=newsletter">Read the guide</a>;`)
|
|
707
|
+
]
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
id: "placeholder",
|
|
712
|
+
name: "Unfilled placeholder",
|
|
713
|
+
generation: 1,
|
|
714
|
+
severity: "warn",
|
|
715
|
+
surface: "copy",
|
|
716
|
+
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.",
|
|
717
|
+
fix: "Fill it from the client or the proof source, or remove the element. Never invent the missing fact to close it.",
|
|
718
|
+
detect: placeholderHits,
|
|
719
|
+
fixtures: {
|
|
720
|
+
flag: [
|
|
721
|
+
f("content/home.md", "Rated 5 stars by [Insert client name] and others."),
|
|
722
|
+
f("content/home.md", "Lorem ipsum dolor sit amet."),
|
|
723
|
+
f("content/home.md", "Rated TODO_PLACEHOLDER: google_rating from our reviews."),
|
|
724
|
+
f("app/card.tsx", `export const Quote = () => <blockquote>[Insert testimonial here]</blockquote>;`)
|
|
725
|
+
],
|
|
726
|
+
pass: [
|
|
727
|
+
f("content/home.md", "Manage bookings in [your account](/account)."),
|
|
728
|
+
f("content/guide.md", 'After 90 days, check your ranking for "[your trade] [your town]" on Google.'),
|
|
729
|
+
f("content/guide.md", "Send a short message: \u201CThanks for choosing [Business Name]! Leave us a review.\u201D"),
|
|
730
|
+
f("content/home.md", "Rated 4.9 from 212 Google reviews.")
|
|
731
|
+
]
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
id: "question-reveal",
|
|
736
|
+
name: "Staged reveal",
|
|
737
|
+
generation: 2,
|
|
738
|
+
severity: "warn",
|
|
739
|
+
surface: "copy",
|
|
740
|
+
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.",
|
|
741
|
+
fix: "State the answer: 'Most customers book again within six weeks.'",
|
|
742
|
+
detect: phrases(QUESTION_REVEAL, quoted),
|
|
743
|
+
fixtures: {
|
|
744
|
+
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.")],
|
|
745
|
+
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.")]
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
id: "inline-label-list",
|
|
750
|
+
name: "Bold-label bullets",
|
|
751
|
+
generation: 2,
|
|
752
|
+
severity: "warn",
|
|
753
|
+
surface: "copy",
|
|
754
|
+
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.",
|
|
755
|
+
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.",
|
|
756
|
+
detect: inlineLabelHits,
|
|
757
|
+
fixtures: {
|
|
758
|
+
flag: [f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n- **Support:** Help when you need it\n")],
|
|
759
|
+
pass: [
|
|
760
|
+
f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
|
|
761
|
+
f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
|
|
762
|
+
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")
|
|
763
|
+
]
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
id: "ing-tail",
|
|
768
|
+
name: "Empty -ing tail",
|
|
769
|
+
generation: 2,
|
|
770
|
+
severity: "warn",
|
|
771
|
+
surface: "copy",
|
|
772
|
+
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.",
|
|
773
|
+
fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
|
|
774
|
+
detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
|
|
775
|
+
fixtures: {
|
|
776
|
+
flag: [
|
|
777
|
+
f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
|
|
778
|
+
f("app/about.tsx", `export const About = () => <p>We have served Brackley since 2009, highlighting our commitment to the town.</p>;`)
|
|
779
|
+
],
|
|
780
|
+
pass: [
|
|
781
|
+
f("content/home.md", "We open at 7am, so you can drop the car off before work."),
|
|
782
|
+
f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
|
|
783
|
+
]
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
id: "vague-attribution",
|
|
788
|
+
name: "Unnamed source",
|
|
789
|
+
generation: 1,
|
|
790
|
+
severity: "warn",
|
|
791
|
+
surface: "copy",
|
|
792
|
+
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.",
|
|
793
|
+
fix: "Name the source and the number ('A 2023 Which? survey of 2,000 drivers found...') or cut the claim.",
|
|
794
|
+
detect: phrases(VAGUE_ATTRIBUTION, quoted, (ctx) => ctx.blocks, sourced),
|
|
795
|
+
fixtures: {
|
|
796
|
+
flag: [
|
|
797
|
+
f("content/home.md", "Studies show that regular servicing saves money."),
|
|
798
|
+
f("content/home.md", "Most experts agree a boiler lasts fifteen years."),
|
|
799
|
+
f("content/home.md", "It is widely known that gel damages nails.")
|
|
800
|
+
],
|
|
801
|
+
pass: [
|
|
802
|
+
f("content/home.md", "A 2023 Which? survey of 2,000 owners found serviced boilers failed half as often."),
|
|
803
|
+
f("content/home.md", "BrightLocal's research shows that reviews drive 20% of local ranking."),
|
|
804
|
+
f("content/home.md", "Research shows 62% of consumers avoid a business with wrong hours.[^7]"),
|
|
805
|
+
f("content/home.md", "Our customers rate us 4.9 from 212 Google reviews.")
|
|
806
|
+
]
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
id: "closing-summary",
|
|
811
|
+
name: "Closing summary",
|
|
812
|
+
generation: 1,
|
|
813
|
+
severity: "warn",
|
|
814
|
+
surface: "copy",
|
|
815
|
+
why: "'Overall,', 'In conclusion,', 'In short,': a paragraph that announces it is summing up, then restates what the reader has just read.",
|
|
816
|
+
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'.",
|
|
817
|
+
detect: phrases([CLOSING_SUMMARY], (m) => quoted(m.replace(/^[.!?>\s]+/, ""))),
|
|
818
|
+
fixtures: {
|
|
819
|
+
flag: [
|
|
820
|
+
f("content/home.md", "We rewire, test and certify.\n\nOverall, we are the right choice for your home."),
|
|
821
|
+
f("content/home.md", "Prices start at \xA340. In conclusion, we offer great value.")
|
|
822
|
+
],
|
|
823
|
+
pass: [f("content/home.md", "We rewire, test and certify.\n\nBook a survey and we'll quote within two days.")]
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: "false-range",
|
|
828
|
+
name: "False range",
|
|
829
|
+
generation: 1,
|
|
830
|
+
severity: "warn",
|
|
831
|
+
surface: "copy",
|
|
832
|
+
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.",
|
|
833
|
+
fix: "Say who you actually serve, or cut it. A real range is fine: 'from 9am to 5pm', 'from small repairs to full rewires'.",
|
|
834
|
+
detect: phrases(FALSE_RANGE, quoted),
|
|
835
|
+
fixtures: {
|
|
836
|
+
flag: [
|
|
837
|
+
f("content/home.md", "We help clients from first-time buyers to seasoned investors alike."),
|
|
838
|
+
f("content/home.md", "Everyone from students to retirees loves our cakes.")
|
|
839
|
+
],
|
|
840
|
+
pass: [
|
|
841
|
+
f("content/home.md", "Open from 9am to 5pm, Monday to Saturday."),
|
|
842
|
+
f("content/home.md", "We take on everything from small repairs to full rewires.")
|
|
843
|
+
]
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
// src/character/tells/density.ts
|
|
849
|
+
var DOCUMENT = /\.(md|markdown|mdx|txt|html?)$/i;
|
|
850
|
+
var MIN_WORDS = 400;
|
|
851
|
+
var WORD = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
852
|
+
var countWords = (text) => (text.match(WORD) ?? []).length;
|
|
853
|
+
function documents(ctx) {
|
|
854
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
855
|
+
for (const block of ctx.blocks) {
|
|
856
|
+
if (!DOCUMENT.test(block.path) || block.literal) continue;
|
|
857
|
+
const list = byPath.get(block.path) ?? [];
|
|
858
|
+
list.push(block);
|
|
859
|
+
byPath.set(block.path, list);
|
|
860
|
+
}
|
|
861
|
+
const docs = [];
|
|
862
|
+
for (const [path, blocks] of byPath) {
|
|
863
|
+
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
864
|
+
const words = countWords(text);
|
|
865
|
+
if (words >= MIN_WORDS) docs.push({ path, blocks, words });
|
|
866
|
+
}
|
|
867
|
+
return docs;
|
|
868
|
+
}
|
|
869
|
+
var withoutHeadings = (text) => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, "");
|
|
870
|
+
var allowed = (doc2, floor, per1000) => Math.max(floor, Math.floor(doc2.words * per1000 / 1e3));
|
|
871
|
+
var thousands = (n) => n.toLocaleString("en-GB");
|
|
872
|
+
function sentences(doc2) {
|
|
873
|
+
const out = [];
|
|
874
|
+
for (const block of doc2.blocks) {
|
|
875
|
+
const text = withoutHeadings(block.text);
|
|
876
|
+
for (const para of text.matchAll(/[^\n]+(?:\n(?!\s*\n)[^\n]+)*/g)) {
|
|
877
|
+
const body = para[0];
|
|
878
|
+
const start = para.index ?? 0;
|
|
879
|
+
for (const m of body.matchAll(/[^.!?]+[.!?]+["'’”)]*|[^.!?]+$/g)) {
|
|
880
|
+
const raw = m[0];
|
|
881
|
+
const trimmed = raw.trim();
|
|
882
|
+
if (countWords(trimmed) === 0) continue;
|
|
883
|
+
const lead = raw.length - raw.trimStart().length;
|
|
884
|
+
out.push({ text: trimmed.replace(/\s+/g, " "), offset: block.offset + start + (m.index ?? 0) + lead });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return out;
|
|
889
|
+
}
|
|
890
|
+
var RATES = [
|
|
891
|
+
{ label: "rather than", pattern: /\brather\s+than\b/i, floor: 4, per1000: 0.9, why: "the document's one move" },
|
|
892
|
+
{ label: "instead of", pattern: /\binstead\s+of\b/i, floor: 4, per1000: 0.7, why: "the reflex swap once 'rather than' is edited out" },
|
|
893
|
+
{ label: "actually", pattern: /\bactually\b/i, floor: 2, per1000: 0.35, why: "filler, the sentence means the same without it" },
|
|
894
|
+
{ label: "properly", pattern: /\bproperly\b/i, floor: 3, per1000: 0.4, why: "filler, the sentence means the same without it" },
|
|
895
|
+
{ label: "genuinely", pattern: /\bgenuinely\b/i, floor: 1, per1000: 0.2, why: "reaching for sincerity: one is emphasis, two is a tic" },
|
|
896
|
+
{
|
|
897
|
+
label: "the single ...est",
|
|
898
|
+
pattern: /\bthe\s+single\s+(?:\w+est|most|\w+\s+most)\b/i,
|
|
899
|
+
floor: 1,
|
|
900
|
+
per1000: 0.15,
|
|
901
|
+
why: "if three things are the single most important, none is"
|
|
902
|
+
},
|
|
903
|
+
{ 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" },
|
|
904
|
+
{ label: "Every opener", pattern: /^Every\b/, opener: true, floor: 4, per1000: 0.6, why: "the same sweeping opener, again" },
|
|
905
|
+
{
|
|
906
|
+
label: "Worth noting opener",
|
|
907
|
+
pattern: /^(?:Worth\s+(?:knowing|noting|saying|mentioning|remembering)|It(?:['’]s|\s+is)\s+worth\s+(?:knowing|noting|saying|mentioning|remembering))\b/,
|
|
908
|
+
opener: true,
|
|
909
|
+
floor: 3,
|
|
910
|
+
per1000: 0.5,
|
|
911
|
+
why: "a hedge that lets a sentence start without committing"
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
label: "negation-led opener",
|
|
915
|
+
pattern: /^(?:None\s+of|Not\s+one|Not\s+a\s+single|Nothing|No\s+one|Nobody|Never)\b/,
|
|
916
|
+
opener: true,
|
|
917
|
+
floor: 5,
|
|
918
|
+
per1000: 1,
|
|
919
|
+
why: "each earns its place once or twice; at twenty the document has one move"
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
label: "participle triad",
|
|
923
|
+
pattern: /\b[a-z]{3,}ed,\s+[a-z]{3,}ed,?\s+and\s+[a-z]{3,}ed\b/i,
|
|
924
|
+
floor: 3,
|
|
925
|
+
per1000: 0.6,
|
|
926
|
+
why: "'researched, written and referenced' is fine once and a cadence in bulk"
|
|
927
|
+
}
|
|
928
|
+
];
|
|
929
|
+
function rateHits(ctx) {
|
|
930
|
+
const hits = [];
|
|
931
|
+
for (const doc2 of documents(ctx)) {
|
|
932
|
+
const list = sentences(doc2);
|
|
933
|
+
for (const rate of RATES) {
|
|
934
|
+
const found = [];
|
|
935
|
+
const global = new RegExp(rate.pattern.source, rate.pattern.flags.replace("g", "") + "g");
|
|
936
|
+
for (const s of list) {
|
|
937
|
+
if (rate.opener) {
|
|
938
|
+
if (rate.pattern.test(s.text)) found.push(s);
|
|
939
|
+
} else {
|
|
940
|
+
const n = (s.text.match(global) ?? []).length;
|
|
941
|
+
for (let k = 0; k < n; k += 1) found.push(s);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const limit = allowed(doc2, rate.floor, rate.per1000);
|
|
945
|
+
if (found.length > limit) {
|
|
946
|
+
hits.push({
|
|
947
|
+
path: doc2.path,
|
|
948
|
+
offset: found[limit].offset,
|
|
949
|
+
message: `"${rate.label}" ${found.length} times in ${thousands(doc2.words)} words (allows ${limit}): ${rate.why}`,
|
|
950
|
+
excerpt: found[limit].text.slice(0, 120)
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return hits;
|
|
956
|
+
}
|
|
957
|
+
function aphorismHits(ctx) {
|
|
958
|
+
const hits = [];
|
|
959
|
+
for (const doc2 of documents(ctx)) {
|
|
960
|
+
const found = [];
|
|
961
|
+
for (const block of doc2.blocks) {
|
|
962
|
+
let at = 0;
|
|
963
|
+
for (const line of withoutHeadings(block.text).split("\n")) {
|
|
964
|
+
const text = line.trim().replace(/^[-*>]\s+/, "");
|
|
965
|
+
const parts2 = text.match(/[^.!?]+[.!?]+/g) ?? [];
|
|
966
|
+
if (parts2.length === 2 && parts2.join("").length >= text.length - 1 && parts2.every((p) => countWords(p) >= 2 && countWords(p) <= 7)) {
|
|
967
|
+
found.push({ text, offset: block.offset + at + line.indexOf(text.charAt(0)) });
|
|
968
|
+
}
|
|
969
|
+
at += line.length + 1;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const limit = allowed(doc2, 5, 1.2);
|
|
973
|
+
if (found.length > limit) {
|
|
974
|
+
hits.push({
|
|
975
|
+
path: doc2.path,
|
|
976
|
+
offset: found[limit].offset,
|
|
977
|
+
message: `${found.length} two-line aphorisms in ${thousands(doc2.words)} words (allows ${limit}). Keep the one that lands hardest`,
|
|
978
|
+
excerpt: found[limit].text
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return hits;
|
|
983
|
+
}
|
|
984
|
+
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;
|
|
985
|
+
function contractionHits(ctx) {
|
|
986
|
+
const hits = [];
|
|
987
|
+
for (const doc2 of documents(ctx)) {
|
|
988
|
+
if (doc2.words < 800) continue;
|
|
989
|
+
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
990
|
+
const count2 = (text.match(CONTRACTION) ?? []).length;
|
|
991
|
+
const rate = count2 * 1e3 / doc2.words;
|
|
992
|
+
if (rate < 1) {
|
|
993
|
+
const first = sentences(doc2)[0];
|
|
994
|
+
hits.push({
|
|
995
|
+
path: doc2.path,
|
|
996
|
+
offset: first?.offset ?? 0,
|
|
997
|
+
message: `${count2} contraction${count2 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return hits;
|
|
1002
|
+
}
|
|
1003
|
+
function rhythmHits(ctx) {
|
|
1004
|
+
const hits = [];
|
|
1005
|
+
for (const doc2 of documents(ctx)) {
|
|
1006
|
+
const list = sentences(doc2);
|
|
1007
|
+
if (list.length < 40) continue;
|
|
1008
|
+
const lengths = list.map((s) => countWords(s.text));
|
|
1009
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
1010
|
+
const sd = Math.sqrt(lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
|
|
1011
|
+
const cv = sd / mean;
|
|
1012
|
+
if (cv < 0.42) {
|
|
1013
|
+
hits.push({
|
|
1014
|
+
path: doc2.path,
|
|
1015
|
+
offset: list[0].offset,
|
|
1016
|
+
message: `${list.length} sentences averaging ${mean.toFixed(1)} words with little variation (spread ${cv.toFixed(2)}, reads metronomic below 0.42)`
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return hits;
|
|
1021
|
+
}
|
|
1022
|
+
var normal = (s) => s.toLowerCase().replace(/[’]/g, "'").match(/[a-z0-9£$€'%]+/g) ?? [];
|
|
1023
|
+
function repeatHits(ctx) {
|
|
1024
|
+
const hits = [];
|
|
1025
|
+
const RUN = 9;
|
|
1026
|
+
for (const doc2 of documents(ctx)) {
|
|
1027
|
+
const whole = /* @__PURE__ */ new Map();
|
|
1028
|
+
const runs = /* @__PURE__ */ new Map();
|
|
1029
|
+
for (const s of sentences(doc2)) {
|
|
1030
|
+
const words = normal(s.text);
|
|
1031
|
+
if (words.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
1032
|
+
const key = words.join(" ");
|
|
1033
|
+
const earlier = whole.get(key);
|
|
1034
|
+
if (earlier) {
|
|
1035
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
whole.set(key, s);
|
|
1039
|
+
let reported = false;
|
|
1040
|
+
for (let i = 0; i + RUN <= words.length && !reported; i += 1) {
|
|
1041
|
+
const run = words.slice(i, i + RUN).join(" ");
|
|
1042
|
+
const seen = runs.get(run);
|
|
1043
|
+
if (seen && seen.key !== key) {
|
|
1044
|
+
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) });
|
|
1045
|
+
reported = true;
|
|
1046
|
+
} else if (!seen) {
|
|
1047
|
+
runs.set(run, { sentence: s, key });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return hits;
|
|
1053
|
+
}
|
|
1054
|
+
function headings(file) {
|
|
1055
|
+
const out = [];
|
|
1056
|
+
for (const m of file.text.matchAll(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm)) {
|
|
1057
|
+
out.push({ text: m[1], offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
1058
|
+
}
|
|
1059
|
+
for (const m of file.text.matchAll(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi)) {
|
|
1060
|
+
const text = m[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
1061
|
+
if (text) out.push({ text, offset: (m.index ?? 0) + m[0].indexOf(m[1]), end: (m.index ?? 0) + m[0].length });
|
|
1062
|
+
}
|
|
1063
|
+
return out.sort((a, b) => a.offset - b.offset);
|
|
1064
|
+
}
|
|
1065
|
+
function longFiles(ctx) {
|
|
1066
|
+
const docs = new Map(documents(ctx).map((d) => [d.path, d]));
|
|
1067
|
+
return ctx.files.filter((f4) => docs.has(f4.path)).map((file) => ({ file, doc: docs.get(file.path) }));
|
|
1068
|
+
}
|
|
1069
|
+
var HEADING_SHAPES = [
|
|
1070
|
+
["X, and Y", /^[^,]{2,}, and [^,]{2,}$/i],
|
|
1071
|
+
["X, because Y", /^[^,]{2,}, because [^,]{2,}$/i],
|
|
1072
|
+
["comma list", /^[A-Z][\w'’-]*(?:, [\w'’-]+){2,}(?:,? (?:and|or) [\w'’-]+)?\.?$/]
|
|
1073
|
+
];
|
|
1074
|
+
function headingShapeHits(ctx) {
|
|
1075
|
+
const hits = [];
|
|
1076
|
+
for (const { file, doc: doc2 } of longFiles(ctx)) {
|
|
1077
|
+
const found = [];
|
|
1078
|
+
for (const heading of headings(file)) {
|
|
1079
|
+
const shape = HEADING_SHAPES.find(([, re]) => re.test(heading.text));
|
|
1080
|
+
if (shape) found.push({ heading, shape: shape[0] });
|
|
1081
|
+
}
|
|
1082
|
+
const limit = allowed(doc2, 3, 0.5);
|
|
1083
|
+
if (found.length > limit) {
|
|
1084
|
+
hits.push({
|
|
1085
|
+
path: file.path,
|
|
1086
|
+
offset: found[limit].heading.offset,
|
|
1087
|
+
message: `${found.length} headings share one shape (${[...new Set(found.map((f4) => f4.shape))].join(", ")}), allows ${limit}`,
|
|
1088
|
+
excerpt: found[limit].heading.text
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return hits;
|
|
1093
|
+
}
|
|
1094
|
+
function headingEchoHits(ctx) {
|
|
1095
|
+
const hits = [];
|
|
1096
|
+
for (const { file } of longFiles(ctx)) {
|
|
1097
|
+
for (const heading of headings(file)) {
|
|
1098
|
+
const words = normal(heading.text);
|
|
1099
|
+
if (words.length < 4) continue;
|
|
1100
|
+
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
1101
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words.length);
|
|
1102
|
+
if (opening.join(" ") === words.join(" ")) {
|
|
1103
|
+
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
return hits;
|
|
1108
|
+
}
|
|
1109
|
+
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(" ");
|
|
1110
|
+
function filler(words, seed = 1, options = {}) {
|
|
1111
|
+
const { contractions = true, uniform } = options;
|
|
1112
|
+
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
1113
|
+
let state = seed;
|
|
1114
|
+
const next = (n) => {
|
|
1115
|
+
state = (state * 1103515245 + 12345) % 2147483648;
|
|
1116
|
+
return state % n;
|
|
1117
|
+
};
|
|
1118
|
+
const out = [];
|
|
1119
|
+
let count2 = 0;
|
|
1120
|
+
let i = 0;
|
|
1121
|
+
while (count2 < words) {
|
|
1122
|
+
const length = uniform ?? shape[i % shape.length];
|
|
1123
|
+
const s = [];
|
|
1124
|
+
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
1125
|
+
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
1126
|
+
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
1127
|
+
out.push(`${s.join(" ")}.`);
|
|
1128
|
+
count2 += s.length;
|
|
1129
|
+
i += 1;
|
|
1130
|
+
}
|
|
1131
|
+
const paragraphs = [];
|
|
1132
|
+
for (let p = 0; p < out.length; p += 4) paragraphs.push(out.slice(p, p + 4).join(" "));
|
|
1133
|
+
return paragraphs.join("\n\n");
|
|
1134
|
+
}
|
|
1135
|
+
var f2 = (path, text) => ({ path, text });
|
|
1136
|
+
var doc = (extra, seed = 1) => `${filler(520, seed)}
|
|
1137
|
+
|
|
1138
|
+
${extra}
|
|
1139
|
+
`;
|
|
1140
|
+
var times = (n, sentence) => Array.from({ length: n }, (_, i) => sentence(i)).join(" ");
|
|
1141
|
+
var DENSITY_TELLS = [
|
|
1142
|
+
{
|
|
1143
|
+
id: "phrase-density",
|
|
1144
|
+
name: "Leaned-on phrase",
|
|
1145
|
+
generation: 2,
|
|
1146
|
+
severity: "warn",
|
|
1147
|
+
surface: "copy",
|
|
1148
|
+
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.",
|
|
1149
|
+
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.",
|
|
1150
|
+
detect: rateHits,
|
|
1151
|
+
fixtures: {
|
|
1152
|
+
flag: [
|
|
1153
|
+
f2("content/proposal.md", doc(times(6, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
1154
|
+
f2("content/proposal.md", doc(times(6, (i) => `We sealed roof ${i} instead of patching it.`))),
|
|
1155
|
+
f2("content/proposal.md", doc(times(4, (i) => `Room ${i} actually needed a new valve.`))),
|
|
1156
|
+
f2("content/proposal.md", doc(times(5, (i) => `Gate ${i} was properly hung.`))),
|
|
1157
|
+
f2("content/proposal.md", doc(times(3, (i) => `Kitchen ${i} was genuinely tidy.`))),
|
|
1158
|
+
f2("content/proposal.md", doc(times(3, (i) => `Pipe ${i} is the single biggest risk.`))),
|
|
1159
|
+
f2("content/proposal.md", doc(times(6, (i) => `That is why tap ${i} leaks.`))),
|
|
1160
|
+
f2("content/proposal.md", doc(times(6, (i) => `Every tap ${i} gets checked.`))),
|
|
1161
|
+
f2("content/proposal.md", doc(times(5, (i) => `Worth noting: valve ${i} sticks.`))),
|
|
1162
|
+
f2("content/proposal.md", doc(times(7, (i) => `None of room ${i} needed paint.`))),
|
|
1163
|
+
f2("content/proposal.md", doc(times(5, (i) => `Wall ${i} was measured, sanded and sealed.`))),
|
|
1164
|
+
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>`)
|
|
1165
|
+
],
|
|
1166
|
+
pass: [
|
|
1167
|
+
f2("content/proposal.md", doc(times(3, (i) => `We fitted boiler ${i} rather than patching it.`))),
|
|
1168
|
+
f2("content/short.md", times(6, (i) => `We fitted boiler ${i} rather than patching it.`)),
|
|
1169
|
+
f2("app/page.tsx", `export const copy = [${Array.from({ length: 40 }, (_, i) => `"We fitted boiler ${i} rather than patching it."`).join(", ")}];`)
|
|
1170
|
+
]
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
id: "aphorism-density",
|
|
1175
|
+
name: "Aphorism cadence",
|
|
1176
|
+
generation: 2,
|
|
1177
|
+
severity: "warn",
|
|
1178
|
+
surface: "copy",
|
|
1179
|
+
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.",
|
|
1180
|
+
fix: "Keep the strongest one. Fold the rest into the paragraph above as plain statements.",
|
|
1181
|
+
detect: aphorismHits,
|
|
1182
|
+
fixtures: {
|
|
1183
|
+
flag: [f2("content/proposal.md", doc(Array.from({ length: 7 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))],
|
|
1184
|
+
pass: [f2("content/proposal.md", doc(Array.from({ length: 3 }, (_, i) => `The site exists. Job ${i} makes it findable.`).join("\n\n")))]
|
|
1185
|
+
}
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
id: "contraction-scarcity",
|
|
1189
|
+
name: "No contractions",
|
|
1190
|
+
generation: 1,
|
|
1191
|
+
severity: "warn",
|
|
1192
|
+
surface: "copy",
|
|
1193
|
+
why: "A long document with almost no contractions reads stiff and machine-made. People write 'we're' and 'don't'.",
|
|
1194
|
+
fix: "Contract where you would in speech: 'we are' to 'we're', 'do not' to 'don't'. Formal legal pages are the deliberate exception.",
|
|
1195
|
+
detect: contractionHits,
|
|
1196
|
+
fixtures: {
|
|
1197
|
+
flag: [f2("content/proposal.md", filler(900, 5, { contractions: false }))],
|
|
1198
|
+
pass: [f2("content/proposal.md", filler(900, 5)), f2("content/short.md", filler(500, 5, { contractions: false }))]
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
{
|
|
1202
|
+
id: "sentence-rhythm",
|
|
1203
|
+
name: "Metronomic rhythm",
|
|
1204
|
+
generation: 2,
|
|
1205
|
+
severity: "warn",
|
|
1206
|
+
surface: "copy",
|
|
1207
|
+
why: "Sentence after sentence of the same length reads generated. A person's sentences run long, then short, then long again.",
|
|
1208
|
+
fix: "Break one long sentence in two, join two short ones, and let some sentences be flat and plain.",
|
|
1209
|
+
detect: rhythmHits,
|
|
1210
|
+
fixtures: {
|
|
1211
|
+
flag: [f2("content/proposal.md", filler(600, 7, { uniform: 12 }))],
|
|
1212
|
+
pass: [f2("content/proposal.md", filler(600, 7))]
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
id: "repeated-sentence",
|
|
1217
|
+
name: "Repeated sentence",
|
|
1218
|
+
generation: 1,
|
|
1219
|
+
severity: "warn",
|
|
1220
|
+
surface: "copy",
|
|
1221
|
+
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.",
|
|
1222
|
+
fix: "Say it once, where it matters most, and write the second place fresh.",
|
|
1223
|
+
detect: repeatHits,
|
|
1224
|
+
fixtures: {
|
|
1225
|
+
flag: [
|
|
1226
|
+
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.")),
|
|
1227
|
+
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."))
|
|
1228
|
+
],
|
|
1229
|
+
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."))]
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
id: "heading-shape",
|
|
1234
|
+
name: "One heading shape",
|
|
1235
|
+
generation: 2,
|
|
1236
|
+
severity: "warn",
|
|
1237
|
+
surface: "copy",
|
|
1238
|
+
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.",
|
|
1239
|
+
fix: "Write each heading for its section. Most should be a plain noun phrase.",
|
|
1240
|
+
detect: headingShapeHits,
|
|
1241
|
+
fixtures: {
|
|
1242
|
+
flag: [
|
|
1243
|
+
f2("content/proposal.md", ["## Fast, and fairly priced", "## Local, and insured", "## Tidy, and quiet", "## Early, and on time", "", filler(520, 9)].join("\n\n")),
|
|
1244
|
+
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>`),
|
|
1245
|
+
f2("content/proposal.md", ["## Keep, rewrite, retire", "## Fix, paint, seal", "## Book, quote, fit", "## Plan, build, check", "", filler(520, 9)].join("\n\n"))
|
|
1246
|
+
],
|
|
1247
|
+
pass: [f2("content/proposal.md", ["## Pricing", "## Fast, and fairly priced", "## Who we are", "## Areas we cover", "", filler(520, 9)].join("\n\n"))]
|
|
1248
|
+
}
|
|
1249
|
+
},
|
|
1250
|
+
{
|
|
1251
|
+
id: "heading-echo",
|
|
1252
|
+
name: "Heading echoed",
|
|
1253
|
+
generation: 2,
|
|
1254
|
+
severity: "warn",
|
|
1255
|
+
surface: "copy",
|
|
1256
|
+
why: "A heading repeated word for word as the first line under it spends the reader's attention twice on the same words.",
|
|
1257
|
+
fix: "Let the first sentence say something the heading did not.",
|
|
1258
|
+
detect: headingEchoHits,
|
|
1259
|
+
fixtures: {
|
|
1260
|
+
flag: [
|
|
1261
|
+
f2("content/proposal.md", `## We fix boilers the same day
|
|
1262
|
+
|
|
1263
|
+
We fix boilers the same day across Brackley.
|
|
1264
|
+
|
|
1265
|
+
${filler(520, 11)}`),
|
|
1266
|
+
f2("site/index.html", `<h2>We fix boilers the same day</h2>
|
|
1267
|
+
<p>We fix boilers the same day across Brackley.</p><p>${filler(520, 11)}</p>`)
|
|
1268
|
+
],
|
|
1269
|
+
pass: [f2("content/proposal.md", `## We fix boilers the same day
|
|
1270
|
+
|
|
1271
|
+
Call before noon and an engineer is with you by six.
|
|
1272
|
+
|
|
1273
|
+
${filler(520, 11)}`)]
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
];
|
|
553
1277
|
|
|
554
1278
|
// src/color/oklch.ts
|
|
555
1279
|
var clamp01 = (n) => n < 0 ? 0 : n > 1 ? 1 : n;
|
|
@@ -613,8 +1337,8 @@ function parts(fn) {
|
|
|
613
1337
|
function hslToRgb(h, s, l) {
|
|
614
1338
|
const k = (n) => (n + h / 30) % 12;
|
|
615
1339
|
const a = s * Math.min(l, 1 - l);
|
|
616
|
-
const
|
|
617
|
-
return { r:
|
|
1340
|
+
const f4 = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
1341
|
+
return { r: f4(0), g: f4(8), b: f4(4) };
|
|
618
1342
|
}
|
|
619
1343
|
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
620
1344
|
function parseColour(raw) {
|
|
@@ -726,7 +1450,7 @@ function firstMatch(text, pattern) {
|
|
|
726
1450
|
return m ? m.index : -1;
|
|
727
1451
|
}
|
|
728
1452
|
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-/;
|
|
729
|
-
var
|
|
1453
|
+
var f3 = (path, text) => ({ path, text });
|
|
730
1454
|
function familyNames(value) {
|
|
731
1455
|
return value.split(",").map((v) => v.trim().replace(/^["']|["']$/g, "").trim()).filter(Boolean);
|
|
732
1456
|
}
|
|
@@ -1191,14 +1915,14 @@ var SOURCE_TELLS = [
|
|
|
1191
1915
|
detect: fontTell(REFLEX_FONTS_1),
|
|
1192
1916
|
fixtures: {
|
|
1193
1917
|
flag: [
|
|
1194
|
-
|
|
1918
|
+
f3("app/layout.tsx", `import { Inter } from "next/font/google";
|
|
1195
1919
|
const inter = Inter({ subsets: ["latin"] });`),
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1920
|
+
f3("index.html", `<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600" rel="stylesheet">`),
|
|
1921
|
+
f3("tailwind.config.js", `fontFamily: { sans: ["Roboto", "sans-serif"] }`),
|
|
1922
|
+
f3("main.ts", `import "@fontsource/montserrat";`),
|
|
1923
|
+
f3("components/A.tsx", `<p className="font-['Open_Sans']">x</p>`)
|
|
1200
1924
|
],
|
|
1201
|
-
pass: [
|
|
1925
|
+
pass: [f3("app/layout.tsx", `import { Bricolage_Grotesque } from "next/font/google";`)]
|
|
1202
1926
|
}
|
|
1203
1927
|
},
|
|
1204
1928
|
{
|
|
@@ -1208,13 +1932,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1208
1932
|
severity: "warn",
|
|
1209
1933
|
surface: "source",
|
|
1210
1934
|
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.",
|
|
1211
|
-
fix: "
|
|
1935
|
+
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.",
|
|
1212
1936
|
detect: fontTell(REFLEX_FONTS_2),
|
|
1213
1937
|
fixtures: {
|
|
1214
|
-
flag: [
|
|
1938
|
+
flag: [f3("src/styles.css", `@theme {
|
|
1215
1939
|
--font-sans: "DM Sans", system-ui, sans-serif;
|
|
1216
1940
|
}`)],
|
|
1217
|
-
pass: [
|
|
1941
|
+
pass: [f3("src/styles.css", `@theme {
|
|
1218
1942
|
--font-sans: "Hanken Grotesk", system-ui, sans-serif;
|
|
1219
1943
|
}`)]
|
|
1220
1944
|
}
|
|
@@ -1230,10 +1954,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1230
1954
|
detect: violetHits,
|
|
1231
1955
|
fixtures: {
|
|
1232
1956
|
flag: [
|
|
1233
|
-
|
|
1234
|
-
|
|
1957
|
+
f3("components/Hero.tsx", `<a className="rounded-md bg-indigo-600 px-4 py-2 text-white">Book</a>`),
|
|
1958
|
+
f3("app/globals.css", `:root { --accent: #5a35d1; }`)
|
|
1235
1959
|
],
|
|
1236
|
-
pass: [
|
|
1960
|
+
pass: [f3("components/Hero.tsx", `<a className="rounded-md bg-emerald-700 px-4 py-2 text-white">Book</a>`)]
|
|
1237
1961
|
}
|
|
1238
1962
|
},
|
|
1239
1963
|
{
|
|
@@ -1247,10 +1971,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1247
1971
|
detect: gradientHits,
|
|
1248
1972
|
fixtures: {
|
|
1249
1973
|
flag: [
|
|
1250
|
-
|
|
1251
|
-
|
|
1974
|
+
f3("components/Hero.tsx", `<section className="bg-gradient-to-r from-blue-600 to-purple-600">`),
|
|
1975
|
+
f3("app/globals.css", `.hero { background: linear-gradient(135deg, #2563eb, #9333ea); }`)
|
|
1252
1976
|
],
|
|
1253
|
-
pass: [
|
|
1977
|
+
pass: [f3("components/Hero.tsx", `<section className="bg-gradient-to-b from-stone-100 to-stone-200">`)]
|
|
1254
1978
|
}
|
|
1255
1979
|
},
|
|
1256
1980
|
{
|
|
@@ -1264,10 +1988,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1264
1988
|
detect: gradientTextHits,
|
|
1265
1989
|
fixtures: {
|
|
1266
1990
|
flag: [
|
|
1267
|
-
|
|
1268
|
-
|
|
1991
|
+
f3("components/Hero.tsx", `<h1 className="bg-gradient-to-r from-rose-500 to-amber-500 bg-clip-text text-transparent">`),
|
|
1992
|
+
f3("app/globals.css", `.headline { -webkit-background-clip: text; color: transparent; }`)
|
|
1269
1993
|
],
|
|
1270
|
-
pass: [
|
|
1994
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-5xl text-ink">`)]
|
|
1271
1995
|
}
|
|
1272
1996
|
},
|
|
1273
1997
|
{
|
|
@@ -1281,10 +2005,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1281
2005
|
detect: glassHits,
|
|
1282
2006
|
fixtures: {
|
|
1283
2007
|
flag: [
|
|
1284
|
-
|
|
1285
|
-
|
|
2008
|
+
f3("components/Hero.tsx", `<div className="rounded-2xl bg-white/10 p-8 backdrop-blur-md">`),
|
|
2009
|
+
f3("app/globals.css", `.card { background: rgb(255 255 255 / .1); backdrop-filter: blur(12px); }`)
|
|
1286
2010
|
],
|
|
1287
|
-
pass: [
|
|
2011
|
+
pass: [f3("components/Hero.tsx", `<div className="rounded-sm bg-paper p-8">`)]
|
|
1288
2012
|
}
|
|
1289
2013
|
},
|
|
1290
2014
|
{
|
|
@@ -1297,10 +2021,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1297
2021
|
fix: "Follow the hero with the thing only this business has: the work, the place, the person. Put proof where a doubt arises.",
|
|
1298
2022
|
detect: heroThenProofHits,
|
|
1299
2023
|
fixtures: {
|
|
1300
|
-
flag: [
|
|
2024
|
+
flag: [f3("app/page.tsx", `export default function Page() {
|
|
1301
2025
|
return (<main><Hero /><TrustStrip /><Services /></main>);
|
|
1302
2026
|
}`)],
|
|
1303
|
-
pass: [
|
|
2027
|
+
pass: [f3("app/page.tsx", `export default function Page() {
|
|
1304
2028
|
return (<main><Hero /><Workshop /><TrustStrip /></main>);
|
|
1305
2029
|
}`)]
|
|
1306
2030
|
}
|
|
@@ -1316,7 +2040,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1316
2040
|
detect: iconGridHits,
|
|
1317
2041
|
fixtures: {
|
|
1318
2042
|
flag: [
|
|
1319
|
-
|
|
2043
|
+
f3(
|
|
1320
2044
|
"components/Features.tsx",
|
|
1321
2045
|
`import { Heart } from "lucide-react";
|
|
1322
2046
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
@@ -1324,7 +2048,7 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1324
2048
|
</div>`
|
|
1325
2049
|
)
|
|
1326
2050
|
],
|
|
1327
|
-
pass: [
|
|
2051
|
+
pass: [f3("components/Features.tsx", `<ul className="divide-y">
|
|
1328
2052
|
<li className="flex justify-between py-3">Gel manicure <span>\xA332</span></li>
|
|
1329
2053
|
</ul>`)]
|
|
1330
2054
|
}
|
|
@@ -1339,8 +2063,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1339
2063
|
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.`,
|
|
1340
2064
|
detect: revealHits,
|
|
1341
2065
|
fixtures: {
|
|
1342
|
-
flag: [
|
|
1343
|
-
pass: [
|
|
2066
|
+
flag: [f3("app/page.tsx", Array.from({ length: REVEAL_LIMIT + 1 }, (_, i) => `<Reveal><Block n={${i}} /></Reveal>`).join("\n"))],
|
|
2067
|
+
pass: [f3("app/page.tsx", `<Reveal><Signature /></Reveal>
|
|
1344
2068
|
<Block />
|
|
1345
2069
|
<Block />`)]
|
|
1346
2070
|
}
|
|
@@ -1355,8 +2079,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1355
2079
|
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.`,
|
|
1356
2080
|
detect: pillHits,
|
|
1357
2081
|
fixtures: {
|
|
1358
|
-
flag: [
|
|
1359
|
-
pass: [
|
|
2082
|
+
flag: [f3("components/Tags.tsx", Array.from({ length: PILL_LIMIT + 1 }, () => `<span className="rounded-full px-3">tag</span>`).join("\n"))],
|
|
2083
|
+
pass: [f3("components/Tags.tsx", `<span className="rounded-full px-3">tag</span>
|
|
1360
2084
|
<a className="rounded-sm px-4">Book</a>`)]
|
|
1361
2085
|
}
|
|
1362
2086
|
},
|
|
@@ -1370,8 +2094,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1370
2094
|
fix: "Delete the primitives nothing imports. Restyle the ones that stay so they belong to this site.",
|
|
1371
2095
|
detect: shadcnHits,
|
|
1372
2096
|
fixtures: {
|
|
1373
|
-
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) =>
|
|
1374
|
-
pass: [
|
|
2097
|
+
flag: [SHADCN_PRIMITIVES.slice(0, SHADCN_LIMIT).map((name) => f3(`src/components/ui/${name}.tsx`, `export function X() { return null; }`))],
|
|
2098
|
+
pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
1375
2099
|
}
|
|
1376
2100
|
},
|
|
1377
2101
|
{
|
|
@@ -1385,11 +2109,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1385
2109
|
detect: creamHits,
|
|
1386
2110
|
fixtures: {
|
|
1387
2111
|
flag: [
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
2112
|
+
f3("app/globals.css", `body { background-color: #f5efe6; }`),
|
|
2113
|
+
f3("app/page.tsx", `<main className="bg-cream text-ink">`),
|
|
2114
|
+
f3("app/page.tsx", `<main className="bg-[#f7f1e8]">`)
|
|
1391
2115
|
],
|
|
1392
|
-
pass: [
|
|
2116
|
+
pass: [f3("app/globals.css", `body { background-color: #ffffff; }`)]
|
|
1393
2117
|
}
|
|
1394
2118
|
},
|
|
1395
2119
|
{
|
|
@@ -1403,11 +2127,11 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1403
2127
|
detect: italicSerifHits,
|
|
1404
2128
|
fixtures: {
|
|
1405
2129
|
flag: [
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
2130
|
+
f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails, <em>reimagined</em></h1>`),
|
|
2131
|
+
f3("components/Hero.tsx", `<span className="font-serif italic">reimagined</span>`),
|
|
2132
|
+
f3("app/globals.css", `.hero h1 { font-style: italic; }`)
|
|
1409
2133
|
],
|
|
1410
|
-
pass: [
|
|
2134
|
+
pass: [f3("components/Hero.tsx", `<h1 className="font-display text-6xl">Nails in Brackley</h1>
|
|
1411
2135
|
<p><em>Walk-ins</em> welcome.</p>`)]
|
|
1412
2136
|
}
|
|
1413
2137
|
},
|
|
@@ -1422,12 +2146,12 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1422
2146
|
detect: eyebrowChipHits,
|
|
1423
2147
|
fixtures: {
|
|
1424
2148
|
flag: [
|
|
1425
|
-
|
|
2149
|
+
f3("components/Hero.tsx", `<span className="inline-flex rounded-full border px-3 py-1 text-xs">Now booking</span>
|
|
1426
2150
|
<h1>Nails in Brackley</h1>`),
|
|
1427
|
-
|
|
2151
|
+
f3("components/Hero.tsx", `<span className={cn("rounded-full px-3 text-xs", "bg-rose-500/10")}>New</span>
|
|
1428
2152
|
<h1>Nails</h1>`)
|
|
1429
2153
|
],
|
|
1430
|
-
pass: [
|
|
2154
|
+
pass: [f3("components/Hero.tsx", `<h1>Nails in Brackley</h1>
|
|
1431
2155
|
<span className="inline-flex rounded-full border px-3 py-1 text-xs">Tag</span>`)]
|
|
1432
2156
|
}
|
|
1433
2157
|
},
|
|
@@ -1441,9 +2165,9 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1441
2165
|
fix: "Use the client's own marks, photography or numerals. If an icon earns its place, set it inline with the text.",
|
|
1442
2166
|
detect: iconTileStackHits,
|
|
1443
2167
|
fixtures: {
|
|
1444
|
-
flag: [
|
|
2168
|
+
flag: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
1445
2169
|
<div className="flex size-10 items-center justify-center rounded-lg bg-violet-100"><Sparkles /></div>`)],
|
|
1446
|
-
pass: [
|
|
2170
|
+
pass: [f3("components/Card.tsx", `import { Sparkles } from "lucide-react";
|
|
1447
2171
|
<p className="flex items-center gap-2"><Sparkles className="size-4" />Aftercare</p>`)]
|
|
1448
2172
|
}
|
|
1449
2173
|
},
|
|
@@ -1457,8 +2181,8 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1457
2181
|
fix: "Lay content out by what it is. If one item matters most, give it the page, not a double-width tile.",
|
|
1458
2182
|
detect: bentoHits,
|
|
1459
2183
|
fixtures: {
|
|
1460
|
-
flag: [
|
|
1461
|
-
pass: [
|
|
2184
|
+
flag: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="md:col-span-2 md:row-span-2" /><div /></div>`)],
|
|
2185
|
+
pass: [f3("components/Features.tsx", `<div className="grid grid-cols-3"><div className="col-span-2" /><div /></div>`)]
|
|
1462
2186
|
}
|
|
1463
2187
|
},
|
|
1464
2188
|
{
|
|
@@ -1472,10 +2196,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1472
2196
|
detect: glowHits,
|
|
1473
2197
|
fixtures: {
|
|
1474
2198
|
flag: [
|
|
1475
|
-
|
|
1476
|
-
|
|
2199
|
+
f3("components/Hero.tsx", `<div className="absolute -top-20 left-1/2 h-96 w-96 rounded-full bg-rose-400/30 blur-3xl" />`),
|
|
2200
|
+
f3("app/globals.css", `.hero { background: radial-gradient(circle at top, rgb(244 63 94 / .25), transparent 60%); }`)
|
|
1477
2201
|
],
|
|
1478
|
-
pass: [
|
|
2202
|
+
pass: [f3("components/Hero.tsx", `<div className="absolute inset-0 bg-black/40" />`)]
|
|
1479
2203
|
}
|
|
1480
2204
|
},
|
|
1481
2205
|
{
|
|
@@ -1489,13 +2213,13 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1489
2213
|
detect: gridBackgroundHits,
|
|
1490
2214
|
fixtures: {
|
|
1491
2215
|
flag: [
|
|
1492
|
-
|
|
2216
|
+
f3(
|
|
1493
2217
|
"app/globals.css",
|
|
1494
2218
|
`.hero { background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px), linear-gradient(to bottom, #e5e5e5 1px, transparent 1px); background-size: 24px 24px; }`
|
|
1495
2219
|
),
|
|
1496
|
-
|
|
2220
|
+
f3("components/Hero.tsx", `<div className="absolute inset-0 bg-[linear-gradient(to_right,#8882_1px,transparent_1px)] bg-[size:24px_24px]" />`)
|
|
1497
2221
|
],
|
|
1498
|
-
pass: [
|
|
2222
|
+
pass: [f3("app/globals.css", `.hero { background-image: url(/shopfront.avif); }`)]
|
|
1499
2223
|
}
|
|
1500
2224
|
},
|
|
1501
2225
|
{
|
|
@@ -1509,10 +2233,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1509
2233
|
detect: marqueeHits,
|
|
1510
2234
|
fixtures: {
|
|
1511
2235
|
flag: [
|
|
1512
|
-
|
|
1513
|
-
|
|
2236
|
+
f3("app/globals.css", `@keyframes marquee { from { transform: translateX(0) } to { transform: translateX(-50%) } }`),
|
|
2237
|
+
f3("components/Logos.tsx", `import Marquee from "react-fast-marquee";`)
|
|
1514
2238
|
],
|
|
1515
|
-
pass: [
|
|
2239
|
+
pass: [f3("app/globals.css", `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }`)]
|
|
1516
2240
|
}
|
|
1517
2241
|
},
|
|
1518
2242
|
{
|
|
@@ -1526,10 +2250,10 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1526
2250
|
detect: thinBorderWideShadowHits,
|
|
1527
2251
|
fixtures: {
|
|
1528
2252
|
flag: [
|
|
1529
|
-
|
|
1530
|
-
|
|
2253
|
+
f3("components/Card.tsx", `<div className="rounded-2xl border border-black/5 bg-white shadow-2xl">`),
|
|
2254
|
+
f3("app/globals.css", `.card { border: 1px solid rgb(0 0 0 / .06); box-shadow: 0 20px 48px rgb(0 0 0 / .08); }`)
|
|
1531
2255
|
],
|
|
1532
|
-
pass: [
|
|
2256
|
+
pass: [f3("components/Card.tsx", `<div className="rounded-sm border border-ink bg-white">`)]
|
|
1533
2257
|
}
|
|
1534
2258
|
},
|
|
1535
2259
|
{
|
|
@@ -1543,15 +2267,61 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
1543
2267
|
detect: introHits,
|
|
1544
2268
|
fixtures: {
|
|
1545
2269
|
flag: [
|
|
1546
|
-
|
|
2270
|
+
f3("components/IntroCinematic.tsx", `const seen = sessionStorage.getItem("intro");
|
|
1547
2271
|
export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
1548
|
-
|
|
2272
|
+
f3("app/page.tsx", `<><Preloader /><Hero /></>`)
|
|
1549
2273
|
],
|
|
1550
|
-
pass: [
|
|
2274
|
+
pass: [f3("components/Introduction.md", `An introduction to the studio.`)]
|
|
1551
2275
|
}
|
|
1552
2276
|
}
|
|
1553
2277
|
];
|
|
1554
2278
|
|
|
2279
|
+
// src/character/house.ts
|
|
2280
|
+
var HOUSE = {
|
|
2281
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
2282
|
+
emoji: { tier: "block", label: "emoji" },
|
|
2283
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
2284
|
+
"no-x-no-y": {
|
|
2285
|
+
tier: "block",
|
|
2286
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
2287
|
+
},
|
|
2288
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
2289
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
2290
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
2291
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
2292
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
2293
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
2294
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
2295
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
2296
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
2297
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
2298
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
2299
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
2300
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
2301
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
2302
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
2303
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
2304
|
+
"false-range": { tier: "review", label: "false range" },
|
|
2305
|
+
// The first candidate for block: evidence of a pasted chat reply, not a
|
|
2306
|
+
// matter of style. Blocks once its estate hits have been read (ROADMAP.md).
|
|
2307
|
+
"chatbot-residue": { tier: "review", label: "chatbot residue" },
|
|
2308
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
2309
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
2310
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
2311
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
2312
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
2313
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
2314
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
2315
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
2316
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
2317
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
2318
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
2319
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
2320
|
+
};
|
|
2321
|
+
function houseRule(id, name = id) {
|
|
2322
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
2323
|
+
}
|
|
2324
|
+
|
|
1555
2325
|
// src/character/parse.ts
|
|
1556
2326
|
function lineAt(text, offset) {
|
|
1557
2327
|
let line = 1;
|
|
@@ -1640,15 +2410,16 @@ function makeSnapshot(patch = {}) {
|
|
|
1640
2410
|
}
|
|
1641
2411
|
|
|
1642
2412
|
// src/snapshot/fonts.ts
|
|
2413
|
+
var SYSTEM = /^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|sans-serif|serif|monospace|cursive)$/i;
|
|
1643
2414
|
function normaliseFamily(raw) {
|
|
1644
2415
|
let name = raw.replace(/["']/g, "").trim();
|
|
1645
2416
|
const next = name.match(/^__(.+?)(?:_Fallback)?_[0-9a-f]{5,8}$/i);
|
|
1646
2417
|
if (next) name = next[1].replace(/_/g, " ");
|
|
1647
2418
|
name = name.replace(/\s+Fallback$/i, "").replace(/\s+Variable$/i, "");
|
|
2419
|
+
if (SYSTEM.test(name)) return "system-ui";
|
|
1648
2420
|
if (!/\s/.test(name) && (/[a-z][A-Z]/.test(name) || /^[a-z]+(?:-[a-z]+)+$/.test(name))) {
|
|
1649
2421
|
name = name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/-/g, " ").replace(/\b([a-z])/g, (c) => c.toUpperCase()).replace(/\bDm\b/, "DM");
|
|
1650
2422
|
}
|
|
1651
|
-
if (/^(?:system-ui|-apple-system|BlinkMacSystemFont|ui-sans-serif|ui-serif|ui-monospace|sans-serif|serif|monospace)$/i.test(name)) return "system-ui";
|
|
1652
2423
|
return name;
|
|
1653
2424
|
}
|
|
1654
2425
|
var KNOWN = {
|
|
@@ -1699,8 +2470,8 @@ var oklchOf = (value) => parseColour(value)?.oklch ?? null;
|
|
|
1699
2470
|
function reflexFonts(list) {
|
|
1700
2471
|
const wanted = new Map(list.map((n) => [n.toLowerCase(), n]));
|
|
1701
2472
|
return (s) => {
|
|
1702
|
-
const total = s.fonts.reduce((n,
|
|
1703
|
-
const display = s.fonts.reduce((n,
|
|
2473
|
+
const total = s.fonts.reduce((n, f4) => n + f4.chars, 0) || 1;
|
|
2474
|
+
const display = s.fonts.reduce((n, f4) => n + f4.displayChars, 0) || 1;
|
|
1704
2475
|
const hits = [];
|
|
1705
2476
|
const seen = /* @__PURE__ */ new Set();
|
|
1706
2477
|
const h1 = s.headings.find((h) => h.level === 1);
|
|
@@ -1901,8 +2672,8 @@ var RENDERED_PATHS = {
|
|
|
1901
2672
|
};
|
|
1902
2673
|
|
|
1903
2674
|
// src/character/check.ts
|
|
1904
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
1905
|
-
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS].map(
|
|
2675
|
+
var CATALOGUE_VERSION = "2026.09.6";
|
|
2676
|
+
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
|
|
1906
2677
|
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
1907
2678
|
);
|
|
1908
2679
|
function tellById(id) {
|
|
@@ -1937,7 +2708,8 @@ function toFinding(tell, hit2, texts) {
|
|
|
1937
2708
|
line: texts.has(hit2.path) ? lineAt(text, hit2.offset) : 0,
|
|
1938
2709
|
excerpt: hit2.excerpt ?? excerptAt(text, hit2.offset),
|
|
1939
2710
|
message: hit2.message,
|
|
1940
|
-
fix: tell.fix
|
|
2711
|
+
fix: tell.fix,
|
|
2712
|
+
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
1941
2713
|
};
|
|
1942
2714
|
}
|
|
1943
2715
|
function report(tells, files, run, options) {
|
|
@@ -2000,6 +2772,7 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
2000
2772
|
// src/fingerprint/index.ts
|
|
2001
2773
|
var FINGERPRINT_VERSION = 1;
|
|
2002
2774
|
var CHROMATIC = 0.03;
|
|
2775
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
2003
2776
|
var oklch = (value) => {
|
|
2004
2777
|
const parsed = parseColour(value);
|
|
2005
2778
|
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
@@ -2012,7 +2785,7 @@ function accentOf(s) {
|
|
|
2012
2785
|
}
|
|
2013
2786
|
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2014
2787
|
if (topButton) return oklch(topButton[0]);
|
|
2015
|
-
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
2788
|
+
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
|
|
2016
2789
|
if (bg) return bg.o;
|
|
2017
2790
|
const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
2018
2791
|
return text ? text.o : null;
|
|
@@ -2115,14 +2888,14 @@ function collectInPage() {
|
|
|
2115
2888
|
t.chars += chars;
|
|
2116
2889
|
text.set(cs.color, t);
|
|
2117
2890
|
const family = firstFamily(cs.fontFamily);
|
|
2118
|
-
const
|
|
2891
|
+
const f4 = fonts.get(family) ?? { chars: 0, displayChars: 0, italicChars: 0, weights: /* @__PURE__ */ new Set() };
|
|
2119
2892
|
const size = px(cs.fontSize);
|
|
2120
2893
|
const weight = parseInt(cs.fontWeight, 10) || 400;
|
|
2121
|
-
|
|
2122
|
-
if (/^H[1-3]$/.test(el.tagName) || size >= 28)
|
|
2123
|
-
if (cs.fontStyle === "italic" || cs.fontStyle.startsWith("oblique"))
|
|
2124
|
-
|
|
2125
|
-
fonts.set(family,
|
|
2894
|
+
f4.chars += chars;
|
|
2895
|
+
if (/^H[1-3]$/.test(el.tagName) || size >= 28) f4.displayChars += chars;
|
|
2896
|
+
if (cs.fontStyle === "italic" || cs.fontStyle.startsWith("oblique")) f4.italicChars += chars;
|
|
2897
|
+
f4.weights.add(weight);
|
|
2898
|
+
fonts.set(family, f4);
|
|
2126
2899
|
sizes.add(Math.round(size));
|
|
2127
2900
|
weights.add(weight);
|
|
2128
2901
|
}
|
|
@@ -2138,7 +2911,7 @@ function collectInPage() {
|
|
|
2138
2911
|
const htmlBg = getComputedStyle(document.documentElement).backgroundColor;
|
|
2139
2912
|
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
|
2140
2913
|
const ground = painted(bodyBg) ? bodyBg : painted(htmlBg) ? htmlBg : "rgb(255, 255, 255)";
|
|
2141
|
-
const
|
|
2914
|
+
const headings2 = Array.from(document.querySelectorAll("h1, h2, h3")).filter(shown).slice(0, 60).map((h) => {
|
|
2142
2915
|
const cs = style(h);
|
|
2143
2916
|
const italic = cs.fontStyle !== "normal";
|
|
2144
2917
|
const italicPart = !italic && Array.from(h.querySelectorAll("*")).some((c) => style(c).fontStyle !== "normal" && (c.textContent ?? "").trim().length > 0);
|
|
@@ -2207,13 +2980,18 @@ function collectInPage() {
|
|
|
2207
2980
|
pushBlocks(root, 0);
|
|
2208
2981
|
const header = document.querySelector("body > header, header");
|
|
2209
2982
|
const h1 = document.querySelector("h1");
|
|
2983
|
+
const waiting = (c) => {
|
|
2984
|
+
const cs = style(c);
|
|
2985
|
+
const op = parseFloat(cs.opacity);
|
|
2986
|
+
return op < 0.1 || op < 1 && cs.transform !== "none" || c.hasAttribute("data-aos") || /\breveal\b|\bfade-?(?:in|up)\b|\banimate-on-scroll\b/i.test(typeof c.className === "string" ? c.className : "");
|
|
2987
|
+
};
|
|
2210
2988
|
const hiddenAtLoad = (el) => {
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
const
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2989
|
+
if ([el, ...Array.from(el.children).slice(0, 6)].some(waiting)) return true;
|
|
2990
|
+
const blocks2 = Array.from(el.querySelectorAll("div, article, li, figure, p, h2, h3")).slice(0, 200).filter((d) => {
|
|
2991
|
+
const b = box(d);
|
|
2992
|
+
return b.width > 100 && b.height > 40 && (d.textContent ?? "").trim().length > 12 && style(d).position !== "fixed";
|
|
2993
|
+
}).slice(0, 12);
|
|
2994
|
+
return blocks2.filter(waiting).length >= Math.min(2, blocks2.length) && blocks2.some(waiting);
|
|
2217
2995
|
};
|
|
2218
2996
|
const cardsIn = (section) => {
|
|
2219
2997
|
let best = { cards: 0, iconCards: 0 };
|
|
@@ -2349,8 +3127,8 @@ function collectInPage() {
|
|
|
2349
3127
|
pageHeight: document.documentElement.scrollHeight,
|
|
2350
3128
|
ground,
|
|
2351
3129
|
colours: { backgrounds: toList(bg).slice(0, 60), text: toList(text).sort((a, b) => b.chars - a.chars).slice(0, 40) },
|
|
2352
|
-
fonts: Array.from(fonts, ([family,
|
|
2353
|
-
headings,
|
|
3130
|
+
fonts: Array.from(fonts, ([family, f4]) => ({ family, chars: f4.chars, displayChars: f4.displayChars, italicChars: f4.italicChars, weights: nums(f4.weights) })).sort((a, b) => b.chars - a.chars),
|
|
3131
|
+
headings: headings2,
|
|
2354
3132
|
controls,
|
|
2355
3133
|
sections,
|
|
2356
3134
|
effects,
|
|
@@ -2412,19 +3190,38 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
2412
3190
|
motion: { ...data.motion, introOverlay: early && !late }
|
|
2413
3191
|
};
|
|
2414
3192
|
}
|
|
2415
|
-
async function
|
|
2416
|
-
const pw = await loadPlaywright();
|
|
3193
|
+
async function launch(pw, url, options) {
|
|
2417
3194
|
const env = globalThis.process?.env ?? {};
|
|
2418
3195
|
const executablePath = options.executablePath ?? env.CRAFT_CHROMIUM ?? void 0;
|
|
2419
3196
|
const host = new URL(url).hostname;
|
|
2420
3197
|
const noProxy = [`localhost`, `127.0.0.1`, `::1`, ...(env.NO_PROXY ?? env.no_proxy ?? "").split(",")].map((h) => h.trim().replace(/^\*?\./, "")).filter(Boolean);
|
|
2421
3198
|
const direct = /^(?:localhost|127\.|\[?::1\]?$)/.test(host) || noProxy.some((h) => host === h || host.endsWith(`.${h}`));
|
|
2422
3199
|
const proxy = direct ? void 0 : env.HTTPS_PROXY ?? env.https_proxy;
|
|
2423
|
-
|
|
3200
|
+
return pw.chromium.launch({ headless: true, ...executablePath ? { executablePath } : {}, ...proxy ? { proxy: { server: proxy } } : {} });
|
|
3201
|
+
}
|
|
3202
|
+
async function snapshotUrl(url, options = {}) {
|
|
3203
|
+
const [result] = await snapshotUrls([url], options);
|
|
3204
|
+
if (!result.snapshot) throw new Error(result.error);
|
|
3205
|
+
return result.snapshot;
|
|
3206
|
+
}
|
|
3207
|
+
async function snapshotUrls(urls, options = {}) {
|
|
3208
|
+
if (urls.length === 0) return [];
|
|
3209
|
+
const pw = await loadPlaywright();
|
|
3210
|
+
const browser = await launch(pw, urls[0], options);
|
|
3211
|
+
const out = [];
|
|
2424
3212
|
try {
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
3213
|
+
for (const url of urls) {
|
|
3214
|
+
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
|
|
3215
|
+
try {
|
|
3216
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
|
|
3217
|
+
out.push({ url, snapshot: await snapshotPage(page, url, options) });
|
|
3218
|
+
} catch (error) {
|
|
3219
|
+
out.push({ url, error: error.message });
|
|
3220
|
+
} finally {
|
|
3221
|
+
await page.close?.();
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
return out;
|
|
2428
3225
|
} finally {
|
|
2429
3226
|
await browser.close();
|
|
2430
3227
|
}
|
|
@@ -2440,5 +3237,6 @@ async function audit(url, options = {}) {
|
|
|
2440
3237
|
loadPlaywright,
|
|
2441
3238
|
overlayInPage,
|
|
2442
3239
|
snapshotPage,
|
|
2443
|
-
snapshotUrl
|
|
3240
|
+
snapshotUrl,
|
|
3241
|
+
snapshotUrls
|
|
2444
3242
|
});
|