pixel-perfect-kit 0.1.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/CLONE-ALOYOGA-HEADER.md +76 -0
- package/CLONE-ANY-HEADER.md +106 -0
- package/CLONE-ANY-SITE.md +120 -0
- package/DEVELOP.md +144 -0
- package/LAUNCH-PROMPT.md +66 -0
- package/LEARNINGS.md +398 -0
- package/LICENSE +21 -0
- package/PLAYBOOK.md +260 -0
- package/README.md +219 -0
- package/WORKFLOW.md +257 -0
- package/bin/ppk +4 -0
- package/harness/adopt.js +52 -0
- package/harness/agent-setup.js +55 -0
- package/harness/behavior-selftest.js +243 -0
- package/harness/benchmarks/README.md +34 -0
- package/harness/benchmarks/battery.js +86 -0
- package/harness/benchmarks/detection-power.js +75 -0
- package/harness/capture-build-selftest.js +134 -0
- package/harness/capture-build.js +323 -0
- package/harness/doctor-selftest.js +58 -0
- package/harness/doctor.js +85 -0
- package/harness/fixtures/01-backdrop-color.js +51 -0
- package/harness/fixtures/02-line-strut.js +53 -0
- package/harness/fixtures/03-compat-mode.js +44 -0
- package/harness/fixtures/README.md +31 -0
- package/harness/human-qa-selftest.js +201 -0
- package/harness/human-qa.js +406 -0
- package/harness/merge-snapshot-selftest.js +56 -0
- package/harness/new-target.js +101 -0
- package/harness/regression.js +50 -0
- package/harness/score.js +58 -0
- package/harness/serve.js +149 -0
- package/harness/setup-selftest.js +93 -0
- package/harness/setup.js +158 -0
- package/harness/tunnel-selftest.js +76 -0
- package/harness/tunnel.js +241 -0
- package/harness/workflow-selftest.js +211 -0
- package/harness/workflow.js +739 -0
- package/package.json +40 -0
- package/skill/ditto-finish/SKILL.md +52 -0
- package/skill/pixel-perfect-clone/SKILL.md +49 -0
- package/tools/RUNBOOK.md +228 -0
- package/tools/behavior-capture.js +307 -0
- package/tools/behavior-worksheet.js +82 -0
- package/tools/browser-capture.js +240 -0
- package/tools/cli-selftest.js +136 -0
- package/tools/extract-fonts.js +133 -0
- package/tools/extract-icons.js +255 -0
- package/tools/merge-snapshot.js +56 -0
- package/tools/pixel-diff.js +845 -0
- package/tools/reassemble.js +63 -0
- package/tools/selftest.js +67 -0
- package/tools/sink.js +80 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli-selftest.js — guards the TOOL CONTRACTS of the kit's Node entry points.
|
|
4
|
+
*
|
|
5
|
+
* selftest.js guards what the diff *measures*; this guards how the tools *behave at their
|
|
6
|
+
* edges* — the Claude Code tool-contract properties: validate input, bound output, fail
|
|
7
|
+
* loudly with a self-describing, actionable message (never a raw stack), and use stable
|
|
8
|
+
* exit codes. Each assertion here is a bug we fixed; this file keeps it fixed.
|
|
9
|
+
*
|
|
10
|
+
* Runs against the real pixel-diff CLI (child process) and the pure, exported guards of
|
|
11
|
+
* serve.js / sink.js (no sockets). Exit 0 = green.
|
|
12
|
+
*/
|
|
13
|
+
"use strict";
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
const cp = require("child_process");
|
|
18
|
+
|
|
19
|
+
const PD = path.join(__dirname, "pixel-diff.js");
|
|
20
|
+
const { resolvePath } = require("../harness/serve.js");
|
|
21
|
+
const { classifyBody, sanitizeName } = require("./sink.js");
|
|
22
|
+
|
|
23
|
+
let failed = 0;
|
|
24
|
+
const ok = (cond, msg) => { if (cond) console.log(` ✓ ${msg}`); else { failed++; console.log(` ✗ ${msg}`); } };
|
|
25
|
+
|
|
26
|
+
// scratch dir in the OS temp area (hermetic)
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ppk-cli-"));
|
|
28
|
+
process.on("exit", () => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) {} });
|
|
29
|
+
const w = (f, s) => { const p = path.join(dir, f); fs.writeFileSync(p, s); return p; };
|
|
30
|
+
const snap = JSON.stringify({ viewport: { width: 1728 }, elements: { logo: { present: true, rect: { x: 0, y: 0, w: 10, h: 10, top: 0, right: 10, bottom: 10, fromRight: 0 } } } });
|
|
31
|
+
const run = (args) => { const r = cp.spawnSync(process.execPath, [PD, ...args], { encoding: "utf8" }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
|
|
32
|
+
|
|
33
|
+
const a = w("a.json", snap), b = w("b.json", snap);
|
|
34
|
+
|
|
35
|
+
console.log("cli-selftest — tool contracts (validate input, self-describing errors, exit codes)");
|
|
36
|
+
|
|
37
|
+
// pixel-diff: valid diff of identical snapshots exits 0
|
|
38
|
+
ok(run([a, b]).code === 0, "identical snapshots → exit 0");
|
|
39
|
+
|
|
40
|
+
// --tol now WORKS (regression: the flag value was being counted as a file)
|
|
41
|
+
{ const r = run([a, b, "--tol", "0.5"]); ok(r.code === 0 && !/usage/.test(r.out), "--tol 0.5 is parsed, not treated as a file"); }
|
|
42
|
+
// a bad --tol value is rejected with a clear message
|
|
43
|
+
{ const r = run([a, b, "--tol", "abc"]); ok(r.code === 2 && /--tol needs a non-negative number/.test(r.out), "--tol abc → exit 2 with an actionable message"); }
|
|
44
|
+
// unknown flags are rejected, not silently ignored
|
|
45
|
+
{ const r = run([a, b, "--nope"]); ok(r.code === 2 && /unknown flag/.test(r.out), "unknown flag → exit 2"); }
|
|
46
|
+
|
|
47
|
+
// missing file → self-describing error (not a raw ENOENT stack), exit 2
|
|
48
|
+
{ const r = run([a, path.join(dir, "nope.json")]); ok(r.code === 2 && /not found/.test(r.out) && !/at Object\./.test(r.out), "missing file → 'not found', no stack trace"); }
|
|
49
|
+
// invalid JSON → self-describing error (not a raw SyntaxError stack)
|
|
50
|
+
{ const bad = w("bad.json", "not json{"); const r = run([a, bad]); ok(r.code === 2 && /not valid JSON/.test(r.out) && !/SyntaxError\b.*\n\s+at /.test(r.out), "invalid JSON → 'not valid JSON', no stack trace"); }
|
|
51
|
+
// a [BLOCKED…] automation sentinel is caught, not diffed as data
|
|
52
|
+
{ const blk = w("blk.json", "[BLOCKED: content filter]"); const r = run([a, blk]); ok(r.code === 2 && /BLOCKED/.test(r.out), "[BLOCKED…] sentinel → exit 2 with guidance"); }
|
|
53
|
+
// wrong shape (no .elements) without --inspect is a clear mix-up error
|
|
54
|
+
{ const noEl = w("noel.json", JSON.stringify({ viewport: { width: 1728 } })); const r = run([a, noEl]); ok(r.code === 2 && /no "elements"/.test(r.out), "snapshot without elements → clear mix-up error"); }
|
|
55
|
+
// wrong count of files → exit 2 + usage
|
|
56
|
+
{ const r = run([a]); ok(r.code === 2 && /expected 2 snapshot files/.test(r.out), "one file → exit 2 with usage"); }
|
|
57
|
+
|
|
58
|
+
// serve.js path-boundary guard (pure): legit paths resolve, traversal + prefix-siblings rejected
|
|
59
|
+
{
|
|
60
|
+
const roots = { cloneDir: "/x/clone", toolsDir: "/x/tools" };
|
|
61
|
+
ok(resolvePath("/", roots) === path.resolve("/x/clone", "index.html"), "'/' → clone/index.html");
|
|
62
|
+
ok(resolvePath("/styles.css", roots) === path.resolve("/x/clone", "styles.css"), "in-root file resolves");
|
|
63
|
+
ok(resolvePath("/tools/browser-capture.js", roots) === path.resolve("/x/tools", "browser-capture.js"), "/tools/* → tools dir");
|
|
64
|
+
ok(resolvePath("/../secret", roots) === null, "../ traversal rejected");
|
|
65
|
+
ok(resolvePath("/tools/../../etc/passwd", roots) === null, "nested ../ traversal rejected");
|
|
66
|
+
ok(resolvePath("/%", roots) === null, "malformed percent-encoding returns null instead of throwing (server survives)");
|
|
67
|
+
ok(resolvePath("/%zz", roots) === null, "invalid escape sequence returns null instead of throwing");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// sink.js body guard (pure): empty / oversize / [BLOCKED] refused; non-JSON warned; good JSON ok
|
|
71
|
+
ok(classifyBody("clone.json", "").write === false && classifyBody("clone.json", "").status === 400, "empty body refused (400)");
|
|
72
|
+
ok(classifyBody("clone.json", "[BLOCKED: x]").write === false, "[BLOCKED…] body refused");
|
|
73
|
+
ok(classifyBody("clone.json", "x".repeat(21 * 1024 * 1024)).status === 413, "oversize body refused (413)");
|
|
74
|
+
{ const r = classifyBody("clone.json", "not json"); ok(r.write === true && /NOT valid JSON/.test(r.message), "non-JSON under .json name is written but warned"); }
|
|
75
|
+
ok(classifyBody("clone.json", snap).status === 200 && classifyBody("clone.json", snap).write === true, "valid JSON snapshot accepted");
|
|
76
|
+
ok(sanitizeName("/a/b/clone.json").renamed === true, "sub-path name flagged as sanitized (silent-rename footgun surfaced)");
|
|
77
|
+
{ const r = sanitizeName("/clone.json?t=1"); ok(r.clean === "clone.json" && r.renamed === false, "query string is stripped, not fused into the filename"); }
|
|
78
|
+
|
|
79
|
+
// reassemble.js — chunk joining is a VERIFIED operation (the HN dogfood run corrupted a
|
|
80
|
+
// snapshot by hand-concatenating chunks; these lock the validator that prevents it).
|
|
81
|
+
{
|
|
82
|
+
const RA = path.join(__dirname, "reassemble.js");
|
|
83
|
+
const runRA = (args) => { const r = cp.spawnSync(process.execPath, [RA, ...args], { encoding: "utf8", cwd: dir }); return { code: r.status, out: (r.stdout || "") + (r.stderr || "") }; };
|
|
84
|
+
const c0 = w("chunk-00.txt", snap.slice(0, 40));
|
|
85
|
+
const c1 = w("chunk-01.txt", snap.slice(40));
|
|
86
|
+
const outFile = path.join(dir, "reassembled.json");
|
|
87
|
+
{ const r = runRA([outFile, "--bytes", String(snap.length), c0, c1]); ok(r.code === 0 && fs.existsSync(outFile) && fs.readFileSync(outFile, "utf8") === snap, "valid chunks + matching byte count reassemble to the exact snapshot"); }
|
|
88
|
+
{ const r = runRA([outFile, "--bytes", String(snap.length + 7), c0, c1]); ok(r.code === 2 && /byte count mismatch/.test(r.out), "byte-count mismatch → exit 2 naming the missing bytes"); }
|
|
89
|
+
// drop a QUOTE at the boundary → structurally invalid JSON, caught by the parse check
|
|
90
|
+
{ const qi = snap.indexOf('"', 10); const cbad = w("chunk-01bad.txt", snap.slice(qi + 1)); const r = runRA([outFile, w("chunk-00q.txt", snap.slice(0, qi)), cbad]); ok(r.code === 2 && /not valid JSON/.test(r.out), "corrupt chunk boundary (dropped quote) → exit 2, nothing written silently"); }
|
|
91
|
+
// drop a LETTER at the boundary → still-parseable-but-wrong JSON (the real dogfood bug);
|
|
92
|
+
// only the byte count catches this class — which is why --bytes must always be passed
|
|
93
|
+
{ const r = runRA([outFile, "--bytes", String(snap.length), w("chunk-00l.txt", snap.slice(0, 40)), w("chunk-01l.txt", snap.slice(41))]); ok(r.code === 2 && /byte count mismatch/.test(r.out), "dropped-letter corruption (valid JSON, wrong data) → caught by --bytes"); }
|
|
94
|
+
{ const r = runRA([outFile]); ok(r.code === 2 && /usage/.test(r.out), "no chunk files → exit 2 with usage"); }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// serve.js port collision → actionable error, not a raw stack
|
|
98
|
+
{
|
|
99
|
+
const holder = cp.spawn(process.execPath, ["-e", "require('http').createServer(() => {}).listen(18094, () => console.log('up')); setTimeout(() => {}, 30000)"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
100
|
+
try {
|
|
101
|
+
let up = false;
|
|
102
|
+
for (let i = 0; i < 30 && !up; i++) { const ping = cp.spawnSync("curl", ["-s", "-o", "/dev/null", "--max-time", "1", "http://127.0.0.1:18094/"], {}); if (ping.status === 0 || ping.status === 52) up = true; else cp.spawnSync(process.execPath, ["-e", "setTimeout(() => {}, 100)"]); }
|
|
103
|
+
fs.mkdirSync(path.join(dir, "targets", "col", "clone"), { recursive: true });
|
|
104
|
+
const r = cp.spawnSync(process.execPath, [path.join(__dirname, "..", "harness", "serve.js"), "col", "18094"], { encoding: "utf8", cwd: dir, timeout: 5000 });
|
|
105
|
+
const out2 = (r.stdout || "") + (r.stderr || "");
|
|
106
|
+
ok(r.status === 1 && /already in use/.test(out2) && !/at Server\./.test(out2), "serve on a taken port → 'already in use' + exit 1, no stack trace");
|
|
107
|
+
} finally { holder.kill(); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// sink streaming abort (real server, tiny limit via env): the size bound must hold DURING
|
|
111
|
+
// the stream, not just at end — the aborted body must never reach disk.
|
|
112
|
+
{
|
|
113
|
+
const port = 17799;
|
|
114
|
+
const sink = cp.spawn(process.execPath, [path.join(__dirname, "sink.js")], {
|
|
115
|
+
cwd: dir,
|
|
116
|
+
env: { ...process.env, PPK_SINK_PORT: String(port), PPK_SINK_MAX_BYTES: "1000" },
|
|
117
|
+
stdio: "ignore",
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
let up = false;
|
|
121
|
+
for (let i = 0; i < 50 && !up; i++) {
|
|
122
|
+
const ping = cp.spawnSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "OPTIONS", `http://127.0.0.1:${port}/ping`], { encoding: "utf8" });
|
|
123
|
+
if (ping.stdout === "200") up = true;
|
|
124
|
+
else cp.spawnSync(process.execPath, ["-e", "setTimeout(() => {}, 100)"]);
|
|
125
|
+
}
|
|
126
|
+
ok(up, "sink starts on an env-configured port");
|
|
127
|
+
const r = cp.spawnSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--data-binary", "x".repeat(5000), `http://127.0.0.1:${port}/big.json`], { encoding: "utf8" });
|
|
128
|
+
ok(r.stdout === "413", `oversize body is aborted mid-stream with 413 (got ${r.stdout || "no response"})`);
|
|
129
|
+
ok(!fs.existsSync(path.join(dir, "big.json")), "aborted oversize body is never written to disk");
|
|
130
|
+
} finally {
|
|
131
|
+
sink.kill();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(failed ? `\n❌ cli-selftest: ${failed} assertion(s) failed — a tool contract regressed.` : `\n✓ cli-selftest: tool contracts hold (validated input, self-describing errors, safe paths).`);
|
|
136
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract-fonts.js — reusable web-font extractor
|
|
3
|
+
* ------------------------------------------------
|
|
4
|
+
* Paste this whole file into the DevTools Console on ANY site (or run it via a
|
|
5
|
+
* browser-automation `javascript_tool`). It:
|
|
6
|
+
* 1. Reads every @font-face rule in the page's stylesheets.
|
|
7
|
+
* 2. Fetches each font binary from its real (CORS-permitting) URL.
|
|
8
|
+
* 3. Bundles them into ONE zip (single download — dodges Chrome's
|
|
9
|
+
* "multiple automatic downloads" block) named "<host>-fonts.zip".
|
|
10
|
+
* 4. Also drops a ready-to-use fonts.css into the same zip.
|
|
11
|
+
*
|
|
12
|
+
* Filter which families to grab with the optional argument:
|
|
13
|
+
* extractFonts() // all families
|
|
14
|
+
* extractFonts(/proxima|arquitecta/i) // only matching families
|
|
15
|
+
*
|
|
16
|
+
* Notes:
|
|
17
|
+
* - Only fonts whose CDN allows cross-origin fetch (most do, incl. Typekit)
|
|
18
|
+
* will download; others are reported as errors but don't stop the run.
|
|
19
|
+
* - These are usually licensed fonts. Use for local testing only; don't
|
|
20
|
+
* redistribute or ship on a public site without a license.
|
|
21
|
+
*/
|
|
22
|
+
async function extractFonts(familyFilter = null) {
|
|
23
|
+
// ---- minimal store-mode ZIP writer (no deps) ----
|
|
24
|
+
const crcTable = (() => {
|
|
25
|
+
const t = [];
|
|
26
|
+
for (let n = 0; n < 256; n++) {
|
|
27
|
+
let c = n;
|
|
28
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
29
|
+
t[n] = c >>> 0;
|
|
30
|
+
}
|
|
31
|
+
return t;
|
|
32
|
+
})();
|
|
33
|
+
const crc32 = (u8) => {
|
|
34
|
+
let c = 0xffffffff;
|
|
35
|
+
for (let i = 0; i < u8.length; i++) c = crcTable[(c ^ u8[i]) & 0xff] ^ (c >>> 8);
|
|
36
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
37
|
+
};
|
|
38
|
+
const u16 = (n) => [n & 255, (n >> 8) & 255];
|
|
39
|
+
const u32 = (n) => [n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >> 24) & 255];
|
|
40
|
+
|
|
41
|
+
function buildZip(files) {
|
|
42
|
+
const enc = new TextEncoder();
|
|
43
|
+
const chunks = [];
|
|
44
|
+
const central = [];
|
|
45
|
+
let offset = 0;
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
const nb = enc.encode(f.name);
|
|
48
|
+
const crc = crc32(f.data);
|
|
49
|
+
const sz = f.data.length;
|
|
50
|
+
const head = [0x50, 0x4b, 3, 4, ...u16(20), ...u16(0), ...u16(0), ...u16(0),
|
|
51
|
+
...u16(0), ...u32(crc), ...u32(sz), ...u32(sz), ...u16(nb.length), ...u16(0)];
|
|
52
|
+
const arr = new Uint8Array(head.length + nb.length + sz);
|
|
53
|
+
arr.set(head, 0); arr.set(nb, head.length); arr.set(f.data, head.length + nb.length);
|
|
54
|
+
chunks.push(arr);
|
|
55
|
+
central.push({ nb, crc, sz, offset });
|
|
56
|
+
offset += arr.length;
|
|
57
|
+
}
|
|
58
|
+
const cd = [];
|
|
59
|
+
let cdSize = 0;
|
|
60
|
+
for (const c of central) {
|
|
61
|
+
const rec = [0x50, 0x4b, 1, 2, ...u16(20), ...u16(20), ...u16(0), ...u16(0),
|
|
62
|
+
...u16(0), ...u16(0), ...u32(c.crc), ...u32(c.sz), ...u32(c.sz),
|
|
63
|
+
...u16(c.nb.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
|
|
64
|
+
...u32(0), ...u32(c.offset)];
|
|
65
|
+
const arr = new Uint8Array(rec.length + c.nb.length);
|
|
66
|
+
arr.set(rec, 0); arr.set(c.nb, rec.length);
|
|
67
|
+
cd.push(arr); cdSize += arr.length;
|
|
68
|
+
}
|
|
69
|
+
const eocd = new Uint8Array([0x50, 0x4b, 5, 6, ...u16(0), ...u16(0),
|
|
70
|
+
...u16(files.length), ...u16(files.length), ...u32(cdSize), ...u32(offset), ...u16(0)]);
|
|
71
|
+
return new Blob([...chunks, ...cd, eocd], { type: 'application/zip' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- 1. collect @font-face rules ----
|
|
75
|
+
const faces = [];
|
|
76
|
+
for (const sheet of document.styleSheets) {
|
|
77
|
+
let rules;
|
|
78
|
+
try { rules = sheet.cssRules; } catch (e) { continue; } // skip cross-origin sheets
|
|
79
|
+
if (!rules) continue;
|
|
80
|
+
for (const r of rules) {
|
|
81
|
+
if (!(r.constructor && r.constructor.name === 'CSSFontFaceRule')) continue;
|
|
82
|
+
const family = (r.style.getPropertyValue('font-family') || '').replace(/["']/g, '').trim();
|
|
83
|
+
if (familyFilter && !familyFilter.test(family)) continue;
|
|
84
|
+
const weight = r.style.getPropertyValue('font-weight') || '400';
|
|
85
|
+
const style = r.style.getPropertyValue('font-style') || 'normal';
|
|
86
|
+
const src = r.style.getPropertyValue('src') || '';
|
|
87
|
+
// prefer woff2, fall back to first url()
|
|
88
|
+
const m = src.match(/url\(["']?([^"')]+)["']?\)\s*format\(["']?woff2["']?\)/i)
|
|
89
|
+
|| src.match(/url\(["']?([^"')]+)["']?\)/i);
|
|
90
|
+
if (!m) continue;
|
|
91
|
+
const ext = (m[1].match(/\.(woff2|woff|ttf|otf)(?:[?#]|$)/i) || [, 'woff2'])[1].toLowerCase();
|
|
92
|
+
const safeFam = (family || 'font').replace(/[^a-z0-9]+/gi, '-').toLowerCase();
|
|
93
|
+
faces.push({
|
|
94
|
+
family, weight, style, url: m[1],
|
|
95
|
+
name: `${safeFam}-${weight}-${style}.${ext}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!faces.length) { console.warn('No matching @font-face rules found.'); return; }
|
|
100
|
+
|
|
101
|
+
// ---- 2. fetch binaries ----
|
|
102
|
+
const files = [];
|
|
103
|
+
const errors = [];
|
|
104
|
+
for (const f of faces) {
|
|
105
|
+
try {
|
|
106
|
+
const buf = await (await fetch(f.url, { mode: 'cors' })).arrayBuffer();
|
|
107
|
+
files.push({ name: f.name, data: new Uint8Array(buf), face: f });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
errors.push(`${f.name}: ${e.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- 3. generate fonts.css ----
|
|
114
|
+
const css = files.map(({ face, name }) =>
|
|
115
|
+
`@font-face {\n font-family: "${face.family}";\n font-weight: ${face.weight};\n font-style: ${face.style};\n src: url("./${name}") format("woff2");\n font-display: swap;\n}`
|
|
116
|
+
).join('\n\n') + '\n';
|
|
117
|
+
files.push({ name: 'fonts.css', data: new TextEncoder().encode(css) });
|
|
118
|
+
|
|
119
|
+
// ---- 4. single download ----
|
|
120
|
+
const host = location.hostname.replace(/^www\./, '');
|
|
121
|
+
const blob = buildZip(files);
|
|
122
|
+
const a = document.createElement('a');
|
|
123
|
+
a.href = URL.createObjectURL(blob);
|
|
124
|
+
a.download = `${host}-fonts.zip`;
|
|
125
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
126
|
+
|
|
127
|
+
console.log(`✅ ${files.length - 1} font(s) zipped → ${a.download} (${blob.size} bytes)`);
|
|
128
|
+
if (errors.length) console.warn('⚠️ skipped (CORS/other):\n' + errors.join('\n'));
|
|
129
|
+
return { downloaded: files.length - 1, errors };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Auto-run when pasted directly; comment out if you only want the function defined.
|
|
133
|
+
extractFonts();
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract-icons.js — reusable web-icon extractor (sibling of fonts/extract-fonts.js)
|
|
3
|
+
* ----------------------------------------------------------------------------------
|
|
4
|
+
* Paste this whole file into the DevTools Console on ANY site (or run it via a
|
|
5
|
+
* browser-automation `javascript_tool`). It finds icons however the site ships
|
|
6
|
+
* them and bundles them into ONE zip (single download — dodges Chrome's
|
|
7
|
+
* "multiple automatic downloads" block) named "<host>-icons.zip":
|
|
8
|
+
*
|
|
9
|
+
* icons/<name>.svg one standalone SVG per icon (inline <svg>, sprite <use>,
|
|
10
|
+
* data-URI background/mask, <img src=*.svg>)
|
|
11
|
+
* icons.css ready-to-use classes; CSS-background icons keep their
|
|
12
|
+
* EXACT original data-URI so they render byte-identically
|
|
13
|
+
* preview.html a gallery of everything captured, with names
|
|
14
|
+
* report.json what was captured, by which method, plus anything skipped
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* extractIcons() // whole page
|
|
18
|
+
* extractIcons('header') // limit to a CSS selector (scope)
|
|
19
|
+
* extractIcons('header', /22|24/)// scope + only icons whose viewBox/size matches
|
|
20
|
+
*
|
|
21
|
+
* Notes:
|
|
22
|
+
* - Icon FONTS (Font Awesome, Material Icons, etc.) can't be turned into SVGs
|
|
23
|
+
* here — they're glyphs in a webfont. They're listed in report.json; grab the
|
|
24
|
+
* font itself with fonts/extract-fonts.js.
|
|
25
|
+
* - External (cross-origin) SVG URLs are fetched best-effort; CORS failures are
|
|
26
|
+
* recorded in report.json rather than aborting the run.
|
|
27
|
+
*/
|
|
28
|
+
async function extractIcons(scope = null, filter = null) {
|
|
29
|
+
const root = scope ? document.querySelector(scope) || document.body : document.body;
|
|
30
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
31
|
+
|
|
32
|
+
// ---------- minimal store-mode ZIP writer (no deps) ----------
|
|
33
|
+
const crcTable = (() => {
|
|
34
|
+
const t = [];
|
|
35
|
+
for (let n = 0; n < 256; n++) {
|
|
36
|
+
let c = n;
|
|
37
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
38
|
+
t[n] = c >>> 0;
|
|
39
|
+
}
|
|
40
|
+
return t;
|
|
41
|
+
})();
|
|
42
|
+
const crc32 = (u8) => {
|
|
43
|
+
let c = 0xffffffff;
|
|
44
|
+
for (let i = 0; i < u8.length; i++) c = crcTable[(c ^ u8[i]) & 0xff] ^ (c >>> 8);
|
|
45
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
46
|
+
};
|
|
47
|
+
const u16 = (n) => [n & 255, (n >> 8) & 255];
|
|
48
|
+
const u32 = (n) => [n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >> 24) & 255];
|
|
49
|
+
function buildZip(files) {
|
|
50
|
+
const enc = new TextEncoder();
|
|
51
|
+
const chunks = [], central = [];
|
|
52
|
+
let offset = 0;
|
|
53
|
+
for (const f of files) {
|
|
54
|
+
const data = typeof f.data === "string" ? enc.encode(f.data) : f.data;
|
|
55
|
+
const nb = enc.encode(f.name), crc = crc32(data), sz = data.length;
|
|
56
|
+
const head = [0x50, 0x4b, 3, 4, ...u16(20), ...u16(0), ...u16(0), ...u16(0),
|
|
57
|
+
...u16(0), ...u32(crc), ...u32(sz), ...u32(sz), ...u16(nb.length), ...u16(0)];
|
|
58
|
+
const arr = new Uint8Array(head.length + nb.length + sz);
|
|
59
|
+
arr.set(head, 0); arr.set(nb, head.length); arr.set(data, head.length + nb.length);
|
|
60
|
+
chunks.push(arr); central.push({ nb, crc, sz, offset }); offset += arr.length;
|
|
61
|
+
}
|
|
62
|
+
const cd = []; let cdSize = 0;
|
|
63
|
+
for (const c of central) {
|
|
64
|
+
const rec = [0x50, 0x4b, 1, 2, ...u16(20), ...u16(20), ...u16(0), ...u16(0),
|
|
65
|
+
...u16(0), ...u16(0), ...u32(c.crc), ...u32(c.sz), ...u32(c.sz),
|
|
66
|
+
...u16(c.nb.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(c.offset)];
|
|
67
|
+
const arr = new Uint8Array(rec.length + c.nb.length);
|
|
68
|
+
arr.set(rec, 0); arr.set(c.nb, rec.length); cd.push(arr); cdSize += arr.length;
|
|
69
|
+
}
|
|
70
|
+
const eocd = new Uint8Array([0x50, 0x4b, 5, 6, ...u16(0), ...u16(0),
|
|
71
|
+
...u16(files.length), ...u16(files.length), ...u32(cdSize), ...u32(offset), ...u16(0)]);
|
|
72
|
+
return new Blob([...chunks, ...cd, eocd], { type: "application/zip" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- helpers ----------
|
|
76
|
+
const used = new Set();
|
|
77
|
+
const slug = (s) =>
|
|
78
|
+
(s || "icon").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "icon";
|
|
79
|
+
const uniqName = (base) => {
|
|
80
|
+
let n = slug(base), i = 2;
|
|
81
|
+
while (used.has(n)) n = `${slug(base)}-${i++}`;
|
|
82
|
+
used.add(n);
|
|
83
|
+
return n;
|
|
84
|
+
};
|
|
85
|
+
// best label for an element: aria-label > title > icon-ish class > tag
|
|
86
|
+
const labelFor = (el) => {
|
|
87
|
+
const a = el.closest("a,button,[aria-label],[title]");
|
|
88
|
+
const lbl = a?.getAttribute("aria-label") || a?.getAttribute("title") || el.getAttribute("aria-label");
|
|
89
|
+
if (lbl) return lbl;
|
|
90
|
+
const cls = [...el.classList].find((c) => /icon|ico|svg|glyph/i.test(c));
|
|
91
|
+
if (cls) return cls.replace(/icon|ico|static|svg/gi, "");
|
|
92
|
+
return el.tagName.toLowerCase();
|
|
93
|
+
};
|
|
94
|
+
const visible = (el) => {
|
|
95
|
+
const r = el.getBoundingClientRect();
|
|
96
|
+
return r.width > 0 && r.height > 0 && r.width <= 200 && r.height <= 200;
|
|
97
|
+
};
|
|
98
|
+
// normalize an svg string for dedup
|
|
99
|
+
const norm = (s) => s.replace(/\s+/g, " ").replace(/>\s+</g, "><").trim();
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
|
|
102
|
+
const svgFiles = []; // {name, svg}
|
|
103
|
+
const cssIcons = []; // {name, w, h, bg} (background/mask data-URIs kept verbatim)
|
|
104
|
+
const report = { host: location.hostname, captured: [], skipped: [] };
|
|
105
|
+
|
|
106
|
+
function pushSvg(svgStr, label, method) {
|
|
107
|
+
if (filter && !filter.test(`${label} ${svgStr}`)) return null; // optional viewBox/size/name filter
|
|
108
|
+
const n = norm(svgStr);
|
|
109
|
+
if (seen.has(n)) return null;
|
|
110
|
+
seen.add(n);
|
|
111
|
+
const name = uniqName(label);
|
|
112
|
+
svgFiles.push({ name: `icons/${name}.svg`, svg: svgStr });
|
|
113
|
+
report.captured.push({ name, method });
|
|
114
|
+
return name;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- 1. inline <svg> (skip the giant logo? no — keep everything small) ----------
|
|
118
|
+
for (const svg of root.querySelectorAll("svg")) {
|
|
119
|
+
if (!visible(svg)) continue;
|
|
120
|
+
if (svg.querySelector("use")) continue; // handled by sprite pass
|
|
121
|
+
const clone = svg.cloneNode(true);
|
|
122
|
+
if (!clone.getAttribute("xmlns")) clone.setAttribute("xmlns", SVG_NS);
|
|
123
|
+
pushSvg(clone.outerHTML, labelFor(svg), "inline-svg");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- 2. sprite <use href="#id"> ----------
|
|
127
|
+
for (const use of root.querySelectorAll("use")) {
|
|
128
|
+
const svg = use.closest("svg");
|
|
129
|
+
if (!svg || !visible(svg)) continue;
|
|
130
|
+
const href = use.getAttribute("href") || use.getAttribute("xlink:href") || "";
|
|
131
|
+
const id = href.startsWith("#") ? href.slice(1) : href.split("#")[1];
|
|
132
|
+
if (!id) continue;
|
|
133
|
+
const sym = document.getElementById(id);
|
|
134
|
+
if (!sym) { report.skipped.push({ label: labelFor(svg), reason: `sprite ref #${id} not found in DOM` }); continue; }
|
|
135
|
+
const vb = sym.getAttribute("viewBox") || svg.getAttribute("viewBox") || "0 0 24 24";
|
|
136
|
+
const out = `<svg xmlns="${SVG_NS}" viewBox="${vb}">${sym.innerHTML}</svg>`;
|
|
137
|
+
pushSvg(out, labelFor(svg), "sprite-use");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------- 3. CSS background-image / mask-image (data-URI or external) ----------
|
|
141
|
+
// strips the FULL data-URI prefix up to the first comma (handles
|
|
142
|
+
// `data:image/svg+xml,`, `;charset=utf-8,`, `;base64,`, etc.)
|
|
143
|
+
const decodeDataSvg = (uri) => {
|
|
144
|
+
const comma = uri.indexOf(",");
|
|
145
|
+
if (comma < 0) return null;
|
|
146
|
+
const meta = uri.slice(0, comma), body = uri.slice(comma + 1);
|
|
147
|
+
if (/;base64/i.test(meta)) { try { return atob(body); } catch { return null; } }
|
|
148
|
+
try { return decodeURIComponent(body); } catch { return body; }
|
|
149
|
+
};
|
|
150
|
+
for (const el of root.querySelectorAll("*")) {
|
|
151
|
+
if (!visible(el)) continue;
|
|
152
|
+
const cs = getComputedStyle(el);
|
|
153
|
+
const mask = cs.maskImage && cs.maskImage !== "none" ? cs.maskImage
|
|
154
|
+
: cs.webkitMaskImage && cs.webkitMaskImage !== "none" ? cs.webkitMaskImage : null;
|
|
155
|
+
const pick = mask || (cs.backgroundImage !== "none" ? cs.backgroundImage : null);
|
|
156
|
+
if (!pick) continue;
|
|
157
|
+
const m = pick.match(/url\((['"]?)(.*?)\1\)/s);
|
|
158
|
+
if (!m) continue;
|
|
159
|
+
const uri = m[2];
|
|
160
|
+
const r = el.getBoundingClientRect();
|
|
161
|
+
const w = Math.round(r.width), h = Math.round(r.height);
|
|
162
|
+
const label = labelFor(el);
|
|
163
|
+
|
|
164
|
+
if (uri.startsWith("data:image/svg")) {
|
|
165
|
+
const svg = decodeDataSvg(uri);
|
|
166
|
+
if (svg && svg.includes("<svg")) {
|
|
167
|
+
const name = pushSvg(svg, label, mask ? "css-mask" : "css-background");
|
|
168
|
+
// keep the exact original URI for a pixel-perfect CSS class too
|
|
169
|
+
if (name) cssIcons.push({ name, w: w || null, h: h || null, bg: pick, recolor: mask ? cs.backgroundColor : null });
|
|
170
|
+
}
|
|
171
|
+
} else if (/\.svg(\?|#|$)/i.test(uri)) {
|
|
172
|
+
// external svg — best-effort fetch
|
|
173
|
+
try {
|
|
174
|
+
const txt = await (await fetch(uri, { mode: "cors" })).text();
|
|
175
|
+
if (txt.includes("<svg")) {
|
|
176
|
+
const name = pushSvg(txt, label, "external-svg");
|
|
177
|
+
if (name) cssIcons.push({ name, w: w || null, h: h || null, bg: `url("./icons/${name}.svg")` });
|
|
178
|
+
}
|
|
179
|
+
} catch (e) { report.skipped.push({ label, reason: "external svg fetch failed (CORS)" }); }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------- 4. <img src="*.svg"> ----------
|
|
184
|
+
for (const img of root.querySelectorAll("img")) {
|
|
185
|
+
if (!visible(img)) continue;
|
|
186
|
+
const src = img.currentSrc || img.src || "";
|
|
187
|
+
if (!/\.svg(\?|#|$)/i.test(src) && !src.startsWith("data:image/svg")) continue;
|
|
188
|
+
try {
|
|
189
|
+
let txt;
|
|
190
|
+
if (src.startsWith("data:image/svg")) txt = decodeDataSvg(src);
|
|
191
|
+
else txt = await (await fetch(src, { mode: "cors" })).text();
|
|
192
|
+
if (txt && txt.includes("<svg")) pushSvg(txt, labelFor(img) || img.alt, "img-svg");
|
|
193
|
+
} catch (e) { report.skipped.push({ label: img.alt || "img", reason: "img svg fetch failed (CORS)" }); }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------- 5. detect (but can't extract) icon fonts ----------
|
|
197
|
+
const iconFontEls = new Set();
|
|
198
|
+
for (const el of root.querySelectorAll("*")) {
|
|
199
|
+
const fam = getComputedStyle(el).fontFamily || "";
|
|
200
|
+
if (/font ?awesome|material icons|material symbols|ionicons|glyphicon|feather|remixicon/i.test(fam)) {
|
|
201
|
+
iconFontEls.add(fam.split(",")[0].replace(/["']/g, "").trim());
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (iconFontEls.size)
|
|
205
|
+
report.skipped.push({ label: [...iconFontEls].join(", "), reason: "icon font — extract the webfont with extract-fonts.js instead" });
|
|
206
|
+
|
|
207
|
+
if (!svgFiles.length) {
|
|
208
|
+
console.warn("No extractable icons found on this page.", report);
|
|
209
|
+
return report;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------- assemble icons.css ----------
|
|
213
|
+
let css = "/* " + location.hostname + " icons — generated by extract-icons.js */\n";
|
|
214
|
+
css += ".icon{display:inline-block;background-repeat:no-repeat;background-position:center;background-size:contain}\n";
|
|
215
|
+
for (const ic of cssIcons) {
|
|
216
|
+
const dims = (ic.w ? `width:${ic.w}px;` : "") + (ic.h ? `height:${ic.h}px;` : "");
|
|
217
|
+
const recolor = ic.recolor && ic.recolor !== "rgba(0, 0, 0, 0)"
|
|
218
|
+
? `background-color:${ic.recolor};-webkit-mask:${ic.bg} center/contain no-repeat;mask:${ic.bg} center/contain no-repeat;`
|
|
219
|
+
: `background-image:${ic.bg};`;
|
|
220
|
+
css += `.icon-${ic.name}{${dims}${recolor}}\n`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------- preview.html ----------
|
|
224
|
+
const cards = svgFiles.map((f) => {
|
|
225
|
+
const nm = f.name.replace("icons/", "").replace(".svg", "");
|
|
226
|
+
return `<figure><div class="box">${f.svg}</div><figcaption>${nm}</figcaption></figure>`;
|
|
227
|
+
}).join("\n");
|
|
228
|
+
const preview = `<!doctype html><meta charset=utf8><title>${location.hostname} icons</title>
|
|
229
|
+
<style>body{font:14px system-ui;padding:24px;background:#fff;color:#111}
|
|
230
|
+
h1{font-size:18px}.grid{display:flex;flex-wrap:wrap;gap:16px;margin-top:16px}
|
|
231
|
+
figure{margin:0;width:120px;text-align:center}.box{height:60px;display:grid;place-items:center;border:1px solid #eee;border-radius:8px}
|
|
232
|
+
.box svg{width:28px;height:28px}figcaption{margin-top:6px;font-size:11px;color:#555;word-break:break-all}</style>
|
|
233
|
+
<h1>${svgFiles.length} icons from ${location.hostname}</h1><div class=grid>${cards}</div>`;
|
|
234
|
+
|
|
235
|
+
// ---------- build + download one zip ----------
|
|
236
|
+
const files = [
|
|
237
|
+
...svgFiles.map((f) => ({ name: f.name, data: f.svg })),
|
|
238
|
+
{ name: "icons.css", data: css },
|
|
239
|
+
{ name: "preview.html", data: preview },
|
|
240
|
+
{ name: "report.json", data: JSON.stringify(report, null, 2) },
|
|
241
|
+
];
|
|
242
|
+
const host = location.hostname.replace(/^www\./, "");
|
|
243
|
+
const blob = buildZip(files);
|
|
244
|
+
const a = document.createElement("a");
|
|
245
|
+
a.href = URL.createObjectURL(blob);
|
|
246
|
+
a.download = `${host}-icons.zip`;
|
|
247
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
248
|
+
|
|
249
|
+
console.log(`✅ ${svgFiles.length} icon(s) → ${a.download} (${blob.size} bytes)`);
|
|
250
|
+
if (report.skipped.length) console.warn("⚠️ skipped:", report.skipped);
|
|
251
|
+
return report;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Auto-run when pasted directly; comment out to only define the function.
|
|
255
|
+
extractIcons();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// tools/merge-snapshot.js <full.json> <partial.json> — fold a PARTIAL re-capture into a
|
|
2
|
+
// full snapshot, for the fix-loop's inner iterations.
|
|
3
|
+
//
|
|
4
|
+
// WHY: the slow half of a verification round is the browser capture, not the diff — and
|
|
5
|
+
// after a one-element fix, re-capturing all N targets to re-run a gate wastes minutes per
|
|
6
|
+
// iteration (astryx: 35-target full captures for single-region fixes, dozens of times).
|
|
7
|
+
// Capture ONLY the affected targets instead (`pxSend(url, subsetTargets)`), then fold them
|
|
8
|
+
// in here: elements present in the partial overwrite their counterparts in the full file;
|
|
9
|
+
// everything else carries over. Gates then run on the merged file in seconds.
|
|
10
|
+
//
|
|
11
|
+
// HONESTY CONTRACT: a merged snapshot is an ITERATION artifact, not a proof — the
|
|
12
|
+
// carried-over elements' measurements are only as fresh as the last full capture, and a fix
|
|
13
|
+
// can shift things outside the subset you re-measured (astryx's bento-height fix moved the
|
|
14
|
+
// FOOTER). So every merge stamps `merged:{at,keys}` into the snapshot root, and the `done`
|
|
15
|
+
// gate REFUSES stamped snapshots: one final full capture (which clears the stamp by
|
|
16
|
+
// overwriting the file) is always required before a clone can claim done.
|
|
17
|
+
//
|
|
18
|
+
// Refuses on viewport-width or compat-mode mismatch (a partial captured at a different
|
|
19
|
+
// width/mode would silently poison x-positions — same rule as the diff itself).
|
|
20
|
+
//
|
|
21
|
+
// USAGE
|
|
22
|
+
// node tools/merge-snapshot.js targets/<name>/clone.json partial.json
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
|
|
27
|
+
function mergeSnapshots(full, partial) {
|
|
28
|
+
if (!partial.elements || !Object.keys(partial.elements).length) return { error: "partial snapshot has no elements — nothing to merge" };
|
|
29
|
+
if (full.viewport && partial.viewport && full.viewport.width !== partial.viewport.width)
|
|
30
|
+
return { error: `viewport widths differ (full=${full.viewport.width} vs partial=${partial.viewport.width}) — a partial captured at another width would poison x-positions; re-capture` };
|
|
31
|
+
if (full.mode && partial.mode && full.mode !== partial.mode)
|
|
32
|
+
return { error: `document modes differ (full=${full.mode} vs partial=${partial.mode}) — quirks/standards mismatch (LEARNINGS #18); re-capture` };
|
|
33
|
+
const keys = Object.keys(partial.elements);
|
|
34
|
+
for (const k of keys) full.elements[k] = partial.elements[k];
|
|
35
|
+
const prior = (full.merged && full.merged.keys) || [];
|
|
36
|
+
full.merged = { at: new Date().toISOString(), keys: [...new Set([...prior, ...keys])] };
|
|
37
|
+
return { keys, carried: Object.keys(full.elements).length - keys.length };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function main() {
|
|
41
|
+
const [fullPath, partialPath] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
42
|
+
if (!fullPath || !partialPath) { console.error("usage: node tools/merge-snapshot.js <full.json> <partial.json>"); process.exit(2); }
|
|
43
|
+
let full, partial;
|
|
44
|
+
try { full = JSON.parse(fs.readFileSync(fullPath, "utf8")); } catch (e) { console.error(`cannot read ${fullPath}: ${e.message}`); process.exit(1); }
|
|
45
|
+
try { partial = JSON.parse(fs.readFileSync(partialPath, "utf8")); } catch (e) { console.error(`cannot read ${partialPath}: ${e.message}`); process.exit(1); }
|
|
46
|
+
const r = mergeSnapshots(full, partial);
|
|
47
|
+
if (r.error) { console.error(`❌ ${r.error}`); process.exit(1); }
|
|
48
|
+
fs.writeFileSync(fullPath, JSON.stringify(full, null, 2));
|
|
49
|
+
console.log(`✓ merged ${r.keys.length} element(s) into ${fullPath} (${r.carried} carried over from the last full capture)
|
|
50
|
+
updated: ${r.keys.join(", ")}
|
|
51
|
+
⚠ ITERATION artifact — the file is stamped merged:{at,keys}; the done gate refuses stamped
|
|
52
|
+
snapshots, so take one FULL capture before advancing done (it overwrites the stamp).`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (require.main === module) main();
|
|
56
|
+
module.exports = { mergeSnapshots };
|