single-file-core 1.5.127 → 1.5.128

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/core/util.js CHANGED
@@ -63,6 +63,8 @@ const CONTENT_TYPE_EXTENSIONS = {
63
63
  "font/collection": ".ttc"
64
64
  };
65
65
  const CONTENT_TYPE_OCTET_STREAM = "application/octet-stream";
66
+ const TRANSPORT_STREAM_SYNC_BYTE = 71;
67
+ const TRANSPORT_STREAM_PACKET_SIZE = 188;
66
68
  const CONTENT_TYPES_HTML = ["text/html", "application/xhtml+xml"];
67
69
  const EXPECTED_TYPES_MEDIA = ["font", "image", "video", "audio"];
68
70
 
@@ -295,11 +297,11 @@ function getInstance(utilOptions) {
295
297
  } catch (error) {
296
298
  // ignored
297
299
  }
298
- if (!contentType || (contentType == CONTENT_TYPE_OCTET_STREAM && options.asBinary)) {
299
- contentType = guessMIMEType(options.expectedType, buffer);
300
- if (!contentType) {
301
- contentType = options.contentType ? options.contentType : options.asBinary ? CONTENT_TYPE_OCTET_STREAM : "";
302
- }
300
+ const guessedContentType = guessMIMEType(options.expectedType, buffer);
301
+ if (guessedContentType) {
302
+ contentType = guessedContentType;
303
+ } else if (!contentType || (contentType == CONTENT_TYPE_OCTET_STREAM && options.asBinary)) {
304
+ contentType = options.contentType ? options.contentType : options.asBinary ? CONTENT_TYPE_OCTET_STREAM : "";
303
305
  }
304
306
  if (!charset && options.charset) {
305
307
  charset = options.charset;
@@ -403,6 +405,24 @@ function guessMIMEType(expectedType, buffer) {
403
405
  if (compareBytes([255, 255, 255], [255, 216, 255])) {
404
406
  return "image/jpeg";
405
407
  }
408
+ if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 97, 118, 105, 102]) ||
409
+ compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 97, 118, 105, 115])) {
410
+ return "image/avif";
411
+ }
412
+ if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 105, 99]) ||
413
+ compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 105, 120]) ||
414
+ compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 118, 99]) ||
415
+ compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 118, 120])) {
416
+ return "image/heic";
417
+ }
418
+ if (compareBytes([255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 12, 74, 88, 76, 32, 13, 10, 135, 10]) ||
419
+ compareBytes([255, 255], [255, 10])) {
420
+ return "image/jxl";
421
+ }
422
+ if (compareBytes([255, 255, 255, 255], [73, 73, 42, 0]) ||
423
+ compareBytes([255, 255, 255, 255], [77, 77, 0, 42])) {
424
+ return "image/tiff";
425
+ }
406
426
  }
407
427
  if (expectedType == "font") {
408
428
  if (compareBytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255],
@@ -444,7 +464,7 @@ function guessMIMEType(expectedType, buffer) {
444
464
  if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 51, 103])) {
445
465
  return "video/3gpp";
446
466
  }
447
- if (compareBytes([255], [71])) {
467
+ if (isTransportStream()) {
448
468
  return "video/mp2t";
449
469
  }
450
470
  }
@@ -475,6 +495,14 @@ function guessMIMEType(expectedType, buffer) {
475
495
  }
476
496
  }
477
497
 
498
+ function isTransportStream() {
499
+ const value = new Uint8Array(buffer);
500
+ return value.length > TRANSPORT_STREAM_PACKET_SIZE * 2 &&
501
+ value[0] == TRANSPORT_STREAM_SYNC_BYTE &&
502
+ value[TRANSPORT_STREAM_PACKET_SIZE] == TRANSPORT_STREAM_SYNC_BYTE &&
503
+ value[TRANSPORT_STREAM_PACKET_SIZE * 2] == TRANSPORT_STREAM_SYNC_BYTE;
504
+ }
505
+
478
506
  function compareBytes(mask, pattern) {
479
507
  let patternMatch = true, index = 0;
480
508
  if (buffer.byteLength >= pattern.length) {
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
1
  {
2
- "name": "single-file-core",
3
- "version": "1.5.127",
4
- "description": "SingleFile Core",
5
- "author": "Gildas Lormeau",
6
- "license": "AGPL-3.0-or-later",
7
- "scripts": {
8
- "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/font-face-composite.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js && deno run --allow-read test/sfz-harness/charset-round-trip.js",
9
- "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
- "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
- "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
12
- "bump-commit": "git commit -m \"bump up version\" package.json package-lock.json"
13
- },
14
- "repository": {
15
- "type": "git",
16
- "url": "git+https://github.com/gildas-lormeau/single-file-core.git"
17
- },
18
- "bugs": {
19
- "url": "https://github.com/gildas-lormeau/single-file-core/issues"
20
- },
21
- "homepage": "https://github.com/gildas-lormeau/single-file-core#readme",
22
- "devDependencies": {
23
- "@eslint/js": "^9.39.5",
24
- "eslint": "^10.9.1"
25
- }
2
+ "name": "single-file-core",
3
+ "version": "1.5.128",
4
+ "description": "SingleFile Core",
5
+ "author": "Gildas Lormeau",
6
+ "license": "AGPL-3.0-or-later",
7
+ "scripts": {
8
+ "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/font-face-composite.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/pages-router.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/content-type-sniffing.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js && deno run --allow-read test/sfz-harness/charset-round-trip.js",
9
+ "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
+ "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
+ "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
12
+ "bump-commit": "git commit -m \"bump up version\" package.json package-lock.json"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/gildas-lormeau/single-file-core.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/gildas-lormeau/single-file-core/issues"
20
+ },
21
+ "homepage": "https://github.com/gildas-lormeau/single-file-core#readme",
22
+ "devDependencies": {
23
+ "@eslint/js": "^9.39.5",
24
+ "eslint": "^10.9.1"
25
+ }
26
26
  }
@@ -32,7 +32,8 @@ import {
32
32
  } from "./../../vendor/zip/zip.js";
33
33
  import {
34
34
  createArchive,
35
- escapeHTML
35
+ escapeHTML,
36
+ PROCESS_OPTION_NAMES
36
37
  } from "./compression.js";
37
38
 
38
39
  const browser = globalThis.browser;
@@ -46,6 +47,13 @@ const TOC_STYLE = "body{font-family:system-ui,sans-serif;margin:2em auto;max-wid
46
47
  "summary{cursor:pointer;font-weight:bold;margin:.5em 0}" +
47
48
  "details{padding-left:1em}ul{margin:.25em 0;padding-left:1.5em}" +
48
49
  "@media(prefers-color-scheme:dark){body{background-color:#111;color:#eee}a{color:#8ab4f8}a:visited{color:#c58af9}}";
50
+ const ARCHIVE_EXCLUDED_OPTION_NAMES = [
51
+ "createRootDirectory",
52
+ "disableCompression",
53
+ "insertTextBody",
54
+ "password",
55
+ "url"
56
+ ];
49
57
  const COMMENT_HEADER = "Page saved with SingleFile";
50
58
  const SYMLINK_UNIX_MODE = 0o120777;
51
59
 
@@ -80,18 +88,11 @@ async function createPagesArchive(pages, options) {
80
88
  };
81
89
  const archiveOptions = {
82
90
  url: pages[0].url,
83
- multiPageArchive: true,
84
- selfExtractingArchive: options.selfExtractingArchive,
85
- extractDataFromPage: options.extractDataFromPage,
86
- preventAppendedData: options.preventAppendedData,
87
- declareAppendedData: options.declareAppendedData,
88
- embeddedPdf: options.embeddedPdf,
89
- embeddedImage: options.embeddedImage,
90
- includeBOM: options.includeBOM,
91
- insertMetaCSP: options.insertMetaCSP,
92
- insertCanonicalLink: options.insertCanonicalLink,
93
- insertMetaNoIndex: options.insertMetaNoIndex
91
+ multiPageArchive: true
94
92
  };
93
+ PROCESS_OPTION_NAMES
94
+ .filter(name => !ARCHIVE_EXCLUDED_OPTION_NAMES.includes(name) && !(name in archiveOptions))
95
+ .forEach(name => archiveOptions[name] = options[name]);
95
96
  const writtenEntries = options.dedupPages ? new Map() : undefined;
96
97
  const aliases = {};
97
98
  const blob = await createArchive(pageData, archiveOptions, options.zipScript, async zipWriter => {
@@ -202,7 +202,7 @@ async function router(content, { extract, display }) {
202
202
  function parseRoute() {
203
203
  const hash = location.hash;
204
204
  const routed = !hash || hash.startsWith(ROUTE_PREFIX);
205
- let path = pages[0].path;
205
+ let path = routed && tocEntry ? TOC_ROUTE : pages[0].path;
206
206
  let fragment;
207
207
  if (routed && hash) {
208
208
  ({ path, fragment } = parseRouteHash(hash));
@@ -34,7 +34,9 @@ any check failed.
34
34
  | `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. |
35
35
  | `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. |
36
36
  | `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. |
37
- | `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. |
37
+ | `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. |
38
+ | `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. |
39
+ | `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. |
38
40
  | `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. |
39
41
  | `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. |
40
42
  | `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. |
@@ -0,0 +1,83 @@
1
+ // `core/util.js` used to trust any Content-Type the server sent and sniff the bytes only when the
2
+ // header was missing or `application/octet-stream`. science.org serves its woff2 files as
3
+ // `text/plain;charset=UTF-8` — 12 of them — and every one was embedded as `data:text/plain`, which
4
+ // costs twice: a browser with no `format()` hint in the `@font-face` rule has nothing left to
5
+ // identify the font by, and the SFZ writer deflates a file that is already Brotli-compressed
6
+ // because it decides compression from the content type.
7
+ //
8
+ // The rule now: a magic-byte match wins over the header, and the header is kept only when nothing
9
+ // matches. That raises the bar for the sniffer's own rules, which is why `video/mp2t` no longer
10
+ // matches on a single `0x47` byte — as a last resort behind a missing header that was tolerable,
11
+ // as an override of a correct header it would relabel any video whose first byte is `G`. It now
12
+ // wants the sync byte at the 188-byte packet stride, and the case below is the regression guard.
13
+ /* global Response */
14
+
15
+ import "./dom-stub.js";
16
+ // util.js pulls in the page-world hooks, which register a document listener as they are evaluated.
17
+ // None of it is exercised here; the stubs exist so that importing util.js is possible at all
18
+ globalThis.window = globalThis.window || {};
19
+ globalThis.document = globalThis.document || {};
20
+ globalThis.Document = globalThis.Document || class { };
21
+ globalThis.MutationObserver = globalThis.MutationObserver || class {
22
+ observe() { }
23
+ };
24
+ const { getInstance } = await import("./../../core/util.js");
25
+
26
+ const FONT_URL = "https://example.com/font";
27
+ const WOFF2 = bytes([0x77, 0x4F, 0x46, 0x32], 64);
28
+ const PNG = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 64);
29
+ const AVIF = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 64);
30
+ const WEBP2 = bytes([0x77, 0x70, 0x32, 0x20], 64);
31
+ const HEIF_MIF1 = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x69, 0x66, 0x31], 64);
32
+ const NOT_A_TRANSPORT_STREAM = bytes([0x47, 0x53, 0x54, 0x00], 512);
33
+
34
+ let failed = false;
35
+
36
+ // [label, bytes, expectedType, the Content-Type the server sent, the type that must reach the data URI]
37
+ const CASES = [
38
+ ["a woff2 served as text/plain is embedded as font/woff2", WOFF2, "font", "text/plain;charset=UTF-8", "font/woff2"],
39
+ ["a woff2 served as font/woff2 is unchanged", WOFF2, "font", "font/woff2", "font/woff2"],
40
+ ["a woff2 served with no type at all is embedded as font/woff2", WOFF2, "font", undefined, "font/woff2"],
41
+ ["a png served as text/plain is embedded as image/png", PNG, "image", "text/plain", "image/png"],
42
+ ["an avif served as application/octet-stream is embedded as image/avif", AVIF, "image", "application/octet-stream", "image/avif"],
43
+ // "mif1" is the generic HEIF brand and an AVIF may carry it too, so it identifies nothing on
44
+ // its own: claiming HEIC here would relabel an AVIF as a format no browser decodes
45
+ ["an ambiguous HEIF brand keeps the type the server sent", HEIF_MIF1, "image", "image/avif", "image/avif"],
46
+ // nothing matches these bytes, so the header is all there is and it has to survive
47
+ ["a format the sniffer does not know keeps the type the server sent", WEBP2, "image", "image/webp2", "image/webp2"],
48
+ ["a format the sniffer does not know and no type falls back to octet-stream", WEBP2, "image", undefined, "application/octet-stream"],
49
+ // the byte is 0x47, the packet stride is not, so this is not a transport stream
50
+ ["a video starting with G is not relabelled as mp2t", NOT_A_TRANSPORT_STREAM, "video", "video/quicktime", "video/quicktime"]
51
+ ];
52
+
53
+ for (const [label, data, expectedType, sentContentType, expectedContentType] of CASES) {
54
+ const { data: dataURI } = await fetchContent(data, expectedType, sentContentType);
55
+ check(label, readDataURIType(dataURI), expectedContentType);
56
+ }
57
+
58
+ console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
59
+ Deno.exit(failed ? 1 : 0);
60
+
61
+ function fetchContent(data, expectedType, contentType) {
62
+ const util = getInstance({
63
+ fetch: async () => new Response(data, { headers: contentType ? { "content-type": contentType } : {} })
64
+ });
65
+ return util.getContent(FONT_URL, { asBinary: true, inline: true, expectedType });
66
+ }
67
+
68
+ function readDataURIType(dataURI) {
69
+ const indexSeparator = dataURI.indexOf(";");
70
+ return dataURI.substring("data:".length, indexSeparator == -1 ? dataURI.indexOf(",") : indexSeparator);
71
+ }
72
+
73
+ function bytes(signature, length) {
74
+ const value = new Uint8Array(length);
75
+ value.set(signature);
76
+ return value;
77
+ }
78
+
79
+ function check(label, actual, expected) {
80
+ const ok = actual === expected;
81
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
82
+ failed ||= !ok;
83
+ }
@@ -13,7 +13,7 @@
13
13
  // - the titles written into the table of contents are CRAWLED, so they are attacker-controlled
14
14
  // text going into an href attribute and into element content. Both escapers are checked here.
15
15
  import "./dom-stub.js";
16
- import { makePageData, makeOptions, runProcess } from "./common.js";
16
+ import { makePageData, makeOptions, runProcess, freezeDate } from "./common.js";
17
17
  import { createPagesArchive } from "../../processors/compression/compression-packager.js";
18
18
  import { ZipReader, ZipWriter, BlobReader, TextReader, TextWriter, Uint8ArrayWriter } from "../../vendor/zip/zip.js";
19
19
 
@@ -175,6 +175,33 @@ const pages = [
175
175
  new TextDecoder("windows-1252").decode(await createPagesArchive(pages, packagerOptions())).includes("<nav><ul>"), false);
176
176
  }
177
177
 
178
+ // the options handed to the archive writer are DERIVED from PROCESS_OPTION_NAMES, not hand-listed.
179
+ // The hand copy carried eleven names and silently dropped maxAppendedDataLength, so
180
+ // --max-appended-data-length did nothing on any multi-page save and nothing failed for months.
181
+ // A one-byte budget has to reach the writer, where it is indistinguishable from refusing to append
182
+ {
183
+ const unfreeze = freezeDate();
184
+ try {
185
+ const budgeted = await createPagesArchive(pages, packagerOptions({ maxAppendedDataLength: 1 }));
186
+ const prevented = await createPagesArchive(pages, packagerOptions({ preventAppendedData: true }));
187
+ const unbudgeted = await createPagesArchive(pages, packagerOptions());
188
+ check("a one-byte appended-data budget reaches the archive writer", equalData(budgeted, prevented), true);
189
+ check("and appending is what the writer does without one", equalData(unbudgeted, prevented), false);
190
+ } finally {
191
+ unfreeze();
192
+ }
193
+ }
194
+
195
+ // `password` is the one name the derivation must NOT forward. An encrypted multi-page archive
196
+ // cannot be written yet, and forwarding the password would half-ship it: the writer would start
197
+ // withholding the prologue's title as if the archive were encrypted, while the table of contents
198
+ // and every entry comment — each one a resource URL — kept riding in that same cleartext prologue
199
+ {
200
+ const prologue = new TextDecoder("windows-1252").decode(await createPagesArchive(pages, packagerOptions({ password: "secret" })));
201
+ check("a password is not forwarded to the archive writer",
202
+ prologue.includes("<title>Intro &#38; &#34;start&#34; &#60;b&#62;</title>"), true);
203
+ }
204
+
178
205
  console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
179
206
  Deno.exit(failed ? 1 : 0);
180
207
 
@@ -0,0 +1,117 @@
1
+ // The router picks the page a multi-page archive opens on. Nothing drove it until this file: the
2
+ // function is inlined into the archive as source text, so it only ever ran inside a saved page,
3
+ // and the suites around it checked what the packager WROTE rather than what a reader would see.
4
+ // That is how the table of contents shipped unreachable. It was stored, the route existed, and
5
+ // no link and no landing rule pointed at it, so --crawl-save-archive-toc looked like it did
6
+ // nothing at all.
7
+ //
8
+ // The rule this file pins: an archive that stores a table of contents opens on it, and one that
9
+ // does not opens on the first page. Two cases guard the edges of that rule. A route in the hash
10
+ // names a page explicitly and has to win over the landing rule, or every deep link into an
11
+ // archive would land on the table of contents instead. A hash that is NOT a route is a plain
12
+ // fragment, and the only page it can mean is the first one, which is where the archive used to
13
+ // land before the fragment was ever read.
14
+ import "./dom-stub.js";
15
+ import { makePageData, makeOptions, runProcess } from "./common.js";
16
+ import { createPagesArchive } from "../../processors/compression/compression-packager.js";
17
+ import { router } from "../../processors/compression/compression-router.js";
18
+ import * as zip from "../../vendor/zip/zip.js";
19
+
20
+ const ARCHIVE_URL = "https://example.com/archive.html";
21
+
22
+ let failed = false;
23
+
24
+ const pages = [
25
+ await makePage(1, { url: "https://example.com/docs/intro.html", title: "Intro" }),
26
+ await makePage(2, { url: "https://example.com/docs/api/reference.html", title: "Reference" })
27
+ ];
28
+ const withTOC = await createPagesArchive(pages, packagerOptions({ tocPage: true }));
29
+ const withoutTOC = await createPagesArchive(pages, packagerOptions());
30
+
31
+ {
32
+ const content = await open(withTOC);
33
+ check("an archive holding a table of contents opens on it", content.includes("<h1>Table of contents</h1>"), true);
34
+ }
35
+
36
+ {
37
+ const content = await open(withoutTOC);
38
+ check("an archive holding no table of contents opens on the first page", content, "page at \"\"");
39
+ }
40
+
41
+ {
42
+ const content = await open(withTOC, "#sfz/pages/2/");
43
+ check("a route in the hash names the page to open", content, "page at \"pages/2/\"");
44
+ }
45
+
46
+ // a bare fragment is what a hand-written link into the saved page looks like. The router scrolls
47
+ // to it after rendering, and the table of contents is not the document it belongs to
48
+ {
49
+ const content = await open(withTOC, "#introduction");
50
+ check("a hash that is not a route opens the first page", content, "page at \"\"");
51
+ }
52
+
53
+ console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
54
+ Deno.exit(failed ? 1 : 0);
55
+
56
+ // the router reads its world out of globalThis and renders through the two functions it is given,
57
+ // so a stub of each is enough to see the page it chose. Only what the first render touches is
58
+ // stubbed here; navigation, scroll restoration and link marking read more of the DOM than this
59
+ async function open(bytes, hash = "") {
60
+ let displayed;
61
+ installEnvironment(hash);
62
+ await router(new Blob([bytes]), {
63
+ extract: (content, { pagePath }) => ({ docContent: "page at " + JSON.stringify(pagePath) }),
64
+ display: (document, docContent) => displayed = docContent
65
+ });
66
+ return displayed;
67
+ }
68
+
69
+ function installEnvironment(hash) {
70
+ // the router asks for web workers, which the archive serves from its own extension URL. There
71
+ // is no such URL here, so the request is answered with the synchronous codec instead
72
+ globalThis.zip = { ...zip, configure: options => zip.configure({ ...options, useWebWorkers: false }) };
73
+ globalThis.document = {
74
+ head: { appendChild() { } },
75
+ styleSheets: [],
76
+ createElement: () => ({ setAttribute() { }, remove() { } }),
77
+ querySelectorAll: () => [],
78
+ querySelector: () => null,
79
+ getElementById: () => null
80
+ };
81
+ globalThis.history = {
82
+ state: null,
83
+ scrollRestoration: "auto",
84
+ replaceState(state) {
85
+ this.state = state;
86
+ }
87
+ };
88
+ // Deno defines location as a getter that throws without --location, so it is replaced rather
89
+ // than assigned
90
+ Object.defineProperty(globalThis, "location", {
91
+ value: { href: ARCHIVE_URL + hash, hash },
92
+ configurable: true,
93
+ writable: true
94
+ });
95
+ }
96
+
97
+ async function makePage(seed, { url, title }) {
98
+ const pageData = makePageData(seed, 2 * 1024);
99
+ pageData.title = title;
100
+ const { bytes } = await runProcess(pageData, makeOptions({ url }));
101
+ return { url, title, getData: async () => bytes };
102
+ }
103
+
104
+ function packagerOptions(overrides = {}) {
105
+ return {
106
+ selfExtractingArchive: true,
107
+ extractDataFromPage: true,
108
+ zipScript: "/* zip script stub */",
109
+ ...overrides
110
+ };
111
+ }
112
+
113
+ function check(label, actual, expected) {
114
+ const ok = actual === expected;
115
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
116
+ failed ||= !ok;
117
+ }