@domandigital/craft 0.12.0 → 0.13.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 +20 -0
- package/CHARACTER.md +92 -1
- package/dist/{audit-5QWFLZ6L.js → audit-5TYDYRXV.js} +6 -3
- package/dist/audit.cjs +613 -88
- package/dist/audit.d.cts +81 -1
- package/dist/audit.d.ts +81 -1
- package/dist/audit.js +611 -88
- package/dist/{chunk-J3QUROJ5.js → chunk-3VTPI7BU.js} +9 -8
- package/dist/{chunk-ZKVJ7K6X.js → chunk-CZDOMIVS.js} +1 -1
- package/dist/chunk-ESCVTKPN.js +217 -0
- package/dist/{chunk-AL6MRG6V.js → chunk-GMVXADJ6.js} +2912 -2102
- package/dist/{chunk-PU5G5DUC.js → chunk-IMB4V34S.js} +26 -13
- package/dist/{chunk-CDAJUS5U.js → chunk-K2BZN4GA.js} +49 -14
- package/dist/{chunk-ULPI335S.js → chunk-ZLDA2SYB.js} +168 -8
- package/dist/{chunk-S3NZAVU6.js → chunk-ZZ5CKOJP.js} +1 -1
- package/dist/cli-BKJMLD3H.js +18 -0
- package/dist/{cli-ZPJIWVEP.js → cli-DRKAMFAH.js} +5 -7
- package/dist/{cli-C3JLFXMG.js → cli-ILH7OO5G.js} +4 -5
- package/dist/{cli-ZB2VJ7CY.js → cli-J5ECRCD4.js} +13 -15
- package/dist/cli-N2IDXH7W.js +20 -0
- package/dist/cli.js +2 -2
- package/dist/index.cjs +929 -401
- package/dist/index.d.cts +408 -196
- package/dist/index.d.ts +408 -196
- package/dist/index.js +911 -401
- package/package.json +1 -1
- package/word-lists.json +1 -1
- package/dist/chunk-LHGY43LL.js +0 -462
- package/dist/chunk-PZ4MF2VV.js +0 -117
- package/dist/cli-2DY3V3SF.js +0 -17
- package/dist/cli-QM2AQQMF.js +0 -19
package/dist/audit.js
CHANGED
|
@@ -1000,14 +1000,14 @@ function contractionHits(ctx) {
|
|
|
1000
1000
|
for (const doc2 of documents(ctx)) {
|
|
1001
1001
|
if (doc2.words < 800) continue;
|
|
1002
1002
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
1003
|
-
const
|
|
1004
|
-
const rate =
|
|
1003
|
+
const count3 = (text.match(CONTRACTION) ?? []).length;
|
|
1004
|
+
const rate = count3 * 1e3 / doc2.words;
|
|
1005
1005
|
if (rate < 1) {
|
|
1006
1006
|
const first = sentences(doc2)[0];
|
|
1007
1007
|
hits.push({
|
|
1008
1008
|
path: doc2.path,
|
|
1009
1009
|
offset: first?.offset ?? 0,
|
|
1010
|
-
message: `${
|
|
1010
|
+
message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
1011
1011
|
});
|
|
1012
1012
|
}
|
|
1013
1013
|
}
|
|
@@ -1130,16 +1130,16 @@ function filler(words, seed = 1, options = {}) {
|
|
|
1130
1130
|
return state % n;
|
|
1131
1131
|
};
|
|
1132
1132
|
const out = [];
|
|
1133
|
-
let
|
|
1133
|
+
let count3 = 0;
|
|
1134
1134
|
let i = 0;
|
|
1135
|
-
while (
|
|
1135
|
+
while (count3 < words) {
|
|
1136
1136
|
const length = uniform ?? shape[i % shape.length];
|
|
1137
1137
|
const s = [];
|
|
1138
1138
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
1139
1139
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
1140
1140
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
1141
1141
|
out.push(`${s.join(" ")}.`);
|
|
1142
|
-
|
|
1142
|
+
count3 += s.length;
|
|
1143
1143
|
i += 1;
|
|
1144
1144
|
}
|
|
1145
1145
|
const paragraphs = [];
|
|
@@ -2720,11 +2720,433 @@ var RENDERED_PATHS = {
|
|
|
2720
2720
|
}
|
|
2721
2721
|
};
|
|
2722
2722
|
|
|
2723
|
+
// src/fingerprint/index.ts
|
|
2724
|
+
var FINGERPRINT_VERSION = 2;
|
|
2725
|
+
var CHROMATIC = 0.03;
|
|
2726
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
2727
|
+
var oklch = (value) => {
|
|
2728
|
+
const parsed = parseColour(value);
|
|
2729
|
+
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
2730
|
+
};
|
|
2731
|
+
function accentOf(s) {
|
|
2732
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
2733
|
+
for (const c of s.controls) {
|
|
2734
|
+
const o = oklch(c.background);
|
|
2735
|
+
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
2736
|
+
}
|
|
2737
|
+
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2738
|
+
if (topButton) return oklch(topButton[0]);
|
|
2739
|
+
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
|
|
2740
|
+
if (bg) return bg.o;
|
|
2741
|
+
const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
2742
|
+
return text ? text.o : null;
|
|
2743
|
+
}
|
|
2744
|
+
function faces(s) {
|
|
2745
|
+
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
2746
|
+
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
2747
|
+
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
2748
|
+
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
2749
|
+
return {
|
|
2750
|
+
display: { family: displayName, class: fontClass(displayName) },
|
|
2751
|
+
body: { family: bodyName, class: fontClass(bodyName) }
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
function median(values) {
|
|
2755
|
+
if (values.length === 0) return null;
|
|
2756
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2757
|
+
const mid = Math.floor(sorted.length / 2);
|
|
2758
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
2759
|
+
}
|
|
2760
|
+
function fingerprint(s) {
|
|
2761
|
+
const effects = [];
|
|
2762
|
+
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
2763
|
+
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
2764
|
+
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
2765
|
+
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
2766
|
+
if (s.effects.marquees > 0) effects.push("marquee");
|
|
2767
|
+
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
2768
|
+
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
2769
|
+
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
2770
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
2771
|
+
return {
|
|
2772
|
+
version: FINGERPRINT_VERSION,
|
|
2773
|
+
accent: accentOf(s),
|
|
2774
|
+
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
2775
|
+
...faces(s),
|
|
2776
|
+
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2777
|
+
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2778
|
+
effects: effects.sort(),
|
|
2779
|
+
layout: s.sections.map((x) => x.kind),
|
|
2780
|
+
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
|
|
2781
|
+
sections: s.sections.map((x) => ({
|
|
2782
|
+
role: x.role,
|
|
2783
|
+
centred: x.geometry.centredShare,
|
|
2784
|
+
width: x.geometry.contentWidthRatio,
|
|
2785
|
+
symmetry: x.geometry.mirrorSymmetry,
|
|
2786
|
+
controls: x.geometry.controls,
|
|
2787
|
+
cards: x.cards,
|
|
2788
|
+
background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
|
|
2789
|
+
badges: (x.badges ?? []).map((b) => b.toLowerCase()),
|
|
2790
|
+
avatars: x.avatars ?? 0,
|
|
2791
|
+
carousel: x.carousel ?? false,
|
|
2792
|
+
figures: x.figures ?? 0
|
|
2793
|
+
}))
|
|
2794
|
+
} : {}
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// src/fingerprint/component.ts
|
|
2799
|
+
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
2800
|
+
|
|
2801
|
+
// src/snapshot/rendered-tells.ts
|
|
2802
|
+
var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
2803
|
+
var GROUND = "rgb(255, 255, 255)";
|
|
2804
|
+
var BAND = "rgb(31, 58, 46)";
|
|
2805
|
+
var geo = (patch = {}) => ({
|
|
2806
|
+
centredShare: 0.1,
|
|
2807
|
+
mirrorSymmetry: 0.6,
|
|
2808
|
+
whitespaceRatio: 0.7,
|
|
2809
|
+
contentWidthRatio: 0.85,
|
|
2810
|
+
background: GROUND,
|
|
2811
|
+
controls: 0,
|
|
2812
|
+
...patch
|
|
2813
|
+
});
|
|
2814
|
+
function withSection(index, patch, roles = []) {
|
|
2815
|
+
const base = makeSnapshot();
|
|
2816
|
+
return {
|
|
2817
|
+
...base,
|
|
2818
|
+
sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
|
|
2822
|
+
var RENDERED_TELLS = [
|
|
2823
|
+
{
|
|
2824
|
+
id: "cta-band-stock",
|
|
2825
|
+
name: "Stock call-to-action band",
|
|
2826
|
+
generation: 1,
|
|
2827
|
+
severity: "warn",
|
|
2828
|
+
surface: "rendered",
|
|
2829
|
+
why: "A full-width coloured band with a centred heading, one line and one or two buttons is what a model closes every section run with. It asks for the booking the same way on every site.",
|
|
2830
|
+
fix: "Keep the ask, change the band: put it where the decision is made (next to the prices, after the proof), in the client's own words, laid out like the rest of the page rather than as a centred slab.",
|
|
2831
|
+
rendered: {
|
|
2832
|
+
detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
|
|
2833
|
+
const g = x.geometry;
|
|
2834
|
+
return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
|
|
2835
|
+
}).map((x) => hit2(s, `a centred ${x.role === "footer-cta" ? "closing " : ""}band on its own colour with ${x.geometry.controls === 1 ? "one button" : "two buttons"}`, x.label)),
|
|
2836
|
+
fixtures: {
|
|
2837
|
+
flag: [
|
|
2838
|
+
withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
|
|
2839
|
+
withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
|
|
2840
|
+
],
|
|
2841
|
+
pass: [
|
|
2842
|
+
makeSnapshot(),
|
|
2843
|
+
// The same ask, laid out like the page: left-aligned on the ground.
|
|
2844
|
+
withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
|
|
2845
|
+
]
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
},
|
|
2849
|
+
{
|
|
2850
|
+
id: "pricing-trio-popular",
|
|
2851
|
+
name: "Three-tier pricing with a popular badge",
|
|
2852
|
+
generation: 1,
|
|
2853
|
+
severity: "warn",
|
|
2854
|
+
surface: "rendered",
|
|
2855
|
+
why: "Three price cards with the middle one badged 'Most popular' is the SaaS pricing page, applied to a plumber or a salon whether or not anyone chose the middle option.",
|
|
2856
|
+
fix: "Keep the prices. Show them the way the trade quotes (a price list, per job, per visit), and drop the badge unless it is true and the client can say how many people chose it.",
|
|
2857
|
+
rendered: {
|
|
2858
|
+
detect: (s) => s.sections.filter((x) => measured(x) && x.role === "pricing" && x.cards === 3).filter((x) => (x.badges ?? []).some((b) => PROMO_BADGE.test(b))).map((x) => hit2(s, `three price cards, one badged "${(x.badges ?? []).find((b) => PROMO_BADGE.test(b))}"`, x.label)),
|
|
2859
|
+
fixtures: {
|
|
2860
|
+
flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
|
|
2861
|
+
pass: [
|
|
2862
|
+
makeSnapshot(),
|
|
2863
|
+
withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
|
|
2864
|
+
withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
|
|
2865
|
+
]
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
},
|
|
2869
|
+
{
|
|
2870
|
+
id: "testimonial-avatar-carousel",
|
|
2871
|
+
name: "Avatar testimonial carousel",
|
|
2872
|
+
generation: 1,
|
|
2873
|
+
severity: "warn",
|
|
2874
|
+
surface: "rendered",
|
|
2875
|
+
why: "Round headshots over quotes in a sliding carousel is the stock testimonial block. Most visitors see the first slide only, and stock or AI faces make every quote read as invented.",
|
|
2876
|
+
fix: "Show the reviews all at once, where they come from (the Google rating, the named job, the town), with real photos of the work rather than of the reviewer.",
|
|
2877
|
+
rendered: {
|
|
2878
|
+
detect: (s) => s.sections.filter((x) => measured(x) && x.role === "testimonials" && x.carousel === true && (x.avatars ?? 0) >= 2).map((x) => hit2(s, `${x.avatars} round portraits in a sliding testimonial carousel`, x.label)),
|
|
2879
|
+
fixtures: {
|
|
2880
|
+
flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
|
|
2881
|
+
pass: [
|
|
2882
|
+
makeSnapshot(),
|
|
2883
|
+
withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
|
|
2884
|
+
withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
|
|
2885
|
+
]
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
},
|
|
2889
|
+
{
|
|
2890
|
+
id: "faq-accordion-closer",
|
|
2891
|
+
name: "FAQ accordion as the closer",
|
|
2892
|
+
generation: 2,
|
|
2893
|
+
severity: "warn",
|
|
2894
|
+
surface: "rendered",
|
|
2895
|
+
why: "Ending the page on a collapsed FAQ, often followed only by a band, is the model's default page ending. The answers a buyer needs are hidden behind clicks at the point they decide.",
|
|
2896
|
+
fix: "Answer the questions that block a booking where they arise (price next to prices, area next to the map), and end on the call to action or the proof.",
|
|
2897
|
+
rendered: {
|
|
2898
|
+
detect: (s) => {
|
|
2899
|
+
const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
|
|
2900
|
+
const last = body[body.length - 1];
|
|
2901
|
+
return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
|
|
2902
|
+
},
|
|
2903
|
+
fixtures: {
|
|
2904
|
+
flag: [
|
|
2905
|
+
withSection(4, { role: "faq", label: "Common questions" }),
|
|
2906
|
+
{
|
|
2907
|
+
...withSection(3, { role: "faq", label: "FAQ" }),
|
|
2908
|
+
sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
|
|
2909
|
+
}
|
|
2910
|
+
],
|
|
2911
|
+
pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
},
|
|
2915
|
+
{
|
|
2916
|
+
id: "stats-row",
|
|
2917
|
+
name: "Row of big numbers",
|
|
2918
|
+
generation: 1,
|
|
2919
|
+
severity: "warn",
|
|
2920
|
+
surface: "rendered",
|
|
2921
|
+
why: "Three or four large figures in a row (500+ clients, 98% satisfaction, 10 years) is the stock proof block, and the numbers are often round, unsourced or invented.",
|
|
2922
|
+
fix: "Keep a figure only if it is true and sourced, and put it in a sentence next to what it proves. One real number in context beats four in a row.",
|
|
2923
|
+
rendered: {
|
|
2924
|
+
detect: (s) => s.sections.filter((x, i) => measured(x) && (x.role === "stats" || x.kind === "stats") && (x.figures ?? 0) >= 3 && (x.figures ?? 0) <= 6 && !(i === 1 && s.sections[0]?.kind === "hero")).map((x) => hit2(s, `${x.figures} large figures in a row`, x.label)),
|
|
2925
|
+
fixtures: {
|
|
2926
|
+
flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
|
|
2927
|
+
pass: [
|
|
2928
|
+
makeSnapshot(),
|
|
2929
|
+
withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
|
|
2930
|
+
withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
|
|
2931
|
+
]
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
},
|
|
2935
|
+
{
|
|
2936
|
+
id: "centred-everything",
|
|
2937
|
+
name: "Every section centred",
|
|
2938
|
+
generation: 2,
|
|
2939
|
+
severity: "warn",
|
|
2940
|
+
surface: "rendered",
|
|
2941
|
+
why: "When almost every section stacks a centred heading over centred text, the page has no reading line and every block looks like the one before. It is the layout nothing was decided for.",
|
|
2942
|
+
fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
|
|
2943
|
+
rendered: {
|
|
2944
|
+
detect: (s) => {
|
|
2945
|
+
const m = s.sections.filter(measured);
|
|
2946
|
+
if (m.length < 4) return [];
|
|
2947
|
+
const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
|
|
2948
|
+
return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
|
|
2949
|
+
},
|
|
2950
|
+
fixtures: {
|
|
2951
|
+
flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
|
|
2952
|
+
pass: [
|
|
2953
|
+
makeSnapshot(),
|
|
2954
|
+
{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
|
|
2955
|
+
]
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
];
|
|
2960
|
+
|
|
2961
|
+
// src/snapshot/imagery-tells.ts
|
|
2962
|
+
var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
2963
|
+
var STOCK_PHOTO_HOSTS = {
|
|
2964
|
+
"unsplash.com": "Unsplash",
|
|
2965
|
+
"pexels.com": "Pexels",
|
|
2966
|
+
"pixabay.com": "Pixabay",
|
|
2967
|
+
"istockphoto.com": "iStock",
|
|
2968
|
+
"gettyimages.com": "Getty Images",
|
|
2969
|
+
"shutterstock.com": "Shutterstock",
|
|
2970
|
+
"ftcdn.net": "Adobe Stock",
|
|
2971
|
+
"dreamstime.com": "Dreamstime",
|
|
2972
|
+
"depositphotos.com": "Depositphotos",
|
|
2973
|
+
"freepik.com": "Freepik",
|
|
2974
|
+
"rawpixel.com": "rawpixel",
|
|
2975
|
+
"stocksnap.io": "StockSnap",
|
|
2976
|
+
"burst.shopifycdn.com": "Burst",
|
|
2977
|
+
"picsum.photos": "Lorem Picsum",
|
|
2978
|
+
"loremflickr.com": "LoremFlickr",
|
|
2979
|
+
"placehold.co": "a placeholder service",
|
|
2980
|
+
"placeholder.com": "a placeholder service",
|
|
2981
|
+
"dummyimage.com": "a placeholder service"
|
|
2982
|
+
};
|
|
2983
|
+
var STOCK_AVATAR_HOSTS = {
|
|
2984
|
+
"randomuser.me": "randomuser.me",
|
|
2985
|
+
"pravatar.cc": "Pravatar",
|
|
2986
|
+
"dicebear.com": "DiceBear",
|
|
2987
|
+
"xsgames.co": "a random-face service",
|
|
2988
|
+
"thispersondoesnotexist.com": "a generated face",
|
|
2989
|
+
"generated.photos": "a generated face"
|
|
2990
|
+
};
|
|
2991
|
+
var STOCK_FILENAMES = [
|
|
2992
|
+
[/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
|
|
2993
|
+
[/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
|
|
2994
|
+
[/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
|
|
2995
|
+
[/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
|
|
2996
|
+
[/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
|
|
2997
|
+
[/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
|
|
2998
|
+
[/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
|
|
2999
|
+
[/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
|
|
3000
|
+
// Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
|
|
3001
|
+
[/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
|
|
3002
|
+
];
|
|
3003
|
+
var byHost = (host, table) => {
|
|
3004
|
+
if (!host) return null;
|
|
3005
|
+
const h = host.toLowerCase();
|
|
3006
|
+
for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
|
|
3007
|
+
return null;
|
|
3008
|
+
};
|
|
3009
|
+
function stockSource(image) {
|
|
3010
|
+
const host = byHost(image.host, STOCK_PHOTO_HOSTS);
|
|
3011
|
+
if (host) return host;
|
|
3012
|
+
let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
|
|
3013
|
+
try {
|
|
3014
|
+
path = decodeURIComponent(path);
|
|
3015
|
+
} catch {
|
|
3016
|
+
}
|
|
3017
|
+
for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
3020
|
+
function stockAvatarSource(image) {
|
|
3021
|
+
return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
|
|
3022
|
+
}
|
|
3023
|
+
var picture = (patch = {}) => ({
|
|
3024
|
+
src: "https://example.test/images/boiler-swap-hinton.jpg",
|
|
3025
|
+
host: "example.test",
|
|
3026
|
+
width: 640,
|
|
3027
|
+
height: 480,
|
|
3028
|
+
top: 1800,
|
|
3029
|
+
role: "photo",
|
|
3030
|
+
alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
|
|
3031
|
+
decorative: false,
|
|
3032
|
+
section: 2,
|
|
3033
|
+
background: false,
|
|
3034
|
+
...patch
|
|
3035
|
+
});
|
|
3036
|
+
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3037
|
+
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3038
|
+
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3039
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3040
|
+
var IMAGERY_TELLS = [
|
|
3041
|
+
{
|
|
3042
|
+
id: "stock-photo",
|
|
3043
|
+
name: "Stock photography",
|
|
3044
|
+
generation: 1,
|
|
3045
|
+
severity: "warn",
|
|
3046
|
+
surface: "rendered",
|
|
3047
|
+
why: "A picture from a stock library is the picture every other site in the trade can use. It shows a job the client did not do, in a kitchen that is not theirs, and a visitor who has seen it before stops believing the rest.",
|
|
3048
|
+
fix: "Use a photo of the client's own work, premises or people, even a plain one taken on a phone. Where there is none yet, use fewer pictures rather than a library one.",
|
|
3049
|
+
rendered: {
|
|
3050
|
+
detect: (s) => {
|
|
3051
|
+
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3052
|
+
if (found.length === 0) return [];
|
|
3053
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3054
|
+
return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3055
|
+
},
|
|
3056
|
+
fixtures: {
|
|
3057
|
+
flag: [
|
|
3058
|
+
withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
|
|
3059
|
+
// Downloaded and self-hosted, found by the library's own file name.
|
|
3060
|
+
withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
|
|
3061
|
+
],
|
|
3062
|
+
pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
},
|
|
3066
|
+
{
|
|
3067
|
+
id: "stock-avatar",
|
|
3068
|
+
name: "Stock or placeholder faces",
|
|
3069
|
+
generation: 1,
|
|
3070
|
+
severity: "warn",
|
|
3071
|
+
surface: "rendered",
|
|
3072
|
+
why: "Round portraits from a placeholder-face service or a stock library next to reviews make every quote read as invented, and some of those faces are generated people who do not exist.",
|
|
3073
|
+
fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
|
|
3074
|
+
rendered: {
|
|
3075
|
+
detect: (s) => {
|
|
3076
|
+
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3077
|
+
if (found.length === 0) return [];
|
|
3078
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3079
|
+
return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3080
|
+
},
|
|
3081
|
+
fixtures: {
|
|
3082
|
+
flag: [
|
|
3083
|
+
withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
|
|
3084
|
+
withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
|
|
3085
|
+
],
|
|
3086
|
+
pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
},
|
|
3090
|
+
{
|
|
3091
|
+
id: "ai-image",
|
|
3092
|
+
name: "Generated image",
|
|
3093
|
+
generation: 2,
|
|
3094
|
+
severity: "warn",
|
|
3095
|
+
surface: "rendered",
|
|
3096
|
+
why: "The file itself says a model made it (its XMP or Content Credentials name a trained-model source). A generated picture stands in for work the client did not photograph, and anyone can read the label.",
|
|
3097
|
+
fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
|
|
3098
|
+
rendered: {
|
|
3099
|
+
detect: (s) => {
|
|
3100
|
+
const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
|
|
3101
|
+
if (found.length === 0) return [];
|
|
3102
|
+
const first = found[0].provenance;
|
|
3103
|
+
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3104
|
+
return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3105
|
+
},
|
|
3106
|
+
fixtures: {
|
|
3107
|
+
flag: [
|
|
3108
|
+
withImages([picture({ provenance: AI_XMP })]),
|
|
3109
|
+
withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
|
|
3110
|
+
],
|
|
3111
|
+
// Credentials that say nothing about a model, and an image never read, are not findings.
|
|
3112
|
+
pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
},
|
|
3116
|
+
{
|
|
3117
|
+
id: "no-real-imagery",
|
|
3118
|
+
name: "No photograph on the page",
|
|
3119
|
+
generation: 1,
|
|
3120
|
+
severity: "warn",
|
|
3121
|
+
surface: "rendered",
|
|
3122
|
+
why: "A page with no photograph at all, only icons and illustrations, is the page a model builds when it has no assets. Icon tiles stand in for the evidence a buyer looks for: the work, the premises, the people.",
|
|
3123
|
+
fix: "Add one real photo of the work where the claim is made: a finished job next to the service, the team next to the booking. One plain photo beats any number of icons.",
|
|
3124
|
+
rendered: {
|
|
3125
|
+
detect: (s) => {
|
|
3126
|
+
if (!s.images || s.sections.length < 4) return [];
|
|
3127
|
+
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3128
|
+
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3129
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3130
|
+
},
|
|
3131
|
+
fixtures: {
|
|
3132
|
+
flag: [
|
|
3133
|
+
withImages([picture({ role: "icon", width: 48, height: 48, src: "https://example.test/icons/wrench.svg" }), picture({ role: "illustration", width: 480, height: 360, src: "https://example.test/hero.svg" })]),
|
|
3134
|
+
withImages([])
|
|
3135
|
+
],
|
|
3136
|
+
// A snapshot with no image record is never judged; nor is a page too short to be a site.
|
|
3137
|
+
pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
];
|
|
3142
|
+
|
|
2723
3143
|
// src/character/check.ts
|
|
2724
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
2725
|
-
var CATALOGUE = [
|
|
2726
|
-
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
2727
|
-
|
|
3144
|
+
var CATALOGUE_VERSION = "2026.09.11";
|
|
3145
|
+
var CATALOGUE = [
|
|
3146
|
+
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3147
|
+
...RENDERED_TELLS,
|
|
3148
|
+
...IMAGERY_TELLS
|
|
3149
|
+
];
|
|
2728
3150
|
function tellById(id) {
|
|
2729
3151
|
return CATALOGUE.find((t) => t.id === id);
|
|
2730
3152
|
}
|
|
@@ -2746,17 +3168,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
|
|
|
2746
3168
|
function marked(lines, line) {
|
|
2747
3169
|
return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
|
|
2748
3170
|
}
|
|
2749
|
-
function toFinding(tell,
|
|
2750
|
-
const text = texts.get(
|
|
3171
|
+
function toFinding(tell, hit4, texts) {
|
|
3172
|
+
const text = texts.get(hit4.path) ?? "";
|
|
2751
3173
|
return {
|
|
2752
3174
|
tell: tell.id,
|
|
2753
3175
|
name: tell.name,
|
|
2754
3176
|
generation: tell.generation,
|
|
2755
3177
|
severity: tell.severity,
|
|
2756
|
-
path:
|
|
2757
|
-
line: texts.has(
|
|
2758
|
-
excerpt:
|
|
2759
|
-
message:
|
|
3178
|
+
path: hit4.path,
|
|
3179
|
+
line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
|
|
3180
|
+
excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
|
|
3181
|
+
message: hit4.message,
|
|
2760
3182
|
fix: tell.fix,
|
|
2761
3183
|
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
2762
3184
|
};
|
|
@@ -2775,14 +3197,14 @@ function report(tells, files, run, options) {
|
|
|
2775
3197
|
};
|
|
2776
3198
|
const excepted = /* @__PURE__ */ new Map();
|
|
2777
3199
|
for (const tell of selected) {
|
|
2778
|
-
for (const
|
|
2779
|
-
const exception = applied.find((e) => e.tell === tell.id && (!e.path ||
|
|
3200
|
+
for (const hit4 of run(tell)) {
|
|
3201
|
+
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
|
|
2780
3202
|
if (exception) {
|
|
2781
3203
|
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
2782
3204
|
continue;
|
|
2783
3205
|
}
|
|
2784
|
-
const finding = toFinding(tell,
|
|
2785
|
-
if (marked(linesOf(
|
|
3206
|
+
const finding = toFinding(tell, hit4, texts);
|
|
3207
|
+
if (marked(linesOf(hit4.path), finding.line)) {
|
|
2786
3208
|
suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
|
|
2787
3209
|
continue;
|
|
2788
3210
|
}
|
|
@@ -2800,7 +3222,7 @@ function report(tells, files, run, options) {
|
|
|
2800
3222
|
catalogueVersion: CATALOGUE_VERSION,
|
|
2801
3223
|
findings,
|
|
2802
3224
|
rejectedExceptions: rejected,
|
|
2803
|
-
excepted: [...excepted].map(([e,
|
|
3225
|
+
excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
|
|
2804
3226
|
suppressed,
|
|
2805
3227
|
summary: {
|
|
2806
3228
|
files: files.length,
|
|
@@ -2818,67 +3240,6 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
2818
3240
|
return result;
|
|
2819
3241
|
}
|
|
2820
3242
|
|
|
2821
|
-
// src/fingerprint/index.ts
|
|
2822
|
-
var FINGERPRINT_VERSION = 2;
|
|
2823
|
-
var CHROMATIC = 0.03;
|
|
2824
|
-
var MIN_ACCENT_AREA = 2e4;
|
|
2825
|
-
var oklch = (value) => {
|
|
2826
|
-
const parsed = parseColour(value);
|
|
2827
|
-
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
2828
|
-
};
|
|
2829
|
-
function accentOf(s) {
|
|
2830
|
-
const buttons = /* @__PURE__ */ new Map();
|
|
2831
|
-
for (const c of s.controls) {
|
|
2832
|
-
const o = oklch(c.background);
|
|
2833
|
-
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
2834
|
-
}
|
|
2835
|
-
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2836
|
-
if (topButton) return oklch(topButton[0]);
|
|
2837
|
-
const bg = s.colours.backgrounds.map((b) => ({ o: oklch(b.value), w: b.area })).filter((x) => x.o && x.o.c >= CHROMATIC && x.w >= MIN_ACCENT_AREA).sort((a, b) => b.w - a.w)[0];
|
|
2838
|
-
if (bg) return bg.o;
|
|
2839
|
-
const text = s.colours.text.map((t) => ({ o: oklch(t.value), w: t.chars })).filter((x) => x.o && x.o.c >= CHROMATIC).sort((a, b) => b.w - a.w)[0];
|
|
2840
|
-
return text ? text.o : null;
|
|
2841
|
-
}
|
|
2842
|
-
function faces(s) {
|
|
2843
|
-
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
2844
|
-
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
2845
|
-
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
2846
|
-
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
2847
|
-
return {
|
|
2848
|
-
display: { family: displayName, class: fontClass(displayName) },
|
|
2849
|
-
body: { family: bodyName, class: fontClass(bodyName) }
|
|
2850
|
-
};
|
|
2851
|
-
}
|
|
2852
|
-
function median(values) {
|
|
2853
|
-
if (values.length === 0) return null;
|
|
2854
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
2855
|
-
const mid = Math.floor(sorted.length / 2);
|
|
2856
|
-
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
2857
|
-
}
|
|
2858
|
-
function fingerprint(s) {
|
|
2859
|
-
const effects = [];
|
|
2860
|
-
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
2861
|
-
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
2862
|
-
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
2863
|
-
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
2864
|
-
if (s.effects.marquees > 0) effects.push("marquee");
|
|
2865
|
-
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
2866
|
-
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
2867
|
-
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
2868
|
-
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
2869
|
-
return {
|
|
2870
|
-
version: FINGERPRINT_VERSION,
|
|
2871
|
-
accent: accentOf(s),
|
|
2872
|
-
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
2873
|
-
...faces(s),
|
|
2874
|
-
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2875
|
-
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2876
|
-
effects: effects.sort(),
|
|
2877
|
-
layout: s.sections.map((x) => x.kind),
|
|
2878
|
-
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
|
|
2879
|
-
};
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
3243
|
// src/audit/collect.ts
|
|
2883
3244
|
function collectInPage() {
|
|
2884
3245
|
const vw = window.innerWidth;
|
|
@@ -3118,7 +3479,7 @@ function collectInPage() {
|
|
|
3118
3479
|
};
|
|
3119
3480
|
};
|
|
3120
3481
|
const CURRENCY = /[£$€]\s?\d/;
|
|
3121
|
-
const roleOf = (el, kind,
|
|
3482
|
+
const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
|
|
3122
3483
|
if (kind === "marquee" || kind === "hero") return kind;
|
|
3123
3484
|
const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
|
|
3124
3485
|
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
|
|
@@ -3142,27 +3503,55 @@ function collectInPage() {
|
|
|
3142
3503
|
if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
|
|
3143
3504
|
if (kind === "stats" || kind === "logos") return kind;
|
|
3144
3505
|
const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
|
|
3145
|
-
const setApart =
|
|
3146
|
-
if (setApart && heading && b.height <= vh * 0.9 &&
|
|
3506
|
+
const setApart = geo2.background !== ground || geo2.centredShare >= 0.6;
|
|
3507
|
+
if (setApart && heading && b.height <= vh * 0.9 && geo2.controls >= 1 && geo2.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
|
|
3147
3508
|
if (cards >= 3 && iconCards >= 2) return "features";
|
|
3148
3509
|
return kind;
|
|
3149
3510
|
};
|
|
3511
|
+
const partsOf = (el) => {
|
|
3512
|
+
const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
|
|
3513
|
+
const badges = [];
|
|
3514
|
+
for (const n of inside) {
|
|
3515
|
+
const t = ownText(n);
|
|
3516
|
+
if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
|
|
3517
|
+
const cs = style(n);
|
|
3518
|
+
const r = box(n);
|
|
3519
|
+
const radius = px(cs.borderTopLeftRadius);
|
|
3520
|
+
const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
|
|
3521
|
+
if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
|
|
3522
|
+
}
|
|
3523
|
+
const avatars = inside.filter((n) => {
|
|
3524
|
+
if (n.tagName !== "IMG") return false;
|
|
3525
|
+
const r = box(n);
|
|
3526
|
+
const rad = style(n).borderTopLeftRadius;
|
|
3527
|
+
const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
|
|
3528
|
+
return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
|
|
3529
|
+
}).length;
|
|
3530
|
+
const carousel = inside.some((n) => {
|
|
3531
|
+
const cs = style(n);
|
|
3532
|
+
if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
|
|
3533
|
+
if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
|
|
3534
|
+
return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
|
|
3535
|
+
});
|
|
3536
|
+
return { badges, avatars, carousel };
|
|
3537
|
+
};
|
|
3150
3538
|
const kept = blocks.slice(0, 40);
|
|
3151
3539
|
const sections = kept.map((el, index) => {
|
|
3152
3540
|
const b = box(el);
|
|
3153
3541
|
const heading = el.querySelector("h1, h2, h3");
|
|
3154
3542
|
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3155
|
-
const
|
|
3543
|
+
const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
|
|
3156
3544
|
const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
|
|
3157
3545
|
const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
|
|
3158
3546
|
const { cards, iconCards } = cardsIn(el);
|
|
3159
3547
|
let kind = "text";
|
|
3160
3548
|
if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
|
|
3161
3549
|
else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
|
|
3162
|
-
else if (
|
|
3550
|
+
else if (images2.length >= 4 && textChars < 300) kind = "logos";
|
|
3163
3551
|
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
3164
3552
|
else if (cards >= 3) kind = "cards";
|
|
3165
3553
|
const geometry = geometryOf(el, b);
|
|
3554
|
+
const parts2 = partsOf(el);
|
|
3166
3555
|
return {
|
|
3167
3556
|
top: Math.round(b.top),
|
|
3168
3557
|
height: Math.round(b.height),
|
|
@@ -3172,7 +3561,9 @@ function collectInPage() {
|
|
|
3172
3561
|
iconCards,
|
|
3173
3562
|
hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
|
|
3174
3563
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3175
|
-
geometry
|
|
3564
|
+
geometry,
|
|
3565
|
+
...parts2,
|
|
3566
|
+
figures: numbers
|
|
3176
3567
|
};
|
|
3177
3568
|
});
|
|
3178
3569
|
if (header && sections.length === 0) {
|
|
@@ -3254,6 +3645,81 @@ function collectInPage() {
|
|
|
3254
3645
|
const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
|
|
3255
3646
|
if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
|
|
3256
3647
|
}
|
|
3648
|
+
const unwrap = (raw) => {
|
|
3649
|
+
if (raw.startsWith("data:")) return raw.slice(0, 64);
|
|
3650
|
+
try {
|
|
3651
|
+
const u = new URL(raw, location.href);
|
|
3652
|
+
const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
|
|
3653
|
+
if (inner) return new URL(inner, location.href).href;
|
|
3654
|
+
const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
|
|
3655
|
+
if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
|
|
3656
|
+
return u.href;
|
|
3657
|
+
} catch {
|
|
3658
|
+
return raw.slice(0, 200);
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
const roundBox = (el, w) => {
|
|
3662
|
+
const rad = style(el).borderTopLeftRadius;
|
|
3663
|
+
return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
|
|
3664
|
+
};
|
|
3665
|
+
const sectionOf = (el) => {
|
|
3666
|
+
const i = kept.findIndex((k) => k.contains(el));
|
|
3667
|
+
return i === -1 ? null : i;
|
|
3668
|
+
};
|
|
3669
|
+
const roleOfImage = (el, src, w, h, alt) => {
|
|
3670
|
+
const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
|
|
3671
|
+
const square = Math.abs(w - h) <= w * 0.15;
|
|
3672
|
+
if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
|
|
3673
|
+
const section = sectionOf(el);
|
|
3674
|
+
const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
|
|
3675
|
+
if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
|
|
3676
|
+
if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
|
|
3677
|
+
if (vector) return "illustration";
|
|
3678
|
+
return w >= 160 && h >= 120 ? "photo" : "icon";
|
|
3679
|
+
};
|
|
3680
|
+
const images = [];
|
|
3681
|
+
const seenImage = /* @__PURE__ */ new Set();
|
|
3682
|
+
const addImage = (el, raw, alt, decorative, background) => {
|
|
3683
|
+
const b = box(el);
|
|
3684
|
+
if (!raw || b.width < 8 || b.height < 8) return;
|
|
3685
|
+
const src = unwrap(raw);
|
|
3686
|
+
const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
|
|
3687
|
+
if (seenImage.has(key)) return;
|
|
3688
|
+
seenImage.add(key);
|
|
3689
|
+
let host = null;
|
|
3690
|
+
try {
|
|
3691
|
+
host = src.startsWith("data:") ? null : new URL(src).hostname;
|
|
3692
|
+
} catch {
|
|
3693
|
+
host = null;
|
|
3694
|
+
}
|
|
3695
|
+
images.push({
|
|
3696
|
+
src,
|
|
3697
|
+
host,
|
|
3698
|
+
width: Math.round(b.width),
|
|
3699
|
+
height: Math.round(b.height),
|
|
3700
|
+
top: Math.round(b.top),
|
|
3701
|
+
role: roleOfImage(el, src, b.width, b.height, alt),
|
|
3702
|
+
alt,
|
|
3703
|
+
decorative,
|
|
3704
|
+
section: sectionOf(el),
|
|
3705
|
+
background
|
|
3706
|
+
});
|
|
3707
|
+
};
|
|
3708
|
+
for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
|
|
3709
|
+
if (!shown(img)) continue;
|
|
3710
|
+
const alt = img.getAttribute("alt");
|
|
3711
|
+
const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
|
|
3712
|
+
addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
|
|
3713
|
+
}
|
|
3714
|
+
for (const el of all) {
|
|
3715
|
+
const bgImage = style(el).backgroundImage || "";
|
|
3716
|
+
if (!bgImage.includes("url(")) continue;
|
|
3717
|
+
const b = box(el);
|
|
3718
|
+
if (b.width < 160 || b.height < 120 || !shown(el)) continue;
|
|
3719
|
+
const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
|
|
3720
|
+
if (m) addImage(el, m[1], null, true, true);
|
|
3721
|
+
}
|
|
3722
|
+
images.sort((a, b) => b.width * b.height - a.width * a.height);
|
|
3257
3723
|
const toList = (m) => Array.from(m, ([value, v]) => ({ value, area: Math.round(v.area), chars: v.chars })).sort((a, b) => b.area - a.area || b.chars - a.chars);
|
|
3258
3724
|
const nums = (s) => Array.from(s).sort((a, b) => a - b);
|
|
3259
3725
|
return {
|
|
@@ -3273,6 +3739,7 @@ function collectInPage() {
|
|
|
3273
3739
|
return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
|
|
3274
3740
|
})(),
|
|
3275
3741
|
effects,
|
|
3742
|
+
images: images.slice(0, 60),
|
|
3276
3743
|
motion: {
|
|
3277
3744
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
3278
3745
|
sections: sections.length,
|
|
@@ -3436,7 +3903,60 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
3436
3903
|
};
|
|
3437
3904
|
}
|
|
3438
3905
|
|
|
3906
|
+
// src/snapshot/provenance.ts
|
|
3907
|
+
var PROVENANCE_SCAN_BYTES = 256 * 1024;
|
|
3908
|
+
var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
|
|
3909
|
+
var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
|
|
3910
|
+
var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
|
|
3911
|
+
var C2PA_PNG = /caBX/;
|
|
3912
|
+
function latin1(bytes) {
|
|
3913
|
+
let out = "";
|
|
3914
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
3915
|
+
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
3916
|
+
}
|
|
3917
|
+
return out;
|
|
3918
|
+
}
|
|
3919
|
+
function xmpPackets(text) {
|
|
3920
|
+
const out = [];
|
|
3921
|
+
const open = /<x:xmpmeta\b|<\?xpacket begin/g;
|
|
3922
|
+
let m;
|
|
3923
|
+
while (m = open.exec(text)) {
|
|
3924
|
+
const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
|
|
3925
|
+
out.push([m.index, close === -1 ? text.length : close]);
|
|
3926
|
+
if (close === -1) break;
|
|
3927
|
+
open.lastIndex = close;
|
|
3928
|
+
}
|
|
3929
|
+
return out;
|
|
3930
|
+
}
|
|
3931
|
+
function scanProvenance(bytes) {
|
|
3932
|
+
const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
|
|
3933
|
+
const text = latin1(head);
|
|
3934
|
+
const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
|
|
3935
|
+
const m = MARKER2.exec(text);
|
|
3936
|
+
if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
|
|
3937
|
+
const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
|
|
3938
|
+
return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3439
3941
|
// src/audit/index.ts
|
|
3942
|
+
var PROVENANCE_MAX_IMAGES = 24;
|
|
3943
|
+
var PROVENANCE_BUDGET_MS = 2e4;
|
|
3944
|
+
async function readProvenance(page, images, timeoutMs) {
|
|
3945
|
+
if (!page.request) return;
|
|
3946
|
+
const deadline = Date.now() + PROVENANCE_BUDGET_MS;
|
|
3947
|
+
const wanted = [...new Set(images.filter((i) => i.role !== "icon" && i.role !== "logo" && /^https?:\/\//.test(i.src)).map((i) => i.src))].slice(0, PROVENANCE_MAX_IMAGES);
|
|
3948
|
+
for (const src of wanted) {
|
|
3949
|
+
const left = deadline - Date.now();
|
|
3950
|
+
if (left < 500) break;
|
|
3951
|
+
try {
|
|
3952
|
+
const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
|
|
3953
|
+
if (!res.ok()) continue;
|
|
3954
|
+
const provenance = scanProvenance(await res.body());
|
|
3955
|
+
for (const image of images) if (image.src === src) image.provenance = provenance;
|
|
3956
|
+
} catch {
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3440
3960
|
var VISUAL_MAX_HEIGHT = 6e3;
|
|
3441
3961
|
async function loadPlaywright() {
|
|
3442
3962
|
for (const name of ["playwright", "playwright-core"]) {
|
|
@@ -3455,6 +3975,7 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
3455
3975
|
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
3456
3976
|
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
3457
3977
|
const data = await page.evaluate(collectInPage);
|
|
3978
|
+
if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
|
|
3458
3979
|
let visual;
|
|
3459
3980
|
if (page.screenshot && options.visual !== false) {
|
|
3460
3981
|
try {
|
|
@@ -3517,6 +4038,8 @@ async function audit(url, options = {}) {
|
|
|
3517
4038
|
return { snapshot, report: auditSnapshot(snapshot, options), fingerprint: fingerprint(snapshot) };
|
|
3518
4039
|
}
|
|
3519
4040
|
export {
|
|
4041
|
+
PROVENANCE_BUDGET_MS,
|
|
4042
|
+
PROVENANCE_MAX_IMAGES,
|
|
3520
4043
|
VISUAL_MAX_HEIGHT,
|
|
3521
4044
|
audit,
|
|
3522
4045
|
collectInPage,
|