@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.
@@ -6,7 +6,7 @@ import {
6
6
  isCream,
7
7
  normaliseFamily,
8
8
  parseColour
9
- } from "./chunk-AL6MRG6V.js";
9
+ } from "./chunk-GQVEYTBY.js";
10
10
 
11
11
  // src/direction/licences.ts
12
12
  var LICENCES = [
@@ -1,23 +1,24 @@
1
1
  import {
2
- groundName,
3
- hueName,
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-J3QUROJ5.js";
7
+ } from "./chunk-3OTKMKJQ.js";
14
8
  import {
15
- fingerprint,
16
- fingerprintDistance
17
- } from "./chunk-PZ4MF2VV.js";
9
+ readSnapshot
10
+ } from "./chunk-WUACK2HX.js";
18
11
  import {
19
- auditSnapshot
20
- } from "./chunk-AL6MRG6V.js";
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-5QWFLZ6L.js");
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-PZ4MF2VV.js";
7
+ } from "./chunk-GQVEYTBY.js";
7
8
  import {
8
- SNAPSHOT_VERSION,
9
- auditSnapshot
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 textBlocks = 0;
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
- textBlocks += 1;
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: textBlocks ? round(centred / textBlocks) : 0,
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 images = Array.from(el.querySelectorAll("img, svg")).filter((i) => shown(i) && box(i).height <= 90);
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 (images.length >= 4 && textChars < 300) kind = "logos";
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,
@@ -0,0 +1,6 @@
1
+ // src/snapshot/types.ts
2
+ var SNAPSHOT_VERSION = 2;
3
+
4
+ export {
5
+ SNAPSHOT_VERSION
6
+ };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SNAPSHOT_VERSION
3
- } from "./chunk-AL6MRG6V.js";
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
+ };