single-file-cli 2.3.1 → 2.4.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/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "2.3.1";
1
+ export const version = "2.4.0";
package/options.js CHANGED
@@ -56,6 +56,7 @@ const OPTIONS_INFO = [{
56
56
  "browser-server": { description: "Server to connect to", type: "string", alias: "browser-remote-debugging-url" },
57
57
  "browser-headless": { description: "Run the browser in headless mode", type: "boolean", defaultValue: true },
58
58
  "browser-executable-path": { description: "Path to chrome/chromium executable", type: "string" },
59
+ "browser-profile": { description: "Path of the browser profile directory to use, e.g. to save pages requiring a logged-in session (see --create-browser-profile). The directory is copied before starting the browser and is left unmodified.", type: "string" },
59
60
  "browser-width": { description: "Width of the browser viewport in pixels", type: "number", defaultValue: 1280 },
60
61
  "browser-height": { description: "Height of the browser viewport in pixels", type: "number", defaultValue: 720 },
61
62
  "browser-debug": { description: "Enable debug mode", type: "boolean" },
@@ -203,6 +204,7 @@ const OPTIONS_INFO = [{
203
204
 
204
205
  "save-raw-page": { description: "Save the original page without interpreting it into the browser", type: "boolean" },
205
206
  "output-json": { description: "Output the result as a JSON string containing the page and network info", type: "boolean" },
207
+ "create-browser-profile": { description: "Path of the browser profile directory to create or update instead of saving a page. The browser is started with a visible window on the URL passed as argument, log in to the website and quit the browser to save the profile, then pass it to --browser-profile when saving pages", type: "string" },
206
208
 
207
209
  }, {
208
210
  "help": { description: "Show help", type: "boolean" },
@@ -303,12 +305,22 @@ function getOptions() {
303
305
  invalidOptions.forEach(({ name, value }) => errorMessages.push(value === undefined ?
304
306
  `Missing value for --${name}` :
305
307
  `Invalid value for --${name}: ${JSON.stringify(value)}`));
306
- if (!urls.length && !options.urlsFile) {
308
+ if (!urls.length && !options.urlsFile && !options.createBrowserProfile) {
307
309
  errorMessages.push("The URL or path of the page to save is required");
308
310
  }
309
311
  if (urls.length > 2) {
310
312
  errorMessages.push(`Unexpected arguments: ${urls.slice(2).join(", ")}`);
311
313
  }
314
+ if (options.createBrowserProfile) {
315
+ if (options.browserProfile) {
316
+ errorMessages.push("--create-browser-profile cannot be used with --browser-profile, it already takes the path of the profile directory");
317
+ }
318
+ if (options.browserServer) {
319
+ errorMessages.push("--create-browser-profile cannot be used with --browser-server");
320
+ }
321
+ } else if (options.browserProfile && options.browserServer) {
322
+ errorMessages.push("--browser-profile cannot be used with --browser-server");
323
+ }
312
324
  if (!options.crawlLinks) {
313
325
  const explicitOptions = parseArgs(Array.from(args), false).options;
314
326
  Object.keys(CRAWL_LINKS_DEPENDENT_OPTIONS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -26,7 +26,7 @@
26
26
  import { Buffer } from "node:buffer";
27
27
  import * as backend from "./lib/cdp-client.js";
28
28
  import { getZipScriptSource } from "./lib/single-file-script.js";
29
- import { createPagesArchive } from "./lib/archive-packager.js";
29
+ import { createPagesArchive } from "./lib/single-file-archive.js";
30
30
  import { Deno, path } from "./lib/deno-polyfill.js";
31
31
 
32
32
  const VALID_URL_TEST = /^(https?|file):\/\//;
@@ -252,7 +252,7 @@ async function runNextTask() {
252
252
  task.status = STATE_PROCESSED;
253
253
  if (options.crawlLinks || tasks.length > 1) {
254
254
  const processedCount = tasks.filter(task => task.status == STATE_PROCESSED).length;
255
- const filenameInfo = pageData && pageData.filename && !options.crawlSaveArchive ? " (" + pageData.filename + ")" : "";
255
+ const filenameInfo = pageData && pageData.filename && !options.crawlSaveArchive && !options.dumpContent ? " (" + pageData.filename + ")" : "";
256
256
  // written to stderr so that stdout stays parseable when using --dump-content
257
257
  console.error(`[${processedCount}/${tasks.length}] ${pageData ? "saved" : "failed"} ${task.url}${filenameInfo}`); // eslint-disable-line no-console
258
258
  }
@@ -22,11 +22,12 @@
22
22
  */
23
23
 
24
24
  import { initialize } from "./single-file-cli-api.js";
25
- import { closeBrowser } from "./lib/browser.js";
25
+ import { closeBrowser, createBrowserProfile, getBrowserOptions } from "./lib/browser.js";
26
26
  import { Deno } from "./lib/deno-polyfill.js";
27
27
  import { getOptions, applySettings, parseUrlsFile } from "./options.js";
28
28
 
29
- const { readTextFile, readFile, exit, addSignalListener } = Deno;
29
+ const { readTextFile, readFile, exit, addSignalListener, build } = Deno;
30
+ const QUIT_BROWSER_HINT = build.os == "darwin" ? " (Cmd+Q)" : "";
30
31
 
31
32
  try {
32
33
  addSignalListener("SIGTERM", closeBrowserAndExit);
@@ -49,6 +50,10 @@ async function run() {
49
50
  const settings = JSON.parse(await readTextFile(options.settingsFile));
50
51
  applySettings(options, settings);
51
52
  }
53
+ if (options.createBrowserProfile) {
54
+ await saveBrowserProfile(options);
55
+ exit(0);
56
+ }
52
57
  if (options.urlsFile) {
53
58
  urls = await getUrlsFile(options.urlsFile);
54
59
  } else {
@@ -81,6 +86,13 @@ async function run() {
81
86
  }
82
87
  }
83
88
 
89
+ async function saveBrowserProfile(options) {
90
+ const profileDirectory = options.createBrowserProfile;
91
+ console.error(`Log in to the website in the browser window, then quit the browser${QUIT_BROWSER_HINT} to save the profile.`); // eslint-disable-line no-console
92
+ await createBrowserProfile(Object.assign(getBrowserOptions(options), { profile: profileDirectory, startUrl: options.url }));
93
+ console.error(`Profile saved, use it with --browser-profile ${JSON.stringify(profileDirectory)}.`); // eslint-disable-line no-console
94
+ }
95
+
84
96
  function parseCookies(textValue) {
85
97
  const httpOnlyRegExp = /^#HttpOnly_(.*)/;
86
98
  return textValue.split(/\r\n|\n/)
@@ -0,0 +1,103 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createServer } from "node:http";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import process from "node:process";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
14
+
15
+ test("pages are not captured as controlled by automation", { timeout: 120000 }, async () => {
16
+ const server = createServer((_, response) => response
17
+ .writeHead(200, { "content-type": "text/html" })
18
+ .end("<html><head><title>Automation</title></head><body><p id=result></p>" +
19
+ "<script>document.getElementById(\"result\").textContent = \"webdriver=\" + navigator.webdriver;</script>" +
20
+ "</body></html>"));
21
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
22
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
23
+ try {
24
+ const outputPath = join(directory, "page.html");
25
+ const url = "http://localhost:" + server.address().port + "/";
26
+ const { stderr } = await execFileAsync(process.execPath, [
27
+ "single-file-node.js", url, outputPath
28
+ ], { cwd: cliDirectory });
29
+ let content;
30
+ try {
31
+ content = await readFile(outputPath, "utf8");
32
+ } catch (error) {
33
+ throw new Error("missing output file, stderr: " + stderr, { cause: error });
34
+ }
35
+ assert.ok(content.includes("webdriver=false"), "expected navigator.webdriver to be false, got: " + content.match(/webdriver=\w+/));
36
+ } finally {
37
+ await rm(directory, { recursive: true });
38
+ server.close();
39
+ }
40
+ });
41
+
42
+ test("pages are not captured with a headless user agent", { timeout: 120000 }, async () => {
43
+ const server = createServer((request, response) => response
44
+ .writeHead(200, { "content-type": "text/html" })
45
+ .end("<html><head><title>User agent</title></head><body>" +
46
+ "<p id=header-agent>header-agent=" + request.headers["user-agent"] + "</p>" +
47
+ "<p id=script-agent></p>" +
48
+ "<script>document.getElementById(\"script-agent\").textContent = \"script-agent=\" + navigator.userAgent;</script>" +
49
+ "</body></html>"));
50
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
51
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
52
+ try {
53
+ const outputPath = join(directory, "page.html");
54
+ const url = "http://localhost:" + server.address().port + "/";
55
+ const { stderr } = await execFileAsync(process.execPath, [
56
+ "single-file-node.js", url, outputPath
57
+ ], { cwd: cliDirectory });
58
+ let content;
59
+ try {
60
+ content = await readFile(outputPath, "utf8");
61
+ } catch (error) {
62
+ throw new Error("missing output file, stderr: " + stderr, { cause: error });
63
+ }
64
+ const headerAgent = content.match(/header-agent=([^<]*)/);
65
+ const scriptAgent = content.match(/script-agent=([^<]*)/);
66
+ assert.ok(headerAgent, "missing the user agent sent to the server");
67
+ assert.ok(scriptAgent, "missing the user agent read in the page");
68
+ assert.ok(!headerAgent[1].includes("Headless"), "unexpected headless token in the user agent sent to the server: " + headerAgent[1]);
69
+ assert.ok(!scriptAgent[1].includes("Headless"), "unexpected headless token in the user agent read in the page: " + scriptAgent[1]);
70
+ assert.ok(scriptAgent[1].includes("Chrome/"), "expected a Chrome user agent, got: " + scriptAgent[1]);
71
+ } finally {
72
+ await rm(directory, { recursive: true });
73
+ server.close();
74
+ }
75
+ });
76
+
77
+ test("the user agent option overrides the browser user agent", { timeout: 120000 }, async () => {
78
+ const userAgent = "Mozilla/5.0 (SingleFile test) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
79
+ const server = createServer((request, response) => response
80
+ .writeHead(200, { "content-type": "text/html" })
81
+ .end("<html><head><title>User agent</title></head><body>" +
82
+ "<p id=header-agent>header-agent=" + request.headers["user-agent"] + "</p>" +
83
+ "</body></html>"));
84
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
85
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
86
+ try {
87
+ const outputPath = join(directory, "page.html");
88
+ const url = "http://localhost:" + server.address().port + "/";
89
+ const { stderr } = await execFileAsync(process.execPath, [
90
+ "single-file-node.js", url, outputPath, "--user-agent=" + userAgent
91
+ ], { cwd: cliDirectory });
92
+ let content;
93
+ try {
94
+ content = await readFile(outputPath, "utf8");
95
+ } catch (error) {
96
+ throw new Error("missing output file, stderr: " + stderr, { cause: error });
97
+ }
98
+ assert.ok(content.includes("header-agent=" + userAgent), "expected the user agent option to be sent, got: " + content.match(/header-agent=[^<]*/));
99
+ } finally {
100
+ await rm(directory, { recursive: true });
101
+ server.close();
102
+ }
103
+ });
@@ -0,0 +1,67 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createServer } from "node:http";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { mkdtemp, mkdir, readdir, readFile, writeFile, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import process from "node:process";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
14
+
15
+ test("a browser profile is copied and left unmodified by a capture", { timeout: 120000 }, async () => {
16
+ const server = createServer((_, response) => response
17
+ .writeHead(200, { "content-type": "text/html" })
18
+ .end("<html><head><title>Profile</title></head><body>content</body></html>"));
19
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
20
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
21
+ try {
22
+ const profilePath = join(directory, "profile");
23
+ await mkdir(join(profilePath, "Default"), { recursive: true });
24
+ await writeFile(join(profilePath, "Default", "Preferences"), "{}");
25
+ const outputPath = join(directory, "page.html");
26
+ const url = "http://localhost:" + server.address().port + "/";
27
+ const { stderr } = await execFileAsync(process.execPath, [
28
+ "single-file-node.js", url, outputPath,
29
+ "--browser-profile", profilePath
30
+ ], { cwd: cliDirectory });
31
+ let content;
32
+ try {
33
+ content = await readFile(outputPath, "utf8");
34
+ } catch (error) {
35
+ throw new Error("missing output file, stderr: " + stderr, { cause: error });
36
+ }
37
+ assert.ok(content.includes("<title>Profile</title>"));
38
+ assert.deepEqual(await readdir(profilePath), ["Default"]);
39
+ assert.deepEqual(await readdir(join(profilePath, "Default")), ["Preferences"]);
40
+ assert.equal(await readFile(join(profilePath, "Default", "Preferences"), "utf8"), "{}");
41
+ } finally {
42
+ await rm(directory, { recursive: true });
43
+ server.close();
44
+ }
45
+ });
46
+
47
+ test("a missing browser profile is reported with its path", { timeout: 120000 }, async () => {
48
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
49
+ try {
50
+ const profilePath = join(directory, "missing");
51
+ let exitCode = 0;
52
+ let stderr = "";
53
+ try {
54
+ await execFileAsync(process.execPath, [
55
+ "single-file-node.js", "https://example.com", join(directory, "page.html"),
56
+ "--browser-profile", profilePath
57
+ ], { cwd: cliDirectory });
58
+ } catch (error) {
59
+ exitCode = error.code;
60
+ stderr = error.stderr;
61
+ }
62
+ assert.notEqual(exitCode, 0);
63
+ assert.ok(stderr.includes(`The browser profile directory was not found at ${JSON.stringify(profilePath)}`));
64
+ } finally {
65
+ await rm(directory, { recursive: true });
66
+ }
67
+ });
@@ -1,7 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { createPagesArchive } from "../../lib/archive-packager.js";
4
- import { configure, ZipWriter, ZipReader, Uint8ArrayReader, Uint8ArrayWriter, TextReader, TextWriter } from "../../lib/single-file-archive.js";
3
+ import { createPagesArchive, configure, ZipWriter, ZipReader, Uint8ArrayReader, Uint8ArrayWriter, TextReader, TextWriter } from "../../lib/single-file-archive.js";
5
4
 
6
5
  configure({ useWebWorkers: false });
7
6
 
@@ -0,0 +1,70 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, readdir, readFile, symlink, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { copyProfile } from "../../lib/browser.js";
7
+
8
+ async function createProfileDirectory() {
9
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
10
+ const sourcePath = join(directory, "profile");
11
+ await mkdir(join(sourcePath, "Default", "Cache"), { recursive: true });
12
+ await mkdir(join(sourcePath, "Default", "Local Storage"), { recursive: true });
13
+ await mkdir(join(sourcePath, "GPUCache"), { recursive: true });
14
+ await writeFile(join(sourcePath, "Local State"), "state");
15
+ await writeFile(join(sourcePath, "Default", "Cookies"), "cookies");
16
+ await writeFile(join(sourcePath, "Default", "Cache", "data_0"), "cached");
17
+ await writeFile(join(sourcePath, "Default", "Local Storage", "leveldb"), "storage");
18
+ await writeFile(join(sourcePath, "GPUCache", "index"), "cached");
19
+ await symlink("hostname-1234", join(sourcePath, "SingletonLock"));
20
+ return { directory, sourcePath, destinationPath: join(directory, "copy") };
21
+ }
22
+
23
+ test("copying a profile keeps the session data", async () => {
24
+ const { directory, sourcePath, destinationPath } = await createProfileDirectory();
25
+ try {
26
+ await mkdir(destinationPath);
27
+ await copyProfile(sourcePath, destinationPath);
28
+ assert.equal(await readFile(join(destinationPath, "Local State"), "utf8"), "state");
29
+ assert.equal(await readFile(join(destinationPath, "Default", "Cookies"), "utf8"), "cookies");
30
+ assert.equal(await readFile(join(destinationPath, "Default", "Local Storage", "leveldb"), "utf8"), "storage");
31
+ } finally {
32
+ await rm(directory, { recursive: true });
33
+ }
34
+ });
35
+
36
+ test("copying a profile skips caches and lock files", async () => {
37
+ const { directory, sourcePath, destinationPath } = await createProfileDirectory();
38
+ try {
39
+ await mkdir(destinationPath);
40
+ await copyProfile(sourcePath, destinationPath);
41
+ assert.deepEqual((await readdir(destinationPath)).sort(), ["Default", "Local State"]);
42
+ assert.deepEqual((await readdir(join(destinationPath, "Default"))).sort(), ["Cookies", "Local Storage"]);
43
+ } finally {
44
+ await rm(directory, { recursive: true });
45
+ }
46
+ });
47
+
48
+ test("copying a profile leaves the source directory unmodified", async () => {
49
+ const { directory, sourcePath, destinationPath } = await createProfileDirectory();
50
+ try {
51
+ await mkdir(destinationPath);
52
+ await copyProfile(sourcePath, destinationPath);
53
+ assert.deepEqual((await readdir(sourcePath)).sort(), ["Default", "GPUCache", "Local State", "SingletonLock"]);
54
+ assert.deepEqual((await readdir(join(sourcePath, "Default"))).sort(), ["Cache", "Cookies", "Local Storage"]);
55
+ } finally {
56
+ await rm(directory, { recursive: true });
57
+ }
58
+ });
59
+
60
+ test("copying a missing profile is reported with its path", async () => {
61
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
62
+ try {
63
+ const missingPath = join(directory, "missing");
64
+ await assert.rejects(
65
+ () => copyProfile(missingPath, directory),
66
+ error => error.message == `The browser profile directory was not found at ${JSON.stringify(missingPath)}`);
67
+ } finally {
68
+ await rm(directory, { recursive: true });
69
+ }
70
+ });
@@ -49,6 +49,21 @@ test("a wrong browser executable path is reported with the path", async () => {
49
49
  assert.ok(stderr.includes("The browser executable was not found at \"/nonexistent/chrome\""));
50
50
  });
51
51
 
52
+ test("creating a browser profile does not require a url", async () => {
53
+ const { code, stderr } = await runCli(["--create-browser-profile", "/path/to/profile", "--browser-executable-path", "/nonexistent/chrome"]);
54
+ assert.notEqual(code, 0);
55
+ assert.equal(stderr.includes("The URL or path of the page to save is required"), false);
56
+ });
57
+
58
+ test("conflicting browser profile options are reported as errors", async () => {
59
+ const { code, stderr } = await runCli(["--create-browser-profile", "/path/to/profile", "--browser-profile", "/path/to/other"]);
60
+ assert.equal(code, 1);
61
+ assert.ok(stderr.includes("--create-browser-profile cannot be used with --browser-profile"));
62
+ const remoteResult = await runCli(["https://example.com", "--browser-profile", "/path/to/profile", "--browser-server", "http://localhost:9222"]);
63
+ assert.equal(remoteResult.code, 1);
64
+ assert.ok(remoteResult.stderr.includes("--browser-profile cannot be used with --browser-server"));
65
+ });
66
+
52
67
  test("unexpected extra arguments are reported as errors", async () => {
53
68
  const { code, stderr } = await runCli(["https://example.com", "out.html", "extra.html"]);
54
69
  assert.equal(code, 1);
@@ -1,228 +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
- /* global URL */
25
-
26
- import {
27
- configure,
28
- createArchive,
29
- TextReader,
30
- Uint8ArrayReader,
31
- Uint8ArrayWriter,
32
- ZipReader
33
- } from "./single-file-archive.js";
34
-
35
- const PAGES_PREFIX = "pages/";
36
- const PAGES_FILENAME = "sfz-pages.json";
37
- const TOC_FILENAME = "sfz-toc.html";
38
- const TOC_TITLE = "Table of contents";
39
- const TOC_STYLE = "body{font-family:system-ui,sans-serif;margin:2em auto;max-width:40em;padding:0 1em;background-color:#fff;color:#000}" +
40
- "a{color:#0000ee}a:visited{color:#551a8b}" +
41
- "summary{cursor:pointer;font-weight:bold;margin:.5em 0}" +
42
- "details{padding-left:1em}ul{margin:.25em 0;padding-left:1.5em}" +
43
- "@media(prefers-color-scheme:dark){body{background-color:#111;color:#eee}a{color:#8ab4f8}a:visited{color:#c58af9}}";
44
- const COMMENT_HEADER = "Page saved with SingleFile";
45
- const SYMLINK_UNIX_MODE = 0o120777;
46
-
47
- export { createPagesArchive };
48
-
49
- async function createPagesArchive(pages, options) {
50
- configure({ useWebWorkers: false });
51
- const manifest = {
52
- pages: pages.map((page, pageIndex) => ({
53
- path: getPagePath(pageIndex),
54
- url: page.url,
55
- originalUrls: page.originalUrls,
56
- title: page.title
57
- }))
58
- };
59
- if (options.markUnarchivedLinks) {
60
- manifest.markUnarchivedLinks = true;
61
- }
62
- if (options.pageTransitions && options.pageTransitions != "auto") {
63
- manifest.pageTransitions = options.pageTransitions;
64
- }
65
- const pageData = {
66
- doctype: "<!DOCTYPE html>",
67
- content: "",
68
- title: pages[0].title || "",
69
- comment: options.insertSingleFileComment ? getComment(pages[0].url, options) : undefined,
70
- tocContent: getTOCContent(pages)
71
- };
72
- const archiveOptions = {
73
- url: pages[0].url,
74
- multiPageArchive: true,
75
- selfExtractingArchive: options.selfExtractingArchive,
76
- extractDataFromPage: options.extractDataFromPage,
77
- preventAppendedData: options.preventAppendedData,
78
- includeBOM: options.includeBOM,
79
- insertMetaCSP: options.insertMetaCSP,
80
- insertCanonicalLink: options.insertCanonicalLink,
81
- insertMetaNoIndex: options.insertMetaNoIndex
82
- };
83
- const writtenEntries = options.dedupPages ? new Map() : undefined;
84
- const aliases = {};
85
- const blob = await createArchive(pageData, archiveOptions, options.zipScript, async zipWriter => {
86
- for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
87
- const pagePath = getPagePath(pageIndex);
88
- const zipReader = new ZipReader(new Uint8ArrayReader(await pages[pageIndex].getData()));
89
- for (const entry of await zipReader.getEntries()) {
90
- const filename = pagePath + entry.filename;
91
- const rawData = await entry.getData(new Uint8ArrayWriter(), { passThrough: true, checkSignature: false });
92
- const canonicalFilename = writtenEntries && findDuplicate(writtenEntries, filename, entry, rawData);
93
- if (canonicalFilename === undefined) {
94
- await zipWriter.add(filename, new Uint8ArrayReader(rawData), {
95
- passThrough: true,
96
- compressionMethod: entry.compressionMethod,
97
- uncompressedSize: entry.uncompressedSize,
98
- signature: entry.signature,
99
- comment: entry.comment,
100
- lastModDate: entry.lastModDate
101
- });
102
- } else {
103
- // the duplicate becomes a symlink entry so that external
104
- // extractors still produce complete page folders, the router
105
- // resolves it from the manifest alias map instead
106
- aliases[filename] = canonicalFilename;
107
- await zipWriter.add(filename, new TextReader(getRelativePath(filename, canonicalFilename)), {
108
- msDosCompatible: false,
109
- unixMode: SYMLINK_UNIX_MODE,
110
- level: 0,
111
- comment: entry.comment,
112
- lastModDate: entry.lastModDate
113
- });
114
- }
115
- }
116
- await zipReader.close();
117
- }
118
- if (Object.keys(aliases).length) {
119
- manifest.aliases = aliases;
120
- }
121
- if (options.tocPage) {
122
- await zipWriter.add(TOC_FILENAME, new TextReader(getTOCPageContent(manifest.pages)));
123
- }
124
- await zipWriter.add(PAGES_FILENAME, new TextReader(JSON.stringify(manifest, null, 2)));
125
- });
126
- return new Uint8Array(await blob.arrayBuffer());
127
- }
128
-
129
- function findDuplicate(writtenEntries, filename, entry, rawData) {
130
- if (entry.directory || !entry.uncompressedSize) {
131
- return;
132
- }
133
- const key = [entry.compressionMethod, entry.uncompressedSize, entry.signature, rawData.length].join(":");
134
- const candidates = writtenEntries.get(key);
135
- if (candidates) {
136
- const match = candidates.find(candidate => equalData(candidate.rawData, rawData));
137
- if (match) {
138
- return match.filename;
139
- }
140
- candidates.push({ filename, rawData });
141
- } else {
142
- writtenEntries.set(key, [{ filename, rawData }]);
143
- }
144
- }
145
-
146
- function equalData(dataLeft, dataRight) {
147
- return dataLeft.length == dataRight.length && dataLeft.every((value, index) => value == dataRight[index]);
148
- }
149
-
150
- function getRelativePath(filename, targetFilename) {
151
- const baseSegments = filename.split("/").slice(0, -1);
152
- const targetSegments = targetFilename.split("/");
153
- while (baseSegments.length && targetSegments.length > 1 && baseSegments[0] == targetSegments[0]) {
154
- baseSegments.shift();
155
- targetSegments.shift();
156
- }
157
- return "../".repeat(baseSegments.length) + targetSegments.join("/");
158
- }
159
-
160
- function getPagePath(pageIndex) {
161
- return pageIndex == 0 ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
162
- }
163
-
164
- function getComment(url, options) {
165
- return "\n " + COMMENT_HEADER +
166
- " \n url: " + url +
167
- (options.removeSavedDate ? " " : " \n saved date: " + new Date()) + "\n";
168
- }
169
-
170
- function getTOCPageContent(pages) {
171
- const origins = new Set(pages.map(page => new URL(page.url).origin));
172
- const rootGroup = { groups: new Map(), pages: [] };
173
- pages.forEach(page => {
174
- const url = new URL(page.url);
175
- const segments = url.pathname.split("/").slice(1, -1);
176
- if (origins.size > 1) {
177
- segments.unshift(url.origin);
178
- }
179
- let group = rootGroup;
180
- segments.forEach(segment => {
181
- if (!group.groups.has(segment)) {
182
- group.groups.set(segment, { groups: new Map(), pages: [] });
183
- }
184
- group = group.groups.get(segment);
185
- });
186
- group.pages.push(page);
187
- });
188
- const title = pages[0].title ? TOC_TITLE + " - " + pages[0].title : TOC_TITLE;
189
- return "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
190
- "<title>" + escapeUnicodeHTML(title) + "</title><style>" + TOC_STYLE + "</style></head><body><main><h1>" +
191
- escapeUnicodeHTML(TOC_TITLE) + "</h1>" + getTOCGroupContent(rootGroup) + "</main></body></html>";
192
- }
193
-
194
- // nested details/summary groups stay collapsible without scripts on purpose,
195
- // the page must remain usable after a plain unzip
196
- function getTOCGroupContent(group) {
197
- let content = "";
198
- if (group.pages.length) {
199
- content += "<ul>" + group.pages.map(page =>
200
- "<li><a href=\"" + escapeUnicodeHTML(page.path + "index.html") + "\">" + escapeUnicodeHTML(page.title || page.url) + "</a></li>").join("") + "</ul>";
201
- }
202
- group.groups.forEach((childGroup, segment) => {
203
- content += "<details open><summary>" + escapeUnicodeHTML(segment) + "</summary>" + getTOCGroupContent(childGroup) + "</details>";
204
- });
205
- return content;
206
- }
207
-
208
- // unlike the prelude TOC below, the stored page is a UTF-8 entry: only the
209
- // markup delimiters need escaping, but crawled titles remain untrusted
210
- function escapeUnicodeHTML(value) {
211
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
212
- }
213
-
214
- function getTOCContent(pages) {
215
- return "<nav><ul>" +
216
- pages.map(page => "<li><a href=\"" + escapeHTML(page.url) + "\">" + escapeHTML(page.title || page.url) + "</a></li>").join("") +
217
- "</ul></nav>";
218
- }
219
-
220
- // the prelude declares the windows-1252 charset, non-ASCII characters must be
221
- // encoded as HTML entities to survive it
222
- function escapeHTML(value) {
223
- return Array.from(value).map(character => {
224
- const codePoint = character.codePointAt(0);
225
- return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
226
- "&#" + codePoint + ";" : character;
227
- }).join("");
228
- }