single-file-core 1.5.85 → 1.5.87

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.85",
3
+ "version": "1.5.87",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -27,7 +27,7 @@ export {
27
27
  extract
28
28
  };
29
29
 
30
- async function extract(content, { password, prompt = () => { }, zipOptions = { useWebWorkers: true }, noBlobURL } = {}) {
30
+ async function extract(content, { password, prompt = () => { }, zipOptions = { useWebWorkers: true }, noBlobURL, entries, pagePath = "" } = {}) {
31
31
  const KNOWN_MIMETYPES = {
32
32
  "gif": "image/gif",
33
33
  "jpg": "image/jpeg",
@@ -74,22 +74,28 @@ async function extract(content, { password, prompt = () => { }, zipOptions = { u
74
74
  const REGEXP_MATCH_MANIFEST = /manifest\.json$/;
75
75
  const CHARSET_UTF8 = ";charset=utf-8";
76
76
  const REGEXP_ESCAPE = /([{}()^$&.*?/+|[\\\\]|\]|-)/g;
77
- let reader;
77
+ let zipReader;
78
78
  zip.configure(zipOptions);
79
- if (content.readUint8Array) {
80
- reader = content;
81
- } else {
82
- if (Array.isArray(content)) {
83
- content = new Blob([new Uint8Array(content)]);
79
+ if (!entries) {
80
+ let reader;
81
+ if (content.readUint8Array) {
82
+ reader = content;
83
+ } else {
84
+ if (Array.isArray(content)) {
85
+ content = new Blob([new Uint8Array(content)]);
86
+ }
87
+ reader = new zip.BlobReader(content);
84
88
  }
85
- reader = new zip.BlobReader(content);
89
+ zipReader = new zip.ZipReader(reader);
90
+ entries = await zipReader.getEntries();
91
+ }
92
+ if (pagePath) {
93
+ entries = entries.filter(entry => entry.filename.startsWith(pagePath));
86
94
  }
87
- const zipReader = new zip.ZipReader(reader);
88
- const entries = await zipReader.getEntries();
89
95
  const options = { password };
90
96
  let docContent, origDocContent, url, resources = [], indexPages = [], textResources = [];
91
97
  await Promise.all(entries.map(async entry => {
92
- const { filename } = entry;
98
+ const filename = entry.filename.substring(pagePath.length);
93
99
  let dataWriter, content, textContent, mimeType;
94
100
  const resourceInfo = {};
95
101
  if (!options.password && entry.encrypted) {
@@ -131,7 +137,7 @@ async function extract(content, { password, prompt = () => { }, zipOptions = { u
131
137
  content = URL.createObjectURL(blob);
132
138
  }
133
139
  }
134
- const name = entry.filename.match(/^([0-9_]+\/)?(.*)$/)[2];
140
+ const name = filename.match(/^([0-9_]+\/)?(.*)$/)[2];
135
141
  let prefixPath = "";
136
142
  const prefixPathMatch = filename.match(/(.*\/)[^/]+$/);
137
143
  if (prefixPathMatch && prefixPathMatch[1]) {
@@ -139,7 +145,7 @@ async function extract(content, { password, prompt = () => { }, zipOptions = { u
139
145
  }
140
146
  Object.assign(resourceInfo, {
141
147
  prefixPath,
142
- filename: entry.filename,
148
+ filename,
143
149
  name,
144
150
  url: entry.comment,
145
151
  content,
@@ -148,7 +154,9 @@ async function extract(content, { password, prompt = () => { }, zipOptions = { u
148
154
  parentResources: []
149
155
  });
150
156
  }));
151
- await zipReader.close();
157
+ if (zipReader) {
158
+ await zipReader.close();
159
+ }
152
160
  indexPages.sort(sortByFilenameLengthDec);
153
161
  textResources.sort(sortByFilenameLengthInc);
154
162
  resources = resources.sort(sortByFilenameLengthDec).concat(...textResources).concat(...indexPages);
@@ -0,0 +1,109 @@
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
+ /* global globalThis */
25
+
26
+ export {
27
+ router
28
+ };
29
+
30
+ async function router(content, { extract, display }) {
31
+ const PAGES_PREFIX = "pages/";
32
+ const PAGES_FILENAME = "sfz-pages.json";
33
+ const ROUTE_PREFIX = "#sfz/";
34
+ const { zip, document, location } = globalThis;
35
+ const cache = new Map();
36
+ const urlToPath = new Map();
37
+ let currentPath;
38
+ zip.configure({ useWebWorkers: true });
39
+ const zipReader = new zip.ZipReader(content.readUint8Array ? content : new zip.BlobReader(content));
40
+ const entries = await zipReader.getEntries();
41
+ const pagesEntry = entries.find(entry => entry.filename == PAGES_FILENAME);
42
+ if (!pagesEntry) {
43
+ throw new Error("Pages data not found");
44
+ }
45
+ const { pages } = JSON.parse(await pagesEntry.getData(new zip.TextWriter()));
46
+ pages.forEach(page => {
47
+ urlToPath.set(stripFragment(page.url), page.path);
48
+ if (page.originalUrls) {
49
+ page.originalUrls.forEach(url => urlToPath.set(stripFragment(url), page.path));
50
+ }
51
+ });
52
+ attachListeners();
53
+ return renderRoute();
54
+
55
+ // document.open() removes the listeners of the document and of its window,
56
+ // re-attaching identical function references is idempotent
57
+ function attachListeners() {
58
+ globalThis.addEventListener("click", interceptClick, true);
59
+ globalThis.addEventListener("hashchange", onHashChange);
60
+ }
61
+
62
+ function onHashChange() {
63
+ if (location.hash.startsWith(ROUTE_PREFIX) || !location.hash) {
64
+ renderRoute().catch(error => globalThis.console.error(error));
65
+ }
66
+ }
67
+
68
+ function interceptClick(event) {
69
+ let node = event.target;
70
+ while (node && node.tagName != "A") {
71
+ node = node.parentNode;
72
+ }
73
+ if (node && node.href) {
74
+ const path = urlToPath.get(stripFragment(node.href));
75
+ if (path !== undefined) {
76
+ event.preventDefault();
77
+ location.hash = ROUTE_PREFIX + path;
78
+ }
79
+ }
80
+ }
81
+
82
+ async function renderRoute() {
83
+ const hash = decodeURIComponent(location.hash);
84
+ const path = hash.startsWith(ROUTE_PREFIX) ? hash.substring(ROUTE_PREFIX.length) : pages[0].path;
85
+ if (path == currentPath || !pages.find(page => page.path == path)) {
86
+ return;
87
+ }
88
+ const docContent = await getPageContent(path);
89
+ currentPath = path;
90
+ await display(document, docContent);
91
+ attachListeners();
92
+ }
93
+
94
+ async function getPageContent(path) {
95
+ if (!cache.has(path)) {
96
+ const pageEntries = entries.filter(entry => path == "" ?
97
+ !entry.filename.startsWith(PAGES_PREFIX) && entry.filename != PAGES_FILENAME :
98
+ entry.filename.startsWith(path));
99
+ const { docContent } = await extract(null, { entries: pageEntries, pagePath: path });
100
+ cache.set(path, docContent);
101
+ }
102
+ return cache.get(path);
103
+ }
104
+
105
+ function stripFragment(url) {
106
+ const indexFragment = url.indexOf("#");
107
+ return indexFragment == -1 ? url : url.substring(0, indexFragment);
108
+ }
109
+ }
@@ -37,6 +37,9 @@ import {
37
37
  import {
38
38
  display
39
39
  } from "./compression-display.js";
40
+ import {
41
+ router
42
+ } from "./compression-router.js";
40
43
 
41
44
  const { Blob, fetch, TextEncoder, TextDecoder, DOMParser } = globalThis;
42
45
 
@@ -87,7 +90,8 @@ const PNG_IHDR_LENGTH = 25;
87
90
  const browser = globalThis.browser;
88
91
 
89
92
  export {
90
- process
93
+ process,
94
+ createArchive
91
95
  };
92
96
 
93
97
  async function process(pageData, options, lastModDate = new Date()) {
@@ -98,16 +102,24 @@ async function process(pageData, options, lastModDate = new Date()) {
98
102
  configure({ workerURI: "/lib/single-file-z-worker.js" });
99
103
  script = await (await fetch(browser.runtime.getURL(SCRIPT_PATH))).text();
100
104
  }
105
+ return createArchive(pageData, options, script, zipWriter => {
106
+ pageData.url = options.url;
107
+ pageData.archiveTime = (new Date()).toISOString();
108
+ return addPageResources(zipWriter, pageData, { password: options.password, disableCompression: options.disableCompression }, options.createRootDirectory ? String(Date.now()) + "_" + (options.tabId || 0) + "/" : "", options.url);
109
+ }, lastModDate);
110
+ }
111
+
112
+ async function createArchive(pageData, options, script, writeEntries, lastModDate = new Date()) {
101
113
  const zipDataWriter = new Uint8ArrayWriter();
102
114
  zipDataWriter.init();
103
115
  zipDataWriter.writable.size = 0;
104
116
  let extraDataOffset, extraData, embeddedImageDataOffset, endTag;
105
117
  if (options.embeddedImage) {
106
- options.embeddedImage = Array.from(options.embeddedImage);
118
+ options.embeddedImage = new Uint8Array(options.embeddedImage);
107
119
  const embeddedImageData = options.embeddedImage.slice(PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH, options.embeddedImage.length - PNG_IEND_LENGTH);
108
120
  await writeData(zipDataWriter.writable, options.embeddedImage.slice(0, PNG_SIGNATURE_LENGTH + PNG_IHDR_LENGTH));
109
121
  if (options.selfExtractingArchive) {
110
- const embeddedImageText = TEXT_DECODER.decode(new Uint8Array(embeddedImageData));
122
+ const embeddedImageText = TEXT_DECODER.decode(embeddedImageData);
111
123
  const tagIndex = EMBEDDED_DATA_REGEXPS.slice(0, -1).findIndex(tests => !embeddedImageText.match(tests[1]));
112
124
  let startTag;
113
125
  [startTag, endTag] = tagIndex == -1 ? ["", ""] : EMBEDDED_DATA_TAGS[tagIndex];
@@ -135,9 +147,7 @@ async function process(pageData, options, lastModDate = new Date()) {
135
147
  }
136
148
  const zipWriter = new ZipWriter(zipDataWriter, { bufferedWrite: true, keepOrder: true, lastModDate, useCompressionStream: true });
137
149
  const startOffset = zipDataWriter.offset;
138
- pageData.url = options.url;
139
- pageData.archiveTime = (new Date()).toISOString();
140
- await addPageResources(zipWriter, pageData, { password: options.password, disableCompression: options.disableCompression }, options.createRootDirectory ? String(Date.now()) + "_" + (options.tabId || 0) + "/" : "", options.url);
150
+ await writeEntries(zipWriter);
141
151
  const data = await zipWriter.close(null, { preventClose: true });
142
152
  if (options.selfExtractingArchive) {
143
153
  const lfCodes = [];
@@ -149,12 +159,12 @@ async function process(pageData, options, lastModDate = new Date()) {
149
159
  const tagIndex = EXTRA_DATA_TAGS.indexOf(options.extractDataFromPageTags);
150
160
  const regExpsTag = EXTRA_DATA_REGEXPS[tagIndex];
151
161
  if (textContent.match(regExpsTag[0]) || textContent.match(regExpsTag[1])) {
152
- return findExtraDataTags(textContent, pageData, options, lastModDate, tagIndex + 1);
162
+ return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, tagIndex + 1);
153
163
  }
154
164
  } else {
155
165
  const matchCommentTags = textContent.match(/<!--/i) || textContent.match(/--!?>/i);
156
166
  if (matchCommentTags) {
157
- return findExtraDataTags(textContent, pageData, options, lastModDate);
167
+ return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate);
158
168
  }
159
169
  }
160
170
  }
@@ -195,12 +205,12 @@ async function process(pageData, options, lastModDate = new Date()) {
195
205
  if (options.preventAppendedData || extraData.length > 65535 - endTags.length - (options.embeddedImage ? PNG_IEND_LENGTH : 0)) {
196
206
  if (!options.extraDataSize) {
197
207
  options.extraDataSize = Math.floor(extraData.length * 1.001);
198
- return process(pageData, options, lastModDate);
208
+ return createArchive(pageData, options, script, writeEntries, lastModDate);
199
209
  }
200
210
  } else {
201
211
  if (options.extraDataSize) {
202
212
  options.extraDataSize = undefined;
203
- return process(pageData, options, lastModDate);
213
+ return createArchive(pageData, options, script, writeEntries, lastModDate);
204
214
  } else {
205
215
  pageContent += extraData;
206
216
  }
@@ -217,7 +227,7 @@ async function process(pageData, options, lastModDate = new Date()) {
217
227
  } else {
218
228
  options.extraData = extraData;
219
229
  options.extraDataSize = Math.floor(extraData.length * 1.001);
220
- return process(pageData, options, lastModDate);
230
+ return createArchive(pageData, options, script, writeEntries, lastModDate);
221
231
  }
222
232
  }
223
233
  if (options.embeddedImage) {
@@ -225,7 +235,7 @@ async function process(pageData, options, lastModDate = new Date()) {
225
235
  return new Blob([
226
236
  pageContent,
227
237
  getCRC32(pageContent, embeddedImageDataOffset),
228
- new Uint8Array(options.embeddedImage.slice(options.embeddedImage.length - PNG_IEND_LENGTH))
238
+ options.embeddedImage.slice(options.embeddedImage.length - PNG_IEND_LENGTH)
229
239
  ], { type: "application/octet-stream" });
230
240
  } else {
231
241
  return new Blob([pageContent], { type: "application/octet-stream" });
@@ -271,6 +281,9 @@ async function prependHTMLData(pageData, zipDataWriter, script, options) {
271
281
  pageContent += "<li style='margin-bottom:10px'><strong>Chrome/Edge/Brave</strong>: Install <a href='https://www.getsinglefile.com'>SingleFile</a> and enable the option \"Allow access to file URLs\" in the details page of the extension.</li>";
272
282
  pageContent += "<li><strong>Safari</strong>: Select \"Security > Disable Local File Restrictions\" in the \"Develop > Developer settings\" menu.</li></ul></div>";
273
283
  }
284
+ if (pageData.tocContent) {
285
+ pageContent += pageData.tocContent;
286
+ }
274
287
  if (options.insertTextBody) {
275
288
  const doc = (new DOMParser()).parseFromString(pageData.content, "text/html");
276
289
  doc.body.querySelectorAll("style, script, noscript").forEach(element => element.remove());
@@ -293,12 +306,17 @@ async function prependHTMLData(pageData, zipDataWriter, script, options) {
293
306
  insertEmbeddedImage: Boolean(options.embeddedImage),
294
307
  insertEmbeddedScreenshotImage: Boolean(options.embeddedScreenshotImage)
295
308
  };
309
+ const bootstrapBody = options.multiPageArchive ?
310
+ "(" + router.toString().replace(/\n|\t/g, "") + ")(content,{extract:" +
311
+ extract.toString().replace(/\n|\t/g, "") + ",display:" +
312
+ display.toString().replace(/\n|\t/g, "") + "})" :
313
+ "(" + extract.toString().replace(/\n|\t/g, "") + ")(content,{prompt}).then(({docContent}) => " +
314
+ display.toString().replace(/\n|\t/g, "") + "(document,docContent," + JSON.stringify(displayOptions) + "))";
296
315
  script = "<script>" +
297
316
  script +
298
317
  "document.currentScript.remove();" +
299
- "globalThis.bootstrap=(()=>{let bootstrapStarted;return async content=>{if (bootstrapStarted) return bootstrapStarted; bootstrapStarted = (" +
300
- extract.toString().replace(/\n|\t/g, "") + ")(content,{prompt}).then(({docContent}) => " +
301
- display.toString().replace(/\n|\t/g, "") + "(document,docContent," + JSON.stringify(displayOptions) + "));return bootstrapStarted;}})();(" +
318
+ "globalThis.bootstrap=(()=>{let bootstrapStarted;return async content=>{if (bootstrapStarted) return bootstrapStarted; bootstrapStarted = " +
319
+ bootstrapBody + ";return bootstrapStarted;}})();(" +
302
320
  getContent.toString().replace(/\n|\t/g, "") + ")().then(globalThis.bootstrap).then(() => document.dispatchEvent(new CustomEvent(\"single-file-display-infobar\"))).catch(error => {" +
303
321
  "console.error(error);" +
304
322
  "const waitMessage = document.getElementById(\"sfz-wait-message\");" +
@@ -376,16 +394,16 @@ function getPageTitle(pageData) {
376
394
  return pageData.title.replace(/</g, "&lt;").replace(/>/g, "&gt;") || "";
377
395
  }
378
396
 
379
- function findExtraDataTags(textContent, pageData, options, lastModDate, indexExtractDataFromPageTags = 0) {
397
+ function findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, indexExtractDataFromPageTags = 0) {
380
398
  const regExpsTag = EXTRA_DATA_REGEXPS[indexExtractDataFromPageTags];
381
399
  const plaintextTag = EXTRA_DATA_TAGS[indexExtractDataFromPageTags][0] == "<plaintext>";
382
400
  const matchTag = !plaintextTag && (textContent.match(regExpsTag[0]) || textContent.match(regExpsTag[1]));
383
401
  if (matchTag) {
384
402
  if (indexExtractDataFromPageTags < EXTRA_DATA_TAGS.length - 1) {
385
- return findExtraDataTags(textContent, pageData, options, lastModDate, indexExtractDataFromPageTags + 1);
403
+ return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, indexExtractDataFromPageTags + 1);
386
404
  } else {
387
405
  options.extractDataFromPage = false;
388
- return process(pageData, options, lastModDate);
406
+ return createArchive(pageData, options, script, writeEntries, lastModDate);
389
407
  }
390
408
  } else {
391
409
  options.extractDataFromPageTags = EXTRA_DATA_TAGS[indexExtractDataFromPageTags];
@@ -393,7 +411,7 @@ function findExtraDataTags(textContent, pageData, options, lastModDate, indexExt
393
411
  // <plaintext> cannot be closed, the file must end with the zip data
394
412
  options.preventAppendedData = true;
395
413
  }
396
- return process(pageData, options, lastModDate);
414
+ return createArchive(pageData, options, script, writeEntries, lastModDate);
397
415
  }
398
416
  }
399
417