single-file-core 1.6.0 → 1.6.2

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.
Files changed (77) hide show
  1. package/core/helper.js +5 -2
  2. package/modules/css-fonts-minifier.js +34 -13
  3. package/modules/template-formatter.js +8 -2
  4. package/package.json +10 -2
  5. package/vendor/zip/zip.js +4 -0
  6. package/.github/workflows/publish.yml +0 -88
  7. package/.github/workflows/test.yml +0 -31
  8. package/.github/workflows/vendor.yml +0 -39
  9. package/css-tree-build/README.md +0 -39
  10. package/css-tree-build/build.js +0 -66
  11. package/css-tree-build/package-lock.json +0 -511
  12. package/css-tree-build/package.json +0 -13
  13. package/deno.lock +0 -19
  14. package/doc/assets/singlefile-archive-byte-map.svg +0 -276
  15. package/doc/assets/singlefile-archive-lenses.svg +0 -245
  16. package/doc/singlefile-archive.md +0 -1968
  17. package/eslint.config.mjs +0 -74
  18. package/test/capture/README.md +0 -74
  19. package/test/capture/canonical-link.js +0 -50
  20. package/test/capture/common.js +0 -73
  21. package/test/capture/deferred-content-options.js +0 -71
  22. package/test/capture/dom.js +0 -26
  23. package/test/capture/maff-metadata.js +0 -205
  24. package/test/capture/resource-cap.js +0 -79
  25. package/test/capture/script-uri-sanitization.js +0 -82
  26. package/test/fixtures/README.md +0 -18
  27. package/test/fixtures/classic-digest.html +0 -5
  28. package/test/fixtures/generate-fixtures.sh +0 -31
  29. package/test/fixtures/multi-page-dedup.zip.html +0 -70
  30. package/test/fixtures/multi-page.zip.html +0 -26
  31. package/test/fixtures/single-page.zip.html +0 -11
  32. package/test/fixtures/site/multi-page/alpha.html +0 -2
  33. package/test/fixtures/site/multi-page/beta.html +0 -2
  34. package/test/fixtures/site/multi-page/index.html +0 -2
  35. package/test/fixtures/site/multi-page/sub/gamma.html +0 -2
  36. package/test/fixtures/site/multi-page-dedup/index.html +0 -2
  37. package/test/fixtures/site/multi-page-dedup/logo.png +0 -0
  38. package/test/fixtures/site/multi-page-dedup/one.html +0 -2
  39. package/test/fixtures/site/multi-page-dedup/shared.css +0 -3
  40. package/test/fixtures/site/multi-page-dedup/two.html +0 -2
  41. package/test/run.js +0 -109
  42. package/test/sfz-harness/README.md +0 -87
  43. package/test/sfz-harness/adopted-stylesheets-hook.js +0 -240
  44. package/test/sfz-harness/byte-map.js +0 -137
  45. package/test/sfz-harness/charset-round-trip.js +0 -161
  46. package/test/sfz-harness/check-determinism.js +0 -57
  47. package/test/sfz-harness/common.js +0 -154
  48. package/test/sfz-harness/content-type-sniffing.js +0 -83
  49. package/test/sfz-harness/css-fonts-minifier.js +0 -235
  50. package/test/sfz-harness/css-property-filter.js +0 -113
  51. package/test/sfz-harness/dom-stub.js +0 -13
  52. package/test/sfz-harness/entry-compression.js +0 -77
  53. package/test/sfz-harness/filename-characters.js +0 -55
  54. package/test/sfz-harness/filename-max-length.js +0 -69
  55. package/test/sfz-harness/font-face-composite.js +0 -135
  56. package/test/sfz-harness/format-rules.js +0 -617
  57. package/test/sfz-harness/gen-e2e-page.js +0 -14
  58. package/test/sfz-harness/inlined-functions.js +0 -82
  59. package/test/sfz-harness/option-wiring.js +0 -60
  60. package/test/sfz-harness/pages-archive.js +0 -318
  61. package/test/sfz-harness/pages-router.js +0 -143
  62. package/test/sfz-harness/relocation-cost.js +0 -94
  63. package/test/sfz-harness/search-triggers.js +0 -35
  64. package/test/sfz-harness/smoke.js +0 -10
  65. package/test/sfz-harness/stored-trigger.js +0 -95
  66. package/test/sfz-harness/trigger-seeds.json +0 -120
  67. package/test/sfz-harness/zip64.js +0 -77
  68. package/zip-build/README.md +0 -56
  69. package/zip-build/lib/fflate-streams.js +0 -47
  70. package/zip-build/lib/fflate.js +0 -2692
  71. package/zip-build/lib/zip-vendor-worker.js +0 -3
  72. package/zip-build/lib/zip-vendor.js +0 -3
  73. package/zip-build/lib/zip.js +0 -25
  74. package/zip-build/package-lock.json +0 -665
  75. package/zip-build/package.json +0 -15
  76. package/zip-build/reserved-property-names.json +0 -10630
  77. package/zip-build/rollup.config.js +0 -61
@@ -1,87 +0,0 @@
1
- # SFZ format harness
2
-
3
- Tests for the SingleFile archive writer in `processors/compression/`, which drive
4
- `process()` directly with synthetic page data, so they need no browser and no network.
5
- The format itself is specified in [`doc/singlefile-archive.md`](../../doc/singlefile-archive.md).
6
-
7
- The directory has since taken in suites for code the archive writer does not own — the
8
- CSS processors, the download filename helpers — because they need the same Deno-with-a-
9
- DOM-stub setup and there was nowhere else to put them. The table says which is which.
10
-
11
- Run them with Deno, from the repository root:
12
-
13
- ```
14
- npm test
15
- ```
16
-
17
- which is [`test/run.js`](../run.js), the runner for every suite under `test/`. It takes
18
- name filters, so this directory alone is:
19
-
20
- ```
21
- deno run --allow-read --allow-run test/run.js sfz-harness
22
- ```
23
-
24
- or one at a time, which needs no runner:
25
-
26
- ```
27
- deno run --allow-read test/sfz-harness/format-rules.js
28
- ```
29
-
30
- ## The suites
31
-
32
- CI runs these. Each prints one `PASS`/`FAIL` line per check and exits non-zero if
33
- any check failed.
34
-
35
- | Script | What it covers |
36
- |---|---|
37
- | `format-rules.js` | The rules of the format: the charset declaration and the doctype cap that keeps it inside the scan window, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
38
- | `stored-trigger.js` | That a stored (uncompressed) entry whose bytes contain a rung's pattern moves the writer to the right rung. |
39
- | `check-determinism.js` | That the same inputs produce the same bytes, and that the levers which should change the output do. |
40
- | `option-wiring.js` | That every option `compression.js` reads is either declared as a caller option or classified as internal, and that `single-file.js` still builds its argument from that declaration. Guards the layer the other suites sit below. |
41
- | `css-property-filter.js` | That the declaration filter keeps a property css-tree's dictionary does not know (`stop-color`, `flood-opacity`, anything newer than the pinned build) and still drops a genuinely invalid value. |
42
- | `adopted-stylesheets-hook.js` | That the page-world hook answers the adopted-stylesheets request for a CLOSED shadow root, which its host does not expose. |
43
- | `inlined-functions.js` | That a function serialized into a self-extracting archive names nothing outside itself. An import survives bundling and still reads correctly, and the archive then throws a bare `ReferenceError` and renders nothing. |
44
- | `pages-archive.js` | That `createPagesArchive` packs several pages into one archive correctly: the first page at the root and the others in folders, the manifest, the symlink a deduplicated entry leaves behind, and the escaping of crawled titles in both tables of contents. Also that the options reaching the archive writer are derived from `PROCESS_OPTION_NAMES` rather than hand-listed — a hand copy dropped `maxAppendedDataLength` for months — and that `password` stays out of that derivation, since forwarding it would make the writer withhold the prologue's title as if the archive were encrypted while the table of contents and every entry comment still rode in that same cleartext prologue. |
45
- | `pages-router.js` | That the router opens a multi-page archive on the page a reader expects: the table of contents when the archive stores one, the first page when it does not, and the page a route in the hash names whatever else is stored. The router is inlined into the archive as source text and only ever ran inside a saved page, so nothing drove it before; the table of contents shipped stored, routable and unreachable. |
46
- | `content-type-sniffing.js` | That a magic-byte match beats the `Content-Type` header, and that the header survives when nothing matches. science.org serves its woff2 files as `text/plain`, which used to be trusted, so the fonts were embedded as `data:text/plain` and the SFZ writer deflated a file that is already Brotli-compressed. Two cases guard the rules themselves rather than the outcome: a video whose first byte is `G` must not be relabelled `video/mp2t`, and the generic `mif1` HEIF brand must identify nothing, because an AVIF can carry it and calling it HEIC would name a format no browser decodes. |
47
- | `entry-compression.js` | That an entry is deflated or stored on the content type the server sent, not on the extension alone — a module served as `text/javascript` from a `.ts` URL used to go in uncompressed — and that an unrecognized `application/octet-stream` still stays stored. |
48
- | `filename-max-length.js` | That `formatFilename` counts the ellipsis as well as the extension in the budget it truncates to, so a filename at `filenameMaxLength` stays at it, and that a limit shorter than the extension does not reach `Blob.slice` with a negative start. |
49
- | `filename-characters.js` | That `getValidFilename` maps a full-width lookalike one character at a time — `C++` used to be saved as `C+` — while a run of characters with no lookalike still collapses to a single replacement. |
50
- | `zip64.js` | That the `page.pdf` record injection accounts for the zip64 end of central directory record (§5.7): all four EOCD fields left at their sentinels, the entry counts and directory size carried in the zip64 record, the directory offset pointing at the injected record, and the archive still readable. The branch runs only past 4 GiB or 65535 entries, so nothing reached it before; the suite forces zip64 through `zipWriter.options` from inside the `writeEntries` callback, with no production lever. |
51
- | `byte-map.js` | That the byte offsets §8.2 of the specification prints still describe what the writer emits: the prologue order, the doctype and root tag with nothing between them, the identifier's length ahead of the region, absolute EOCD offsets, and the entry order. The specimen §8.2 documents is saved from a live URL and has never been in this repository, so none of its numbers could be checked; three of them were wrong. This builds an equivalent with no network. |
52
- | `relocation-cost.js` | That the figures §5.2 prints for relocating the extra-data element reconstruct. Two of the three came from live captures and did not: the paragraph subtracted the terminator and the end tags from the reservation without subtracting the element, which the appended placement carries too, so it over-counted by the whole element. This pins the corrected arithmetic — the cost is the reservation margin alone, it is positive on every rung whenever an element exists, and the only way relocation saves bytes is to have no element to relocate. |
53
- | `charset-round-trip.js` | That the encoding tables §8.4 prints still describe the WHATWG index: which 20 of the 38 encodings carry all 256 byte values through a decode injectively, the sizes of the reverse tables they need, and the five windows-1252 positions a platform codec of the same name leaves undefined. It also re-derives the reverse table the extractor ships as a literal, which no build step checks and which corrupts one byte per occurrence when wrong. |
54
- | `css-fonts-minifier.js` | That `removeUnusedFonts` reads the font families it prunes on correctly: a `var()` family resolved from the values the document declares and not only from the ones the body inherits, every font kept when the value is genuinely undetermined, and a multi-word family name that does not also claim a font named after its own tail. |
55
- | `font-face-composite.js` | That several `@font-face` rules declaring the same family with the same style descriptors are one composite face and not a stack where the last rule wins, which is what CSS Fonts 4 §5.2 and §4.5.1 say: both members are kept with their own sources, faces split by `unicode-range` are all kept, an outright duplicate rule is emitted once, and a source repeated inside one rule is listed once, at the position of its later declaration. |
56
-
57
- ## The tools
58
-
59
- Not tests — they print, they do not assert, and CI does not run them. The runner skips
60
- them by name, in the `NOT_SUITES` list of [`test/run.js`](../run.js). Everything else in
61
- this directory IS run, so a new file is either a suite or a line in that list.
62
-
63
- | Script | Use |
64
- |---|---|
65
- | `smoke.js` | Build one archive and print its size, chosen rung and first 200 bytes. |
66
- | `search-triggers.js` | Search seeds for page data that triggers a given condition. Its results are `trigger-seeds.json`. |
67
- | `gen-e2e-page.js` | Write a page fixture to disk for end-to-end use. |
68
-
69
- `common.js` builds the synthetic page data and options; `dom-stub.js` supplies the
70
- minimum DOM the compression code touches, because Deno has no `DOMParser`. Import it
71
- first — `compression.js` reads `globalThis.DOMParser` when it loads.
72
-
73
- ## Adding a test
74
-
75
- Add checks to the suite that already covers the area, rather than creating a file per
76
- rule. A check is one line:
77
-
78
- ```js
79
- check("the label that appears in the output", actual, expected);
80
- ```
81
-
82
- Two things are worth doing every time. Write the comment that says *why* the rule
83
- exists, not what the code does — several rules here look arbitrary until you know which
84
- tokenizer state or which reader made them necessary. And confirm the test can fail:
85
- break the rule in `compression.js`, watch the check go red, then put it back. A check
86
- that passes against a deliberately broken writer is protecting nothing, which is how
87
- the case-insensitivity of the rung patterns went untested for as long as it did.
@@ -1,240 +0,0 @@
1
- /*
2
- * Copyright 2010-2026 Gildas Lormeau
3
- * contact : gildas.lormeau <at> gmail.com
4
- *
5
- * This file is part of SingleFile.
6
- *
7
- * The code in this file is free software: you can redistribute it and/or
8
- * modify it under the terms of the GNU Affero General Public License
9
- * (GNU AGPL) as published by the Free Software Foundation, either version 3
10
- * of the License, or (at your option) any later version.
11
- *
12
- * The code in this file is distributed in the hope that it will be useful,
13
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
15
- * General Public License for more details.
16
- *
17
- * As additional permission under GNU AGPL version 3 section 7, you may
18
- * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
19
- * AGPL normally required by section 4, provided you include this license
20
- * notice and a URL through which recipients can access the Corresponding
21
- * Source.
22
- */
23
-
24
- // A shadow root's constructed stylesheets cannot be read from the extension's own
25
- // world, so the page-world hook reads them and answers a request event. It used to
26
- // resolve the root with `event.target.shadowRoot`, which is null for a CLOSED root,
27
- // and then answered nothing at all. The extension still captured the root's content,
28
- // because it reaches closed roots through openOrClosedShadowRoot, so a closed root
29
- // came back in the saved page with its markup intact and its styles gone, with no
30
- // error anywhere. Measured on 2026-08-30 in Firefox and in Chrome, and again after
31
- // the fix, with tmp/closed-shadow-root-probe.html.
32
- //
33
- // The hook now records every root as it is attached and falls back to that record.
34
- // This test drives the hook's own listener over a stub of the one browser rule the
35
- // bug turns on: attachShadow returns the root to its caller either way, and leaves
36
- // host.shadowRoot null when the mode is closed.
37
-
38
- const REQUEST_EVENT = "single-file-request-get-adopted-stylesheets";
39
- const RESPONSE_EVENT = "single-file-response-get-adopted-stylesheets";
40
- const UNREGISTER_EVENT = "single-file-unregister-request-get-adopted-stylesheets";
41
- const HOOK_PATH = new URL("../../processors/hooks/content/content-hooks-frames-web.js", import.meta.url);
42
-
43
- let failures = 0;
44
-
45
- const documentListeners = new Map();
46
- installStubs();
47
- const originalAttachShadow = globalThis.Element.prototype.attachShadow;
48
- new Function(await Deno.readTextFile(HOOK_PATH))();
49
- const listener = documentListeners.get(REQUEST_EVENT);
50
-
51
- check("the hook registers a listener for the request event", Boolean(listener));
52
- check("the hook patches attachShadow", globalThis.Element.prototype.attachShadow !== originalAttachShadow);
53
- check("the patched attachShadow still looks native",
54
- globalThis.Element.prototype.attachShadow.toString() === "function attachShadow() { [native code] }" &&
55
- globalThis.Element.prototype.attachShadow.name === "attachShadow");
56
-
57
- // an open root: the host exposes it, which is the path that always worked
58
- const openHost = new StubElement();
59
- const openRoot = openHost.attachShadow({ mode: "open" });
60
- adopt(openRoot, ".open { color: red }");
61
- check("attachShadow returns the root for an open mode", openRoot instanceof StubShadowRoot);
62
- check("an open root answers with its stylesheets",
63
- sameStrings(request(openHost, openRoot), [".open { color: red }"]));
64
-
65
- // a closed root: the host exposes nothing, so only the record can resolve it
66
- const closedHost = new StubElement();
67
- const closedRoot = closedHost.attachShadow({ mode: "closed" });
68
- adopt(closedRoot, ".closed { color: blue }");
69
- check("a closed root is hidden from its host", closedHost.shadowRoot === null);
70
- check("attachShadow returns the root for a closed mode", closedRoot instanceof StubShadowRoot);
71
- check("a closed root answers with its stylesheets",
72
- sameStrings(request(closedHost, closedRoot), [".closed { color: blue }"]));
73
-
74
- // the same, for a sheet the page never passed through replaceSync, so that the
75
- // answer comes from cssRules rather than from the recorded text
76
- const rulesHost = new StubElement();
77
- const rulesRoot = rulesHost.attachShadow({ mode: "closed" });
78
- rulesRoot.adoptedStyleSheets = [{ cssRules: [{ cssText: ".rules { color: green }" }] }];
79
- check("a closed root answers from cssRules when the text was not recorded",
80
- sameStrings(request(rulesHost, rulesRoot), [".rules { color: green }"]));
81
-
82
- // an empty root still registers the hook's listener on itself, which is what lets a
83
- // NESTED host inside it be reached later; only the answer is withheld
84
- const emptyHost = new StubElement();
85
- const emptyRoot = emptyHost.attachShadow({ mode: "closed" });
86
- check("an empty root answers nothing", request(emptyHost, emptyRoot) === undefined);
87
- check("an empty root is still listened to for nested hosts", emptyRoot.listenerCount(REQUEST_EVENT) > 0);
88
-
89
- // a target that is neither a host nor a recorded root must not throw
90
- check("an unrelated target is ignored", request(new StubElement(), new StubShadowRoot()) === undefined);
91
-
92
- // the cleanup the hook installs on each root it answers for. Both halves of it were dead:
93
- // the unregister handler was a fresh closure per handshake, so it was never the no-op that
94
- // re-adding a registered listener is and one was left behind per root per save, and it
95
- // removed the request listener without the capture flag it had been added with, which
96
- // matches nothing at all
97
- const repeatHost = new StubElement();
98
- const repeatRoot = repeatHost.attachShadow({ mode: "open" });
99
- adopt(repeatRoot, ".repeat { color: teal }");
100
- request(repeatHost, repeatRoot);
101
- const unregisterAfterFirst = repeatRoot.listenerCount(UNREGISTER_EVENT);
102
- request(repeatHost, repeatRoot);
103
- request(repeatHost, repeatRoot);
104
- check("one handshake installs one unregister listener", unregisterAfterFirst === 1);
105
- check("repeated handshakes do not accumulate unregister listeners",
106
- repeatRoot.listenerCount(UNREGISTER_EVENT) === 1);
107
- check("the root is listened to for requests until it is unregistered",
108
- repeatRoot.listenerCount(REQUEST_EVENT) === 1);
109
- repeatRoot.dispatchEvent(new globalThis.CustomEvent(UNREGISTER_EVENT, { bubbles: true }));
110
- check("unregistering removes the request listener the hook added with capture",
111
- repeatRoot.listenerCount(REQUEST_EVENT) === 0);
112
- check("the unregister listener removes itself once it has fired",
113
- repeatRoot.listenerCount(UNREGISTER_EVENT) === 0);
114
-
115
- console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
116
- Deno.exit(failures ? 1 : 0);
117
-
118
- function request(target, listeningRoot) {
119
- let answer;
120
- const responseListener = event => answer = event.detail.adoptedStyleSheets;
121
- listeningRoot.addEventListener(RESPONSE_EVENT, responseListener);
122
- listener({ target, stopPropagation() { } });
123
- listeningRoot.removeEventListener(RESPONSE_EVENT, responseListener);
124
- return answer;
125
- }
126
-
127
- function adopt(root, text) {
128
- const sheet = new globalThis.CSSStyleSheet();
129
- sheet.replaceSync(text);
130
- root.adoptedStyleSheets = [sheet];
131
- }
132
-
133
- function sameStrings(value, expected) {
134
- return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
135
- }
136
-
137
- function check(label, condition) {
138
- if (!condition) {
139
- failures++;
140
- }
141
- console.log((condition ? "PASS" : "FAIL") + " " + label);
142
- }
143
-
144
- // the stub below models only what the hook touches while it installs itself and
145
- // while it answers one request; it is deliberately not a DOM
146
- function StubShadowRoot() {
147
- this.adoptedStyleSheets = [];
148
- this.listeners = new Map();
149
- }
150
-
151
- function StubElement() {
152
- this.shadowRoot = null;
153
- this.listeners = new Map();
154
- }
155
-
156
- function installStubs() {
157
- // a listener is identified by type, callback AND capture: adding one that is already
158
- // registered is a no-op, and removing one with a different capture flag matches nothing.
159
- // The stub has to model both, because those are the two rules the cleanup code got wrong
160
- const listenerMethods = {
161
- addEventListener(type, listener, options) {
162
- const capture = Boolean(options && options.capture);
163
- if (!this.listeners.has(type)) {
164
- this.listeners.set(type, []);
165
- }
166
- const listeners = this.listeners.get(type);
167
- if (!listeners.some(entry => entry.listener === listener && entry.capture === capture)) {
168
- listeners.push({ listener, capture, once: Boolean(options && options.once) });
169
- }
170
- },
171
- removeEventListener(type, listener, options) {
172
- const capture = Boolean(options && options.capture);
173
- const listeners = this.listeners.get(type) || [];
174
- const index = listeners.findIndex(entry => entry.listener === listener && entry.capture === capture);
175
- if (index !== -1) {
176
- listeners.splice(index, 1);
177
- }
178
- },
179
- dispatchEvent(event) {
180
- event.currentTarget = this;
181
- (this.listeners.get(event.type) || []).slice().forEach(entry => {
182
- if (entry.once) {
183
- this.removeEventListener(event.type, entry.listener, { capture: entry.capture });
184
- }
185
- entry.listener(event);
186
- });
187
- event.currentTarget = null;
188
- return true;
189
- },
190
- listenerCount(type) {
191
- return (this.listeners.get(type) || []).length;
192
- }
193
- };
194
- Object.assign(StubShadowRoot.prototype, listenerMethods);
195
- Object.assign(StubElement.prototype, listenerMethods);
196
-
197
- // the rule the bug turns on: the caller gets the root whatever the mode, while the
198
- // host exposes it only when the mode is open
199
- StubElement.prototype.attachShadow = function (init) {
200
- const shadowRoot = new StubShadowRoot();
201
- shadowRoot.host = this;
202
- this.shadowRoot = init && init.mode === "closed" ? null : shadowRoot;
203
- return shadowRoot;
204
- };
205
-
206
- const recordedText = new WeakMap();
207
- class StubCSSStyleSheet {
208
- replaceSync(text) {
209
- recordedText.set(this, text);
210
- }
211
- get cssRules() {
212
- return [{ cssText: recordedText.get(this) }];
213
- }
214
- }
215
-
216
- globalThis.Element = StubElement;
217
- globalThis.CSSStyleSheet = StubCSSStyleSheet;
218
- globalThis.CustomEvent = class CustomEvent {
219
- constructor(type, init = {}) {
220
- this.type = type;
221
- this.detail = init.detail;
222
- this.bubbles = Boolean(init.bubbles);
223
- }
224
- };
225
- globalThis.Event = class Event { constructor(type) { this.type = type; } };
226
- globalThis.UIEvent = globalThis.Event;
227
- globalThis.screen = { width: 0, height: 0 };
228
- globalThis.MutationObserver = class MutationObserver { observe() { } };
229
- globalThis.fetch = () => Promise.reject(new Error("the hook test makes no request"));
230
- globalThis.document = {
231
- addEventListener(type, listener) {
232
- documentListeners.set(type, listener);
233
- },
234
- removeEventListener() { },
235
- dispatchEvent() { return true; },
236
- querySelectorAll: () => [],
237
- documentElement: {},
238
- fonts: { add() { }, delete() { }, clear() { }, forEach() { } }
239
- };
240
- }
@@ -1,137 +0,0 @@
1
- // §8.2 of doc/singlefile-archive.md is a table of byte offsets. Nothing could check it: the
2
- // specimen it describes is saved from a live URL through the CLI, so it needs a network and a
3
- // browser and has never existed in this repository. Three of its rows were wrong for an unknown
4
- // length of time — the doctype is 15 bytes and nothing separates it from the root element start
5
- // tag, so the first three offsets were each one too high — and §4.2's derived figure is stale by
6
- // about 20 KB. This builds an equivalent specimen from the harness, deterministically and with no
7
- // network, and asserts both the offsets the document prints and the structural relations they
8
- // stand for. A writer change that moves the layout fails here instead of rotting in the prose.
9
- import "./dom-stub.js";
10
- import { makeOptions, runProcess, freezeDate } from "./common.js";
11
-
12
- const zipScript = await Deno.readTextFile(new URL("../../vendor/zip/zip.min.js", import.meta.url));
13
-
14
- let failed = false;
15
-
16
- function check(label, actual, expected) {
17
- const ok = actual === expected;
18
- console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
19
- failed ||= !ok;
20
- }
21
-
22
- // a two-entry archive, matching the shape §8.2 documents: index.html and manifest.json only
23
- function makeSpecimenPageData() {
24
- return {
25
- title: "Example Domain",
26
- doctype: "<!DOCTYPE html>",
27
- content: "<html><body><h1>Example Domain</h1><p>" + "specimen ".repeat(64) + "</p></body></html>",
28
- comment: "\n url: https://example.com/ \n saved date: Wed Aug 13 2025 \n",
29
- resources: { stylesheets: [], images: [] }
30
- };
31
- }
32
-
33
- const restoreDate = freezeDate();
34
- const { bytes } = await runProcess(makeSpecimenPageData(), makeOptions({
35
- zipScript,
36
- url: "https://example.com/",
37
- insertCanonicalLink: false
38
- }));
39
- restoreDate();
40
-
41
- const text = new TextDecoder("windows-1252").decode(bytes);
42
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
43
- const at = needle => text.indexOf(needle);
44
-
45
- const doctype = at("<!DOCTYPE html>");
46
- const root = at("<html data-sfz>");
47
- const charset = at("<meta charset=");
48
- const charsetEnd = charset + text.substring(charset).indexOf(">") + 1;
49
- const comment = at("<!--\n url:");
50
- const title = at("<title>");
51
- const style = at("<style>");
52
- const body = at("<body hidden>");
53
- const script = at("<script>");
54
- const wrapperStart = at("<!--sfz-data");
55
- const region = at("PK\x03\x04");
56
- const central = text.indexOf("PK\x01\x02", region);
57
- const eocd = text.indexOf("PK\x05\x06", central);
58
- const payload = at("<sfz-extra-data>");
59
- const endTags = at("</body></html>");
60
-
61
- console.log(`specimen: ${bytes.length} bytes`);
62
- console.log([
63
- ["html-prologue begins", doctype], ["root element start tag", root],
64
- ["charset declaration", charset], ["implementation comment", comment],
65
- ["title", title], ["stylesheet", style], ["body hidden", body],
66
- ["bootstrap script", script], ["wrapper start tag", wrapperStart],
67
- ["ZIP region begins", region], ["central directory", central],
68
- ["EOCD", eocd], ["recovery payload", payload], ["end tags", endTags]
69
- ].map(([label, offset]) => ` ${String(offset).padStart(7)} ${label}`).join("\n"));
70
-
71
- // the prologue order, which §3.1 and §6.1 both got backwards until 2026-09-01: the charset
72
- // declaration precedes the comment, because the comment carries an unbounded URL
73
- check("doctype opens the file", doctype, 0);
74
- check("root element start tag follows the doctype with nothing between", root, doctype + "<!DOCTYPE html>".length);
75
- check("charset declaration follows the root element start tag", charset, root + "<html data-sfz>".length);
76
- check("the comment begins where the charset declaration ends", comment, charsetEnd);
77
- check("the charset declaration is inside the prescan window", charsetEnd <= 1024, true);
78
-
79
- // the ordering the byte map asserts, region by region
80
- check("prologue order holds end to end",
81
- [doctype, root, charset, comment, title, style, body, script, wrapperStart, region, central, eocd, payload, endTags]
82
- .every((offset, index, all) => offset >= 0 && (index === 0 || offset > all[index - 1])), true);
83
-
84
- // the identifier sits inside the wrapper, so the region starts 12 bytes after the tag (§1.3)
85
- check("the identifier precedes the region by the length of the start tag", region - wrapperStart, "<!--sfz-data".length);
86
-
87
- // offsets are absolute file positions, not region-relative (§5.3)
88
- check("the EOCD directory offset is an absolute file position", view.getUint32(eocd + 16, true), central);
89
- check("the EOCD counts both entries", view.getUint16(eocd + 10, true), 2);
90
- check("the EOCD declares no archive comment", view.getUint16(eocd + 20, true), 0);
91
-
92
- // the region ends at the wrapper close tag, and the payload describes it minus the two
93
- // comment-length bytes (§4.5)
94
- const wrapperEnd = text.indexOf("-->", eocd);
95
- check("the wrapper closes after the EOCD", wrapperEnd > eocd, true);
96
- check("the appended run fits the budget", bytes.length - wrapperEnd <= 65535, true);
97
-
98
- // the entries the document names, in the order it names them (§4.2)
99
- let records = 0;
100
- for (let index = text.indexOf("PK\x01\x02"); index != -1; index = text.indexOf("PK\x01\x02", index + 1)) {
101
- records++;
102
- }
103
- check("the central directory holds two records", records, 2);
104
- check("index.html is listed first", text.indexOf("index.html", central) < text.indexOf("manifest.json", central), true);
105
-
106
- // §4.2: page.pdf's local header lies before the ZIP region, so the prepended-data compensation
107
- // a reader of the recovered region applies takes its offset negative. The magnitude is the
108
- // region's start and moves with the size of the inlined library, so only the relation is checked
109
- {
110
- const pdf = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<< /X (specimen) >>\nendobj\ntrailer\n<<>>\n%%EOF\n");
111
- const restore = freezeDate();
112
- const { bytes: pdfBytes } = await runProcess(makeSpecimenPageData(), makeOptions({
113
- zipScript,
114
- url: "https://example.com/",
115
- insertCanonicalLink: false,
116
- embeddedPdf: pdf
117
- }));
118
- restore();
119
- const pdfText = new TextDecoder("windows-1252").decode(pdfBytes);
120
- const pdfView = new DataView(pdfBytes.buffer, pdfBytes.byteOffset, pdfBytes.byteLength);
121
- const pdfRegion = pdfText.indexOf("PK\x03\x04", pdfText.indexOf("<!--sfz-data"));
122
- let record = pdfText.indexOf("PK\x01\x02", pdfRegion);
123
- let stored;
124
- while (record != -1 && stored === undefined) {
125
- if (pdfText.substr(record + 46, pdfView.getUint16(record + 28, true)) == "page.pdf") {
126
- stored = pdfView.getUint32(record + 42, true);
127
- }
128
- record = pdfText.indexOf("PK\x01\x02", record + 1);
129
- }
130
- check("page.pdf is listed in the central directory", stored !== undefined, true);
131
- check("its local header sits where the central record says", pdfText.indexOf("PK\x03\x04"), stored);
132
- check("its header is inside the PDF scan window", stored < 1024, true);
133
- check("its header lies before the ZIP region", stored < pdfRegion, true);
134
- check("so the compensated offset is negative", stored - pdfRegion < 0, true);
135
- }
136
-
137
- Deno.exit(failed ? 1 : 0);
@@ -1,161 +0,0 @@
1
- /*
2
- * Copyright 2010-2026 Gildas Lormeau
3
- * contact : gildas.lormeau <at> gmail.com
4
- *
5
- * This file is part of SingleFile.
6
- *
7
- * The code in this file is free software: you can redistribute it and/or
8
- * modify it under the terms of the GNU Affero General Public License
9
- * (GNU AGPL) as published by the Free Software Foundation, either version 3
10
- * of the License, or (at your option) any later version.
11
- *
12
- * The code in this file is distributed in the hope that it will be useful,
13
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
15
- * General Public License for more details.
16
- *
17
- * As additional permission under GNU AGPL version 3 section 7, you may
18
- * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
19
- * AGPL normally required by section 4, provided you include this license
20
- * notice and a URL through which recipients can access the Corresponding
21
- * Source.
22
- */
23
-
24
- // Universal mode recovers the ZIP region from the characters the HTML parser produced, so the
25
- // declared charset has to carry all 256 byte values through a decode injectively (§2.1). Which
26
- // encodings do is a property of the WHATWG index, not of this repository, and §8.4 prints the
27
- // answer as a table: 20 qualify, 18 do not, and each qualifying one needs a reverse table of a
28
- // stated size. Nothing re-derived that table -- it was measured once, by hand, outside the repo,
29
- // and would go stale silently if an index changed or the prose were edited.
30
- //
31
- // The last check is the one with teeth. §5.5 requires the reverse table to be derived from the
32
- // WHATWG index and NOT from a platform codec of the same name, because most platform codecs
33
- // leave five windows-1252 positions undefined and those bytes occur in ordinary compressed data.
34
- // The extractor ships that table as a literal, so it is derived once at authoring time and never
35
- // again; here it is re-derived from the runtime's own decoder and compared entry by entry.
36
-
37
- const BYTES = new Uint8Array(256).map((_, index) => index);
38
-
39
- // the 20 of §8.4, in the order the section lists them
40
- const QUALIFYING = [
41
- "windows-1252", "iso-8859-2", "iso-8859-4", "iso-8859-5", "iso-8859-10", "iso-8859-13",
42
- "iso-8859-14", "iso-8859-15", "iso-8859-16", "koi8-r", "koi8-u", "macintosh", "windows-1250",
43
- "windows-1251", "windows-1254", "windows-1256", "windows-1258", "x-mac-cyrillic", "ibm866",
44
- "x-user-defined"
45
- ];
46
- // the 18 that do not: eight single-byte encodings with undefined positions in their index, then
47
- // the multi-byte ones, which decode a lone byte sequence to U+FFFD or to fewer than 256 characters
48
- const DISQUALIFIED = [
49
- "iso-8859-3", "iso-8859-6", "iso-8859-7", "iso-8859-8", "windows-874", "windows-1253",
50
- "windows-1255", "windows-1257",
51
- "utf-8", "utf-16le", "utf-16be", "gbk", "gb18030", "big5", "euc-jp", "shift_jis", "euc-kr",
52
- "iso-2022-jp"
53
- ];
54
-
55
- let failures = 0;
56
-
57
- function describe(label) {
58
- const points = Array.from(new TextDecoder(label).decode(BYTES));
59
- if (points.length != 256) {
60
- return { qualifies: false, reason: points.length + " characters" };
61
- }
62
- const table = new Map();
63
- const seen = new Set();
64
- let identity = 0;
65
- for (let byte = 0; byte < 256; byte++) {
66
- const codePoint = points[byte].codePointAt(0);
67
- if (codePoint == 0xFFFD) {
68
- return { qualifies: false, reason: "U+FFFD at 0x" + byte.toString(16) };
69
- }
70
- if (seen.has(codePoint)) {
71
- return { qualifies: false, reason: "collision at 0x" + byte.toString(16) };
72
- }
73
- seen.add(codePoint);
74
- if (codePoint == byte) {
75
- identity++;
76
- } else {
77
- table.set(codePoint, byte);
78
- }
79
- }
80
- return { qualifies: true, identity, table, points };
81
- }
82
-
83
- const described = new Map([...QUALIFYING, ...DISQUALIFIED].map(label => [label, describe(label)]));
84
-
85
- check("§8.4 covers the whole standard: 20 qualifying + 18 disqualified",
86
- QUALIFYING.length + DISQUALIFIED.length == 38 && new Set([...QUALIFYING, ...DISQUALIFIED]).size == 38);
87
-
88
- const wrongVerdict = [...described].filter(([label, result]) =>
89
- result.qualifies != QUALIFYING.includes(label));
90
- check("every encoding falls on the side of the table §8.4 puts it on", wrongVerdict.length == 0,
91
- wrongVerdict.map(([label, result]) => label + " " + (result.reason || "qualifies")).join(", "));
92
-
93
- // §8.4 quotes the extremes of the reverse-table sizes; they bound what an implementation has to
94
- // carry to support any qualifying charset rather than only the one the reference writer declares
95
- const qualifying = QUALIFYING.filter(label => described.get(label).qualifies);
96
- const sizes = qualifying.map(label => [label, described.get(label).table.size]);
97
- const smallest = Math.min(...sizes.map(([, size]) => size));
98
- const largest = Math.max(...sizes.map(([, size]) => size));
99
- check("the smallest reverse table is 8 entries, iso-8859-15", smallest == 8 &&
100
- sizes.filter(([, size]) => size == smallest).map(([label]) => label).join() == "iso-8859-15");
101
- check("the largest is 128, for koi8-r, koi8-u, ibm866 and x-user-defined", largest == 128 &&
102
- sizes.filter(([, size]) => size == largest).map(([label]) => label).sort().join() ==
103
- "ibm866,koi8-r,koi8-u,x-user-defined");
104
-
105
- const windows1252 = described.get("windows-1252");
106
- check("windows-1252 decodes 229 of the 256 values to themselves (§5.5 rule 1)",
107
- windows1252.identity == 229, String(windows1252.identity));
108
- check("its reverse table is the remaining 27 (§5.5 rule 2)", windows1252.table.size == 27,
109
- String(windows1252.table.size));
110
-
111
- // the trap of §5.5: iso-8859-1 is a LABEL of windows-1252, not the identity mapping its name
112
- // suggests, so a reader that treats it as latin-1 builds a table with no entries at all
113
- check("iso-8859-1 is a label of windows-1252, not a separate identity encoding",
114
- new TextDecoder("iso-8859-1").encoding == "windows-1252" &&
115
- new TextDecoder("latin1").encoding == "windows-1252");
116
-
117
- // the five positions of the §5.5 table: the WHATWG index assigns them, most platform codecs do not
118
- check("the WHATWG index assigns 0x81, 0x8D, 0x8F, 0x90 and 0x9D (§5.5)",
119
- [0x81, 0x8D, 0x8F, 0x90, 0x9D].every(byte =>
120
- windows1252.points[byte].codePointAt(0) == byte && !windows1252.table.has(byte)));
121
-
122
- // §8.4's caveat on x-user-defined: it qualifies on the criterion and is still a poor choice
123
- check("x-user-defined maps 0x80-0xFF into the Private Use Area, U+F780-U+F7FF",
124
- [...Array(128).keys()].every(index =>
125
- described.get("x-user-defined").points[128 + index].codePointAt(0) == 0xF780 + index));
126
-
127
- // the round trip of §5.5 rules 1 and 2, on every byte value and every qualifying charset. NUL and
128
- // the newlines need rules 3 and 4 in a browser, where the parser has replaced and normalized them;
129
- // through a decoder alone they arrive intact, so the mapping is exact for all 256 values here
130
- const broken = qualifying.filter(label => {
131
- const { table, points } = described.get(label);
132
- return points.some((character, byte) => {
133
- const codePoint = character.codePointAt(0);
134
- return (table.has(codePoint) ? table.get(codePoint) : codePoint) != byte;
135
- });
136
- });
137
- check("all 256 byte values survive decode and reverse mapping, under every qualifying charset",
138
- broken.length == 0, broken.join(", "));
139
-
140
- // the extractor's own table, re-derived. It is inlined into every archive, so an error here is
141
- // not caught by any build step and corrupts one byte per occurrence in the recovered region
142
- const compression = await Deno.readTextFile(new URL("../../processors/compression/compression.js", import.meta.url));
143
- const literal = compression.slice(compression.indexOf("const characterMap = new Map(["));
144
- const shipped = new Map([...literal.slice(0, literal.indexOf("]);")).matchAll(/\[(\d+),\s*(\d+)\]/g)]
145
- .map(([, codePoint, byte]) => [Number(codePoint), Number(byte)]));
146
- const expected = new Map([[0xFFFD, 0], ...windows1252.table]);
147
- const wrongEntries = [...expected].filter(([codePoint, byte]) => shipped.get(codePoint) !== byte);
148
- const extraEntries = [...shipped].filter(([codePoint]) => !expected.has(codePoint));
149
- check("the extractor's characterMap is the derived windows-1252 table plus U+FFFD (28 entries)",
150
- shipped.size == 28 && wrongEntries.length == 0 && extraEntries.length == 0,
151
- "missing/wrong " + JSON.stringify(wrongEntries) + " extra " + JSON.stringify(extraEntries));
152
-
153
- console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
154
- Deno.exit(failures ? 1 : 0);
155
-
156
- function check(label, condition, detail) {
157
- if (!condition) {
158
- failures++;
159
- }
160
- console.log((condition ? "PASS" : "FAIL") + " " + label + (condition || !detail ? "" : ": " + detail));
161
- }