@mrkt_frwd/leaf 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Asare
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @mrkt_frwd/leaf
2
+
3
+ Take a self-contained HTML page apart into editable CSS and JS, put it back, and
4
+ prove it is the same file.
5
+
6
+ ```bash
7
+ npx leaf unpack page.html --output-dir ./out
8
+ npx leaf pack ./out --out page.html
9
+ npx leaf check page.html
10
+ ```
11
+
12
+ Leaf never rebuilds the document from a parse tree — it cuts block bodies out by
13
+ byte offset and puts them back. Unchanged in, unchanged out, verified against
14
+ every page in the studio. `docs/getting-started.md` has the rest.
15
+
16
+ MIT.
@@ -0,0 +1,50 @@
1
+ # Getting started
2
+
3
+ A **Leaf** is a self-contained HTML page taken apart into editable files, and put
4
+ back without losing a byte.
5
+
6
+ ```bash
7
+ npx leaf # prints this guide
8
+ npx leaf unpack page.html --output-dir ./out # take it apart
9
+ npx leaf pack ./out --out page.html # put it back
10
+ npx leaf check page.html # is it actually self-contained?
11
+ ```
12
+
13
+ ## What you get
14
+
15
+ ```
16
+ out/
17
+ shell.html the original document, with /*leaf:N*/ where each block was
18
+ parts/00-style.css
19
+ parts/01-script.js
20
+ leaf.json order, types, byte counts, digests
21
+ ```
22
+
23
+ Edit the parts. `pack` puts them back in the order `leaf.json` records and tells
24
+ you whether the result is byte-identical to what you unpacked — unchanged in,
25
+ unchanged out.
26
+
27
+ ## Why the shell keeps the original
28
+
29
+ Most tools that do this rebuild the document from a parse tree, which is how a
30
+ page comes back with its attribute quoting normalised, its shader indentation
31
+ re-flowed and a newline missing from inside a template literal. It still loads.
32
+ It still looks right. That is the problem.
33
+
34
+ Leaf never rebuilds. It records byte offsets, cuts the block bodies out, and puts
35
+ them back where they were. The round trip is the guarantee, and it is tested
36
+ against every page in the studio rather than a fixture chosen to pass.
37
+
38
+ ## check
39
+
40
+ `check` answers one question: does this page fetch anything from another origin?
41
+ A single-file page claims it opens from `file://` on a machine with no network
42
+ and looks the same. A Google Fonts stylesheet or a CDN import map breaks that
43
+ claim quietly — the page renders in a fallback typeface and reports nothing.
44
+
45
+ Hyperlinks, canonical URLs and `og:image` are not failures. Only what the browser
46
+ fetches to render the page counts.
47
+
48
+ ## Licence
49
+
50
+ MIT — `LICENSE` in this package. Copyright (c) 2026 Joe Asare.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@mrkt_frwd/leaf",
3
+ "version": "0.1.0",
4
+ "description": "Leaf — take a self-contained HTML page apart into editable CSS and JS, put it back, and prove it is the same file.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs"
8
+ },
9
+ "bin": {
10
+ "leaf": "./src/cli.mjs"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "docs"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "license": "MIT"
20
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Is this page actually self-contained?
3
+ *
4
+ * The claim a single-file page makes is that it opens from a file:// URL on a
5
+ * machine with no network and looks the same. Every reference to another origin
6
+ * is a way that claim fails quietly — the page still renders, just wrong, and
7
+ * a screenshot of it looks entirely plausible. That failure mode has already
8
+ * cost this codebase a set of recordings that rendered in the wrong typeface
9
+ * and reported nothing.
10
+ */
11
+ import fs from 'fs';
12
+ import { findBlocks } from './parse.mjs';
13
+
14
+ /**
15
+ * Only references the browser actually fetches to render the page. A hyperlink
16
+ * to GitHub, a canonical URL and an og:image are not self-containment failures
17
+ * — they are metadata and navigation, and flagging them would make every
18
+ * correctly built page look broken.
19
+ */
20
+ const FETCHED = [
21
+ { what: 'script', re: /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi },
22
+ { what: 'stylesheet', re: /<link\b(?=[^>]*\brel\s*=\s*["'](?:stylesheet|preload|modulepreload|icon)["'])[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi },
23
+ { what: 'media', re: /<(?:img|source|video|audio|track|embed|iframe)\b[^>]*\b(?:src|poster)\s*=\s*["']([^"']+)["'][^>]*>/gi },
24
+ { what: 'poster', re: /<video\b[^>]*\bposter\s*=\s*["']([^"']+)["'][^>]*>/gi },
25
+ ];
26
+ const CSS_URL = /url\(\s*["']?([^"')]+)["']?\s*\)/gi;
27
+
28
+ const isRemote = (ref) => /^(?:https?:)?\/\//i.test(ref);
29
+
30
+ export function check(htmlPath) {
31
+ const html = fs.readFileSync(htmlPath, 'utf8');
32
+ const blocks = findBlocks(html);
33
+ const remote = new Map();
34
+
35
+ const note = (ref, where) => {
36
+ if (!isRemote(ref)) return;
37
+ const origin = ref.replace(/^(?:https?:)?\/\//i, '').split('/')[0];
38
+ if (!remote.has(ref)) remote.set(ref, { ref, origin, where });
39
+ };
40
+
41
+ for (const { what, re } of FETCHED) {
42
+ re.lastIndex = 0;
43
+ for (const m of html.matchAll(re)) note(m[1], what);
44
+ }
45
+ for (const b of blocks) {
46
+ if (b.tag !== 'style') continue;
47
+ for (const m of b.content.matchAll(CSS_URL)) note(m[1], 'css');
48
+ }
49
+ // An import map is a promise about where modules come from — a remote entry
50
+ // there is as disqualifying as a remote <script src>.
51
+ for (const b of blocks) {
52
+ if ((b.attrs.type || '').toLowerCase() !== 'importmap') continue;
53
+ try {
54
+ const map = JSON.parse(b.content);
55
+ for (const target of Object.values(map.imports || {})) note(target, 'importmap');
56
+ } catch { /* a malformed import map is the page's problem, not ours */ }
57
+ }
58
+
59
+ const findings = [...remote.values()];
60
+ return {
61
+ file: htmlPath,
62
+ bytes: Buffer.byteLength(html, 'utf8'),
63
+ blocks: blocks.filter((b) => !b.empty).length,
64
+ selfContained: findings.length === 0,
65
+ remote: findings,
66
+ };
67
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Leaf's CLI prints its own instructions, the way Job's does. An agent should
4
+ * need one command to become useful here.
5
+ */
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { unpack } from './unpack.mjs';
9
+ import { pack } from './pack.mjs';
10
+ import { check } from './check.mjs';
11
+
12
+ const USAGE = `leaf — a single-file page, unfolded and folded back.
13
+
14
+ leaf unpack <page.html> --output-dir <dir> take it apart into editable parts
15
+ leaf pack <dir> [--out <page.html>] put it back, and prove it matches
16
+ leaf check <page.html> is it genuinely self-contained?
17
+
18
+ A leaf directory holds shell.html, parts/ and leaf.json. Edit the parts; the
19
+ shell keeps the original document with a /*leaf:N*/ token where each block was.
20
+ pack refuses quietly to lie: it reports whether the result is byte-identical to
21
+ what was unpacked.
22
+ `;
23
+
24
+ function arg(argv, name) {
25
+ const i = argv.indexOf(name);
26
+ return i === -1 ? null : argv[i + 1] || null;
27
+ }
28
+
29
+ function main(argv) {
30
+ const [cmd, ...rest] = argv;
31
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
32
+ process.stdout.write(USAGE);
33
+ return 0;
34
+ }
35
+
36
+ if (cmd === 'unpack') {
37
+ const file = rest.find((a) => !a.startsWith('-'));
38
+ const dest = arg(rest, '--output-dir');
39
+ if (!file || !dest) { process.stderr.write('usage: leaf unpack <page.html> --output-dir <dir>\n'); return 2; }
40
+ const m = unpack(file, dest);
41
+ process.stdout.write(`unpacked ${m.source.name} → ${dest}\n`);
42
+ process.stdout.write(`sha256 ${m.source.sha256.slice(0, 12)}… ${m.parts.length} part(s)\n`);
43
+ for (const p of m.parts) process.stdout.write(` ${p.file.padEnd(28)} ${String(p.bytes).padStart(7)} B\n`);
44
+ return 0;
45
+ }
46
+
47
+ if (cmd === 'pack') {
48
+ const dir = rest.find((a) => !a.startsWith('-'));
49
+ if (!dir) { process.stderr.write('usage: leaf pack <dir> [--out <page.html>]\n'); return 2; }
50
+ const out = arg(rest, '--out');
51
+ const r = pack(dir, { outFile: out });
52
+ if (out) process.stdout.write(`packed → ${out}\n`);
53
+ process.stdout.write(`sha256 ${r.sha256.slice(0, 12)}… ${r.bytes} B\n`);
54
+ process.stdout.write(r.identical
55
+ ? 'identical to the source it was unpacked from\n'
56
+ : `changed from source ${r.source.sha256.slice(0, 12)}… — expected if you edited a part\n`);
57
+ return 0;
58
+ }
59
+
60
+ if (cmd === 'check') {
61
+ const file = rest.find((a) => !a.startsWith('-'));
62
+ if (!file) { process.stderr.write('usage: leaf check <page.html>\n'); return 2; }
63
+ const r = check(file);
64
+ process.stdout.write(`${path.basename(r.file)} ${r.blocks} block(s) ${r.bytes} B\n`);
65
+ if (r.selfContained) { process.stdout.write('ok self-contained — nothing is fetched from another origin\n'); return 0; }
66
+ process.stdout.write(`not self-contained — ${r.remote.length} remote reference(s)\n`);
67
+ for (const x of r.remote) process.stdout.write(` ${x.where.padEnd(12)} ${x.ref}\n`);
68
+ return 1;
69
+ }
70
+
71
+ process.stderr.write(`unknown command: ${cmd}\n\n${USAGE}`);
72
+ return 2;
73
+ }
74
+
75
+ process.exit(main(process.argv.slice(2)));
package/src/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Leaf — a single-file page, unfolded and folded back.
3
+ *
4
+ * unpack takes a self-contained HTML experience apart into editable CSS and JS.
5
+ * pack puts it back and proves it is the same file. check says whether the page
6
+ * is genuinely self-contained or only looks like it.
7
+ */
8
+ export { findBlocks, parseAttrs } from './parse.mjs';
9
+ export { unpack, SCHEMA } from './unpack.mjs';
10
+ export { pack } from './pack.mjs';
11
+ export { check } from './check.mjs';
package/src/pack.mjs ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Editable parts → single file, and it must be the same file.
3
+ *
4
+ * pack verifies its own output against the source digest leaf.json recorded at
5
+ * unpack time. An unchanged round trip that does not reproduce the original
6
+ * byte for byte is a bug in this package, and it says so rather than shipping
7
+ * a file that is nearly right.
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import crypto from 'crypto';
12
+ import { SCHEMA } from './unpack.mjs';
13
+
14
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
15
+
16
+ export function pack(srcDir, { outFile = null } = {}) {
17
+ const manifestPath = path.join(srcDir, 'leaf.json');
18
+ if (!fs.existsSync(manifestPath)) {
19
+ const err = new Error(`no leaf.json in ${srcDir} — is this a leaf directory?`);
20
+ err.code = 'NOT_A_LEAF';
21
+ throw err;
22
+ }
23
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
24
+ if (manifest.schema !== SCHEMA) {
25
+ const err = new Error(`unsupported schema ${manifest.schema} (expected ${SCHEMA})`);
26
+ err.code = 'SCHEMA';
27
+ throw err;
28
+ }
29
+
30
+ let html = fs.readFileSync(path.join(srcDir, manifest.shell.file), 'utf8');
31
+ for (const part of manifest.parts) {
32
+ const at = html.indexOf(part.token);
33
+ if (at === -1) {
34
+ const err = new Error(`shell.html has lost the token for ${part.file} — put ${part.token} back where the block belongs`);
35
+ err.code = 'TOKEN_MISSING';
36
+ throw err;
37
+ }
38
+ const body = fs.readFileSync(path.join(srcDir, part.file), 'utf8');
39
+ html = html.slice(0, at) + body + html.slice(at + part.token.length);
40
+ }
41
+
42
+ const digest = sha256(html);
43
+ const identical = digest === manifest.source.sha256;
44
+ if (outFile) fs.writeFileSync(outFile, html);
45
+ return { html, sha256: digest, bytes: Buffer.byteLength(html, 'utf8'), identical, source: manifest.source };
46
+ }
package/src/parse.mjs ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Find the <script> and <style> blocks in an HTML file, by offset.
3
+ *
4
+ * Deliberately not a full HTML parser. It needs to answer one question — where
5
+ * does each embedded block's content start and end, in bytes — and a real parser
6
+ * would answer it by rebuilding the document, which is exactly the step that
7
+ * loses the original formatting. Offsets keep the file authoritative.
8
+ */
9
+
10
+ const BLOCKS = [
11
+ { tag: 'script', re: /<script\b([^>]*)>/gi, close: '</script>' },
12
+ { tag: 'style', re: /<style\b([^>]*)>/gi, close: '</style>' },
13
+ ];
14
+
15
+ /** Parse the attributes of an open tag into a plain object, order preserved. */
16
+ export function parseAttrs(raw = '') {
17
+ const attrs = {};
18
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
19
+ let m;
20
+ while ((m = re.exec(raw)) !== null) {
21
+ attrs[m[1]] = m[2] ?? m[3] ?? m[4] ?? '';
22
+ }
23
+ return attrs;
24
+ }
25
+
26
+ /**
27
+ * Every embedded block, in document order.
28
+ *
29
+ * A block with no content is reported with `empty: true` and never extracted —
30
+ * `<script src=…></script>` is a reference, not a payload, and turning it into
31
+ * an empty file would make the round trip look lossy when it is not.
32
+ */
33
+ export function findBlocks(html) {
34
+ const found = [];
35
+ for (const { tag, re, close } of BLOCKS) {
36
+ re.lastIndex = 0;
37
+ let open;
38
+ while ((open = re.exec(html)) !== null) {
39
+ const contentStart = open.index + open[0].length;
40
+ const closeAt = html.toLowerCase().indexOf(close, contentStart);
41
+ if (closeAt === -1) continue; // unclosed — leave it alone rather than guess
42
+ const content = html.slice(contentStart, closeAt);
43
+ found.push({
44
+ tag,
45
+ attrs: parseAttrs(open[1] || ''),
46
+ openTag: open[0],
47
+ contentStart,
48
+ contentEnd: closeAt,
49
+ content,
50
+ empty: content.trim() === '',
51
+ });
52
+ re.lastIndex = closeAt + close.length;
53
+ }
54
+ }
55
+ return found.sort((a, b) => a.contentStart - b.contentStart);
56
+ }
package/src/unpack.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Single-file page → editable parts, without losing a byte.
3
+ *
4
+ * The shell keeps the original document exactly as written, with each embedded
5
+ * block's body swapped for a `/*leaf:N*\/` token — valid syntax in both CSS and
6
+ * JavaScript, so the shell still parses and can still be edited by hand.
7
+ * Everything needed to put it back lives in leaf.json.
8
+ */
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import crypto from 'crypto';
12
+ import { findBlocks } from './parse.mjs';
13
+
14
+ export const SCHEMA = 'leaf/1';
15
+
16
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
17
+
18
+ function extensionFor(block) {
19
+ if (block.tag === 'style') return 'css';
20
+ const type = (block.attrs.type || '').toLowerCase();
21
+ if (type === 'importmap' || type.includes('json')) return 'json';
22
+ return 'js';
23
+ }
24
+
25
+ function nameFor(block, i) {
26
+ const type = (block.attrs.type || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
27
+ const stem = type ? `${block.tag}-${type}` : block.tag;
28
+ return `${String(i).padStart(2, '0')}-${stem}.${extensionFor(block)}`;
29
+ }
30
+
31
+ /**
32
+ * A token the document does not already contain. Collisions are vanishingly
33
+ * unlikely and silently catastrophic, so this checks rather than assumes.
34
+ */
35
+ function tokenFor(index, html) {
36
+ let token = `/*leaf:${index}*/`;
37
+ let guard = 0;
38
+ while (html.includes(token)) {
39
+ token = `/*leaf:${index}:${(guard += 1)}*/`;
40
+ if (guard > 64) throw new Error('could not find a collision-free token');
41
+ }
42
+ return token;
43
+ }
44
+
45
+ export function unpack(htmlPath, destDir) {
46
+ const original = fs.readFileSync(htmlPath, 'utf8');
47
+ const blocks = findBlocks(original).filter((b) => !b.empty);
48
+
49
+ const parts = [];
50
+ let shell = '';
51
+ let cursor = 0;
52
+
53
+ blocks.forEach((block, i) => {
54
+ const file = nameFor(block, i);
55
+ const token = tokenFor(i, original);
56
+ shell += original.slice(cursor, block.contentStart) + token;
57
+ cursor = block.contentEnd;
58
+ parts.push({
59
+ index: i,
60
+ tag: block.tag,
61
+ type: block.attrs.type || null,
62
+ file: path.posix.join('parts', file),
63
+ token,
64
+ bytes: Buffer.byteLength(block.content, 'utf8'),
65
+ sha256: sha256(block.content),
66
+ content: block.content,
67
+ });
68
+ });
69
+ shell += original.slice(cursor);
70
+
71
+ fs.mkdirSync(path.join(destDir, 'parts'), { recursive: true });
72
+ fs.writeFileSync(path.join(destDir, 'shell.html'), shell);
73
+ for (const p of parts) fs.writeFileSync(path.join(destDir, p.file), p.content);
74
+
75
+ const manifest = {
76
+ schema: SCHEMA,
77
+ source: {
78
+ name: path.basename(htmlPath),
79
+ bytes: Buffer.byteLength(original, 'utf8'),
80
+ sha256: sha256(original),
81
+ },
82
+ shell: { file: 'shell.html', sha256: sha256(shell) },
83
+ parts: parts.map(({ content, ...rest }) => rest),
84
+ };
85
+ fs.writeFileSync(path.join(destDir, 'leaf.json'), JSON.stringify(manifest, null, 2) + '\n');
86
+ return manifest;
87
+ }