@domandigital/craft 0.5.0 → 0.7.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.
@@ -0,0 +1,163 @@
1
+ import {
2
+ groundName,
3
+ hueName,
4
+ loadNull,
5
+ shapeName
6
+ } from "./chunk-GV3QOTQU.js";
7
+ import {
8
+ parseFlags
9
+ } from "./chunk-YYC4EGER.js";
10
+ import {
11
+ fingerprint,
12
+ fingerprintDistance
13
+ } from "./chunk-Q54JSNC5.js";
14
+ import {
15
+ SNAPSHOT_VERSION,
16
+ auditSnapshot
17
+ } from "./chunk-3AP4PJMX.js";
18
+
19
+ // src/estate/cli.ts
20
+ import { existsSync, readFileSync, writeFileSync } from "fs";
21
+ import { resolve } from "path";
22
+
23
+ // src/estate/index.ts
24
+ var ESTATE_VERSION = 1;
25
+ var SIBLING_AT = 0.31;
26
+ function emptyEstate() {
27
+ return { version: ESTATE_VERSION, sites: [] };
28
+ }
29
+ function addSite(register, site) {
30
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(site.id)) throw new Error(`site id "${site.id}" must be short kebab-case`);
31
+ const sites = [...register.sites.filter((s) => s.id !== site.id), site].sort((a, b) => a.id.localeCompare(b.id));
32
+ return { version: ESTATE_VERSION, sites };
33
+ }
34
+ function sharedParts(a, b, d = fingerprintDistance(a, b)) {
35
+ const out = [];
36
+ const close = (k) => d.parts[k] <= 0.2;
37
+ if (close("accent")) out.push(a.accent || b.accent ? `${hueName(a.accent)} accent` : "no accent");
38
+ if (close("type")) {
39
+ if (a.display.family === b.display.family) out.push(`${a.display.family} headline`);
40
+ if (a.body.family === b.body.family) out.push(`${a.body.family} body`);
41
+ }
42
+ if (close("ground")) out.push(`${groundName(a.ground)} ground`);
43
+ if (close("shape")) out.push(`${shapeName(a.roundness)} buttons`);
44
+ if (close("motion")) out.push(a.motion > 0 || b.motion > 0 ? "scroll reveals" : "no reveals");
45
+ if (close("effects") && a.effects.length) out.push(a.effects.filter((e) => b.effects.includes(e)).join(", "));
46
+ if (close("layout")) out.push("running order");
47
+ return out;
48
+ }
49
+ function compareToEstate(target, register, options = {}) {
50
+ const at = options.siblingAt ?? SIBLING_AT;
51
+ return register.sites.filter((s) => s.id !== options.exclude).map((s) => {
52
+ const d = fingerprintDistance(target, s.fingerprint);
53
+ return { id: s.id, distance: d.total, sibling: d.total < at, shared: sharedParts(target, s.fingerprint, d) };
54
+ }).sort((a, b) => a.distance - b.distance);
55
+ }
56
+ function estatePairs(register, siblingAt = SIBLING_AT) {
57
+ const out = [];
58
+ const s = register.sites;
59
+ for (let i = 0; i < s.length; i += 1) {
60
+ for (let j = i + 1; j < s.length; j += 1) {
61
+ const d = fingerprintDistance(s[i].fingerprint, s[j].fingerprint);
62
+ out.push({ a: s[i].id, b: s[j].id, distance: d.total, sibling: d.total < siblingAt, shared: sharedParts(s[i].fingerprint, s[j].fingerprint, d) });
63
+ }
64
+ }
65
+ return out.sort((x, y) => x.distance - y.distance);
66
+ }
67
+ function siblingLine(briefs) {
68
+ const d = [];
69
+ for (const runs of briefs) {
70
+ for (let i = 0; i < runs.length; i += 1) for (let j = i + 1; j < runs.length; j += 1) d.push(fingerprintDistance(runs[i], runs[j]).total);
71
+ }
72
+ if (d.length === 0) throw new Error("no null pages to measure the sibling line from");
73
+ d.sort((a, b) => a - b);
74
+ const mid = Math.floor(d.length / 2);
75
+ const m = d.length % 2 ? d[mid] : (d[mid - 1] + d[mid]) / 2;
76
+ return Math.round(m * 1e3) / 1e3;
77
+ }
78
+
79
+ // src/estate/cli.ts
80
+ var USAGE = "usage: craft estate add <url | snapshot.json> --id <id> [--client <name>] | craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]";
81
+ function loadEstate(path) {
82
+ if (!existsSync(path)) return emptyEstate();
83
+ const data = JSON.parse(readFileSync(path, "utf8"));
84
+ if (data.version !== ESTATE_VERSION || !Array.isArray(data.sites)) throw new Error(`${path} is not an estate register (version ${ESTATE_VERSION})`);
85
+ return data;
86
+ }
87
+ async function snapshotOf(target, cwd) {
88
+ const local = resolve(cwd, target);
89
+ if (/\.json$/i.test(target) && existsSync(local)) {
90
+ const snap = JSON.parse(readFileSync(local, "utf8"));
91
+ if (snap.version !== SNAPSHOT_VERSION) throw new Error(`${target} is not a snapshot`);
92
+ return snap;
93
+ }
94
+ if (!/^https?:\/\//.test(target)) throw new Error(`${target} is neither a URL, a snapshot file, nor a site in the register`);
95
+ const { snapshotUrl } = await import("./audit-YNODYB3U.js");
96
+ return snapshotUrl(target);
97
+ }
98
+ var line = (distance, sibling, shared) => `${distance.toFixed(2)}${sibling ? " SIBLING" : ""}${shared.length ? ` shares ${shared.join(", ")}` : ""}`;
99
+ function formatMatches(label, matches, at) {
100
+ const lines = [`${label}: nearest sites in the estate (siblings below ${at.toFixed(2)})`];
101
+ for (const m of matches) lines.push(` ${m.id.padEnd(20)} ${line(m.distance, m.sibling, m.shared)}`);
102
+ if (matches.length === 0) lines.push(" the register is empty");
103
+ return lines.join("\n");
104
+ }
105
+ function formatPairs(pairs, at, sites) {
106
+ const siblings = pairs.filter((p) => p.sibling).length;
107
+ const lines = [`craft estate: ${sites} sites, ${pairs.length} pair${pairs.length === 1 ? "" : "s"}, ${siblings} sibling pair${siblings === 1 ? "" : "s"} (below ${at.toFixed(2)})`];
108
+ for (const p of pairs) lines.push(` ${`${p.a} + ${p.b}`.padEnd(34)} ${line(p.distance, p.sibling, p.shared)}`);
109
+ return lines.join("\n");
110
+ }
111
+ async function runEstate(args, io) {
112
+ try {
113
+ const flags = parseFlags(args);
114
+ if (typeof flags === "string") throw new Error(flags);
115
+ const [sub, target] = flags.positional;
116
+ const file = resolve(io.cwd, flags.estate ?? "estate.json");
117
+ const register = loadEstate(file);
118
+ const at = flags.null ? siblingLine(flags.null.split(",").map((p) => loadNull(p.trim(), io.cwd).runs.map((r) => r.fingerprint))) : SIBLING_AT;
119
+ if (sub === "add") {
120
+ if (!target || !flags.id) throw new Error(USAGE);
121
+ const snap = await snapshotOf(target, io.cwd);
122
+ const fp = fingerprint(snap);
123
+ const next = addSite(register, {
124
+ id: flags.id,
125
+ client: flags.client ?? flags.id,
126
+ url: snap.url,
127
+ fingerprint: fp,
128
+ tells: [...new Set(auditSnapshot(snap).findings.map((f) => f.tell))].sort(),
129
+ addedAt: snap.capturedAt
130
+ });
131
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}
132
+ `);
133
+ io.out(`craft estate: ${flags.id} ${register.sites.some((s) => s.id === flags.id) ? "updated" : "added"}, ${next.sites.length} sites in ${flags.estate ?? "estate.json"}`);
134
+ io.out(formatMatches(flags.id, compareToEstate(fp, next, { exclude: flags.id, siblingAt: at }).slice(0, 3), at));
135
+ return 0;
136
+ }
137
+ if (sub === "compare") {
138
+ if (!target) {
139
+ const pairs = estatePairs(register, at);
140
+ io.out(flags.json ? JSON.stringify({ siblingAt: at, pairs }, null, 2) : formatPairs(pairs, at, register.sites.length));
141
+ return flags.strict && pairs.some((p) => p.sibling) ? 1 : 0;
142
+ }
143
+ const known = register.sites.find((s) => s.id === target);
144
+ const fp = known ? known.fingerprint : fingerprint(await snapshotOf(target, io.cwd));
145
+ const matches = compareToEstate(fp, register, { exclude: known?.id, siblingAt: at });
146
+ io.out(flags.json ? JSON.stringify({ siblingAt: at, matches }, null, 2) : formatMatches(known?.id ?? target, matches, at));
147
+ return flags.strict && matches.some((m) => m.sibling) ? 1 : 0;
148
+ }
149
+ throw new Error(USAGE);
150
+ } catch (error) {
151
+ io.err(`craft: ${error.message}`);
152
+ return 2;
153
+ }
154
+ }
155
+
156
+ export {
157
+ SIBLING_AT,
158
+ compareToEstate,
159
+ loadEstate,
160
+ formatMatches,
161
+ formatPairs,
162
+ runEstate
163
+ };
@@ -0,0 +1,458 @@
1
+ import {
2
+ parseFlags
3
+ } from "./chunk-YYC4EGER.js";
4
+ import {
5
+ fingerprint,
6
+ fingerprintDistance
7
+ } from "./chunk-Q54JSNC5.js";
8
+ import {
9
+ CATALOGUE_VERSION,
10
+ REFLEX_FONTS_1,
11
+ REFLEX_FONTS_2,
12
+ auditSnapshot,
13
+ checkCopy,
14
+ deltaEOk,
15
+ extractStrings,
16
+ formatHex,
17
+ isAiViolet,
18
+ isCream,
19
+ oklchToRgb,
20
+ scanSource
21
+ } from "./chunk-3AP4PJMX.js";
22
+
23
+ // src/null/cli.ts
24
+ import { spawn } from "child_process";
25
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
26
+ import { tmpdir } from "os";
27
+ import { join, relative, resolve } from "path";
28
+ import { pathToFileURL } from "url";
29
+
30
+ // src/null/index.ts
31
+ var NULL_VERSION = 1;
32
+ function nullPrompt(brief) {
33
+ return [
34
+ `Build the home page for this business: ${brief.trim()}`,
35
+ "",
36
+ "Write it as one complete, self-contained HTML file. You may load fonts and libraries from a CDN. Use real copy, not placeholder text. Reply with the HTML only."
37
+ ].join("\n");
38
+ }
39
+ function extractHtml(reply) {
40
+ const start = reply.search(/<!doctype html|<html[\s>]/i);
41
+ if (start < 0) return null;
42
+ const end = reply.toLowerCase().lastIndexOf("</html>");
43
+ const html = end > start ? reply.slice(start, end + "</html>".length) : reply.slice(start).replace(/\s*```\s*$/, "");
44
+ return html.length > 200 ? html : null;
45
+ }
46
+ var MIN_RUNS = 5;
47
+ var TYPICAL_AT = 0.1;
48
+ function meanNearest(distances, k) {
49
+ const sorted = [...distances].sort((a, b) => a - b).slice(0, k);
50
+ return sorted.reduce((s, d) => s + d, 0) / sorted.length;
51
+ }
52
+ function median(values) {
53
+ const sorted = [...values].sort((a, b) => a - b);
54
+ const mid = Math.floor(sorted.length / 2);
55
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
56
+ }
57
+ function typicality(target, runs, k = 3) {
58
+ if (runs.length < MIN_RUNS) throw new Error(`a null model needs at least ${MIN_RUNS} runs; this one has ${runs.length}`);
59
+ const kk = Math.min(k, runs.length - 1);
60
+ const toTarget = runs.map((r) => ({ id: r.id, distance: fingerprintDistance(target, r.fingerprint).total }));
61
+ const distance = meanNearest(
62
+ toTarget.map((t) => t.distance),
63
+ kk
64
+ );
65
+ const loo = runs.map(
66
+ (r, i) => meanNearest(
67
+ runs.filter((_, j) => j !== i).map((o) => fingerprintDistance(r.fingerprint, o.fingerprint).total),
68
+ kk
69
+ )
70
+ );
71
+ const score = loo.filter((d) => d >= distance).length / loo.length;
72
+ return {
73
+ score: Math.round(score * 100) / 100,
74
+ typical: score >= TYPICAL_AT,
75
+ distance: Math.round(distance * 1e3) / 1e3,
76
+ baseline: Math.round(median(loo) * 1e3) / 1e3,
77
+ nearest: toTarget.sort((a, b) => a.distance - b.distance).slice(0, kk),
78
+ shared: sharedChoices(target, runs)
79
+ };
80
+ }
81
+ var HUES = [
82
+ ["red", 0, 45],
83
+ ["orange", 45, 75],
84
+ ["yellow", 75, 110],
85
+ ["green", 110, 165],
86
+ ["teal", 165, 215],
87
+ ["blue", 215, 265],
88
+ ["violet", 265, 315],
89
+ ["pink", 315, 360]
90
+ ];
91
+ function hueName(o) {
92
+ if (!o) return "none";
93
+ if (o.c < 0.03) return "neutral";
94
+ return HUES.find(([, lo, hi]) => o.h >= lo && o.h < hi)?.[0] ?? "red";
95
+ }
96
+ function groundName(o) {
97
+ if (o.l < 0.3) return "dark";
98
+ if (isCream(o)) return "cream";
99
+ if (o.l >= 0.97 && o.c < 0.012) return "white";
100
+ return o.c < 0.02 ? "grey" : "tinted";
101
+ }
102
+ function shapeName(roundness) {
103
+ if (roundness === null) return "no buttons";
104
+ if (roundness >= 0.45) return "pill";
105
+ return roundness >= 0.15 ? "soft" : "square";
106
+ }
107
+ function choicesOf(fp) {
108
+ return {
109
+ display: [fp.display.family],
110
+ body: [fp.body.family],
111
+ accent: [hueName(fp.accent)],
112
+ ground: [groundName(fp.ground)],
113
+ shape: [shapeName(fp.roundness)],
114
+ motion: [fp.motion >= 0.6 ? "reveals most sections" : fp.motion > 0 ? "reveals some sections" : "no scroll reveals"],
115
+ effects: fp.effects,
116
+ opening: [fp.layout.slice(0, 3).join(" > ") || "none"]
117
+ };
118
+ }
119
+ function sharedChoices(target, runs) {
120
+ const mine = choicesOf(target);
121
+ const all = runs.map((r) => choicesOf(r.fingerprint));
122
+ const out = [];
123
+ for (const [part, values] of Object.entries(mine)) {
124
+ for (const value of values) {
125
+ const count = all.filter((c) => c[part].includes(value)).length;
126
+ if (count / runs.length >= 0.25) out.push({ part, value, runs: count, of: runs.length });
127
+ }
128
+ }
129
+ return out.sort((a, b) => b.runs - a.runs);
130
+ }
131
+ var REFLEX_1 = new Set(REFLEX_FONTS_1.map((f) => f.toLowerCase()));
132
+ var REFLEX_2 = new Set(REFLEX_FONTS_2.map((f) => f.toLowerCase()));
133
+ function knownFor(kind, value, sample) {
134
+ if (kind === "face") return REFLEX_1.has(value.toLowerCase()) ? "reflex-font" : REFLEX_2.has(value.toLowerCase()) ? "reflex-font-2" : null;
135
+ if (kind === "accent" && value === "violet") return sample.some((f) => f.accent && isAiViolet(f.accent)) ? "ai-violet" : null;
136
+ if (kind === "ground" && value === "cream") return "cream-palette";
137
+ if (kind === "shape" && value === "pill") return "pill-everything";
138
+ if (kind === "motion" && value === "reveals most sections") return "reveal-everywhere";
139
+ if (kind === "tell") return value;
140
+ if (kind === "opening" && /^hero > (?:logos|stats)/.test(value)) return "hero-then-proof";
141
+ return null;
142
+ }
143
+ var STOP = new Set(
144
+ "a an the and or but of to in on for with at by from as is are be we our you your us it its this that these those will can all more get has have not no any".split(" ")
145
+ );
146
+ function grams(text) {
147
+ const out = /* @__PURE__ */ new Set();
148
+ for (const clause of text.toLowerCase().replace(/[’']/g, "'").split(/[.!?;:|()\u2013\u2014\u00b7•]+|,\s/)) {
149
+ const words = clause.match(/[a-z][a-z'-]*/g) ?? [];
150
+ for (let n = 2; n <= 4; n += 1) {
151
+ for (let i = 0; i + n <= words.length; i += 1) {
152
+ const g = words.slice(i, i + n);
153
+ if (STOP.has(g[0]) || STOP.has(g[n - 1])) continue;
154
+ if (g.filter((w) => !STOP.has(w)).length < 2) continue;
155
+ out.add(g.join(" "));
156
+ }
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ var GRAMMAR = /* @__PURE__ */ new Set([
162
+ "privacy policy",
163
+ "cookie policy",
164
+ "rights reserved",
165
+ "all rights reserved",
166
+ "terms of service",
167
+ "terms and conditions",
168
+ "terms conditions",
169
+ "how it works",
170
+ "what we do",
171
+ "about us",
172
+ "contact us",
173
+ "get in touch",
174
+ "opening hours",
175
+ "read more",
176
+ "learn more",
177
+ "book now",
178
+ "free quote",
179
+ "our services",
180
+ "quick links",
181
+ "email address",
182
+ "phone number",
183
+ "full name",
184
+ "first name",
185
+ "last name",
186
+ "your name",
187
+ "your email",
188
+ "send message"
189
+ ]);
190
+ var LEGAL = /©|&copy;|\bregistered (?:in|office|number)\b|\bcompany (?:no|number|reg)|\bvat (?:no|number|reg)/i;
191
+ var NOISE = /\S+@\S+|https?:\/\/\S+|\b[\w-]+(?:\.[\w-]+)*\.(?:co\.uk|com|org|net|uk|io)\b|\b\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm)\b|\d+/gi;
192
+ function textGrams(text) {
193
+ if (LEGAL.test(text)) return /* @__PURE__ */ new Set();
194
+ const out = grams(text.replace(NOISE, " | "));
195
+ for (const g of out) if (GRAMMAR.has(g)) out.delete(g);
196
+ return out;
197
+ }
198
+ function properNouns(brief) {
199
+ return new Set((brief.match(/\b[A-Z][a-z]+/g) ?? []).map((w) => w.toLowerCase()).filter((w) => !STOP.has(w)));
200
+ }
201
+ function harvest(models, options = {}) {
202
+ const minShare = options.minShare ?? 0.25;
203
+ const runs = models.flatMap((m, b) => m.runs.map((r) => ({ ...r, brief: b })));
204
+ const of = runs.length;
205
+ if (of === 0) return [];
206
+ const multiBrief = models.length > 1;
207
+ const sample = runs.map((r) => r.fingerprint);
208
+ const tally = /* @__PURE__ */ new Map();
209
+ const add = (kind, value, run, brief, example) => {
210
+ const key = `${kind}\0${value}`;
211
+ const t = tally.get(key) ?? { kind, value, runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example };
212
+ t.runs.add(run);
213
+ t.briefs.add(brief);
214
+ tally.set(key, t);
215
+ };
216
+ runs.forEach((r, i) => {
217
+ const c = choicesOf(r.fingerprint);
218
+ for (const f of /* @__PURE__ */ new Set([...c.display, ...c.body])) add("face", f, i, r.brief);
219
+ add("accent", c.accent[0], i, r.brief);
220
+ add("ground", c.ground[0], i, r.brief);
221
+ add("shape", c.shape[0], i, r.brief);
222
+ add("motion", c.motion[0], i, r.brief);
223
+ add("opening", c.opening[0], i, r.brief);
224
+ for (const t of r.tells) add("tell", t, i, r.brief);
225
+ });
226
+ const out = [];
227
+ for (const t of tally.values()) {
228
+ if (t.runs.size / of < minShare) continue;
229
+ if (t.kind === "accent" && (t.value === "none" || t.value === "neutral")) continue;
230
+ if (t.kind === "shape" && t.value === "no buttons") continue;
231
+ if (t.kind === "ground" && (t.value === "white" || t.value === "grey")) continue;
232
+ if (t.kind === "motion" && t.value === "no scroll reveals") continue;
233
+ out.push({ kind: t.kind, value: t.value, runs: t.runs.size, of, briefs: t.briefs.size, known: knownFor(t.kind, t.value, sample) });
234
+ }
235
+ const phraseMin = Math.max(3, Math.ceil(of * minShare * 0.4));
236
+ const names = models.map((m) => properNouns(m.brief));
237
+ const phrases = /* @__PURE__ */ new Map();
238
+ runs.forEach((r, i) => {
239
+ for (const text of r.copy) {
240
+ for (const g of textGrams(text)) {
241
+ if (g.split(" ").some((w) => names[r.brief].has(w))) continue;
242
+ const p = phrases.get(g) ?? { runs: /* @__PURE__ */ new Set(), briefs: /* @__PURE__ */ new Set(), example: text.trim().slice(0, 140) };
243
+ p.runs.add(i);
244
+ p.briefs.add(r.brief);
245
+ phrases.set(g, p);
246
+ }
247
+ }
248
+ });
249
+ const kept = [...phrases].filter(([, p]) => p.runs.size >= phraseMin && (!multiBrief || p.briefs.size >= 2)).sort((a, b) => b[1].runs.size - a[1].runs.size || b[0].length - a[0].length);
250
+ const maximal = kept.filter(([g, p]) => !kept.some(([h, q]) => h !== g && h.includes(g) && q.runs.size >= 0.8 * p.runs.size));
251
+ for (const [g, p] of maximal.slice(0, 40)) {
252
+ out.push({ kind: "phrase", value: g, runs: p.runs.size, of, briefs: p.briefs.size, known: options.copyTell?.(g) ?? null, example: p.example });
253
+ }
254
+ const order = ["face", "accent", "ground", "shape", "motion", "opening", "tell", "phrase"];
255
+ return out.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.runs - a.runs);
256
+ }
257
+ function hueSwatch(runs, hue) {
258
+ const hits = runs.map((r) => r.fingerprint.accent).filter((o) => !!o && hueName(o) === hue);
259
+ if (hits.length === 0) return null;
260
+ const centre = hits.reduce((best, o) => hits.reduce((s, p) => s + deltaEOk(o, p), 0) < hits.reduce((s, p) => s + deltaEOk(best, p), 0) ? o : best);
261
+ return formatHex(oklchToRgb(centre));
262
+ }
263
+
264
+ // src/null/cli.ts
265
+ var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
266
+ var pad = (n) => String(n).padStart(2, "0");
267
+ function generate(prompt, model) {
268
+ const bin = process.env.CRAFT_CLAUDE ?? "claude";
269
+ const args = ["-p", "--tools", "", "--no-session-persistence", "--disable-slash-commands", "--setting-sources", "", "--strict-mcp-config", ...model ? ["--model", model] : [], prompt];
270
+ const cwd = mkdtempSync(join(tmpdir(), "craft-null-"));
271
+ return new Promise((done, fail) => {
272
+ const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
273
+ let out = "";
274
+ let err = "";
275
+ child.stdout.on("data", (d) => out += d.toString());
276
+ child.stderr.on("data", (d) => err += d.toString());
277
+ child.on("error", (e) => fail(new Error(`${bin}: ${e.message}. Install Claude Code, or set CRAFT_CLAUDE.`)));
278
+ child.on("close", (code) => {
279
+ rmSync(cwd, { recursive: true, force: true });
280
+ if (code === 0) done(out);
281
+ else fail(new Error(`${bin} exited ${code}: ${(err || out).trim().slice(0, 200)}`));
282
+ });
283
+ });
284
+ }
285
+ async function pool(items, size, work) {
286
+ const queue = [...items];
287
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(size, queue.length)) }, async () => {
288
+ for (let item = queue.shift(); item !== void 0; item = queue.shift()) await work(item);
289
+ }));
290
+ }
291
+ function nullPath(target, cwd) {
292
+ const abs = resolve(cwd, target);
293
+ return existsSync(abs) && statSync(abs).isDirectory() ? join(abs, "null.json") : abs;
294
+ }
295
+ function loadNull(target, cwd) {
296
+ const path = nullPath(target, cwd);
297
+ if (!existsSync(path)) throw new Error(`no null model at ${target}. Build one with craft null build.`);
298
+ const model = readJson(path);
299
+ if (model.version !== NULL_VERSION) throw new Error(`${target} is null model version ${String(model.version)}; this craft reads ${NULL_VERSION}`);
300
+ return model;
301
+ }
302
+ function measureRun(id, html, snapshot) {
303
+ const file = { path: `${id}.html`, text: html };
304
+ const tells = new Set([...auditSnapshot(snapshot).findings, ...scanSource([file]).findings].map((f) => f.tell));
305
+ const copy = [...new Set(extractStrings(file).filter((b) => !b.literal).map((b) => b.text.replace(/\s+/g, " ").trim()).filter(Boolean))];
306
+ return { id, fingerprint: fingerprint(snapshot), tells: [...tells].sort(), copy };
307
+ }
308
+ async function build(flags, io) {
309
+ const brief = flags.brief ?? (flags.direction ? readJson(resolve(io.cwd, flags.direction)).brief : void 0);
310
+ 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]');
311
+ if (!flags.out) throw new Error("--out <dir> is where the pages and null.json go");
312
+ const runs = Number(flags.runs ?? 20);
313
+ const parallel = Number(flags.parallel ?? 4);
314
+ if (!Number.isInteger(runs) || runs < 5) throw new Error("--runs needs a whole number, 5 or more");
315
+ const dir = resolve(io.cwd, flags.out);
316
+ const pages = join(dir, "pages");
317
+ const shots = join(dir, "snapshots");
318
+ mkdirSync(pages, { recursive: true });
319
+ mkdirSync(shots, { recursive: true });
320
+ const prompt = nullPrompt(brief);
321
+ const ids = Array.from({ length: runs }, (_, i) => pad(i + 1));
322
+ for (const id of ids) {
323
+ const path = join(pages, `${id}.html`);
324
+ if (!existsSync(path)) continue;
325
+ const raw = readFileSync(path, "utf8");
326
+ const html = extractHtml(raw);
327
+ if (!html) {
328
+ writeFileSync(join(pages, `${id}.reply.txt`), raw);
329
+ rmSync(path);
330
+ rmSync(join(shots, `${id}.json`), { force: true });
331
+ io.out(` ${id} was a reply, not a page: kept as ${id}.reply.txt and generated again`);
332
+ } else if (html !== raw) writeFileSync(path, html);
333
+ }
334
+ const missing = ids.filter((id) => !existsSync(join(pages, `${id}.html`)));
335
+ const failed = [];
336
+ if (missing.length) io.out(`craft null: generating ${missing.length} page${missing.length === 1 ? "" : "s"}, ${parallel} at a time`);
337
+ await pool(missing, parallel, async (id) => {
338
+ try {
339
+ const reply = await generate(prompt, flags.model);
340
+ const html = extractHtml(reply);
341
+ if (!html) {
342
+ writeFileSync(join(pages, `${id}.reply.txt`), reply);
343
+ failed.push(`${id}: the reply held no HTML page (kept as ${id}.reply.txt)`);
344
+ return;
345
+ }
346
+ writeFileSync(join(pages, `${id}.html`), html);
347
+ io.out(` ${id} written`);
348
+ } catch (error) {
349
+ failed.push(`${id}: ${error.message}`);
350
+ }
351
+ });
352
+ const toShoot = ids.filter((id) => existsSync(join(pages, `${id}.html`)) && !existsSync(join(shots, `${id}.json`)));
353
+ if (toShoot.length) {
354
+ io.out(`craft null: snapshotting ${toShoot.length} page${toShoot.length === 1 ? "" : "s"}`);
355
+ const { snapshotUrls } = await import("./audit-YNODYB3U.js");
356
+ const results = await snapshotUrls(toShoot.map((id) => pathToFileURL(join(pages, `${id}.html`)).href));
357
+ results.forEach((r, i) => {
358
+ const id = toShoot[i];
359
+ if (r.snapshot) writeFileSync(join(shots, `${id}.json`), `${JSON.stringify({ ...r.snapshot, url: `pages/${id}.html` }, null, 2)}
360
+ `);
361
+ else failed.push(`${id}: the page would not render: ${r.error}`);
362
+ });
363
+ }
364
+ 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"), readJson(join(shots, `${id}.json`))));
365
+ const model = {
366
+ version: NULL_VERSION,
367
+ brief: brief.trim(),
368
+ prompt,
369
+ model: flags.model ?? "default",
370
+ catalogueVersion: CATALOGUE_VERSION,
371
+ builtAt: (/* @__PURE__ */ new Date()).toISOString(),
372
+ runs: measured
373
+ };
374
+ writeFileSync(join(dir, "null.json"), `${JSON.stringify(model, null, 2)}
375
+ `);
376
+ io.out(`craft null: ${measured.length} of ${runs} pages in ${relative(io.cwd, join(dir, "null.json")) || "null.json"}`);
377
+ for (const f of failed) io.err(` ${f}`);
378
+ if (measured.length < runs) {
379
+ io.err(`craft null: ${runs - measured.length} missing. Run the same command again to fill them in.`);
380
+ return 1;
381
+ }
382
+ return 0;
383
+ }
384
+ function copyTell(phrase) {
385
+ const text = `We found that ${phrase} was there.`;
386
+ const hit = checkCopy([{ path: "phrase.md", text }]).findings[0];
387
+ return hit ? hit.tell : null;
388
+ }
389
+ function formatHarvest(candidates, models) {
390
+ const lines = [`craft tells harvest: ${models.reduce((s, m) => s + m.runs.length, 0)} null pages from ${models.length} brief${models.length === 1 ? "" : "s"}`];
391
+ const fresh = candidates.filter((c) => !c.known);
392
+ const known = candidates.filter((c) => c.known);
393
+ const runs = models.flatMap((m) => m.runs);
394
+ const line = (c) => {
395
+ const swatch = c.kind === "accent" ? hueSwatch(runs, c.value) : null;
396
+ const where = models.length > 1 ? `, ${c.briefs} brief${c.briefs === 1 ? "" : "s"}` : "";
397
+ return ` ${c.kind.padEnd(8)} ${`${c.value}${swatch ? ` (${swatch})` : ""}`.padEnd(44)} ${c.runs} of ${c.of}${where}${c.known ? ` caught by ${c.known}` : ""}`;
398
+ };
399
+ lines.push("", "Not on the catalogue: candidates for the next generation. A person decides.");
400
+ lines.push(...fresh.length ? fresh.map(line) : [" none"]);
401
+ lines.push("", "Already caught: the catalogue still describes what the model builds.");
402
+ lines.push(...known.length ? known.map(line) : [" none"]);
403
+ return lines.join("\n");
404
+ }
405
+ function describeTypicality(t, pages, briefs) {
406
+ const pct = Math.round(t.score * 100);
407
+ return [
408
+ `Typicality against ${pages} null pages from ${briefs} brief${briefs === 1 ? "" : "s"}`,
409
+ ` 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.`,
410
+ ` distance ${t.distance.toFixed(3)} to its nearest null pages; the null's own median is ${t.baseline.toFixed(3)}`,
411
+ ` nearest ${t.nearest.map((n) => `${n.id} ${n.distance.toFixed(2)}`).join(", ")}`,
412
+ ` shares ${t.shared.map((s) => `${s.part} ${s.value} (${s.runs} of ${s.of})`).join(", ") || "no choice with a quarter of the null"}`
413
+ ].join("\n");
414
+ }
415
+ async function runNull(args, io) {
416
+ try {
417
+ const flags = parseFlags(args);
418
+ if (typeof flags === "string") throw new Error(flags);
419
+ if (flags.positional[0] !== "build") throw new Error('usage: craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]');
420
+ return await build(flags, io);
421
+ } catch (error) {
422
+ io.err(`craft: ${error.message}`);
423
+ return 2;
424
+ }
425
+ }
426
+ function runHarvest(args, io) {
427
+ const flags = parseFlags(args);
428
+ if (typeof flags === "string") throw new Error(flags);
429
+ const targets = flags.positional.slice(1);
430
+ if (targets.length === 0) throw new Error("usage: craft tells harvest <null.json | dir>... [--share 0.25] [--json]");
431
+ const paths = targets.flatMap((t) => {
432
+ const abs = resolve(io.cwd, t);
433
+ if (existsSync(abs) && statSync(abs).isDirectory() && !existsSync(join(abs, "null.json"))) {
434
+ return readdirSync(abs).map((d) => join(abs, d, "null.json")).filter((p) => existsSync(p));
435
+ }
436
+ return [t];
437
+ });
438
+ const models = paths.map((p) => loadNull(p, io.cwd));
439
+ const minShare = flags.share === void 0 ? void 0 : Number(flags.share);
440
+ if (minShare !== void 0 && !(minShare > 0 && minShare <= 1)) throw new Error("--share needs a number above 0 and at most 1");
441
+ const candidates = harvest(models, { minShare, copyTell });
442
+ io.out(flags.json ? JSON.stringify({ briefs: models.map((m) => m.brief), candidates }, null, 2) : formatHarvest(candidates, models));
443
+ return 0;
444
+ }
445
+
446
+ export {
447
+ typicality,
448
+ hueName,
449
+ groundName,
450
+ shapeName,
451
+ nullPath,
452
+ loadNull,
453
+ measureRun,
454
+ formatHarvest,
455
+ describeTypicality,
456
+ runNull,
457
+ runHarvest
458
+ };