@domandigital/craft 0.10.0 → 0.12.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 +35 -0
- package/CHARACTER.md +9 -1
- package/COPY.md +6 -2
- package/README.md +40 -0
- package/dist/{audit-EGDNWSQB.js → audit-5QWFLZ6L.js} +6 -3
- package/dist/audit.cjs +279 -16
- package/dist/audit.d.cts +81 -4
- package/dist/audit.d.ts +81 -4
- package/dist/audit.js +278 -16
- package/dist/{chunk-3NGWXZZE.js → chunk-AL6MRG6V.js} +40 -3
- package/dist/{chunk-2NQFIDLE.js → chunk-CDAJUS5U.js} +13 -11
- package/dist/{chunk-JKIDWHG3.js → chunk-J3QUROJ5.js} +299 -32
- package/dist/chunk-JYNXGCYZ.js +75 -0
- package/dist/{chunk-APO4BT2F.js → chunk-LHGY43LL.js} +11 -7
- package/dist/chunk-PU5G5DUC.js +206 -0
- package/dist/{chunk-QMYERCBB.js → chunk-PZ4MF2VV.js} +19 -7
- package/dist/{chunk-JWHC67ZS.js → chunk-S3NZAVU6.js} +121 -1
- package/dist/{chunk-TSKRBIOC.js → chunk-ULPI335S.js} +181 -13
- package/dist/chunk-ZKVJ7K6X.js +19 -0
- package/dist/cli-2DY3V3SF.js +17 -0
- package/dist/{cli-PRWTNFVP.js → cli-C3JLFXMG.js} +5 -4
- package/dist/cli-QM2AQQMF.js +19 -0
- package/dist/{cli-ADQQURNR.js → cli-ZB2VJ7CY.js} +25 -17
- package/dist/{cli-2WBGTBKD.js → cli-ZPJIWVEP.js} +15 -80
- package/dist/cli.js +2 -2
- package/dist/index.cjs +486 -9
- package/dist/index.d.cts +364 -6
- package/dist/index.d.ts +364 -6
- package/dist/index.js +462 -9
- package/licences.json +104 -0
- package/package.json +15 -7
- package/word-lists.json +1 -1
- package/dist/chunk-U2JTFQKO.js +0 -112
- package/dist/cli-EK3GSAOW.js +0 -15
- package/dist/cli-VQUNNIZM.js +0 -16
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
snapshotUrl,
|
|
3
|
+
snapshotUrls
|
|
4
|
+
} from "./chunk-ULPI335S.js";
|
|
5
|
+
import {
|
|
6
|
+
describeTypicality,
|
|
7
|
+
loadNull,
|
|
8
|
+
typicality
|
|
9
|
+
} from "./chunk-LHGY43LL.js";
|
|
10
|
+
import {
|
|
11
|
+
readSnapshot
|
|
12
|
+
} from "./chunk-ZKVJ7K6X.js";
|
|
13
|
+
import {
|
|
14
|
+
SOURCE_FILE,
|
|
15
|
+
finish,
|
|
16
|
+
loadConfig,
|
|
17
|
+
loadExceptions,
|
|
18
|
+
parseFlags,
|
|
19
|
+
prepareReport,
|
|
20
|
+
readPaths,
|
|
21
|
+
toJson
|
|
22
|
+
} from "./chunk-J3QUROJ5.js";
|
|
23
|
+
import {
|
|
24
|
+
fingerprint
|
|
25
|
+
} from "./chunk-PZ4MF2VV.js";
|
|
26
|
+
import {
|
|
27
|
+
auditSnapshot,
|
|
28
|
+
scanSource
|
|
29
|
+
} from "./chunk-AL6MRG6V.js";
|
|
30
|
+
|
|
31
|
+
// src/audit/cli.ts
|
|
32
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
33
|
+
import { resolve } from "path";
|
|
34
|
+
|
|
35
|
+
// src/audit/pages.ts
|
|
36
|
+
var decode = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
37
|
+
function parseSitemap(xml) {
|
|
38
|
+
const locs = [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/gi)].map((m) => decode(m[1]));
|
|
39
|
+
return /<sitemapindex[\s>]/i.test(xml) ? { urls: [], sitemaps: locs } : { urls: locs, sitemaps: [] };
|
|
40
|
+
}
|
|
41
|
+
function parseUrlList(text) {
|
|
42
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
43
|
+
}
|
|
44
|
+
function parseViewports(value) {
|
|
45
|
+
const widths = value.split(",").map((w) => w.trim());
|
|
46
|
+
return widths.map((w) => {
|
|
47
|
+
const width = Number(w);
|
|
48
|
+
if (!Number.isInteger(width) || width < 240 || width > 3840) throw new Error(`--viewport: "${w}" is not a width in px between 240 and 3840`);
|
|
49
|
+
return { width, height: width < 600 ? 844 : width < 1024 ? 1024 : 900 };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/audit/cli.ts
|
|
54
|
+
var fmt = (o) => o ? `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` : "none";
|
|
55
|
+
function describeFingerprint(fp) {
|
|
56
|
+
return [
|
|
57
|
+
"Fingerprint",
|
|
58
|
+
` accent ${fmt(fp.accent)}`,
|
|
59
|
+
` ground ${fmt(fp.ground)}`,
|
|
60
|
+
` display ${fp.display.family} (${fp.display.class})`,
|
|
61
|
+
` body ${fp.body.family} (${fp.body.class})`,
|
|
62
|
+
` roundness ${fp.roundness === null ? "no buttons" : fp.roundness.toFixed(2)}`,
|
|
63
|
+
` motion ${Math.round(fp.motion * 100)}% of sections below the fold reveal on scroll`,
|
|
64
|
+
` effects ${fp.effects.join(", ") || "none"}`,
|
|
65
|
+
` layout ${fp.layout.join(" > ") || "none"}`
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
function merge(a, b) {
|
|
69
|
+
const byTell = { ...a.summary.byTell };
|
|
70
|
+
for (const [k, v] of Object.entries(b.summary.byTell)) byTell[k] = (byTell[k] ?? 0) + v;
|
|
71
|
+
return {
|
|
72
|
+
catalogueVersion: a.catalogueVersion,
|
|
73
|
+
findings: [...a.findings, ...b.findings],
|
|
74
|
+
rejectedExceptions: a.rejectedExceptions,
|
|
75
|
+
excepted: [...a.excepted, ...b.excepted],
|
|
76
|
+
suppressed: [...a.suppressed, ...b.suppressed],
|
|
77
|
+
summary: {
|
|
78
|
+
files: a.summary.files + b.summary.files,
|
|
79
|
+
findings: a.summary.findings + b.summary.findings,
|
|
80
|
+
byGeneration: { 1: a.summary.byGeneration[1] + b.summary.byGeneration[1], 2: a.summary.byGeneration[2] + b.summary.byGeneration[2], 3: a.summary.byGeneration[3] + b.summary.byGeneration[3] },
|
|
81
|
+
byTell,
|
|
82
|
+
blocking: a.summary.blocking + b.summary.blocking
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function readSnapshotFile(path) {
|
|
87
|
+
return readSnapshot(JSON.parse(readFileSync(path, "utf8")), path);
|
|
88
|
+
}
|
|
89
|
+
async function readText(source, cwd) {
|
|
90
|
+
if (/^https?:\/\//i.test(source)) {
|
|
91
|
+
const res = await fetch(source);
|
|
92
|
+
if (!res.ok) throw new Error(`${source}: HTTP ${res.status}`);
|
|
93
|
+
return res.text();
|
|
94
|
+
}
|
|
95
|
+
const path = resolve(cwd, source);
|
|
96
|
+
if (!existsSync(path)) throw new Error(`no such file: ${source}`);
|
|
97
|
+
return readFileSync(path, "utf8");
|
|
98
|
+
}
|
|
99
|
+
async function resolvePages(source, cwd) {
|
|
100
|
+
const text = await readText(source, cwd);
|
|
101
|
+
if (!/^\s*<(?:\?xml|urlset|sitemapindex)/i.test(text)) return parseUrlList(text);
|
|
102
|
+
const { urls, sitemaps } = parseSitemap(text);
|
|
103
|
+
const nested = await Promise.all(sitemaps.map(async (s) => parseSitemap(await readText(s, cwd)).urls));
|
|
104
|
+
const all = [...urls, ...nested.flat()];
|
|
105
|
+
if (all.length === 0) throw new Error(`${source}: no <loc> entries`);
|
|
106
|
+
return [...new Set(all)];
|
|
107
|
+
}
|
|
108
|
+
function viewportsOf(flags) {
|
|
109
|
+
if (flags.viewport) return parseViewports(flags.viewport);
|
|
110
|
+
if (flags.width || flags.height) return [{ width: Number(flags.width ?? 1440), height: Number(flags.height ?? 900) }];
|
|
111
|
+
return [void 0];
|
|
112
|
+
}
|
|
113
|
+
async function runAudit(command, args, io) {
|
|
114
|
+
try {
|
|
115
|
+
const flags = parseFlags(args);
|
|
116
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
117
|
+
const target = flags.positional[0];
|
|
118
|
+
if (command === "snapshot" && (flags.pages || flags.viewport)) throw new Error("craft snapshot takes one page; use --width and --height");
|
|
119
|
+
if (flags.pages && target) throw new Error("give a page or --pages, not both");
|
|
120
|
+
if (!target && !flags.pages) throw new Error(`usage: craft ${command} <url${command === "audit" ? " | snapshot.json> | --pages <sitemap.xml | urls.txt>" : ">"}`);
|
|
121
|
+
const viewports = viewportsOf(flags);
|
|
122
|
+
const audited = [];
|
|
123
|
+
const failed = [];
|
|
124
|
+
const local = target ? resolve(io.cwd, target) : "";
|
|
125
|
+
if (target && command === "audit" && /\.json$/i.test(target) && existsSync(local)) {
|
|
126
|
+
audited.push({ url: target, viewport: null, snapshot: readSnapshotFile(local) });
|
|
127
|
+
} else if (target && viewports.length === 1) {
|
|
128
|
+
const snapshot = await snapshotUrl(target, { viewport: viewports[0] });
|
|
129
|
+
audited.push({ url: target, viewport: viewports[0] ?? null, snapshot });
|
|
130
|
+
} else {
|
|
131
|
+
const urls = flags.pages ? await resolvePages(flags.pages, io.cwd) : [target];
|
|
132
|
+
for (const viewport of viewports) {
|
|
133
|
+
for (const r of await snapshotUrls(urls, { viewport })) {
|
|
134
|
+
if (r.snapshot) audited.push({ url: r.url, viewport: viewport ?? null, snapshot: r.snapshot });
|
|
135
|
+
else failed.push({ url: r.url, viewport: viewport ?? null, error: r.error ?? "no snapshot" });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (audited.length === 0) throw new Error(`no page could be measured:
|
|
140
|
+
${failed.map((f) => ` ${f.url}: ${f.error}`).join("\n")}`);
|
|
141
|
+
const single = audited.length === 1 && failed.length === 0;
|
|
142
|
+
if (flags.out) {
|
|
143
|
+
if (!single) throw new Error("--out saves one snapshot; drop --pages and --viewport, or snapshot each page");
|
|
144
|
+
writeFileSync(resolve(io.cwd, flags.out), `${JSON.stringify(audited[0].snapshot, null, 2)}
|
|
145
|
+
`);
|
|
146
|
+
}
|
|
147
|
+
if (command === "snapshot") {
|
|
148
|
+
const { snapshot } = audited[0];
|
|
149
|
+
if (!flags.out) io.out(JSON.stringify(snapshot, null, 2));
|
|
150
|
+
else io.out(`craft snapshot: ${snapshot.url} saved to ${flags.out}`);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
const root = flags.repo ? resolve(io.cwd, flags.repo) : io.cwd;
|
|
154
|
+
const exceptions = loadExceptions(flags, root);
|
|
155
|
+
const config = loadConfig(flags, root);
|
|
156
|
+
const label = (a) => viewports.length > 1 && a.viewport ? `${a.snapshot.url} @ ${a.viewport.width}px` : a.snapshot.url;
|
|
157
|
+
let report = audited.map((a) => {
|
|
158
|
+
const r = auditSnapshot(a.snapshot, { exceptions });
|
|
159
|
+
return { ...r, findings: r.findings.map((f) => ({ ...f, path: label(a) })) };
|
|
160
|
+
}).reduce((acc, r) => merge(acc, r));
|
|
161
|
+
if (flags.repo) {
|
|
162
|
+
report = merge(scanSource(readPaths(["."], root, SOURCE_FILE, void 0, config.ignore), { exceptions }), report);
|
|
163
|
+
}
|
|
164
|
+
const prepared = prepareReport(report, flags, io, config);
|
|
165
|
+
if (!prepared) return 0;
|
|
166
|
+
const nulls = flags.null ? flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd)) : [];
|
|
167
|
+
const pooled = nulls.flatMap((m, i) => m.runs.map((r) => nulls.length > 1 ? { ...r, id: `${i + 1}/${r.id}` } : r));
|
|
168
|
+
const pages = audited.map((a) => {
|
|
169
|
+
const fp = fingerprint(a.snapshot);
|
|
170
|
+
return { url: a.snapshot.url, viewport: a.viewport, fingerprint: fp, typicality: nulls.length ? typicality(fp, pooled) : null };
|
|
171
|
+
});
|
|
172
|
+
const unmeasured = failed.length > 0 && flags.strict ? 1 : 0;
|
|
173
|
+
if (flags.json) {
|
|
174
|
+
const extra = single ? { fingerprint: pages[0].fingerprint, typicality: pages[0].typicality, snapshot: flags.out ?? null } : {};
|
|
175
|
+
io.out(toJson({ report: prepared, ...extra, pages, failed }));
|
|
176
|
+
return Math.max(prepared.summary.blocking > 0 || flags.strict && prepared.summary.findings > 0 ? 1 : 0, unmeasured);
|
|
177
|
+
}
|
|
178
|
+
const title = single ? `craft audit ${audited[0].snapshot.url}` : `craft audit: ${audited.length} page${audited.length === 1 ? "" : "s"}`;
|
|
179
|
+
const code = finish(prepared, { ...flags, json: false }, title, io);
|
|
180
|
+
for (const page of pages) {
|
|
181
|
+
io.out("");
|
|
182
|
+
if (!single) io.out(`${page.url}${page.viewport ? ` @ ${page.viewport.width}px` : ""}`);
|
|
183
|
+
io.out(describeFingerprint(page.fingerprint));
|
|
184
|
+
if (page.typicality) {
|
|
185
|
+
io.out("");
|
|
186
|
+
io.out(describeTypicality(page.typicality, pooled.length, nulls.length));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (failed.length) {
|
|
190
|
+
io.out("");
|
|
191
|
+
io.out("Not measured (never counts as a pass):");
|
|
192
|
+
for (const f of failed) io.out(` ${f.url}${f.viewport ? ` @ ${f.viewport.width}px` : ""}: ${f.error}`);
|
|
193
|
+
}
|
|
194
|
+
return Math.max(code, unmeasured);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
io.err(`craft: ${error.message}`);
|
|
197
|
+
return 2;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export {
|
|
202
|
+
describeFingerprint,
|
|
203
|
+
merge,
|
|
204
|
+
resolvePages,
|
|
205
|
+
runAudit
|
|
206
|
+
};
|
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
fontClass,
|
|
4
4
|
normaliseFamily,
|
|
5
5
|
parseColour
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-AL6MRG6V.js";
|
|
7
7
|
|
|
8
8
|
// src/fingerprint/index.ts
|
|
9
|
-
var FINGERPRINT_VERSION =
|
|
9
|
+
var FINGERPRINT_VERSION = 2;
|
|
10
10
|
var CHROMATIC = 0.03;
|
|
11
11
|
var MIN_ACCENT_AREA = 2e4;
|
|
12
12
|
var oklch = (value) => {
|
|
@@ -61,23 +61,36 @@ function fingerprint(s) {
|
|
|
61
61
|
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
62
62
|
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
63
63
|
effects: effects.sort(),
|
|
64
|
-
layout: s.sections.map((x) => x.kind)
|
|
64
|
+
layout: s.sections.map((x) => x.kind),
|
|
65
|
+
...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 })) } : {}
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
var DISTANCE_WEIGHTS = { accent: 0.25, type: 0.25, ground: 0.1, shape: 0.1, motion: 0.1, effects: 0.1, layout: 0.1 };
|
|
68
|
-
function levenshtein(a, b) {
|
|
69
|
+
function levenshtein(a, b, cost = (x, y) => x === y ? 0 : 1) {
|
|
69
70
|
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
70
71
|
for (let i = 1; i <= a.length; i += 1) {
|
|
71
72
|
let prev = row[0];
|
|
72
73
|
row[0] = i;
|
|
73
74
|
for (let j = 1; j <= b.length; j += 1) {
|
|
74
75
|
const cur = row[j];
|
|
75
|
-
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1]
|
|
76
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + cost(a[i - 1], b[j - 1]));
|
|
76
77
|
prev = cur;
|
|
77
78
|
}
|
|
78
79
|
}
|
|
79
80
|
return row[b.length];
|
|
80
81
|
}
|
|
82
|
+
function sectionCost(x, y) {
|
|
83
|
+
if (x.role !== y.role) return 1;
|
|
84
|
+
return Math.min(0.5, (Math.abs(x.centred - y.centred) + Math.abs(x.width - y.width)) / 2);
|
|
85
|
+
}
|
|
86
|
+
function layoutDistance(a, b) {
|
|
87
|
+
if (a.sections && b.sections) {
|
|
88
|
+
const longest2 = Math.max(a.sections.length, b.sections.length);
|
|
89
|
+
return longest2 === 0 ? 0 : levenshtein(a.sections, b.sections, sectionCost) / longest2;
|
|
90
|
+
}
|
|
91
|
+
const longest = Math.max(a.layout.length, b.layout.length);
|
|
92
|
+
return longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
|
|
93
|
+
}
|
|
81
94
|
var clamp = (n) => Math.max(0, Math.min(1, n));
|
|
82
95
|
function faceDistance(a, b) {
|
|
83
96
|
if (a.family.toLowerCase() === b.family.toLowerCase()) return 0;
|
|
@@ -92,8 +105,7 @@ function fingerprintDistance(a, b) {
|
|
|
92
105
|
const union = /* @__PURE__ */ new Set([...a.effects, ...b.effects]);
|
|
93
106
|
const shared = a.effects.filter((e) => b.effects.includes(e)).length;
|
|
94
107
|
const effects = union.size === 0 ? 0 : 1 - shared / union.size;
|
|
95
|
-
const
|
|
96
|
-
const layout = longest === 0 ? 0 : levenshtein(a.layout, b.layout) / longest;
|
|
108
|
+
const layout = layoutDistance(a, b);
|
|
97
109
|
const parts = { accent, ground, type, shape, motion, effects, layout };
|
|
98
110
|
const total = Object.keys(DISTANCE_WEIGHTS).reduce((sum, k) => sum + DISTANCE_WEIGHTS[k] * parts[k], 0);
|
|
99
111
|
return { total: Math.round(total * 1e3) / 1e3, parts };
|
|
@@ -6,7 +6,115 @@ import {
|
|
|
6
6
|
isCream,
|
|
7
7
|
normaliseFamily,
|
|
8
8
|
parseColour
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-AL6MRG6V.js";
|
|
10
|
+
|
|
11
|
+
// src/direction/licences.ts
|
|
12
|
+
var LICENCES = [
|
|
13
|
+
{
|
|
14
|
+
name: "Basteleur",
|
|
15
|
+
subject: "face",
|
|
16
|
+
licence: "OFL-1.1",
|
|
17
|
+
multiClient: "yes",
|
|
18
|
+
checked: "2026-09-24",
|
|
19
|
+
source: "https://velvetyne.fr/fonts/basteleur/",
|
|
20
|
+
note: "Velvetyne, designed by Keussel. Keep the OFL notice with the font files."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "Gulax",
|
|
24
|
+
subject: "face",
|
|
25
|
+
licence: "OFL-1.1",
|
|
26
|
+
multiClient: "yes",
|
|
27
|
+
checked: "2026-09-24",
|
|
28
|
+
source: "https://velvetyne.fr/fonts/gulax/",
|
|
29
|
+
note: "Velvetyne. Keep the OFL notice with the font files."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "Le Murmure",
|
|
33
|
+
subject: "face",
|
|
34
|
+
licence: "OFL-1.1",
|
|
35
|
+
multiClient: "yes",
|
|
36
|
+
checked: "2026-09-24",
|
|
37
|
+
source: "https://velvetyne.fr/fonts/le-murmure/",
|
|
38
|
+
note: "Velvetyne. Keep the OFL notice with the font files."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "Blaze Type",
|
|
42
|
+
subject: "foundry",
|
|
43
|
+
licence: "commercial",
|
|
44
|
+
multiClient: "per-site",
|
|
45
|
+
cap: 10,
|
|
46
|
+
checked: "2026-09-24",
|
|
47
|
+
source: "https://blazetype.eu/eula",
|
|
48
|
+
note: "The EULA sells a professional licence for 1 to 10 websites, and lets an agency buy in a client's name at the listed price. Its /license page says one licence covers every domain; the EULA is the contract, so buy per client."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "Power Type",
|
|
52
|
+
subject: "foundry",
|
|
53
|
+
licence: "commercial",
|
|
54
|
+
multiClient: "unverified",
|
|
55
|
+
checked: "2026-09-24",
|
|
56
|
+
source: "https://power-type.com/eula",
|
|
57
|
+
note: "Research on 2026-09-11 reported a Super License capped at 10 self-hosted domains. The EULA page did not load when checked; read it before relying on the cap."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "Grilli Type",
|
|
61
|
+
subject: "foundry",
|
|
62
|
+
licence: "commercial",
|
|
63
|
+
multiClient: "per-site",
|
|
64
|
+
checked: "2026-09-24",
|
|
65
|
+
source: "https://www.grillitype.com/information",
|
|
66
|
+
note: "Web licences cover any number of domains controlled by the licensee, priced by monthly visitors, so a client site needs the client's own licence. Unlimited tiers start at $10,000 per style."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "Phosphor",
|
|
70
|
+
subject: "icons",
|
|
71
|
+
licence: "MIT",
|
|
72
|
+
multiClient: "yes",
|
|
73
|
+
checked: "2026-09-24",
|
|
74
|
+
source: "https://github.com/phosphor-icons/core/blob/main/LICENSE",
|
|
75
|
+
note: "Six weights. The icon-tile tells treat it exactly as they treat Lucide: the library is not the tell, the tinted tile is."
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "Tabler Icons",
|
|
79
|
+
subject: "icons",
|
|
80
|
+
licence: "MIT",
|
|
81
|
+
multiClient: "yes",
|
|
82
|
+
checked: "2026-09-24",
|
|
83
|
+
source: "https://github.com/tabler/tabler-icons/blob/main/LICENSE",
|
|
84
|
+
note: "One stroke grid across the set."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "Iconoir",
|
|
88
|
+
subject: "icons",
|
|
89
|
+
licence: "MIT",
|
|
90
|
+
multiClient: "yes",
|
|
91
|
+
checked: "2026-09-24",
|
|
92
|
+
source: "https://github.com/iconoir-icons/iconoir/blob/main/LICENSE",
|
|
93
|
+
note: "Smaller set, no paid tier."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "Unicons",
|
|
97
|
+
subject: "icons",
|
|
98
|
+
licence: "IconScout Simple License",
|
|
99
|
+
multiClient: "yes",
|
|
100
|
+
checked: "2026-09-24",
|
|
101
|
+
source: "https://github.com/Iconscout/unicons/blob/master/LICENSE",
|
|
102
|
+
note: "IconScout's free set, personal and commercial use, attribution optional."
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "IconScout premium",
|
|
106
|
+
subject: "icons",
|
|
107
|
+
licence: "commercial",
|
|
108
|
+
multiClient: "per-site",
|
|
109
|
+
checked: "2026-09-24",
|
|
110
|
+
source: "https://iconscout.com/licenses",
|
|
111
|
+
note: "Each licence covers a single end product. Never in a tool, template or shared kit (create-site, craft, a house component library), even modified. Record the assets each site uses. Assets come from different contributors, so take one site's set from one contributor."
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
function faceLicence(family) {
|
|
115
|
+
const name = family.trim().toLowerCase();
|
|
116
|
+
return LICENCES.find((l) => l.subject === "face" && l.name.toLowerCase() === name);
|
|
117
|
+
}
|
|
10
118
|
|
|
11
119
|
// src/direction/types.ts
|
|
12
120
|
var DIRECTION_VERSION = 1;
|
|
@@ -150,6 +258,18 @@ function validateDirection(input, ctx = {}) {
|
|
|
150
258
|
ok = false;
|
|
151
259
|
}
|
|
152
260
|
}
|
|
261
|
+
if ((key === "display" || key === "body") && typeof c.value === "string" && c.value.trim()) {
|
|
262
|
+
const family = normaliseFamily(c.value);
|
|
263
|
+
const entry = faceLicence(family);
|
|
264
|
+
if (!entry) {
|
|
265
|
+
warn(`${at}.value`, `${family}: licence not in craft's register. Read the licensor's own terms for use on this client's site, then add it to src/direction/licences.ts.`);
|
|
266
|
+
} else if (entry.multiClient === "unverified") {
|
|
267
|
+
warn(`${at}.value`, `${family}: licence unverified (${entry.note})`);
|
|
268
|
+
} else if (entry.multiClient === "capped" || entry.multiClient === "per-site") {
|
|
269
|
+
const scope = entry.multiClient === "capped" ? `covers up to ${entry.cap} sites` : "needs a licence for this site, in the client's name";
|
|
270
|
+
warn(`${at}.value`, `${family}: ${entry.licence} licence ${scope}. Record the purchase before launch.`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
153
273
|
const fp = ctx.fingerprint;
|
|
154
274
|
if (fp && typeof c.value === "string") {
|
|
155
275
|
if ((key === "accent" || key === "ground") && parseColour(c.value)) {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePng
|
|
3
|
+
} from "./chunk-JYNXGCYZ.js";
|
|
1
4
|
import {
|
|
2
5
|
fingerprint
|
|
3
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-PZ4MF2VV.js";
|
|
4
7
|
import {
|
|
5
8
|
SNAPSHOT_VERSION,
|
|
6
9
|
auditSnapshot
|
|
7
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-AL6MRG6V.js";
|
|
8
11
|
|
|
9
12
|
// src/audit/collect.ts
|
|
10
13
|
function collectInPage() {
|
|
@@ -103,18 +106,26 @@ function collectInPage() {
|
|
|
103
106
|
top: Math.round(box(h).top)
|
|
104
107
|
};
|
|
105
108
|
});
|
|
109
|
+
const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
|
|
110
|
+
const isControl = (el) => {
|
|
111
|
+
const isButton = isButtonEl(el);
|
|
112
|
+
if (!isButton && el.tagName !== "A") return false;
|
|
113
|
+
if (!shown(el)) return false;
|
|
114
|
+
const cs = style(el);
|
|
115
|
+
const b = box(el);
|
|
116
|
+
const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
|
|
117
|
+
if (!label || b.height < 24 || b.height > 90) return false;
|
|
118
|
+
if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
|
|
119
|
+
if (!isButton && px(cs.paddingLeft) < 8) return false;
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
106
122
|
const controls = [];
|
|
107
123
|
for (const el of all) {
|
|
108
|
-
|
|
109
|
-
const isButton =
|
|
110
|
-
if (!isButton && tag !== "A") continue;
|
|
111
|
-
if (!shown(el)) continue;
|
|
124
|
+
if (!isControl(el)) continue;
|
|
125
|
+
const isButton = isButtonEl(el);
|
|
112
126
|
const cs = style(el);
|
|
113
127
|
const b = box(el);
|
|
114
128
|
const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
|
|
115
|
-
if (!label || b.height < 24 || b.height > 90) continue;
|
|
116
|
-
if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
|
|
117
|
-
if (!isButton && px(cs.paddingLeft) < 8) continue;
|
|
118
129
|
const r = cs.borderTopLeftRadius;
|
|
119
130
|
const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
|
|
120
131
|
controls.push({
|
|
@@ -194,7 +205,80 @@ function collectInPage() {
|
|
|
194
205
|
}
|
|
195
206
|
return best;
|
|
196
207
|
};
|
|
197
|
-
const
|
|
208
|
+
const backgroundOf = (el) => {
|
|
209
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
210
|
+
const c = style(n).backgroundColor;
|
|
211
|
+
if (painted(c)) return c;
|
|
212
|
+
}
|
|
213
|
+
return ground;
|
|
214
|
+
};
|
|
215
|
+
const geometryOf = (el, b) => {
|
|
216
|
+
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter((n) => (ownText(n).length > 1 || /^(IMG|SVG|VIDEO|PICTURE|BUTTON|INPUT|TEXTAREA|SELECT)$/i.test(n.tagName)) && shown(n)).slice(0, 400);
|
|
217
|
+
const centreX = b.left + b.width / 2;
|
|
218
|
+
let left = 0;
|
|
219
|
+
let right = 0;
|
|
220
|
+
let covered = 0;
|
|
221
|
+
let minX = Infinity;
|
|
222
|
+
let maxX = -Infinity;
|
|
223
|
+
let textBlocks = 0;
|
|
224
|
+
let centred = 0;
|
|
225
|
+
for (const n of leaves) {
|
|
226
|
+
const r = box(n);
|
|
227
|
+
const area = r.width * r.height;
|
|
228
|
+
covered += area;
|
|
229
|
+
minX = Math.min(minX, r.left);
|
|
230
|
+
maxX = Math.max(maxX, r.left + r.width);
|
|
231
|
+
const split = Math.max(0, Math.min(r.width, centreX - r.left));
|
|
232
|
+
left += split * r.height;
|
|
233
|
+
right += (r.width - split) * r.height;
|
|
234
|
+
if (ownText(n).length > 1) {
|
|
235
|
+
textBlocks += 1;
|
|
236
|
+
const mid = r.left + r.width / 2;
|
|
237
|
+
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
241
|
+
return {
|
|
242
|
+
centredShare: textBlocks ? round(centred / textBlocks) : 0,
|
|
243
|
+
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
244
|
+
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
245
|
+
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
246
|
+
background: backgroundOf(el),
|
|
247
|
+
controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
const CURRENCY = /[£$€]\s?\d/;
|
|
251
|
+
const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
|
|
252
|
+
if (kind === "marquee" || kind === "hero") return kind;
|
|
253
|
+
const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
|
|
254
|
+
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
|
|
255
|
+
const own = leaves.map(ownText);
|
|
256
|
+
const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
|
|
257
|
+
if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
|
|
258
|
+
const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
|
|
259
|
+
if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
|
|
260
|
+
const quotes = el.querySelectorAll("blockquote, q").length;
|
|
261
|
+
if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
|
|
262
|
+
const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
|
|
263
|
+
const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
|
|
264
|
+
if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
|
|
265
|
+
const form = el.querySelector("form");
|
|
266
|
+
const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
|
|
267
|
+
if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
|
|
268
|
+
const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
|
|
269
|
+
const r = box(i);
|
|
270
|
+
return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
|
|
271
|
+
}).length;
|
|
272
|
+
if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
|
|
273
|
+
if (kind === "stats" || kind === "logos") return kind;
|
|
274
|
+
const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
|
|
275
|
+
const setApart = geo.background !== ground || geo.centredShare >= 0.6;
|
|
276
|
+
if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
|
|
277
|
+
if (cards >= 3 && iconCards >= 2) return "features";
|
|
278
|
+
return kind;
|
|
279
|
+
};
|
|
280
|
+
const kept = blocks.slice(0, 40);
|
|
281
|
+
const sections = kept.map((el, index) => {
|
|
198
282
|
const b = box(el);
|
|
199
283
|
const heading = el.querySelector("h1, h2, h3");
|
|
200
284
|
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
@@ -208,6 +292,7 @@ function collectInPage() {
|
|
|
208
292
|
else if (images.length >= 4 && textChars < 300) kind = "logos";
|
|
209
293
|
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
210
294
|
else if (cards >= 3) kind = "cards";
|
|
295
|
+
const geometry = geometryOf(el, b);
|
|
211
296
|
return {
|
|
212
297
|
top: Math.round(b.top),
|
|
213
298
|
height: Math.round(b.height),
|
|
@@ -215,7 +300,9 @@ function collectInPage() {
|
|
|
215
300
|
label,
|
|
216
301
|
cards,
|
|
217
302
|
iconCards,
|
|
218
|
-
hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
|
|
303
|
+
hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
|
|
304
|
+
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
305
|
+
geometry
|
|
219
306
|
};
|
|
220
307
|
});
|
|
221
308
|
if (header && sections.length === 0) {
|
|
@@ -308,6 +395,13 @@ function collectInPage() {
|
|
|
308
395
|
headings,
|
|
309
396
|
controls,
|
|
310
397
|
sections,
|
|
398
|
+
rhythmVariance: (() => {
|
|
399
|
+
const hs = sections.map((x) => x.height);
|
|
400
|
+
if (hs.length < 2) return null;
|
|
401
|
+
const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
|
|
402
|
+
const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
|
|
403
|
+
return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
|
|
404
|
+
})(),
|
|
311
405
|
effects,
|
|
312
406
|
motion: {
|
|
313
407
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
@@ -341,7 +435,67 @@ function overlayInPage() {
|
|
|
341
435
|
return false;
|
|
342
436
|
}
|
|
343
437
|
|
|
438
|
+
// src/snapshot/visual.ts
|
|
439
|
+
var EDGE_STEP = 32;
|
|
440
|
+
function visualMeasures(img, maxSamples = 1e6) {
|
|
441
|
+
const { width: w, height: h, rgba } = img;
|
|
442
|
+
if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
|
|
443
|
+
const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
|
|
444
|
+
const lum = (x, y) => {
|
|
445
|
+
const i = (y * w + x) * 4;
|
|
446
|
+
return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
|
447
|
+
};
|
|
448
|
+
let n = 0;
|
|
449
|
+
let sumRg = 0;
|
|
450
|
+
let sumYb = 0;
|
|
451
|
+
let sumRg2 = 0;
|
|
452
|
+
let sumYb2 = 0;
|
|
453
|
+
let edges = 0;
|
|
454
|
+
let edgeN = 0;
|
|
455
|
+
let asym = 0;
|
|
456
|
+
let asymN = 0;
|
|
457
|
+
for (let y = 0; y < h; y += step) {
|
|
458
|
+
for (let x = 0; x < w; x += step) {
|
|
459
|
+
const i = (y * w + x) * 4;
|
|
460
|
+
const r = rgba[i];
|
|
461
|
+
const g = rgba[i + 1];
|
|
462
|
+
const b = rgba[i + 2];
|
|
463
|
+
const rg = r - g;
|
|
464
|
+
const yb = 0.5 * (r + g) - b;
|
|
465
|
+
sumRg += rg;
|
|
466
|
+
sumYb += yb;
|
|
467
|
+
sumRg2 += rg * rg;
|
|
468
|
+
sumYb2 += yb * yb;
|
|
469
|
+
n += 1;
|
|
470
|
+
const l = lum(x, y);
|
|
471
|
+
if (x + step < w && y + step < h) {
|
|
472
|
+
if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
|
|
473
|
+
edgeN += 1;
|
|
474
|
+
}
|
|
475
|
+
if (x < w / 2) {
|
|
476
|
+
asym += Math.abs(l - lum(w - 1 - x, y));
|
|
477
|
+
asymN += 1;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const meanRg = sumRg / n;
|
|
482
|
+
const meanYb = sumYb / n;
|
|
483
|
+
const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
|
|
484
|
+
const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
|
|
485
|
+
const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
|
|
486
|
+
const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
|
|
487
|
+
return {
|
|
488
|
+
colourfulness: round(colourfulness, 1),
|
|
489
|
+
edgeDensity: round(edgeN ? edges / edgeN : 0),
|
|
490
|
+
symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
|
|
491
|
+
width: w,
|
|
492
|
+
height: h,
|
|
493
|
+
sampled: n
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
344
497
|
// src/audit/index.ts
|
|
498
|
+
var VISUAL_MAX_HEIGHT = 6e3;
|
|
345
499
|
async function loadPlaywright() {
|
|
346
500
|
for (const name of ["playwright", "playwright-core"]) {
|
|
347
501
|
try {
|
|
@@ -359,12 +513,23 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
359
513
|
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
360
514
|
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
361
515
|
const data = await page.evaluate(collectInPage);
|
|
516
|
+
let visual;
|
|
517
|
+
if (page.screenshot && options.visual !== false) {
|
|
518
|
+
try {
|
|
519
|
+
const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
|
|
520
|
+
const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
|
|
521
|
+
visual = visualMeasures(decodePng(png));
|
|
522
|
+
} catch {
|
|
523
|
+
visual = void 0;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
362
526
|
return {
|
|
363
527
|
version: SNAPSHOT_VERSION,
|
|
364
528
|
url,
|
|
365
529
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
366
530
|
...data,
|
|
367
|
-
motion: { ...data.motion, introOverlay: early && !late }
|
|
531
|
+
motion: { ...data.motion, introOverlay: early && !late },
|
|
532
|
+
...visual ? { visual } : {}
|
|
368
533
|
};
|
|
369
534
|
}
|
|
370
535
|
async function launch(pw, url, options) {
|
|
@@ -390,7 +555,9 @@ async function snapshotUrls(urls, options = {}) {
|
|
|
390
555
|
for (const url of urls) {
|
|
391
556
|
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
|
|
392
557
|
try {
|
|
393
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
|
|
558
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
|
|
559
|
+
const status = typeof response?.status === "function" ? response.status() : 200;
|
|
560
|
+
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
394
561
|
out.push({ url, snapshot: await snapshotPage(page, url, options) });
|
|
395
562
|
} catch (error) {
|
|
396
563
|
out.push({ url, error: error.message });
|
|
@@ -411,6 +578,7 @@ async function audit(url, options = {}) {
|
|
|
411
578
|
export {
|
|
412
579
|
collectInPage,
|
|
413
580
|
overlayInPage,
|
|
581
|
+
VISUAL_MAX_HEIGHT,
|
|
414
582
|
loadPlaywright,
|
|
415
583
|
snapshotPage,
|
|
416
584
|
snapshotUrl,
|