@domandigital/craft 0.6.0 → 0.8.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 +36 -0
- package/CHARACTER.md +280 -20
- package/COPY.md +280 -0
- package/README.md +12 -0
- package/dist/audit-W57YDZ2K.js +20 -0
- package/dist/audit.cjs +901 -103
- package/dist/audit.d.cts +16 -1
- package/dist/audit.d.ts +16 -1
- package/dist/audit.js +899 -102
- package/dist/chunk-35OX6C4H.js +112 -0
- package/dist/{chunk-GWMSSRIQ.js → chunk-CZHRNPDD.js} +1521 -934
- package/dist/chunk-K253ZXB3.js +458 -0
- package/dist/{chunk-HYGFP3LB.js → chunk-OGSYVCMU.js} +40 -3
- package/dist/chunk-OVOH7KZY.js +163 -0
- package/dist/{cli-7ME4VIRG.js → chunk-Q4MN3OXF.js} +50 -101
- package/dist/chunk-ULYKASLA.js +175 -0
- package/dist/chunk-X65RDW65.js +364 -0
- package/dist/cli-F7ONSKZS.js +21 -0
- package/dist/cli-FKJOMOOF.js +15 -0
- package/dist/{cli-F7W4F3ER.js → cli-KA3JDNDH.js} +14 -168
- package/dist/cli-LZ7R6KJM.js +312 -0
- package/dist/cli-SHLNRHK6.js +16 -0
- package/dist/cli.js +2 -1
- package/dist/index.cjs +1528 -99
- package/dist/index.d.cts +365 -4
- package/dist/index.d.ts +365 -4
- package/dist/index.js +1493 -96
- package/package.json +4 -2
- package/word-lists.json +164 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CATALOGUE,
|
|
3
|
+
CATALOGUE_VERSION,
|
|
4
|
+
applyHouseGate,
|
|
5
|
+
checkCopy,
|
|
6
|
+
extractCopy,
|
|
7
|
+
extractStrings,
|
|
8
|
+
fileKind,
|
|
9
|
+
houseRule,
|
|
10
|
+
scanSource
|
|
11
|
+
} from "./chunk-CZHRNPDD.js";
|
|
12
|
+
|
|
13
|
+
// src/character/cli.ts
|
|
14
|
+
import { execFileSync } from "child_process";
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
16
|
+
import { join, relative, resolve } from "path";
|
|
17
|
+
|
|
18
|
+
// src/character/facts.ts
|
|
19
|
+
var MONTH = "(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
|
|
20
|
+
var PATTERNS = [
|
|
21
|
+
["url", /\bhttps?:\/\/[^\s<>"'`)\]]+|\bwww\.[^\s<>"'`)\]]+/gi],
|
|
22
|
+
["email", /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
|
|
23
|
+
["phone", /(?:\+44\s?\(?0?\)?\s?|\b0)\d(?:[\s-]?\d){8,9}\b/g],
|
|
24
|
+
["postcode", /\b[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}\b/g],
|
|
25
|
+
["money", /[£$€]\s?\d[\d,]*(?:\.\d{1,2})?(?:\s?(?:k|m|bn)\b)?|\b\d[\d,]*(?:\.\d{1,2})?\s?(?:pounds|GBP)\b/gi],
|
|
26
|
+
["date", new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}\\b(?:\\s+\\d{4})?|\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?\\b(?:,?\\s+\\d{4})?|\\b${MONTH}\\s+\\d{4}\\b|\\b\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}\\b|\\b(?:19|20)\\d{2}\\b`, "g")],
|
|
27
|
+
["time", /\b\d{1,2}(?:[:.]\d{2})?\s?(?:am|pm)\b|\b\d{1,2}:\d{2}\b/gi],
|
|
28
|
+
["number", /\b\d[\d,]*(?:\.\d+)?%?|\b\d+(?:\.\d+)?\s?(?:per ?cent)\b/gi]
|
|
29
|
+
];
|
|
30
|
+
var NAME = /[A-Z][a-zA-Z’'&-]*[a-zA-Z](?:\s+(?:of|and|&|the)?\s*[A-Z][a-zA-Z’'&-]*[a-zA-Z])*/g;
|
|
31
|
+
var normalise = (kind, value) => {
|
|
32
|
+
const v = value.trim();
|
|
33
|
+
if (kind === "phone") return v.replace(/[^\d+]/g, "").replace(/^\+44\(?0?\)?/, "0").replace(/^\+44/, "0");
|
|
34
|
+
if (kind === "money" || kind === "number") return v.replace(/[\s,]/g, "").toLowerCase();
|
|
35
|
+
if (kind === "postcode") return v.replace(/\s/g, "").toUpperCase();
|
|
36
|
+
if (kind === "url") return v.replace(/[.,;:]+$/, "").toLowerCase();
|
|
37
|
+
return v.replace(/\s+/g, " ").toLowerCase();
|
|
38
|
+
};
|
|
39
|
+
function visibleText(file) {
|
|
40
|
+
const prose = /\.(md|markdown|mdx|txt)$/i.test(file.path);
|
|
41
|
+
return (prose ? extractCopy(file) : extractStrings(file)).map((b) => b.text).join("\n");
|
|
42
|
+
}
|
|
43
|
+
function protectedFacts(text) {
|
|
44
|
+
const facts = [];
|
|
45
|
+
let rest = text;
|
|
46
|
+
const mask = (start, length) => {
|
|
47
|
+
rest = rest.slice(0, start) + " ".repeat(length) + rest.slice(start + length);
|
|
48
|
+
};
|
|
49
|
+
for (const [kind, pattern] of PATTERNS) {
|
|
50
|
+
for (const m of [...rest.matchAll(pattern)]) {
|
|
51
|
+
const value = m[0].replace(/[.,;:]+$/, "");
|
|
52
|
+
facts.push({ kind, value, key: `${kind}:${normalise(kind, value)}` });
|
|
53
|
+
mask(m.index ?? 0, m[0].length);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const lines = rest.split("\n");
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
if (/^\s{0,3}#{1,6}\s/.test(line)) continue;
|
|
59
|
+
for (const m of line.matchAll(NAME)) {
|
|
60
|
+
const at = m.index ?? 0;
|
|
61
|
+
const before = line.slice(0, at).trimEnd();
|
|
62
|
+
const opensSentence = before === "" || /[.!?:"“(>*-]$/.test(before) || /^\s*(?:[-*+]|\d+\.)$/.test(before);
|
|
63
|
+
const word = m[0];
|
|
64
|
+
const acronym = /^[A-Z]{2,}$/.test(word);
|
|
65
|
+
if (opensSentence && !acronym && !/\s/.test(word)) continue;
|
|
66
|
+
if (word === "I") continue;
|
|
67
|
+
facts.push({ kind: "name", value: word, key: `name:${normalise("name", word)}` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return facts;
|
|
71
|
+
}
|
|
72
|
+
var unique = (facts) => [...new Map(facts.map((f) => [f.key, f])).values()];
|
|
73
|
+
function compareFacts(before, after) {
|
|
74
|
+
const a = unique(protectedFacts(before));
|
|
75
|
+
const b = unique(protectedFacts(after));
|
|
76
|
+
const inA = new Set(a.map((f) => f.key));
|
|
77
|
+
const inB = new Set(b.map((f) => f.key));
|
|
78
|
+
return {
|
|
79
|
+
before: a.length,
|
|
80
|
+
after: b.length,
|
|
81
|
+
lost: a.filter((f) => !inB.has(f.key)),
|
|
82
|
+
added: b.filter((f) => !inA.has(f.key))
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function formatComparison(result, beforePath, afterPath) {
|
|
86
|
+
const lines = [`craft copy compare: ${beforePath} -> ${afterPath}`, ` ${result.before} protected facts before, ${result.after} after`];
|
|
87
|
+
if (result.lost.length) {
|
|
88
|
+
lines.push("", ` Lost (${result.lost.length}): restore each, or say why it went`);
|
|
89
|
+
for (const f of result.lost) lines.push(` ${f.kind.padEnd(9)} ${f.value}`);
|
|
90
|
+
}
|
|
91
|
+
if (result.added.length) {
|
|
92
|
+
lines.push("", ` Added (${result.added.length}): each needs a source, or it is invented`);
|
|
93
|
+
for (const f of result.added) lines.push(` ${f.kind.padEnd(9)} ${f.value}`);
|
|
94
|
+
}
|
|
95
|
+
if (!result.lost.length && !result.added.length) lines.push("", " Every fact kept, nothing added.");
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/character/format.ts
|
|
100
|
+
function formatReport(report, title) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
let current = "";
|
|
103
|
+
for (const f of report.findings) {
|
|
104
|
+
if (f.path !== current) {
|
|
105
|
+
current = f.path;
|
|
106
|
+
lines.push("", f.path);
|
|
107
|
+
}
|
|
108
|
+
lines.push(` ${String(f.line).padStart(4)} ${f.tell} (gen ${f.generation}, ${f.severity}) ${f.message}`);
|
|
109
|
+
lines.push(` ${f.excerpt}`);
|
|
110
|
+
}
|
|
111
|
+
const fixes = new Map(report.findings.map((f) => [f.tell, f.fix]));
|
|
112
|
+
if (fixes.size > 0) {
|
|
113
|
+
lines.push("", "What to do instead:");
|
|
114
|
+
for (const [tell, fix] of fixes) lines.push(` ${tell}: ${fix}`);
|
|
115
|
+
}
|
|
116
|
+
for (const r of report.rejectedExceptions) {
|
|
117
|
+
lines.push("", `Exception not applied (${r.exception.tell}): ${r.reason}`);
|
|
118
|
+
}
|
|
119
|
+
for (const e of report.excepted) {
|
|
120
|
+
lines.push("", `Excepted: ${e.count} \xD7 ${e.tell}, because ${e.because}`);
|
|
121
|
+
}
|
|
122
|
+
if (report.suppressed.length > 0) {
|
|
123
|
+
lines.push("", `Suppressed: ${report.suppressed.length} marked copy-ok or craft-ok (${report.suppressed.map((s) => `${s.path}:${s.line}`).join(", ")})`);
|
|
124
|
+
}
|
|
125
|
+
const { summary } = report;
|
|
126
|
+
const gens = `gen 1: ${summary.byGeneration[1]}, gen 2: ${summary.byGeneration[2]}, gen 3: ${summary.byGeneration[3]}`;
|
|
127
|
+
lines.push(
|
|
128
|
+
"",
|
|
129
|
+
`${title}: ${summary.findings} finding${summary.findings === 1 ? "" : "s"} in ${summary.files} file${summary.files === 1 ? "" : "s"} (${gens}). Catalogue ${report.catalogueVersion}.`
|
|
130
|
+
);
|
|
131
|
+
return lines.join("\n").replace(/^\n/, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/character/cli.ts
|
|
135
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".turbo", ".vercel", "coverage", "vendor", ".cache"]);
|
|
136
|
+
var MAX_BYTES = 1e6;
|
|
137
|
+
var NOT_SITE_COPY = /^(?:README|CHANGELOG|CLAUDE|AGENTS|CONTRIBUTING|LICENSE|CODE_OF_CONDUCT|SECURITY)(?:\.[a-z]+)?$/i;
|
|
138
|
+
var HELP = `craft: find the AI look and say what to do instead.
|
|
139
|
+
|
|
140
|
+
Usage
|
|
141
|
+
craft scan [paths...] [--staged] [--json] [--strict] [--direction <file>]
|
|
142
|
+
craft copy <paths...> [--gate] [--json] [--strict] [--direction <file>]
|
|
143
|
+
craft copy compare <before> <after> [--json]
|
|
144
|
+
craft tells list [--json]
|
|
145
|
+
craft tells harvest <null.json | dir>... [--share 0.25] [--json]
|
|
146
|
+
craft snapshot <url> [--out <file>] [--width <px>] [--height <px>]
|
|
147
|
+
craft audit <url | snapshot.json> [--repo <dir>] [--null <null.json>] [--out <file>] [--json] [--strict]
|
|
148
|
+
craft direction init [--snapshot <file>] [--client <name>] [--out <file>]
|
|
149
|
+
craft direction validate [--snapshot <file>] [--direction <file>] [--json]
|
|
150
|
+
craft direction propose [--snapshot <file>] [--estate <estate.json | dir>] [--out <file>]
|
|
151
|
+
craft null build --brief "<text>" --out <dir> [--runs 20] [--parallel 4] [--model <name>]
|
|
152
|
+
craft estate add <url | snapshot.json> --id <id> [--client <name>] [--estate <file>]
|
|
153
|
+
craft estate compare [<url | snapshot.json | id>] [--null <dir>] [--json] [--strict]
|
|
154
|
+
craft report <url | snapshot.json> [--repo <dir>] [--null <dir>] [--estate <file>] [--direction <file>] [--json] [--strict]
|
|
155
|
+
craft retrofit <url | snapshot.json> [the same] [--out RETROFIT.md]
|
|
156
|
+
|
|
157
|
+
scan Markup, component code and stylesheets (source and compiled CSS).
|
|
158
|
+
--staged reads the git index, for a pre-commit hook.
|
|
159
|
+
copy The prose in Markdown, markup and content files.
|
|
160
|
+
--gate applies the house copy policy: the blocking tier of COPY.md
|
|
161
|
+
fails the run. What copy-check and the pre-commit hook run.
|
|
162
|
+
compare lists the protected facts (prices, numbers, dates, times,
|
|
163
|
+
phones, emails, links, postcodes, names) a rewrite lost or added.
|
|
164
|
+
Exit 1 if any: restore it, source it, or say why.
|
|
165
|
+
tells The catalogue this build judges against. harvest reads null models
|
|
166
|
+
for choices the model keeps making that the catalogue does not know.
|
|
167
|
+
snapshot Render a page in a browser and save what it looks like, as JSON.
|
|
168
|
+
audit Judge a rendered page (live, or a saved snapshot) and fingerprint it.
|
|
169
|
+
--repo also scans that site's source, so one report covers both.
|
|
170
|
+
--null scores how typical it is against a null model (comma-separate
|
|
171
|
+
several to pool them).
|
|
172
|
+
snapshot and audit need Playwright: npm i -D playwright.
|
|
173
|
+
direction The site's art-direction.json: every choice with a reason from the
|
|
174
|
+
client's world. init writes today's choices with empty reasons;
|
|
175
|
+
validate applies the reason rule; propose reads colours off the
|
|
176
|
+
sources' PNG photos and drafts choices for a person to confirm.
|
|
177
|
+
null The counterfactual: about 20 pages \`claude -p\` builds from the brief
|
|
178
|
+
alone, snapshotted and fingerprinted into null.json. Resumable.
|
|
179
|
+
estate The register of shipped sites (estate.json). add fingerprints a site;
|
|
180
|
+
compare lists the nearest, and marks siblings: two sites closer than
|
|
181
|
+
two pages Claude builds for one brief. --strict exits 1 on a sibling.
|
|
182
|
+
report Every signal for one site: tells, typicality, the estate, the reasons.
|
|
183
|
+
A verdict (default, mixed, decided, unproven) and what to do, in order.
|
|
184
|
+
Anything not given is "not measured", and never counts as a pass.
|
|
185
|
+
retrofit The report as a checklist: decide, then change type, colour, shape,
|
|
186
|
+
effects, motion and copy. Never the page grammar.
|
|
187
|
+
|
|
188
|
+
Exceptions come from art-direction.json in the working directory, or --direction.
|
|
189
|
+
Every tell ships as warn: exit 1 only on a block, or on any finding with --strict.`;
|
|
190
|
+
var VALUE_FLAGS = {
|
|
191
|
+
"--direction": "direction",
|
|
192
|
+
"--out": "out",
|
|
193
|
+
"--repo": "repo",
|
|
194
|
+
"--width": "width",
|
|
195
|
+
"--height": "height",
|
|
196
|
+
"--snapshot": "snapshot",
|
|
197
|
+
"--estate": "estate",
|
|
198
|
+
"--client": "client",
|
|
199
|
+
"--brief": "brief",
|
|
200
|
+
"--runs": "runs",
|
|
201
|
+
"--parallel": "parallel",
|
|
202
|
+
"--model": "model",
|
|
203
|
+
"--share": "share",
|
|
204
|
+
"--null": "null",
|
|
205
|
+
"--id": "id"
|
|
206
|
+
};
|
|
207
|
+
function parseFlags(args) {
|
|
208
|
+
const flags = { positional: [], json: false, strict: false, staged: false, gate: false };
|
|
209
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
210
|
+
const a = args[i];
|
|
211
|
+
if (a === "--json") flags.json = true;
|
|
212
|
+
else if (a === "--strict") flags.strict = true;
|
|
213
|
+
else if (a === "--staged") flags.staged = true;
|
|
214
|
+
else if (a === "--gate") flags.gate = true;
|
|
215
|
+
else if (a in VALUE_FLAGS) {
|
|
216
|
+
const value = args[i + 1];
|
|
217
|
+
i += 1;
|
|
218
|
+
if (!value || value.startsWith("--")) return `${a} needs a value`;
|
|
219
|
+
flags[VALUE_FLAGS[a]] = value;
|
|
220
|
+
} else if (a.startsWith("--")) return `unknown option ${a}`;
|
|
221
|
+
else flags.positional.push(a);
|
|
222
|
+
}
|
|
223
|
+
return flags;
|
|
224
|
+
}
|
|
225
|
+
function walk(root, cwd, into, wanted, skipDir = (_) => false) {
|
|
226
|
+
const stat = statSync(root);
|
|
227
|
+
if (stat.isFile()) {
|
|
228
|
+
if (stat.size <= MAX_BYTES) into.push({ path: relative(cwd, root) || root, text: readFileSync(root, "utf8") });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
232
|
+
if (entry.isDirectory()) {
|
|
233
|
+
if (!SKIP_DIRS.has(entry.name) && !skipDir(entry.name)) walk(join(root, entry.name), cwd, into, wanted, skipDir);
|
|
234
|
+
} else if (entry.isFile() && wanted(entry.name)) {
|
|
235
|
+
walk(join(root, entry.name), cwd, into, wanted, skipDir);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function readPaths(paths, cwd, wanted, skipDir) {
|
|
240
|
+
const files = [];
|
|
241
|
+
for (const p of paths) {
|
|
242
|
+
const abs = resolve(cwd, p);
|
|
243
|
+
if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
|
|
244
|
+
walk(abs, cwd, files, wanted, skipDir);
|
|
245
|
+
}
|
|
246
|
+
return files;
|
|
247
|
+
}
|
|
248
|
+
function readStaged(cwd, wanted) {
|
|
249
|
+
const names = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], { cwd, encoding: "utf8" }).split("\0").filter((p) => p && wanted(p));
|
|
250
|
+
return names.map((path) => ({
|
|
251
|
+
path,
|
|
252
|
+
// The index, not the working tree: the commit is what gets checked.
|
|
253
|
+
text: execFileSync("git", ["show", `:${path}`], { cwd, encoding: "utf8", maxBuffer: MAX_BYTES * 4 })
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
function loadExceptions(flags, cwd) {
|
|
257
|
+
const path = resolve(cwd, flags.direction ?? "art-direction.json");
|
|
258
|
+
if (!existsSync(path)) {
|
|
259
|
+
if (flags.direction) throw new Error(`no such file: ${flags.direction}`);
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
263
|
+
return Array.isArray(data.exceptions) ? data.exceptions : [];
|
|
264
|
+
}
|
|
265
|
+
function finish(report, flags, title, io) {
|
|
266
|
+
io.out(flags.json ? JSON.stringify(report, null, 2) : formatReport(report, title));
|
|
267
|
+
if (report.summary.blocking > 0) return 1;
|
|
268
|
+
if (flags.strict && report.summary.findings > 0) return 1;
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
var SOURCE_FILE = (p) => ["markup", "script", "css"].includes(fileKind(p)) && !/\.d\.ts$|\.min\.js$/.test(p);
|
|
272
|
+
var COPY_FILE = (p) => (["markup", "prose", "script"].includes(fileKind(p)) || /\.jsonl?$/i.test(p)) && !NOT_SITE_COPY.test(p.split("/").pop() ?? p) && !/(?:^|\/)(?:package(?:-lock)?|tsconfig[\w.-]*|art-direction)\.json$/.test(p);
|
|
273
|
+
var NOT_COPY_DIR = (name) => name.startsWith("_");
|
|
274
|
+
function run(argv, io) {
|
|
275
|
+
const [command, ...rest] = argv;
|
|
276
|
+
if (command === "direction") {
|
|
277
|
+
return import("./cli-KA3JDNDH.js").then((m) => m.runDirection(rest, io));
|
|
278
|
+
}
|
|
279
|
+
if (command === "report" || command === "retrofit") {
|
|
280
|
+
return import("./cli-LZ7R6KJM.js").then((m) => m.runReport(command, rest, io));
|
|
281
|
+
}
|
|
282
|
+
if (command === "estate") {
|
|
283
|
+
return import("./cli-SHLNRHK6.js").then((m) => m.runEstate(rest, io));
|
|
284
|
+
}
|
|
285
|
+
if (command === "null") {
|
|
286
|
+
return import("./cli-F7ONSKZS.js").then((m) => m.runNull(rest, io));
|
|
287
|
+
}
|
|
288
|
+
if (command === "tells" && rest[0] === "harvest") {
|
|
289
|
+
return import("./cli-F7ONSKZS.js").then((m) => m.runHarvest(rest, io)).catch((error) => {
|
|
290
|
+
io.err(`craft: ${error.message}`);
|
|
291
|
+
return 2;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (command === "snapshot" || command === "audit") {
|
|
295
|
+
return import("./cli-FKJOMOOF.js").then((m) => m.runAudit(command, rest, io));
|
|
296
|
+
}
|
|
297
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
298
|
+
io.out(HELP);
|
|
299
|
+
return command ? 0 : 2;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
if (command === "tells") {
|
|
303
|
+
const flags = parseFlags(rest);
|
|
304
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
305
|
+
if (flags.positional[0] !== "list") throw new Error("usage: craft tells list [--json] | craft tells harvest <null.json>...");
|
|
306
|
+
if (flags.json) {
|
|
307
|
+
const data = CATALOGUE.map(({ id, name, generation, surface, severity, why, fix }) => ({
|
|
308
|
+
id,
|
|
309
|
+
name,
|
|
310
|
+
generation,
|
|
311
|
+
surface,
|
|
312
|
+
severity,
|
|
313
|
+
why,
|
|
314
|
+
fix,
|
|
315
|
+
...surface === "copy" ? { house: houseRule(id, name).tier, houseLabel: houseRule(id, name).label } : {}
|
|
316
|
+
}));
|
|
317
|
+
io.out(JSON.stringify({ version: CATALOGUE_VERSION, tells: data }, null, 2));
|
|
318
|
+
} else {
|
|
319
|
+
io.out(`Catalogue ${CATALOGUE_VERSION}`);
|
|
320
|
+
for (const t of CATALOGUE) io.out(` ${t.id.padEnd(24)} gen ${t.generation} ${t.surface.padEnd(6)} ${t.name}`);
|
|
321
|
+
}
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (command === "copy" && rest[0] === "compare") {
|
|
325
|
+
const flags = parseFlags(rest.slice(1));
|
|
326
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
327
|
+
if (flags.positional.length !== 2) throw new Error("usage: craft copy compare <before> <after> [--json]");
|
|
328
|
+
const [before, after] = flags.positional.map((p) => {
|
|
329
|
+
const abs = resolve(io.cwd, p);
|
|
330
|
+
if (!existsSync(abs)) throw new Error(`no such path: ${p}`);
|
|
331
|
+
return visibleText({ path: p, text: readFileSync(abs, "utf8") });
|
|
332
|
+
});
|
|
333
|
+
const result = compareFacts(before, after);
|
|
334
|
+
io.out(flags.json ? JSON.stringify(result, null, 2) : formatComparison(result, flags.positional[0], flags.positional[1]));
|
|
335
|
+
return result.lost.length || result.added.length ? 1 : 0;
|
|
336
|
+
}
|
|
337
|
+
if (command === "scan" || command === "copy") {
|
|
338
|
+
const flags = parseFlags(rest);
|
|
339
|
+
if (typeof flags === "string") throw new Error(flags);
|
|
340
|
+
const wanted = command === "scan" ? SOURCE_FILE : COPY_FILE;
|
|
341
|
+
if (command === "copy" && flags.positional.length === 0 && !flags.staged) throw new Error("usage: craft copy <paths...>");
|
|
342
|
+
const files = flags.staged ? readStaged(io.cwd, wanted) : readPaths(flags.positional.length > 0 ? flags.positional : ["."], io.cwd, wanted, command === "copy" ? NOT_COPY_DIR : void 0);
|
|
343
|
+
const exceptions = loadExceptions(flags, io.cwd);
|
|
344
|
+
let report = command === "scan" ? scanSource(files, { exceptions }) : checkCopy(files, { exceptions });
|
|
345
|
+
if (command === "copy" && flags.gate) report = applyHouseGate(report);
|
|
346
|
+
return finish(report, flags, command === "scan" ? "craft scan" : "craft copy", io);
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`unknown command "${command}". Run craft --help.`);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
io.err(`craft: ${error.message}`);
|
|
351
|
+
return 2;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export {
|
|
356
|
+
parseFlags,
|
|
357
|
+
readPaths,
|
|
358
|
+
loadExceptions,
|
|
359
|
+
finish,
|
|
360
|
+
SOURCE_FILE,
|
|
361
|
+
COPY_FILE,
|
|
362
|
+
NOT_COPY_DIR,
|
|
363
|
+
run
|
|
364
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describeTypicality,
|
|
3
|
+
formatHarvest,
|
|
4
|
+
loadNull,
|
|
5
|
+
measureRun,
|
|
6
|
+
nullPath,
|
|
7
|
+
runHarvest,
|
|
8
|
+
runNull
|
|
9
|
+
} from "./chunk-K253ZXB3.js";
|
|
10
|
+
import "./chunk-X65RDW65.js";
|
|
11
|
+
import "./chunk-OGSYVCMU.js";
|
|
12
|
+
import "./chunk-CZHRNPDD.js";
|
|
13
|
+
export {
|
|
14
|
+
describeTypicality,
|
|
15
|
+
formatHarvest,
|
|
16
|
+
loadNull,
|
|
17
|
+
measureRun,
|
|
18
|
+
nullPath,
|
|
19
|
+
runHarvest,
|
|
20
|
+
runNull
|
|
21
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describeFingerprint,
|
|
3
|
+
merge,
|
|
4
|
+
runAudit
|
|
5
|
+
} from "./chunk-35OX6C4H.js";
|
|
6
|
+
import "./chunk-Q4MN3OXF.js";
|
|
7
|
+
import "./chunk-K253ZXB3.js";
|
|
8
|
+
import "./chunk-X65RDW65.js";
|
|
9
|
+
import "./chunk-OGSYVCMU.js";
|
|
10
|
+
import "./chunk-CZHRNPDD.js";
|
|
11
|
+
export {
|
|
12
|
+
describeFingerprint,
|
|
13
|
+
merge,
|
|
14
|
+
runAudit
|
|
15
|
+
};
|
|
@@ -1,43 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DIRECTION_VERSION,
|
|
3
|
+
validateDirection
|
|
4
|
+
} from "./chunk-ULYKASLA.js";
|
|
5
|
+
import {
|
|
6
|
+
parseFlags
|
|
7
|
+
} from "./chunk-X65RDW65.js";
|
|
1
8
|
import {
|
|
2
9
|
fingerprint
|
|
3
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-OGSYVCMU.js";
|
|
4
11
|
import {
|
|
5
|
-
REFLEX_FONTS_1,
|
|
6
|
-
REFLEX_FONTS_2,
|
|
7
12
|
deltaEOk,
|
|
8
13
|
formatHex,
|
|
9
14
|
hexToOklch,
|
|
10
15
|
isAiViolet,
|
|
11
16
|
isCream,
|
|
12
|
-
normaliseFamily,
|
|
13
17
|
oklchToRgb,
|
|
14
|
-
parseColour
|
|
15
|
-
|
|
16
|
-
} from "./chunk-GWMSSRIQ.js";
|
|
18
|
+
parseColour
|
|
19
|
+
} from "./chunk-CZHRNPDD.js";
|
|
17
20
|
|
|
18
21
|
// src/direction/cli.ts
|
|
19
22
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
20
23
|
import { dirname, join, resolve } from "path";
|
|
21
24
|
|
|
22
|
-
// src/direction/types.ts
|
|
23
|
-
var DIRECTION_VERSION = 1;
|
|
24
|
-
var SOURCE_KINDS = [
|
|
25
|
-
"livery",
|
|
26
|
-
"shopfront",
|
|
27
|
-
"signage",
|
|
28
|
-
"packaging",
|
|
29
|
-
"product",
|
|
30
|
-
"uniform",
|
|
31
|
-
"interior",
|
|
32
|
-
"place",
|
|
33
|
-
"material",
|
|
34
|
-
"trade",
|
|
35
|
-
"print",
|
|
36
|
-
"photo",
|
|
37
|
-
"founder"
|
|
38
|
-
];
|
|
39
|
-
var CHOICE_KEYS = ["accent", "ground", "display", "body", "shape", "motif", "signature"];
|
|
40
|
-
|
|
41
25
|
// src/direction/init.ts
|
|
42
26
|
var hex = (o) => {
|
|
43
27
|
const { r, g, b } = oklchToRgb(o);
|
|
@@ -254,148 +238,6 @@ function propose(input) {
|
|
|
254
238
|
};
|
|
255
239
|
}
|
|
256
240
|
|
|
257
|
-
// src/direction/validate.ts
|
|
258
|
-
var PREFERENCE = /\b(?:client (?:likes|liked|wants|wanted|asked|chose|prefers?)|(?:we|they|i) (?:like|liked|love|loved|prefer)|on[- ]trend|trendy|popular|what (?:everyone|competitors) (?:uses?|does)|brand (?:colou?r|font|guidelines?) (?:is|are|says?))\b/i;
|
|
259
|
-
var MOOD = /\b(?:modern|clean|fresh|professional|trustworthy|premium|elegant|sleek|timeless|minimal(?:ist)?|bold|vibrant|friendly|approachable|luxur(?:y|ious)|sophisticated|contemporary|stylish|warm|inviting|high[- ]end)\b/gi;
|
|
260
|
-
var VALUE_WORDS = /* @__PURE__ */ new Set(["green", "blue", "navy", "black", "white", "cream", "grey", "gray", "gold", "yellow", "orange", "purple", "violet", "pink", "brown", "teal", "bottle", "dark", "light", "deep", "pale", "bright", "colour", "color", "font", "face", "serif", "sans", "type", "lettering"]);
|
|
261
|
-
var STOP = /* @__PURE__ */ new Set(["with", "that", "this", "from", "their", "they", "have", "been", "were", "which", "what", "when", "where", "there", "about", "into", "onto", "over", "same", "each", "every"]);
|
|
262
|
-
function words(text) {
|
|
263
|
-
return new Set((text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).map((w) => w.replace(/'s$/, "")).filter((w) => !STOP.has(w) && !VALUE_WORDS.has(w)));
|
|
264
|
-
}
|
|
265
|
-
function tellFor(key, value) {
|
|
266
|
-
if (key === "accent") {
|
|
267
|
-
const c = parseColour(value);
|
|
268
|
-
return c && isAiViolet(c.oklch) ? "ai-violet" : null;
|
|
269
|
-
}
|
|
270
|
-
if (key === "ground") {
|
|
271
|
-
const c = parseColour(value);
|
|
272
|
-
return c && isCream(c.oklch) ? "cream-palette" : null;
|
|
273
|
-
}
|
|
274
|
-
if (key === "display" || key === "body") {
|
|
275
|
-
const name = normaliseFamily(value).toLowerCase();
|
|
276
|
-
if (REFLEX_FONTS_1.some((f) => f.toLowerCase() === name)) return "reflex-font";
|
|
277
|
-
if (REFLEX_FONTS_2.some((f) => f.toLowerCase() === name)) return "reflex-font-2";
|
|
278
|
-
}
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
function validateDirection(input, ctx = {}) {
|
|
282
|
-
const problems = [];
|
|
283
|
-
const err = (at, message) => problems.push({ severity: "error", at, message });
|
|
284
|
-
const warn = (at, message) => problems.push({ severity: "warn", at, message });
|
|
285
|
-
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
286
|
-
err("", "art-direction.json must be a JSON object");
|
|
287
|
-
return { valid: false, problems, decided: 0 };
|
|
288
|
-
}
|
|
289
|
-
const d = input;
|
|
290
|
-
const legacy = d.version === void 0 && d.choices === void 0 && Array.isArray(d.exceptions);
|
|
291
|
-
if (legacy) {
|
|
292
|
-
warn("", "exceptions only: run craft direction init to record the choices and their reasons");
|
|
293
|
-
return { valid: true, problems, decided: 0 };
|
|
294
|
-
}
|
|
295
|
-
if (d.version !== DIRECTION_VERSION) err("version", `version must be ${DIRECTION_VERSION}`);
|
|
296
|
-
if (typeof d.client !== "string" || !d.client.trim()) err("client", "name the client");
|
|
297
|
-
if (typeof d.brief !== "string" || d.brief.trim().split(/\s+/).length < 8) err("brief", "the brief needs a sentence: who, where, and what they do");
|
|
298
|
-
const sources = Array.isArray(d.sources) ? d.sources : [];
|
|
299
|
-
if (!Array.isArray(d.sources)) err("sources", "sources must be a list");
|
|
300
|
-
if (sources.length === 0) err("sources", "no sources: a reason needs something in the client's world to point at");
|
|
301
|
-
const ids = /* @__PURE__ */ new Set();
|
|
302
|
-
sources.forEach((s, i) => {
|
|
303
|
-
const at = `sources[${i}]`;
|
|
304
|
-
if (!s || typeof s !== "object") return err(at, "a source must be an object");
|
|
305
|
-
if (typeof s.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.id)) err(`${at}.id`, "id must be short kebab-case");
|
|
306
|
-
else if (ids.has(s.id)) err(`${at}.id`, `"${s.id}" is used twice`);
|
|
307
|
-
else ids.add(s.id);
|
|
308
|
-
if (!SOURCE_KINDS.includes(s.kind)) err(`${at}.kind`, `kind must be one of: ${SOURCE_KINDS.join(", ")}`);
|
|
309
|
-
if (typeof s.note !== "string" || s.note.trim().split(/\s+/).length < 3) err(`${at}.note`, "say what it is and where");
|
|
310
|
-
if (s.path && ctx.pathExists && !/^https?:/.test(s.path) && !ctx.pathExists(s.path)) err(`${at}.path`, `${s.path} does not exist`);
|
|
311
|
-
for (const [j, c] of (s.colours ?? []).entries()) if (!parseColour(c)) err(`${at}.colours[${j}]`, `"${c}" is not a colour`);
|
|
312
|
-
});
|
|
313
|
-
const choices = d.choices && typeof d.choices === "object" ? d.choices : {};
|
|
314
|
-
if (!d.choices || typeof d.choices !== "object") err("choices", "choices must be an object");
|
|
315
|
-
const exceptions = Array.isArray(d.exceptions) ? d.exceptions : [];
|
|
316
|
-
let decided = 0;
|
|
317
|
-
for (const key of Object.keys(choices)) {
|
|
318
|
-
if (!CHOICE_KEYS.includes(key)) err(`choices.${key}`, `not a choice craft knows. Choices are: ${CHOICE_KEYS.join(", ")}. Layout and navigation stay conventional.`);
|
|
319
|
-
}
|
|
320
|
-
for (const key of CHOICE_KEYS) {
|
|
321
|
-
const c = choices[key];
|
|
322
|
-
const at = `choices.${key}`;
|
|
323
|
-
if (!c) {
|
|
324
|
-
warn(at, "not decided yet");
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
327
|
-
let ok = true;
|
|
328
|
-
if (typeof c.value !== "string" || !c.value.trim()) {
|
|
329
|
-
err(`${at}.value`, "no value");
|
|
330
|
-
ok = false;
|
|
331
|
-
} else if ((key === "accent" || key === "ground") && !parseColour(c.value)) {
|
|
332
|
-
err(`${at}.value`, `"${c.value}" is not a colour`);
|
|
333
|
-
ok = false;
|
|
334
|
-
}
|
|
335
|
-
const because = typeof c.because === "string" ? c.because.trim() : "";
|
|
336
|
-
const evidence = Array.isArray(c.evidence) ? c.evidence : [];
|
|
337
|
-
if (because.split(/\s+/).filter(Boolean).length < 8) {
|
|
338
|
-
err(`${at}.because`, "a reason is at least a sentence. Without one this is a default, however good it looks.");
|
|
339
|
-
ok = false;
|
|
340
|
-
} else if (because.startsWith("PROPOSED:")) {
|
|
341
|
-
err(`${at}.because`, "a proposal, not yet a decision. Check it against the source, then rewrite the reason in your own words.");
|
|
342
|
-
ok = false;
|
|
343
|
-
} else {
|
|
344
|
-
if (PREFERENCE.test(because)) {
|
|
345
|
-
err(`${at}.because`, "that is a preference, not a reason. Say what in the client's world this comes from.");
|
|
346
|
-
ok = false;
|
|
347
|
-
}
|
|
348
|
-
const moods = because.match(MOOD) ?? [];
|
|
349
|
-
if (moods.length >= 2) {
|
|
350
|
-
err(`${at}.because`, `"${moods.join(", ")}" describes a mood, not a source. Name the thing it comes from.`);
|
|
351
|
-
ok = false;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (evidence.length === 0) {
|
|
355
|
-
err(`${at}.evidence`, "cite at least one source");
|
|
356
|
-
ok = false;
|
|
357
|
-
}
|
|
358
|
-
for (const id of evidence) {
|
|
359
|
-
if (!ids.has(id)) {
|
|
360
|
-
err(`${at}.evidence`, `no source called "${id}"`);
|
|
361
|
-
ok = false;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
if (ok) {
|
|
365
|
-
const cited = sources.filter((s) => evidence.includes(s.id));
|
|
366
|
-
const reasonWords = words(because);
|
|
367
|
-
const tied = cited.some((s) => reasonWords.has(s.id) || [...words(`${s.note} ${s.lettering ?? ""}`)].some((w) => reasonWords.has(w)));
|
|
368
|
-
if (!tied) {
|
|
369
|
-
err(`${at}.because`, `the reason does not mention what its evidence shows (${evidence.join(", ")}). Say what on it this comes from.`);
|
|
370
|
-
ok = false;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (typeof c.value === "string") {
|
|
374
|
-
const tell = tellFor(key, c.value);
|
|
375
|
-
if (tell && !exceptions.some((e) => e.tell === tell)) {
|
|
376
|
-
err(`${at}.value`, `${c.value} is on the tell catalogue (${tell}). Keep it only with an exception for ${tell}, carrying the same reason.`);
|
|
377
|
-
ok = false;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
const fp = ctx.fingerprint;
|
|
381
|
-
if (fp && typeof c.value === "string") {
|
|
382
|
-
if ((key === "accent" || key === "ground") && parseColour(c.value)) {
|
|
383
|
-
const declared = parseColour(c.value).oklch;
|
|
384
|
-
const measured = key === "accent" ? fp.accent : fp.ground;
|
|
385
|
-
if (measured && deltaEOk(declared, measured) > 0.08) {
|
|
386
|
-
warn(`${at}.value`, `the page does not show it: its ${key} measures oklch(${measured.l.toFixed(2)} ${measured.c.toFixed(3)} ${measured.h.toFixed(0)})`);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
if (key === "display" || key === "body") {
|
|
390
|
-
const measured = key === "display" ? fp.display.family : fp.body.family;
|
|
391
|
-
if (normaliseFamily(c.value).toLowerCase() !== measured.toLowerCase()) warn(`${at}.value`, `the page sets ${measured}, not ${c.value}`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (ok) decided += 1;
|
|
395
|
-
}
|
|
396
|
-
return { valid: problems.every((p) => p.severity !== "error"), problems, decided };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
241
|
// src/direction/cli.ts
|
|
400
242
|
var readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
|
401
243
|
function fingerprintFrom(path, cwd) {
|
|
@@ -404,6 +246,10 @@ function fingerprintFrom(path, cwd) {
|
|
|
404
246
|
function estateFrom(dir, cwd) {
|
|
405
247
|
if (!dir) return [];
|
|
406
248
|
const root = resolve(cwd, dir);
|
|
249
|
+
if (root.endsWith(".json")) {
|
|
250
|
+
const register = readJson(root);
|
|
251
|
+
return (register.sites ?? []).map((s) => ({ id: s.id, fingerprint: s.fingerprint }));
|
|
252
|
+
}
|
|
407
253
|
return readdirSync(root).filter((f) => f.endsWith(".json")).flatMap((f) => {
|
|
408
254
|
try {
|
|
409
255
|
const snap = readJson(join(root, f));
|