@domandigital/craft 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/CHARACTER.md +272 -39
- package/COPY.md +5 -0
- package/README.md +19 -1
- package/art-direction.schema.json +428 -25
- package/dist/{audit-5TYDYRXV.js → audit-MTUC24XU.js} +3 -2
- package/dist/audit.cjs +674 -92
- package/dist/audit.d.cts +15 -1
- package/dist/audit.d.ts +15 -1
- package/dist/audit.js +674 -92
- package/dist/{chunk-IMB4V34S.js → chunk-2IQ5OXFR.js} +7 -7
- package/dist/{chunk-GMVXADJ6.js → chunk-LFNSB6W7.js} +711 -92
- package/dist/{chunk-3VTPI7BU.js → chunk-MUVFECFC.js} +74 -96
- package/dist/chunk-OQXTQQAU.js +27 -0
- package/dist/{chunk-ZLDA2SYB.js → chunk-OUQ25LY2.js} +47 -6
- package/dist/{chunk-K2BZN4GA.js → chunk-QU6Q2QZZ.js} +49 -14
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-ESCVTKPN.js → chunk-RXXRXV3V.js} +9 -26
- package/dist/{chunk-CZDOMIVS.js → chunk-WUACK2HX.js} +2 -1
- package/dist/chunk-YDURAOYR.js +711 -0
- package/dist/cli-22TSWAX6.js +21 -0
- package/dist/cli-5ACKICBO.js +22 -0
- package/dist/{cli-DRKAMFAH.js → cli-7OZCYOQY.js} +89 -22
- package/dist/{cli-J5ECRCD4.js → cli-FCKHYAVZ.js} +40 -16
- package/dist/{cli-ILH7OO5G.js → cli-FLG3ZCAF.js} +6 -4
- package/dist/cli.js +3 -2
- package/dist/generate.js +6 -0
- package/dist/index.cjs +1618 -328
- package/dist/index.d.cts +619 -58
- package/dist/index.d.ts +619 -58
- package/dist/index.js +1555 -328
- package/dist/migrate-VIFAYIBM.js +9 -0
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-ZZ5CKOJP.js +0 -295
- package/dist/cli-BKJMLD3H.js +0 -18
- package/dist/cli-N2IDXH7W.js +0 -20
package/dist/index.js
CHANGED
|
@@ -490,9 +490,9 @@ function fluidClamp(minPx, maxPx, minViewport, maxViewport, rootPx = 16) {
|
|
|
490
490
|
const interceptRem = round((minPx - slope * minViewport) / rootPx);
|
|
491
491
|
const slopeVw = round(slope * 100);
|
|
492
492
|
const preferred = interceptRem === 0 ? `${slopeVw}vw` : `${interceptRem}rem + ${slopeVw}vw`;
|
|
493
|
-
const
|
|
493
|
+
const lower2 = Math.min(minRem, maxRem);
|
|
494
494
|
const upper = Math.max(minRem, maxRem);
|
|
495
|
-
return `clamp(${
|
|
495
|
+
return `clamp(${lower2}rem, ${preferred}, ${upper}rem)`;
|
|
496
496
|
}
|
|
497
497
|
function fluidType(options = {}) {
|
|
498
498
|
const {
|
|
@@ -1286,7 +1286,7 @@ function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => fal
|
|
|
1286
1286
|
};
|
|
1287
1287
|
}
|
|
1288
1288
|
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
|
|
1289
|
-
var wordList = (
|
|
1289
|
+
var wordList = (words3) => new RegExp(`\\b(?:${[...words3].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
|
|
1290
1290
|
var quoted = (m) => `"${m}"`;
|
|
1291
1291
|
var AI_WORDS = [
|
|
1292
1292
|
"tapestry",
|
|
@@ -2124,8 +2124,8 @@ function documents(ctx) {
|
|
|
2124
2124
|
const docs = [];
|
|
2125
2125
|
for (const [path, blocks] of byPath) {
|
|
2126
2126
|
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2127
|
-
const
|
|
2128
|
-
if (
|
|
2127
|
+
const words3 = countWords(text);
|
|
2128
|
+
if (words3 >= MIN_WORDS) docs.push({ path, blocks, words: words3 });
|
|
2129
2129
|
}
|
|
2130
2130
|
return docs;
|
|
2131
2131
|
}
|
|
@@ -2250,14 +2250,14 @@ function contractionHits(ctx) {
|
|
|
2250
2250
|
for (const doc2 of documents(ctx)) {
|
|
2251
2251
|
if (doc2.words < 800) continue;
|
|
2252
2252
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2253
|
-
const
|
|
2254
|
-
const rate =
|
|
2253
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
2254
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
2255
2255
|
if (rate < 1) {
|
|
2256
2256
|
const first = sentences(doc2)[0];
|
|
2257
2257
|
hits.push({
|
|
2258
2258
|
path: doc2.path,
|
|
2259
2259
|
offset: first?.offset ?? 0,
|
|
2260
|
-
message: `${
|
|
2260
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2261
2261
|
});
|
|
2262
2262
|
}
|
|
2263
2263
|
}
|
|
@@ -2269,14 +2269,14 @@ function rhythmHits(ctx) {
|
|
|
2269
2269
|
const list = sentences(doc2);
|
|
2270
2270
|
if (list.length < 40) continue;
|
|
2271
2271
|
const lengths = list.map((s) => countWords(s.text));
|
|
2272
|
-
const
|
|
2273
|
-
const
|
|
2274
|
-
const
|
|
2275
|
-
if (
|
|
2272
|
+
const mean2 = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
2273
|
+
const sd2 = Math.sqrt(lengths.reduce((a, b) => a + (b - mean2) ** 2, 0) / lengths.length);
|
|
2274
|
+
const cv2 = sd2 / mean2;
|
|
2275
|
+
if (cv2 < 0.42) {
|
|
2276
2276
|
hits.push({
|
|
2277
2277
|
path: doc2.path,
|
|
2278
2278
|
offset: list[0].offset,
|
|
2279
|
-
message: `${list.length} sentences averaging ${
|
|
2279
|
+
message: `${list.length} sentences averaging ${mean2.toFixed(1)} words with little variation (spread ${cv2.toFixed(2)}, reads metronomic below 0.42)`
|
|
2280
2280
|
});
|
|
2281
2281
|
}
|
|
2282
2282
|
}
|
|
@@ -2290,19 +2290,19 @@ function repeatHits(ctx) {
|
|
|
2290
2290
|
const whole = /* @__PURE__ */ new Map();
|
|
2291
2291
|
const runs = /* @__PURE__ */ new Map();
|
|
2292
2292
|
for (const s of sentences(doc2)) {
|
|
2293
|
-
const
|
|
2294
|
-
if (
|
|
2293
|
+
const words3 = normal(s.text);
|
|
2294
|
+
if (words3.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
2295
2295
|
if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
|
|
2296
|
-
const key =
|
|
2296
|
+
const key = words3.join(" ");
|
|
2297
2297
|
const earlier = whole.get(key);
|
|
2298
2298
|
if (earlier) {
|
|
2299
|
-
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${
|
|
2299
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words3.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
2300
2300
|
continue;
|
|
2301
2301
|
}
|
|
2302
2302
|
whole.set(key, s);
|
|
2303
2303
|
let reported = false;
|
|
2304
|
-
for (let i = 0; i + RUN <=
|
|
2305
|
-
const run =
|
|
2304
|
+
for (let i = 0; i + RUN <= words3.length && !reported; i += 1) {
|
|
2305
|
+
const run = words3.slice(i, i + RUN).join(" ");
|
|
2306
2306
|
const seen = runs.get(run);
|
|
2307
2307
|
if (seen && seen.key !== key) {
|
|
2308
2308
|
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) });
|
|
@@ -2359,11 +2359,11 @@ function headingEchoHits(ctx) {
|
|
|
2359
2359
|
const hits = [];
|
|
2360
2360
|
for (const { file } of longFiles(ctx)) {
|
|
2361
2361
|
for (const heading of headings(file)) {
|
|
2362
|
-
const
|
|
2363
|
-
if (
|
|
2362
|
+
const words3 = normal(heading.text);
|
|
2363
|
+
if (words3.length < 4) continue;
|
|
2364
2364
|
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
2365
|
-
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0,
|
|
2366
|
-
if (opening.join(" ") ===
|
|
2365
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words3.length);
|
|
2366
|
+
if (opening.join(" ") === words3.join(" ")) {
|
|
2367
2367
|
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
2368
2368
|
}
|
|
2369
2369
|
}
|
|
@@ -2371,7 +2371,7 @@ function headingEchoHits(ctx) {
|
|
|
2371
2371
|
return hits;
|
|
2372
2372
|
}
|
|
2373
2373
|
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(" ");
|
|
2374
|
-
function filler(
|
|
2374
|
+
function filler(words3, seed = 1, options = {}) {
|
|
2375
2375
|
const { contractions = true, uniform } = options;
|
|
2376
2376
|
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
2377
2377
|
let state = seed;
|
|
@@ -2380,16 +2380,16 @@ function filler(words2, seed = 1, options = {}) {
|
|
|
2380
2380
|
return state % n;
|
|
2381
2381
|
};
|
|
2382
2382
|
const out = [];
|
|
2383
|
-
let
|
|
2383
|
+
let count4 = 0;
|
|
2384
2384
|
let i = 0;
|
|
2385
|
-
while (
|
|
2385
|
+
while (count4 < words3) {
|
|
2386
2386
|
const length = uniform ?? shape[i % shape.length];
|
|
2387
2387
|
const s = [];
|
|
2388
2388
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2389
2389
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2390
2390
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2391
2391
|
out.push(`${s.join(" ")}.`);
|
|
2392
|
-
|
|
2392
|
+
count4 += s.length;
|
|
2393
2393
|
i += 1;
|
|
2394
2394
|
}
|
|
2395
2395
|
const paragraphs = [];
|
|
@@ -3529,64 +3529,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
3529
3529
|
}
|
|
3530
3530
|
];
|
|
3531
3531
|
|
|
3532
|
-
// src/
|
|
3533
|
-
var
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3532
|
+
// src/snapshot/types.ts
|
|
3533
|
+
var SNAPSHOT_VERSION = 2;
|
|
3534
|
+
|
|
3535
|
+
// src/snapshot/fixture.ts
|
|
3536
|
+
function makeSnapshot(patch = {}) {
|
|
3537
|
+
const base = {
|
|
3538
|
+
version: SNAPSHOT_VERSION,
|
|
3539
|
+
url: "https://example.test/",
|
|
3540
|
+
capturedAt: "2026-09-23T00:00:00.000Z",
|
|
3541
|
+
viewport: { width: 1440, height: 900 },
|
|
3542
|
+
pageHeight: 4200,
|
|
3543
|
+
ground: "rgb(255, 255, 255)",
|
|
3544
|
+
colours: {
|
|
3545
|
+
backgrounds: [
|
|
3546
|
+
{ value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
|
|
3547
|
+
{ value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
|
|
3548
|
+
],
|
|
3549
|
+
text: [
|
|
3550
|
+
{ value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
|
|
3551
|
+
{ value: "rgb(31, 58, 46)", area: 0, chars: 300 }
|
|
3552
|
+
]
|
|
3553
|
+
},
|
|
3554
|
+
fonts: [
|
|
3555
|
+
{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
|
|
3556
|
+
{ family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
|
|
3557
|
+
],
|
|
3558
|
+
headings: [
|
|
3559
|
+
{ level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
|
|
3560
|
+
{ level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
|
|
3561
|
+
],
|
|
3562
|
+
controls: [
|
|
3563
|
+
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
3564
|
+
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
3565
|
+
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
3566
|
+
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
3567
|
+
],
|
|
3568
|
+
sections: [
|
|
3569
|
+
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3570
|
+
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3571
|
+
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
3572
|
+
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3573
|
+
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
3574
|
+
],
|
|
3575
|
+
effects: {
|
|
3576
|
+
glass: 0,
|
|
3577
|
+
gradientText: 0,
|
|
3578
|
+
gradients: [],
|
|
3579
|
+
glows: 0,
|
|
3580
|
+
gridBackgrounds: 0,
|
|
3581
|
+
marquees: 0,
|
|
3582
|
+
hairlineShadowCards: 0,
|
|
3583
|
+
eyebrowChip: false
|
|
3584
|
+
},
|
|
3585
|
+
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3586
|
+
metrics: {
|
|
3587
|
+
fontSizes: [14, 16, 20, 36, 64],
|
|
3588
|
+
fontWeights: [400, 500],
|
|
3589
|
+
radii: ["4px"],
|
|
3590
|
+
shadows: [],
|
|
3591
|
+
spacingOnScalePct: 96,
|
|
3592
|
+
bodyLineLengthCh: 68,
|
|
3593
|
+
bodyFontSizePx: 17
|
|
3594
|
+
}
|
|
3589
3595
|
};
|
|
3596
|
+
const out = { ...base };
|
|
3597
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3598
|
+
const current = base[key];
|
|
3599
|
+
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3600
|
+
}
|
|
3601
|
+
return out;
|
|
3590
3602
|
}
|
|
3591
3603
|
|
|
3592
3604
|
// src/character/parse.ts
|
|
@@ -3759,15 +3771,15 @@ var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
|
3759
3771
|
function readsAsProse(value) {
|
|
3760
3772
|
if (value.length < 12 || !/\s/.test(value)) return false;
|
|
3761
3773
|
if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
|
|
3762
|
-
const
|
|
3763
|
-
if (looksLikeCode(
|
|
3764
|
-
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(
|
|
3774
|
+
const words3 = withoutMarkup(value);
|
|
3775
|
+
if (looksLikeCode(words3) || /[<>]/.test(words3)) return false;
|
|
3776
|
+
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words3);
|
|
3765
3777
|
}
|
|
3766
3778
|
function withoutMarkup(value) {
|
|
3767
3779
|
return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
|
|
3768
3780
|
}
|
|
3769
|
-
function looksLikeCode(
|
|
3770
|
-
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(
|
|
3781
|
+
function looksLikeCode(words3) {
|
|
3782
|
+
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words3);
|
|
3771
3783
|
}
|
|
3772
3784
|
var UTILITY = /^(?:flex|grid|block|inline|hidden|contents|relative|absolute|fixed|sticky|static|rounded|border|shadow|underline|italic|uppercase|lowercase|capitalize|truncate|container|group|peer|transition|antialiased|prose|sr-only|outline|ring|grow|shrink|isolate|invisible|visible|collapse)$/;
|
|
3773
3785
|
function looksLikeClasses(value) {
|
|
@@ -3920,76 +3932,664 @@ function extractStrings(file) {
|
|
|
3920
3932
|
return [];
|
|
3921
3933
|
}
|
|
3922
3934
|
|
|
3923
|
-
// src/
|
|
3924
|
-
var
|
|
3935
|
+
// src/character/facts.ts
|
|
3936
|
+
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)?)";
|
|
3937
|
+
var PATTERNS = [
|
|
3938
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
3939
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
3940
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
3941
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
3942
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
3943
|
+
["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")],
|
|
3944
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
3945
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
3946
|
+
];
|
|
3947
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
3948
|
+
var normalise = (kind, value) => {
|
|
3949
|
+
const v = value.trim();
|
|
3950
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
3951
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
3952
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
3953
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
3954
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
3955
|
+
};
|
|
3956
|
+
function visibleText(file) {
|
|
3957
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
3958
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
3959
|
+
}
|
|
3960
|
+
function protectedFacts(text) {
|
|
3961
|
+
const facts = [];
|
|
3962
|
+
let rest = text;
|
|
3963
|
+
const mask = (start, length) => {
|
|
3964
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
3965
|
+
};
|
|
3966
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
3967
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
3968
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
3969
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
3970
|
+
mask(m.index ?? 0, m[0].length);
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
const lines = rest.split("\n");
|
|
3974
|
+
for (const line of lines) {
|
|
3975
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
3976
|
+
for (const m of line.matchAll(NAME)) {
|
|
3977
|
+
const at = m.index ?? 0;
|
|
3978
|
+
const before = line.slice(0, at).trimEnd();
|
|
3979
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
3980
|
+
const word = m[0];
|
|
3981
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
3982
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
3983
|
+
if (word === "I") continue;
|
|
3984
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
return facts;
|
|
3988
|
+
}
|
|
3989
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
3990
|
+
function compareFacts(before, after) {
|
|
3991
|
+
const a = unique(protectedFacts(before));
|
|
3992
|
+
const b = unique(protectedFacts(after));
|
|
3993
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
3994
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
3995
|
+
return {
|
|
3996
|
+
before: a.length,
|
|
3997
|
+
after: b.length,
|
|
3998
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
3999
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4003
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4004
|
+
if (result.lost.length) {
|
|
4005
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4006
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4007
|
+
}
|
|
4008
|
+
if (result.added.length) {
|
|
4009
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4010
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4011
|
+
}
|
|
4012
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4013
|
+
return lines.join("\n");
|
|
4014
|
+
}
|
|
3925
4015
|
|
|
3926
|
-
// src/
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
4016
|
+
// src/character/specificity.ts
|
|
4017
|
+
var TRADE_NOUNS = [
|
|
4018
|
+
// heating, plumbing, electrical
|
|
4019
|
+
"boiler",
|
|
4020
|
+
"radiator",
|
|
4021
|
+
"combi",
|
|
4022
|
+
"cylinder",
|
|
4023
|
+
"thermostat",
|
|
4024
|
+
"underfloor heating",
|
|
4025
|
+
"heat pump",
|
|
4026
|
+
"solar panel",
|
|
4027
|
+
"plumbing",
|
|
4028
|
+
"plumber",
|
|
4029
|
+
"leak",
|
|
4030
|
+
"toilet",
|
|
4031
|
+
"shower",
|
|
4032
|
+
"bathroom",
|
|
4033
|
+
"wet room",
|
|
4034
|
+
"drain",
|
|
4035
|
+
"fuse board",
|
|
4036
|
+
"fuseboard",
|
|
4037
|
+
"consumer unit",
|
|
4038
|
+
"rewire",
|
|
4039
|
+
"socket",
|
|
4040
|
+
"ev charger",
|
|
4041
|
+
"electrician",
|
|
4042
|
+
"gas engineer",
|
|
4043
|
+
"power flush",
|
|
4044
|
+
"landlord certificate",
|
|
4045
|
+
"eicr",
|
|
4046
|
+
// building and outside
|
|
4047
|
+
"extension",
|
|
4048
|
+
"loft conversion",
|
|
4049
|
+
"loft",
|
|
4050
|
+
"kitchen",
|
|
4051
|
+
"roof",
|
|
4052
|
+
"roofer",
|
|
4053
|
+
"gutter",
|
|
4054
|
+
"chimney",
|
|
4055
|
+
"driveway",
|
|
4056
|
+
"patio",
|
|
4057
|
+
"fence",
|
|
4058
|
+
"fencing",
|
|
4059
|
+
"decking",
|
|
4060
|
+
"window",
|
|
4061
|
+
"conservatory",
|
|
4062
|
+
"plastering",
|
|
4063
|
+
"plasterer",
|
|
4064
|
+
"render",
|
|
4065
|
+
"brickwork",
|
|
4066
|
+
"bricklayer",
|
|
4067
|
+
"tiling",
|
|
4068
|
+
"tiler",
|
|
4069
|
+
"flooring",
|
|
4070
|
+
"carpet",
|
|
4071
|
+
"joinery",
|
|
4072
|
+
"joiner",
|
|
4073
|
+
"carpenter",
|
|
4074
|
+
"staircase",
|
|
4075
|
+
"damp",
|
|
4076
|
+
"scaffolding",
|
|
4077
|
+
"garage conversion",
|
|
4078
|
+
"lawn",
|
|
4079
|
+
"hedge",
|
|
4080
|
+
"tree surgery",
|
|
4081
|
+
"stump",
|
|
4082
|
+
"landscaping",
|
|
4083
|
+
"gardener",
|
|
4084
|
+
"gate",
|
|
4085
|
+
"locksmith",
|
|
4086
|
+
"pest control",
|
|
4087
|
+
"wasp",
|
|
4088
|
+
"cleaning",
|
|
4089
|
+
"oven",
|
|
4090
|
+
"end of tenancy",
|
|
4091
|
+
// vehicles
|
|
4092
|
+
"mot",
|
|
4093
|
+
"tyre",
|
|
4094
|
+
"brake",
|
|
4095
|
+
"clutch",
|
|
4096
|
+
"cambelt",
|
|
4097
|
+
"exhaust",
|
|
4098
|
+
"gearbox",
|
|
4099
|
+
"valeting",
|
|
4100
|
+
// personal and health
|
|
4101
|
+
"haircut",
|
|
4102
|
+
"skin fade",
|
|
4103
|
+
"beard",
|
|
4104
|
+
"barber",
|
|
4105
|
+
"balayage",
|
|
4106
|
+
"nails",
|
|
4107
|
+
"lashes",
|
|
4108
|
+
"massage",
|
|
4109
|
+
"physio",
|
|
4110
|
+
"physiotherapy",
|
|
4111
|
+
"osteopath",
|
|
4112
|
+
"chiropractor",
|
|
4113
|
+
"dentist",
|
|
4114
|
+
"dental",
|
|
4115
|
+
"implant",
|
|
4116
|
+
"brace",
|
|
4117
|
+
"invisalign",
|
|
4118
|
+
"hygienist",
|
|
4119
|
+
"optician",
|
|
4120
|
+
"eye test",
|
|
4121
|
+
"vet",
|
|
4122
|
+
"grooming",
|
|
4123
|
+
"dog walking",
|
|
4124
|
+
"personal trainer",
|
|
4125
|
+
"yoga",
|
|
4126
|
+
"pilates",
|
|
4127
|
+
// professional
|
|
4128
|
+
"accountant",
|
|
4129
|
+
"tax return",
|
|
4130
|
+
"bookkeeping",
|
|
4131
|
+
"payroll",
|
|
4132
|
+
"vat",
|
|
4133
|
+
"self assessment",
|
|
4134
|
+
"solicitor",
|
|
4135
|
+
"conveyancing",
|
|
4136
|
+
"probate",
|
|
4137
|
+
"divorce",
|
|
4138
|
+
"lasting power of attorney",
|
|
4139
|
+
"mortgage",
|
|
4140
|
+
"surveyor",
|
|
4141
|
+
"architect",
|
|
4142
|
+
"planning permission",
|
|
4143
|
+
"building regulations",
|
|
4144
|
+
// web and digital, for the agency's own pages
|
|
4145
|
+
"website",
|
|
4146
|
+
"web design",
|
|
4147
|
+
"seo",
|
|
4148
|
+
"hosting",
|
|
4149
|
+
"google business profile",
|
|
4150
|
+
"online shop",
|
|
4151
|
+
"ecommerce",
|
|
4152
|
+
// food and hospitality
|
|
4153
|
+
"breakfast",
|
|
4154
|
+
"lunch",
|
|
4155
|
+
"dinner",
|
|
4156
|
+
"coffee",
|
|
4157
|
+
"bakery",
|
|
4158
|
+
"cake",
|
|
4159
|
+
"catering",
|
|
4160
|
+
"wedding",
|
|
4161
|
+
"venue"
|
|
4162
|
+
];
|
|
4163
|
+
var STOCK_FIGURES = [
|
|
4164
|
+
/\b24\s?\/\s?7\b/gi,
|
|
4165
|
+
/\b24[\s-]hours?\b/gi,
|
|
4166
|
+
/\b1[01]0\s?%/g,
|
|
4167
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
4168
|
+
/#\s?1\b/g,
|
|
4169
|
+
/\bno\.?\s?1\b/gi,
|
|
4170
|
+
/\b365\s+days\b/gi
|
|
4171
|
+
];
|
|
4172
|
+
var NOT_NAMES = new Set(
|
|
4173
|
+
"a an the and of for to in on at by with your you our we us it get free quote quotes call now today book booking contact learn more read see view explore discover start started home about services service quality trusted trust expert experts expertise professional professionals best solutions solution digital team welcome why choose how works local reliable affordable fast friendly premium leading excellence excellent results success growth business businesses customer customers client clients care support help new our story mission vision values innovation innovative modern simple easy smart better great amazing perfect ultimate transform elevate empower unlock experience experienced dedicated passionate committed tailored bespoke seamless peace mind can do all every everything more less here there this that what who where when need needs make difference satisfaction guaranteed".split(" ")
|
|
4174
|
+
);
|
|
4175
|
+
var BRIEF_STOP = new Set(
|
|
4176
|
+
"a an the and or of for to in on at by with from who that which what their they them its it is are was were be been being has have had do does did can will would should could may might must this these those there here as into over under business company small local family run based owned services service provides provide offering offers people customers clients home homes house houses area areas around near across nearby town city county village uk".split(" ")
|
|
4177
|
+
);
|
|
4178
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
4179
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
4180
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4181
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
4182
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
4183
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
4184
|
+
function caseless(line) {
|
|
4185
|
+
const words3 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
4186
|
+
const letters = words3.filter((w) => w.length >= 2);
|
|
4187
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
4188
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
4189
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
4190
|
+
return null;
|
|
4191
|
+
}
|
|
4192
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
4193
|
+
function briefTerms(brief) {
|
|
4194
|
+
const terms = /* @__PURE__ */ new Set();
|
|
4195
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
4196
|
+
const lower2 = w.toLowerCase();
|
|
4197
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower2) && !NOT_NAMES.has(lower2)) terms.add(lower2);
|
|
4198
|
+
}
|
|
4199
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
4200
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
4201
|
+
return [...terms];
|
|
4202
|
+
}
|
|
4203
|
+
function specifics(text, brief) {
|
|
4204
|
+
const found = /* @__PURE__ */ new Map();
|
|
4205
|
+
const add = (kind, value) => {
|
|
4206
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
4207
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
4208
|
+
};
|
|
4209
|
+
for (const raw of text.split("\n")) {
|
|
4210
|
+
const line = maskStock(raw);
|
|
4211
|
+
if (!line.trim()) continue;
|
|
4212
|
+
const capitals = caseless(line);
|
|
4213
|
+
for (const f4 of protectedFacts(line)) {
|
|
4214
|
+
if (f4.kind === "name") {
|
|
4215
|
+
if (capitals === "shout") continue;
|
|
4216
|
+
if (capitals === "title") {
|
|
4217
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
4218
|
+
continue;
|
|
4219
|
+
}
|
|
4220
|
+
const words3 = f4.value.split(/\s+/);
|
|
4221
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
4222
|
+
const first = words3.findIndex(named);
|
|
4223
|
+
if (first === -1) continue;
|
|
4224
|
+
const last = words3.length - 1 - [...words3].reverse().findIndex(named);
|
|
4225
|
+
add("name", words3.slice(first, last + 1).join(" "));
|
|
4226
|
+
continue;
|
|
4227
|
+
}
|
|
4228
|
+
add(f4.kind, f4.value);
|
|
4229
|
+
}
|
|
4230
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
4231
|
+
const m = re.exec(line);
|
|
4232
|
+
if (m) add("trade", term);
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
if (brief) {
|
|
4236
|
+
for (const term of briefTerms(brief)) {
|
|
4237
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
4238
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
return [...found.values()];
|
|
4242
|
+
}
|
|
4243
|
+
function specificity(text, brief) {
|
|
4244
|
+
const list = specifics(text, brief);
|
|
4245
|
+
const words3 = countWords2(text);
|
|
4246
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
4247
|
+
const hard = list.length - trade;
|
|
4248
|
+
return {
|
|
4249
|
+
words: words3,
|
|
4250
|
+
specifics: list,
|
|
4251
|
+
hard,
|
|
4252
|
+
trade,
|
|
4253
|
+
perHundred: words3 ? Math.round(list.length / words3 * 1e3) / 10 : 0,
|
|
4254
|
+
generic: hard === 0 && trade < 2
|
|
4255
|
+
};
|
|
4256
|
+
}
|
|
4257
|
+
var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
|
|
4258
|
+
var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
|
|
4259
|
+
var SERVICE_LABEL = /\bservices?\b|\bwhat we (?:do|fix|offer|make|build)\b|\bhow we\b|\bour work\b|\bwe (?:fix|fit|install|repair|build|offer|make)\b/i;
|
|
4260
|
+
function sentencesOf(blocks) {
|
|
4261
|
+
const out = [];
|
|
4262
|
+
for (const block of blocks) {
|
|
4263
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
4264
|
+
const s = m[0].trim();
|
|
4265
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
return [...new Set(out)];
|
|
4269
|
+
}
|
|
4270
|
+
function pageCopy(snapshot) {
|
|
4271
|
+
const withText2 = snapshot.sections.filter((s) => s.text);
|
|
4272
|
+
if (!snapshot.firstScreenText && withText2.length === 0) return null;
|
|
4273
|
+
const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
|
|
4274
|
+
const services = snapshot.sections.filter((s) => s.kind !== "hero" && !NOT_SERVICE_ROLES.has(s.role ?? s.kind)).filter((s) => SERVICE_ROLES.has(s.role ?? s.kind) || SERVICE_LABEL.test(s.label)).flatMap((s) => s.text ?? []);
|
|
4275
|
+
const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
|
|
4276
|
+
return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
|
|
4277
|
+
}
|
|
4278
|
+
function side(page, other, brief) {
|
|
4279
|
+
const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
|
|
4280
|
+
const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
|
|
4281
|
+
const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
|
|
4282
|
+
return {
|
|
4283
|
+
url: page.url,
|
|
4284
|
+
sentences: sentences3.length,
|
|
4285
|
+
interchangeable,
|
|
4286
|
+
share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
|
|
4287
|
+
perHundred: specificity(sentences3.join("\n"), brief).perHundred
|
|
4288
|
+
};
|
|
4289
|
+
}
|
|
4290
|
+
function compareCompetitor(ours, competitor, brief) {
|
|
4291
|
+
return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
|
|
4292
|
+
}
|
|
4293
|
+
var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
|
|
4294
|
+
function formatCompetitor(result) {
|
|
4295
|
+
const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
|
|
4296
|
+
const lines = [
|
|
4297
|
+
`craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
|
|
4298
|
+
" Hero and service sentences holding nothing the other page does not also say:",
|
|
4299
|
+
row("ours", result.ours),
|
|
4300
|
+
row("competitor", result.competitor)
|
|
4301
|
+
];
|
|
4302
|
+
if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
|
|
4303
|
+
if (result.ours.interchangeable.length) {
|
|
4304
|
+
lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
|
|
4305
|
+
for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
|
|
4306
|
+
if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
|
|
4307
|
+
} else {
|
|
4308
|
+
lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
|
|
4309
|
+
}
|
|
4310
|
+
return lines.join("\n");
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
// src/character/tells/specificity.ts
|
|
4314
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4315
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
4316
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
4317
|
+
function literalText(inner) {
|
|
4318
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
4319
|
+
if (/[{}]/.test(t)) return null;
|
|
4320
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
4321
|
+
}
|
|
4322
|
+
var HOME_FILE = /(?:^|\/)app\/(?:\([^/]+\)\/)*page\.(?:tsx|jsx|mdx)$|(?:^|\/)pages\/index\.(?:tsx|jsx|astro|vue|mdx)$|(?:^|\/)routes\/(?:\([^/]+\)\/)*\+page\.svelte$|^(?:(?:public|src|site|www)\/)?index\.html?$|(?:^|\/)[\w-]*(?:hero|home)[\w-]*\.(?:tsx|jsx|astro|vue|svelte|html?)$/i;
|
|
4323
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
4324
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
4325
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
4326
|
+
function headlines(ctx) {
|
|
4327
|
+
const out = [];
|
|
4328
|
+
for (const file of ctx.files) {
|
|
4329
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
4330
|
+
for (const m of file.text.matchAll(H1)) {
|
|
4331
|
+
const h1 = literalText(m[1]);
|
|
4332
|
+
if (!h1) continue;
|
|
4333
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
4334
|
+
const p = NEXT_P.exec(after);
|
|
4335
|
+
const sub = p ? literalText(p[1]) : null;
|
|
4336
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
return out;
|
|
4340
|
+
}
|
|
4341
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
4342
|
+
var STANDING_CLAIM = /(?<![\w#])(?:trusted|reliable|reputable|award[- ]winning|top[- ]rated|best[- ]rated|highly (?:rated|recommended|experienced|skilled|qualified)|fully (?:qualified|certified|accredited|licensed|insured)|(?:qualified|certified|accredited|approved|registered|experienced|expert) (?:engineers?|electricians?|plumbers?|builders?|technicians?|installers?|team|tradesmen|professionals?|staff)|(?:years|decades) of experience|(?:5|five)[- ]star (?:service|rated|reviews?)|(?:happy|satisfied) (?:customers|clients)|(?:customers|clients) (?:love|trust|recommend)|number one|no\.? ?1|#1)(?![\w])/i;
|
|
4343
|
+
var PROOF_NAMES = [
|
|
4344
|
+
"Gas Safe",
|
|
4345
|
+
"NICEIC",
|
|
4346
|
+
"NAPIT",
|
|
4347
|
+
"ELECSA",
|
|
4348
|
+
"Stroma",
|
|
4349
|
+
"TrustMark",
|
|
4350
|
+
"Which? Trusted Trader",
|
|
4351
|
+
"Checkatrade",
|
|
4352
|
+
"TrustATrader",
|
|
4353
|
+
"Rated People",
|
|
4354
|
+
"MyBuilder",
|
|
4355
|
+
"Federation of Master Builders",
|
|
4356
|
+
"FMB",
|
|
4357
|
+
"Guild of Master Craftsmen",
|
|
4358
|
+
"OFTEC",
|
|
4359
|
+
"HETAS",
|
|
4360
|
+
"CIPHE",
|
|
4361
|
+
"APHC",
|
|
4362
|
+
"CHAS",
|
|
4363
|
+
"SafeContractor",
|
|
4364
|
+
"Constructionline",
|
|
4365
|
+
"FENSA",
|
|
4366
|
+
"CERTASS",
|
|
4367
|
+
"MCS",
|
|
4368
|
+
"RECC",
|
|
4369
|
+
"BPEC",
|
|
4370
|
+
"City & Guilds",
|
|
4371
|
+
"NVQ",
|
|
4372
|
+
"CSCS",
|
|
4373
|
+
"Worcester Bosch",
|
|
4374
|
+
"Vaillant",
|
|
4375
|
+
"Viessmann",
|
|
4376
|
+
"BAFE",
|
|
4377
|
+
"NSI",
|
|
4378
|
+
"SSAIB",
|
|
4379
|
+
"BPCA",
|
|
4380
|
+
"RICS",
|
|
4381
|
+
"CIOB",
|
|
4382
|
+
"RIBA",
|
|
4383
|
+
"ICAEW",
|
|
4384
|
+
"ACCA",
|
|
4385
|
+
"CIMA",
|
|
4386
|
+
"SRA",
|
|
4387
|
+
"Law Society",
|
|
4388
|
+
"GDC",
|
|
4389
|
+
"GMC",
|
|
4390
|
+
"HCPC",
|
|
4391
|
+
"CQC",
|
|
4392
|
+
"Ofsted",
|
|
4393
|
+
"ISO 9001",
|
|
4394
|
+
"ISO 27001",
|
|
4395
|
+
"Cyber Essentials",
|
|
4396
|
+
"Companies House",
|
|
4397
|
+
"FCA",
|
|
4398
|
+
"Trustpilot",
|
|
4399
|
+
"Feefo",
|
|
4400
|
+
"Reviews.io",
|
|
4401
|
+
"Yell",
|
|
4402
|
+
"Houzz"
|
|
4403
|
+
];
|
|
4404
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4405
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
4406
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
4407
|
+
var PROOF_PATTERNS = [
|
|
4408
|
+
PROOF_NAME,
|
|
4409
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
4410
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
4411
|
+
/\b[1-5](?:\.\d)?\s?(?:\/\s?5|out of 5)\b|\brated\s+[1-5](?:\.\d)?\b|★{3,}|\b\d[\d,]*\+?\s+(?:verified\s+)?(?:reviews|ratings)\b/i,
|
|
4412
|
+
// A registration or licence number.
|
|
4413
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
4414
|
+
];
|
|
4415
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
4416
|
+
function sectionHasProof(s, section, index) {
|
|
4417
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
4418
|
+
const text = section.text ?? [];
|
|
4419
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
4420
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
4421
|
+
}
|
|
4422
|
+
function unprovenClaims(s) {
|
|
4423
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
4424
|
+
const out = [];
|
|
4425
|
+
s.sections.forEach((section, i) => {
|
|
4426
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
4427
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
4428
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
4429
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
4432
|
+
return out;
|
|
4433
|
+
}
|
|
4434
|
+
var withText = (texts, patch = () => ({})) => {
|
|
4435
|
+
const base = makeSnapshot();
|
|
4436
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
4437
|
+
};
|
|
4438
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
4439
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
4440
|
+
{
|
|
4441
|
+
id: "generic-hero-claim",
|
|
4442
|
+
name: "Headline that fits any business",
|
|
4443
|
+
generation: 1,
|
|
4444
|
+
severity: "warn",
|
|
4445
|
+
surface: "copy",
|
|
4446
|
+
why: "The first thing a visitor reads on the home page names no place, no person, no price and no job, so it would fit every competitor's site unchanged. A model writes it when it has nothing particular to say.",
|
|
4447
|
+
fix: 'Put what only this business can say on the first screen: the job, the town, a price, the name. "Boilers fixed the same day in Brackley" beats "Quality you can trust".',
|
|
4448
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
4449
|
+
fixtures: {
|
|
4450
|
+
flag: [
|
|
4451
|
+
{ path: "app/page.tsx", text: '<section>\n <h1 className="text-6xl">Quality you can trust</h1>\n <p>We deliver reliable solutions tailored to your needs.</p>\n</section>' },
|
|
4452
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
4453
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
3939
4454
|
],
|
|
3940
|
-
|
|
3941
|
-
{
|
|
3942
|
-
|
|
4455
|
+
pass: [
|
|
4456
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
4457
|
+
// A stock headline rescued by the line under it.
|
|
4458
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
4459
|
+
// Two trade nouns say what the business does.
|
|
4460
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
4461
|
+
// The words come from data: nothing to read.
|
|
4462
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
4463
|
+
// Another page's headline is a label, not the site's claim.
|
|
4464
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
3943
4465
|
]
|
|
3944
4466
|
},
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
gradientText: 0,
|
|
3969
|
-
gradients: [],
|
|
3970
|
-
glows: 0,
|
|
3971
|
-
gridBackgrounds: 0,
|
|
3972
|
-
marquees: 0,
|
|
3973
|
-
hairlineShadowCards: 0,
|
|
3974
|
-
eyebrowChip: false
|
|
3975
|
-
},
|
|
3976
|
-
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3977
|
-
metrics: {
|
|
3978
|
-
fontSizes: [14, 16, 20, 36, 64],
|
|
3979
|
-
fontWeights: [400, 500],
|
|
3980
|
-
radii: ["4px"],
|
|
3981
|
-
shadows: [],
|
|
3982
|
-
spacingOnScalePct: 96,
|
|
3983
|
-
bodyLineLengthCh: 68,
|
|
3984
|
-
bodyFontSizePx: 17
|
|
4467
|
+
rendered: {
|
|
4468
|
+
detect: (s) => {
|
|
4469
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
4470
|
+
const text = s.firstScreenText.join("\n");
|
|
4471
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
4472
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
4473
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
4474
|
+
},
|
|
4475
|
+
fixtures: {
|
|
4476
|
+
flag: [
|
|
4477
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
4478
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
4479
|
+
],
|
|
4480
|
+
pass: [
|
|
4481
|
+
makeSnapshot(),
|
|
4482
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
4483
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
4484
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
4485
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
4486
|
+
// Nor is any page but the home page.
|
|
4487
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
4488
|
+
]
|
|
4489
|
+
}
|
|
3985
4490
|
}
|
|
4491
|
+
}
|
|
4492
|
+
];
|
|
4493
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
4494
|
+
{
|
|
4495
|
+
id: "unproven-claim",
|
|
4496
|
+
name: "Claim with no proof next to it",
|
|
4497
|
+
generation: 1,
|
|
4498
|
+
severity: "warn",
|
|
4499
|
+
surface: "rendered",
|
|
4500
|
+
why: `"Trusted", "fully qualified" and "award-winning" ask the visitor to take the business's word for it. A page built with no reviews and no registration number makes the claim anyway, with nothing beside it.`,
|
|
4501
|
+
fix: "Put the proof where the claim is: the Gas Safe or NICEIC number, the named review with its town, the rating and where it was left. Or cut the claim.",
|
|
4502
|
+
rendered: {
|
|
4503
|
+
detect: (s) => {
|
|
4504
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
4505
|
+
const found = unprovenClaims(s);
|
|
4506
|
+
if (found.length === 0) return [];
|
|
4507
|
+
const first = found[0];
|
|
4508
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
4509
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
4510
|
+
},
|
|
4511
|
+
fixtures: {
|
|
4512
|
+
flag: [
|
|
4513
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4514
|
+
// Proof two sections away is not next to the claim.
|
|
4515
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["Recent jobs"], ["\u201CThey turned up on time and fixed the boiler in an hour.\u201D", "Sam, Brackley"]], (i) => i === 3 ? { role: "testimonials" } : {})
|
|
4516
|
+
],
|
|
4517
|
+
pass: [
|
|
4518
|
+
makeSnapshot(),
|
|
4519
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
4520
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
4521
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
4522
|
+
{
|
|
4523
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4524
|
+
images: [{ src: "https://example.test/img/gas-safe-register.svg", host: "example.test", width: 96, height: 96, top: 1200, role: "logo", alt: "Gas Safe Register", decorative: false, section: 1, background: false }]
|
|
4525
|
+
}
|
|
4526
|
+
]
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
];
|
|
4531
|
+
|
|
4532
|
+
// src/character/house.ts
|
|
4533
|
+
var HOUSE = {
|
|
4534
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
4535
|
+
emoji: { tier: "block", label: "emoji" },
|
|
4536
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
4537
|
+
"no-x-no-y": {
|
|
4538
|
+
tier: "block",
|
|
4539
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
4540
|
+
},
|
|
4541
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
4542
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
4543
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
4544
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
4545
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
4546
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
4547
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
4548
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
4549
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
4550
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
4551
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
4552
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
4553
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
4554
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
4555
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
4556
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
4557
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
4558
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
4559
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
4560
|
+
"false-range": { tier: "review", label: "false range" },
|
|
4561
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
4562
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
4563
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
4564
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
4565
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
4566
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
4567
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
4568
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
4569
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
4570
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
4571
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
4572
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
4573
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
4574
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
4575
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
4576
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
4577
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
4578
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
4579
|
+
};
|
|
4580
|
+
function houseRule(id, name = id) {
|
|
4581
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
4582
|
+
}
|
|
4583
|
+
var HOUSE_LOCKED = "the house blocking tier cannot be lowered by a repo. A genuine one-off takes copy-ok on its line; a rule that is wrong is changed in craft's house.ts and COPY.md";
|
|
4584
|
+
function holdHouseBlocks(exceptions, severity) {
|
|
4585
|
+
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
4586
|
+
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
4587
|
+
return {
|
|
4588
|
+
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
4589
|
+
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
4590
|
+
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
4591
|
+
refusedSeverity
|
|
3986
4592
|
};
|
|
3987
|
-
const out = { ...base };
|
|
3988
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
3989
|
-
const current = base[key];
|
|
3990
|
-
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3991
|
-
}
|
|
3992
|
-
return out;
|
|
3993
4593
|
}
|
|
3994
4594
|
|
|
3995
4595
|
// src/snapshot/fonts.ts
|
|
@@ -4109,7 +4709,7 @@ function blueToPurple(s) {
|
|
|
4109
4709
|
}
|
|
4110
4710
|
return hits;
|
|
4111
4711
|
}
|
|
4112
|
-
var
|
|
4712
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4113
4713
|
var RENDERED_PATHS = {
|
|
4114
4714
|
"reflex-font": {
|
|
4115
4715
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -4147,11 +4747,11 @@ var RENDERED_PATHS = {
|
|
|
4147
4747
|
}
|
|
4148
4748
|
},
|
|
4149
4749
|
"gradient-text": {
|
|
4150
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
4750
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
4151
4751
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
4152
4752
|
},
|
|
4153
4753
|
"glass-panel": {
|
|
4154
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
4754
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
4155
4755
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
4156
4756
|
},
|
|
4157
4757
|
"cream-palette": {
|
|
@@ -4229,7 +4829,7 @@ var RENDERED_PATHS = {
|
|
|
4229
4829
|
detect: (s) => {
|
|
4230
4830
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
4231
4831
|
const n = s.effects.glows + radial;
|
|
4232
|
-
return n > 0 ? [hit(s, `${
|
|
4832
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
4233
4833
|
},
|
|
4234
4834
|
fixtures: {
|
|
4235
4835
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -4237,11 +4837,11 @@ var RENDERED_PATHS = {
|
|
|
4237
4837
|
}
|
|
4238
4838
|
},
|
|
4239
4839
|
"grid-background": {
|
|
4240
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
4840
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
4241
4841
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
4242
4842
|
},
|
|
4243
4843
|
marquee: {
|
|
4244
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
4844
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
4245
4845
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
4246
4846
|
},
|
|
4247
4847
|
"thin-border-wide-shadow": {
|
|
@@ -4359,9 +4959,14 @@ function faceDistance(a, b) {
|
|
|
4359
4959
|
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
4360
4960
|
return a.class === b.class ? 0.5 : 1;
|
|
4361
4961
|
}
|
|
4962
|
+
var ACCENT_SCALE = 0.3;
|
|
4963
|
+
var GROUND_SCALE = 0.15;
|
|
4964
|
+
var SHARED_PART = 0.2;
|
|
4965
|
+
var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
|
|
4966
|
+
var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
|
|
4362
4967
|
function fingerprintDistance(a, b) {
|
|
4363
|
-
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) /
|
|
4364
|
-
const ground = clamp(deltaEOk(a.ground, b.ground) /
|
|
4968
|
+
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / ACCENT_SCALE) : a.accent || b.accent ? 1 : 0;
|
|
4969
|
+
const ground = clamp(deltaEOk(a.ground, b.ground) / GROUND_SCALE);
|
|
4365
4970
|
const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
|
|
4366
4971
|
const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
|
|
4367
4972
|
const motion = clamp(Math.abs(a.motion - b.motion));
|
|
@@ -4472,8 +5077,8 @@ function sharedChoices(target2, runs) {
|
|
|
4472
5077
|
const out = [];
|
|
4473
5078
|
for (const [part, values] of Object.entries(mine)) {
|
|
4474
5079
|
for (const value of values) {
|
|
4475
|
-
const
|
|
4476
|
-
if (
|
|
5080
|
+
const count4 = all.filter((c) => c[part].includes(value)).length;
|
|
5081
|
+
if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
|
|
4477
5082
|
}
|
|
4478
5083
|
}
|
|
4479
5084
|
return out.sort((a, b) => b.runs - a.runs);
|
|
@@ -4496,10 +5101,10 @@ var STOP = new Set(
|
|
|
4496
5101
|
function grams(text) {
|
|
4497
5102
|
const out = /* @__PURE__ */ new Set();
|
|
4498
5103
|
for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
|
|
4499
|
-
const
|
|
5104
|
+
const words3 = clause.match(/[a-z][a-z'-]*/g) ?? [];
|
|
4500
5105
|
for (let n = 2; n <= 4; n += 1) {
|
|
4501
|
-
for (let i = 0; i + n <=
|
|
4502
|
-
const g =
|
|
5106
|
+
for (let i = 0; i + n <= words3.length; i += 1) {
|
|
5107
|
+
const g = words3.slice(i, i + n);
|
|
4503
5108
|
if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
|
|
4504
5109
|
if (g.filter((w) => !STOP.has(w)).length < 2) continue;
|
|
4505
5110
|
out.add(g.join(" "));
|
|
@@ -4666,7 +5271,7 @@ function closestComponents(a, b, role) {
|
|
|
4666
5271
|
}
|
|
4667
5272
|
function estateComponentPairs(register, role) {
|
|
4668
5273
|
const out = [];
|
|
4669
|
-
const s = register.sites;
|
|
5274
|
+
const s = register.sites.filter((site) => site.fingerprint);
|
|
4670
5275
|
for (let i = 0; i < s.length; i += 1) {
|
|
4671
5276
|
for (let j = i + 1; j < s.length; j += 1) {
|
|
4672
5277
|
const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
|
|
@@ -4947,7 +5552,7 @@ var picture = (patch = {}) => ({
|
|
|
4947
5552
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
4948
5553
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
4949
5554
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
4950
|
-
var
|
|
5555
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4951
5556
|
var IMAGERY_TELLS = [
|
|
4952
5557
|
{
|
|
4953
5558
|
id: "stock-photo",
|
|
@@ -4962,7 +5567,7 @@ var IMAGERY_TELLS = [
|
|
|
4962
5567
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
4963
5568
|
if (found.length === 0) return [];
|
|
4964
5569
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
4965
|
-
return [hit3(s, `${
|
|
5570
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
4966
5571
|
},
|
|
4967
5572
|
fixtures: {
|
|
4968
5573
|
flag: [
|
|
@@ -4987,7 +5592,7 @@ var IMAGERY_TELLS = [
|
|
|
4987
5592
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
4988
5593
|
if (found.length === 0) return [];
|
|
4989
5594
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
4990
|
-
return [hit3(s, `${
|
|
5595
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
4991
5596
|
},
|
|
4992
5597
|
fixtures: {
|
|
4993
5598
|
flag: [
|
|
@@ -5012,7 +5617,7 @@ var IMAGERY_TELLS = [
|
|
|
5012
5617
|
if (found.length === 0) return [];
|
|
5013
5618
|
const first = found[0].provenance;
|
|
5014
5619
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
5015
|
-
return [hit3(s, `${
|
|
5620
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
5016
5621
|
},
|
|
5017
5622
|
fixtures: {
|
|
5018
5623
|
flag: [
|
|
@@ -5037,7 +5642,7 @@ var IMAGERY_TELLS = [
|
|
|
5037
5642
|
if (!s.images || s.sections.length < 4) return [];
|
|
5038
5643
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
5039
5644
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
5040
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
5645
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
5041
5646
|
},
|
|
5042
5647
|
fixtures: {
|
|
5043
5648
|
flag: [
|
|
@@ -5052,11 +5657,13 @@ var IMAGERY_TELLS = [
|
|
|
5052
5657
|
];
|
|
5053
5658
|
|
|
5054
5659
|
// src/character/check.ts
|
|
5055
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
5660
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
5056
5661
|
var CATALOGUE = [
|
|
5057
5662
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
5663
|
+
...SPECIFICITY_COPY_TELLS,
|
|
5058
5664
|
...RENDERED_TELLS,
|
|
5059
|
-
...IMAGERY_TELLS
|
|
5665
|
+
...IMAGERY_TELLS,
|
|
5666
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
5060
5667
|
];
|
|
5061
5668
|
function tellById(id) {
|
|
5062
5669
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -5133,7 +5740,7 @@ function report(tells, files, run, options) {
|
|
|
5133
5740
|
catalogueVersion: CATALOGUE_VERSION,
|
|
5134
5741
|
findings,
|
|
5135
5742
|
rejectedExceptions: rejected,
|
|
5136
|
-
excepted: [...excepted].map(([e,
|
|
5743
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
5137
5744
|
suppressed,
|
|
5138
5745
|
summary: {
|
|
5139
5746
|
files: files.length,
|
|
@@ -5174,7 +5781,7 @@ function runTell(tell, files) {
|
|
|
5174
5781
|
}
|
|
5175
5782
|
function catalogueTable() {
|
|
5176
5783
|
const rows = CATALOGUE.map(
|
|
5177
|
-
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5784
|
+
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5178
5785
|
);
|
|
5179
5786
|
return [
|
|
5180
5787
|
`Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
|
|
@@ -5239,87 +5846,6 @@ function formatReport(report2, title) {
|
|
|
5239
5846
|
return lines.join("\n").replace(/^\n/, "");
|
|
5240
5847
|
}
|
|
5241
5848
|
|
|
5242
|
-
// src/character/facts.ts
|
|
5243
|
-
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)?)";
|
|
5244
|
-
var PATTERNS = [
|
|
5245
|
-
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
5246
|
-
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
5247
|
-
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
5248
|
-
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
5249
|
-
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
5250
|
-
["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")],
|
|
5251
|
-
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
5252
|
-
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
5253
|
-
];
|
|
5254
|
-
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
5255
|
-
var normalise = (kind, value) => {
|
|
5256
|
-
const v = value.trim();
|
|
5257
|
-
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
5258
|
-
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
5259
|
-
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
5260
|
-
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
5261
|
-
return v.replace(/\s+/g, " ").toLowerCase();
|
|
5262
|
-
};
|
|
5263
|
-
function visibleText(file) {
|
|
5264
|
-
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
5265
|
-
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
5266
|
-
}
|
|
5267
|
-
function protectedFacts(text) {
|
|
5268
|
-
const facts = [];
|
|
5269
|
-
let rest = text;
|
|
5270
|
-
const mask = (start, length) => {
|
|
5271
|
-
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
5272
|
-
};
|
|
5273
|
-
for (const [kind, pattern] of PATTERNS) {
|
|
5274
|
-
for (const m of [...rest.matchAll(pattern)]) {
|
|
5275
|
-
const value = m[0].replace(/[.,;:]+$/, "");
|
|
5276
|
-
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
5277
|
-
mask(m.index ?? 0, m[0].length);
|
|
5278
|
-
}
|
|
5279
|
-
}
|
|
5280
|
-
const lines = rest.split("\n");
|
|
5281
|
-
for (const line of lines) {
|
|
5282
|
-
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
5283
|
-
for (const m of line.matchAll(NAME)) {
|
|
5284
|
-
const at = m.index ?? 0;
|
|
5285
|
-
const before = line.slice(0, at).trimEnd();
|
|
5286
|
-
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
5287
|
-
const word = m[0];
|
|
5288
|
-
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
5289
|
-
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
5290
|
-
if (word === "I") continue;
|
|
5291
|
-
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
5292
|
-
}
|
|
5293
|
-
}
|
|
5294
|
-
return facts;
|
|
5295
|
-
}
|
|
5296
|
-
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
5297
|
-
function compareFacts(before, after) {
|
|
5298
|
-
const a = unique(protectedFacts(before));
|
|
5299
|
-
const b = unique(protectedFacts(after));
|
|
5300
|
-
const inA = new Set(a.map((f4) => f4.key));
|
|
5301
|
-
const inB = new Set(b.map((f4) => f4.key));
|
|
5302
|
-
return {
|
|
5303
|
-
before: a.length,
|
|
5304
|
-
after: b.length,
|
|
5305
|
-
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
5306
|
-
added: b.filter((f4) => !inA.has(f4.key))
|
|
5307
|
-
};
|
|
5308
|
-
}
|
|
5309
|
-
function formatComparison(result, beforePath, afterPath) {
|
|
5310
|
-
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
5311
|
-
if (result.lost.length) {
|
|
5312
|
-
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
5313
|
-
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5314
|
-
}
|
|
5315
|
-
if (result.added.length) {
|
|
5316
|
-
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
5317
|
-
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5318
|
-
}
|
|
5319
|
-
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
5320
|
-
return lines.join("\n");
|
|
5321
|
-
}
|
|
5322
|
-
|
|
5323
5849
|
// src/character/claims.ts
|
|
5324
5850
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
|
|
5325
5851
|
var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
|
|
@@ -5339,9 +5865,9 @@ var ATTRIBUTION = [
|
|
|
5339
5865
|
];
|
|
5340
5866
|
var LINK2 = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
|
|
5341
5867
|
function cleanName(raw) {
|
|
5342
|
-
const
|
|
5343
|
-
while (
|
|
5344
|
-
return
|
|
5868
|
+
const words3 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
|
|
5869
|
+
while (words3.length && NOT_A_SOURCE.test(words3[0])) words3.shift();
|
|
5870
|
+
return words3.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
|
|
5345
5871
|
}
|
|
5346
5872
|
var IMPERATIVE = /^(?:Add|Use|Check|Make|Get|Set|Include|Create|Update|List|Keep|Ask|Write|Track|Test|Claim|Build|Link|Submit|Verify|Review|Fix|Remove|Upload|Respond|Reply|Choose|Pick|Start|Run|Open|Install|Connect|Enable|Turn|Put|Give|Send|Book|Call|Read|See|Try|Compare|Aim|Target|Avoid|Post|Share|Mark|Tag)$/;
|
|
5347
5873
|
var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
|
|
@@ -5434,6 +5960,207 @@ function formatClaims(report2, label = "craft copy claims") {
|
|
|
5434
5960
|
return lines.join("\n");
|
|
5435
5961
|
}
|
|
5436
5962
|
|
|
5963
|
+
// src/character/shape.ts
|
|
5964
|
+
var ABBREVIATIONS = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No|approx|incl|Ltd|Co|Inc|Jr|Sr)\./g;
|
|
5965
|
+
var DOT = "\0";
|
|
5966
|
+
function splitSentences(text) {
|
|
5967
|
+
const guarded = text.replace(/\s+/g, " ").replace(ABBREVIATIONS, (m) => m.replace(/\./g, DOT)).replace(/(\d)\.(\d)/g, `$1${DOT}$2`);
|
|
5968
|
+
return guarded.split(/(?<=[.!?]["'’”)]*)\s+(?=["'‘“(]?[A-Z0-9£$€])/).map((s) => s.split(DOT).join(".").trim()).filter((s) => words(s).length > 0);
|
|
5969
|
+
}
|
|
5970
|
+
var WORD3 = /[A-Za-z0-9£$€][A-Za-z0-9£$€%'’-]*/g;
|
|
5971
|
+
function words(text) {
|
|
5972
|
+
return text.match(WORD3) ?? [];
|
|
5973
|
+
}
|
|
5974
|
+
var lower = (w) => w.toLowerCase().replace(/’/g, "'");
|
|
5975
|
+
var FUNCTION_WORDS = new Set(
|
|
5976
|
+
"a an the this that these those my your our their his her its we you they i he she it me us them and or but nor so yet for if when while where whereas because although though unless once since until as than of to in on at by with from into onto over under about after before between through during without within across is are was were be been being am do does did done have has had will would can could may might must shall should not no every each all any some more most many much few such own same other only just also very too there here what which who whom whose how why then which's it's you'll we'll you're we're they're don't won't can't isn't aren't".split(" ")
|
|
5977
|
+
);
|
|
5978
|
+
var ITEM = "[A-Za-z0-9\xA3$\u20AC'\u2019-]+(?: [A-Za-z0-9\xA3$\u20AC'\u2019-]+){0,5}";
|
|
5979
|
+
var TRICOLON = new RegExp(`(${ITEM}), (${ITEM}),? (and|or) (${ITEM})(?=[,.;:!?)]|$)`, "g");
|
|
5980
|
+
function tricolons(sentence) {
|
|
5981
|
+
const s = sentence.replace(/\s+/g, " ");
|
|
5982
|
+
const out = [];
|
|
5983
|
+
for (const m of s.matchAll(TRICOLON)) {
|
|
5984
|
+
const [, aSpan, b, , c] = m;
|
|
5985
|
+
const bWords = words(b);
|
|
5986
|
+
const cWords = words(c);
|
|
5987
|
+
if (bWords.length === 0 || cWords.length === 0 || cWords.length > 6) continue;
|
|
5988
|
+
const aWords = words(aSpan);
|
|
5989
|
+
const a = aWords.slice(-Math.max(1, bWords.length));
|
|
5990
|
+
const lead = lower(bWords[0]);
|
|
5991
|
+
const parallel = bWords.length >= 2 && FUNCTION_WORDS.has(lead) && (lower(cWords[0]) === lead || lower(a[0]) === lead);
|
|
5992
|
+
out.push({ text: m[0], parallel });
|
|
5993
|
+
}
|
|
5994
|
+
return out;
|
|
5995
|
+
}
|
|
5996
|
+
var STACKED = /,\s+(?:and|but|so|or)\s+(?:if|when|where|once|unless|provided)\b[^,.;!?]{1,60},/i;
|
|
5997
|
+
function stackedConditional(sentence) {
|
|
5998
|
+
return STACKED.test(sentence);
|
|
5999
|
+
}
|
|
6000
|
+
var SUBORDINATOR = /\b(?:if|when|which|because|so|while|where)\b/gi;
|
|
6001
|
+
function clauseDepth(sentence) {
|
|
6002
|
+
return (sentence.match(/,/g) ?? []).length + (sentence.match(SUBORDINATOR) ?? []).length;
|
|
6003
|
+
}
|
|
6004
|
+
var HEDGES = [
|
|
6005
|
+
"if it's a fit",
|
|
6006
|
+
"if it is a fit",
|
|
6007
|
+
"where needed",
|
|
6008
|
+
"if needed",
|
|
6009
|
+
"as needed",
|
|
6010
|
+
"where appropriate",
|
|
6011
|
+
"if appropriate",
|
|
6012
|
+
"as appropriate",
|
|
6013
|
+
"as required",
|
|
6014
|
+
"where required",
|
|
6015
|
+
"if required",
|
|
6016
|
+
"where possible",
|
|
6017
|
+
"if possible",
|
|
6018
|
+
"where relevant",
|
|
6019
|
+
"if relevant",
|
|
6020
|
+
"depending on",
|
|
6021
|
+
"can help",
|
|
6022
|
+
"tend to",
|
|
6023
|
+
"tends to",
|
|
6024
|
+
"may",
|
|
6025
|
+
"might",
|
|
6026
|
+
"could",
|
|
6027
|
+
"typically",
|
|
6028
|
+
"usually",
|
|
6029
|
+
"generally",
|
|
6030
|
+
"often",
|
|
6031
|
+
"potentially",
|
|
6032
|
+
"likely"
|
|
6033
|
+
];
|
|
6034
|
+
var HEDGE = new RegExp(`\\b(?:${HEDGES.map((h) => h.replace(/'/g, "['\u2019]").replace(/ /g, "\\s+")).join("|")})\\b`, "gi");
|
|
6035
|
+
var CONDITIONAL_CLOSE = /\b(?:if|unless|where|provided|depending)\b/i;
|
|
6036
|
+
function hedgeCount(text) {
|
|
6037
|
+
return (text.match(HEDGE) ?? []).length;
|
|
6038
|
+
}
|
|
6039
|
+
function hedgedClose(sentences3) {
|
|
6040
|
+
const last = sentences3[sentences3.length - 1];
|
|
6041
|
+
if (!last) return false;
|
|
6042
|
+
HEDGE.lastIndex = 0;
|
|
6043
|
+
return HEDGE.test(last) || CONDITIONAL_CLOSE.test(last);
|
|
6044
|
+
}
|
|
6045
|
+
var NOMINAL = /^[a-z]{3,}(?:tion|sion|ment|ness|ity|ance|ence)s?$/;
|
|
6046
|
+
var NOT_NOMINAL = new Set(
|
|
6047
|
+
[
|
|
6048
|
+
"nation station ration lotion potion portion question moment cement comment element garment segment fragment apartment document ointment",
|
|
6049
|
+
"city pity entity quality sentence science audience silence balance finance distance instance entrance substance romance province"
|
|
6050
|
+
].join(" ").split(" ").flatMap((w) => [w, `${w}s`])
|
|
6051
|
+
);
|
|
6052
|
+
function nominalisations(text) {
|
|
6053
|
+
return words(text).filter((w) => {
|
|
6054
|
+
const l = lower(w);
|
|
6055
|
+
return NOMINAL.test(l) && !NOT_NOMINAL.has(l);
|
|
6056
|
+
}).length;
|
|
6057
|
+
}
|
|
6058
|
+
var PRONOUN_OPENER = /^["'‘“(]?(?:you|your|we|our)\b/i;
|
|
6059
|
+
var TRANSITION_OPENER = /^["'‘“(]?(?:however|moreover|additionally|furthermore|thus|therefore|consequently|in addition|as a result|ultimately|overall|importantly|notably)\b/i;
|
|
6060
|
+
var IMPERSONAL_OPENER = /^["'‘“(]?(?:it is|it's|it’s|there is|there are|there's|there’s)\b/i;
|
|
6061
|
+
var NOT_PARTICIPLE = new Set(
|
|
6062
|
+
"thing nothing something anything everything bring string spring sting swing king ring sing wing during morning evening ceiling building meeting wedding pudding clothing lightning including".split(" ")
|
|
6063
|
+
);
|
|
6064
|
+
function participialClauses(sentence) {
|
|
6065
|
+
let n = 0;
|
|
6066
|
+
for (const m of sentence.matchAll(/,\s+([A-Za-z]{2,}ing)\b/g)) if (!NOT_PARTICIPLE.has(lower(m[1]))) n += 1;
|
|
6067
|
+
const open = sentence.match(/^["'‘“(]?([A-Z][a-z]{2,}ing)\b[^,]{0,60},/);
|
|
6068
|
+
if (open && !NOT_PARTICIPLE.has(lower(open[1]))) n += 1;
|
|
6069
|
+
return n;
|
|
6070
|
+
}
|
|
6071
|
+
function phrasalCoordinations(sentence) {
|
|
6072
|
+
let n = 0;
|
|
6073
|
+
for (const m of sentence.matchAll(/(^|[^,]\s)([A-Za-z][a-z'’-]{2,}) (?:and|or) ([A-Za-z][a-z'’-]{2,})\b/g)) {
|
|
6074
|
+
const a = lower(m[2]);
|
|
6075
|
+
const b = lower(m[3]);
|
|
6076
|
+
if (FUNCTION_WORDS.has(a) || FUNCTION_WORDS.has(b) || a.endsWith("ly") || b.endsWith("ly")) continue;
|
|
6077
|
+
n += 1;
|
|
6078
|
+
}
|
|
6079
|
+
return n;
|
|
6080
|
+
}
|
|
6081
|
+
function skeleton(sentence) {
|
|
6082
|
+
const out = [];
|
|
6083
|
+
for (const w of words(sentence)) {
|
|
6084
|
+
const l = lower(w);
|
|
6085
|
+
const token = FUNCTION_WORDS.has(l) ? l : "_";
|
|
6086
|
+
if (token === "_" && out[out.length - 1] === "_") continue;
|
|
6087
|
+
out.push(token);
|
|
6088
|
+
}
|
|
6089
|
+
return out;
|
|
6090
|
+
}
|
|
6091
|
+
function skeletonRepeat(sentences3) {
|
|
6092
|
+
const grams2 = /* @__PURE__ */ new Map();
|
|
6093
|
+
let total = 0;
|
|
6094
|
+
for (const s of sentences3) {
|
|
6095
|
+
const sk = skeleton(s);
|
|
6096
|
+
for (let i = 0; i + 4 <= sk.length; i += 1) {
|
|
6097
|
+
const g = sk.slice(i, i + 4).join(" ");
|
|
6098
|
+
grams2.set(g, (grams2.get(g) ?? 0) + 1);
|
|
6099
|
+
total += 1;
|
|
6100
|
+
}
|
|
6101
|
+
}
|
|
6102
|
+
if (total === 0) return 0;
|
|
6103
|
+
let repeated = 0;
|
|
6104
|
+
for (const count4 of grams2.values()) if (count4 > 1) repeated += count4;
|
|
6105
|
+
return repeated / total;
|
|
6106
|
+
}
|
|
6107
|
+
var mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
|
|
6108
|
+
var sd = (xs) => {
|
|
6109
|
+
const m = mean(xs);
|
|
6110
|
+
return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length);
|
|
6111
|
+
};
|
|
6112
|
+
function cv(xs, min) {
|
|
6113
|
+
if (xs.length < min) return null;
|
|
6114
|
+
const m = mean(xs);
|
|
6115
|
+
return m === 0 ? null : sd(xs) / m;
|
|
6116
|
+
}
|
|
6117
|
+
function shapeOf(text) {
|
|
6118
|
+
const ss = splitSentences(text);
|
|
6119
|
+
const wordCount = words(text).length;
|
|
6120
|
+
const per100 = (n) => wordCount === 0 ? 0 : 100 * n / wordCount;
|
|
6121
|
+
const share = (re) => ss.length === 0 ? 0 : ss.filter((s) => re.test(s)).length / ss.length;
|
|
6122
|
+
const tri = ss.flatMap(tricolons);
|
|
6123
|
+
const commas = (text.match(/,/g) ?? []).length;
|
|
6124
|
+
return {
|
|
6125
|
+
sentences: ss.length,
|
|
6126
|
+
words: wordCount,
|
|
6127
|
+
tricolons: tri.length,
|
|
6128
|
+
parallelTriads: tri.filter((t) => t.parallel).length,
|
|
6129
|
+
stackedConditionals: ss.filter(stackedConditional).length,
|
|
6130
|
+
clauseDepth: ss.length === 0 ? 0 : mean(ss.map(clauseDepth)),
|
|
6131
|
+
sentenceCv: cv(ss.map((s) => words(s).length), 3),
|
|
6132
|
+
hedgeRate: per100(hedgeCount(text)),
|
|
6133
|
+
hedgedClose: hedgedClose(ss),
|
|
6134
|
+
nominalisationRate: per100(nominalisations(text)),
|
|
6135
|
+
pronounOpenerShare: share(PRONOUN_OPENER),
|
|
6136
|
+
transitionOpenerShare: share(TRANSITION_OPENER),
|
|
6137
|
+
impersonalOpenerShare: share(IMPERSONAL_OPENER),
|
|
6138
|
+
commasPerSentence: ss.length === 0 ? 0 : commas / ss.length,
|
|
6139
|
+
commasPer100: per100(commas),
|
|
6140
|
+
participialRate: ss.length === 0 ? 0 : ss.reduce((s, x) => s + participialClauses(x), 0) / ss.length,
|
|
6141
|
+
coordinationRate: per100(ss.reduce((s, x) => s + phrasalCoordinations(x), 0)),
|
|
6142
|
+
skeletonRepeat: skeletonRepeat(ss)
|
|
6143
|
+
};
|
|
6144
|
+
}
|
|
6145
|
+
function shapeMoves(f4) {
|
|
6146
|
+
const out = [];
|
|
6147
|
+
if (f4.parallelTriads > 0) out.push("parallel triad");
|
|
6148
|
+
else if (f4.tricolons > 0) out.push("tricolon");
|
|
6149
|
+
if (f4.stackedConditionals > 0) out.push("stacked conditional");
|
|
6150
|
+
if (f4.hedgedClose) out.push("hedged close");
|
|
6151
|
+
if (f4.participialRate > 0) out.push("participial clause");
|
|
6152
|
+
return out;
|
|
6153
|
+
}
|
|
6154
|
+
function paragraphUniformity(paragraphs) {
|
|
6155
|
+
return cv(
|
|
6156
|
+
paragraphs.map((p) => words(p).length).filter((n) => n > 0),
|
|
6157
|
+
4
|
|
6158
|
+
);
|
|
6159
|
+
}
|
|
6160
|
+
function isBlurb(f4) {
|
|
6161
|
+
return f4.sentences >= 2 && f4.sentences <= 8 && f4.words >= 20 && f4.words <= 160;
|
|
6162
|
+
}
|
|
6163
|
+
|
|
5437
6164
|
// src/character/baseline.ts
|
|
5438
6165
|
var BASELINE_VERSION = 1;
|
|
5439
6166
|
function fnv1a(text) {
|
|
@@ -5729,7 +6456,9 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
5729
6456
|
}
|
|
5730
6457
|
|
|
5731
6458
|
// src/direction/types.ts
|
|
5732
|
-
var DIRECTION_VERSION =
|
|
6459
|
+
var DIRECTION_VERSION = 2;
|
|
6460
|
+
var DIRECTION_VERSIONS = [1, 2];
|
|
6461
|
+
var CUSTOMER_KINDS = ["review", "enquiry", "conversation", "search"];
|
|
5733
6462
|
var SOURCE_KINDS = [
|
|
5734
6463
|
"livery",
|
|
5735
6464
|
"shopfront",
|
|
@@ -5743,9 +6472,413 @@ var SOURCE_KINDS = [
|
|
|
5743
6472
|
"trade",
|
|
5744
6473
|
"print",
|
|
5745
6474
|
"photo",
|
|
5746
|
-
"founder"
|
|
6475
|
+
"founder",
|
|
6476
|
+
...CUSTOMER_KINDS,
|
|
6477
|
+
"reference"
|
|
5747
6478
|
];
|
|
5748
6479
|
var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
|
|
6480
|
+
var CONSIDERATION = ["low", "considered", "high"];
|
|
6481
|
+
var PAGE_TYPES = ["home", "service", "about", "pricing", "booking", "contact", "article", "landing"];
|
|
6482
|
+
var HIERARCHY_FIELDS = ["primaryAction", "order", "lead", "journey"];
|
|
6483
|
+
|
|
6484
|
+
// src/direction/reason.ts
|
|
6485
|
+
var PROPOSED = "PROPOSED:";
|
|
6486
|
+
var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
|
|
6487
|
+
var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
|
|
6488
|
+
var CONVENTION = new RegExp(
|
|
6489
|
+
[
|
|
6490
|
+
// authority
|
|
6491
|
+
String.raw`industry[- ]standard`,
|
|
6492
|
+
String.raw`best[- ]practices?`,
|
|
6493
|
+
String.raw`the norm\b`,
|
|
6494
|
+
String.raw`standard (?:layout|structure|practice|pattern|format)`,
|
|
6495
|
+
String.raw`tried[- ]and[- ]tested`,
|
|
6496
|
+
String.raw`proven (?:layout|structure|pattern|format)`,
|
|
6497
|
+
// the crowd
|
|
6498
|
+
String.raw`(?:most|other|typical|many|all|similar)\s+(?:\w+\s+){0,2}(?:web)?sites\b`,
|
|
6499
|
+
String.raw`competitors?(?:'|’)?s?\b`,
|
|
6500
|
+
String.raw`(?:like|as) (?:everyone|everybody|all the others|the rest)\b`,
|
|
6501
|
+
String.raw`commonly used`,
|
|
6502
|
+
String.raw`(?:what|how) (?:everyone|others|the industry) (?:does|do|uses?)`,
|
|
6503
|
+
// familiarity
|
|
6504
|
+
String.raw`familiar (?:layout|structure|pattern|format|order|shape|design)`,
|
|
6505
|
+
String.raw`recogni[sz]able (?:layout|structure|pattern|format|order)`,
|
|
6506
|
+
String.raw`conventional(?:ly)?\b`,
|
|
6507
|
+
String.raw`(?:visitors|users|people|customers|anyone) (?:are|is) used to`,
|
|
6508
|
+
String.raw`feels? (?:at home|comfortable|familiar)(?: with)?(?: the)? (?:layout|site|page|structure|design)?`,
|
|
6509
|
+
String.raw`intuitive(?:ly)?\b`,
|
|
6510
|
+
String.raw`as usual\b`,
|
|
6511
|
+
String.raw`what (?:visitors|users|people) expect\b`
|
|
6512
|
+
].join("|"),
|
|
6513
|
+
"i"
|
|
6514
|
+
);
|
|
6515
|
+
var GENERIC = new Set(
|
|
6516
|
+
"customer customers visitor visitors client clients people person someone business businesses service services website site page pages want wants need needs help first before after know feel time work good right sure make find look looking trust reason reasons thing things just really very able also".split(" ")
|
|
6517
|
+
);
|
|
6518
|
+
var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every", "your", "them", "then", "than", "will", "would", "could", "should", "because", "so", "the", "and"]);
|
|
6519
|
+
var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
|
|
6520
|
+
function contentWords(text) {
|
|
6521
|
+
return new Set(
|
|
6522
|
+
(text.toLowerCase().match(/[a-z][a-z'’-]{3,}/g) ?? []).map((w) => w.replace(/['’]s$/, "").replace(/['’]/g, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w) && !GENERIC.has(w))
|
|
6523
|
+
);
|
|
6524
|
+
}
|
|
6525
|
+
var stem = (w) => w.replace(/(?:ing|ers|er|ed|es|s)$/, "");
|
|
6526
|
+
function shares(a, b) {
|
|
6527
|
+
const bs = new Set([...b].map(stem));
|
|
6528
|
+
return [...a].filter((w) => bs.has(stem(w)));
|
|
6529
|
+
}
|
|
6530
|
+
function jobWords(job) {
|
|
6531
|
+
return contentWords(
|
|
6532
|
+
[
|
|
6533
|
+
job.statement?.verb,
|
|
6534
|
+
job.statement?.object,
|
|
6535
|
+
job.statement?.context,
|
|
6536
|
+
job.functional?.value,
|
|
6537
|
+
job.emotional?.value,
|
|
6538
|
+
job.social?.value,
|
|
6539
|
+
...Array.isArray(job.objections) ? job.objections : [],
|
|
6540
|
+
...Array.isArray(job.language) ? job.language : []
|
|
6541
|
+
].filter((x) => typeof x === "string").join(" ")
|
|
6542
|
+
);
|
|
6543
|
+
}
|
|
6544
|
+
var isCustomerVoice = (s) => Boolean(s && CUSTOMER_KINDS.includes(s.kind));
|
|
6545
|
+
function checkReason(input) {
|
|
6546
|
+
const problems = [];
|
|
6547
|
+
const err = (field, message) => problems.push({ severity: "error", at: `${input.at}.${field}`, message });
|
|
6548
|
+
const because = typeof input.because === "string" ? input.because.trim() : "";
|
|
6549
|
+
const evidence = Array.isArray(input.evidence) ? input.evidence.filter((e) => typeof e === "string") : [];
|
|
6550
|
+
if (input.evidence !== void 0 && (!Array.isArray(input.evidence) || input.evidence.some((e) => typeof e !== "string"))) err("evidence", "evidence must be a list of source ids");
|
|
6551
|
+
const ids = new Map(input.sources.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
|
|
6552
|
+
if (because.split(/\s+/).filter(Boolean).length < 8) {
|
|
6553
|
+
err("because", "a reason is at least a sentence. Without one this is a default, however good it looks.");
|
|
6554
|
+
return problems;
|
|
6555
|
+
}
|
|
6556
|
+
if (because.startsWith(PROPOSED)) {
|
|
6557
|
+
err("because", "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
|
|
6558
|
+
return problems;
|
|
6559
|
+
}
|
|
6560
|
+
if (PREFERENCE.test(because)) err("because", "that is a preference, not a reason. Say what in the client's world this comes from.");
|
|
6561
|
+
const moods = because.match(MOOD) ?? [];
|
|
6562
|
+
if (moods.length >= 2) err("because", `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
|
|
6563
|
+
const convention = because.match(CONVENTION);
|
|
6564
|
+
if (convention) {
|
|
6565
|
+
err("because", `"${convention[0]}" is the category's default speaking, not a reason. Say what in this client's job or world it answers.`);
|
|
6566
|
+
}
|
|
6567
|
+
const needsEvidence = input.tie !== "job";
|
|
6568
|
+
if (needsEvidence && evidence.length === 0) err("evidence", "cite at least one source");
|
|
6569
|
+
for (const id of evidence) if (!ids.has(id)) err("evidence", `no source called "${id}"`);
|
|
6570
|
+
if (input.tie === "customer") {
|
|
6571
|
+
for (const id of evidence) {
|
|
6572
|
+
if (ids.has(id) && !isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is a ${ids.get(id).kind}. The job is evidenced by the customer's own words: ${CUSTOMER_KINDS.join(", ")}.`);
|
|
6573
|
+
}
|
|
6574
|
+
}
|
|
6575
|
+
if (input.tie === "source") {
|
|
6576
|
+
for (const id of evidence) {
|
|
6577
|
+
if (isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is customer voice. Token decisions need a physical source or an outside-category reference.`);
|
|
6578
|
+
}
|
|
6579
|
+
}
|
|
6580
|
+
if (problems.length) return problems;
|
|
6581
|
+
const words3 = contentWords(because);
|
|
6582
|
+
if (input.tie === "job") {
|
|
6583
|
+
if (!input.job) {
|
|
6584
|
+
err("because", "a structural reason is checked against the job, and the file has no job map yet");
|
|
6585
|
+
} else if (shares(words3, jobWords(input.job)).length === 0) {
|
|
6586
|
+
err("because", "the reason does not use this client's job (its statement, objections or the customer's words). A reason that would fit any client's site is a default.");
|
|
6587
|
+
}
|
|
6588
|
+
} else {
|
|
6589
|
+
const cited = evidence.map((id) => ids.get(id));
|
|
6590
|
+
const tied = cited.some((s) => words3.has(s.id) || shares(words3, contentWords(`${s.note} ${s.lettering ?? ""}`)).length > 0);
|
|
6591
|
+
if (!tied) err("because", `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
|
|
6592
|
+
}
|
|
6593
|
+
return problems;
|
|
6594
|
+
}
|
|
6595
|
+
|
|
6596
|
+
// src/direction/job.ts
|
|
6597
|
+
var JOB_VERBS = new Set(
|
|
6598
|
+
"find choose pick book get fix stop avoid know feel understand compare decide keep protect prepare plan sort arrange replace repair learn prove show recover reduce save spend hire check see buy sell move start finish handle manage meet reach return treat look end make give take bring hand leave trust rely feel prove explain secure settle solve restore improve change become stay pass sign agree ask call reassure justify confirm".split(" ")
|
|
6599
|
+
);
|
|
6600
|
+
var LABEL_WORDS = /* @__PURE__ */ new Set(["website", "websites", "site", "sites", "page", "pages", "homepage", "web", "online", "presence", "company", "business", "services", "service", "local", "best", "good", "an", "a", "the", "for", "uk"]);
|
|
6601
|
+
var NOUNISH = /(?:tion|sion|ment|ness|ity|site|page)$/;
|
|
6602
|
+
var words2 = (s) => s.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
|
|
6603
|
+
function isCategoryLabel(object, brief = "") {
|
|
6604
|
+
const trade = /* @__PURE__ */ new Set([...TRADE_NOUNS.flatMap((t) => t.split(" ")), ...words2(brief).filter((w) => w.length > 3)]);
|
|
6605
|
+
const ws = words2(object);
|
|
6606
|
+
return ws.length > 0 && ws.every((w) => LABEL_WORDS.has(w) || trade.has(w) || trade.has(w.replace(/s$/, "")));
|
|
6607
|
+
}
|
|
6608
|
+
var normalise2 = (s) => s.toLowerCase().replace(/[“”"‘’'.,!?;:()-]/g, " ").replace(/\s+/g, " ").trim();
|
|
6609
|
+
function validateJob(job, sources, ctx = {}) {
|
|
6610
|
+
const problems = [];
|
|
6611
|
+
const err = (at, message) => problems.push({ severity: "error", at: `job${at}`, message });
|
|
6612
|
+
const warn = (at, message) => problems.push({ severity: "warn", at: `job${at}`, message });
|
|
6613
|
+
if (!job || typeof job !== "object" || Array.isArray(job)) {
|
|
6614
|
+
err("", "no job map: say what the visitor is trying to get done, in their words, before deciding structure or look");
|
|
6615
|
+
return { problems, decided: false };
|
|
6616
|
+
}
|
|
6617
|
+
const j = job;
|
|
6618
|
+
const ids = new Map(sources.map((s) => [s.id, s]));
|
|
6619
|
+
const st = j.statement;
|
|
6620
|
+
if (!st || typeof st !== "object") err(".statement", "a job statement is verb, object and context");
|
|
6621
|
+
else {
|
|
6622
|
+
const verb = typeof st.verb === "string" ? st.verb.trim().toLowerCase() : "";
|
|
6623
|
+
if (!verb) err(".statement.verb", "what the visitor is trying to do, as a verb: find, choose, book, fix");
|
|
6624
|
+
else if (verb.split(/\s+/).length > 3 || NOUNISH.test(verb) || TRADE_NOUNS.includes(verb)) err(".statement.verb", `"${st.verb}" is not a verb. A job starts with what the visitor is trying to do.`);
|
|
6625
|
+
else if (!JOB_VERBS.has(verb.split(/\s+/)[0])) warn(".statement.verb", `"${st.verb}" is not a verb craft knows. Check it says what the visitor does, not what the site is.`);
|
|
6626
|
+
const object = typeof st.object === "string" ? st.object.trim() : "";
|
|
6627
|
+
if (words2(object).length < 3) err(".statement.object", "the object needs a few words: what they are trying to get, and what makes it hard");
|
|
6628
|
+
else if (isCategoryLabel(object, ctx.brief)) err(".statement.object", `"${object}" names the category, not the job. Say what the visitor needs from it: "an electrician they can trust with a dangerous fault".`);
|
|
6629
|
+
if (typeof st.context !== "string" || words2(st.context).length < 3) err(".statement.context", "the context: when, under what pressure, or what they are trying to avoid");
|
|
6630
|
+
}
|
|
6631
|
+
for (const part of ["functional", "emotional", "social"]) {
|
|
6632
|
+
const p = j[part];
|
|
6633
|
+
if (!p || typeof p !== "object") {
|
|
6634
|
+
err(`.${part}`, `the ${part} side of the job, with the customer-voice source that shows it`);
|
|
6635
|
+
continue;
|
|
6636
|
+
}
|
|
6637
|
+
if (typeof p.value !== "string" || words2(p.value).length < 6) err(`.${part}.value`, "a sentence: what they need, in the terms they would use");
|
|
6638
|
+
else problems.push(...checkReason({ at: `job.${part}`, because: p.value, evidence: p.evidence, sources, tie: "customer" }).map((x) => ({ ...x, at: x.at.replace(/\.because$/, ".value") })));
|
|
6639
|
+
}
|
|
6640
|
+
const c = j.consideration;
|
|
6641
|
+
if (!c || typeof c !== "object") err(".consideration", `how long and how carefully they decide: ${CONSIDERATION.join(", ")}`);
|
|
6642
|
+
else {
|
|
6643
|
+
if (!CONSIDERATION.includes(c.value)) err(".consideration.value", `one of ${CONSIDERATION.join(", ")}`);
|
|
6644
|
+
problems.push(...checkReason({ at: "job.consideration", because: c.because, evidence: c.evidence, sources, tie: "customer" }));
|
|
6645
|
+
}
|
|
6646
|
+
const objections = Array.isArray(j.objections) ? j.objections : [];
|
|
6647
|
+
if (objections.length === 0) err(".objections", "what stops them: the worries they bring to the page");
|
|
6648
|
+
objections.forEach((o, i) => {
|
|
6649
|
+
if (typeof o !== "string" || words2(o).length < 3) err(`.objections[${i}]`, "an objection is a few words: what they worry about");
|
|
6650
|
+
});
|
|
6651
|
+
const evidence = Array.isArray(j.evidence) ? j.evidence : [];
|
|
6652
|
+
const voice = evidence.map((id) => ids.get(id)).filter(isCustomerVoice);
|
|
6653
|
+
if (evidence.length === 0 || voice.length === 0) err(".evidence", "cite the reviews, enquiries or conversations the job comes from");
|
|
6654
|
+
for (const id of evidence) if (!ids.has(id)) err(".evidence", `no source called "${id}"`);
|
|
6655
|
+
const language = Array.isArray(j.language) ? j.language : [];
|
|
6656
|
+
if (language.length === 0) err(".language", "quote the customer: the words they use for the problem");
|
|
6657
|
+
const texts = voice.map((s) => normalise2(`${s.note} ${ctx.readSource?.(s) ?? ""}`));
|
|
6658
|
+
language.forEach((q, i) => {
|
|
6659
|
+
if (typeof q !== "string" || !q.trim()) return err(`.language[${i}]`, "an empty quote");
|
|
6660
|
+
const n = normalise2(q);
|
|
6661
|
+
if (!texts.some((t) => t.includes(n))) warn(`.language[${i}]`, `"${q}" is not in any cited source's note or file. Quote it from the source, or it may be invented.`);
|
|
6662
|
+
});
|
|
6663
|
+
return { problems, decided: problems.every((p) => p.severity !== "error") };
|
|
6664
|
+
}
|
|
6665
|
+
|
|
6666
|
+
// src/direction/hierarchy.ts
|
|
6667
|
+
var SECTION_ROLES = [
|
|
6668
|
+
"hero",
|
|
6669
|
+
"logos",
|
|
6670
|
+
"stats",
|
|
6671
|
+
"cards",
|
|
6672
|
+
"marquee",
|
|
6673
|
+
"text",
|
|
6674
|
+
"other",
|
|
6675
|
+
"cta-band",
|
|
6676
|
+
"footer-cta",
|
|
6677
|
+
"pricing",
|
|
6678
|
+
"testimonials",
|
|
6679
|
+
"faq",
|
|
6680
|
+
"process",
|
|
6681
|
+
"features",
|
|
6682
|
+
"team",
|
|
6683
|
+
"contact"
|
|
6684
|
+
];
|
|
6685
|
+
var POSITIONS = /* @__PURE__ */ new Set([...SECTION_ROLES, "header", "sticky"]);
|
|
6686
|
+
function considerationTied(because, job) {
|
|
6687
|
+
const text = because.toLowerCase();
|
|
6688
|
+
const level = job.consideration?.value;
|
|
6689
|
+
if (level && new RegExp(`\\b${level}\\b`).test(text)) return true;
|
|
6690
|
+
const said = contentWords(because);
|
|
6691
|
+
return (Array.isArray(job.objections) ? job.objections : []).some((o) => typeof o === "string" && [...contentWords(o)].some((w) => said.has(w)));
|
|
6692
|
+
}
|
|
6693
|
+
function validateHierarchy(hierarchy, job, sources) {
|
|
6694
|
+
const problems = [];
|
|
6695
|
+
const err = (at, message) => problems.push({ severity: "error", at: `hierarchy${at}`, message });
|
|
6696
|
+
const pages = [];
|
|
6697
|
+
if (!hierarchy || typeof hierarchy !== "object" || Array.isArray(hierarchy)) {
|
|
6698
|
+
err("", "no hierarchy: decide what the home page leads with, where it asks, and the order, each from the job");
|
|
6699
|
+
return { problems, pages };
|
|
6700
|
+
}
|
|
6701
|
+
const h = hierarchy;
|
|
6702
|
+
for (const key of Object.keys(h)) if (!PAGE_TYPES.includes(key)) err(`.${key}`, `not a page type craft knows. Page types are: ${PAGE_TYPES.join(", ")}`);
|
|
6703
|
+
if (!h.home) err(".home", "no home page hierarchy: the home page is where most visitors start");
|
|
6704
|
+
for (const page of PAGE_TYPES) {
|
|
6705
|
+
const p = h[page];
|
|
6706
|
+
if (!p) continue;
|
|
6707
|
+
let decided = 0;
|
|
6708
|
+
const at = (f4) => `hierarchy.${page}.${f4}`;
|
|
6709
|
+
const reason = (where, because, evidence) => checkReason({ at: where, because, evidence, sources, tie: "job", job });
|
|
6710
|
+
const count4 = (found) => {
|
|
6711
|
+
problems.push(...found);
|
|
6712
|
+
if (!found.some((x) => x.severity === "error")) decided += 1;
|
|
6713
|
+
};
|
|
6714
|
+
if (!p.primaryAction) err(`.${page}.primaryAction`, "not decided yet");
|
|
6715
|
+
else {
|
|
6716
|
+
const a = p.primaryAction;
|
|
6717
|
+
const found = [];
|
|
6718
|
+
if (typeof a.value !== "string" || !a.value.trim()) found.push({ severity: "error", at: `${at("primaryAction")}.value`, message: "what the visitor is asked to do" });
|
|
6719
|
+
const positions = Array.isArray(a.positions) ? a.positions : [];
|
|
6720
|
+
if (positions.length === 0) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: 'where the ask sits: one place, or several ("hero", "footer-cta")' });
|
|
6721
|
+
for (const pos of positions) if (!POSITIONS.has(pos)) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: `"${pos}" is not a section role, "header" or "sticky"` });
|
|
6722
|
+
const r = reason(at("primaryAction"), a.because, a.evidence);
|
|
6723
|
+
found.push(...r);
|
|
6724
|
+
if (r.length === 0 && job && typeof a.because === "string" && !considerationTied(a.because, job)) {
|
|
6725
|
+
found.push({
|
|
6726
|
+
severity: "error",
|
|
6727
|
+
at: `${at("primaryAction")}.because`,
|
|
6728
|
+
message: `say how the job's consideration (${job.consideration?.value ?? "not set"}) or one of its objections leads to asking here. Placement evidence is mixed, so the reason is what decides it.`
|
|
6729
|
+
});
|
|
6730
|
+
}
|
|
6731
|
+
count4(found);
|
|
6732
|
+
}
|
|
6733
|
+
const order = Array.isArray(p.order) ? p.order : [];
|
|
6734
|
+
if (order.length < 2) err(`.${page}.order`, "the running order, two sections or more, each with why it sits there");
|
|
6735
|
+
else {
|
|
6736
|
+
const found = [];
|
|
6737
|
+
order.forEach((o, i) => {
|
|
6738
|
+
if (!o || !SECTION_ROLES.includes(o.role)) found.push({ severity: "error", at: `${at("order")}[${i}].role`, message: `a section role: ${SECTION_ROLES.join(", ")}` });
|
|
6739
|
+
found.push(...reason(`${at("order")}[${i}]`, o?.because));
|
|
6740
|
+
});
|
|
6741
|
+
count4(found);
|
|
6742
|
+
}
|
|
6743
|
+
if (!p.lead) err(`.${page}.lead`, "not decided yet");
|
|
6744
|
+
else {
|
|
6745
|
+
const found = [];
|
|
6746
|
+
if (typeof p.lead.value !== "string" || !p.lead.value.trim()) found.push({ severity: "error", at: `${at("lead")}.value`, message: "what the page leads with" });
|
|
6747
|
+
found.push(...reason(at("lead"), p.lead.because));
|
|
6748
|
+
count4(found);
|
|
6749
|
+
}
|
|
6750
|
+
const journey = Array.isArray(p.journey) ? p.journey : [];
|
|
6751
|
+
if (journey.length < 2) err(`.${page}.journey`, "the stages the visitor goes through, two or more, each with why");
|
|
6752
|
+
else {
|
|
6753
|
+
const found = [];
|
|
6754
|
+
journey.forEach((s, i) => {
|
|
6755
|
+
if (!s || typeof s.stage !== "string" || !s.stage.trim()) found.push({ severity: "error", at: `${at("journey")}[${i}].stage`, message: "name the stage" });
|
|
6756
|
+
found.push(...reason(`${at("journey")}[${i}]`, s?.because));
|
|
6757
|
+
});
|
|
6758
|
+
count4(found);
|
|
6759
|
+
}
|
|
6760
|
+
pages.push({ page, decided, total: HIERARCHY_FIELDS.length });
|
|
6761
|
+
}
|
|
6762
|
+
return { problems, pages };
|
|
6763
|
+
}
|
|
6764
|
+
function renderedOrder(snapshot) {
|
|
6765
|
+
const sections = snapshot.sections ?? [];
|
|
6766
|
+
if (sections.length === 0 || !sections.every((s) => s.role)) return null;
|
|
6767
|
+
return sections.map((s) => s.role).filter((r) => r !== "other");
|
|
6768
|
+
}
|
|
6769
|
+
function checkOrder(declared, page, snapshot, strict = false) {
|
|
6770
|
+
if (!declared || !Array.isArray(declared.order)) return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}`, message: "no declared hierarchy for this page: its running order cannot be checked" }];
|
|
6771
|
+
const rendered = renderedOrder(snapshot);
|
|
6772
|
+
if (!rendered) return [{ severity: "warn", at: `hierarchy.${page}.order`, message: "the snapshot has no section roles (version 1): take a fresh one to check the order" }];
|
|
6773
|
+
const said = declared.order.map((o) => o.role).filter((r) => r !== "other");
|
|
6774
|
+
if (said.join(",") === rendered.join(",")) return [];
|
|
6775
|
+
return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}.order`, message: `the page runs ${rendered.join(", ")}; the file says ${said.join(", ")}` }];
|
|
6776
|
+
}
|
|
6777
|
+
|
|
6778
|
+
// src/direction/research.ts
|
|
6779
|
+
function tradeOf(brief) {
|
|
6780
|
+
const text = brief.toLowerCase();
|
|
6781
|
+
return TRADE_NOUNS.filter((t) => new RegExp(`\\b${t.replace(/ /g, "\\s+")}s?\\b`).test(text));
|
|
6782
|
+
}
|
|
6783
|
+
function categoryMatchesBrief(category, brief) {
|
|
6784
|
+
const known = tradeOf(category);
|
|
6785
|
+
if (tradeOf(brief).some((t) => known.includes(t))) return true;
|
|
6786
|
+
const label = category.trim().replace(/^(?:(?:a|an|the|local|independent)\s+)+/i, "");
|
|
6787
|
+
if (!label) return false;
|
|
6788
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
|
|
6789
|
+
return new RegExp(`\\b${escaped}s?\\b`, "i").test(brief);
|
|
6790
|
+
}
|
|
6791
|
+
function researchPrompt(input) {
|
|
6792
|
+
const trade = tradeOf(input.brief);
|
|
6793
|
+
const category = trade.length ? trade.join(", ") : "this kind of business";
|
|
6794
|
+
const who = input.client ? `${input.client}: ${input.brief}` : input.brief;
|
|
6795
|
+
const lines = [
|
|
6796
|
+
"# Research brief: the customer's job",
|
|
6797
|
+
"",
|
|
6798
|
+
`The business: ${who}`,
|
|
6799
|
+
"",
|
|
6800
|
+
"Find out about the people who hire a business like this, not about the businesses. Answer each question with sources: reviews, forum threads, Q&A sites, complaints, search suggestions. Quote people's own words and link to where they said them.",
|
|
6801
|
+
"",
|
|
6802
|
+
"1. **The situation.** What has just happened, or is about to, when someone starts looking? What pressure are they under: time, money, safety, someone else waiting on them?",
|
|
6803
|
+
'2. **The job.** What are they trying to get done, in one sentence that starts with a verb? What would "done" look like to them?',
|
|
6804
|
+
"3. **Objections.** What do they worry about before getting in touch? What has gone wrong for them, or people they know, before?",
|
|
6805
|
+
"4. **Their words.** The phrases they actually use for the problem and for what they want. Quote them exactly.",
|
|
6806
|
+
"5. **Who else is involved.** Who do they have to explain or justify the choice to (a partner, a landlord, a tenant, a manager), and what would make them look good or bad to that person?",
|
|
6807
|
+
"6. **Consideration.** Do they decide in minutes, days or months? What do they compare before deciding?",
|
|
6808
|
+
"7. **Where they look.** What do they search for, and where do they ask?",
|
|
6809
|
+
"",
|
|
6810
|
+
"## Do not",
|
|
6811
|
+
"",
|
|
6812
|
+
`- Do not describe, list, rank or summarise what ${category} websites look like, or how they are laid out.`,
|
|
6813
|
+
'- Do not suggest colours, fonts, layouts, sections or visual "inspiration" from this category.',
|
|
6814
|
+
"- Do not summarise competitors' marketing. Their claims are the category average; the customer's words are the point.",
|
|
6815
|
+
"- Do not invent quotes. If you cannot find a source for something, say so."
|
|
6816
|
+
];
|
|
6817
|
+
if (input.visual) {
|
|
6818
|
+
lines.push(
|
|
6819
|
+
"",
|
|
6820
|
+
"# Visual references, from outside the category",
|
|
6821
|
+
"",
|
|
6822
|
+
`Separately, and only from outside ${category}: find things this business already resembles or shares a world with. Print, signage, packaging, tools, vehicles, uniforms, places, materials, other trades and crafts. For each, say what it is, where it is from, and what about it could carry over.`,
|
|
6823
|
+
"",
|
|
6824
|
+
`Nothing from a ${category} website, and nothing from a web design gallery.`
|
|
6825
|
+
);
|
|
6826
|
+
}
|
|
6827
|
+
return `${lines.join("\n")}
|
|
6828
|
+
`;
|
|
6829
|
+
}
|
|
6830
|
+
|
|
6831
|
+
// src/estate/direction.ts
|
|
6832
|
+
function summariseDirection(d) {
|
|
6833
|
+
const c = d.choices ?? {};
|
|
6834
|
+
const home = d.hierarchy?.home;
|
|
6835
|
+
const s = {};
|
|
6836
|
+
if (typeof c.accent?.value === "string" && c.accent.value) s.accent = c.accent.value;
|
|
6837
|
+
if (typeof c.ground?.value === "string" && c.ground.value) s.ground = c.ground.value;
|
|
6838
|
+
if (typeof c.display?.value === "string" && c.display.value) s.display = normaliseFamily(c.display.value);
|
|
6839
|
+
if (typeof c.body?.value === "string" && c.body.value) s.body = normaliseFamily(c.body.value);
|
|
6840
|
+
if (typeof c.shape?.value === "string" && c.shape.value) s.shape = c.shape.value;
|
|
6841
|
+
if (Array.isArray(home?.order) && home.order.length) s.order = home.order.filter((o) => o && typeof o.role === "string").map((o) => o.role);
|
|
6842
|
+
if (typeof home?.primaryAction?.value === "string" && home.primaryAction.value) {
|
|
6843
|
+
s.primaryAction = home.primaryAction.value;
|
|
6844
|
+
s.positions = Array.isArray(home.primaryAction.positions) ? [...home.primaryAction.positions] : [];
|
|
6845
|
+
}
|
|
6846
|
+
return s;
|
|
6847
|
+
}
|
|
6848
|
+
var delta = (a, b) => {
|
|
6849
|
+
const x = a ? parseColour(a) : null;
|
|
6850
|
+
const y = b ? parseColour(b) : null;
|
|
6851
|
+
return x && y ? deltaEOk(x.oklch, y.oklch) : null;
|
|
6852
|
+
};
|
|
6853
|
+
var same = (a, b) => Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
|
|
6854
|
+
function compareDirections(a, b) {
|
|
6855
|
+
const shared = [];
|
|
6856
|
+
const accentDelta = delta(a.accent, b.accent);
|
|
6857
|
+
const groundDelta = delta(a.ground, b.ground);
|
|
6858
|
+
if (accentDelta !== null && accentDelta <= ACCENT_SHARED) shared.push(`accent (\u0394E ${accentDelta.toFixed(3)})`);
|
|
6859
|
+
if (groundDelta !== null && groundDelta <= GROUND_SHARED) shared.push(`ground (\u0394E ${groundDelta.toFixed(3)})`);
|
|
6860
|
+
if (a.display && b.display && same(normaliseFamily(a.display), normaliseFamily(b.display))) shared.push(`${normaliseFamily(a.display)} headline`);
|
|
6861
|
+
if (a.body && b.body && same(normaliseFamily(a.body), normaliseFamily(b.body))) shared.push(`${normaliseFamily(a.body)} body`);
|
|
6862
|
+
if (same(a.shape, b.shape)) shared.push(`shape "${a.shape}"`);
|
|
6863
|
+
let orderDistance = null;
|
|
6864
|
+
if (a.order?.length && b.order?.length) {
|
|
6865
|
+
orderDistance = levenshtein(a.order, b.order) / Math.max(a.order.length, b.order.length);
|
|
6866
|
+
if (orderDistance <= SHARED_PART) shared.push(orderDistance === 0 ? "the same section order" : "nearly the same section order");
|
|
6867
|
+
}
|
|
6868
|
+
if (same(a.primaryAction, b.primaryAction) && [...a.positions ?? []].sort().join(",") === [...b.positions ?? []].sort().join(",")) shared.push(`"${a.primaryAction}" asked in the same places`);
|
|
6869
|
+
const surface = shared.filter((s) => /^(?:accent|ground|shape)|headline$/.test(s)).length;
|
|
6870
|
+
const flagged = surface >= 2 || orderDistance !== null && orderDistance <= SHARED_PART;
|
|
6871
|
+
return { shared, accentDelta, groundDelta, orderDistance, flagged };
|
|
6872
|
+
}
|
|
6873
|
+
function compareToDeclared(target2, sites, exclude) {
|
|
6874
|
+
return sites.filter((s) => s.direction && s.id !== exclude).map((s) => ({ id: s.id, ...compareDirections(target2, s.direction) })).sort((x, y) => y.shared.length - x.shared.length || (x.accentDelta ?? 1) - (y.accentDelta ?? 1));
|
|
6875
|
+
}
|
|
6876
|
+
function formatDirectionMatches(label, matches) {
|
|
6877
|
+
const lines = [`${label}: declared directions in the estate (ranked, not judged: no sibling line yet)`];
|
|
6878
|
+
for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${m.flagged ? "LOOK " : " "}${m.shared.length ? `shares ${m.shared.join(", ")}` : "nothing shared"}`);
|
|
6879
|
+
if (matches.length === 0) lines.push(" no site in the register has a declared direction yet: craft estate add --direction <file>");
|
|
6880
|
+
return lines.join("\n");
|
|
6881
|
+
}
|
|
5749
6882
|
|
|
5750
6883
|
// src/direction/licences.ts
|
|
5751
6884
|
var LICENCES = [
|
|
@@ -5859,13 +6992,6 @@ function licencesJson() {
|
|
|
5859
6992
|
}
|
|
5860
6993
|
|
|
5861
6994
|
// src/direction/validate.ts
|
|
5862
|
-
var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|what (?:everyone|competitors) (?:uses?|does)|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
|
|
5863
|
-
var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
|
|
5864
|
-
var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
|
|
5865
|
-
var STOP2 = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
|
|
5866
|
-
function words(text) {
|
|
5867
|
-
return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP2.has(w) && !VALUE_WORDS.has(w)));
|
|
5868
|
-
}
|
|
5869
6995
|
function tellFor(key, value) {
|
|
5870
6996
|
if (key === "accent") {
|
|
5871
6997
|
const c = parseColour(value);
|
|
@@ -5882,44 +7008,58 @@ function tellFor(key, value) {
|
|
|
5882
7008
|
}
|
|
5883
7009
|
return null;
|
|
5884
7010
|
}
|
|
7011
|
+
var MIN_TOKENS = 5;
|
|
7012
|
+
var emptyLayers = () => ({ job: false, hierarchy: [], tokens: { decided: 0, total: CHOICE_KEYS.length }, complete: false });
|
|
5885
7013
|
function validateDirection(input, ctx = {}) {
|
|
5886
7014
|
const problems = [];
|
|
5887
7015
|
const err = (at, message) => problems.push({ severity: "error", at, message });
|
|
5888
7016
|
const warn = (at, message) => problems.push({ severity: "warn", at, message });
|
|
5889
7017
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
5890
7018
|
err("", "art-direction.json must be a JSON object");
|
|
5891
|
-
return { valid: false, problems, decided: 0 };
|
|
7019
|
+
return { valid: false, problems, decided: 0, layers: emptyLayers() };
|
|
5892
7020
|
}
|
|
5893
7021
|
const d = input;
|
|
5894
7022
|
const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
|
|
5895
7023
|
if (legacy) {
|
|
5896
7024
|
warn("", "exceptions only: run craft direction init to record the choices and their reasons");
|
|
5897
|
-
return { valid: true, problems, decided: 0 };
|
|
7025
|
+
return { valid: true, problems, decided: 0, layers: emptyLayers() };
|
|
5898
7026
|
}
|
|
5899
|
-
if (d.version
|
|
7027
|
+
if (!DIRECTION_VERSIONS.includes(d.version)) err("version", `version must be ${DIRECTION_VERSIONS.join(" or ")}`);
|
|
7028
|
+
else if (d.version === 1) warn("version", "version 1: the job map and the hierarchy are not recorded, so the site cannot be decided. Move to version 2 (craft direction init --out) and add them.");
|
|
5900
7029
|
if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
|
|
5901
7030
|
if (typeof d.brief !== "string" || d.brief.trim().split(/\s+/).length < 8) err("brief", "the brief needs a sentence: who, where, and what they do");
|
|
5902
|
-
const
|
|
7031
|
+
const rawSources = Array.isArray(d.sources) ? d.sources : [];
|
|
7032
|
+
const sources = rawSources.filter((s) => Boolean(s && typeof s === "object" && !Array.isArray(s)));
|
|
5903
7033
|
if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
|
|
5904
7034
|
if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
|
|
5905
7035
|
const ids = /* @__PURE__ */ new Set();
|
|
5906
|
-
|
|
7036
|
+
rawSources.forEach((s, i) => {
|
|
5907
7037
|
const at = `sources[${i}]`;
|
|
5908
|
-
if (!s || typeof s !== "object") return err(at, "a source must be an object");
|
|
7038
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) return err(at, "a source must be an object");
|
|
5909
7039
|
if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
|
|
5910
7040
|
else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
|
|
5911
7041
|
else ids.add(s.id);
|
|
5912
7042
|
if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
|
|
5913
7043
|
if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
|
|
5914
|
-
if (s.path
|
|
5915
|
-
|
|
7044
|
+
if (s.path !== void 0 && typeof s.path !== "string") err(`${at}.path`, "a path must be text");
|
|
7045
|
+
if (typeof s.path === "string" && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
|
|
7046
|
+
if (s.colours !== void 0 && !Array.isArray(s.colours)) err(`${at}.colours`, "colours must be a list");
|
|
7047
|
+
for (const [j, c] of (Array.isArray(s.colours) ? s.colours : []).entries()) if (typeof c !== "string" || !parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
|
|
7048
|
+
if (s.kind === "reference") {
|
|
7049
|
+
if (typeof s.category !== "string" || !s.category.trim()) err(`${at}.category`, "say where the reference comes from: print, a place, another trade");
|
|
7050
|
+
else if (/\b(?:web ?sites?|web design|competitors?|dribbble|behance|awwwards|themeforest)\b/i.test(s.category) || categoryMatchesBrief(s.category, typeof d.brief === "string" ? d.brief : "")) {
|
|
7051
|
+
err(`${at}.category`, `"${s.category}" is inside the client's category. A reference must come from outside it, or it hands back the category average.`);
|
|
7052
|
+
}
|
|
7053
|
+
if (typeof s.path === "string" && /^https?:/.test(s.path)) warn(`${at}.path`, "a web page as a reference: check it is not a site in the client's category");
|
|
7054
|
+
}
|
|
7055
|
+
if (/\bcompetitor(?:s|['’]s)?\b/i.test(`${s.note} ${s.category ?? ""}`)) err(at, "a source cannot be a competitor's website; use customer voice or a reference from outside the category");
|
|
5916
7056
|
});
|
|
5917
7057
|
const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
|
|
5918
7058
|
if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
|
|
5919
7059
|
const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
|
|
5920
7060
|
let decided = 0;
|
|
5921
7061
|
for (const key of Object.keys(choices)) {
|
|
5922
|
-
if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}.
|
|
7062
|
+
if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Record section order and the primary action in hierarchy.`);
|
|
5923
7063
|
}
|
|
5924
7064
|
for (const key of CHOICE_KEYS) {
|
|
5925
7065
|
const c = choices[key];
|
|
@@ -5936,44 +7076,9 @@ function validateDirection(input, ctx = {}) {
|
|
|
5936
7076
|
err(`${at}.value`, `"${c.value}" is not a colour`);
|
|
5937
7077
|
ok = false;
|
|
5938
7078
|
}
|
|
5939
|
-
const
|
|
5940
|
-
|
|
5941
|
-
if (
|
|
5942
|
-
err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
|
|
5943
|
-
ok = false;
|
|
5944
|
-
} else if (because.startsWith("PROPOSED:")) {
|
|
5945
|
-
err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
|
|
5946
|
-
ok = false;
|
|
5947
|
-
} else {
|
|
5948
|
-
if (PREFERENCE.test(because)) {
|
|
5949
|
-
err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
|
|
5950
|
-
ok = false;
|
|
5951
|
-
}
|
|
5952
|
-
const moods = because.match(MOOD) ?? [];
|
|
5953
|
-
if (moods.length >= 2) {
|
|
5954
|
-
err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
|
|
5955
|
-
ok = false;
|
|
5956
|
-
}
|
|
5957
|
-
}
|
|
5958
|
-
if (evidence.length === 0) {
|
|
5959
|
-
err(`${at}.evidence`, "cite at least one source");
|
|
5960
|
-
ok = false;
|
|
5961
|
-
}
|
|
5962
|
-
for (const id of evidence) {
|
|
5963
|
-
if (!ids.has(id)) {
|
|
5964
|
-
err(`${at}.evidence`, `no source called "${id}"`);
|
|
5965
|
-
ok = false;
|
|
5966
|
-
}
|
|
5967
|
-
}
|
|
5968
|
-
if (ok) {
|
|
5969
|
-
const cited = sources.filter((s) => evidence.includes(s.id));
|
|
5970
|
-
const reasonWords = words(because);
|
|
5971
|
-
const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
|
|
5972
|
-
if (!tied) {
|
|
5973
|
-
err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
|
|
5974
|
-
ok = false;
|
|
5975
|
-
}
|
|
5976
|
-
}
|
|
7079
|
+
const reasons = checkReason({ at, because: c.because, evidence: c.evidence, sources, tie: "source" });
|
|
7080
|
+
problems.push(...reasons);
|
|
7081
|
+
if (reasons.length) ok = false;
|
|
5977
7082
|
if (typeof c.value === "string") {
|
|
5978
7083
|
const tell = tellFor(key, c.value);
|
|
5979
7084
|
if (tell && !exceptions.some((e) => e.tell === tell)) {
|
|
@@ -6009,7 +7114,33 @@ function validateDirection(input, ctx = {}) {
|
|
|
6009
7114
|
}
|
|
6010
7115
|
if (ok) decided += 1;
|
|
6011
7116
|
}
|
|
6012
|
-
|
|
7117
|
+
let job = false;
|
|
7118
|
+
let hierarchy = [];
|
|
7119
|
+
if (d.version === 2) {
|
|
7120
|
+
const j = validateJob(d.job, sources, { brief: typeof d.brief === "string" ? d.brief : "", readSource: ctx.readSource });
|
|
7121
|
+
problems.push(...j.problems);
|
|
7122
|
+
job = j.decided && d.job !== void 0;
|
|
7123
|
+
const h = validateHierarchy(d.hierarchy, d.job, sources);
|
|
7124
|
+
problems.push(...h.problems);
|
|
7125
|
+
hierarchy = h.pages;
|
|
7126
|
+
if (ctx.snapshot) problems.push(...checkOrder(d.hierarchy?.[ctx.page ?? "home"], ctx.page ?? "home", ctx.snapshot, ctx.strict));
|
|
7127
|
+
}
|
|
7128
|
+
if (ctx.estate?.length) {
|
|
7129
|
+
for (const m of compareToDeclared(summariseDirection(d), ctx.estate, ctx.estateId)) {
|
|
7130
|
+
if (m.flagged) warn("", `close to ${m.id}'s declared direction: shares ${m.shared.join(", ")}. Decide which of these should differ before either site is built.`);
|
|
7131
|
+
}
|
|
7132
|
+
}
|
|
7133
|
+
if (ctx.strict) {
|
|
7134
|
+
for (const p of problems) if (p.severity === "warn" && /the page (?:does not show|sets|runs)/.test(p.message)) p.severity = "error";
|
|
7135
|
+
}
|
|
7136
|
+
const home = hierarchy.find((p) => p.page === "home");
|
|
7137
|
+
const complete = job && Boolean(home) && hierarchy.every((p) => p.decided === p.total) && decided >= MIN_TOKENS;
|
|
7138
|
+
return {
|
|
7139
|
+
valid: problems.every((p) => p.severity !== "error"),
|
|
7140
|
+
problems,
|
|
7141
|
+
decided,
|
|
7142
|
+
layers: { job, hierarchy, tokens: { decided, total: CHOICE_KEYS.length }, complete: complete && problems.every((p) => p.severity !== "error") }
|
|
7143
|
+
};
|
|
6013
7144
|
}
|
|
6014
7145
|
|
|
6015
7146
|
// src/direction/init.ts
|
|
@@ -6028,11 +7159,31 @@ function initDirection(input) {
|
|
|
6028
7159
|
body: empty(fp.body.family),
|
|
6029
7160
|
...fp.roundness !== null ? { shape: empty(fp.roundness >= 0.45 ? "pill buttons" : fp.roundness <= 0.05 ? "square corners" : `corners at ${Math.round(fp.roundness * 100)}% of button height`) } : {}
|
|
6030
7161
|
} : {};
|
|
7162
|
+
const part = () => ({ value: "", evidence: [] });
|
|
6031
7163
|
return {
|
|
6032
7164
|
$schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
|
|
6033
7165
|
version: DIRECTION_VERSION,
|
|
6034
7166
|
client: input.client ?? "",
|
|
6035
7167
|
brief: input.brief ?? "",
|
|
7168
|
+
// Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
|
|
7169
|
+
job: {
|
|
7170
|
+
statement: { verb: "", object: "", context: "" },
|
|
7171
|
+
functional: part(),
|
|
7172
|
+
emotional: part(),
|
|
7173
|
+
social: part(),
|
|
7174
|
+
consideration: { value: "low", because: "", evidence: [] },
|
|
7175
|
+
objections: [],
|
|
7176
|
+
language: [],
|
|
7177
|
+
evidence: []
|
|
7178
|
+
},
|
|
7179
|
+
hierarchy: {
|
|
7180
|
+
home: {
|
|
7181
|
+
primaryAction: { value: "", positions: [], because: "" },
|
|
7182
|
+
order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
|
|
7183
|
+
lead: { value: "", because: "" },
|
|
7184
|
+
journey: []
|
|
7185
|
+
}
|
|
7186
|
+
},
|
|
6036
7187
|
sources: [],
|
|
6037
7188
|
choices,
|
|
6038
7189
|
...input.exceptions?.length ? { exceptions: input.exceptions } : {}
|
|
@@ -6040,7 +7191,6 @@ function initDirection(input) {
|
|
|
6040
7191
|
}
|
|
6041
7192
|
|
|
6042
7193
|
// src/direction/propose.ts
|
|
6043
|
-
var PROPOSED = "PROPOSED:";
|
|
6044
7194
|
function srgbToLinear2(c) {
|
|
6045
7195
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
6046
7196
|
}
|
|
@@ -6102,7 +7252,13 @@ function accentCandidates(input) {
|
|
|
6102
7252
|
const parsed = parseColour(c);
|
|
6103
7253
|
if (!parsed || !chromatic(parsed.oklch)) continue;
|
|
6104
7254
|
const o = parsed.oklch;
|
|
6105
|
-
const siblings =
|
|
7255
|
+
const siblings = [
|
|
7256
|
+
...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
|
|
7257
|
+
...(input.declared ?? []).flatMap((e) => {
|
|
7258
|
+
const p = parseColour(e.accent);
|
|
7259
|
+
return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
|
|
7260
|
+
})
|
|
7261
|
+
];
|
|
6106
7262
|
const nearest2 = siblings.sort((a, b) => a.d - b.d)[0];
|
|
6107
7263
|
const penalty = isAiViolet(o) ? 1 : 0;
|
|
6108
7264
|
out.push({
|
|
@@ -6169,7 +7325,7 @@ function addSite(register, site) {
|
|
|
6169
7325
|
}
|
|
6170
7326
|
function sharedParts(a, b, d = fingerprintDistance(a, b)) {
|
|
6171
7327
|
const out = [];
|
|
6172
|
-
const close = (k) => d.parts[k] <=
|
|
7328
|
+
const close = (k) => d.parts[k] <= SHARED_PART;
|
|
6173
7329
|
if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
|
|
6174
7330
|
if (close("type")) {
|
|
6175
7331
|
if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
|
|
@@ -6184,14 +7340,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
|
|
|
6184
7340
|
}
|
|
6185
7341
|
function compareToEstate(target2, register, options = {}) {
|
|
6186
7342
|
const at = options.siblingAt ?? SIBLING_AT;
|
|
6187
|
-
return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
|
|
7343
|
+
return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
|
|
6188
7344
|
const d = fingerprintDistance(target2, s.fingerprint);
|
|
6189
7345
|
return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
|
|
6190
7346
|
}).sort((a, b) => a.distance - b.distance);
|
|
6191
7347
|
}
|
|
6192
7348
|
function estatePairs(register, siblingAt = SIBLING_AT) {
|
|
6193
7349
|
const out = [];
|
|
6194
|
-
const s = register.sites;
|
|
7350
|
+
const s = register.sites.filter((site) => site.fingerprint);
|
|
6195
7351
|
for (let i = 0; i < s.length; i += 1) {
|
|
6196
7352
|
for (let j = i + 1; j < s.length; j += 1) {
|
|
6197
7353
|
const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
|
|
@@ -6311,8 +7467,16 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
|
6311
7467
|
}
|
|
6312
7468
|
if (input.direction) {
|
|
6313
7469
|
const d = input.direction;
|
|
6314
|
-
const
|
|
6315
|
-
|
|
7470
|
+
const layers = d.layers;
|
|
7471
|
+
const raised2 = !d.valid || d.decided < MIN_DECIDED || !layers.complete;
|
|
7472
|
+
const pages = layers.hierarchy.map((p) => `${p.page} ${p.decided} of ${p.total}`).join(", ");
|
|
7473
|
+
const detail = [
|
|
7474
|
+
`job ${layers.job ? "decided" : "not decided"}`,
|
|
7475
|
+
`hierarchy ${pages || "not decided"}`,
|
|
7476
|
+
`${d.decided} of 7 choices decided with a reason`
|
|
7477
|
+
].join("; ");
|
|
7478
|
+
signals.push({ id: "reasons", raised: raised2, detail: `${detail}${d.valid ? "" : ", art-direction.json has errors"}` });
|
|
7479
|
+
if (!layers.job) actions.push({ area: "direction", what: "Write the job map: what the visitor is trying to get done, from reviews, enquiries and conversations. Run craft direction research for the questions.", why: "structure and look are decided from the job", priority: 1 });
|
|
6316
7480
|
for (const p of d.problems) {
|
|
6317
7481
|
const key = p.at.match(/^choices\.(\w+)/)?.[1];
|
|
6318
7482
|
if (p.severity === "error" || p.message === "not decided yet") {
|
|
@@ -6330,11 +7494,11 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
|
6330
7494
|
const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
|
|
6331
7495
|
const merged = [];
|
|
6332
7496
|
for (const a of actions) {
|
|
6333
|
-
const
|
|
6334
|
-
if (
|
|
7497
|
+
const same2 = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
|
|
7498
|
+
if (same2) {
|
|
6335
7499
|
const fromTell = /\(\S+, gen \d\)/.test(a.why);
|
|
6336
|
-
|
|
6337
|
-
|
|
7500
|
+
same2.why = `${same2.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
|
|
7501
|
+
same2.priority = Math.min(same2.priority, a.priority);
|
|
6338
7502
|
} else merged.push({ ...a });
|
|
6339
7503
|
}
|
|
6340
7504
|
const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
|
|
@@ -6411,6 +7575,8 @@ function retrofitPlan(report2, direction) {
|
|
|
6411
7575
|
return lines.join("\n");
|
|
6412
7576
|
}
|
|
6413
7577
|
export {
|
|
7578
|
+
ACCENT_SCALE,
|
|
7579
|
+
ACCENT_SHARED,
|
|
6414
7580
|
AI_PHRASES,
|
|
6415
7581
|
AI_SOURCE_TYPES,
|
|
6416
7582
|
AI_VIOLET,
|
|
@@ -6421,10 +7587,14 @@ export {
|
|
|
6421
7587
|
CATALOGUE_VERSION,
|
|
6422
7588
|
CHOICE_KEYS,
|
|
6423
7589
|
COMPONENT_ROLES,
|
|
7590
|
+
CONSIDERATION,
|
|
7591
|
+
CONVENTION,
|
|
7592
|
+
CUSTOMER_KINDS,
|
|
6424
7593
|
DEFAULT_IGNORE,
|
|
6425
7594
|
DENSITIES,
|
|
6426
7595
|
DENSITY_NAMES,
|
|
6427
7596
|
DIRECTION_VERSION,
|
|
7597
|
+
DIRECTION_VERSIONS,
|
|
6428
7598
|
DISTANCE_WEIGHTS,
|
|
6429
7599
|
DURATION_MS,
|
|
6430
7600
|
DURATION_S,
|
|
@@ -6434,6 +7604,12 @@ export {
|
|
|
6434
7604
|
ESTATE_VERSION,
|
|
6435
7605
|
EXIT_RATIO,
|
|
6436
7606
|
FINGERPRINT_VERSION,
|
|
7607
|
+
FUNCTION_WORDS,
|
|
7608
|
+
GROUND_SCALE,
|
|
7609
|
+
GROUND_SHARED,
|
|
7610
|
+
HIERARCHY_FIELDS,
|
|
7611
|
+
HOME_FILE,
|
|
7612
|
+
HOME_URL,
|
|
6437
7613
|
HOUSE,
|
|
6438
7614
|
HOUSE_BUDGET,
|
|
6439
7615
|
HOUSE_LOCKED,
|
|
@@ -6444,11 +7620,15 @@ export {
|
|
|
6444
7620
|
LIGHTNESS_CURVE,
|
|
6445
7621
|
MIN_DECIDED,
|
|
6446
7622
|
MIN_RUNS,
|
|
7623
|
+
MOOD,
|
|
6447
7624
|
NEGATIVE_REASSURANCE,
|
|
6448
7625
|
NULL_VERSION,
|
|
7626
|
+
PAGE_TYPES,
|
|
6449
7627
|
PILL_LIMIT,
|
|
6450
7628
|
PLAINER_WORDS,
|
|
7629
|
+
PREFERENCE,
|
|
6451
7630
|
PROMO_BADGE,
|
|
7631
|
+
PROOF_NAMES,
|
|
6452
7632
|
PROPOSED,
|
|
6453
7633
|
PROVENANCE_SCAN_BYTES,
|
|
6454
7634
|
RAMP_STEPS,
|
|
@@ -6460,17 +7640,24 @@ export {
|
|
|
6460
7640
|
REVEAL_LIMIT,
|
|
6461
7641
|
REVIEW_PHRASES,
|
|
6462
7642
|
SCALE,
|
|
7643
|
+
SECTION_ROLES,
|
|
6463
7644
|
SHADCN_LIMIT,
|
|
6464
7645
|
SHADCN_PRIMITIVES,
|
|
7646
|
+
SHARED_PART,
|
|
6465
7647
|
SIBLING_AT,
|
|
6466
7648
|
SNAPSHOT_VERSION,
|
|
6467
7649
|
SOURCE_KINDS,
|
|
6468
7650
|
SPACE_STEPS,
|
|
7651
|
+
SPECIFICITY_COPY_TELLS,
|
|
7652
|
+
SPECIFICITY_RENDERED_TELLS,
|
|
6469
7653
|
SPRING,
|
|
7654
|
+
STANDING_CLAIM,
|
|
6470
7655
|
STOCK_AVATAR_HOSTS,
|
|
7656
|
+
STOCK_FIGURES,
|
|
6471
7657
|
STOCK_PHOTO_HOSTS,
|
|
6472
7658
|
STOCK_PHRASES,
|
|
6473
7659
|
TELL_HEAVY,
|
|
7660
|
+
TRADE_NOUNS,
|
|
6474
7661
|
TYPE_STEPS,
|
|
6475
7662
|
TYPICAL_AT,
|
|
6476
7663
|
VAGUE_WORDS,
|
|
@@ -6481,22 +7668,32 @@ export {
|
|
|
6481
7668
|
applyHouseGate,
|
|
6482
7669
|
applySeverity,
|
|
6483
7670
|
auditSnapshot,
|
|
7671
|
+
briefTerms,
|
|
6484
7672
|
catalogueTable,
|
|
6485
7673
|
characterReport,
|
|
6486
7674
|
checkCopy,
|
|
7675
|
+
checkOrder,
|
|
6487
7676
|
checkPair,
|
|
7677
|
+
checkReason,
|
|
6488
7678
|
checkRestraint,
|
|
6489
7679
|
choicesOf,
|
|
7680
|
+
clauseDepth,
|
|
6490
7681
|
closestComponents,
|
|
7682
|
+
compareCompetitor,
|
|
7683
|
+
compareDirections,
|
|
6491
7684
|
compareFacts,
|
|
7685
|
+
compareToDeclared,
|
|
6492
7686
|
compareToEstate,
|
|
6493
7687
|
componentDistance,
|
|
6494
7688
|
componentShared,
|
|
6495
7689
|
componentTypicalities,
|
|
6496
7690
|
componentTypicality,
|
|
6497
7691
|
componentsOf,
|
|
7692
|
+
contentWords,
|
|
7693
|
+
countWords2 as countWords,
|
|
6498
7694
|
craftTokens,
|
|
6499
7695
|
createBaseline,
|
|
7696
|
+
cv,
|
|
6500
7697
|
deltaEOk,
|
|
6501
7698
|
deltaEOkHex,
|
|
6502
7699
|
densityCss,
|
|
@@ -6519,11 +7716,15 @@ export {
|
|
|
6519
7716
|
fontClass,
|
|
6520
7717
|
formatClaims,
|
|
6521
7718
|
formatComparison,
|
|
7719
|
+
formatCompetitor,
|
|
7720
|
+
formatDirectionMatches,
|
|
6522
7721
|
formatHex,
|
|
6523
7722
|
formatReport,
|
|
6524
7723
|
globToRegExp,
|
|
6525
7724
|
groundName,
|
|
6526
7725
|
harvest,
|
|
7726
|
+
hedgeCount,
|
|
7727
|
+
hedgedClose,
|
|
6527
7728
|
hexToOklch,
|
|
6528
7729
|
holdHouseBlocks,
|
|
6529
7730
|
houseRule,
|
|
@@ -6534,49 +7735,75 @@ export {
|
|
|
6534
7735
|
inSrgbGamut,
|
|
6535
7736
|
initDirection,
|
|
6536
7737
|
isAiViolet,
|
|
7738
|
+
isBlurb,
|
|
7739
|
+
isCategoryLabel,
|
|
6537
7740
|
isCream,
|
|
7741
|
+
jobWords,
|
|
6538
7742
|
layoutDistance,
|
|
6539
7743
|
licencesJson,
|
|
6540
7744
|
makeSnapshot,
|
|
6541
7745
|
motionTokens,
|
|
6542
7746
|
namedSources,
|
|
6543
7747
|
nearest,
|
|
7748
|
+
nominalisations,
|
|
6544
7749
|
normaliseFamily,
|
|
6545
7750
|
nullPrompt,
|
|
6546
7751
|
oklchToHex,
|
|
6547
7752
|
oklchToRgb,
|
|
7753
|
+
pageCopy,
|
|
6548
7754
|
paletteFromPixels,
|
|
7755
|
+
paragraphUniformity,
|
|
6549
7756
|
parseBaseline,
|
|
6550
7757
|
parseColour,
|
|
6551
7758
|
parseCraftConfig,
|
|
6552
7759
|
parseHex,
|
|
7760
|
+
participialClauses,
|
|
7761
|
+
phrasalCoordinations,
|
|
6553
7762
|
propose,
|
|
6554
7763
|
protectedFacts,
|
|
6555
7764
|
ramp,
|
|
6556
7765
|
rampFromAnchors,
|
|
6557
7766
|
readSnapshot,
|
|
7767
|
+
renderedOrder,
|
|
7768
|
+
researchPrompt,
|
|
6558
7769
|
retrofitPlan,
|
|
6559
7770
|
runTell,
|
|
6560
7771
|
scanProvenance,
|
|
6561
7772
|
scanSource,
|
|
6562
7773
|
sectionRhythm,
|
|
6563
7774
|
semantic,
|
|
7775
|
+
sentencesOf,
|
|
6564
7776
|
severityExceptions,
|
|
7777
|
+
shapeMoves,
|
|
6565
7778
|
shapeName,
|
|
7779
|
+
shapeOf,
|
|
7780
|
+
words as shapeWords,
|
|
6566
7781
|
sharedParts,
|
|
6567
7782
|
shouldAnimate,
|
|
6568
7783
|
siblingLine,
|
|
7784
|
+
skeleton,
|
|
7785
|
+
skeletonRepeat,
|
|
7786
|
+
specificity,
|
|
7787
|
+
specifics,
|
|
7788
|
+
splitSentences,
|
|
7789
|
+
stackedConditional,
|
|
6569
7790
|
stockAvatarSource,
|
|
6570
7791
|
stockSource,
|
|
7792
|
+
summariseDirection,
|
|
6571
7793
|
tailwindV3Preset,
|
|
6572
7794
|
tailwindV4Theme,
|
|
6573
7795
|
tellById,
|
|
6574
7796
|
toGamut,
|
|
6575
7797
|
toJson,
|
|
6576
7798
|
toSarif,
|
|
7799
|
+
tradeOf,
|
|
7800
|
+
tricolons,
|
|
6577
7801
|
typeFeatureTokens,
|
|
6578
7802
|
typicality,
|
|
7803
|
+
unprovenClaims,
|
|
6579
7804
|
validateDirection,
|
|
7805
|
+
validateHierarchy,
|
|
7806
|
+
validateJob,
|
|
6580
7807
|
visibleText,
|
|
6581
7808
|
visualMeasures,
|
|
6582
7809
|
wcagContrast,
|