@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 +21 -0
- package/README.md +16 -0
- package/docs/getting-started.md +50 -0
- package/package.json +20 -0
- package/src/check.mjs +67 -0
- package/src/cli.mjs +75 -0
- package/src/index.mjs +11 -0
- package/src/pack.mjs +46 -0
- package/src/parse.mjs +56 -0
- package/src/unpack.mjs +87 -0
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
|
+
}
|