@domandigital/craft 0.12.0 → 0.14.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 +26 -0
- package/CHARACTER.md +140 -1
- package/COPY.md +5 -0
- package/dist/{audit-5QWFLZ6L.js → audit-6WHR7ZTV.js} +7 -3
- package/dist/audit.cjs +1267 -165
- package/dist/audit.d.cts +95 -1
- package/dist/audit.d.ts +95 -1
- package/dist/audit.js +1265 -165
- package/dist/{chunk-J3QUROJ5.js → chunk-3OTKMKJQ.js} +66 -91
- package/dist/{chunk-PU5G5DUC.js → chunk-7T5WR74C.js} +27 -14
- package/dist/{chunk-AL6MRG6V.js → chunk-GQVEYTBY.js} +3592 -2173
- package/dist/{chunk-S3NZAVU6.js → chunk-OOZOEWGD.js} +1 -1
- package/dist/{chunk-CDAJUS5U.js → chunk-PBK6SYH5.js} +51 -16
- package/dist/{chunk-ULPI335S.js → chunk-Q7ILL2HV.js} +211 -10
- package/dist/chunk-QYBAVU3P.js +6 -0
- package/dist/{chunk-ZKVJ7K6X.js → chunk-WUACK2HX.js} +2 -1
- package/dist/chunk-XVBR2UPH.js +217 -0
- package/dist/cli-3CGUKXY4.js +19 -0
- package/dist/{cli-ZPJIWVEP.js → cli-O3CDSONO.js} +7 -8
- package/dist/{cli-ZB2VJ7CY.js → cli-WG3UJNUF.js} +15 -16
- package/dist/cli-WSHPSWVU.js +21 -0
- package/dist/{cli-C3JLFXMG.js → cli-Z2IQG3LC.js} +5 -5
- package/dist/cli.js +3 -2
- package/dist/index.cjs +1719 -636
- package/dist/index.d.cts +570 -196
- package/dist/index.d.ts +570 -196
- package/dist/index.js +1684 -636
- package/dist/migrate-VIFAYIBM.js +9 -0
- 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
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
loadNull,
|
|
5
|
-
shapeName
|
|
6
|
-
} from "./chunk-LHGY43LL.js";
|
|
7
|
-
import {
|
|
8
|
-
readSnapshot
|
|
9
|
-
} from "./chunk-ZKVJ7K6X.js";
|
|
2
|
+
loadNull
|
|
3
|
+
} from "./chunk-XVBR2UPH.js";
|
|
10
4
|
import {
|
|
11
5
|
parseFlags,
|
|
12
6
|
toJson
|
|
13
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-3OTKMKJQ.js";
|
|
14
8
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "./chunk-PZ4MF2VV.js";
|
|
9
|
+
readSnapshot
|
|
10
|
+
} from "./chunk-WUACK2HX.js";
|
|
18
11
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
COMPONENT_ROLES,
|
|
13
|
+
auditSnapshot,
|
|
14
|
+
closestComponents,
|
|
15
|
+
estateComponentPairs,
|
|
16
|
+
fingerprint,
|
|
17
|
+
fingerprintDistance,
|
|
18
|
+
groundName,
|
|
19
|
+
hueName,
|
|
20
|
+
shapeName
|
|
21
|
+
} from "./chunk-GQVEYTBY.js";
|
|
21
22
|
|
|
22
23
|
// src/estate/cli.ts
|
|
23
24
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -80,7 +81,7 @@ function siblingLine(briefs) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
// src/estate/cli.ts
|
|
83
|
-
var USAGE = "usage: craft estate add <url | snapshot.json> --id <id> [--client <name>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]";
|
|
84
|
+
var USAGE = "usage: craft estate add <url | snapshot.json> --id <id> [--client <name>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--component <role>] [--json] [--strict]";
|
|
84
85
|
function loadEstate(path) {
|
|
85
86
|
if (!existsSync(path)) return emptyEstate();
|
|
86
87
|
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -94,7 +95,7 @@ async function snapshotOf(target, cwd) {
|
|
|
94
95
|
return snap;
|
|
95
96
|
}
|
|
96
97
|
if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
|
|
97
|
-
const { snapshotUrl } = await import("./audit-
|
|
98
|
+
const { snapshotUrl } = await import("./audit-6WHR7ZTV.js");
|
|
98
99
|
return snapshotUrl(target);
|
|
99
100
|
}
|
|
100
101
|
var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
|
|
@@ -110,6 +111,21 @@ function formatPairs(pairs, at, sites) {
|
|
|
110
111
|
for (const p of pairs) lines.push(` ${`${p.a} + ${p.b}`.padEnd(34)} ${line(p.distance, p.sibling, p.shared)}`);
|
|
111
112
|
return lines.join("\n");
|
|
112
113
|
}
|
|
114
|
+
function componentRole(name) {
|
|
115
|
+
if (!COMPONENT_ROLES.includes(name)) throw new Error(`--component takes one of ${COMPONENT_ROLES.join(", ")}`);
|
|
116
|
+
return name;
|
|
117
|
+
}
|
|
118
|
+
var withRole = (register, role) => register.sites.filter((s) => (s.fingerprint.sections ?? []).some((x) => x.role === role)).length;
|
|
119
|
+
function formatComponentPairs(role, pairs, measured, sites) {
|
|
120
|
+
const lines = [`craft estate: ${role} components, ${measured} of ${sites} sites have one, closest pairs first (no sibling line yet: ranked, not judged)`];
|
|
121
|
+
for (const p of pairs) lines.push(` ${`${p.a} + ${p.b}`.padEnd(34)} ${p.distance.toFixed(2)}${p.shared.length ? ` shares ${p.shared.join(", ")}` : ""}`);
|
|
122
|
+
if (pairs.length === 0) {
|
|
123
|
+
lines.push(
|
|
124
|
+
measured < 2 ? ` fewer than two sites have a ${role} section measured. Roles come from snapshot version 2: re-add the sites from a fresh snapshot.` : " no pairs"
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
113
129
|
async function runEstate(args, io) {
|
|
114
130
|
try {
|
|
115
131
|
const flags = parseFlags(args);
|
|
@@ -136,6 +152,24 @@ async function runEstate(args, io) {
|
|
|
136
152
|
io.out(formatMatches(flags.id, compareToEstate(fp, next, { exclude: flags.id, siblingAt: at }).slice(0, 3), at));
|
|
137
153
|
return 0;
|
|
138
154
|
}
|
|
155
|
+
if (sub === "compare" && flags.component) {
|
|
156
|
+
const role = componentRole(flags.component);
|
|
157
|
+
if (!target) {
|
|
158
|
+
const pairs2 = estateComponentPairs(register, role);
|
|
159
|
+
const measured = withRole(register, role);
|
|
160
|
+
io.out(flags.json ? toJson({ role, measured, sites: register.sites.length, pairs: pairs2 }) : formatComponentPairs(role, pairs2, measured, register.sites.length));
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
const known = register.sites.find((s) => s.id === target);
|
|
164
|
+
const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
|
|
165
|
+
if (!(fp.sections ?? []).some((x) => x.role === role)) throw new Error(`${known?.id ?? target} has no ${role} section measured (roles need snapshot version 2)`);
|
|
166
|
+
const pairs = register.sites.filter((s) => s.id !== known?.id).flatMap((s) => {
|
|
167
|
+
const c = closestComponents(fp, s.fingerprint, role);
|
|
168
|
+
return c ? [{ a: known?.id ?? target, b: s.id, ...c }] : [];
|
|
169
|
+
}).sort((x, y) => x.distance - y.distance);
|
|
170
|
+
io.out(flags.json ? toJson({ role, measured: withRole(register, role), sites: register.sites.length, pairs }) : formatComponentPairs(role, pairs, withRole(register, role), register.sites.length));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
139
173
|
if (sub === "compare") {
|
|
140
174
|
if (!target) {
|
|
141
175
|
const pairs = estatePairs(register, at);
|
|
@@ -161,5 +195,6 @@ export {
|
|
|
161
195
|
loadEstate,
|
|
162
196
|
formatMatches,
|
|
163
197
|
formatPairs,
|
|
198
|
+
formatComponentPairs,
|
|
164
199
|
runEstate
|
|
165
200
|
};
|
|
@@ -2,12 +2,12 @@ import {
|
|
|
2
2
|
decodePng
|
|
3
3
|
} from "./chunk-JYNXGCYZ.js";
|
|
4
4
|
import {
|
|
5
|
+
auditSnapshot,
|
|
5
6
|
fingerprint
|
|
6
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-GQVEYTBY.js";
|
|
7
8
|
import {
|
|
8
|
-
SNAPSHOT_VERSION
|
|
9
|
-
|
|
10
|
-
} from "./chunk-AL6MRG6V.js";
|
|
9
|
+
SNAPSHOT_VERSION
|
|
10
|
+
} from "./chunk-QYBAVU3P.js";
|
|
11
11
|
|
|
12
12
|
// src/audit/collect.ts
|
|
13
13
|
function collectInPage() {
|
|
@@ -220,7 +220,7 @@ function collectInPage() {
|
|
|
220
220
|
let covered = 0;
|
|
221
221
|
let minX = Infinity;
|
|
222
222
|
let maxX = -Infinity;
|
|
223
|
-
let
|
|
223
|
+
let textBlocks2 = 0;
|
|
224
224
|
let centred = 0;
|
|
225
225
|
for (const n of leaves) {
|
|
226
226
|
const r = box(n);
|
|
@@ -232,14 +232,14 @@ function collectInPage() {
|
|
|
232
232
|
left += split * r.height;
|
|
233
233
|
right += (r.width - split) * r.height;
|
|
234
234
|
if (ownText(n).length > 1) {
|
|
235
|
-
|
|
235
|
+
textBlocks2 += 1;
|
|
236
236
|
const mid = r.left + r.width / 2;
|
|
237
237
|
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
241
241
|
return {
|
|
242
|
-
centredShare:
|
|
242
|
+
centredShare: textBlocks2 ? round(centred / textBlocks2) : 0,
|
|
243
243
|
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
244
244
|
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
245
245
|
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
@@ -277,22 +277,87 @@ function collectInPage() {
|
|
|
277
277
|
if (cards >= 3 && iconCards >= 2) return "features";
|
|
278
278
|
return kind;
|
|
279
279
|
};
|
|
280
|
+
const partsOf = (el) => {
|
|
281
|
+
const inside = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter(shown);
|
|
282
|
+
const badges = [];
|
|
283
|
+
for (const n of inside) {
|
|
284
|
+
const t = ownText(n);
|
|
285
|
+
if (t.length < 2 || t.length > 24 || badges.length >= 5) continue;
|
|
286
|
+
const cs = style(n);
|
|
287
|
+
const r = box(n);
|
|
288
|
+
const radius = px(cs.borderTopLeftRadius);
|
|
289
|
+
const drawn = painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1;
|
|
290
|
+
if (drawn && r.height <= 40 && r.width <= 240 && radius >= r.height / 3 && !isControl(n)) badges.push(t);
|
|
291
|
+
}
|
|
292
|
+
const avatars = inside.filter((n) => {
|
|
293
|
+
if (n.tagName !== "IMG") return false;
|
|
294
|
+
const r = box(n);
|
|
295
|
+
const rad = style(n).borderTopLeftRadius;
|
|
296
|
+
const round = rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= r.width * 0.4;
|
|
297
|
+
return r.width <= 96 && r.width >= 16 && Math.abs(r.width - r.height) <= r.width * 0.15 && round;
|
|
298
|
+
}).length;
|
|
299
|
+
const carousel = inside.some((n) => {
|
|
300
|
+
const cs = style(n);
|
|
301
|
+
if (n.getAttribute("aria-roledescription") === "carousel" || cs.scrollSnapType && cs.scrollSnapType !== "none") return true;
|
|
302
|
+
if (/^(auto|scroll|hidden)$/.test(cs.overflowX) && n.children.length >= 3 && n.scrollWidth > n.clientWidth * 1.2) return true;
|
|
303
|
+
return /^(BUTTON|A)$/.test(n.tagName) && /\b(prev|previous|next)\b/i.test(n.getAttribute("aria-label") ?? "");
|
|
304
|
+
});
|
|
305
|
+
return { badges, avatars, carousel };
|
|
306
|
+
};
|
|
307
|
+
const TEXT_BLOCK = "h1, h2, h3, h4, h5, h6, p, li, blockquote, figcaption, dt, dd, td, th, summary, cite, q";
|
|
308
|
+
const CHROME = "nav, [role=navigation], footer, [role=contentinfo], button, [role=button], [aria-hidden=true], script, style, noscript, template";
|
|
309
|
+
const readable = (el) => {
|
|
310
|
+
if (!shown(el)) return false;
|
|
311
|
+
const r = el.getBoundingClientRect();
|
|
312
|
+
return r.width > 2 && r.height > 2;
|
|
313
|
+
};
|
|
314
|
+
const textBlocks = (scope, limit, keep = () => true) => {
|
|
315
|
+
const out = [];
|
|
316
|
+
const seen = /* @__PURE__ */ new Set();
|
|
317
|
+
const add = (el) => {
|
|
318
|
+
const t = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
319
|
+
if (t.length < 2 || seen.has(t)) return;
|
|
320
|
+
seen.add(t);
|
|
321
|
+
out.push(t);
|
|
322
|
+
};
|
|
323
|
+
for (const el of Array.from(scope.querySelectorAll(TEXT_BLOCK))) {
|
|
324
|
+
if (out.length >= limit) break;
|
|
325
|
+
if (el.parentElement?.closest(TEXT_BLOCK) || el.closest(CHROME) || !readable(el) || !keep(el)) continue;
|
|
326
|
+
add(el);
|
|
327
|
+
}
|
|
328
|
+
for (const el of Array.from(scope.querySelectorAll("div, span"))) {
|
|
329
|
+
if (out.length >= limit) break;
|
|
330
|
+
if (ownText(el).split(" ").length < 3) continue;
|
|
331
|
+
if (el.closest(TEXT_BLOCK) || el.closest(CHROME) || el.closest("a") || !readable(el) || !keep(el)) continue;
|
|
332
|
+
add(el);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
};
|
|
336
|
+
const inChromeHeader = (el) => {
|
|
337
|
+
const h = el.closest("header");
|
|
338
|
+
return Boolean(h && !h.querySelector("h1"));
|
|
339
|
+
};
|
|
340
|
+
const firstScreenText = textBlocks(document.body, 12, (el) => {
|
|
341
|
+
const b = box(el);
|
|
342
|
+
return b.top < vh && b.top + b.height > 0 && !inChromeHeader(el) && !el.closest("a");
|
|
343
|
+
});
|
|
280
344
|
const kept = blocks.slice(0, 40);
|
|
281
345
|
const sections = kept.map((el, index) => {
|
|
282
346
|
const b = box(el);
|
|
283
347
|
const heading = el.querySelector("h1, h2, h3");
|
|
284
348
|
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
285
|
-
const
|
|
349
|
+
const images2 = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
|
|
286
350
|
const textChars = (el.textContent ?? "").replace(/\s+/g, "").length;
|
|
287
351
|
const numbers = Array.from(el.querySelectorAll("*")).filter((n) => /^[£$€]?\d[\d,.]*\s?(?:[%+kKmM★]|\/5|\+)?$/.test(ownText(n)) && px(style(n).fontSize) >= 24).length;
|
|
288
352
|
const { cards, iconCards } = cardsIn(el);
|
|
289
353
|
let kind = "text";
|
|
290
354
|
if (marqueeTargets.some((t) => el.contains(t))) kind = "marquee";
|
|
291
355
|
else if (h1 && el.contains(h1) || index === 0 && b.top < vh) kind = "hero";
|
|
292
|
-
else if (
|
|
356
|
+
else if (images2.length >= 4 && textChars < 300) kind = "logos";
|
|
293
357
|
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
294
358
|
else if (cards >= 3) kind = "cards";
|
|
295
359
|
const geometry = geometryOf(el, b);
|
|
360
|
+
const parts = partsOf(el);
|
|
296
361
|
return {
|
|
297
362
|
top: Math.round(b.top),
|
|
298
363
|
height: Math.round(b.height),
|
|
@@ -302,7 +367,10 @@ function collectInPage() {
|
|
|
302
367
|
iconCards,
|
|
303
368
|
hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
|
|
304
369
|
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
305
|
-
geometry
|
|
370
|
+
geometry,
|
|
371
|
+
...parts,
|
|
372
|
+
figures: numbers,
|
|
373
|
+
text: textBlocks(el, 40)
|
|
306
374
|
};
|
|
307
375
|
});
|
|
308
376
|
if (header && sections.length === 0) {
|
|
@@ -384,6 +452,81 @@ function collectInPage() {
|
|
|
384
452
|
const width = para.getBoundingClientRect().width - px(cs.paddingLeft) - px(cs.paddingRight);
|
|
385
453
|
if (avg > 0 && width > 0) bodyLineLengthCh = Math.round(width / avg);
|
|
386
454
|
}
|
|
455
|
+
const unwrap = (raw) => {
|
|
456
|
+
if (raw.startsWith("data:")) return raw.slice(0, 64);
|
|
457
|
+
try {
|
|
458
|
+
const u = new URL(raw, location.href);
|
|
459
|
+
const inner = /\/_(?:next|vercel)\/image$/.test(u.pathname) ? u.searchParams.get("url") : null;
|
|
460
|
+
if (inner) return new URL(inner, location.href).href;
|
|
461
|
+
const cf = u.pathname.match(/^\/cdn-cgi\/image\/[^/]+\/(.+)$/);
|
|
462
|
+
if (cf) return /^https?:\/\//.test(cf[1]) ? cf[1] : new URL(`/${cf[1]}`, location.href).href;
|
|
463
|
+
return u.href;
|
|
464
|
+
} catch {
|
|
465
|
+
return raw.slice(0, 200);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const roundBox = (el, w) => {
|
|
469
|
+
const rad = style(el).borderTopLeftRadius;
|
|
470
|
+
return rad.endsWith("%") ? parseFloat(rad) >= 40 : px(rad) >= w * 0.4;
|
|
471
|
+
};
|
|
472
|
+
const sectionOf = (el) => {
|
|
473
|
+
const i = kept.findIndex((k) => k.contains(el));
|
|
474
|
+
return i === -1 ? null : i;
|
|
475
|
+
};
|
|
476
|
+
const roleOfImage = (el, src, w, h, alt) => {
|
|
477
|
+
const vector = /\.svg(?:[?#]|$)/i.test(src) || src.startsWith("data:image/svg");
|
|
478
|
+
const square = Math.abs(w - h) <= w * 0.15;
|
|
479
|
+
if (w >= 16 && w <= 96 && square && (roundBox(el, w) || el.parentElement !== null && roundBox(el.parentElement, w))) return "avatar";
|
|
480
|
+
const section = sectionOf(el);
|
|
481
|
+
const hint = `${alt ?? ""} ${src} ${typeof el.className === "string" ? el.className : ""}`;
|
|
482
|
+
if (/\blogo\b/i.test(hint) || section !== null && sections[section]?.kind === "logos" || el.closest("header, footer, nav") && h <= 120) return "logo";
|
|
483
|
+
if (Math.max(w, h) <= (vector ? 120 : 72)) return "icon";
|
|
484
|
+
if (vector) return "illustration";
|
|
485
|
+
return w >= 160 && h >= 120 ? "photo" : "icon";
|
|
486
|
+
};
|
|
487
|
+
const images = [];
|
|
488
|
+
const seenImage = /* @__PURE__ */ new Set();
|
|
489
|
+
const addImage = (el, raw, alt, decorative, background) => {
|
|
490
|
+
const b = box(el);
|
|
491
|
+
if (!raw || b.width < 8 || b.height < 8) return;
|
|
492
|
+
const src = unwrap(raw);
|
|
493
|
+
const key = `${src} ${Math.round(b.top)} ${Math.round(b.left)}`;
|
|
494
|
+
if (seenImage.has(key)) return;
|
|
495
|
+
seenImage.add(key);
|
|
496
|
+
let host = null;
|
|
497
|
+
try {
|
|
498
|
+
host = src.startsWith("data:") ? null : new URL(src).hostname;
|
|
499
|
+
} catch {
|
|
500
|
+
host = null;
|
|
501
|
+
}
|
|
502
|
+
images.push({
|
|
503
|
+
src,
|
|
504
|
+
host,
|
|
505
|
+
width: Math.round(b.width),
|
|
506
|
+
height: Math.round(b.height),
|
|
507
|
+
top: Math.round(b.top),
|
|
508
|
+
role: roleOfImage(el, src, b.width, b.height, alt),
|
|
509
|
+
alt,
|
|
510
|
+
decorative,
|
|
511
|
+
section: sectionOf(el),
|
|
512
|
+
background
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
for (const img of Array.from(document.querySelectorAll("img")).slice(0, 400)) {
|
|
516
|
+
if (!shown(img)) continue;
|
|
517
|
+
const alt = img.getAttribute("alt");
|
|
518
|
+
const decorative = alt === "" || img.getAttribute("aria-hidden") === "true" || /^(?:presentation|none)$/.test(img.getAttribute("role") ?? "");
|
|
519
|
+
addImage(img, img.currentSrc || img.getAttribute("src") || "", alt, decorative, false);
|
|
520
|
+
}
|
|
521
|
+
for (const el of all) {
|
|
522
|
+
const bgImage = style(el).backgroundImage || "";
|
|
523
|
+
if (!bgImage.includes("url(")) continue;
|
|
524
|
+
const b = box(el);
|
|
525
|
+
if (b.width < 160 || b.height < 120 || !shown(el)) continue;
|
|
526
|
+
const m = bgImage.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
|
|
527
|
+
if (m) addImage(el, m[1], null, true, true);
|
|
528
|
+
}
|
|
529
|
+
images.sort((a, b) => b.width * b.height - a.width * a.height);
|
|
387
530
|
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);
|
|
388
531
|
const nums = (s) => Array.from(s).sort((a, b) => a - b);
|
|
389
532
|
return {
|
|
@@ -403,6 +546,8 @@ function collectInPage() {
|
|
|
403
546
|
return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
|
|
404
547
|
})(),
|
|
405
548
|
effects,
|
|
549
|
+
images: images.slice(0, 60),
|
|
550
|
+
firstScreenText,
|
|
406
551
|
motion: {
|
|
407
552
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
408
553
|
sections: sections.length,
|
|
@@ -494,7 +639,60 @@ function visualMeasures(img, maxSamples = 1e6) {
|
|
|
494
639
|
};
|
|
495
640
|
}
|
|
496
641
|
|
|
642
|
+
// src/snapshot/provenance.ts
|
|
643
|
+
var PROVENANCE_SCAN_BYTES = 256 * 1024;
|
|
644
|
+
var AI_SOURCE_TYPES = ["compositeWithTrainedAlgorithmicMedia", "trainedAlgorithmicMedia"];
|
|
645
|
+
var MARKER = new RegExp(`(?:${AI_SOURCE_TYPES.join("|")})`);
|
|
646
|
+
var C2PA_BOX = /jumd[\s\S]{16,24}c2pa/;
|
|
647
|
+
var C2PA_PNG = /caBX/;
|
|
648
|
+
function latin1(bytes) {
|
|
649
|
+
let out = "";
|
|
650
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
651
|
+
out += String.fromCharCode(...bytes.subarray(i, Math.min(bytes.length, i + 8192)));
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
function xmpPackets(text) {
|
|
656
|
+
const out = [];
|
|
657
|
+
const open = /<x:xmpmeta\b|<\?xpacket begin/g;
|
|
658
|
+
let m;
|
|
659
|
+
while (m = open.exec(text)) {
|
|
660
|
+
const close = text.indexOf(m[0].startsWith("<x:") ? "</x:xmpmeta>" : "<?xpacket end", m.index);
|
|
661
|
+
out.push([m.index, close === -1 ? text.length : close]);
|
|
662
|
+
if (close === -1) break;
|
|
663
|
+
open.lastIndex = close;
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
function scanProvenance(bytes) {
|
|
668
|
+
const head = bytes.subarray(0, PROVENANCE_SCAN_BYTES);
|
|
669
|
+
const text = latin1(head);
|
|
670
|
+
const c2pa = C2PA_BOX.test(text) || text.startsWith("\x89PNG") && C2PA_PNG.test(text);
|
|
671
|
+
const m = MARKER.exec(text);
|
|
672
|
+
if (!m) return { digitalSourceType: null, source: null, c2pa, bytes: head.length };
|
|
673
|
+
const inXmp = xmpPackets(text).some(([a, b]) => m.index >= a && m.index < b);
|
|
674
|
+
return { digitalSourceType: m[0], source: inXmp ? "xmp" : c2pa ? "c2pa" : "other", c2pa, bytes: head.length };
|
|
675
|
+
}
|
|
676
|
+
|
|
497
677
|
// src/audit/index.ts
|
|
678
|
+
var PROVENANCE_MAX_IMAGES = 24;
|
|
679
|
+
var PROVENANCE_BUDGET_MS = 2e4;
|
|
680
|
+
async function readProvenance(page, images, timeoutMs) {
|
|
681
|
+
if (!page.request) return;
|
|
682
|
+
const deadline = Date.now() + PROVENANCE_BUDGET_MS;
|
|
683
|
+
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);
|
|
684
|
+
for (const src of wanted) {
|
|
685
|
+
const left = deadline - Date.now();
|
|
686
|
+
if (left < 500) break;
|
|
687
|
+
try {
|
|
688
|
+
const res = await page.request.get(src, { headers: { Range: `bytes=0-${PROVENANCE_SCAN_BYTES - 1}` }, timeout: Math.min(timeoutMs, left) });
|
|
689
|
+
if (!res.ok()) continue;
|
|
690
|
+
const provenance = scanProvenance(await res.body());
|
|
691
|
+
for (const image of images) if (image.src === src) image.provenance = provenance;
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
498
696
|
var VISUAL_MAX_HEIGHT = 6e3;
|
|
499
697
|
async function loadPlaywright() {
|
|
500
698
|
for (const name of ["playwright", "playwright-core"]) {
|
|
@@ -513,6 +711,7 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
513
711
|
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
514
712
|
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
515
713
|
const data = await page.evaluate(collectInPage);
|
|
714
|
+
if (data.images && options.provenance !== false) await readProvenance(page, data.images, 8e3);
|
|
516
715
|
let visual;
|
|
517
716
|
if (page.screenshot && options.visual !== false) {
|
|
518
717
|
try {
|
|
@@ -578,6 +777,8 @@ async function audit(url, options = {}) {
|
|
|
578
777
|
export {
|
|
579
778
|
collectInPage,
|
|
580
779
|
overlayInPage,
|
|
780
|
+
PROVENANCE_MAX_IMAGES,
|
|
781
|
+
PROVENANCE_BUDGET_MS,
|
|
581
782
|
VISUAL_MAX_HEIGHT,
|
|
582
783
|
loadPlaywright,
|
|
583
784
|
snapshotPage,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SNAPSHOT_VERSION
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-QYBAVU3P.js";
|
|
4
4
|
|
|
5
5
|
// src/snapshot/migrate.ts
|
|
6
6
|
var READABLE_SNAPSHOT_VERSIONS = [1, 2];
|
|
@@ -15,5 +15,6 @@ function readSnapshot(data, source = "snapshot") {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export {
|
|
18
|
+
READABLE_SNAPSHOT_VERSIONS,
|
|
18
19
|
readSnapshot
|
|
19
20
|
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseFlags,
|
|
3
|
+
toJson
|
|
4
|
+
} from "./chunk-3OTKMKJQ.js";
|
|
5
|
+
import {
|
|
6
|
+
readSnapshot
|
|
7
|
+
} from "./chunk-WUACK2HX.js";
|
|
8
|
+
import {
|
|
9
|
+
CATALOGUE_VERSION,
|
|
10
|
+
NULL_VERSION,
|
|
11
|
+
auditSnapshot,
|
|
12
|
+
checkCopy,
|
|
13
|
+
extractHtml,
|
|
14
|
+
extractStrings,
|
|
15
|
+
fingerprint,
|
|
16
|
+
harvest,
|
|
17
|
+
hueSwatch,
|
|
18
|
+
nullPrompt,
|
|
19
|
+
scanSource
|
|
20
|
+
} from "./chunk-GQVEYTBY.js";
|
|
21
|
+
|
|
22
|
+
// src/null/cli.ts
|
|
23
|
+
import { spawn } from "child_process";
|
|
24
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
25
|
+
import { tmpdir } from "os";
|
|
26
|
+
import { join, relative, resolve } from "path";
|
|
27
|
+
import { pathToFileURL } from "url";
|
|
28
|
+
var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
29
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
30
|
+
function generate(prompt, model) {
|
|
31
|
+
const bin = process.env.CRAFT_CLAUDE ?? "claude";
|
|
32
|
+
const args = ["-p", "--tools", "", "--no-session-persistence", "--disable-slash-commands", "--setting-sources", "", "--strict-mcp-config", ...model ? ["--model", model] : [], prompt];
|
|
33
|
+
const cwd = mkdtempSync(join(tmpdir(), "craft-null-"));
|
|
34
|
+
return new Promise((done, fail) => {
|
|
35
|
+
const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
36
|
+
let out = "";
|
|
37
|
+
let err = "";
|
|
38
|
+
child.stdout.on("data", (d) => out += d.toString());
|
|
39
|
+
child.stderr.on("data", (d) => err += d.toString());
|
|
40
|
+
child.on("error", (e) => fail(new Error(`${bin}: ${e.message}. Install Claude Code, or set CRAFT_CLAUDE.`)));
|
|
41
|
+
child.on("close", (code) => {
|
|
42
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
43
|
+
if (code === 0) done(out);
|
|
44
|
+
else fail(new Error(`${bin} exited ${code}: ${(err || out).trim().slice(0, 200)}`));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function pool(items, size, work) {
|
|
49
|
+
const queue = [...items];
|
|
50
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(size, queue.length)) }, async () => {
|
|
51
|
+
for (let item = queue.shift(); item !== void 0; item = queue.shift()) await work(item);
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
function nullPath(target, cwd) {
|
|
55
|
+
const abs = resolve(cwd, target);
|
|
56
|
+
return existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "null.json") : abs;
|
|
57
|
+
}
|
|
58
|
+
function loadNull(target, cwd) {
|
|
59
|
+
const path = nullPath(target, cwd);
|
|
60
|
+
if (!existsSync(path)) throw new Error(`no null model at ${target}. Build one with craft null build.`);
|
|
61
|
+
const model = readJson(path);
|
|
62
|
+
if (model.version !== NULL_VERSION) throw new Error(`${target} is null model version ${String(model.version)}; this craft reads ${NULL_VERSION}`);
|
|
63
|
+
return model;
|
|
64
|
+
}
|
|
65
|
+
function measureRun(id, html, snapshot) {
|
|
66
|
+
const file = { path: `${id}.html`, text: html };
|
|
67
|
+
const tells = new Set([...auditSnapshot(snapshot).findings, ...scanSource([file]).findings].map((f) => f.tell));
|
|
68
|
+
const copy = [...new Set(extractStrings(file).filter((b) => !b.literal).map((b) => b.text.replace(/\s+/g, " ").trim()).filter(Boolean))];
|
|
69
|
+
return { id, fingerprint: fingerprint(snapshot), tells: [...tells].sort(), copy };
|
|
70
|
+
}
|
|
71
|
+
async function build(flags, io) {
|
|
72
|
+
const brief = flags.brief ?? (flags.direction ? readJson(resolve(io.cwd, flags.direction)).brief : void 0);
|
|
73
|
+
if (!brief || brief.trim().split(/\s+/).length < 5) throw new Error('usage: craft null build --brief "<who, where, what they do>" --out <dir> [--runs 20] [--parallel 4]');
|
|
74
|
+
if (!flags.out) throw new Error("--out <dir> is where the pages and null.json go");
|
|
75
|
+
const runs = Number(flags.runs ?? 20);
|
|
76
|
+
const parallel = Number(flags.parallel ?? 4);
|
|
77
|
+
if (!Number.isInteger(runs) || runs < 5) throw new Error("--runs needs a whole number, 5 or more");
|
|
78
|
+
const dir = resolve(io.cwd, flags.out);
|
|
79
|
+
const pages = join(dir, "pages");
|
|
80
|
+
const shots = join(dir, "snapshots");
|
|
81
|
+
mkdirSync(pages, { recursive: true });
|
|
82
|
+
mkdirSync(shots, { recursive: true });
|
|
83
|
+
const prompt = nullPrompt(brief);
|
|
84
|
+
const ids = Array.from({ length: runs }, (_, i) => pad(i + 1));
|
|
85
|
+
for (const id of ids) {
|
|
86
|
+
const path = join(pages, `${id}.html`);
|
|
87
|
+
if (!existsSync(path)) continue;
|
|
88
|
+
const raw = readFileSync(path, "utf8");
|
|
89
|
+
const html = extractHtml(raw);
|
|
90
|
+
if (!html) {
|
|
91
|
+
writeFileSync(join(pages, `${id}.reply.txt`), raw);
|
|
92
|
+
rmSync(path);
|
|
93
|
+
rmSync(join(shots, `${id}.json`), { force: true });
|
|
94
|
+
io.out(` ${id} was a reply, not a page: kept as ${id}.reply.txt and generated again`);
|
|
95
|
+
} else if (html !== raw) writeFileSync(path, html);
|
|
96
|
+
}
|
|
97
|
+
const missing = ids.filter((id) => !existsSync(join(pages, `${id}.html`)));
|
|
98
|
+
const failed = [];
|
|
99
|
+
if (missing.length) io.out(`craft null: generating ${missing.length} page${missing.length === 1 ? "" : "s"}, ${parallel} at a time`);
|
|
100
|
+
await pool(missing, parallel, async (id) => {
|
|
101
|
+
try {
|
|
102
|
+
const reply = await generate(prompt, flags.model);
|
|
103
|
+
const html = extractHtml(reply);
|
|
104
|
+
if (!html) {
|
|
105
|
+
writeFileSync(join(pages, `${id}.reply.txt`), reply);
|
|
106
|
+
failed.push(`${id}: the reply held no HTML page (kept as ${id}.reply.txt)`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
writeFileSync(join(pages, `${id}.html`), html);
|
|
110
|
+
io.out(` ${id} written`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
failed.push(`${id}: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
|
|
116
|
+
if (toShoot.length) {
|
|
117
|
+
io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
|
|
118
|
+
const { snapshotUrls } = await import("./audit-6WHR7ZTV.js");
|
|
119
|
+
const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
|
|
120
|
+
results.forEach((r, i) => {
|
|
121
|
+
const id = toShoot[i];
|
|
122
|
+
if (r.snapshot) writeFileSync(join(shots, `${id}.json`), `${JSON.stringify({ ...r.snapshot, url: `pages/${id}.html` }, null, 2)}
|
|
123
|
+
`);
|
|
124
|
+
else failed.push(`${id}: the page would not render: ${r.error}`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const measured = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && existsSync(join(shots, `${id}.json`))).map((id) => measureRun(id, readFileSync(join(pages, `${id}.html`), "utf8"), readSnapshot(readJson(join(shots, `${id}.json`)), `${id}.json`)));
|
|
128
|
+
const model = {
|
|
129
|
+
version: NULL_VERSION,
|
|
130
|
+
brief: brief.trim(),
|
|
131
|
+
prompt,
|
|
132
|
+
model: flags.model ?? "default",
|
|
133
|
+
catalogueVersion: CATALOGUE_VERSION,
|
|
134
|
+
builtAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
135
|
+
runs: measured
|
|
136
|
+
};
|
|
137
|
+
writeFileSync(join(dir, "null.json"), `${JSON.stringify(model, null, 2)}
|
|
138
|
+
`);
|
|
139
|
+
io.out(`craft null: ${measured.length} of ${runs} pages in ${relative(io.cwd, join(dir, "null.json")) || "null.json"}`);
|
|
140
|
+
for (const f of failed) io.err(` ${f}`);
|
|
141
|
+
if (measured.length < runs) {
|
|
142
|
+
io.err(`craft null: ${runs - measured.length} missing. Run the same command again to fill them in.`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
function copyTell(phrase) {
|
|
148
|
+
const text = `We found that ${phrase} was there.`;
|
|
149
|
+
const hit = checkCopy([{ path: "phrase.md", text }]).findings[0];
|
|
150
|
+
return hit ? hit.tell : null;
|
|
151
|
+
}
|
|
152
|
+
function formatHarvest(candidates, models) {
|
|
153
|
+
const lines = [`craft tells harvest: ${models.reduce((s, m) => s + m.runs.length, 0)} null pages from ${models.length} brief${models.length === 1 ? "" : "s"}`];
|
|
154
|
+
const fresh = candidates.filter((c) => !c.known);
|
|
155
|
+
const known = candidates.filter((c) => c.known);
|
|
156
|
+
const runs = models.flatMap((m) => m.runs);
|
|
157
|
+
const line = (c) => {
|
|
158
|
+
const swatch = c.kind === "accent" ? hueSwatch(runs, c.value) : null;
|
|
159
|
+
const where = models.length > 1 ? `, ${c.briefs} brief${c.briefs === 1 ? "" : "s"}` : "";
|
|
160
|
+
return ` ${c.kind.padEnd(8)} ${`${c.value}${swatch ? ` (${swatch})` : ""}`.padEnd(44)} ${c.runs} of ${c.of}${where}${c.known ? ` caught by ${c.known}` : ""}`;
|
|
161
|
+
};
|
|
162
|
+
lines.push("", "Not on the catalogue: candidates for the next generation. A person decides.");
|
|
163
|
+
lines.push(...fresh.length ? fresh.map(line) : [" none"]);
|
|
164
|
+
lines.push("", "Already caught: the catalogue still describes what the model builds.");
|
|
165
|
+
lines.push(...known.length ? known.map(line) : [" none"]);
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
function describeTypicality(t, pages, briefs) {
|
|
169
|
+
const pct = Math.round(t.score * 100);
|
|
170
|
+
return [
|
|
171
|
+
`Typicality against ${pages} null pages from ${briefs} brief${briefs === 1 ? "" : "s"}`,
|
|
172
|
+
` score ${t.score.toFixed(2)}: ${t.typical ? "typical" : "not typical"}. ${pct}% of the model's own pages sit further from the rest than this one.`,
|
|
173
|
+
` distance ${t.distance.toFixed(3)} to its nearest null pages; the null's own median is ${t.baseline.toFixed(3)}`,
|
|
174
|
+
` nearest ${t.nearest.map((n) => `${n.id} ${n.distance.toFixed(2)}`).join(", ")}`,
|
|
175
|
+
` shares ${t.shared.map((s) => `${s.part} ${s.value} (${s.runs} of ${s.of})`).join(", ") || "no choice with a quarter of the null"}`
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
async function runNull(args, io) {
|
|
179
|
+
try {
|
|
180
|
+
const flags = parseFlags(args);
|
|
181
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
182
|
+
if (flags.positional[0] !== "build") throw new Error('usage: craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]');
|
|
183
|
+
return await build(flags, io);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
io.err(`craft: ${error.message}`);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function runHarvest(args, io) {
|
|
190
|
+
const flags = parseFlags(args);
|
|
191
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
192
|
+
const targets = flags.positional.slice(1);
|
|
193
|
+
if (targets.length === 0) throw new Error("usage: craft tells harvest <null.json | dir>... [--share 0.25] [--json]");
|
|
194
|
+
const paths = targets.flatMap((t) => {
|
|
195
|
+
const abs = resolve(io.cwd, t);
|
|
196
|
+
if (existsSync(abs) && statSync(abs).isDirectory() && !existsSync(join(abs, "null.json"))) {
|
|
197
|
+
return readdirSync(abs).map((d) => join(abs, d, "null.json")).filter((p) => existsSync(p));
|
|
198
|
+
}
|
|
199
|
+
return [t];
|
|
200
|
+
});
|
|
201
|
+
const models = paths.map((p) => loadNull(p, io.cwd));
|
|
202
|
+
const minShare = flags.share === void 0 ? void 0 : Number(flags.share);
|
|
203
|
+
if (minShare !== void 0 && !(minShare > 0 && minShare <= 1)) throw new Error("--share needs a number above 0 and at most 1");
|
|
204
|
+
const candidates = harvest(models, { minShare, copyTell });
|
|
205
|
+
io.out(flags.json ? toJson({ briefs: models.map((m) => m.brief), candidates }) : formatHarvest(candidates, models));
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export {
|
|
210
|
+
nullPath,
|
|
211
|
+
loadNull,
|
|
212
|
+
measureRun,
|
|
213
|
+
formatHarvest,
|
|
214
|
+
describeTypicality,
|
|
215
|
+
runNull,
|
|
216
|
+
runHarvest
|
|
217
|
+
};
|