@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.cjs
CHANGED
|
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
ACCENT_SCALE: () => ACCENT_SCALE,
|
|
24
|
+
ACCENT_SHARED: () => ACCENT_SHARED,
|
|
23
25
|
AI_PHRASES: () => AI_PHRASES,
|
|
24
26
|
AI_SOURCE_TYPES: () => AI_SOURCE_TYPES,
|
|
25
27
|
AI_VIOLET: () => AI_VIOLET,
|
|
@@ -30,10 +32,14 @@ __export(index_exports, {
|
|
|
30
32
|
CATALOGUE_VERSION: () => CATALOGUE_VERSION,
|
|
31
33
|
CHOICE_KEYS: () => CHOICE_KEYS,
|
|
32
34
|
COMPONENT_ROLES: () => COMPONENT_ROLES,
|
|
35
|
+
CONSIDERATION: () => CONSIDERATION,
|
|
36
|
+
CONVENTION: () => CONVENTION,
|
|
37
|
+
CUSTOMER_KINDS: () => CUSTOMER_KINDS,
|
|
33
38
|
DEFAULT_IGNORE: () => DEFAULT_IGNORE,
|
|
34
39
|
DENSITIES: () => DENSITIES,
|
|
35
40
|
DENSITY_NAMES: () => DENSITY_NAMES,
|
|
36
41
|
DIRECTION_VERSION: () => DIRECTION_VERSION,
|
|
42
|
+
DIRECTION_VERSIONS: () => DIRECTION_VERSIONS,
|
|
37
43
|
DISTANCE_WEIGHTS: () => DISTANCE_WEIGHTS,
|
|
38
44
|
DURATION_MS: () => DURATION_MS,
|
|
39
45
|
DURATION_S: () => DURATION_S,
|
|
@@ -43,6 +49,12 @@ __export(index_exports, {
|
|
|
43
49
|
ESTATE_VERSION: () => ESTATE_VERSION,
|
|
44
50
|
EXIT_RATIO: () => EXIT_RATIO,
|
|
45
51
|
FINGERPRINT_VERSION: () => FINGERPRINT_VERSION,
|
|
52
|
+
FUNCTION_WORDS: () => FUNCTION_WORDS,
|
|
53
|
+
GROUND_SCALE: () => GROUND_SCALE,
|
|
54
|
+
GROUND_SHARED: () => GROUND_SHARED,
|
|
55
|
+
HIERARCHY_FIELDS: () => HIERARCHY_FIELDS,
|
|
56
|
+
HOME_FILE: () => HOME_FILE,
|
|
57
|
+
HOME_URL: () => HOME_URL,
|
|
46
58
|
HOUSE: () => HOUSE,
|
|
47
59
|
HOUSE_BUDGET: () => HOUSE_BUDGET,
|
|
48
60
|
HOUSE_LOCKED: () => HOUSE_LOCKED,
|
|
@@ -53,11 +65,15 @@ __export(index_exports, {
|
|
|
53
65
|
LIGHTNESS_CURVE: () => LIGHTNESS_CURVE,
|
|
54
66
|
MIN_DECIDED: () => MIN_DECIDED,
|
|
55
67
|
MIN_RUNS: () => MIN_RUNS,
|
|
68
|
+
MOOD: () => MOOD,
|
|
56
69
|
NEGATIVE_REASSURANCE: () => NEGATIVE_REASSURANCE,
|
|
57
70
|
NULL_VERSION: () => NULL_VERSION,
|
|
71
|
+
PAGE_TYPES: () => PAGE_TYPES,
|
|
58
72
|
PILL_LIMIT: () => PILL_LIMIT,
|
|
59
73
|
PLAINER_WORDS: () => PLAINER_WORDS,
|
|
74
|
+
PREFERENCE: () => PREFERENCE,
|
|
60
75
|
PROMO_BADGE: () => PROMO_BADGE,
|
|
76
|
+
PROOF_NAMES: () => PROOF_NAMES,
|
|
61
77
|
PROPOSED: () => PROPOSED,
|
|
62
78
|
PROVENANCE_SCAN_BYTES: () => PROVENANCE_SCAN_BYTES,
|
|
63
79
|
RAMP_STEPS: () => RAMP_STEPS,
|
|
@@ -69,17 +85,24 @@ __export(index_exports, {
|
|
|
69
85
|
REVEAL_LIMIT: () => REVEAL_LIMIT,
|
|
70
86
|
REVIEW_PHRASES: () => REVIEW_PHRASES,
|
|
71
87
|
SCALE: () => SCALE,
|
|
88
|
+
SECTION_ROLES: () => SECTION_ROLES,
|
|
72
89
|
SHADCN_LIMIT: () => SHADCN_LIMIT,
|
|
73
90
|
SHADCN_PRIMITIVES: () => SHADCN_PRIMITIVES,
|
|
91
|
+
SHARED_PART: () => SHARED_PART,
|
|
74
92
|
SIBLING_AT: () => SIBLING_AT,
|
|
75
93
|
SNAPSHOT_VERSION: () => SNAPSHOT_VERSION,
|
|
76
94
|
SOURCE_KINDS: () => SOURCE_KINDS,
|
|
77
95
|
SPACE_STEPS: () => SPACE_STEPS,
|
|
96
|
+
SPECIFICITY_COPY_TELLS: () => SPECIFICITY_COPY_TELLS,
|
|
97
|
+
SPECIFICITY_RENDERED_TELLS: () => SPECIFICITY_RENDERED_TELLS,
|
|
78
98
|
SPRING: () => SPRING,
|
|
99
|
+
STANDING_CLAIM: () => STANDING_CLAIM,
|
|
79
100
|
STOCK_AVATAR_HOSTS: () => STOCK_AVATAR_HOSTS,
|
|
101
|
+
STOCK_FIGURES: () => STOCK_FIGURES,
|
|
80
102
|
STOCK_PHOTO_HOSTS: () => STOCK_PHOTO_HOSTS,
|
|
81
103
|
STOCK_PHRASES: () => STOCK_PHRASES,
|
|
82
104
|
TELL_HEAVY: () => TELL_HEAVY,
|
|
105
|
+
TRADE_NOUNS: () => TRADE_NOUNS,
|
|
83
106
|
TYPE_STEPS: () => TYPE_STEPS,
|
|
84
107
|
TYPICAL_AT: () => TYPICAL_AT,
|
|
85
108
|
VAGUE_WORDS: () => VAGUE_WORDS,
|
|
@@ -90,22 +113,32 @@ __export(index_exports, {
|
|
|
90
113
|
applyHouseGate: () => applyHouseGate,
|
|
91
114
|
applySeverity: () => applySeverity,
|
|
92
115
|
auditSnapshot: () => auditSnapshot,
|
|
116
|
+
briefTerms: () => briefTerms,
|
|
93
117
|
catalogueTable: () => catalogueTable,
|
|
94
118
|
characterReport: () => characterReport,
|
|
95
119
|
checkCopy: () => checkCopy,
|
|
120
|
+
checkOrder: () => checkOrder,
|
|
96
121
|
checkPair: () => checkPair,
|
|
122
|
+
checkReason: () => checkReason,
|
|
97
123
|
checkRestraint: () => checkRestraint,
|
|
98
124
|
choicesOf: () => choicesOf,
|
|
125
|
+
clauseDepth: () => clauseDepth,
|
|
99
126
|
closestComponents: () => closestComponents,
|
|
127
|
+
compareCompetitor: () => compareCompetitor,
|
|
128
|
+
compareDirections: () => compareDirections,
|
|
100
129
|
compareFacts: () => compareFacts,
|
|
130
|
+
compareToDeclared: () => compareToDeclared,
|
|
101
131
|
compareToEstate: () => compareToEstate,
|
|
102
132
|
componentDistance: () => componentDistance,
|
|
103
133
|
componentShared: () => componentShared,
|
|
104
134
|
componentTypicalities: () => componentTypicalities,
|
|
105
135
|
componentTypicality: () => componentTypicality,
|
|
106
136
|
componentsOf: () => componentsOf,
|
|
137
|
+
contentWords: () => contentWords,
|
|
138
|
+
countWords: () => countWords2,
|
|
107
139
|
craftTokens: () => craftTokens,
|
|
108
140
|
createBaseline: () => createBaseline,
|
|
141
|
+
cv: () => cv,
|
|
109
142
|
deltaEOk: () => deltaEOk,
|
|
110
143
|
deltaEOkHex: () => deltaEOkHex,
|
|
111
144
|
densityCss: () => densityCss,
|
|
@@ -128,11 +161,15 @@ __export(index_exports, {
|
|
|
128
161
|
fontClass: () => fontClass,
|
|
129
162
|
formatClaims: () => formatClaims,
|
|
130
163
|
formatComparison: () => formatComparison,
|
|
164
|
+
formatCompetitor: () => formatCompetitor,
|
|
165
|
+
formatDirectionMatches: () => formatDirectionMatches,
|
|
131
166
|
formatHex: () => formatHex,
|
|
132
167
|
formatReport: () => formatReport,
|
|
133
168
|
globToRegExp: () => globToRegExp,
|
|
134
169
|
groundName: () => groundName,
|
|
135
170
|
harvest: () => harvest,
|
|
171
|
+
hedgeCount: () => hedgeCount,
|
|
172
|
+
hedgedClose: () => hedgedClose,
|
|
136
173
|
hexToOklch: () => hexToOklch,
|
|
137
174
|
holdHouseBlocks: () => holdHouseBlocks,
|
|
138
175
|
houseRule: () => houseRule,
|
|
@@ -143,49 +180,75 @@ __export(index_exports, {
|
|
|
143
180
|
inSrgbGamut: () => inSrgbGamut,
|
|
144
181
|
initDirection: () => initDirection,
|
|
145
182
|
isAiViolet: () => isAiViolet,
|
|
183
|
+
isBlurb: () => isBlurb,
|
|
184
|
+
isCategoryLabel: () => isCategoryLabel,
|
|
146
185
|
isCream: () => isCream,
|
|
186
|
+
jobWords: () => jobWords,
|
|
147
187
|
layoutDistance: () => layoutDistance,
|
|
148
188
|
licencesJson: () => licencesJson,
|
|
149
189
|
makeSnapshot: () => makeSnapshot,
|
|
150
190
|
motionTokens: () => motionTokens,
|
|
151
191
|
namedSources: () => namedSources,
|
|
152
192
|
nearest: () => nearest,
|
|
193
|
+
nominalisations: () => nominalisations,
|
|
153
194
|
normaliseFamily: () => normaliseFamily,
|
|
154
195
|
nullPrompt: () => nullPrompt,
|
|
155
196
|
oklchToHex: () => oklchToHex,
|
|
156
197
|
oklchToRgb: () => oklchToRgb,
|
|
198
|
+
pageCopy: () => pageCopy,
|
|
157
199
|
paletteFromPixels: () => paletteFromPixels,
|
|
200
|
+
paragraphUniformity: () => paragraphUniformity,
|
|
158
201
|
parseBaseline: () => parseBaseline,
|
|
159
202
|
parseColour: () => parseColour,
|
|
160
203
|
parseCraftConfig: () => parseCraftConfig,
|
|
161
204
|
parseHex: () => parseHex,
|
|
205
|
+
participialClauses: () => participialClauses,
|
|
206
|
+
phrasalCoordinations: () => phrasalCoordinations,
|
|
162
207
|
propose: () => propose,
|
|
163
208
|
protectedFacts: () => protectedFacts,
|
|
164
209
|
ramp: () => ramp,
|
|
165
210
|
rampFromAnchors: () => rampFromAnchors,
|
|
166
211
|
readSnapshot: () => readSnapshot,
|
|
212
|
+
renderedOrder: () => renderedOrder,
|
|
213
|
+
researchPrompt: () => researchPrompt,
|
|
167
214
|
retrofitPlan: () => retrofitPlan,
|
|
168
215
|
runTell: () => runTell,
|
|
169
216
|
scanProvenance: () => scanProvenance,
|
|
170
217
|
scanSource: () => scanSource,
|
|
171
218
|
sectionRhythm: () => sectionRhythm,
|
|
172
219
|
semantic: () => semantic,
|
|
220
|
+
sentencesOf: () => sentencesOf,
|
|
173
221
|
severityExceptions: () => severityExceptions,
|
|
222
|
+
shapeMoves: () => shapeMoves,
|
|
174
223
|
shapeName: () => shapeName,
|
|
224
|
+
shapeOf: () => shapeOf,
|
|
225
|
+
shapeWords: () => words,
|
|
175
226
|
sharedParts: () => sharedParts,
|
|
176
227
|
shouldAnimate: () => shouldAnimate,
|
|
177
228
|
siblingLine: () => siblingLine,
|
|
229
|
+
skeleton: () => skeleton,
|
|
230
|
+
skeletonRepeat: () => skeletonRepeat,
|
|
231
|
+
specificity: () => specificity,
|
|
232
|
+
specifics: () => specifics,
|
|
233
|
+
splitSentences: () => splitSentences,
|
|
234
|
+
stackedConditional: () => stackedConditional,
|
|
178
235
|
stockAvatarSource: () => stockAvatarSource,
|
|
179
236
|
stockSource: () => stockSource,
|
|
237
|
+
summariseDirection: () => summariseDirection,
|
|
180
238
|
tailwindV3Preset: () => tailwindV3Preset,
|
|
181
239
|
tailwindV4Theme: () => tailwindV4Theme,
|
|
182
240
|
tellById: () => tellById,
|
|
183
241
|
toGamut: () => toGamut,
|
|
184
242
|
toJson: () => toJson,
|
|
185
243
|
toSarif: () => toSarif,
|
|
244
|
+
tradeOf: () => tradeOf,
|
|
245
|
+
tricolons: () => tricolons,
|
|
186
246
|
typeFeatureTokens: () => typeFeatureTokens,
|
|
187
247
|
typicality: () => typicality,
|
|
248
|
+
unprovenClaims: () => unprovenClaims,
|
|
188
249
|
validateDirection: () => validateDirection,
|
|
250
|
+
validateHierarchy: () => validateHierarchy,
|
|
251
|
+
validateJob: () => validateJob,
|
|
189
252
|
visibleText: () => visibleText,
|
|
190
253
|
visualMeasures: () => visualMeasures,
|
|
191
254
|
wcagContrast: () => wcagContrast,
|
|
@@ -685,9 +748,9 @@ function fluidClamp(minPx, maxPx, minViewport, maxViewport, rootPx = 16) {
|
|
|
685
748
|
const interceptRem = round((minPx - slope * minViewport) / rootPx);
|
|
686
749
|
const slopeVw = round(slope * 100);
|
|
687
750
|
const preferred = interceptRem === 0 ? `${slopeVw}vw` : `${interceptRem}rem + ${slopeVw}vw`;
|
|
688
|
-
const
|
|
751
|
+
const lower2 = Math.min(minRem, maxRem);
|
|
689
752
|
const upper = Math.max(minRem, maxRem);
|
|
690
|
-
return `clamp(${
|
|
753
|
+
return `clamp(${lower2}rem, ${preferred}, ${upper}rem)`;
|
|
691
754
|
}
|
|
692
755
|
function fluidType(options = {}) {
|
|
693
756
|
const {
|
|
@@ -1481,7 +1544,7 @@ function phrases(patterns, label, source = (ctx) => ctx.blocks, skip = () => fal
|
|
|
1481
1544
|
};
|
|
1482
1545
|
}
|
|
1483
1546
|
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/'/g, "['\u2019]?").replace(/ /g, "\\s+");
|
|
1484
|
-
var wordList = (
|
|
1547
|
+
var wordList = (words3) => new RegExp(`\\b(?:${[...words3].sort((a, b) => b.length - a.length).map(escape).join("|")})\\b`, "i");
|
|
1485
1548
|
var quoted = (m) => `"${m}"`;
|
|
1486
1549
|
var AI_WORDS = [
|
|
1487
1550
|
"tapestry",
|
|
@@ -2319,8 +2382,8 @@ function documents(ctx) {
|
|
|
2319
2382
|
const docs = [];
|
|
2320
2383
|
for (const [path, blocks] of byPath) {
|
|
2321
2384
|
const text = blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2322
|
-
const
|
|
2323
|
-
if (
|
|
2385
|
+
const words3 = countWords(text);
|
|
2386
|
+
if (words3 >= MIN_WORDS) docs.push({ path, blocks, words: words3 });
|
|
2324
2387
|
}
|
|
2325
2388
|
return docs;
|
|
2326
2389
|
}
|
|
@@ -2445,14 +2508,14 @@ function contractionHits(ctx) {
|
|
|
2445
2508
|
for (const doc2 of documents(ctx)) {
|
|
2446
2509
|
if (doc2.words < 800) continue;
|
|
2447
2510
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
2448
|
-
const
|
|
2449
|
-
const rate =
|
|
2511
|
+
const count4 = (text.match(CONTRACTION) ?? []).length;
|
|
2512
|
+
const rate = count4 * 1e3 / doc2.words;
|
|
2450
2513
|
if (rate < 1) {
|
|
2451
2514
|
const first = sentences(doc2)[0];
|
|
2452
2515
|
hits.push({
|
|
2453
2516
|
path: doc2.path,
|
|
2454
2517
|
offset: first?.offset ?? 0,
|
|
2455
|
-
message: `${
|
|
2518
|
+
message: `${count4} contraction${count4 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
2456
2519
|
});
|
|
2457
2520
|
}
|
|
2458
2521
|
}
|
|
@@ -2464,14 +2527,14 @@ function rhythmHits(ctx) {
|
|
|
2464
2527
|
const list = sentences(doc2);
|
|
2465
2528
|
if (list.length < 40) continue;
|
|
2466
2529
|
const lengths = list.map((s) => countWords(s.text));
|
|
2467
|
-
const
|
|
2468
|
-
const
|
|
2469
|
-
const
|
|
2470
|
-
if (
|
|
2530
|
+
const mean2 = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
2531
|
+
const sd2 = Math.sqrt(lengths.reduce((a, b) => a + (b - mean2) ** 2, 0) / lengths.length);
|
|
2532
|
+
const cv2 = sd2 / mean2;
|
|
2533
|
+
if (cv2 < 0.42) {
|
|
2471
2534
|
hits.push({
|
|
2472
2535
|
path: doc2.path,
|
|
2473
2536
|
offset: list[0].offset,
|
|
2474
|
-
message: `${list.length} sentences averaging ${
|
|
2537
|
+
message: `${list.length} sentences averaging ${mean2.toFixed(1)} words with little variation (spread ${cv2.toFixed(2)}, reads metronomic below 0.42)`
|
|
2475
2538
|
});
|
|
2476
2539
|
}
|
|
2477
2540
|
}
|
|
@@ -2485,19 +2548,19 @@ function repeatHits(ctx) {
|
|
|
2485
2548
|
const whole = /* @__PURE__ */ new Map();
|
|
2486
2549
|
const runs = /* @__PURE__ */ new Map();
|
|
2487
2550
|
for (const s of sentences(doc2)) {
|
|
2488
|
-
const
|
|
2489
|
-
if (
|
|
2551
|
+
const words3 = normal(s.text);
|
|
2552
|
+
if (words3.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
|
|
2490
2553
|
if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
|
|
2491
|
-
const key =
|
|
2554
|
+
const key = words3.join(" ");
|
|
2492
2555
|
const earlier = whole.get(key);
|
|
2493
2556
|
if (earlier) {
|
|
2494
|
-
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${
|
|
2557
|
+
hits.push({ path: doc2.path, offset: s.offset, message: `the same sentence appears twice (${words3.length} words)`, excerpt: s.text.slice(0, 120) });
|
|
2495
2558
|
continue;
|
|
2496
2559
|
}
|
|
2497
2560
|
whole.set(key, s);
|
|
2498
2561
|
let reported = false;
|
|
2499
|
-
for (let i = 0; i + RUN <=
|
|
2500
|
-
const run =
|
|
2562
|
+
for (let i = 0; i + RUN <= words3.length && !reported; i += 1) {
|
|
2563
|
+
const run = words3.slice(i, i + RUN).join(" ");
|
|
2501
2564
|
const seen = runs.get(run);
|
|
2502
2565
|
if (seen && seen.key !== key) {
|
|
2503
2566
|
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) });
|
|
@@ -2554,11 +2617,11 @@ function headingEchoHits(ctx) {
|
|
|
2554
2617
|
const hits = [];
|
|
2555
2618
|
for (const { file } of longFiles(ctx)) {
|
|
2556
2619
|
for (const heading of headings(file)) {
|
|
2557
|
-
const
|
|
2558
|
-
if (
|
|
2620
|
+
const words3 = normal(heading.text);
|
|
2621
|
+
if (words3.length < 4) continue;
|
|
2559
2622
|
const after = file.text.slice(heading.end, heading.end + 600).trimStart().replace(/^\s*(?:<\/?(?:p|div|section)\b[^>]*>\s*)*/i, "");
|
|
2560
|
-
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0,
|
|
2561
|
-
if (opening.join(" ") ===
|
|
2623
|
+
const opening = normal(after.split(/\n\s*\n/)[0] ?? "").slice(0, words3.length);
|
|
2624
|
+
if (opening.join(" ") === words3.join(" ")) {
|
|
2562
2625
|
hits.push({ path: file.path, offset: heading.offset, message: `the heading "${heading.text}" is repeated as the first words under it` });
|
|
2563
2626
|
}
|
|
2564
2627
|
}
|
|
@@ -2566,7 +2629,7 @@ function headingEchoHits(ctx) {
|
|
|
2566
2629
|
return hits;
|
|
2567
2630
|
}
|
|
2568
2631
|
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(" ");
|
|
2569
|
-
function filler(
|
|
2632
|
+
function filler(words3, seed = 1, options = {}) {
|
|
2570
2633
|
const { contractions = true, uniform } = options;
|
|
2571
2634
|
const shape = [5, 17, 9, 24, 6, 13, 31, 8, 11, 4, 20, 15];
|
|
2572
2635
|
let state = seed;
|
|
@@ -2575,16 +2638,16 @@ function filler(words2, seed = 1, options = {}) {
|
|
|
2575
2638
|
return state % n;
|
|
2576
2639
|
};
|
|
2577
2640
|
const out = [];
|
|
2578
|
-
let
|
|
2641
|
+
let count4 = 0;
|
|
2579
2642
|
let i = 0;
|
|
2580
|
-
while (
|
|
2643
|
+
while (count4 < words3) {
|
|
2581
2644
|
const length = uniform ?? shape[i % shape.length];
|
|
2582
2645
|
const s = [];
|
|
2583
2646
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
2584
2647
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
2585
2648
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
2586
2649
|
out.push(`${s.join(" ")}.`);
|
|
2587
|
-
|
|
2650
|
+
count4 += s.length;
|
|
2588
2651
|
i += 1;
|
|
2589
2652
|
}
|
|
2590
2653
|
const paragraphs = [];
|
|
@@ -3724,64 +3787,76 @@ export function IntroCinematic() { return <AnimatePresence /> }`),
|
|
|
3724
3787
|
}
|
|
3725
3788
|
];
|
|
3726
3789
|
|
|
3727
|
-
// src/
|
|
3728
|
-
var
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3790
|
+
// src/snapshot/types.ts
|
|
3791
|
+
var SNAPSHOT_VERSION = 2;
|
|
3792
|
+
|
|
3793
|
+
// src/snapshot/fixture.ts
|
|
3794
|
+
function makeSnapshot(patch = {}) {
|
|
3795
|
+
const base = {
|
|
3796
|
+
version: SNAPSHOT_VERSION,
|
|
3797
|
+
url: "https://example.test/",
|
|
3798
|
+
capturedAt: "2026-09-23T00:00:00.000Z",
|
|
3799
|
+
viewport: { width: 1440, height: 900 },
|
|
3800
|
+
pageHeight: 4200,
|
|
3801
|
+
ground: "rgb(255, 255, 255)",
|
|
3802
|
+
colours: {
|
|
3803
|
+
backgrounds: [
|
|
3804
|
+
{ value: "rgb(255, 255, 255)", area: 6e6, chars: 0 },
|
|
3805
|
+
{ value: "rgb(31, 58, 46)", area: 9e4, chars: 0 }
|
|
3806
|
+
],
|
|
3807
|
+
text: [
|
|
3808
|
+
{ value: "rgb(28, 28, 26)", area: 0, chars: 5200 },
|
|
3809
|
+
{ value: "rgb(31, 58, 46)", area: 0, chars: 300 }
|
|
3810
|
+
]
|
|
3811
|
+
},
|
|
3812
|
+
fonts: [
|
|
3813
|
+
{ family: "S\xF6hne", chars: 4200, displayChars: 0, italicChars: 0, weights: [400, 500] },
|
|
3814
|
+
{ family: "Tiempos Headline", chars: 900, displayChars: 900, italicChars: 0, weights: [500] }
|
|
3815
|
+
],
|
|
3816
|
+
headings: [
|
|
3817
|
+
{ level: 1, family: "Tiempos Headline", sizePx: 64, weight: 500, italic: false, italicPart: false, text: "Boilers fixed the same day in Brackley", top: 180 },
|
|
3818
|
+
{ level: 2, family: "Tiempos Headline", sizePx: 36, weight: 500, italic: false, italicPart: false, text: "What we fix", top: 1100 }
|
|
3819
|
+
],
|
|
3820
|
+
controls: [
|
|
3821
|
+
{ kind: "button", radiusPx: 4, heightPx: 44, background: "rgb(31, 58, 46)", text: "Book a visit" },
|
|
3822
|
+
{ kind: "link", radiusPx: 4, heightPx: 44, background: "rgb(255, 255, 255)", text: "Call 01280 000000" },
|
|
3823
|
+
{ kind: "button", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "Send" },
|
|
3824
|
+
{ kind: "link", radiusPx: 4, heightPx: 40, background: "rgb(31, 58, 46)", text: "See prices" }
|
|
3825
|
+
],
|
|
3826
|
+
sections: [
|
|
3827
|
+
{ top: 0, height: 800, kind: "hero", label: "Boilers fixed the same day in Brackley", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3828
|
+
{ top: 800, height: 900, kind: "text", label: "What we fix", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3829
|
+
{ top: 1700, height: 900, kind: "cards", label: "Recent jobs", cards: 3, iconCards: 0, hiddenAtLoad: false },
|
|
3830
|
+
{ top: 2600, height: 700, kind: "text", label: "Prices", cards: 0, iconCards: 0, hiddenAtLoad: false },
|
|
3831
|
+
{ top: 3300, height: 900, kind: "text", label: "Contact", cards: 0, iconCards: 0, hiddenAtLoad: false }
|
|
3832
|
+
],
|
|
3833
|
+
effects: {
|
|
3834
|
+
glass: 0,
|
|
3835
|
+
gradientText: 0,
|
|
3836
|
+
gradients: [],
|
|
3837
|
+
glows: 0,
|
|
3838
|
+
gridBackgrounds: 0,
|
|
3839
|
+
marquees: 0,
|
|
3840
|
+
hairlineShadowCards: 0,
|
|
3841
|
+
eyebrowChip: false
|
|
3842
|
+
},
|
|
3843
|
+
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
3844
|
+
metrics: {
|
|
3845
|
+
fontSizes: [14, 16, 20, 36, 64],
|
|
3846
|
+
fontWeights: [400, 500],
|
|
3847
|
+
radii: ["4px"],
|
|
3848
|
+
shadows: [],
|
|
3849
|
+
spacingOnScalePct: 96,
|
|
3850
|
+
bodyLineLengthCh: 68,
|
|
3851
|
+
bodyFontSizePx: 17
|
|
3852
|
+
}
|
|
3784
3853
|
};
|
|
3854
|
+
const out = { ...base };
|
|
3855
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3856
|
+
const current = base[key];
|
|
3857
|
+
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
3858
|
+
}
|
|
3859
|
+
return out;
|
|
3785
3860
|
}
|
|
3786
3861
|
|
|
3787
3862
|
// src/character/parse.ts
|
|
@@ -3954,15 +4029,15 @@ var blank = (s) => s.replace(/[^\n]/g, " ");
|
|
|
3954
4029
|
function readsAsProse(value) {
|
|
3955
4030
|
if (value.length < 12 || !/\s/.test(value)) return false;
|
|
3956
4031
|
if (looksLikeClasses(value) || looksLikeAddress(value)) return false;
|
|
3957
|
-
const
|
|
3958
|
-
if (looksLikeCode(
|
|
3959
|
-
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(
|
|
4032
|
+
const words3 = withoutMarkup(value);
|
|
4033
|
+
if (looksLikeCode(words3) || /[<>]/.test(words3)) return false;
|
|
4034
|
+
return /[a-zA-Z]{3,}\s+[a-zA-Z]{2,}/.test(words3);
|
|
3960
4035
|
}
|
|
3961
4036
|
function withoutMarkup(value) {
|
|
3962
4037
|
return value.replace(/<\/?[a-zA-Z][^<>]*>/g, " ").replace(/\$\{[^}]*\}/g, " ").replace(/\{\{[^}]*\}\}/g, " ").replace(/&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi, "'");
|
|
3963
4038
|
}
|
|
3964
|
-
function looksLikeCode(
|
|
3965
|
-
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(
|
|
4039
|
+
function looksLikeCode(words3) {
|
|
4040
|
+
return /[{}]|;\s*(?:\n|$)|\s=\s|===?|=>|\b(?:const|let|var|function|import|export)\s/.test(words3);
|
|
3966
4041
|
}
|
|
3967
4042
|
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)$/;
|
|
3968
4043
|
function looksLikeClasses(value) {
|
|
@@ -4115,76 +4190,664 @@ function extractStrings(file) {
|
|
|
4115
4190
|
return [];
|
|
4116
4191
|
}
|
|
4117
4192
|
|
|
4118
|
-
// src/
|
|
4119
|
-
var
|
|
4193
|
+
// src/character/facts.ts
|
|
4194
|
+
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)?)";
|
|
4195
|
+
var PATTERNS = [
|
|
4196
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
4197
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
4198
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
4199
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
4200
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
4201
|
+
["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")],
|
|
4202
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
4203
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
4204
|
+
];
|
|
4205
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
4206
|
+
var normalise = (kind, value) => {
|
|
4207
|
+
const v = value.trim();
|
|
4208
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
4209
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
4210
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
4211
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
4212
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
4213
|
+
};
|
|
4214
|
+
function visibleText(file) {
|
|
4215
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
4216
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
4217
|
+
}
|
|
4218
|
+
function protectedFacts(text) {
|
|
4219
|
+
const facts = [];
|
|
4220
|
+
let rest = text;
|
|
4221
|
+
const mask = (start, length) => {
|
|
4222
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
4223
|
+
};
|
|
4224
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
4225
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
4226
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
4227
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
4228
|
+
mask(m.index ?? 0, m[0].length);
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
const lines = rest.split("\n");
|
|
4232
|
+
for (const line of lines) {
|
|
4233
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
4234
|
+
for (const m of line.matchAll(NAME)) {
|
|
4235
|
+
const at = m.index ?? 0;
|
|
4236
|
+
const before = line.slice(0, at).trimEnd();
|
|
4237
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
4238
|
+
const word = m[0];
|
|
4239
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
4240
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
4241
|
+
if (word === "I") continue;
|
|
4242
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
return facts;
|
|
4246
|
+
}
|
|
4247
|
+
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
4248
|
+
function compareFacts(before, after) {
|
|
4249
|
+
const a = unique(protectedFacts(before));
|
|
4250
|
+
const b = unique(protectedFacts(after));
|
|
4251
|
+
const inA = new Set(a.map((f4) => f4.key));
|
|
4252
|
+
const inB = new Set(b.map((f4) => f4.key));
|
|
4253
|
+
return {
|
|
4254
|
+
before: a.length,
|
|
4255
|
+
after: b.length,
|
|
4256
|
+
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
4257
|
+
added: b.filter((f4) => !inA.has(f4.key))
|
|
4258
|
+
};
|
|
4259
|
+
}
|
|
4260
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
4261
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
4262
|
+
if (result.lost.length) {
|
|
4263
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
4264
|
+
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4265
|
+
}
|
|
4266
|
+
if (result.added.length) {
|
|
4267
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
4268
|
+
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
4269
|
+
}
|
|
4270
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
4271
|
+
return lines.join("\n");
|
|
4272
|
+
}
|
|
4120
4273
|
|
|
4121
|
-
// src/
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4274
|
+
// src/character/specificity.ts
|
|
4275
|
+
var TRADE_NOUNS = [
|
|
4276
|
+
// heating, plumbing, electrical
|
|
4277
|
+
"boiler",
|
|
4278
|
+
"radiator",
|
|
4279
|
+
"combi",
|
|
4280
|
+
"cylinder",
|
|
4281
|
+
"thermostat",
|
|
4282
|
+
"underfloor heating",
|
|
4283
|
+
"heat pump",
|
|
4284
|
+
"solar panel",
|
|
4285
|
+
"plumbing",
|
|
4286
|
+
"plumber",
|
|
4287
|
+
"leak",
|
|
4288
|
+
"toilet",
|
|
4289
|
+
"shower",
|
|
4290
|
+
"bathroom",
|
|
4291
|
+
"wet room",
|
|
4292
|
+
"drain",
|
|
4293
|
+
"fuse board",
|
|
4294
|
+
"fuseboard",
|
|
4295
|
+
"consumer unit",
|
|
4296
|
+
"rewire",
|
|
4297
|
+
"socket",
|
|
4298
|
+
"ev charger",
|
|
4299
|
+
"electrician",
|
|
4300
|
+
"gas engineer",
|
|
4301
|
+
"power flush",
|
|
4302
|
+
"landlord certificate",
|
|
4303
|
+
"eicr",
|
|
4304
|
+
// building and outside
|
|
4305
|
+
"extension",
|
|
4306
|
+
"loft conversion",
|
|
4307
|
+
"loft",
|
|
4308
|
+
"kitchen",
|
|
4309
|
+
"roof",
|
|
4310
|
+
"roofer",
|
|
4311
|
+
"gutter",
|
|
4312
|
+
"chimney",
|
|
4313
|
+
"driveway",
|
|
4314
|
+
"patio",
|
|
4315
|
+
"fence",
|
|
4316
|
+
"fencing",
|
|
4317
|
+
"decking",
|
|
4318
|
+
"window",
|
|
4319
|
+
"conservatory",
|
|
4320
|
+
"plastering",
|
|
4321
|
+
"plasterer",
|
|
4322
|
+
"render",
|
|
4323
|
+
"brickwork",
|
|
4324
|
+
"bricklayer",
|
|
4325
|
+
"tiling",
|
|
4326
|
+
"tiler",
|
|
4327
|
+
"flooring",
|
|
4328
|
+
"carpet",
|
|
4329
|
+
"joinery",
|
|
4330
|
+
"joiner",
|
|
4331
|
+
"carpenter",
|
|
4332
|
+
"staircase",
|
|
4333
|
+
"damp",
|
|
4334
|
+
"scaffolding",
|
|
4335
|
+
"garage conversion",
|
|
4336
|
+
"lawn",
|
|
4337
|
+
"hedge",
|
|
4338
|
+
"tree surgery",
|
|
4339
|
+
"stump",
|
|
4340
|
+
"landscaping",
|
|
4341
|
+
"gardener",
|
|
4342
|
+
"gate",
|
|
4343
|
+
"locksmith",
|
|
4344
|
+
"pest control",
|
|
4345
|
+
"wasp",
|
|
4346
|
+
"cleaning",
|
|
4347
|
+
"oven",
|
|
4348
|
+
"end of tenancy",
|
|
4349
|
+
// vehicles
|
|
4350
|
+
"mot",
|
|
4351
|
+
"tyre",
|
|
4352
|
+
"brake",
|
|
4353
|
+
"clutch",
|
|
4354
|
+
"cambelt",
|
|
4355
|
+
"exhaust",
|
|
4356
|
+
"gearbox",
|
|
4357
|
+
"valeting",
|
|
4358
|
+
// personal and health
|
|
4359
|
+
"haircut",
|
|
4360
|
+
"skin fade",
|
|
4361
|
+
"beard",
|
|
4362
|
+
"barber",
|
|
4363
|
+
"balayage",
|
|
4364
|
+
"nails",
|
|
4365
|
+
"lashes",
|
|
4366
|
+
"massage",
|
|
4367
|
+
"physio",
|
|
4368
|
+
"physiotherapy",
|
|
4369
|
+
"osteopath",
|
|
4370
|
+
"chiropractor",
|
|
4371
|
+
"dentist",
|
|
4372
|
+
"dental",
|
|
4373
|
+
"implant",
|
|
4374
|
+
"brace",
|
|
4375
|
+
"invisalign",
|
|
4376
|
+
"hygienist",
|
|
4377
|
+
"optician",
|
|
4378
|
+
"eye test",
|
|
4379
|
+
"vet",
|
|
4380
|
+
"grooming",
|
|
4381
|
+
"dog walking",
|
|
4382
|
+
"personal trainer",
|
|
4383
|
+
"yoga",
|
|
4384
|
+
"pilates",
|
|
4385
|
+
// professional
|
|
4386
|
+
"accountant",
|
|
4387
|
+
"tax return",
|
|
4388
|
+
"bookkeeping",
|
|
4389
|
+
"payroll",
|
|
4390
|
+
"vat",
|
|
4391
|
+
"self assessment",
|
|
4392
|
+
"solicitor",
|
|
4393
|
+
"conveyancing",
|
|
4394
|
+
"probate",
|
|
4395
|
+
"divorce",
|
|
4396
|
+
"lasting power of attorney",
|
|
4397
|
+
"mortgage",
|
|
4398
|
+
"surveyor",
|
|
4399
|
+
"architect",
|
|
4400
|
+
"planning permission",
|
|
4401
|
+
"building regulations",
|
|
4402
|
+
// web and digital, for the agency's own pages
|
|
4403
|
+
"website",
|
|
4404
|
+
"web design",
|
|
4405
|
+
"seo",
|
|
4406
|
+
"hosting",
|
|
4407
|
+
"google business profile",
|
|
4408
|
+
"online shop",
|
|
4409
|
+
"ecommerce",
|
|
4410
|
+
// food and hospitality
|
|
4411
|
+
"breakfast",
|
|
4412
|
+
"lunch",
|
|
4413
|
+
"dinner",
|
|
4414
|
+
"coffee",
|
|
4415
|
+
"bakery",
|
|
4416
|
+
"cake",
|
|
4417
|
+
"catering",
|
|
4418
|
+
"wedding",
|
|
4419
|
+
"venue"
|
|
4420
|
+
];
|
|
4421
|
+
var STOCK_FIGURES = [
|
|
4422
|
+
/\b24\s?\/\s?7\b/gi,
|
|
4423
|
+
/\b24[\s-]hours?\b/gi,
|
|
4424
|
+
/\b1[01]0\s?%/g,
|
|
4425
|
+
/\b(?:5|five)[\s-]?star\b/gi,
|
|
4426
|
+
/#\s?1\b/g,
|
|
4427
|
+
/\bno\.?\s?1\b/gi,
|
|
4428
|
+
/\b365\s+days\b/gi
|
|
4429
|
+
];
|
|
4430
|
+
var NOT_NAMES = new Set(
|
|
4431
|
+
"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(" ")
|
|
4432
|
+
);
|
|
4433
|
+
var BRIEF_STOP = new Set(
|
|
4434
|
+
"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(" ")
|
|
4435
|
+
);
|
|
4436
|
+
var WORD2 = /[A-Za-z0-9£$€][\w'’£$€%.,-]*/g;
|
|
4437
|
+
var countWords2 = (text) => (text.match(WORD2) ?? []).length;
|
|
4438
|
+
var escape2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4439
|
+
var termPattern = (term) => new RegExp(`(?<![\\w\xA3$\u20AC])${term.split(/\s+/).map(escape2).join("[\\s-]+")}(?:s|es)?(?![\\w])`, "i");
|
|
4440
|
+
var TRADE_PATTERNS = TRADE_NOUNS.map((t) => [t, termPattern(t)]);
|
|
4441
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set(["a", "an", "the", "and", "or", "of", "in", "on", "at", "to", "for", "by", "with", "from"]);
|
|
4442
|
+
function caseless(line) {
|
|
4443
|
+
const words3 = line.match(/[A-Za-z][A-Za-z’'-]*/g) ?? [];
|
|
4444
|
+
const letters = words3.filter((w) => w.length >= 2);
|
|
4445
|
+
if (letters.length >= 3 && letters.filter((w) => w === w.toUpperCase()).length / letters.length >= 0.6) return "shout";
|
|
4446
|
+
const content = letters.filter((w) => !SMALL_WORDS.has(w.toLowerCase()));
|
|
4447
|
+
if (content.length >= 3 && content.filter((w) => /^[A-Z]/.test(w)).length / content.length >= 0.8) return "title";
|
|
4448
|
+
return null;
|
|
4449
|
+
}
|
|
4450
|
+
var maskStock = (text) => STOCK_FIGURES.reduce((t, re) => t.replace(re, (m) => " ".repeat(m.length)), text);
|
|
4451
|
+
function briefTerms(brief) {
|
|
4452
|
+
const terms = /* @__PURE__ */ new Set();
|
|
4453
|
+
for (const w of brief.match(/[A-Za-z][A-Za-z’'&-]*/g) ?? []) {
|
|
4454
|
+
const lower2 = w.toLowerCase();
|
|
4455
|
+
if (/^[A-Z]/.test(w) && w.length >= 3 && !BRIEF_STOP.has(lower2) && !NOT_NAMES.has(lower2)) terms.add(lower2);
|
|
4456
|
+
}
|
|
4457
|
+
for (const [term, re] of TRADE_PATTERNS) if (re.test(brief)) terms.add(term);
|
|
4458
|
+
for (const f4 of protectedFacts(maskStock(brief))) if (f4.kind !== "name") terms.add(f4.value.toLowerCase());
|
|
4459
|
+
return [...terms];
|
|
4460
|
+
}
|
|
4461
|
+
function specifics(text, brief) {
|
|
4462
|
+
const found = /* @__PURE__ */ new Map();
|
|
4463
|
+
const add = (kind, value) => {
|
|
4464
|
+
const key = value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
4465
|
+
if (key && !found.has(key)) found.set(key, { kind, value, key });
|
|
4466
|
+
};
|
|
4467
|
+
for (const raw of text.split("\n")) {
|
|
4468
|
+
const line = maskStock(raw);
|
|
4469
|
+
if (!line.trim()) continue;
|
|
4470
|
+
const capitals = caseless(line);
|
|
4471
|
+
for (const f4 of protectedFacts(line)) {
|
|
4472
|
+
if (f4.kind === "name") {
|
|
4473
|
+
if (capitals === "shout") continue;
|
|
4474
|
+
if (capitals === "title") {
|
|
4475
|
+
for (const acronym of f4.value.match(/\b[A-Z]{2,}\b/g) ?? []) add("name", acronym);
|
|
4476
|
+
continue;
|
|
4477
|
+
}
|
|
4478
|
+
const words3 = f4.value.split(/\s+/);
|
|
4479
|
+
const named = (w) => !NOT_NAMES.has(w.toLowerCase()) && w !== "&";
|
|
4480
|
+
const first = words3.findIndex(named);
|
|
4481
|
+
if (first === -1) continue;
|
|
4482
|
+
const last = words3.length - 1 - [...words3].reverse().findIndex(named);
|
|
4483
|
+
add("name", words3.slice(first, last + 1).join(" "));
|
|
4484
|
+
continue;
|
|
4485
|
+
}
|
|
4486
|
+
add(f4.kind, f4.value);
|
|
4487
|
+
}
|
|
4488
|
+
for (const [term, re] of TRADE_PATTERNS) {
|
|
4489
|
+
const m = re.exec(line);
|
|
4490
|
+
if (m) add("trade", term);
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
if (brief) {
|
|
4494
|
+
for (const term of briefTerms(brief)) {
|
|
4495
|
+
if ([...found.keys()].some((k) => k === term || k.split(" ").includes(term))) continue;
|
|
4496
|
+
if (termPattern(term).test(text)) add("brief", term);
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
4499
|
+
return [...found.values()];
|
|
4500
|
+
}
|
|
4501
|
+
function specificity(text, brief) {
|
|
4502
|
+
const list = specifics(text, brief);
|
|
4503
|
+
const words3 = countWords2(text);
|
|
4504
|
+
const trade = list.filter((s) => s.kind === "trade").length;
|
|
4505
|
+
const hard = list.length - trade;
|
|
4506
|
+
return {
|
|
4507
|
+
words: words3,
|
|
4508
|
+
specifics: list,
|
|
4509
|
+
hard,
|
|
4510
|
+
trade,
|
|
4511
|
+
perHundred: words3 ? Math.round(list.length / words3 * 1e3) / 10 : 0,
|
|
4512
|
+
generic: hard === 0 && trade < 2
|
|
4513
|
+
};
|
|
4514
|
+
}
|
|
4515
|
+
var NOT_SERVICE_ROLES = /* @__PURE__ */ new Set(["testimonials", "team", "faq", "contact", "logos", "stats", "cta-band", "footer-cta", "marquee"]);
|
|
4516
|
+
var SERVICE_ROLES = /* @__PURE__ */ new Set(["features", "process", "pricing", "cards"]);
|
|
4517
|
+
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;
|
|
4518
|
+
function sentencesOf(blocks) {
|
|
4519
|
+
const out = [];
|
|
4520
|
+
for (const block of blocks) {
|
|
4521
|
+
for (const m of block.matchAll(/[^.!?]+[.!?]*/g)) {
|
|
4522
|
+
const s = m[0].trim();
|
|
4523
|
+
if (countWords2(s) >= 3) out.push(s);
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
return [...new Set(out)];
|
|
4527
|
+
}
|
|
4528
|
+
function pageCopy(snapshot) {
|
|
4529
|
+
const withText2 = snapshot.sections.filter((s) => s.text);
|
|
4530
|
+
if (!snapshot.firstScreenText && withText2.length === 0) return null;
|
|
4531
|
+
const hero = [...snapshot.firstScreenText ?? [], ...snapshot.sections.filter((s) => s.kind === "hero").flatMap((s) => s.text ?? [])];
|
|
4532
|
+
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 ?? []);
|
|
4533
|
+
const all = [...snapshot.firstScreenText ?? [], ...withText2.flatMap((s) => s.text ?? [])];
|
|
4534
|
+
return { url: snapshot.url, hero: sentencesOf(hero), services: sentencesOf(services), all };
|
|
4535
|
+
}
|
|
4536
|
+
function side(page, other, brief) {
|
|
4537
|
+
const otherKeys = new Set(specifics(other.all.join("\n"), brief).map((s) => s.key));
|
|
4538
|
+
const sentences3 = [.../* @__PURE__ */ new Set([...page.hero, ...page.services])];
|
|
4539
|
+
const interchangeable = sentences3.filter((s) => !specifics(s, brief).some((x) => !otherKeys.has(x.key)));
|
|
4540
|
+
return {
|
|
4541
|
+
url: page.url,
|
|
4542
|
+
sentences: sentences3.length,
|
|
4543
|
+
interchangeable,
|
|
4544
|
+
share: sentences3.length ? Math.round(interchangeable.length / sentences3.length * 1e3) / 1e3 : null,
|
|
4545
|
+
perHundred: specificity(sentences3.join("\n"), brief).perHundred
|
|
4546
|
+
};
|
|
4547
|
+
}
|
|
4548
|
+
function compareCompetitor(ours, competitor, brief) {
|
|
4549
|
+
return { ours: side(ours, competitor, brief), competitor: side(competitor, ours, brief), brief: Boolean(brief) };
|
|
4550
|
+
}
|
|
4551
|
+
var pctOf = (share) => share === null ? "no sentences" : `${Math.round(share * 100)}%`;
|
|
4552
|
+
function formatCompetitor(result) {
|
|
4553
|
+
const row = (label, s) => ` ${label.padEnd(11)} ${s.interchangeable.length} of ${s.sentences} (${pctOf(s.share)}), ${s.perHundred} specifics per 100 words`;
|
|
4554
|
+
const lines = [
|
|
4555
|
+
`craft copy compare: ${result.ours.url} against ${result.competitor.url}`,
|
|
4556
|
+
" Hero and service sentences holding nothing the other page does not also say:",
|
|
4557
|
+
row("ours", result.ours),
|
|
4558
|
+
row("competitor", result.competitor)
|
|
4559
|
+
];
|
|
4560
|
+
if (result.brief) lines.push(" The brief's own terms were recognised on both pages.");
|
|
4561
|
+
if (result.ours.interchangeable.length) {
|
|
4562
|
+
lines.push("", " Interchangeable on ours: add the job, the place, the price or the name only this business has");
|
|
4563
|
+
for (const s of result.ours.interchangeable.slice(0, 12)) lines.push(` "${s.length > 110 ? `${s.slice(0, 107)}...` : s}"`);
|
|
4564
|
+
if (result.ours.interchangeable.length > 12) lines.push(` and ${result.ours.interchangeable.length - 12} more`);
|
|
4565
|
+
} else {
|
|
4566
|
+
lines.push("", " Every hero and service sentence on ours holds something the competitor does not say.");
|
|
4567
|
+
}
|
|
4568
|
+
return lines.join("\n");
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
// src/character/tells/specificity.ts
|
|
4572
|
+
var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4573
|
+
var snapHit = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
4574
|
+
var ENTITIES = { amp: "&", nbsp: " ", rsquo: "\u2019", lsquo: "\u2018", ldquo: "\u201C", rdquo: "\u201D", quot: '"', apos: "'", "#39": "'", pound: "\xA3" };
|
|
4575
|
+
function literalText(inner) {
|
|
4576
|
+
const t = inner.replace(/\{\s*(["'`])\s*\1\s*\}/g, " ").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " ");
|
|
4577
|
+
if (/[{}]/.test(t)) return null;
|
|
4578
|
+
return t.replace(/&(#?\w+);/g, (m, e) => ENTITIES[e] ?? m).replace(/\s+/g, " ").trim();
|
|
4579
|
+
}
|
|
4580
|
+
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;
|
|
4581
|
+
var HOME_URL = /^https?:\/\/[^/?#]+(?:\/(?:[a-z]{2}(?:-[a-z]{2})?\/?)?(?:index\.html?)?)?(?:[?#].*)?$/i;
|
|
4582
|
+
var H1 = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
|
|
4583
|
+
var NEXT_P = /<p\b[^>]*>([\s\S]*?)<\/p>/i;
|
|
4584
|
+
function headlines(ctx) {
|
|
4585
|
+
const out = [];
|
|
4586
|
+
for (const file of ctx.files) {
|
|
4587
|
+
if (fileKind(file.path) !== "markup" || !HOME_FILE.test(file.path)) continue;
|
|
4588
|
+
for (const m of file.text.matchAll(H1)) {
|
|
4589
|
+
const h1 = literalText(m[1]);
|
|
4590
|
+
if (!h1) continue;
|
|
4591
|
+
const after = file.text.slice((m.index ?? 0) + m[0].length, (m.index ?? 0) + m[0].length + 1200);
|
|
4592
|
+
const p = NEXT_P.exec(after);
|
|
4593
|
+
const sub = p ? literalText(p[1]) : null;
|
|
4594
|
+
out.push({ path: file.path, offset: m.index ?? 0, h1, text: [h1, sub].filter(Boolean).join("\n") });
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
return out;
|
|
4598
|
+
}
|
|
4599
|
+
var GENERIC_MESSAGE = "the headline and the line under it name nothing particular: no place, name, number, price or job";
|
|
4600
|
+
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;
|
|
4601
|
+
var PROOF_NAMES = [
|
|
4602
|
+
"Gas Safe",
|
|
4603
|
+
"NICEIC",
|
|
4604
|
+
"NAPIT",
|
|
4605
|
+
"ELECSA",
|
|
4606
|
+
"Stroma",
|
|
4607
|
+
"TrustMark",
|
|
4608
|
+
"Which? Trusted Trader",
|
|
4609
|
+
"Checkatrade",
|
|
4610
|
+
"TrustATrader",
|
|
4611
|
+
"Rated People",
|
|
4612
|
+
"MyBuilder",
|
|
4613
|
+
"Federation of Master Builders",
|
|
4614
|
+
"FMB",
|
|
4615
|
+
"Guild of Master Craftsmen",
|
|
4616
|
+
"OFTEC",
|
|
4617
|
+
"HETAS",
|
|
4618
|
+
"CIPHE",
|
|
4619
|
+
"APHC",
|
|
4620
|
+
"CHAS",
|
|
4621
|
+
"SafeContractor",
|
|
4622
|
+
"Constructionline",
|
|
4623
|
+
"FENSA",
|
|
4624
|
+
"CERTASS",
|
|
4625
|
+
"MCS",
|
|
4626
|
+
"RECC",
|
|
4627
|
+
"BPEC",
|
|
4628
|
+
"City & Guilds",
|
|
4629
|
+
"NVQ",
|
|
4630
|
+
"CSCS",
|
|
4631
|
+
"Worcester Bosch",
|
|
4632
|
+
"Vaillant",
|
|
4633
|
+
"Viessmann",
|
|
4634
|
+
"BAFE",
|
|
4635
|
+
"NSI",
|
|
4636
|
+
"SSAIB",
|
|
4637
|
+
"BPCA",
|
|
4638
|
+
"RICS",
|
|
4639
|
+
"CIOB",
|
|
4640
|
+
"RIBA",
|
|
4641
|
+
"ICAEW",
|
|
4642
|
+
"ACCA",
|
|
4643
|
+
"CIMA",
|
|
4644
|
+
"SRA",
|
|
4645
|
+
"Law Society",
|
|
4646
|
+
"GDC",
|
|
4647
|
+
"GMC",
|
|
4648
|
+
"HCPC",
|
|
4649
|
+
"CQC",
|
|
4650
|
+
"Ofsted",
|
|
4651
|
+
"ISO 9001",
|
|
4652
|
+
"ISO 27001",
|
|
4653
|
+
"Cyber Essentials",
|
|
4654
|
+
"Companies House",
|
|
4655
|
+
"FCA",
|
|
4656
|
+
"Trustpilot",
|
|
4657
|
+
"Feefo",
|
|
4658
|
+
"Reviews.io",
|
|
4659
|
+
"Yell",
|
|
4660
|
+
"Houzz"
|
|
4661
|
+
];
|
|
4662
|
+
var escape3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4663
|
+
var PROOF_NAME = new RegExp(`(?<![\\w])(?:${PROOF_NAMES.map(escape3).join("|")})(?![\\w])`);
|
|
4664
|
+
var PROOF_NAME_ANY_CASE = new RegExp(PROOF_NAME.source, "i");
|
|
4665
|
+
var PROOF_PATTERNS = [
|
|
4666
|
+
PROOF_NAME,
|
|
4667
|
+
/\bGoogle (?:reviews?|rating)\b|\breviews? on Google\b/i,
|
|
4668
|
+
// A rating or a count of reviews: "4.9 out of 5", "rated 5", "212 reviews".
|
|
4669
|
+
/\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,
|
|
4670
|
+
// A registration or licence number.
|
|
4671
|
+
/\b(?:reg(?:istration|istered)?|licen[cs]e|membership|company)\s*(?:no\.?|number|#)\s*:?\s*[A-Z0-9-]{4,}/i
|
|
4672
|
+
];
|
|
4673
|
+
var isQuote = (block) => /^["“]/.test(block) && block.split(/\s+/).length >= 8 || /^[—–-]\s*[A-Z]/.test(block);
|
|
4674
|
+
function sectionHasProof(s, section, index) {
|
|
4675
|
+
if ((section.role ?? section.kind) === "testimonials") return true;
|
|
4676
|
+
const text = section.text ?? [];
|
|
4677
|
+
if (text.some((b) => isQuote(b) || PROOF_PATTERNS.some((re) => re.test(b)))) return true;
|
|
4678
|
+
return (s.images ?? []).some((i) => i.section === index && i.role === "logo" && PROOF_NAME_ANY_CASE.test(`${i.alt ?? ""} ${i.src}`));
|
|
4679
|
+
}
|
|
4680
|
+
function unprovenClaims(s) {
|
|
4681
|
+
const proof = s.sections.map((section, i) => sectionHasProof(s, section, i));
|
|
4682
|
+
const out = [];
|
|
4683
|
+
s.sections.forEach((section, i) => {
|
|
4684
|
+
if (!section.text || proof[i] || proof[i - 1] || proof[i + 1]) return;
|
|
4685
|
+
for (const sentence of sentencesOf(section.text)) {
|
|
4686
|
+
const m = STANDING_CLAIM.exec(sentence);
|
|
4687
|
+
if (m) out.push({ claim: m[0], sentence, section: section.label });
|
|
4688
|
+
}
|
|
4689
|
+
});
|
|
4690
|
+
return out;
|
|
4691
|
+
}
|
|
4692
|
+
var withText = (texts, patch = () => ({})) => {
|
|
4693
|
+
const base = makeSnapshot();
|
|
4694
|
+
return { ...base, sections: base.sections.map((sec, i) => ({ ...sec, text: texts[i] ?? [], ...patch(i) })) };
|
|
4695
|
+
};
|
|
4696
|
+
var CLAIM_TEXT = ["What we fix", "Our fully qualified engineers are trusted by homeowners across the county."];
|
|
4697
|
+
var SPECIFICITY_COPY_TELLS = [
|
|
4698
|
+
{
|
|
4699
|
+
id: "generic-hero-claim",
|
|
4700
|
+
name: "Headline that fits any business",
|
|
4701
|
+
generation: 1,
|
|
4702
|
+
severity: "warn",
|
|
4703
|
+
surface: "copy",
|
|
4704
|
+
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.",
|
|
4705
|
+
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".',
|
|
4706
|
+
detect: (ctx) => headlines(ctx).filter((h) => specificity(h.text).generic).map((h) => ({ path: h.path, offset: h.offset, message: GENERIC_MESSAGE, excerpt: h.h1 })),
|
|
4707
|
+
fixtures: {
|
|
4708
|
+
flag: [
|
|
4709
|
+
{ 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>' },
|
|
4710
|
+
// Shouted and Title Case: its capitals are not names, and its figures are stock.
|
|
4711
|
+
{ path: "index.html", text: "<h1>YOUR VISION, OUR EXPERTISE</h1>\n<p>24/7 Support And 100% Satisfaction Guaranteed For Every Customer</p>" }
|
|
4134
4712
|
],
|
|
4135
|
-
|
|
4136
|
-
{
|
|
4137
|
-
|
|
4713
|
+
pass: [
|
|
4714
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">Boilers fixed the same day in Brackley</h1>' },
|
|
4715
|
+
// A stock headline rescued by the line under it.
|
|
4716
|
+
{ path: "index.html", text: "<h1>Quality you can trust</h1>\n<p>Gas Safe registered engineers in Northampton since 2009.</p>" },
|
|
4717
|
+
// Two trade nouns say what the business does.
|
|
4718
|
+
{ path: "index.html", text: "<h1>Plumbing and boiler repairs</h1>" },
|
|
4719
|
+
// The words come from data: nothing to read.
|
|
4720
|
+
{ path: "app/page.tsx", text: '<h1 className="text-6xl">{site.headline}</h1>' },
|
|
4721
|
+
// Another page's headline is a label, not the site's claim.
|
|
4722
|
+
{ path: "app/privacy/page.tsx", text: "<h1>Privacy policy</h1>\n<p>This policy explains how we use your data.</p>" }
|
|
4138
4723
|
]
|
|
4139
4724
|
},
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
gradientText: 0,
|
|
4164
|
-
gradients: [],
|
|
4165
|
-
glows: 0,
|
|
4166
|
-
gridBackgrounds: 0,
|
|
4167
|
-
marquees: 0,
|
|
4168
|
-
hairlineShadowCards: 0,
|
|
4169
|
-
eyebrowChip: false
|
|
4170
|
-
},
|
|
4171
|
-
motion: { hiddenSections: 0, sections: 5, animations: 0, introOverlay: false },
|
|
4172
|
-
metrics: {
|
|
4173
|
-
fontSizes: [14, 16, 20, 36, 64],
|
|
4174
|
-
fontWeights: [400, 500],
|
|
4175
|
-
radii: ["4px"],
|
|
4176
|
-
shadows: [],
|
|
4177
|
-
spacingOnScalePct: 96,
|
|
4178
|
-
bodyLineLengthCh: 68,
|
|
4179
|
-
bodyFontSizePx: 17
|
|
4725
|
+
rendered: {
|
|
4726
|
+
detect: (s) => {
|
|
4727
|
+
if (!s.firstScreenText || !HOME_URL.test(s.url)) return [];
|
|
4728
|
+
const text = s.firstScreenText.join("\n");
|
|
4729
|
+
if (!text.trim() || !specificity(text).generic) return [];
|
|
4730
|
+
const h1 = s.headings.find((h) => h.level === 1 && h.top < s.viewport.height);
|
|
4731
|
+
return [snapHit(s, `the first screen names nothing particular: no place, name, number, price or job`, h1?.text ?? s.firstScreenText[0])];
|
|
4732
|
+
},
|
|
4733
|
+
fixtures: {
|
|
4734
|
+
flag: [
|
|
4735
|
+
makeSnapshot({ firstScreenText: ["Quality you can trust", "We deliver reliable solutions tailored to your needs."], headings: [{ ...makeSnapshot().headings[0], text: "Quality you can trust" }] }),
|
|
4736
|
+
makeSnapshot({ firstScreenText: ["YOUR VISION, OUR EXPERTISE", "24/7 support. 100% satisfaction. 5-star service."] })
|
|
4737
|
+
],
|
|
4738
|
+
pass: [
|
|
4739
|
+
makeSnapshot(),
|
|
4740
|
+
makeSnapshot({ firstScreenText: ["Boilers fixed the same day in Brackley", "From \xA385, with the part on the van."] }),
|
|
4741
|
+
makeSnapshot({ firstScreenText: ["Quality plumbing you can trust", "Boilers, radiators and underfloor heating."] }),
|
|
4742
|
+
// A first screen that is a picture and nothing else is not judged on its words.
|
|
4743
|
+
makeSnapshot({ firstScreenText: [] }),
|
|
4744
|
+
// Nor is any page but the home page.
|
|
4745
|
+
makeSnapshot({ url: "https://example.test/privacy", firstScreenText: ["Privacy policy", "This policy explains how we use your data."] })
|
|
4746
|
+
]
|
|
4747
|
+
}
|
|
4180
4748
|
}
|
|
4749
|
+
}
|
|
4750
|
+
];
|
|
4751
|
+
var SPECIFICITY_RENDERED_TELLS = [
|
|
4752
|
+
{
|
|
4753
|
+
id: "unproven-claim",
|
|
4754
|
+
name: "Claim with no proof next to it",
|
|
4755
|
+
generation: 1,
|
|
4756
|
+
severity: "warn",
|
|
4757
|
+
surface: "rendered",
|
|
4758
|
+
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.`,
|
|
4759
|
+
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.",
|
|
4760
|
+
rendered: {
|
|
4761
|
+
detect: (s) => {
|
|
4762
|
+
if (!s.sections.some((x) => x.text)) return [];
|
|
4763
|
+
const found = unprovenClaims(s);
|
|
4764
|
+
if (found.length === 0) return [];
|
|
4765
|
+
const first = found[0];
|
|
4766
|
+
const where = first.section ? `"${first.section}"` : "an unnamed section";
|
|
4767
|
+
return [snapHit(s, `${count(found.length, "claim")} with no review or accreditation within one section: "${first.claim}" in ${where}`, first.sentence)];
|
|
4768
|
+
},
|
|
4769
|
+
fixtures: {
|
|
4770
|
+
flag: [
|
|
4771
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4772
|
+
// Proof two sections away is not next to the claim.
|
|
4773
|
+
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" } : {})
|
|
4774
|
+
],
|
|
4775
|
+
pass: [
|
|
4776
|
+
makeSnapshot(),
|
|
4777
|
+
withText([["Boilers fixed the same day in Brackley"], ["Our fully qualified engineers are Gas Safe registered, number 123456."]]),
|
|
4778
|
+
withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT, ["What customers said"]], (i) => i === 2 ? { role: "testimonials" } : {}),
|
|
4779
|
+
withText([["Rated 4.9 out of 5 from 212 reviews on Google"], CLAIM_TEXT]),
|
|
4780
|
+
{
|
|
4781
|
+
...withText([["Boilers fixed the same day in Brackley"], CLAIM_TEXT]),
|
|
4782
|
+
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 }]
|
|
4783
|
+
}
|
|
4784
|
+
]
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
];
|
|
4789
|
+
|
|
4790
|
+
// src/character/house.ts
|
|
4791
|
+
var HOUSE = {
|
|
4792
|
+
"em-dash": { tier: "block", label: "em dash, use a full stop or a comma" },
|
|
4793
|
+
emoji: { tier: "block", label: "emoji" },
|
|
4794
|
+
"not-just-but": { tier: "block", label: "contrastive negation. The template is the tell, not the sentiment. Say the positive claim" },
|
|
4795
|
+
"no-x-no-y": {
|
|
4796
|
+
tier: "block",
|
|
4797
|
+
label: '"No X, no Y" list. Defining the brand by what it is not, itemised, is the same template as contrastive negation'
|
|
4798
|
+
},
|
|
4799
|
+
"no-x-badge": { tier: "block", label: 'standalone "No X" badge. Reads as a slapped-on kicker, and worse when reused across pieces' },
|
|
4800
|
+
"ai-phrase": { tier: "block", label: "AI tell" },
|
|
4801
|
+
"plainer-word": { tier: "block", label: "a plainer word exists. Name the mechanism instead" },
|
|
4802
|
+
"plain-english": { tier: "block", label: 'use "properly explained"' },
|
|
4803
|
+
buzzword: { tier: "block", label: "forbidden phrase" },
|
|
4804
|
+
"negative-reassurance": { tier: "block", label: "negative reassurance, say what they get" },
|
|
4805
|
+
// Evidence of a pasted chat reply, not a matter of style. Moved from review
|
|
4806
|
+
// on 2026-09-24 after its estate hits were read (ROADMAP.md): 15 of 15 real.
|
|
4807
|
+
"chatbot-residue": { tier: "block", label: "chatbot residue. A chat reply was pasted in: remove it, and source or cut the claim it backed" },
|
|
4808
|
+
"review-phrase": { tier: "review", label: "review tier" },
|
|
4809
|
+
"ai-vocabulary": { tier: "review", label: "AI vocabulary" },
|
|
4810
|
+
"stock-phrase": { tier: "review", label: "stock phrase" },
|
|
4811
|
+
"hollow-imperative": { tier: "review", label: "hollow imperative" },
|
|
4812
|
+
"rhetorical-opener": { tier: "review", label: "rhetorical question opener" },
|
|
4813
|
+
"staccato-triplet": { tier: "review", label: "staccato triplet" },
|
|
4814
|
+
"where-x-meets-y": { tier: "review", label: "'where X meets Y'" },
|
|
4815
|
+
"ing-tail": { tier: "review", label: "empty -ing tail" },
|
|
4816
|
+
"vague-attribution": { tier: "review", label: "unnamed source" },
|
|
4817
|
+
"closing-summary": { tier: "review", label: "closing summary" },
|
|
4818
|
+
"false-range": { tier: "review", label: "false range" },
|
|
4819
|
+
"placeholder": { tier: "review", label: "unfilled placeholder" },
|
|
4820
|
+
// Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
|
|
4821
|
+
// a proposal can honestly say "the attached research". Review until measured there.
|
|
4822
|
+
"prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
|
|
4823
|
+
"question-reveal": { tier: "review", label: "staged reveal" },
|
|
4824
|
+
"inline-label-list": { tier: "review", label: "bold-label bullets" },
|
|
4825
|
+
"phrase-density": { tier: "review", label: "density" },
|
|
4826
|
+
"aphorism-density": { tier: "review", label: "density" },
|
|
4827
|
+
"contraction-scarcity": { tier: "review", label: "density" },
|
|
4828
|
+
"sentence-rhythm": { tier: "review", label: "density" },
|
|
4829
|
+
"repeated-sentence": { tier: "review", label: "repetition" },
|
|
4830
|
+
"heading-shape": { tier: "review", label: "headings" },
|
|
4831
|
+
"heading-echo": { tier: "review", label: "headings" },
|
|
4832
|
+
// A threshold on a count, not a named pattern: a person decides whether the
|
|
4833
|
+
// headline needed a particular. Review until its estate hits are read.
|
|
4834
|
+
"generic-hero-claim": { tier: "review", label: "headline that fits any business" },
|
|
4835
|
+
// Banned only "when vague", per the rules. Too noisy for a sweep.
|
|
4836
|
+
"vague-word": { tier: "explicit", label: "vague" }
|
|
4837
|
+
};
|
|
4838
|
+
function houseRule(id, name = id) {
|
|
4839
|
+
return HOUSE[id] ?? { tier: "review", label: name };
|
|
4840
|
+
}
|
|
4841
|
+
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";
|
|
4842
|
+
function holdHouseBlocks(exceptions, severity) {
|
|
4843
|
+
const blocks = (tell) => HOUSE[tell]?.tier === "block";
|
|
4844
|
+
const refusedSeverity = Object.entries(severity).filter(([tell, o]) => blocks(tell) && o.level !== "block").map(([tell]) => tell);
|
|
4845
|
+
return {
|
|
4846
|
+
exceptions: exceptions.filter((e) => !blocks(e.tell)),
|
|
4847
|
+
refused: exceptions.filter((e) => blocks(e.tell)),
|
|
4848
|
+
severity: Object.fromEntries(Object.entries(severity).filter(([tell]) => !refusedSeverity.includes(tell))),
|
|
4849
|
+
refusedSeverity
|
|
4181
4850
|
};
|
|
4182
|
-
const out = { ...base };
|
|
4183
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
4184
|
-
const current = base[key];
|
|
4185
|
-
out[key] = value && typeof value === "object" && !Array.isArray(value) && current && typeof current === "object" && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
4186
|
-
}
|
|
4187
|
-
return out;
|
|
4188
4851
|
}
|
|
4189
4852
|
|
|
4190
4853
|
// src/snapshot/fonts.ts
|
|
@@ -4304,7 +4967,7 @@ function blueToPurple(s) {
|
|
|
4304
4967
|
}
|
|
4305
4968
|
return hits;
|
|
4306
4969
|
}
|
|
4307
|
-
var
|
|
4970
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
4308
4971
|
var RENDERED_PATHS = {
|
|
4309
4972
|
"reflex-font": {
|
|
4310
4973
|
detect: reflexFonts(REFLEX_FONTS_1),
|
|
@@ -4342,11 +5005,11 @@ var RENDERED_PATHS = {
|
|
|
4342
5005
|
}
|
|
4343
5006
|
},
|
|
4344
5007
|
"gradient-text": {
|
|
4345
|
-
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${
|
|
5008
|
+
detect: (s) => s.effects.gradientText > 0 ? [hit(s, `${count2(s.effects.gradientText, "heading")} painted with a gradient`)] : [],
|
|
4346
5009
|
fixtures: { flag: [makeSnapshot({ effects: { gradientText: 1 } })], pass: [makeSnapshot()] }
|
|
4347
5010
|
},
|
|
4348
5011
|
"glass-panel": {
|
|
4349
|
-
detect: (s) => s.effects.glass > 0 ? [hit(s, `${
|
|
5012
|
+
detect: (s) => s.effects.glass > 0 ? [hit(s, `${count2(s.effects.glass, "translucent blurred panel")}`)] : [],
|
|
4350
5013
|
fixtures: { flag: [makeSnapshot({ effects: { glass: 2 } })], pass: [makeSnapshot()] }
|
|
4351
5014
|
},
|
|
4352
5015
|
"cream-palette": {
|
|
@@ -4424,7 +5087,7 @@ var RENDERED_PATHS = {
|
|
|
4424
5087
|
detect: (s) => {
|
|
4425
5088
|
const radial = s.effects.gradients.filter((g) => g.radial).length;
|
|
4426
5089
|
const n = s.effects.glows + radial;
|
|
4427
|
-
return n > 0 ? [hit(s, `${
|
|
5090
|
+
return n > 0 ? [hit(s, `${count2(n, "soft glow")} behind the content`)] : [];
|
|
4428
5091
|
},
|
|
4429
5092
|
fixtures: {
|
|
4430
5093
|
flag: [makeSnapshot({ effects: { glows: 1 } }), makeSnapshot({ effects: { gradients: [{ stops: ["rgba(99, 102, 241, 0.3)", "rgba(0, 0, 0, 0)"], area: 9e5, radial: true }] } })],
|
|
@@ -4432,11 +5095,11 @@ var RENDERED_PATHS = {
|
|
|
4432
5095
|
}
|
|
4433
5096
|
},
|
|
4434
5097
|
"grid-background": {
|
|
4435
|
-
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${
|
|
5098
|
+
detect: (s) => s.effects.gridBackgrounds > 0 ? [hit(s, `${count2(s.effects.gridBackgrounds, "graph-paper background")}`)] : [],
|
|
4436
5099
|
fixtures: { flag: [makeSnapshot({ effects: { gridBackgrounds: 1 } })], pass: [makeSnapshot()] }
|
|
4437
5100
|
},
|
|
4438
5101
|
marquee: {
|
|
4439
|
-
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${
|
|
5102
|
+
detect: (s) => s.effects.marquees > 0 ? [hit(s, `${count2(s.effects.marquees, "endless scrolling strip")}`)] : [],
|
|
4440
5103
|
fixtures: { flag: [makeSnapshot({ effects: { marquees: 1 } })], pass: [makeSnapshot()] }
|
|
4441
5104
|
},
|
|
4442
5105
|
"thin-border-wide-shadow": {
|
|
@@ -4554,9 +5217,14 @@ function faceDistance(a, b) {
|
|
|
4554
5217
|
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
4555
5218
|
return a.class === b.class ? 0.5 : 1;
|
|
4556
5219
|
}
|
|
5220
|
+
var ACCENT_SCALE = 0.3;
|
|
5221
|
+
var GROUND_SCALE = 0.15;
|
|
5222
|
+
var SHARED_PART = 0.2;
|
|
5223
|
+
var ACCENT_SHARED = ACCENT_SCALE * SHARED_PART;
|
|
5224
|
+
var GROUND_SHARED = GROUND_SCALE * SHARED_PART;
|
|
4557
5225
|
function fingerprintDistance(a, b) {
|
|
4558
|
-
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) /
|
|
4559
|
-
const ground = clamp(deltaEOk(a.ground, b.ground) /
|
|
5226
|
+
const accent = a.accent && b.accent ? clamp(deltaEOk(a.accent, b.accent) / ACCENT_SCALE) : a.accent || b.accent ? 1 : 0;
|
|
5227
|
+
const ground = clamp(deltaEOk(a.ground, b.ground) / GROUND_SCALE);
|
|
4560
5228
|
const type = 0.6 * faceDistance(a.display, b.display) + 0.4 * faceDistance(a.body, b.body);
|
|
4561
5229
|
const shape = a.roundness === null || b.roundness === null ? a.roundness === b.roundness ? 0 : 0.5 : clamp(Math.abs(a.roundness - b.roundness) / 0.5);
|
|
4562
5230
|
const motion = clamp(Math.abs(a.motion - b.motion));
|
|
@@ -4667,8 +5335,8 @@ function sharedChoices(target2, runs) {
|
|
|
4667
5335
|
const out = [];
|
|
4668
5336
|
for (const [part, values] of Object.entries(mine)) {
|
|
4669
5337
|
for (const value of values) {
|
|
4670
|
-
const
|
|
4671
|
-
if (
|
|
5338
|
+
const count4 = all.filter((c) => c[part].includes(value)).length;
|
|
5339
|
+
if (count4 / runs.length >= 0.25) out.push({ part, value, runs: count4, of: runs.length });
|
|
4672
5340
|
}
|
|
4673
5341
|
}
|
|
4674
5342
|
return out.sort((a, b) => b.runs - a.runs);
|
|
@@ -4691,10 +5359,10 @@ var STOP = new Set(
|
|
|
4691
5359
|
function grams(text) {
|
|
4692
5360
|
const out = /* @__PURE__ */ new Set();
|
|
4693
5361
|
for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
|
|
4694
|
-
const
|
|
5362
|
+
const words3 = clause.match(/[a-z][a-z'-]*/g) ?? [];
|
|
4695
5363
|
for (let n = 2; n <= 4; n += 1) {
|
|
4696
|
-
for (let i = 0; i + n <=
|
|
4697
|
-
const g =
|
|
5364
|
+
for (let i = 0; i + n <= words3.length; i += 1) {
|
|
5365
|
+
const g = words3.slice(i, i + n);
|
|
4698
5366
|
if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
|
|
4699
5367
|
if (g.filter((w) => !STOP.has(w)).length < 2) continue;
|
|
4700
5368
|
out.add(g.join(" "));
|
|
@@ -4861,7 +5529,7 @@ function closestComponents(a, b, role) {
|
|
|
4861
5529
|
}
|
|
4862
5530
|
function estateComponentPairs(register, role) {
|
|
4863
5531
|
const out = [];
|
|
4864
|
-
const s = register.sites;
|
|
5532
|
+
const s = register.sites.filter((site) => site.fingerprint);
|
|
4865
5533
|
for (let i = 0; i < s.length; i += 1) {
|
|
4866
5534
|
for (let j = i + 1; j < s.length; j += 1) {
|
|
4867
5535
|
const c = closestComponents(s[i].fingerprint, s[j].fingerprint, role);
|
|
@@ -5142,7 +5810,7 @@ var picture = (patch = {}) => ({
|
|
|
5142
5810
|
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
5143
5811
|
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
5144
5812
|
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
5145
|
-
var
|
|
5813
|
+
var count3 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
5146
5814
|
var IMAGERY_TELLS = [
|
|
5147
5815
|
{
|
|
5148
5816
|
id: "stock-photo",
|
|
@@ -5157,7 +5825,7 @@ var IMAGERY_TELLS = [
|
|
|
5157
5825
|
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
5158
5826
|
if (found.length === 0) return [];
|
|
5159
5827
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5160
|
-
return [hit3(s, `${
|
|
5828
|
+
return [hit3(s, `${count3(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
5161
5829
|
},
|
|
5162
5830
|
fixtures: {
|
|
5163
5831
|
flag: [
|
|
@@ -5182,7 +5850,7 @@ var IMAGERY_TELLS = [
|
|
|
5182
5850
|
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
5183
5851
|
if (found.length === 0) return [];
|
|
5184
5852
|
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
5185
|
-
return [hit3(s, `${
|
|
5853
|
+
return [hit3(s, `${count3(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
5186
5854
|
},
|
|
5187
5855
|
fixtures: {
|
|
5188
5856
|
flag: [
|
|
@@ -5207,7 +5875,7 @@ var IMAGERY_TELLS = [
|
|
|
5207
5875
|
if (found.length === 0) return [];
|
|
5208
5876
|
const first = found[0].provenance;
|
|
5209
5877
|
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
5210
|
-
return [hit3(s, `${
|
|
5878
|
+
return [hit3(s, `${count3(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
5211
5879
|
},
|
|
5212
5880
|
fixtures: {
|
|
5213
5881
|
flag: [
|
|
@@ -5232,7 +5900,7 @@ var IMAGERY_TELLS = [
|
|
|
5232
5900
|
if (!s.images || s.sections.length < 4) return [];
|
|
5233
5901
|
if (s.images.some((i) => i.role === "photo")) return [];
|
|
5234
5902
|
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
5235
|
-
return [hit3(s, `no photograph on the page${icons ? `: ${
|
|
5903
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count3(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
5236
5904
|
},
|
|
5237
5905
|
fixtures: {
|
|
5238
5906
|
flag: [
|
|
@@ -5247,11 +5915,13 @@ var IMAGERY_TELLS = [
|
|
|
5247
5915
|
];
|
|
5248
5916
|
|
|
5249
5917
|
// src/character/check.ts
|
|
5250
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
5918
|
+
var CATALOGUE_VERSION = "2026.09.12";
|
|
5251
5919
|
var CATALOGUE = [
|
|
5252
5920
|
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
5921
|
+
...SPECIFICITY_COPY_TELLS,
|
|
5253
5922
|
...RENDERED_TELLS,
|
|
5254
|
-
...IMAGERY_TELLS
|
|
5923
|
+
...IMAGERY_TELLS,
|
|
5924
|
+
...SPECIFICITY_RENDERED_TELLS
|
|
5255
5925
|
];
|
|
5256
5926
|
function tellById(id) {
|
|
5257
5927
|
return CATALOGUE.find((t) => t.id === id);
|
|
@@ -5328,7 +5998,7 @@ function report(tells, files, run, options) {
|
|
|
5328
5998
|
catalogueVersion: CATALOGUE_VERSION,
|
|
5329
5999
|
findings,
|
|
5330
6000
|
rejectedExceptions: rejected,
|
|
5331
|
-
excepted: [...excepted].map(([e,
|
|
6001
|
+
excepted: [...excepted].map(([e, count4]) => ({ tell: e.tell, because: e.because, count: count4 })),
|
|
5332
6002
|
suppressed,
|
|
5333
6003
|
summary: {
|
|
5334
6004
|
files: files.length,
|
|
@@ -5369,7 +6039,7 @@ function runTell(tell, files) {
|
|
|
5369
6039
|
}
|
|
5370
6040
|
function catalogueTable() {
|
|
5371
6041
|
const rows = CATALOGUE.map(
|
|
5372
|
-
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
6042
|
+
(t) => `| \`${t.id}\` | ${t.generation} | ${t.surface}${t.rendered && t.surface !== "rendered" ? " + rendered" : ""} | ${t.severity} | ${t.name} | ${t.why.replace(/\|/g, "\\|")} |`
|
|
5373
6043
|
);
|
|
5374
6044
|
return [
|
|
5375
6045
|
`Catalogue version \`${CATALOGUE_VERSION}\`, ${CATALOGUE.length} tells.`,
|
|
@@ -5434,87 +6104,6 @@ function formatReport(report2, title) {
|
|
|
5434
6104
|
return lines.join("\n").replace(/^\n/, "");
|
|
5435
6105
|
}
|
|
5436
6106
|
|
|
5437
|
-
// src/character/facts.ts
|
|
5438
|
-
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)?)";
|
|
5439
|
-
var PATTERNS = [
|
|
5440
|
-
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
5441
|
-
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
5442
|
-
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
5443
|
-
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
5444
|
-
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
5445
|
-
["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")],
|
|
5446
|
-
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
5447
|
-
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
5448
|
-
];
|
|
5449
|
-
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
5450
|
-
var normalise = (kind, value) => {
|
|
5451
|
-
const v = value.trim();
|
|
5452
|
-
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
5453
|
-
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
5454
|
-
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
5455
|
-
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
5456
|
-
return v.replace(/\s+/g, " ").toLowerCase();
|
|
5457
|
-
};
|
|
5458
|
-
function visibleText(file) {
|
|
5459
|
-
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
5460
|
-
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
5461
|
-
}
|
|
5462
|
-
function protectedFacts(text) {
|
|
5463
|
-
const facts = [];
|
|
5464
|
-
let rest = text;
|
|
5465
|
-
const mask = (start, length) => {
|
|
5466
|
-
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
5467
|
-
};
|
|
5468
|
-
for (const [kind, pattern] of PATTERNS) {
|
|
5469
|
-
for (const m of [...rest.matchAll(pattern)]) {
|
|
5470
|
-
const value = m[0].replace(/[.,;:]+$/, "");
|
|
5471
|
-
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
5472
|
-
mask(m.index ?? 0, m[0].length);
|
|
5473
|
-
}
|
|
5474
|
-
}
|
|
5475
|
-
const lines = rest.split("\n");
|
|
5476
|
-
for (const line of lines) {
|
|
5477
|
-
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
5478
|
-
for (const m of line.matchAll(NAME)) {
|
|
5479
|
-
const at = m.index ?? 0;
|
|
5480
|
-
const before = line.slice(0, at).trimEnd();
|
|
5481
|
-
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
5482
|
-
const word = m[0];
|
|
5483
|
-
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
5484
|
-
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
5485
|
-
if (word === "I") continue;
|
|
5486
|
-
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
5487
|
-
}
|
|
5488
|
-
}
|
|
5489
|
-
return facts;
|
|
5490
|
-
}
|
|
5491
|
-
var unique = (facts) => [...new Map(facts.map((f4) => [f4.key, f4])).values()];
|
|
5492
|
-
function compareFacts(before, after) {
|
|
5493
|
-
const a = unique(protectedFacts(before));
|
|
5494
|
-
const b = unique(protectedFacts(after));
|
|
5495
|
-
const inA = new Set(a.map((f4) => f4.key));
|
|
5496
|
-
const inB = new Set(b.map((f4) => f4.key));
|
|
5497
|
-
return {
|
|
5498
|
-
before: a.length,
|
|
5499
|
-
after: b.length,
|
|
5500
|
-
lost: a.filter((f4) => !inB.has(f4.key)),
|
|
5501
|
-
added: b.filter((f4) => !inA.has(f4.key))
|
|
5502
|
-
};
|
|
5503
|
-
}
|
|
5504
|
-
function formatComparison(result, beforePath, afterPath) {
|
|
5505
|
-
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
5506
|
-
if (result.lost.length) {
|
|
5507
|
-
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
5508
|
-
for (const f4 of result.lost) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5509
|
-
}
|
|
5510
|
-
if (result.added.length) {
|
|
5511
|
-
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
5512
|
-
for (const f4 of result.added) lines.push(` ${f4.kind.padEnd(9)} ${f4.value}`);
|
|
5513
|
-
}
|
|
5514
|
-
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
5515
|
-
return lines.join("\n");
|
|
5516
|
-
}
|
|
5517
|
-
|
|
5518
6107
|
// src/character/claims.ts
|
|
5519
6108
|
var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
|
|
5520
6109
|
var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
|
|
@@ -5534,9 +6123,9 @@ var ATTRIBUTION = [
|
|
|
5534
6123
|
];
|
|
5535
6124
|
var LINK2 = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
|
|
5536
6125
|
function cleanName(raw) {
|
|
5537
|
-
const
|
|
5538
|
-
while (
|
|
5539
|
-
return
|
|
6126
|
+
const words3 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
|
|
6127
|
+
while (words3.length && NOT_A_SOURCE.test(words3[0])) words3.shift();
|
|
6128
|
+
return words3.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
|
|
5540
6129
|
}
|
|
5541
6130
|
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)$/;
|
|
5542
6131
|
var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
|
|
@@ -5629,6 +6218,207 @@ function formatClaims(report2, label = "craft copy claims") {
|
|
|
5629
6218
|
return lines.join("\n");
|
|
5630
6219
|
}
|
|
5631
6220
|
|
|
6221
|
+
// src/character/shape.ts
|
|
6222
|
+
var ABBREVIATIONS = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No|approx|incl|Ltd|Co|Inc|Jr|Sr)\./g;
|
|
6223
|
+
var DOT = "\0";
|
|
6224
|
+
function splitSentences(text) {
|
|
6225
|
+
const guarded = text.replace(/\s+/g, " ").replace(ABBREVIATIONS, (m) => m.replace(/\./g, DOT)).replace(/(\d)\.(\d)/g, `$1${DOT}$2`);
|
|
6226
|
+
return guarded.split(/(?<=[.!?]["'’”)]*)\s+(?=["'‘“(]?[A-Z0-9£$€])/).map((s) => s.split(DOT).join(".").trim()).filter((s) => words(s).length > 0);
|
|
6227
|
+
}
|
|
6228
|
+
var WORD3 = /[A-Za-z0-9£$€][A-Za-z0-9£$€%'’-]*/g;
|
|
6229
|
+
function words(text) {
|
|
6230
|
+
return text.match(WORD3) ?? [];
|
|
6231
|
+
}
|
|
6232
|
+
var lower = (w) => w.toLowerCase().replace(/’/g, "'");
|
|
6233
|
+
var FUNCTION_WORDS = new Set(
|
|
6234
|
+
"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(" ")
|
|
6235
|
+
);
|
|
6236
|
+
var ITEM = "[A-Za-z0-9\xA3$\u20AC'\u2019-]+(?: [A-Za-z0-9\xA3$\u20AC'\u2019-]+){0,5}";
|
|
6237
|
+
var TRICOLON = new RegExp(`(${ITEM}), (${ITEM}),? (and|or) (${ITEM})(?=[,.;:!?)]|$)`, "g");
|
|
6238
|
+
function tricolons(sentence) {
|
|
6239
|
+
const s = sentence.replace(/\s+/g, " ");
|
|
6240
|
+
const out = [];
|
|
6241
|
+
for (const m of s.matchAll(TRICOLON)) {
|
|
6242
|
+
const [, aSpan, b, , c] = m;
|
|
6243
|
+
const bWords = words(b);
|
|
6244
|
+
const cWords = words(c);
|
|
6245
|
+
if (bWords.length === 0 || cWords.length === 0 || cWords.length > 6) continue;
|
|
6246
|
+
const aWords = words(aSpan);
|
|
6247
|
+
const a = aWords.slice(-Math.max(1, bWords.length));
|
|
6248
|
+
const lead = lower(bWords[0]);
|
|
6249
|
+
const parallel = bWords.length >= 2 && FUNCTION_WORDS.has(lead) && (lower(cWords[0]) === lead || lower(a[0]) === lead);
|
|
6250
|
+
out.push({ text: m[0], parallel });
|
|
6251
|
+
}
|
|
6252
|
+
return out;
|
|
6253
|
+
}
|
|
6254
|
+
var STACKED = /,\s+(?:and|but|so|or)\s+(?:if|when|where|once|unless|provided)\b[^,.;!?]{1,60},/i;
|
|
6255
|
+
function stackedConditional(sentence) {
|
|
6256
|
+
return STACKED.test(sentence);
|
|
6257
|
+
}
|
|
6258
|
+
var SUBORDINATOR = /\b(?:if|when|which|because|so|while|where)\b/gi;
|
|
6259
|
+
function clauseDepth(sentence) {
|
|
6260
|
+
return (sentence.match(/,/g) ?? []).length + (sentence.match(SUBORDINATOR) ?? []).length;
|
|
6261
|
+
}
|
|
6262
|
+
var HEDGES = [
|
|
6263
|
+
"if it's a fit",
|
|
6264
|
+
"if it is a fit",
|
|
6265
|
+
"where needed",
|
|
6266
|
+
"if needed",
|
|
6267
|
+
"as needed",
|
|
6268
|
+
"where appropriate",
|
|
6269
|
+
"if appropriate",
|
|
6270
|
+
"as appropriate",
|
|
6271
|
+
"as required",
|
|
6272
|
+
"where required",
|
|
6273
|
+
"if required",
|
|
6274
|
+
"where possible",
|
|
6275
|
+
"if possible",
|
|
6276
|
+
"where relevant",
|
|
6277
|
+
"if relevant",
|
|
6278
|
+
"depending on",
|
|
6279
|
+
"can help",
|
|
6280
|
+
"tend to",
|
|
6281
|
+
"tends to",
|
|
6282
|
+
"may",
|
|
6283
|
+
"might",
|
|
6284
|
+
"could",
|
|
6285
|
+
"typically",
|
|
6286
|
+
"usually",
|
|
6287
|
+
"generally",
|
|
6288
|
+
"often",
|
|
6289
|
+
"potentially",
|
|
6290
|
+
"likely"
|
|
6291
|
+
];
|
|
6292
|
+
var HEDGE = new RegExp(`\\b(?:${HEDGES.map((h) => h.replace(/'/g, "['\u2019]").replace(/ /g, "\\s+")).join("|")})\\b`, "gi");
|
|
6293
|
+
var CONDITIONAL_CLOSE = /\b(?:if|unless|where|provided|depending)\b/i;
|
|
6294
|
+
function hedgeCount(text) {
|
|
6295
|
+
return (text.match(HEDGE) ?? []).length;
|
|
6296
|
+
}
|
|
6297
|
+
function hedgedClose(sentences3) {
|
|
6298
|
+
const last = sentences3[sentences3.length - 1];
|
|
6299
|
+
if (!last) return false;
|
|
6300
|
+
HEDGE.lastIndex = 0;
|
|
6301
|
+
return HEDGE.test(last) || CONDITIONAL_CLOSE.test(last);
|
|
6302
|
+
}
|
|
6303
|
+
var NOMINAL = /^[a-z]{3,}(?:tion|sion|ment|ness|ity|ance|ence)s?$/;
|
|
6304
|
+
var NOT_NOMINAL = new Set(
|
|
6305
|
+
[
|
|
6306
|
+
"nation station ration lotion potion portion question moment cement comment element garment segment fragment apartment document ointment",
|
|
6307
|
+
"city pity entity quality sentence science audience silence balance finance distance instance entrance substance romance province"
|
|
6308
|
+
].join(" ").split(" ").flatMap((w) => [w, `${w}s`])
|
|
6309
|
+
);
|
|
6310
|
+
function nominalisations(text) {
|
|
6311
|
+
return words(text).filter((w) => {
|
|
6312
|
+
const l = lower(w);
|
|
6313
|
+
return NOMINAL.test(l) && !NOT_NOMINAL.has(l);
|
|
6314
|
+
}).length;
|
|
6315
|
+
}
|
|
6316
|
+
var PRONOUN_OPENER = /^["'‘“(]?(?:you|your|we|our)\b/i;
|
|
6317
|
+
var TRANSITION_OPENER = /^["'‘“(]?(?:however|moreover|additionally|furthermore|thus|therefore|consequently|in addition|as a result|ultimately|overall|importantly|notably)\b/i;
|
|
6318
|
+
var IMPERSONAL_OPENER = /^["'‘“(]?(?:it is|it's|it’s|there is|there are|there's|there’s)\b/i;
|
|
6319
|
+
var NOT_PARTICIPLE = new Set(
|
|
6320
|
+
"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(" ")
|
|
6321
|
+
);
|
|
6322
|
+
function participialClauses(sentence) {
|
|
6323
|
+
let n = 0;
|
|
6324
|
+
for (const m of sentence.matchAll(/,\s+([A-Za-z]{2,}ing)\b/g)) if (!NOT_PARTICIPLE.has(lower(m[1]))) n += 1;
|
|
6325
|
+
const open = sentence.match(/^["'‘“(]?([A-Z][a-z]{2,}ing)\b[^,]{0,60},/);
|
|
6326
|
+
if (open && !NOT_PARTICIPLE.has(lower(open[1]))) n += 1;
|
|
6327
|
+
return n;
|
|
6328
|
+
}
|
|
6329
|
+
function phrasalCoordinations(sentence) {
|
|
6330
|
+
let n = 0;
|
|
6331
|
+
for (const m of sentence.matchAll(/(^|[^,]\s)([A-Za-z][a-z'’-]{2,}) (?:and|or) ([A-Za-z][a-z'’-]{2,})\b/g)) {
|
|
6332
|
+
const a = lower(m[2]);
|
|
6333
|
+
const b = lower(m[3]);
|
|
6334
|
+
if (FUNCTION_WORDS.has(a) || FUNCTION_WORDS.has(b) || a.endsWith("ly") || b.endsWith("ly")) continue;
|
|
6335
|
+
n += 1;
|
|
6336
|
+
}
|
|
6337
|
+
return n;
|
|
6338
|
+
}
|
|
6339
|
+
function skeleton(sentence) {
|
|
6340
|
+
const out = [];
|
|
6341
|
+
for (const w of words(sentence)) {
|
|
6342
|
+
const l = lower(w);
|
|
6343
|
+
const token = FUNCTION_WORDS.has(l) ? l : "_";
|
|
6344
|
+
if (token === "_" && out[out.length - 1] === "_") continue;
|
|
6345
|
+
out.push(token);
|
|
6346
|
+
}
|
|
6347
|
+
return out;
|
|
6348
|
+
}
|
|
6349
|
+
function skeletonRepeat(sentences3) {
|
|
6350
|
+
const grams2 = /* @__PURE__ */ new Map();
|
|
6351
|
+
let total = 0;
|
|
6352
|
+
for (const s of sentences3) {
|
|
6353
|
+
const sk = skeleton(s);
|
|
6354
|
+
for (let i = 0; i + 4 <= sk.length; i += 1) {
|
|
6355
|
+
const g = sk.slice(i, i + 4).join(" ");
|
|
6356
|
+
grams2.set(g, (grams2.get(g) ?? 0) + 1);
|
|
6357
|
+
total += 1;
|
|
6358
|
+
}
|
|
6359
|
+
}
|
|
6360
|
+
if (total === 0) return 0;
|
|
6361
|
+
let repeated = 0;
|
|
6362
|
+
for (const count4 of grams2.values()) if (count4 > 1) repeated += count4;
|
|
6363
|
+
return repeated / total;
|
|
6364
|
+
}
|
|
6365
|
+
var mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
|
|
6366
|
+
var sd = (xs) => {
|
|
6367
|
+
const m = mean(xs);
|
|
6368
|
+
return Math.sqrt(xs.reduce((s, x) => s + (x - m) ** 2, 0) / xs.length);
|
|
6369
|
+
};
|
|
6370
|
+
function cv(xs, min) {
|
|
6371
|
+
if (xs.length < min) return null;
|
|
6372
|
+
const m = mean(xs);
|
|
6373
|
+
return m === 0 ? null : sd(xs) / m;
|
|
6374
|
+
}
|
|
6375
|
+
function shapeOf(text) {
|
|
6376
|
+
const ss = splitSentences(text);
|
|
6377
|
+
const wordCount = words(text).length;
|
|
6378
|
+
const per100 = (n) => wordCount === 0 ? 0 : 100 * n / wordCount;
|
|
6379
|
+
const share = (re) => ss.length === 0 ? 0 : ss.filter((s) => re.test(s)).length / ss.length;
|
|
6380
|
+
const tri = ss.flatMap(tricolons);
|
|
6381
|
+
const commas = (text.match(/,/g) ?? []).length;
|
|
6382
|
+
return {
|
|
6383
|
+
sentences: ss.length,
|
|
6384
|
+
words: wordCount,
|
|
6385
|
+
tricolons: tri.length,
|
|
6386
|
+
parallelTriads: tri.filter((t) => t.parallel).length,
|
|
6387
|
+
stackedConditionals: ss.filter(stackedConditional).length,
|
|
6388
|
+
clauseDepth: ss.length === 0 ? 0 : mean(ss.map(clauseDepth)),
|
|
6389
|
+
sentenceCv: cv(ss.map((s) => words(s).length), 3),
|
|
6390
|
+
hedgeRate: per100(hedgeCount(text)),
|
|
6391
|
+
hedgedClose: hedgedClose(ss),
|
|
6392
|
+
nominalisationRate: per100(nominalisations(text)),
|
|
6393
|
+
pronounOpenerShare: share(PRONOUN_OPENER),
|
|
6394
|
+
transitionOpenerShare: share(TRANSITION_OPENER),
|
|
6395
|
+
impersonalOpenerShare: share(IMPERSONAL_OPENER),
|
|
6396
|
+
commasPerSentence: ss.length === 0 ? 0 : commas / ss.length,
|
|
6397
|
+
commasPer100: per100(commas),
|
|
6398
|
+
participialRate: ss.length === 0 ? 0 : ss.reduce((s, x) => s + participialClauses(x), 0) / ss.length,
|
|
6399
|
+
coordinationRate: per100(ss.reduce((s, x) => s + phrasalCoordinations(x), 0)),
|
|
6400
|
+
skeletonRepeat: skeletonRepeat(ss)
|
|
6401
|
+
};
|
|
6402
|
+
}
|
|
6403
|
+
function shapeMoves(f4) {
|
|
6404
|
+
const out = [];
|
|
6405
|
+
if (f4.parallelTriads > 0) out.push("parallel triad");
|
|
6406
|
+
else if (f4.tricolons > 0) out.push("tricolon");
|
|
6407
|
+
if (f4.stackedConditionals > 0) out.push("stacked conditional");
|
|
6408
|
+
if (f4.hedgedClose) out.push("hedged close");
|
|
6409
|
+
if (f4.participialRate > 0) out.push("participial clause");
|
|
6410
|
+
return out;
|
|
6411
|
+
}
|
|
6412
|
+
function paragraphUniformity(paragraphs) {
|
|
6413
|
+
return cv(
|
|
6414
|
+
paragraphs.map((p) => words(p).length).filter((n) => n > 0),
|
|
6415
|
+
4
|
|
6416
|
+
);
|
|
6417
|
+
}
|
|
6418
|
+
function isBlurb(f4) {
|
|
6419
|
+
return f4.sentences >= 2 && f4.sentences <= 8 && f4.words >= 20 && f4.words <= 160;
|
|
6420
|
+
}
|
|
6421
|
+
|
|
5632
6422
|
// src/character/baseline.ts
|
|
5633
6423
|
var BASELINE_VERSION = 1;
|
|
5634
6424
|
function fnv1a(text) {
|
|
@@ -5924,7 +6714,9 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
5924
6714
|
}
|
|
5925
6715
|
|
|
5926
6716
|
// src/direction/types.ts
|
|
5927
|
-
var DIRECTION_VERSION =
|
|
6717
|
+
var DIRECTION_VERSION = 2;
|
|
6718
|
+
var DIRECTION_VERSIONS = [1, 2];
|
|
6719
|
+
var CUSTOMER_KINDS = ["review", "enquiry", "conversation", "search"];
|
|
5928
6720
|
var SOURCE_KINDS = [
|
|
5929
6721
|
"livery",
|
|
5930
6722
|
"shopfront",
|
|
@@ -5938,9 +6730,413 @@ var SOURCE_KINDS = [
|
|
|
5938
6730
|
"trade",
|
|
5939
6731
|
"print",
|
|
5940
6732
|
"photo",
|
|
5941
|
-
"founder"
|
|
6733
|
+
"founder",
|
|
6734
|
+
...CUSTOMER_KINDS,
|
|
6735
|
+
"reference"
|
|
5942
6736
|
];
|
|
5943
6737
|
var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
|
|
6738
|
+
var CONSIDERATION = ["low", "considered", "high"];
|
|
6739
|
+
var PAGE_TYPES = ["home", "service", "about", "pricing", "booking", "contact", "article", "landing"];
|
|
6740
|
+
var HIERARCHY_FIELDS = ["primaryAction", "order", "lead", "journey"];
|
|
6741
|
+
|
|
6742
|
+
// src/direction/reason.ts
|
|
6743
|
+
var PROPOSED = "PROPOSED:";
|
|
6744
|
+
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;
|
|
6745
|
+
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;
|
|
6746
|
+
var CONVENTION = new RegExp(
|
|
6747
|
+
[
|
|
6748
|
+
// authority
|
|
6749
|
+
String.raw`industry[- ]standard`,
|
|
6750
|
+
String.raw`best[- ]practices?`,
|
|
6751
|
+
String.raw`the norm\b`,
|
|
6752
|
+
String.raw`standard (?:layout|structure|practice|pattern|format)`,
|
|
6753
|
+
String.raw`tried[- ]and[- ]tested`,
|
|
6754
|
+
String.raw`proven (?:layout|structure|pattern|format)`,
|
|
6755
|
+
// the crowd
|
|
6756
|
+
String.raw`(?:most|other|typical|many|all|similar)\s+(?:\w+\s+){0,2}(?:web)?sites\b`,
|
|
6757
|
+
String.raw`competitors?(?:'|’)?s?\b`,
|
|
6758
|
+
String.raw`(?:like|as) (?:everyone|everybody|all the others|the rest)\b`,
|
|
6759
|
+
String.raw`commonly used`,
|
|
6760
|
+
String.raw`(?:what|how) (?:everyone|others|the industry) (?:does|do|uses?)`,
|
|
6761
|
+
// familiarity
|
|
6762
|
+
String.raw`familiar (?:layout|structure|pattern|format|order|shape|design)`,
|
|
6763
|
+
String.raw`recogni[sz]able (?:layout|structure|pattern|format|order)`,
|
|
6764
|
+
String.raw`conventional(?:ly)?\b`,
|
|
6765
|
+
String.raw`(?:visitors|users|people|customers|anyone) (?:are|is) used to`,
|
|
6766
|
+
String.raw`feels? (?:at home|comfortable|familiar)(?: with)?(?: the)? (?:layout|site|page|structure|design)?`,
|
|
6767
|
+
String.raw`intuitive(?:ly)?\b`,
|
|
6768
|
+
String.raw`as usual\b`,
|
|
6769
|
+
String.raw`what (?:visitors|users|people) expect\b`
|
|
6770
|
+
].join("|"),
|
|
6771
|
+
"i"
|
|
6772
|
+
);
|
|
6773
|
+
var GENERIC = new Set(
|
|
6774
|
+
"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(" ")
|
|
6775
|
+
);
|
|
6776
|
+
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"]);
|
|
6777
|
+
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"]);
|
|
6778
|
+
function contentWords(text) {
|
|
6779
|
+
return new Set(
|
|
6780
|
+
(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))
|
|
6781
|
+
);
|
|
6782
|
+
}
|
|
6783
|
+
var stem = (w) => w.replace(/(?:ing|ers|er|ed|es|s)$/, "");
|
|
6784
|
+
function shares(a, b) {
|
|
6785
|
+
const bs = new Set([...b].map(stem));
|
|
6786
|
+
return [...a].filter((w) => bs.has(stem(w)));
|
|
6787
|
+
}
|
|
6788
|
+
function jobWords(job) {
|
|
6789
|
+
return contentWords(
|
|
6790
|
+
[
|
|
6791
|
+
job.statement?.verb,
|
|
6792
|
+
job.statement?.object,
|
|
6793
|
+
job.statement?.context,
|
|
6794
|
+
job.functional?.value,
|
|
6795
|
+
job.emotional?.value,
|
|
6796
|
+
job.social?.value,
|
|
6797
|
+
...Array.isArray(job.objections) ? job.objections : [],
|
|
6798
|
+
...Array.isArray(job.language) ? job.language : []
|
|
6799
|
+
].filter((x) => typeof x === "string").join(" ")
|
|
6800
|
+
);
|
|
6801
|
+
}
|
|
6802
|
+
var isCustomerVoice = (s) => Boolean(s && CUSTOMER_KINDS.includes(s.kind));
|
|
6803
|
+
function checkReason(input) {
|
|
6804
|
+
const problems = [];
|
|
6805
|
+
const err = (field, message) => problems.push({ severity: "error", at: `${input.at}.${field}`, message });
|
|
6806
|
+
const because = typeof input.because === "string" ? input.because.trim() : "";
|
|
6807
|
+
const evidence = Array.isArray(input.evidence) ? input.evidence.filter((e) => typeof e === "string") : [];
|
|
6808
|
+
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");
|
|
6809
|
+
const ids = new Map(input.sources.filter((s) => s && typeof s.id === "string").map((s) => [s.id, s]));
|
|
6810
|
+
if (because.split(/\s+/).filter(Boolean).length < 8) {
|
|
6811
|
+
err("because", "a reason is at least a sentence. Without one this is a default, however good it looks.");
|
|
6812
|
+
return problems;
|
|
6813
|
+
}
|
|
6814
|
+
if (because.startsWith(PROPOSED)) {
|
|
6815
|
+
err("because", "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
|
|
6816
|
+
return problems;
|
|
6817
|
+
}
|
|
6818
|
+
if (PREFERENCE.test(because)) err("because", "that is a preference, not a reason. Say what in the client's world this comes from.");
|
|
6819
|
+
const moods = because.match(MOOD) ?? [];
|
|
6820
|
+
if (moods.length >= 2) err("because", `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
|
|
6821
|
+
const convention = because.match(CONVENTION);
|
|
6822
|
+
if (convention) {
|
|
6823
|
+
err("because", `"${convention[0]}" is the category's default speaking, not a reason. Say what in this client's job or world it answers.`);
|
|
6824
|
+
}
|
|
6825
|
+
const needsEvidence = input.tie !== "job";
|
|
6826
|
+
if (needsEvidence && evidence.length === 0) err("evidence", "cite at least one source");
|
|
6827
|
+
for (const id of evidence) if (!ids.has(id)) err("evidence", `no source called "${id}"`);
|
|
6828
|
+
if (input.tie === "customer") {
|
|
6829
|
+
for (const id of evidence) {
|
|
6830
|
+
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(", ")}.`);
|
|
6831
|
+
}
|
|
6832
|
+
}
|
|
6833
|
+
if (input.tie === "source") {
|
|
6834
|
+
for (const id of evidence) {
|
|
6835
|
+
if (isCustomerVoice(ids.get(id))) err("evidence", `"${id}" is customer voice. Token decisions need a physical source or an outside-category reference.`);
|
|
6836
|
+
}
|
|
6837
|
+
}
|
|
6838
|
+
if (problems.length) return problems;
|
|
6839
|
+
const words3 = contentWords(because);
|
|
6840
|
+
if (input.tie === "job") {
|
|
6841
|
+
if (!input.job) {
|
|
6842
|
+
err("because", "a structural reason is checked against the job, and the file has no job map yet");
|
|
6843
|
+
} else if (shares(words3, jobWords(input.job)).length === 0) {
|
|
6844
|
+
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.");
|
|
6845
|
+
}
|
|
6846
|
+
} else {
|
|
6847
|
+
const cited = evidence.map((id) => ids.get(id));
|
|
6848
|
+
const tied = cited.some((s) => words3.has(s.id) || shares(words3, contentWords(`${s.note} ${s.lettering ?? ""}`)).length > 0);
|
|
6849
|
+
if (!tied) err("because", `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
|
|
6850
|
+
}
|
|
6851
|
+
return problems;
|
|
6852
|
+
}
|
|
6853
|
+
|
|
6854
|
+
// src/direction/job.ts
|
|
6855
|
+
var JOB_VERBS = new Set(
|
|
6856
|
+
"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(" ")
|
|
6857
|
+
);
|
|
6858
|
+
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"]);
|
|
6859
|
+
var NOUNISH = /(?:tion|sion|ment|ness|ity|site|page)$/;
|
|
6860
|
+
var words2 = (s) => s.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? [];
|
|
6861
|
+
function isCategoryLabel(object, brief = "") {
|
|
6862
|
+
const trade = /* @__PURE__ */ new Set([...TRADE_NOUNS.flatMap((t) => t.split(" ")), ...words2(brief).filter((w) => w.length > 3)]);
|
|
6863
|
+
const ws = words2(object);
|
|
6864
|
+
return ws.length > 0 && ws.every((w) => LABEL_WORDS.has(w) || trade.has(w) || trade.has(w.replace(/s$/, "")));
|
|
6865
|
+
}
|
|
6866
|
+
var normalise2 = (s) => s.toLowerCase().replace(/[“”"‘’'.,!?;:()-]/g, " ").replace(/\s+/g, " ").trim();
|
|
6867
|
+
function validateJob(job, sources, ctx = {}) {
|
|
6868
|
+
const problems = [];
|
|
6869
|
+
const err = (at, message) => problems.push({ severity: "error", at: `job${at}`, message });
|
|
6870
|
+
const warn = (at, message) => problems.push({ severity: "warn", at: `job${at}`, message });
|
|
6871
|
+
if (!job || typeof job !== "object" || Array.isArray(job)) {
|
|
6872
|
+
err("", "no job map: say what the visitor is trying to get done, in their words, before deciding structure or look");
|
|
6873
|
+
return { problems, decided: false };
|
|
6874
|
+
}
|
|
6875
|
+
const j = job;
|
|
6876
|
+
const ids = new Map(sources.map((s) => [s.id, s]));
|
|
6877
|
+
const st = j.statement;
|
|
6878
|
+
if (!st || typeof st !== "object") err(".statement", "a job statement is verb, object and context");
|
|
6879
|
+
else {
|
|
6880
|
+
const verb = typeof st.verb === "string" ? st.verb.trim().toLowerCase() : "";
|
|
6881
|
+
if (!verb) err(".statement.verb", "what the visitor is trying to do, as a verb: find, choose, book, fix");
|
|
6882
|
+
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.`);
|
|
6883
|
+
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.`);
|
|
6884
|
+
const object = typeof st.object === "string" ? st.object.trim() : "";
|
|
6885
|
+
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");
|
|
6886
|
+
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".`);
|
|
6887
|
+
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");
|
|
6888
|
+
}
|
|
6889
|
+
for (const part of ["functional", "emotional", "social"]) {
|
|
6890
|
+
const p = j[part];
|
|
6891
|
+
if (!p || typeof p !== "object") {
|
|
6892
|
+
err(`.${part}`, `the ${part} side of the job, with the customer-voice source that shows it`);
|
|
6893
|
+
continue;
|
|
6894
|
+
}
|
|
6895
|
+
if (typeof p.value !== "string" || words2(p.value).length < 6) err(`.${part}.value`, "a sentence: what they need, in the terms they would use");
|
|
6896
|
+
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") })));
|
|
6897
|
+
}
|
|
6898
|
+
const c = j.consideration;
|
|
6899
|
+
if (!c || typeof c !== "object") err(".consideration", `how long and how carefully they decide: ${CONSIDERATION.join(", ")}`);
|
|
6900
|
+
else {
|
|
6901
|
+
if (!CONSIDERATION.includes(c.value)) err(".consideration.value", `one of ${CONSIDERATION.join(", ")}`);
|
|
6902
|
+
problems.push(...checkReason({ at: "job.consideration", because: c.because, evidence: c.evidence, sources, tie: "customer" }));
|
|
6903
|
+
}
|
|
6904
|
+
const objections = Array.isArray(j.objections) ? j.objections : [];
|
|
6905
|
+
if (objections.length === 0) err(".objections", "what stops them: the worries they bring to the page");
|
|
6906
|
+
objections.forEach((o, i) => {
|
|
6907
|
+
if (typeof o !== "string" || words2(o).length < 3) err(`.objections[${i}]`, "an objection is a few words: what they worry about");
|
|
6908
|
+
});
|
|
6909
|
+
const evidence = Array.isArray(j.evidence) ? j.evidence : [];
|
|
6910
|
+
const voice = evidence.map((id) => ids.get(id)).filter(isCustomerVoice);
|
|
6911
|
+
if (evidence.length === 0 || voice.length === 0) err(".evidence", "cite the reviews, enquiries or conversations the job comes from");
|
|
6912
|
+
for (const id of evidence) if (!ids.has(id)) err(".evidence", `no source called "${id}"`);
|
|
6913
|
+
const language = Array.isArray(j.language) ? j.language : [];
|
|
6914
|
+
if (language.length === 0) err(".language", "quote the customer: the words they use for the problem");
|
|
6915
|
+
const texts = voice.map((s) => normalise2(`${s.note} ${ctx.readSource?.(s) ?? ""}`));
|
|
6916
|
+
language.forEach((q, i) => {
|
|
6917
|
+
if (typeof q !== "string" || !q.trim()) return err(`.language[${i}]`, "an empty quote");
|
|
6918
|
+
const n = normalise2(q);
|
|
6919
|
+
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.`);
|
|
6920
|
+
});
|
|
6921
|
+
return { problems, decided: problems.every((p) => p.severity !== "error") };
|
|
6922
|
+
}
|
|
6923
|
+
|
|
6924
|
+
// src/direction/hierarchy.ts
|
|
6925
|
+
var SECTION_ROLES = [
|
|
6926
|
+
"hero",
|
|
6927
|
+
"logos",
|
|
6928
|
+
"stats",
|
|
6929
|
+
"cards",
|
|
6930
|
+
"marquee",
|
|
6931
|
+
"text",
|
|
6932
|
+
"other",
|
|
6933
|
+
"cta-band",
|
|
6934
|
+
"footer-cta",
|
|
6935
|
+
"pricing",
|
|
6936
|
+
"testimonials",
|
|
6937
|
+
"faq",
|
|
6938
|
+
"process",
|
|
6939
|
+
"features",
|
|
6940
|
+
"team",
|
|
6941
|
+
"contact"
|
|
6942
|
+
];
|
|
6943
|
+
var POSITIONS = /* @__PURE__ */ new Set([...SECTION_ROLES, "header", "sticky"]);
|
|
6944
|
+
function considerationTied(because, job) {
|
|
6945
|
+
const text = because.toLowerCase();
|
|
6946
|
+
const level = job.consideration?.value;
|
|
6947
|
+
if (level && new RegExp(`\\b${level}\\b`).test(text)) return true;
|
|
6948
|
+
const said = contentWords(because);
|
|
6949
|
+
return (Array.isArray(job.objections) ? job.objections : []).some((o) => typeof o === "string" && [...contentWords(o)].some((w) => said.has(w)));
|
|
6950
|
+
}
|
|
6951
|
+
function validateHierarchy(hierarchy, job, sources) {
|
|
6952
|
+
const problems = [];
|
|
6953
|
+
const err = (at, message) => problems.push({ severity: "error", at: `hierarchy${at}`, message });
|
|
6954
|
+
const pages = [];
|
|
6955
|
+
if (!hierarchy || typeof hierarchy !== "object" || Array.isArray(hierarchy)) {
|
|
6956
|
+
err("", "no hierarchy: decide what the home page leads with, where it asks, and the order, each from the job");
|
|
6957
|
+
return { problems, pages };
|
|
6958
|
+
}
|
|
6959
|
+
const h = hierarchy;
|
|
6960
|
+
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(", ")}`);
|
|
6961
|
+
if (!h.home) err(".home", "no home page hierarchy: the home page is where most visitors start");
|
|
6962
|
+
for (const page of PAGE_TYPES) {
|
|
6963
|
+
const p = h[page];
|
|
6964
|
+
if (!p) continue;
|
|
6965
|
+
let decided = 0;
|
|
6966
|
+
const at = (f4) => `hierarchy.${page}.${f4}`;
|
|
6967
|
+
const reason = (where, because, evidence) => checkReason({ at: where, because, evidence, sources, tie: "job", job });
|
|
6968
|
+
const count4 = (found) => {
|
|
6969
|
+
problems.push(...found);
|
|
6970
|
+
if (!found.some((x) => x.severity === "error")) decided += 1;
|
|
6971
|
+
};
|
|
6972
|
+
if (!p.primaryAction) err(`.${page}.primaryAction`, "not decided yet");
|
|
6973
|
+
else {
|
|
6974
|
+
const a = p.primaryAction;
|
|
6975
|
+
const found = [];
|
|
6976
|
+
if (typeof a.value !== "string" || !a.value.trim()) found.push({ severity: "error", at: `${at("primaryAction")}.value`, message: "what the visitor is asked to do" });
|
|
6977
|
+
const positions = Array.isArray(a.positions) ? a.positions : [];
|
|
6978
|
+
if (positions.length === 0) found.push({ severity: "error", at: `${at("primaryAction")}.positions`, message: 'where the ask sits: one place, or several ("hero", "footer-cta")' });
|
|
6979
|
+
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"` });
|
|
6980
|
+
const r = reason(at("primaryAction"), a.because, a.evidence);
|
|
6981
|
+
found.push(...r);
|
|
6982
|
+
if (r.length === 0 && job && typeof a.because === "string" && !considerationTied(a.because, job)) {
|
|
6983
|
+
found.push({
|
|
6984
|
+
severity: "error",
|
|
6985
|
+
at: `${at("primaryAction")}.because`,
|
|
6986
|
+
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.`
|
|
6987
|
+
});
|
|
6988
|
+
}
|
|
6989
|
+
count4(found);
|
|
6990
|
+
}
|
|
6991
|
+
const order = Array.isArray(p.order) ? p.order : [];
|
|
6992
|
+
if (order.length < 2) err(`.${page}.order`, "the running order, two sections or more, each with why it sits there");
|
|
6993
|
+
else {
|
|
6994
|
+
const found = [];
|
|
6995
|
+
order.forEach((o, i) => {
|
|
6996
|
+
if (!o || !SECTION_ROLES.includes(o.role)) found.push({ severity: "error", at: `${at("order")}[${i}].role`, message: `a section role: ${SECTION_ROLES.join(", ")}` });
|
|
6997
|
+
found.push(...reason(`${at("order")}[${i}]`, o?.because));
|
|
6998
|
+
});
|
|
6999
|
+
count4(found);
|
|
7000
|
+
}
|
|
7001
|
+
if (!p.lead) err(`.${page}.lead`, "not decided yet");
|
|
7002
|
+
else {
|
|
7003
|
+
const found = [];
|
|
7004
|
+
if (typeof p.lead.value !== "string" || !p.lead.value.trim()) found.push({ severity: "error", at: `${at("lead")}.value`, message: "what the page leads with" });
|
|
7005
|
+
found.push(...reason(at("lead"), p.lead.because));
|
|
7006
|
+
count4(found);
|
|
7007
|
+
}
|
|
7008
|
+
const journey = Array.isArray(p.journey) ? p.journey : [];
|
|
7009
|
+
if (journey.length < 2) err(`.${page}.journey`, "the stages the visitor goes through, two or more, each with why");
|
|
7010
|
+
else {
|
|
7011
|
+
const found = [];
|
|
7012
|
+
journey.forEach((s, i) => {
|
|
7013
|
+
if (!s || typeof s.stage !== "string" || !s.stage.trim()) found.push({ severity: "error", at: `${at("journey")}[${i}].stage`, message: "name the stage" });
|
|
7014
|
+
found.push(...reason(`${at("journey")}[${i}]`, s?.because));
|
|
7015
|
+
});
|
|
7016
|
+
count4(found);
|
|
7017
|
+
}
|
|
7018
|
+
pages.push({ page, decided, total: HIERARCHY_FIELDS.length });
|
|
7019
|
+
}
|
|
7020
|
+
return { problems, pages };
|
|
7021
|
+
}
|
|
7022
|
+
function renderedOrder(snapshot) {
|
|
7023
|
+
const sections = snapshot.sections ?? [];
|
|
7024
|
+
if (sections.length === 0 || !sections.every((s) => s.role)) return null;
|
|
7025
|
+
return sections.map((s) => s.role).filter((r) => r !== "other");
|
|
7026
|
+
}
|
|
7027
|
+
function checkOrder(declared, page, snapshot, strict = false) {
|
|
7028
|
+
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" }];
|
|
7029
|
+
const rendered = renderedOrder(snapshot);
|
|
7030
|
+
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" }];
|
|
7031
|
+
const said = declared.order.map((o) => o.role).filter((r) => r !== "other");
|
|
7032
|
+
if (said.join(",") === rendered.join(",")) return [];
|
|
7033
|
+
return [{ severity: strict ? "error" : "warn", at: `hierarchy.${page}.order`, message: `the page runs ${rendered.join(", ")}; the file says ${said.join(", ")}` }];
|
|
7034
|
+
}
|
|
7035
|
+
|
|
7036
|
+
// src/direction/research.ts
|
|
7037
|
+
function tradeOf(brief) {
|
|
7038
|
+
const text = brief.toLowerCase();
|
|
7039
|
+
return TRADE_NOUNS.filter((t) => new RegExp(`\\b${t.replace(/ /g, "\\s+")}s?\\b`).test(text));
|
|
7040
|
+
}
|
|
7041
|
+
function categoryMatchesBrief(category, brief) {
|
|
7042
|
+
const known = tradeOf(category);
|
|
7043
|
+
if (tradeOf(brief).some((t) => known.includes(t))) return true;
|
|
7044
|
+
const label = category.trim().replace(/^(?:(?:a|an|the|local|independent)\s+)+/i, "");
|
|
7045
|
+
if (!label) return false;
|
|
7046
|
+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
|
|
7047
|
+
return new RegExp(`\\b${escaped}s?\\b`, "i").test(brief);
|
|
7048
|
+
}
|
|
7049
|
+
function researchPrompt(input) {
|
|
7050
|
+
const trade = tradeOf(input.brief);
|
|
7051
|
+
const category = trade.length ? trade.join(", ") : "this kind of business";
|
|
7052
|
+
const who = input.client ? `${input.client}: ${input.brief}` : input.brief;
|
|
7053
|
+
const lines = [
|
|
7054
|
+
"# Research brief: the customer's job",
|
|
7055
|
+
"",
|
|
7056
|
+
`The business: ${who}`,
|
|
7057
|
+
"",
|
|
7058
|
+
"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.",
|
|
7059
|
+
"",
|
|
7060
|
+
"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?",
|
|
7061
|
+
'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?',
|
|
7062
|
+
"3. **Objections.** What do they worry about before getting in touch? What has gone wrong for them, or people they know, before?",
|
|
7063
|
+
"4. **Their words.** The phrases they actually use for the problem and for what they want. Quote them exactly.",
|
|
7064
|
+
"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?",
|
|
7065
|
+
"6. **Consideration.** Do they decide in minutes, days or months? What do they compare before deciding?",
|
|
7066
|
+
"7. **Where they look.** What do they search for, and where do they ask?",
|
|
7067
|
+
"",
|
|
7068
|
+
"## Do not",
|
|
7069
|
+
"",
|
|
7070
|
+
`- Do not describe, list, rank or summarise what ${category} websites look like, or how they are laid out.`,
|
|
7071
|
+
'- Do not suggest colours, fonts, layouts, sections or visual "inspiration" from this category.',
|
|
7072
|
+
"- Do not summarise competitors' marketing. Their claims are the category average; the customer's words are the point.",
|
|
7073
|
+
"- Do not invent quotes. If you cannot find a source for something, say so."
|
|
7074
|
+
];
|
|
7075
|
+
if (input.visual) {
|
|
7076
|
+
lines.push(
|
|
7077
|
+
"",
|
|
7078
|
+
"# Visual references, from outside the category",
|
|
7079
|
+
"",
|
|
7080
|
+
`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.`,
|
|
7081
|
+
"",
|
|
7082
|
+
`Nothing from a ${category} website, and nothing from a web design gallery.`
|
|
7083
|
+
);
|
|
7084
|
+
}
|
|
7085
|
+
return `${lines.join("\n")}
|
|
7086
|
+
`;
|
|
7087
|
+
}
|
|
7088
|
+
|
|
7089
|
+
// src/estate/direction.ts
|
|
7090
|
+
function summariseDirection(d) {
|
|
7091
|
+
const c = d.choices ?? {};
|
|
7092
|
+
const home = d.hierarchy?.home;
|
|
7093
|
+
const s = {};
|
|
7094
|
+
if (typeof c.accent?.value === "string" && c.accent.value) s.accent = c.accent.value;
|
|
7095
|
+
if (typeof c.ground?.value === "string" && c.ground.value) s.ground = c.ground.value;
|
|
7096
|
+
if (typeof c.display?.value === "string" && c.display.value) s.display = normaliseFamily(c.display.value);
|
|
7097
|
+
if (typeof c.body?.value === "string" && c.body.value) s.body = normaliseFamily(c.body.value);
|
|
7098
|
+
if (typeof c.shape?.value === "string" && c.shape.value) s.shape = c.shape.value;
|
|
7099
|
+
if (Array.isArray(home?.order) && home.order.length) s.order = home.order.filter((o) => o && typeof o.role === "string").map((o) => o.role);
|
|
7100
|
+
if (typeof home?.primaryAction?.value === "string" && home.primaryAction.value) {
|
|
7101
|
+
s.primaryAction = home.primaryAction.value;
|
|
7102
|
+
s.positions = Array.isArray(home.primaryAction.positions) ? [...home.primaryAction.positions] : [];
|
|
7103
|
+
}
|
|
7104
|
+
return s;
|
|
7105
|
+
}
|
|
7106
|
+
var delta = (a, b) => {
|
|
7107
|
+
const x = a ? parseColour(a) : null;
|
|
7108
|
+
const y = b ? parseColour(b) : null;
|
|
7109
|
+
return x && y ? deltaEOk(x.oklch, y.oklch) : null;
|
|
7110
|
+
};
|
|
7111
|
+
var same = (a, b) => Boolean(a && b && a.trim().toLowerCase() === b.trim().toLowerCase());
|
|
7112
|
+
function compareDirections(a, b) {
|
|
7113
|
+
const shared = [];
|
|
7114
|
+
const accentDelta = delta(a.accent, b.accent);
|
|
7115
|
+
const groundDelta = delta(a.ground, b.ground);
|
|
7116
|
+
if (accentDelta !== null && accentDelta <= ACCENT_SHARED) shared.push(`accent (\u0394E ${accentDelta.toFixed(3)})`);
|
|
7117
|
+
if (groundDelta !== null && groundDelta <= GROUND_SHARED) shared.push(`ground (\u0394E ${groundDelta.toFixed(3)})`);
|
|
7118
|
+
if (a.display && b.display && same(normaliseFamily(a.display), normaliseFamily(b.display))) shared.push(`${normaliseFamily(a.display)} headline`);
|
|
7119
|
+
if (a.body && b.body && same(normaliseFamily(a.body), normaliseFamily(b.body))) shared.push(`${normaliseFamily(a.body)} body`);
|
|
7120
|
+
if (same(a.shape, b.shape)) shared.push(`shape "${a.shape}"`);
|
|
7121
|
+
let orderDistance = null;
|
|
7122
|
+
if (a.order?.length && b.order?.length) {
|
|
7123
|
+
orderDistance = levenshtein(a.order, b.order) / Math.max(a.order.length, b.order.length);
|
|
7124
|
+
if (orderDistance <= SHARED_PART) shared.push(orderDistance === 0 ? "the same section order" : "nearly the same section order");
|
|
7125
|
+
}
|
|
7126
|
+
if (same(a.primaryAction, b.primaryAction) && [...a.positions ?? []].sort().join(",") === [...b.positions ?? []].sort().join(",")) shared.push(`"${a.primaryAction}" asked in the same places`);
|
|
7127
|
+
const surface = shared.filter((s) => /^(?:accent|ground|shape)|headline$/.test(s)).length;
|
|
7128
|
+
const flagged = surface >= 2 || orderDistance !== null && orderDistance <= SHARED_PART;
|
|
7129
|
+
return { shared, accentDelta, groundDelta, orderDistance, flagged };
|
|
7130
|
+
}
|
|
7131
|
+
function compareToDeclared(target2, sites, exclude) {
|
|
7132
|
+
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));
|
|
7133
|
+
}
|
|
7134
|
+
function formatDirectionMatches(label, matches) {
|
|
7135
|
+
const lines = [`${label}: declared directions in the estate (ranked, not judged: no sibling line yet)`];
|
|
7136
|
+
for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${m.flagged ? "LOOK " : " "}${m.shared.length ? `shares ${m.shared.join(", ")}` : "nothing shared"}`);
|
|
7137
|
+
if (matches.length === 0) lines.push(" no site in the register has a declared direction yet: craft estate add --direction <file>");
|
|
7138
|
+
return lines.join("\n");
|
|
7139
|
+
}
|
|
5944
7140
|
|
|
5945
7141
|
// src/direction/licences.ts
|
|
5946
7142
|
var LICENCES = [
|
|
@@ -6054,13 +7250,6 @@ function licencesJson() {
|
|
|
6054
7250
|
}
|
|
6055
7251
|
|
|
6056
7252
|
// src/direction/validate.ts
|
|
6057
|
-
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;
|
|
6058
|
-
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;
|
|
6059
|
-
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"]);
|
|
6060
|
-
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"]);
|
|
6061
|
-
function words(text) {
|
|
6062
|
-
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)));
|
|
6063
|
-
}
|
|
6064
7253
|
function tellFor(key, value) {
|
|
6065
7254
|
if (key === "accent") {
|
|
6066
7255
|
const c = parseColour(value);
|
|
@@ -6077,44 +7266,58 @@ function tellFor(key, value) {
|
|
|
6077
7266
|
}
|
|
6078
7267
|
return null;
|
|
6079
7268
|
}
|
|
7269
|
+
var MIN_TOKENS = 5;
|
|
7270
|
+
var emptyLayers = () => ({ job: false, hierarchy: [], tokens: { decided: 0, total: CHOICE_KEYS.length }, complete: false });
|
|
6080
7271
|
function validateDirection(input, ctx = {}) {
|
|
6081
7272
|
const problems = [];
|
|
6082
7273
|
const err = (at, message) => problems.push({ severity: "error", at, message });
|
|
6083
7274
|
const warn = (at, message) => problems.push({ severity: "warn", at, message });
|
|
6084
7275
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
6085
7276
|
err("", "art-direction.json must be a JSON object");
|
|
6086
|
-
return { valid: false, problems, decided: 0 };
|
|
7277
|
+
return { valid: false, problems, decided: 0, layers: emptyLayers() };
|
|
6087
7278
|
}
|
|
6088
7279
|
const d = input;
|
|
6089
7280
|
const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
|
|
6090
7281
|
if (legacy) {
|
|
6091
7282
|
warn("", "exceptions only: run craft direction init to record the choices and their reasons");
|
|
6092
|
-
return { valid: true, problems, decided: 0 };
|
|
7283
|
+
return { valid: true, problems, decided: 0, layers: emptyLayers() };
|
|
6093
7284
|
}
|
|
6094
|
-
if (d.version
|
|
7285
|
+
if (!DIRECTION_VERSIONS.includes(d.version)) err("version", `version must be ${DIRECTION_VERSIONS.join(" or ")}`);
|
|
7286
|
+
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.");
|
|
6095
7287
|
if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
|
|
6096
7288
|
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");
|
|
6097
|
-
const
|
|
7289
|
+
const rawSources = Array.isArray(d.sources) ? d.sources : [];
|
|
7290
|
+
const sources = rawSources.filter((s) => Boolean(s && typeof s === "object" && !Array.isArray(s)));
|
|
6098
7291
|
if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
|
|
6099
7292
|
if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
|
|
6100
7293
|
const ids = /* @__PURE__ */ new Set();
|
|
6101
|
-
|
|
7294
|
+
rawSources.forEach((s, i) => {
|
|
6102
7295
|
const at = `sources[${i}]`;
|
|
6103
|
-
if (!s || typeof s !== "object") return err(at, "a source must be an object");
|
|
7296
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) return err(at, "a source must be an object");
|
|
6104
7297
|
if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
|
|
6105
7298
|
else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
|
|
6106
7299
|
else ids.add(s.id);
|
|
6107
7300
|
if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
|
|
6108
7301
|
if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
|
|
6109
|
-
if (s.path
|
|
6110
|
-
|
|
7302
|
+
if (s.path !== void 0 && typeof s.path !== "string") err(`${at}.path`, "a path must be text");
|
|
7303
|
+
if (typeof s.path === "string" && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
|
|
7304
|
+
if (s.colours !== void 0 && !Array.isArray(s.colours)) err(`${at}.colours`, "colours must be a list");
|
|
7305
|
+
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`);
|
|
7306
|
+
if (s.kind === "reference") {
|
|
7307
|
+
if (typeof s.category !== "string" || !s.category.trim()) err(`${at}.category`, "say where the reference comes from: print, a place, another trade");
|
|
7308
|
+
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 : "")) {
|
|
7309
|
+
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.`);
|
|
7310
|
+
}
|
|
7311
|
+
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");
|
|
7312
|
+
}
|
|
7313
|
+
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");
|
|
6111
7314
|
});
|
|
6112
7315
|
const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
|
|
6113
7316
|
if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
|
|
6114
7317
|
const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
|
|
6115
7318
|
let decided = 0;
|
|
6116
7319
|
for (const key of Object.keys(choices)) {
|
|
6117
|
-
if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}.
|
|
7320
|
+
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.`);
|
|
6118
7321
|
}
|
|
6119
7322
|
for (const key of CHOICE_KEYS) {
|
|
6120
7323
|
const c = choices[key];
|
|
@@ -6131,44 +7334,9 @@ function validateDirection(input, ctx = {}) {
|
|
|
6131
7334
|
err(`${at}.value`, `"${c.value}" is not a colour`);
|
|
6132
7335
|
ok = false;
|
|
6133
7336
|
}
|
|
6134
|
-
const
|
|
6135
|
-
|
|
6136
|
-
if (
|
|
6137
|
-
err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
|
|
6138
|
-
ok = false;
|
|
6139
|
-
} else if (because.startsWith("PROPOSED:")) {
|
|
6140
|
-
err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
|
|
6141
|
-
ok = false;
|
|
6142
|
-
} else {
|
|
6143
|
-
if (PREFERENCE.test(because)) {
|
|
6144
|
-
err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
|
|
6145
|
-
ok = false;
|
|
6146
|
-
}
|
|
6147
|
-
const moods = because.match(MOOD) ?? [];
|
|
6148
|
-
if (moods.length >= 2) {
|
|
6149
|
-
err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
|
|
6150
|
-
ok = false;
|
|
6151
|
-
}
|
|
6152
|
-
}
|
|
6153
|
-
if (evidence.length === 0) {
|
|
6154
|
-
err(`${at}.evidence`, "cite at least one source");
|
|
6155
|
-
ok = false;
|
|
6156
|
-
}
|
|
6157
|
-
for (const id of evidence) {
|
|
6158
|
-
if (!ids.has(id)) {
|
|
6159
|
-
err(`${at}.evidence`, `no source called "${id}"`);
|
|
6160
|
-
ok = false;
|
|
6161
|
-
}
|
|
6162
|
-
}
|
|
6163
|
-
if (ok) {
|
|
6164
|
-
const cited = sources.filter((s) => evidence.includes(s.id));
|
|
6165
|
-
const reasonWords = words(because);
|
|
6166
|
-
const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
|
|
6167
|
-
if (!tied) {
|
|
6168
|
-
err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
|
|
6169
|
-
ok = false;
|
|
6170
|
-
}
|
|
6171
|
-
}
|
|
7337
|
+
const reasons = checkReason({ at, because: c.because, evidence: c.evidence, sources, tie: "source" });
|
|
7338
|
+
problems.push(...reasons);
|
|
7339
|
+
if (reasons.length) ok = false;
|
|
6172
7340
|
if (typeof c.value === "string") {
|
|
6173
7341
|
const tell = tellFor(key, c.value);
|
|
6174
7342
|
if (tell && !exceptions.some((e) => e.tell === tell)) {
|
|
@@ -6204,7 +7372,33 @@ function validateDirection(input, ctx = {}) {
|
|
|
6204
7372
|
}
|
|
6205
7373
|
if (ok) decided += 1;
|
|
6206
7374
|
}
|
|
6207
|
-
|
|
7375
|
+
let job = false;
|
|
7376
|
+
let hierarchy = [];
|
|
7377
|
+
if (d.version === 2) {
|
|
7378
|
+
const j = validateJob(d.job, sources, { brief: typeof d.brief === "string" ? d.brief : "", readSource: ctx.readSource });
|
|
7379
|
+
problems.push(...j.problems);
|
|
7380
|
+
job = j.decided && d.job !== void 0;
|
|
7381
|
+
const h = validateHierarchy(d.hierarchy, d.job, sources);
|
|
7382
|
+
problems.push(...h.problems);
|
|
7383
|
+
hierarchy = h.pages;
|
|
7384
|
+
if (ctx.snapshot) problems.push(...checkOrder(d.hierarchy?.[ctx.page ?? "home"], ctx.page ?? "home", ctx.snapshot, ctx.strict));
|
|
7385
|
+
}
|
|
7386
|
+
if (ctx.estate?.length) {
|
|
7387
|
+
for (const m of compareToDeclared(summariseDirection(d), ctx.estate, ctx.estateId)) {
|
|
7388
|
+
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.`);
|
|
7389
|
+
}
|
|
7390
|
+
}
|
|
7391
|
+
if (ctx.strict) {
|
|
7392
|
+
for (const p of problems) if (p.severity === "warn" && /the page (?:does not show|sets|runs)/.test(p.message)) p.severity = "error";
|
|
7393
|
+
}
|
|
7394
|
+
const home = hierarchy.find((p) => p.page === "home");
|
|
7395
|
+
const complete = job && Boolean(home) && hierarchy.every((p) => p.decided === p.total) && decided >= MIN_TOKENS;
|
|
7396
|
+
return {
|
|
7397
|
+
valid: problems.every((p) => p.severity !== "error"),
|
|
7398
|
+
problems,
|
|
7399
|
+
decided,
|
|
7400
|
+
layers: { job, hierarchy, tokens: { decided, total: CHOICE_KEYS.length }, complete: complete && problems.every((p) => p.severity !== "error") }
|
|
7401
|
+
};
|
|
6208
7402
|
}
|
|
6209
7403
|
|
|
6210
7404
|
// src/direction/init.ts
|
|
@@ -6223,11 +7417,31 @@ function initDirection(input) {
|
|
|
6223
7417
|
body: empty(fp.body.family),
|
|
6224
7418
|
...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`) } : {}
|
|
6225
7419
|
} : {};
|
|
7420
|
+
const part = () => ({ value: "", evidence: [] });
|
|
6226
7421
|
return {
|
|
6227
7422
|
$schema: "https://unpkg.com/@domandigital/craft/art-direction.schema.json",
|
|
6228
7423
|
version: DIRECTION_VERSION,
|
|
6229
7424
|
client: input.client ?? "",
|
|
6230
7425
|
brief: input.brief ?? "",
|
|
7426
|
+
// Empty on purpose, like the reasons: the first validate lists the job and the structure as undecided.
|
|
7427
|
+
job: {
|
|
7428
|
+
statement: { verb: "", object: "", context: "" },
|
|
7429
|
+
functional: part(),
|
|
7430
|
+
emotional: part(),
|
|
7431
|
+
social: part(),
|
|
7432
|
+
consideration: { value: "low", because: "", evidence: [] },
|
|
7433
|
+
objections: [],
|
|
7434
|
+
language: [],
|
|
7435
|
+
evidence: []
|
|
7436
|
+
},
|
|
7437
|
+
hierarchy: {
|
|
7438
|
+
home: {
|
|
7439
|
+
primaryAction: { value: "", positions: [], because: "" },
|
|
7440
|
+
order: fp?.sections?.length ? fp.sections.map((s) => ({ role: s.role, because: "" })) : [],
|
|
7441
|
+
lead: { value: "", because: "" },
|
|
7442
|
+
journey: []
|
|
7443
|
+
}
|
|
7444
|
+
},
|
|
6231
7445
|
sources: [],
|
|
6232
7446
|
choices,
|
|
6233
7447
|
...input.exceptions?.length ? { exceptions: input.exceptions } : {}
|
|
@@ -6235,7 +7449,6 @@ function initDirection(input) {
|
|
|
6235
7449
|
}
|
|
6236
7450
|
|
|
6237
7451
|
// src/direction/propose.ts
|
|
6238
|
-
var PROPOSED = "PROPOSED:";
|
|
6239
7452
|
function srgbToLinear2(c) {
|
|
6240
7453
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
6241
7454
|
}
|
|
@@ -6297,7 +7510,13 @@ function accentCandidates(input) {
|
|
|
6297
7510
|
const parsed = parseColour(c);
|
|
6298
7511
|
if (!parsed || !chromatic(parsed.oklch)) continue;
|
|
6299
7512
|
const o = parsed.oklch;
|
|
6300
|
-
const siblings =
|
|
7513
|
+
const siblings = [
|
|
7514
|
+
...(input.estate ?? []).map((e) => ({ id: e.id, d: e.fingerprint.accent ? deltaEOk(o, e.fingerprint.accent) : 1 })),
|
|
7515
|
+
...(input.declared ?? []).flatMap((e) => {
|
|
7516
|
+
const p = parseColour(e.accent);
|
|
7517
|
+
return p ? [{ id: `${e.id} (declared)`, d: deltaEOk(o, p.oklch) }] : [];
|
|
7518
|
+
})
|
|
7519
|
+
];
|
|
6301
7520
|
const nearest2 = siblings.sort((a, b) => a.d - b.d)[0];
|
|
6302
7521
|
const penalty = isAiViolet(o) ? 1 : 0;
|
|
6303
7522
|
out.push({
|
|
@@ -6364,7 +7583,7 @@ function addSite(register, site) {
|
|
|
6364
7583
|
}
|
|
6365
7584
|
function sharedParts(a, b, d = fingerprintDistance(a, b)) {
|
|
6366
7585
|
const out = [];
|
|
6367
|
-
const close = (k) => d.parts[k] <=
|
|
7586
|
+
const close = (k) => d.parts[k] <= SHARED_PART;
|
|
6368
7587
|
if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
|
|
6369
7588
|
if (close("type")) {
|
|
6370
7589
|
if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
|
|
@@ -6379,14 +7598,14 @@ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
|
|
|
6379
7598
|
}
|
|
6380
7599
|
function compareToEstate(target2, register, options = {}) {
|
|
6381
7600
|
const at = options.siblingAt ?? SIBLING_AT;
|
|
6382
|
-
return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
|
|
7601
|
+
return register.sites.filter((s) => s.id !== options.exclude && s.fingerprint).map((s) => {
|
|
6383
7602
|
const d = fingerprintDistance(target2, s.fingerprint);
|
|
6384
7603
|
return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target2, s.fingerprint, d) };
|
|
6385
7604
|
}).sort((a, b) => a.distance - b.distance);
|
|
6386
7605
|
}
|
|
6387
7606
|
function estatePairs(register, siblingAt = SIBLING_AT) {
|
|
6388
7607
|
const out = [];
|
|
6389
|
-
const s = register.sites;
|
|
7608
|
+
const s = register.sites.filter((site) => site.fingerprint);
|
|
6390
7609
|
for (let i = 0; i < s.length; i += 1) {
|
|
6391
7610
|
for (let j = i + 1; j < s.length; j += 1) {
|
|
6392
7611
|
const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
|
|
@@ -6506,8 +7725,16 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
|
6506
7725
|
}
|
|
6507
7726
|
if (input.direction) {
|
|
6508
7727
|
const d = input.direction;
|
|
6509
|
-
const
|
|
6510
|
-
|
|
7728
|
+
const layers = d.layers;
|
|
7729
|
+
const raised2 = !d.valid || d.decided < MIN_DECIDED || !layers.complete;
|
|
7730
|
+
const pages = layers.hierarchy.map((p) => `${p.page} ${p.decided} of ${p.total}`).join(", ");
|
|
7731
|
+
const detail = [
|
|
7732
|
+
`job ${layers.job ? "decided" : "not decided"}`,
|
|
7733
|
+
`hierarchy ${pages || "not decided"}`,
|
|
7734
|
+
`${d.decided} of 7 choices decided with a reason`
|
|
7735
|
+
].join("; ");
|
|
7736
|
+
signals.push({ id: "reasons", raised: raised2, detail: `${detail}${d.valid ? "" : ", art-direction.json has errors"}` });
|
|
7737
|
+
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 });
|
|
6511
7738
|
for (const p of d.problems) {
|
|
6512
7739
|
const key = p.at.match(/^choices\.(\w+)/)?.[1];
|
|
6513
7740
|
if (p.severity === "error" || p.message === "not decided yet") {
|
|
@@ -6525,11 +7752,11 @@ function characterReport(input, copyTells = /* @__PURE__ */ new Set()) {
|
|
|
6525
7752
|
const order = ["direction", "type", "colour", "shape", "effects", "motion", "layout", "copy"];
|
|
6526
7753
|
const merged = [];
|
|
6527
7754
|
for (const a of actions) {
|
|
6528
|
-
const
|
|
6529
|
-
if (
|
|
7755
|
+
const same2 = a.choice && a.area !== "direction" && a.area !== "copy" ? merged.find((m) => m.choice === a.choice && m.area === a.area) : void 0;
|
|
7756
|
+
if (same2) {
|
|
6530
7757
|
const fromTell = /\(\S+, gen \d\)/.test(a.why);
|
|
6531
|
-
|
|
6532
|
-
|
|
7758
|
+
same2.why = `${same2.why}; ${fromTell ? a.why : `${a.what.replace(/\.$/, "")} (${a.why})`}`;
|
|
7759
|
+
same2.priority = Math.min(same2.priority, a.priority);
|
|
6533
7760
|
} else merged.push({ ...a });
|
|
6534
7761
|
}
|
|
6535
7762
|
const unique2 = merged.filter((a, i) => merged.findIndex((b) => b.area === a.area && b.what === a.what) === i);
|
|
@@ -6607,6 +7834,8 @@ function retrofitPlan(report2, direction) {
|
|
|
6607
7834
|
}
|
|
6608
7835
|
// Annotate the CommonJS export names for ESM import in node:
|
|
6609
7836
|
0 && (module.exports = {
|
|
7837
|
+
ACCENT_SCALE,
|
|
7838
|
+
ACCENT_SHARED,
|
|
6610
7839
|
AI_PHRASES,
|
|
6611
7840
|
AI_SOURCE_TYPES,
|
|
6612
7841
|
AI_VIOLET,
|
|
@@ -6617,10 +7846,14 @@ function retrofitPlan(report2, direction) {
|
|
|
6617
7846
|
CATALOGUE_VERSION,
|
|
6618
7847
|
CHOICE_KEYS,
|
|
6619
7848
|
COMPONENT_ROLES,
|
|
7849
|
+
CONSIDERATION,
|
|
7850
|
+
CONVENTION,
|
|
7851
|
+
CUSTOMER_KINDS,
|
|
6620
7852
|
DEFAULT_IGNORE,
|
|
6621
7853
|
DENSITIES,
|
|
6622
7854
|
DENSITY_NAMES,
|
|
6623
7855
|
DIRECTION_VERSION,
|
|
7856
|
+
DIRECTION_VERSIONS,
|
|
6624
7857
|
DISTANCE_WEIGHTS,
|
|
6625
7858
|
DURATION_MS,
|
|
6626
7859
|
DURATION_S,
|
|
@@ -6630,6 +7863,12 @@ function retrofitPlan(report2, direction) {
|
|
|
6630
7863
|
ESTATE_VERSION,
|
|
6631
7864
|
EXIT_RATIO,
|
|
6632
7865
|
FINGERPRINT_VERSION,
|
|
7866
|
+
FUNCTION_WORDS,
|
|
7867
|
+
GROUND_SCALE,
|
|
7868
|
+
GROUND_SHARED,
|
|
7869
|
+
HIERARCHY_FIELDS,
|
|
7870
|
+
HOME_FILE,
|
|
7871
|
+
HOME_URL,
|
|
6633
7872
|
HOUSE,
|
|
6634
7873
|
HOUSE_BUDGET,
|
|
6635
7874
|
HOUSE_LOCKED,
|
|
@@ -6640,11 +7879,15 @@ function retrofitPlan(report2, direction) {
|
|
|
6640
7879
|
LIGHTNESS_CURVE,
|
|
6641
7880
|
MIN_DECIDED,
|
|
6642
7881
|
MIN_RUNS,
|
|
7882
|
+
MOOD,
|
|
6643
7883
|
NEGATIVE_REASSURANCE,
|
|
6644
7884
|
NULL_VERSION,
|
|
7885
|
+
PAGE_TYPES,
|
|
6645
7886
|
PILL_LIMIT,
|
|
6646
7887
|
PLAINER_WORDS,
|
|
7888
|
+
PREFERENCE,
|
|
6647
7889
|
PROMO_BADGE,
|
|
7890
|
+
PROOF_NAMES,
|
|
6648
7891
|
PROPOSED,
|
|
6649
7892
|
PROVENANCE_SCAN_BYTES,
|
|
6650
7893
|
RAMP_STEPS,
|
|
@@ -6656,17 +7899,24 @@ function retrofitPlan(report2, direction) {
|
|
|
6656
7899
|
REVEAL_LIMIT,
|
|
6657
7900
|
REVIEW_PHRASES,
|
|
6658
7901
|
SCALE,
|
|
7902
|
+
SECTION_ROLES,
|
|
6659
7903
|
SHADCN_LIMIT,
|
|
6660
7904
|
SHADCN_PRIMITIVES,
|
|
7905
|
+
SHARED_PART,
|
|
6661
7906
|
SIBLING_AT,
|
|
6662
7907
|
SNAPSHOT_VERSION,
|
|
6663
7908
|
SOURCE_KINDS,
|
|
6664
7909
|
SPACE_STEPS,
|
|
7910
|
+
SPECIFICITY_COPY_TELLS,
|
|
7911
|
+
SPECIFICITY_RENDERED_TELLS,
|
|
6665
7912
|
SPRING,
|
|
7913
|
+
STANDING_CLAIM,
|
|
6666
7914
|
STOCK_AVATAR_HOSTS,
|
|
7915
|
+
STOCK_FIGURES,
|
|
6667
7916
|
STOCK_PHOTO_HOSTS,
|
|
6668
7917
|
STOCK_PHRASES,
|
|
6669
7918
|
TELL_HEAVY,
|
|
7919
|
+
TRADE_NOUNS,
|
|
6670
7920
|
TYPE_STEPS,
|
|
6671
7921
|
TYPICAL_AT,
|
|
6672
7922
|
VAGUE_WORDS,
|
|
@@ -6677,22 +7927,32 @@ function retrofitPlan(report2, direction) {
|
|
|
6677
7927
|
applyHouseGate,
|
|
6678
7928
|
applySeverity,
|
|
6679
7929
|
auditSnapshot,
|
|
7930
|
+
briefTerms,
|
|
6680
7931
|
catalogueTable,
|
|
6681
7932
|
characterReport,
|
|
6682
7933
|
checkCopy,
|
|
7934
|
+
checkOrder,
|
|
6683
7935
|
checkPair,
|
|
7936
|
+
checkReason,
|
|
6684
7937
|
checkRestraint,
|
|
6685
7938
|
choicesOf,
|
|
7939
|
+
clauseDepth,
|
|
6686
7940
|
closestComponents,
|
|
7941
|
+
compareCompetitor,
|
|
7942
|
+
compareDirections,
|
|
6687
7943
|
compareFacts,
|
|
7944
|
+
compareToDeclared,
|
|
6688
7945
|
compareToEstate,
|
|
6689
7946
|
componentDistance,
|
|
6690
7947
|
componentShared,
|
|
6691
7948
|
componentTypicalities,
|
|
6692
7949
|
componentTypicality,
|
|
6693
7950
|
componentsOf,
|
|
7951
|
+
contentWords,
|
|
7952
|
+
countWords,
|
|
6694
7953
|
craftTokens,
|
|
6695
7954
|
createBaseline,
|
|
7955
|
+
cv,
|
|
6696
7956
|
deltaEOk,
|
|
6697
7957
|
deltaEOkHex,
|
|
6698
7958
|
densityCss,
|
|
@@ -6715,11 +7975,15 @@ function retrofitPlan(report2, direction) {
|
|
|
6715
7975
|
fontClass,
|
|
6716
7976
|
formatClaims,
|
|
6717
7977
|
formatComparison,
|
|
7978
|
+
formatCompetitor,
|
|
7979
|
+
formatDirectionMatches,
|
|
6718
7980
|
formatHex,
|
|
6719
7981
|
formatReport,
|
|
6720
7982
|
globToRegExp,
|
|
6721
7983
|
groundName,
|
|
6722
7984
|
harvest,
|
|
7985
|
+
hedgeCount,
|
|
7986
|
+
hedgedClose,
|
|
6723
7987
|
hexToOklch,
|
|
6724
7988
|
holdHouseBlocks,
|
|
6725
7989
|
houseRule,
|
|
@@ -6730,49 +7994,75 @@ function retrofitPlan(report2, direction) {
|
|
|
6730
7994
|
inSrgbGamut,
|
|
6731
7995
|
initDirection,
|
|
6732
7996
|
isAiViolet,
|
|
7997
|
+
isBlurb,
|
|
7998
|
+
isCategoryLabel,
|
|
6733
7999
|
isCream,
|
|
8000
|
+
jobWords,
|
|
6734
8001
|
layoutDistance,
|
|
6735
8002
|
licencesJson,
|
|
6736
8003
|
makeSnapshot,
|
|
6737
8004
|
motionTokens,
|
|
6738
8005
|
namedSources,
|
|
6739
8006
|
nearest,
|
|
8007
|
+
nominalisations,
|
|
6740
8008
|
normaliseFamily,
|
|
6741
8009
|
nullPrompt,
|
|
6742
8010
|
oklchToHex,
|
|
6743
8011
|
oklchToRgb,
|
|
8012
|
+
pageCopy,
|
|
6744
8013
|
paletteFromPixels,
|
|
8014
|
+
paragraphUniformity,
|
|
6745
8015
|
parseBaseline,
|
|
6746
8016
|
parseColour,
|
|
6747
8017
|
parseCraftConfig,
|
|
6748
8018
|
parseHex,
|
|
8019
|
+
participialClauses,
|
|
8020
|
+
phrasalCoordinations,
|
|
6749
8021
|
propose,
|
|
6750
8022
|
protectedFacts,
|
|
6751
8023
|
ramp,
|
|
6752
8024
|
rampFromAnchors,
|
|
6753
8025
|
readSnapshot,
|
|
8026
|
+
renderedOrder,
|
|
8027
|
+
researchPrompt,
|
|
6754
8028
|
retrofitPlan,
|
|
6755
8029
|
runTell,
|
|
6756
8030
|
scanProvenance,
|
|
6757
8031
|
scanSource,
|
|
6758
8032
|
sectionRhythm,
|
|
6759
8033
|
semantic,
|
|
8034
|
+
sentencesOf,
|
|
6760
8035
|
severityExceptions,
|
|
8036
|
+
shapeMoves,
|
|
6761
8037
|
shapeName,
|
|
8038
|
+
shapeOf,
|
|
8039
|
+
shapeWords,
|
|
6762
8040
|
sharedParts,
|
|
6763
8041
|
shouldAnimate,
|
|
6764
8042
|
siblingLine,
|
|
8043
|
+
skeleton,
|
|
8044
|
+
skeletonRepeat,
|
|
8045
|
+
specificity,
|
|
8046
|
+
specifics,
|
|
8047
|
+
splitSentences,
|
|
8048
|
+
stackedConditional,
|
|
6765
8049
|
stockAvatarSource,
|
|
6766
8050
|
stockSource,
|
|
8051
|
+
summariseDirection,
|
|
6767
8052
|
tailwindV3Preset,
|
|
6768
8053
|
tailwindV4Theme,
|
|
6769
8054
|
tellById,
|
|
6770
8055
|
toGamut,
|
|
6771
8056
|
toJson,
|
|
6772
8057
|
toSarif,
|
|
8058
|
+
tradeOf,
|
|
8059
|
+
tricolons,
|
|
6773
8060
|
typeFeatureTokens,
|
|
6774
8061
|
typicality,
|
|
8062
|
+
unprovenClaims,
|
|
6775
8063
|
validateDirection,
|
|
8064
|
+
validateHierarchy,
|
|
8065
|
+
validateJob,
|
|
6776
8066
|
visibleText,
|
|
6777
8067
|
visualMeasures,
|
|
6778
8068
|
wcagContrast,
|