@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.cjs
CHANGED
|
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/audit/index.ts
|
|
21
21
|
var audit_exports = {};
|
|
22
22
|
__export(audit_exports, {
|
|
23
|
+
PROVENANCE_BUDGET_MS: () => PROVENANCE_BUDGET_MS,
|
|
24
|
+
PROVENANCE_MAX_IMAGES: () => PROVENANCE_MAX_IMAGES,
|
|
23
25
|
VISUAL_MAX_HEIGHT: () => VISUAL_MAX_HEIGHT,
|
|
24
26
|
audit: () => audit,
|
|
25
27
|
collectInPage: () => collectInPage,
|
|
@@ -1033,14 +1035,14 @@ function contractionHits(ctx) {
|
|
|
1033
1035
|
for (const doc2 of documents(ctx)) {
|
|
1034
1036
|
if (doc2.words < 800) continue;
|
|
1035
1037
|
const text = doc2.blocks.map((b) => withoutHeadings(b.text)).join("\n");
|
|
1036
|
-
const
|
|
1037
|
-
const rate =
|
|
1038
|
+
const count3 = (text.match(CONTRACTION) ?? []).length;
|
|
1039
|
+
const rate = count3 * 1e3 / doc2.words;
|
|
1038
1040
|
if (rate < 1) {
|
|
1039
1041
|
const first = sentences(doc2)[0];
|
|
1040
1042
|
hits.push({
|
|
1041
1043
|
path: doc2.path,
|
|
1042
1044
|
offset: first?.offset ?? 0,
|
|
1043
|
-
message: `${
|
|
1045
|
+
message: `${count3} contraction${count3 === 1 ? "" : "s"} in ${thousands(doc2.words)} words. Prose with none reads stiff; formal legal pages are the exception`
|
|
1044
1046
|
});
|
|
1045
1047
|
}
|
|
1046
1048
|
}
|
|
@@ -1163,16 +1165,16 @@ function filler(words, seed = 1, options = {}) {
|
|
|
1163
1165
|
return state % n;
|
|
1164
1166
|
};
|
|
1165
1167
|
const out = [];
|
|
1166
|
-
let
|
|
1168
|
+
let count3 = 0;
|
|
1167
1169
|
let i = 0;
|
|
1168
|
-
while (
|
|
1170
|
+
while (count3 < words) {
|
|
1169
1171
|
const length = uniform ?? shape[i % shape.length];
|
|
1170
1172
|
const s = [];
|
|
1171
1173
|
for (let w = 0; w < length; w += 1) s.push(FILLER_WORDS[next(FILLER_WORDS.length)]);
|
|
1172
1174
|
if (contractions && i % 6 === 2) s.splice(1, 0, "we're");
|
|
1173
1175
|
s[0] = s[0].charAt(0).toUpperCase() + s[0].slice(1);
|
|
1174
1176
|
out.push(`${s.join(" ")}.`);
|
|
1175
|
-
|
|
1177
|
+
count3 += s.length;
|
|
1176
1178
|
i += 1;
|
|
1177
1179
|
}
|
|
1178
1180
|
const paragraphs = [];
|
|
@@ -2753,11 +2755,433 @@ var RENDERED_PATHS = {
|
|
|
2753
2755
|
}
|
|
2754
2756
|
};
|
|
2755
2757
|
|
|
2758
|
+
// src/fingerprint/index.ts
|
|
2759
|
+
var FINGERPRINT_VERSION = 2;
|
|
2760
|
+
var CHROMATIC = 0.03;
|
|
2761
|
+
var MIN_ACCENT_AREA = 2e4;
|
|
2762
|
+
var oklch = (value) => {
|
|
2763
|
+
const parsed = parseColour(value);
|
|
2764
|
+
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
2765
|
+
};
|
|
2766
|
+
function accentOf(s) {
|
|
2767
|
+
const buttons = /* @__PURE__ */ new Map();
|
|
2768
|
+
for (const c of s.controls) {
|
|
2769
|
+
const o = oklch(c.background);
|
|
2770
|
+
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
2771
|
+
}
|
|
2772
|
+
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2773
|
+
if (topButton) return oklch(topButton[0]);
|
|
2774
|
+
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];
|
|
2775
|
+
if (bg) return bg.o;
|
|
2776
|
+
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];
|
|
2777
|
+
return text ? text.o : null;
|
|
2778
|
+
}
|
|
2779
|
+
function faces(s) {
|
|
2780
|
+
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
2781
|
+
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
2782
|
+
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
2783
|
+
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
2784
|
+
return {
|
|
2785
|
+
display: { family: displayName, class: fontClass(displayName) },
|
|
2786
|
+
body: { family: bodyName, class: fontClass(bodyName) }
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function median(values) {
|
|
2790
|
+
if (values.length === 0) return null;
|
|
2791
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2792
|
+
const mid = Math.floor(sorted.length / 2);
|
|
2793
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
2794
|
+
}
|
|
2795
|
+
function fingerprint(s) {
|
|
2796
|
+
const effects = [];
|
|
2797
|
+
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
2798
|
+
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
2799
|
+
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
2800
|
+
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
2801
|
+
if (s.effects.marquees > 0) effects.push("marquee");
|
|
2802
|
+
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
2803
|
+
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
2804
|
+
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
2805
|
+
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
2806
|
+
return {
|
|
2807
|
+
version: FINGERPRINT_VERSION,
|
|
2808
|
+
accent: accentOf(s),
|
|
2809
|
+
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
2810
|
+
...faces(s),
|
|
2811
|
+
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2812
|
+
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2813
|
+
effects: effects.sort(),
|
|
2814
|
+
layout: s.sections.map((x) => x.kind),
|
|
2815
|
+
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? {
|
|
2816
|
+
sections: s.sections.map((x) => ({
|
|
2817
|
+
role: x.role,
|
|
2818
|
+
centred: x.geometry.centredShare,
|
|
2819
|
+
width: x.geometry.contentWidthRatio,
|
|
2820
|
+
symmetry: x.geometry.mirrorSymmetry,
|
|
2821
|
+
controls: x.geometry.controls,
|
|
2822
|
+
cards: x.cards,
|
|
2823
|
+
background: x.geometry.background === s.ground ? null : oklch(x.geometry.background),
|
|
2824
|
+
badges: (x.badges ?? []).map((b) => b.toLowerCase()),
|
|
2825
|
+
avatars: x.avatars ?? 0,
|
|
2826
|
+
carousel: x.carousel ?? false,
|
|
2827
|
+
figures: x.figures ?? 0
|
|
2828
|
+
}))
|
|
2829
|
+
} : {}
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// src/fingerprint/component.ts
|
|
2834
|
+
var PROMO_BADGE = /\b(?:most popular|popular|recommended|best value|best seller|bestseller|most chosen)\b/i;
|
|
2835
|
+
|
|
2836
|
+
// src/snapshot/rendered-tells.ts
|
|
2837
|
+
var hit2 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
2838
|
+
var GROUND = "rgb(255, 255, 255)";
|
|
2839
|
+
var BAND = "rgb(31, 58, 46)";
|
|
2840
|
+
var geo = (patch = {}) => ({
|
|
2841
|
+
centredShare: 0.1,
|
|
2842
|
+
mirrorSymmetry: 0.6,
|
|
2843
|
+
whitespaceRatio: 0.7,
|
|
2844
|
+
contentWidthRatio: 0.85,
|
|
2845
|
+
background: GROUND,
|
|
2846
|
+
controls: 0,
|
|
2847
|
+
...patch
|
|
2848
|
+
});
|
|
2849
|
+
function withSection(index, patch, roles = []) {
|
|
2850
|
+
const base = makeSnapshot();
|
|
2851
|
+
return {
|
|
2852
|
+
...base,
|
|
2853
|
+
sections: base.sections.map((s, i) => ({ ...s, role: roles[i] ?? s.kind, geometry: geo(), badges: [], avatars: 0, carousel: false, figures: 0, ...i === index ? patch : {} }))
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
var measured = (s) => s.geometry !== void 0 && s.role !== void 0;
|
|
2857
|
+
var RENDERED_TELLS = [
|
|
2858
|
+
{
|
|
2859
|
+
id: "cta-band-stock",
|
|
2860
|
+
name: "Stock call-to-action band",
|
|
2861
|
+
generation: 1,
|
|
2862
|
+
severity: "warn",
|
|
2863
|
+
surface: "rendered",
|
|
2864
|
+
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.",
|
|
2865
|
+
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.",
|
|
2866
|
+
rendered: {
|
|
2867
|
+
detect: (s) => s.sections.filter((x) => measured(x) && (x.role === "cta-band" || x.role === "footer-cta")).filter((x) => {
|
|
2868
|
+
const g = x.geometry;
|
|
2869
|
+
return g.centredShare >= 0.8 && g.background !== s.ground && g.controls >= 1 && g.controls <= 2 && g.contentWidthRatio <= 0.7;
|
|
2870
|
+
}).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)),
|
|
2871
|
+
fixtures: {
|
|
2872
|
+
flag: [
|
|
2873
|
+
withSection(4, { role: "footer-cta", label: "Boiler out today?", geometry: geo({ centredShare: 1, background: BAND, controls: 1, contentWidthRatio: 0.45 }) }),
|
|
2874
|
+
withSection(3, { role: "cta-band", label: "Ready to book?", geometry: geo({ centredShare: 0.9, background: BAND, controls: 2, contentWidthRatio: 0.5 }) })
|
|
2875
|
+
],
|
|
2876
|
+
pass: [
|
|
2877
|
+
makeSnapshot(),
|
|
2878
|
+
// The same ask, laid out like the page: left-aligned on the ground.
|
|
2879
|
+
withSection(4, { role: "footer-cta", label: "Book a visit", geometry: geo({ centredShare: 0.1, background: GROUND, controls: 1, contentWidthRatio: 0.85 }) })
|
|
2880
|
+
]
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
},
|
|
2884
|
+
{
|
|
2885
|
+
id: "pricing-trio-popular",
|
|
2886
|
+
name: "Three-tier pricing with a popular badge",
|
|
2887
|
+
generation: 1,
|
|
2888
|
+
severity: "warn",
|
|
2889
|
+
surface: "rendered",
|
|
2890
|
+
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.",
|
|
2891
|
+
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.",
|
|
2892
|
+
rendered: {
|
|
2893
|
+
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)),
|
|
2894
|
+
fixtures: {
|
|
2895
|
+
flag: [withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: ["Most popular"], label: "Plans" })],
|
|
2896
|
+
pass: [
|
|
2897
|
+
makeSnapshot(),
|
|
2898
|
+
withSection(3, { role: "pricing", kind: "cards", cards: 3, badges: [], label: "Prices" }),
|
|
2899
|
+
withSection(3, { role: "pricing", kind: "text", cards: 0, badges: [], label: "Price list" })
|
|
2900
|
+
]
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
},
|
|
2904
|
+
{
|
|
2905
|
+
id: "testimonial-avatar-carousel",
|
|
2906
|
+
name: "Avatar testimonial carousel",
|
|
2907
|
+
generation: 1,
|
|
2908
|
+
severity: "warn",
|
|
2909
|
+
surface: "rendered",
|
|
2910
|
+
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.",
|
|
2911
|
+
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.",
|
|
2912
|
+
rendered: {
|
|
2913
|
+
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)),
|
|
2914
|
+
fixtures: {
|
|
2915
|
+
flag: [withSection(2, { role: "testimonials", avatars: 3, carousel: true, label: "What our clients say" })],
|
|
2916
|
+
pass: [
|
|
2917
|
+
makeSnapshot(),
|
|
2918
|
+
withSection(2, { role: "testimonials", avatars: 3, carousel: false, label: "Reviews" }),
|
|
2919
|
+
withSection(2, { role: "testimonials", avatars: 0, carousel: true, label: "Reviews" })
|
|
2920
|
+
]
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
},
|
|
2924
|
+
{
|
|
2925
|
+
id: "faq-accordion-closer",
|
|
2926
|
+
name: "FAQ accordion as the closer",
|
|
2927
|
+
generation: 2,
|
|
2928
|
+
severity: "warn",
|
|
2929
|
+
surface: "rendered",
|
|
2930
|
+
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.",
|
|
2931
|
+
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.",
|
|
2932
|
+
rendered: {
|
|
2933
|
+
detect: (s) => {
|
|
2934
|
+
const body = s.sections.filter((x) => x.role !== "footer-cta" && x.role !== "cta-band");
|
|
2935
|
+
const last = body[body.length - 1];
|
|
2936
|
+
return last && measured(last) && last.role === "faq" ? [hit2(s, "the page ends on a question-and-answer list", last.label)] : [];
|
|
2937
|
+
},
|
|
2938
|
+
fixtures: {
|
|
2939
|
+
flag: [
|
|
2940
|
+
withSection(4, { role: "faq", label: "Common questions" }),
|
|
2941
|
+
{
|
|
2942
|
+
...withSection(3, { role: "faq", label: "FAQ" }),
|
|
2943
|
+
sections: withSection(3, { role: "faq", label: "FAQ" }).sections.map((x, i) => i === 4 ? { ...x, role: "footer-cta" } : x)
|
|
2944
|
+
}
|
|
2945
|
+
],
|
|
2946
|
+
pass: [makeSnapshot(), withSection(2, { role: "faq", label: "Common questions" })]
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
},
|
|
2950
|
+
{
|
|
2951
|
+
id: "stats-row",
|
|
2952
|
+
name: "Row of big numbers",
|
|
2953
|
+
generation: 1,
|
|
2954
|
+
severity: "warn",
|
|
2955
|
+
surface: "rendered",
|
|
2956
|
+
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.",
|
|
2957
|
+
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.",
|
|
2958
|
+
rendered: {
|
|
2959
|
+
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)),
|
|
2960
|
+
fixtures: {
|
|
2961
|
+
flag: [withSection(3, { role: "stats", kind: "stats", figures: 4, label: "500+" })],
|
|
2962
|
+
pass: [
|
|
2963
|
+
makeSnapshot(),
|
|
2964
|
+
withSection(3, { role: "text", kind: "text", figures: 1, label: "Since 2009" }),
|
|
2965
|
+
withSection(1, { role: "stats", kind: "stats", figures: 4, label: "500+" })
|
|
2966
|
+
]
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
},
|
|
2970
|
+
{
|
|
2971
|
+
id: "centred-everything",
|
|
2972
|
+
name: "Every section centred",
|
|
2973
|
+
generation: 2,
|
|
2974
|
+
severity: "warn",
|
|
2975
|
+
surface: "rendered",
|
|
2976
|
+
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.",
|
|
2977
|
+
fix: "Pick an alignment and hold it: left-aligned text with a clear edge, keeping centring for the one moment that earns it.",
|
|
2978
|
+
rendered: {
|
|
2979
|
+
detect: (s) => {
|
|
2980
|
+
const m = s.sections.filter(measured);
|
|
2981
|
+
if (m.length < 4) return [];
|
|
2982
|
+
const centred = m.filter((x) => x.geometry.centredShare >= 0.7).length;
|
|
2983
|
+
return centred / m.length >= 0.8 ? [hit2(s, `${centred} of ${m.length} sections centre their text`)] : [];
|
|
2984
|
+
},
|
|
2985
|
+
fixtures: {
|
|
2986
|
+
flag: [{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x) => ({ ...x, role: x.kind, geometry: geo({ centredShare: 0.9 }) })) }],
|
|
2987
|
+
pass: [
|
|
2988
|
+
makeSnapshot(),
|
|
2989
|
+
{ ...makeSnapshot(), sections: makeSnapshot().sections.map((x, i) => ({ ...x, role: x.kind, geometry: geo({ centredShare: i < 2 ? 0.9 : 0.1 }) })) }
|
|
2990
|
+
]
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
];
|
|
2995
|
+
|
|
2996
|
+
// src/snapshot/imagery-tells.ts
|
|
2997
|
+
var hit3 = (s, message, excerpt) => ({ path: s.url, offset: 0, message, excerpt: excerpt ?? message });
|
|
2998
|
+
var STOCK_PHOTO_HOSTS = {
|
|
2999
|
+
"unsplash.com": "Unsplash",
|
|
3000
|
+
"pexels.com": "Pexels",
|
|
3001
|
+
"pixabay.com": "Pixabay",
|
|
3002
|
+
"istockphoto.com": "iStock",
|
|
3003
|
+
"gettyimages.com": "Getty Images",
|
|
3004
|
+
"shutterstock.com": "Shutterstock",
|
|
3005
|
+
"ftcdn.net": "Adobe Stock",
|
|
3006
|
+
"dreamstime.com": "Dreamstime",
|
|
3007
|
+
"depositphotos.com": "Depositphotos",
|
|
3008
|
+
"freepik.com": "Freepik",
|
|
3009
|
+
"rawpixel.com": "rawpixel",
|
|
3010
|
+
"stocksnap.io": "StockSnap",
|
|
3011
|
+
"burst.shopifycdn.com": "Burst",
|
|
3012
|
+
"picsum.photos": "Lorem Picsum",
|
|
3013
|
+
"loremflickr.com": "LoremFlickr",
|
|
3014
|
+
"placehold.co": "a placeholder service",
|
|
3015
|
+
"placeholder.com": "a placeholder service",
|
|
3016
|
+
"dummyimage.com": "a placeholder service"
|
|
3017
|
+
};
|
|
3018
|
+
var STOCK_AVATAR_HOSTS = {
|
|
3019
|
+
"randomuser.me": "randomuser.me",
|
|
3020
|
+
"pravatar.cc": "Pravatar",
|
|
3021
|
+
"dicebear.com": "DiceBear",
|
|
3022
|
+
"xsgames.co": "a random-face service",
|
|
3023
|
+
"thispersondoesnotexist.com": "a generated face",
|
|
3024
|
+
"generated.photos": "a generated face"
|
|
3025
|
+
};
|
|
3026
|
+
var STOCK_FILENAMES = [
|
|
3027
|
+
[/(?:^|[/_-])shutterstock[_-]?\d{4,}/i, "Shutterstock"],
|
|
3028
|
+
[/(?:^|[/_-])istock(?:photo)?[_-]?\d{4,}/i, "iStock"],
|
|
3029
|
+
[/(?:^|[/_-])adobestock[_-]?\d{4,}/i, "Adobe Stock"],
|
|
3030
|
+
[/(?:^|[/_-])gettyimages[_-]?\d{4,}/i, "Getty Images"],
|
|
3031
|
+
[/(?:^|[/_-])depositphotos[_-]?\d{4,}/i, "Depositphotos"],
|
|
3032
|
+
[/(?:^|[/_-])dreamstime[_-]/i, "Dreamstime"],
|
|
3033
|
+
[/(?:^|[/_-])pexels-[a-z0-9-]*\d{4,}/i, "Pexels"],
|
|
3034
|
+
[/-unsplash\.(?:jpe?g|png|webp|avif)(?:[?#]|$)/i, "Unsplash"],
|
|
3035
|
+
// Unsplash's photo ids: photo-<13-digit timestamp>-<12 hex>.
|
|
3036
|
+
[/photo-1\d{12}-[0-9a-f]{12}/, "Unsplash"]
|
|
3037
|
+
];
|
|
3038
|
+
var byHost = (host, table) => {
|
|
3039
|
+
if (!host) return null;
|
|
3040
|
+
const h = host.toLowerCase();
|
|
3041
|
+
for (const [domain, name] of Object.entries(table)) if (h === domain || h.endsWith(`.${domain}`)) return name;
|
|
3042
|
+
return null;
|
|
3043
|
+
};
|
|
3044
|
+
function stockSource(image) {
|
|
3045
|
+
const host = byHost(image.host, STOCK_PHOTO_HOSTS);
|
|
3046
|
+
if (host) return host;
|
|
3047
|
+
let path = image.src.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i, "").replace(/[?#].*$/, "");
|
|
3048
|
+
try {
|
|
3049
|
+
path = decodeURIComponent(path);
|
|
3050
|
+
} catch {
|
|
3051
|
+
}
|
|
3052
|
+
for (const [re, name] of STOCK_FILENAMES) if (re.test(path)) return name;
|
|
3053
|
+
return null;
|
|
3054
|
+
}
|
|
3055
|
+
function stockAvatarSource(image) {
|
|
3056
|
+
return byHost(image.host, STOCK_AVATAR_HOSTS) ?? stockSource(image);
|
|
3057
|
+
}
|
|
3058
|
+
var picture = (patch = {}) => ({
|
|
3059
|
+
src: "https://example.test/images/boiler-swap-hinton.jpg",
|
|
3060
|
+
host: "example.test",
|
|
3061
|
+
width: 640,
|
|
3062
|
+
height: 480,
|
|
3063
|
+
top: 1800,
|
|
3064
|
+
role: "photo",
|
|
3065
|
+
alt: "The new combi boiler fitted in a kitchen cupboard in Hinton",
|
|
3066
|
+
decorative: false,
|
|
3067
|
+
section: 2,
|
|
3068
|
+
background: false,
|
|
3069
|
+
...patch
|
|
3070
|
+
});
|
|
3071
|
+
var withImages = (images) => ({ ...makeSnapshot(), images });
|
|
3072
|
+
var AI_XMP = { digitalSourceType: "trainedAlgorithmicMedia", source: "xmp", c2pa: false, bytes: 48e3 };
|
|
3073
|
+
var NOTHING = { digitalSourceType: null, source: null, c2pa: false, bytes: 48e3 };
|
|
3074
|
+
var count2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
3075
|
+
var IMAGERY_TELLS = [
|
|
3076
|
+
{
|
|
3077
|
+
id: "stock-photo",
|
|
3078
|
+
name: "Stock photography",
|
|
3079
|
+
generation: 1,
|
|
3080
|
+
severity: "warn",
|
|
3081
|
+
surface: "rendered",
|
|
3082
|
+
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.",
|
|
3083
|
+
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.",
|
|
3084
|
+
rendered: {
|
|
3085
|
+
detect: (s) => {
|
|
3086
|
+
const found = (s.images ?? []).filter((i) => i.role === "photo" || i.role === "illustration").map((i) => ({ image: i, from: stockSource(i) })).filter((x) => x.from);
|
|
3087
|
+
if (found.length === 0) return [];
|
|
3088
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3089
|
+
return [hit3(s, `${count2(found.length, "picture")} from ${names}`, found[0].image.src)];
|
|
3090
|
+
},
|
|
3091
|
+
fixtures: {
|
|
3092
|
+
flag: [
|
|
3093
|
+
withImages([picture({ src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952?w=1600", host: "images.unsplash.com", alt: "Plumber fixing a sink" })]),
|
|
3094
|
+
// Downloaded and self-hosted, found by the library's own file name.
|
|
3095
|
+
withImages([picture({ src: "https://example.test/img/shutterstock_1234567890.jpg" })])
|
|
3096
|
+
],
|
|
3097
|
+
pass: [makeSnapshot(), withImages([picture()]), withImages([picture({ role: "icon", width: 48, height: 48, src: "https://images.unsplash.com/photo-1581578731548-c64695cc6952", host: "images.unsplash.com" })])]
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
},
|
|
3101
|
+
{
|
|
3102
|
+
id: "stock-avatar",
|
|
3103
|
+
name: "Stock or placeholder faces",
|
|
3104
|
+
generation: 1,
|
|
3105
|
+
severity: "warn",
|
|
3106
|
+
surface: "rendered",
|
|
3107
|
+
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.",
|
|
3108
|
+
fix: "Drop the portraits. Name the reviewer, the town and the job, and link the review to where it was left.",
|
|
3109
|
+
rendered: {
|
|
3110
|
+
detect: (s) => {
|
|
3111
|
+
const found = (s.images ?? []).filter((i) => i.role === "avatar").map((i) => ({ image: i, from: stockAvatarSource(i) })).filter((x) => x.from);
|
|
3112
|
+
if (found.length === 0) return [];
|
|
3113
|
+
const names = [...new Set(found.map((x) => x.from))].join(", ");
|
|
3114
|
+
return [hit3(s, `${count2(found.length, "portrait")} from ${names}`, found[0].image.src)];
|
|
3115
|
+
},
|
|
3116
|
+
fixtures: {
|
|
3117
|
+
flag: [
|
|
3118
|
+
withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://i.pravatar.cc/150?img=12", host: "i.pravatar.cc", alt: "Sarah" })]),
|
|
3119
|
+
withImages([picture({ role: "avatar", width: 64, height: 64, src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?crop=faces", host: "images.unsplash.com" })])
|
|
3120
|
+
],
|
|
3121
|
+
pass: [makeSnapshot(), withImages([picture(), picture({ role: "avatar", width: 56, height: 56, src: "https://example.test/team/sam.jpg", alt: "Sam, engineer" })])]
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
},
|
|
3125
|
+
{
|
|
3126
|
+
id: "ai-image",
|
|
3127
|
+
name: "Generated image",
|
|
3128
|
+
generation: 2,
|
|
3129
|
+
severity: "warn",
|
|
3130
|
+
surface: "rendered",
|
|
3131
|
+
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.",
|
|
3132
|
+
fix: "Replace it with a photo of the real thing: the work, the premises, the people. Where there is none yet, use fewer pictures.",
|
|
3133
|
+
rendered: {
|
|
3134
|
+
detect: (s) => {
|
|
3135
|
+
const found = (s.images ?? []).filter((i) => i.provenance?.digitalSourceType);
|
|
3136
|
+
if (found.length === 0) return [];
|
|
3137
|
+
const first = found[0].provenance;
|
|
3138
|
+
const where = first.source === "xmp" ? "its XMP metadata" : first.source === "c2pa" ? "its Content Credentials" : "its metadata";
|
|
3139
|
+
return [hit3(s, `${count2(found.length, "image")} labelled as made by a generative model (${first.digitalSourceType} in ${where})`, found[0].src)];
|
|
3140
|
+
},
|
|
3141
|
+
fixtures: {
|
|
3142
|
+
flag: [
|
|
3143
|
+
withImages([picture({ provenance: AI_XMP })]),
|
|
3144
|
+
withImages([picture({ provenance: { digitalSourceType: "compositeWithTrainedAlgorithmicMedia", source: "c2pa", c2pa: true, bytes: 12e4 } })])
|
|
3145
|
+
],
|
|
3146
|
+
// Credentials that say nothing about a model, and an image never read, are not findings.
|
|
3147
|
+
pass: [makeSnapshot(), withImages([picture({ provenance: NOTHING })]), withImages([picture({ provenance: { ...NOTHING, c2pa: true } })]), withImages([picture()])]
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
},
|
|
3151
|
+
{
|
|
3152
|
+
id: "no-real-imagery",
|
|
3153
|
+
name: "No photograph on the page",
|
|
3154
|
+
generation: 1,
|
|
3155
|
+
severity: "warn",
|
|
3156
|
+
surface: "rendered",
|
|
3157
|
+
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.",
|
|
3158
|
+
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.",
|
|
3159
|
+
rendered: {
|
|
3160
|
+
detect: (s) => {
|
|
3161
|
+
if (!s.images || s.sections.length < 4) return [];
|
|
3162
|
+
if (s.images.some((i) => i.role === "photo")) return [];
|
|
3163
|
+
const icons = s.images.filter((i) => i.role === "icon" || i.role === "illustration").length;
|
|
3164
|
+
return [hit3(s, `no photograph on the page${icons ? `: ${count2(icons, "icon or illustration", "icons and illustrations")} instead` : ""}`)];
|
|
3165
|
+
},
|
|
3166
|
+
fixtures: {
|
|
3167
|
+
flag: [
|
|
3168
|
+
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" })]),
|
|
3169
|
+
withImages([])
|
|
3170
|
+
],
|
|
3171
|
+
// A snapshot with no image record is never judged; nor is a page too short to be a site.
|
|
3172
|
+
pass: [makeSnapshot(), withImages([picture()]), { ...withImages([]), sections: makeSnapshot().sections.slice(0, 3) }]
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
];
|
|
3177
|
+
|
|
2756
3178
|
// src/character/check.ts
|
|
2757
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
2758
|
-
var CATALOGUE = [
|
|
2759
|
-
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
2760
|
-
|
|
3179
|
+
var CATALOGUE_VERSION = "2026.09.11";
|
|
3180
|
+
var CATALOGUE = [
|
|
3181
|
+
...[...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map((t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t),
|
|
3182
|
+
...RENDERED_TELLS,
|
|
3183
|
+
...IMAGERY_TELLS
|
|
3184
|
+
];
|
|
2761
3185
|
function tellById(id) {
|
|
2762
3186
|
return CATALOGUE.find((t) => t.id === id);
|
|
2763
3187
|
}
|
|
@@ -2779,17 +3203,17 @@ var MARKER = /\b(?:copy|craft)-ok\b/;
|
|
|
2779
3203
|
function marked(lines, line) {
|
|
2780
3204
|
return MARKER.test(lines[line - 1] ?? "") || MARKER.test(lines[line - 2] ?? "");
|
|
2781
3205
|
}
|
|
2782
|
-
function toFinding(tell,
|
|
2783
|
-
const text = texts.get(
|
|
3206
|
+
function toFinding(tell, hit4, texts) {
|
|
3207
|
+
const text = texts.get(hit4.path) ?? "";
|
|
2784
3208
|
return {
|
|
2785
3209
|
tell: tell.id,
|
|
2786
3210
|
name: tell.name,
|
|
2787
3211
|
generation: tell.generation,
|
|
2788
3212
|
severity: tell.severity,
|
|
2789
|
-
path:
|
|
2790
|
-
line: texts.has(
|
|
2791
|
-
excerpt:
|
|
2792
|
-
message:
|
|
3213
|
+
path: hit4.path,
|
|
3214
|
+
line: texts.has(hit4.path) ? lineAt(text, hit4.offset) : 0,
|
|
3215
|
+
excerpt: hit4.excerpt ?? excerptAt(text, hit4.offset),
|
|
3216
|
+
message: hit4.message,
|
|
2793
3217
|
fix: tell.fix,
|
|
2794
3218
|
...tell.surface === "copy" ? { house: houseRule(tell.id, tell.name).tier } : {}
|
|
2795
3219
|
};
|
|
@@ -2808,14 +3232,14 @@ function report(tells, files, run, options) {
|
|
|
2808
3232
|
};
|
|
2809
3233
|
const excepted = /* @__PURE__ */ new Map();
|
|
2810
3234
|
for (const tell of selected) {
|
|
2811
|
-
for (const
|
|
2812
|
-
const exception = applied.find((e) => e.tell === tell.id && (!e.path ||
|
|
3235
|
+
for (const hit4 of run(tell)) {
|
|
3236
|
+
const exception = applied.find((e) => e.tell === tell.id && (!e.path || hit4.path.includes(e.path)));
|
|
2813
3237
|
if (exception) {
|
|
2814
3238
|
excepted.set(exception, (excepted.get(exception) ?? 0) + 1);
|
|
2815
3239
|
continue;
|
|
2816
3240
|
}
|
|
2817
|
-
const finding = toFinding(tell,
|
|
2818
|
-
if (marked(linesOf(
|
|
3241
|
+
const finding = toFinding(tell, hit4, texts);
|
|
3242
|
+
if (marked(linesOf(hit4.path), finding.line)) {
|
|
2819
3243
|
suppressed.push({ tell: tell.id, path: finding.path, line: finding.line });
|
|
2820
3244
|
continue;
|
|
2821
3245
|
}
|
|
@@ -2833,7 +3257,7 @@ function report(tells, files, run, options) {
|
|
|
2833
3257
|
catalogueVersion: CATALOGUE_VERSION,
|
|
2834
3258
|
findings,
|
|
2835
3259
|
rejectedExceptions: rejected,
|
|
2836
|
-
excepted: [...excepted].map(([e,
|
|
3260
|
+
excepted: [...excepted].map(([e, count3]) => ({ tell: e.tell, because: e.because, count: count3 })),
|
|
2837
3261
|
suppressed,
|
|
2838
3262
|
summary: {
|
|
2839
3263
|
files: files.length,
|
|
@@ -2851,67 +3275,6 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
2851
3275
|
return result;
|
|
2852
3276
|
}
|
|
2853
3277
|
|
|
2854
|
-
// src/fingerprint/index.ts
|
|
2855
|
-
var FINGERPRINT_VERSION = 2;
|
|
2856
|
-
var CHROMATIC = 0.03;
|
|
2857
|
-
var MIN_ACCENT_AREA = 2e4;
|
|
2858
|
-
var oklch = (value) => {
|
|
2859
|
-
const parsed = parseColour(value);
|
|
2860
|
-
return parsed && parsed.alpha > 0.5 ? parsed.oklch : null;
|
|
2861
|
-
};
|
|
2862
|
-
function accentOf(s) {
|
|
2863
|
-
const buttons = /* @__PURE__ */ new Map();
|
|
2864
|
-
for (const c of s.controls) {
|
|
2865
|
-
const o = oklch(c.background);
|
|
2866
|
-
if (o && o.c >= CHROMATIC) buttons.set(c.background, (buttons.get(c.background) ?? 0) + 1);
|
|
2867
|
-
}
|
|
2868
|
-
const topButton = [...buttons].sort((a, b) => b[1] - a[1])[0];
|
|
2869
|
-
if (topButton) return oklch(topButton[0]);
|
|
2870
|
-
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];
|
|
2871
|
-
if (bg) return bg.o;
|
|
2872
|
-
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];
|
|
2873
|
-
return text ? text.o : null;
|
|
2874
|
-
}
|
|
2875
|
-
function faces(s) {
|
|
2876
|
-
const byChars = [...s.fonts].sort((a, b) => b.chars - a.chars);
|
|
2877
|
-
const byDisplay = [...s.fonts].sort((a, b) => b.displayChars - a.displayChars);
|
|
2878
|
-
const bodyName = normaliseFamily(byChars[0]?.family ?? "system-ui");
|
|
2879
|
-
const displayName = normaliseFamily((byDisplay[0]?.displayChars ? byDisplay[0].family : byChars[0]?.family) ?? "system-ui");
|
|
2880
|
-
return {
|
|
2881
|
-
display: { family: displayName, class: fontClass(displayName) },
|
|
2882
|
-
body: { family: bodyName, class: fontClass(bodyName) }
|
|
2883
|
-
};
|
|
2884
|
-
}
|
|
2885
|
-
function median(values) {
|
|
2886
|
-
if (values.length === 0) return null;
|
|
2887
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
2888
|
-
const mid = Math.floor(sorted.length / 2);
|
|
2889
|
-
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
2890
|
-
}
|
|
2891
|
-
function fingerprint(s) {
|
|
2892
|
-
const effects = [];
|
|
2893
|
-
if (s.effects.glass > 0) effects.push("glass-panel");
|
|
2894
|
-
if (s.effects.gradientText > 0) effects.push("gradient-text");
|
|
2895
|
-
if (s.effects.glows > 0 || s.effects.gradients.some((g) => g.radial)) effects.push("radial-spotlight-glow");
|
|
2896
|
-
if (s.effects.gridBackgrounds > 0) effects.push("grid-background");
|
|
2897
|
-
if (s.effects.marquees > 0) effects.push("marquee");
|
|
2898
|
-
if (s.effects.hairlineShadowCards >= 3) effects.push("thin-border-wide-shadow");
|
|
2899
|
-
if (s.effects.eyebrowChip) effects.push("hero-eyebrow-chip");
|
|
2900
|
-
if (s.motion.introOverlay) effects.push("intro-cinematic");
|
|
2901
|
-
const below = s.sections.filter((x) => x.top > s.viewport.height).length;
|
|
2902
|
-
return {
|
|
2903
|
-
version: FINGERPRINT_VERSION,
|
|
2904
|
-
accent: accentOf(s),
|
|
2905
|
-
ground: oklch(s.ground) ?? { l: 1, c: 0, h: 0 },
|
|
2906
|
-
...faces(s),
|
|
2907
|
-
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2908
|
-
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2909
|
-
effects: effects.sort(),
|
|
2910
|
-
layout: s.sections.map((x) => x.kind),
|
|
2911
|
-
...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 })) } : {}
|
|
2912
|
-
};
|
|
2913
|
-
}
|
|
2914
|
-
|
|
2915
3278
|
// src/audit/collect.ts
|
|
2916
3279
|
function collectInPage() {
|
|
2917
3280
|
const vw = window.innerWidth;
|
|
@@ -3151,7 +3514,7 @@ function collectInPage() {
|
|
|
3151
3514
|
};
|
|
3152
3515
|
};
|
|
3153
3516
|
const CURRENCY = /[£$€]\s?\d/;
|
|
3154
|
-
const roleOf = (el, kind,
|
|
3517
|
+
const roleOf = (el, kind, geo2, b, cards, iconCards, last) => {
|
|
3155
3518
|
if (kind === "marquee" || kind === "hero") return kind;
|
|
3156
3519
|
const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
|
|
3157
3520
|
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
|
|
@@ -3175,27 +3538,55 @@ function collectInPage() {
|
|
|
3175
3538
|
if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
|
|
3176
3539
|
if (kind === "stats" || kind === "logos") return kind;
|
|
3177
3540
|
const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
|
|
3178
|
-
const setApart =
|
|
3179
|
-
if (setApart && heading && b.height <= vh * 0.9 &&
|
|
3541
|
+
const setApart = geo2.background !== ground || geo2.centredShare >= 0.6;
|
|
3542
|
+
if (setApart && heading && b.height <= vh * 0.9 && geo2.controls >= 1 && geo2.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
|
|
3180
3543
|
if (cards >= 3 && iconCards >= 2) return "features";
|
|
3181
3544
|
return kind;
|
|
3182
3545
|
};
|
|
3546
|
+
const partsOf = (el) => {
|
|
3547
|
+
const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
|
|
3548
|
+
const badges = [];
|
|
3549
|
+
for (const n of inside) {
|
|
3550
|
+
const t = ownText(n);
|
|
3551
|
+
if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
|
|
3552
|
+
const cs = style(n);
|
|
3553
|
+
const r = box(n);
|
|
3554
|
+
const radius = px(cs.borderTopLeftRadius);
|
|
3555
|
+
const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
|
|
3556
|
+
if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
|
|
3557
|
+
}
|
|
3558
|
+
const avatars = inside.filter((n) => {
|
|
3559
|
+
if (n.tagName !== "IMG") return false;
|
|
3560
|
+
const r = box(n);
|
|
3561
|
+
const rad = style(n).borderTopLeftRadius;
|
|
3562
|
+
const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
|
|
3563
|
+
return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
|
|
3564
|
+
}).length;
|
|
3565
|
+
const carousel = inside.some((n) => {
|
|
3566
|
+
const cs = style(n);
|
|
3567
|
+
if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
|
|
3568
|
+
if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
|
|
3569
|
+
return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
|
|
3570
|
+
});
|
|
3571
|
+
return { badges, avatars, carousel };
|
|
3572
|
+
};
|
|
3183
3573
|
const kept = blocks.slice(0, 40);
|
|
3184
3574
|
const sections = kept.map((el, index) => {
|
|
3185
3575
|
const b = box(el);
|
|
3186
3576
|
const heading = el.querySelector("h1, h2, h3");
|
|
3187
3577
|
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3188
|
-
const
|
|
3578
|
+
const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
|
|
3189
3579
|
const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
|
|
3190
3580
|
const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
|
|
3191
3581
|
const { cards, iconCards } = cardsIn(el);
|
|
3192
3582
|
let kind = "text";
|
|
3193
3583
|
if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
|
|
3194
3584
|
else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
|
|
3195
|
-
else if (
|
|
3585
|
+
else if (images2.length >= 4 && textChars < 300) kind = "logos";
|
|
3196
3586
|
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
3197
3587
|
else if (cards >= 3) kind = "cards";
|
|
3198
3588
|
const geometry = geometryOf(el, b);
|
|
3589
|
+
const parts2 = partsOf(el);
|
|
3199
3590
|
return {
|
|
3200
3591
|
top: Math.round(b.top),
|
|
3201
3592
|
height: Math.round(b.height),
|
|
@@ -3205,7 +3596,9 @@ function collectInPage() {
|
|
|
3205
3596
|
iconCards,
|
|
3206
3597
|
hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
|
|
3207
3598
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3208
|
-
geometry
|
|
3599
|
+
geometry,
|
|
3600
|
+
...parts2,
|
|
3601
|
+
figures: numbers
|
|
3209
3602
|
};
|
|
3210
3603
|
});
|
|
3211
3604
|
if (header && sections.length === 0) {
|
|
@@ -3287,6 +3680,81 @@ function collectInPage() {
|
|
|
3287
3680
|
const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
|
|
3288
3681
|
if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
|
|
3289
3682
|
}
|
|
3683
|
+
const unwrap = (raw) => {
|
|
3684
|
+
if (raw.startsWith("data:")) return raw.slice(0, 64);
|
|
3685
|
+
try {
|
|
3686
|
+
const u = new URL(raw, location.href);
|
|
3687
|
+
const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
|
|
3688
|
+
if (inner) return new URL(inner, location.href).href;
|
|
3689
|
+
const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
|
|
3690
|
+
if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
|
|
3691
|
+
return u.href;
|
|
3692
|
+
} catch {
|
|
3693
|
+
return raw.slice(0, 200);
|
|
3694
|
+
}
|
|
3695
|
+
};
|
|
3696
|
+
const roundBox = (el, w) => {
|
|
3697
|
+
const rad = style(el).borderTopLeftRadius;
|
|
3698
|
+
return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
|
|
3699
|
+
};
|
|
3700
|
+
const sectionOf = (el) => {
|
|
3701
|
+
const i = kept.findIndex((k) => k.contains(el));
|
|
3702
|
+
return i === -1 ? null : i;
|
|
3703
|
+
};
|
|
3704
|
+
const roleOfImage = (el, src, w, h, alt) => {
|
|
3705
|
+
const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
|
|
3706
|
+
const square = Math.abs(w - h) <= w * 0.15;
|
|
3707
|
+
if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
|
|
3708
|
+
const section = sectionOf(el);
|
|
3709
|
+
const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
|
|
3710
|
+
if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
|
|
3711
|
+
if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
|
|
3712
|
+
if (vector) return "illustration";
|
|
3713
|
+
return w >= 160 && h >= 120 ? "photo" : "icon";
|
|
3714
|
+
};
|
|
3715
|
+
const images = [];
|
|
3716
|
+
const seenImage = /* @__PURE__ */ new Set();
|
|
3717
|
+
const addImage = (el, raw, alt, decorative, background) => {
|
|
3718
|
+
const b = box(el);
|
|
3719
|
+
if (!raw || b.width < 8 || b.height < 8) return;
|
|
3720
|
+
const src = unwrap(raw);
|
|
3721
|
+
const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
|
|
3722
|
+
if (seenImage.has(key)) return;
|
|
3723
|
+
seenImage.add(key);
|
|
3724
|
+
let host = null;
|
|
3725
|
+
try {
|
|
3726
|
+
host = src.startsWith("data:") ? null : new URL(src).hostname;
|
|
3727
|
+
} catch {
|
|
3728
|
+
host = null;
|
|
3729
|
+
}
|
|
3730
|
+
images.push({
|
|
3731
|
+
src,
|
|
3732
|
+
host,
|
|
3733
|
+
width: Math.round(b.width),
|
|
3734
|
+
height: Math.round(b.height),
|
|
3735
|
+
top: Math.round(b.top),
|
|
3736
|
+
role: roleOfImage(el, src, b.width, b.height, alt),
|
|
3737
|
+
alt,
|
|
3738
|
+
decorative,
|
|
3739
|
+
section: sectionOf(el),
|
|
3740
|
+
background
|
|
3741
|
+
});
|
|
3742
|
+
};
|
|
3743
|
+
for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
|
|
3744
|
+
if (!shown(img)) continue;
|
|
3745
|
+
const alt = img.getAttribute("alt");
|
|
3746
|
+
const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
|
|
3747
|
+
addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
|
|
3748
|
+
}
|
|
3749
|
+
for (const el of all) {
|
|
3750
|
+
const bgImage = style(el).backgroundImage || "";
|
|
3751
|
+
if (!bgImage.includes("url(")) continue;
|
|
3752
|
+
const b = box(el);
|
|
3753
|
+
if (b.width < 160 || b.height < 120 || !shown(el)) continue;
|
|
3754
|
+
const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
|
|
3755
|
+
if (m) addImage(el, m[1], null, true, true);
|
|
3756
|
+
}
|
|
3757
|
+
images.sort((a, b) => b.width * b.height - a.width * a.height);
|
|
3290
3758
|
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);
|
|
3291
3759
|
const nums = (s) => Array.from(s).sort((a, b) => a - b);
|
|
3292
3760
|
return {
|
|
@@ -3306,6 +3774,7 @@ function collectInPage() {
|
|
|
3306
3774
|
return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
|
|
3307
3775
|
})(),
|
|
3308
3776
|
effects,
|
|
3777
|
+
images: images.slice(0, 60),
|
|
3309
3778
|
motion: {
|
|
3310
3779
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
3311
3780
|
sections: sections.length,
|
|
@@ -3469,7 +3938,60 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
3469
3938
|
};
|
|
3470
3939
|
}
|
|
3471
3940
|
|
|
3941
|
+
// src/snapshot/provenance.ts
|
|
3942
|
+
var PROVENANCE_SCAN_BYTES = 256 * 1024;
|
|
3943
|
+
var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
|
|
3944
|
+
var MARKER2 = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
|
|
3945
|
+
var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
|
|
3946
|
+
var C2PA_PNG = /caBX/;
|
|
3947
|
+
function latin1(bytes) {
|
|
3948
|
+
let out = "";
|
|
3949
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
3950
|
+
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
3951
|
+
}
|
|
3952
|
+
return out;
|
|
3953
|
+
}
|
|
3954
|
+
function xmpPackets(text) {
|
|
3955
|
+
const out = [];
|
|
3956
|
+
const open = /<x:xmpmeta\b|<\?xpacket begin/g;
|
|
3957
|
+
let m;
|
|
3958
|
+
while (m = open.exec(text)) {
|
|
3959
|
+
const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
|
|
3960
|
+
out.push([m.index, close === -1 ? text.length : close]);
|
|
3961
|
+
if (close === -1) break;
|
|
3962
|
+
open.lastIndex = close;
|
|
3963
|
+
}
|
|
3964
|
+
return out;
|
|
3965
|
+
}
|
|
3966
|
+
function scanProvenance(bytes) {
|
|
3967
|
+
const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
|
|
3968
|
+
const text = latin1(head);
|
|
3969
|
+
const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
|
|
3970
|
+
const m = MARKER2.exec(text);
|
|
3971
|
+
if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
|
|
3972
|
+
const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
|
|
3973
|
+
return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3472
3976
|
// src/audit/index.ts
|
|
3977
|
+
var PROVENANCE_MAX_IMAGES = 24;
|
|
3978
|
+
var PROVENANCE_BUDGET_MS = 2e4;
|
|
3979
|
+
async function readProvenance(page, images, timeoutMs) {
|
|
3980
|
+
if (!page.request) return;
|
|
3981
|
+
const deadline = Date.now() + PROVENANCE_BUDGET_MS;
|
|
3982
|
+
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);
|
|
3983
|
+
for (const src of wanted) {
|
|
3984
|
+
const left = deadline - Date.now();
|
|
3985
|
+
if (left < 500) break;
|
|
3986
|
+
try {
|
|
3987
|
+
const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
|
|
3988
|
+
if (!res.ok()) continue;
|
|
3989
|
+
const provenance = scanProvenance(await res.body());
|
|
3990
|
+
for (const image of images) if (image.src === src) image.provenance = provenance;
|
|
3991
|
+
} catch {
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3473
3995
|
var VISUAL_MAX_HEIGHT = 6e3;
|
|
3474
3996
|
async function loadPlaywright() {
|
|
3475
3997
|
for (const name of ["playwright", "playwright-core"]) {
|
|
@@ -3488,6 +4010,7 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
3488
4010
|
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
3489
4011
|
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
3490
4012
|
const data = await page.evaluate(collectInPage);
|
|
4013
|
+
if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
|
|
3491
4014
|
let visual;
|
|
3492
4015
|
if (page.screenshot && options.visual !== false) {
|
|
3493
4016
|
try {
|
|
@@ -3551,6 +4074,8 @@ async function audit(url, options = {}) {
|
|
|
3551
4074
|
}
|
|
3552
4075
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3553
4076
|
0 && (module.exports = {
|
|
4077
|
+
PROVENANCE_BUDGET_MS,
|
|
4078
|
+
PROVENANCE_MAX_IMAGES,
|
|
3554
4079
|
VISUAL_MAX_HEIGHT,
|
|
3555
4080
|
audit,
|
|
3556
4081
|
collectInPage,
|