single-file-cli 2.1.3 → 2.2.1

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.1.3";
1
+ export const version = "2.2.1";
package/options.js CHANGED
@@ -24,6 +24,8 @@
24
24
  import { version } from "./lib/version.js";
25
25
  import { Deno } from "./lib/deno-polyfill.js";
26
26
 
27
+ const { args, build, exit } = Deno;
28
+
27
29
  const USAGE_TEXT = `single-file [url] [output]
28
30
 
29
31
  Positionals:
@@ -59,7 +61,7 @@ const OPTIONS_INFO = [{
59
61
  "browser-debug": { description: "Enable debug mode", type: "boolean" },
60
62
  "browser-arg": { description: "Argument passed to the browser", type: "string[]", alias: "browser-argument" },
61
63
  "browser-args": { description: "Arguments provided as a JSON array and passed to the browser", type: "string" },
62
- "browser-single-process": { description: "Run the browser as a single process", type: "boolean", defaultValue: true },
64
+ "browser-single-process": { description: "Run the browser as a single process (enabled by default on Windows only, current browsers on other platforms do not support this mode)", type: "boolean", defaultValue: build.os == "windows" },
63
65
  "browser-start-minimized": { description: "Minimize the browser", type: "boolean" },
64
66
  "browser-ignore-insecure-certs": { description: "Ignore HTTPs errors", type: "boolean" },
65
67
  "browser-remote-debugging-URL": { description: "Remote debugging URL", type: "string" }
@@ -142,7 +144,7 @@ const OPTIONS_INFO = [{
142
144
  "filename-template": { description: "Template used to generate the output filename (see https://github.com/gildas-lormeau/SingleFile/wiki/Template-variables-and-functions)", type: "string", defaultValue: "%if-empty<{page-title}|No title> ({date-locale} {time-locale}).{filename-extension}" },
143
145
  "filename-conflict-action": { description: "Action when the filename is conflicting with existing one on the filesystem. The possible values are \"uniquify\" (default), \"overwrite\" and \"skip\"", type: "string", defaultValue: "uniquify" },
144
146
  "filename-replacement-character": { description: "The character used for replacing invalid characters in filenames", type: "string", defaultValue: "_" },
145
- "filename-replaced-character": { description: "The replaced character and the character(s) used for replacing the replacement character in filenames separated by a space, e.g. --filename-replaced-character \">\" _GT_ to replace \">\" with _GT_", type: "string[]", defaultValue: ["~ ~", "+ +", "? ?", "% %", "* *", ": :", "| |", "\" "", "< <", "> >", "\\\\ \", "\\x00-\\x1f _", "\x7F _"] },
147
+ "filename-replaced-character": { description: "Character to replace in filenames followed by the replacement character(s), both in a single value separated by a space, e.g. --filename-replaced-character \"> _GT_\" to replace \">\" with \"_GT_\" (repeat the option to replace multiple characters)", type: "string[]", defaultValue: ["~ ~", "+ +", "? ?", "% %", "* *", ": :", "| |", "\" "", "< <", "> >", "\\\\ \", "\\x00-\\x1f _", "\x7F _"] },
146
148
  "filename-max-length": { description: "Specify the maximum length of the filename", type: "number", defaultValue: 192 },
147
149
  "filename-max-length-unit": { description: "Specify the unit of the maximum length of the filename ('bytes' or 'chars')", type: "string", defaultValue: "bytes" },
148
150
  "replace-emojis-in-filename": { description: "Replace emojis in the filename with their unicode text representation", type: "boolean" },
@@ -159,6 +161,10 @@ const OPTIONS_INFO = [{
159
161
  "crawl-external-links-max-depth": { description: "Max depth when crawling pages found in external links (0: infinite)", type: "number", defaultValue: 1 },
160
162
  "crawl-replace-URLs": { description: "Replace URLs of saved pages with relative paths of saved pages on the filesystem", type: "boolean" },
161
163
  "crawl-rewrite-rule": { description: "Rewrite rule used to rewrite URLs of crawled pages", type: "string[]" },
164
+ "crawl-save-archive": { description: "Save all the crawled pages into a single (self-extracting) ZIP file, requires --compress-content", type: "boolean" },
165
+ "crawl-save-archive-dedup": { description: "Deduplicate identical resources shared between pages when using --crawl-save-archive", type: "boolean" },
166
+ "crawl-save-archive-mark-unarchived-links": { description: "Mark links to pages not saved in the archive when using --crawl-save-archive", type: "boolean" },
167
+ "crawl-save-archive-toc": { description: "Save a table of contents page into the archive when using --crawl-save-archive", type: "boolean" },
162
168
  }, {
163
169
  "browser-script": { description: "Path of a script executed in the page (and all the frames) before it is loaded", type: "string[]" },
164
170
  "browser-stylesheet": { description: "Path of a stylesheet file inserted into the page (and all the frames) after it is loaded", type: "string[]" },
@@ -202,7 +208,14 @@ const OPTIONS_INFO = [{
202
208
  "version": { description: "Print the version number and exit.", type: "boolean" },
203
209
  }];
204
210
 
205
- const { args, exit } = Deno;
211
+ const CRAWL_LINKS_DEPENDENT_OPTIONS = {
212
+ crawlInnerLinksOnly: "--crawl-inner-links-only",
213
+ crawlNoParent: "--crawl-no-parent",
214
+ crawlRemoveURLFragment: "--crawl-remove-URL-fragment",
215
+ crawlMaxDepth: "--crawl-max-depth",
216
+ crawlExternalLinksMaxDepth: "--crawl-external-links-max-depth",
217
+ crawlRewriteRules: "--crawl-rewrite-rule"
218
+ };
206
219
  export { getOptions, parseArgs, applySettings, parseUrlsFile };
207
220
 
208
221
  function parseUrlsFile(content) {
@@ -295,6 +308,12 @@ function getOptions() {
295
308
  if (urls.length > 2) {
296
309
  errorMessages.push(`Unexpected arguments: ${urls.slice(2).join(", ")}`);
297
310
  }
311
+ if (!options.crawlLinks) {
312
+ const explicitOptions = parseArgs(Array.from(args), false).options;
313
+ Object.keys(CRAWL_LINKS_DEPENDENT_OPTIONS)
314
+ .filter(optionKey => explicitOptions[optionKey] !== undefined)
315
+ .forEach(optionKey => errorMessages.push(`${CRAWL_LINKS_DEPENDENT_OPTIONS[optionKey]} requires --crawl-links`));
316
+ }
298
317
  if (errorMessages.length) {
299
318
  printUsage();
300
319
  errorMessages.forEach(message => console.error(`Error: ${message}`)); // eslint-disable-line no-console
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
+ "license": "AGPL-3.0-or-later",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/gildas-lormeau/single-file-cli.git"
10
+ },
6
11
  "engines": {
7
12
  "deno": ">=2.2",
8
13
  "bun": ">=1.2",
@@ -26,6 +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
30
  import { Deno, path } from "./lib/deno-polyfill.js";
30
31
 
31
32
  const VALID_URL_TEST = /^(https?|file):\/\//;
@@ -59,8 +60,8 @@ const DEFAULT_OPTIONS = {
59
60
  const STATE_PROCESSING = "processing";
60
61
  const STATE_PROCESSED = "processed";
61
62
 
62
- const { readTextFile, writeTextFile, writeFile, stdout, mkdir, stat, errors } = Deno;
63
- let tasks = [], maxParallelWorkers, sessionFilename;
63
+ const { readTextFile, writeTextFile, readFile, writeFile, stdout, mkdir, makeTempDir, remove, stat, errors } = Deno;
64
+ let tasks = [], maxParallelWorkers, sessionFilename, archiveTempDirectory;
64
65
 
65
66
  export { initialize };
66
67
 
@@ -69,6 +70,25 @@ async function initialize(options) {
69
70
  if ((options.embedPdf || options.embeddedPdf || options.embedScreenshot || options.embeddedImage) && !options.compressContent) {
70
71
  throw new Error("--embed-pdf, --embedded-pdf, --embed-screenshot and --embedded-image require --compress-content");
71
72
  }
73
+ if (options.crawlSaveArchiveDedup && !options.crawlSaveArchive) {
74
+ throw new Error("--crawl-save-archive-dedup requires --crawl-save-archive");
75
+ }
76
+ if (options.crawlSaveArchiveMarkUnarchivedLinks && !options.crawlSaveArchive) {
77
+ throw new Error("--crawl-save-archive-mark-unarchived-links requires --crawl-save-archive");
78
+ }
79
+ if (options.crawlSaveArchiveToc && !options.crawlSaveArchive) {
80
+ throw new Error("--crawl-save-archive-toc requires --crawl-save-archive");
81
+ }
82
+ if (options.crawlSaveArchive) {
83
+ if (!options.compressContent) {
84
+ throw new Error("--crawl-save-archive requires --compress-content");
85
+ }
86
+ if (options.embedPdf || options.embeddedPdf || options.embedScreenshot || options.embeddedImage ||
87
+ options.outputJson || options.insertTextBody || options.password) {
88
+ throw new Error("--crawl-save-archive is not compatible with --embed-pdf, --embedded-pdf, --embed-screenshot, --embedded-image, --output-json, --insert-text-body and --password");
89
+ }
90
+ archiveTempDirectory = await makeTempDir();
91
+ }
72
92
  maxParallelWorkers = options.maxParallelWorkers || 8;
73
93
  try {
74
94
  await backend.initialize(options);
@@ -128,6 +148,9 @@ async function capture(urls, options) {
128
148
  async function finish(options) {
129
149
  const promiseTasks = tasks.map(task => task.promise);
130
150
  await Promise.all(promiseTasks);
151
+ if (options.crawlSaveArchive) {
152
+ await savePagesArchive(options);
153
+ }
131
154
  if (options.crawlReplaceURLs && !options.compressContent) {
132
155
  for (const task of tasks) {
133
156
  try {
@@ -155,6 +178,43 @@ async function finish(options) {
155
178
  }
156
179
  }
157
180
 
181
+ async function savePagesArchive(options) {
182
+ const archiveTasks = tasks.filter(task => task.archiveFilename);
183
+ if (archiveTasks.length) {
184
+ const pages = archiveTasks.map(task => ({
185
+ url: task.url,
186
+ originalUrls: task.originalUrls,
187
+ title: task.title,
188
+ getData: () => readFile(task.archiveFilename)
189
+ }));
190
+ const content = await createPagesArchive(pages, {
191
+ zipScript: getZipScriptSource(),
192
+ dedupPages: options.crawlSaveArchiveDedup,
193
+ markUnarchivedLinks: options.crawlSaveArchiveMarkUnarchivedLinks,
194
+ tocPage: options.crawlSaveArchiveToc,
195
+ selfExtractingArchive: options.selfExtractingArchive,
196
+ extractDataFromPage: options.extractDataFromPage,
197
+ preventAppendedData: options.preventAppendedData,
198
+ includeBOM: options.includeBOM,
199
+ insertMetaCSP: options.insertMetaCSP,
200
+ insertCanonicalLink: options.insertCanonicalLink,
201
+ insertMetaNoIndex: options.insertMetaNoIndex,
202
+ insertSingleFileComment: options.insertSingleFileComment,
203
+ removeSavedDate: options.removeSavedDate
204
+ });
205
+ if (options.dumpContent && !options.output) {
206
+ await stdout.write(content);
207
+ } else {
208
+ let outputFilename = options.output || archiveTasks[0].filename || "archive.html";
209
+ if (options.selfExtractingArchive) {
210
+ outputFilename = outputFilename.replace(/\.zip$/, ".html");
211
+ }
212
+ await writeOutputFile(outputFilename, content, options);
213
+ }
214
+ }
215
+ await remove(archiveTempDirectory, { recursive: true });
216
+ }
217
+
158
218
  function runTasks() {
159
219
  const availableTasks = tasks.filter(task => !task.status).length;
160
220
  const processingTasks = tasks.filter(task => task.status == STATE_PROCESSING).length;
@@ -171,6 +231,12 @@ async function runNextTask() {
171
231
  const options = task.options;
172
232
  const taskOptions = JSON.parse(JSON.stringify(options));
173
233
  taskOptions.url = task.url;
234
+ if (taskOptions.crawlSaveArchive) {
235
+ taskOptions.selfExtractingArchive = false;
236
+ taskOptions.extractDataFromPage = false;
237
+ taskOptions.createRootDirectory = false;
238
+ taskOptions.archiveFilename = archiveTempDirectory + "/" + tasks.indexOf(task) + ".zip";
239
+ }
174
240
  task.status = STATE_PROCESSING;
175
241
  await saveTasks();
176
242
  task.promise = capturePage(taskOptions);
@@ -178,6 +244,8 @@ async function runNextTask() {
178
244
  task.status = STATE_PROCESSED;
179
245
  if (pageData) {
180
246
  task.filename = pageData.filename;
247
+ task.title = pageData.title;
248
+ task.archiveFilename = pageData.archiveFilename;
181
249
  if (options.crawlLinks && testMaxDepth(task)) {
182
250
  const urls = pageData.links;
183
251
  let newTasks = await Promise.all(urls.map(url => createTask(url, options, task, task.rootTaskURL || task.url)));
@@ -289,6 +357,11 @@ async function capturePage(options) {
289
357
  await writeTextFile(options.debugMessagesFile, pageData.debugMessages.map(([timestamp, message]) =>
290
358
  `[${new Date(timestamp).toISOString()}] ${message.join(" ")}`).join("\n"));
291
359
  }
360
+ if (options.archiveFilename) {
361
+ await writeFile(options.archiveFilename, content);
362
+ pageData.archiveFilename = options.archiveFilename;
363
+ return pageData;
364
+ }
292
365
  if (options.outputJson) {
293
366
  if (content instanceof Uint8Array) {
294
367
  pageData.content = undefined;
@@ -0,0 +1,177 @@
1
+ /* global URL, TextDecoder */
2
+
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { createServer } from "node:http";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import process from "node:process";
13
+ import { configure, ZipReader, Uint8ArrayReader, TextWriter } from "../../lib/single-file-archive.js";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+ const TEST_TIMEOUT = 120000;
18
+
19
+ const crawlPromises = new Map();
20
+
21
+ test("crawled pages are merged into a single self-extracting archive", { timeout: TEST_TIMEOUT }, async () => {
22
+ const { filenames, entryNames, data, stderr } = await getCrawlResult();
23
+ assert.deepEqual(filenames, ["archive.html"], "stderr: " + stderr);
24
+ assert.ok(entryNames.includes("index.html"));
25
+ assert.ok(entryNames.includes("pages/2/index.html"));
26
+ assert.ok(entryNames.includes("pages/3/index.html"));
27
+ assert.ok(entryNames.includes("sfz-pages.json"));
28
+ const prelude = new TextDecoder("windows-1252").decode(data.subarray(0, 64));
29
+ assert.ok(prelude.startsWith("<!DOCTYPE html><html data-sfz>"));
30
+ });
31
+
32
+ test("the pages manifest maps paths to the crawled URLs", { timeout: TEST_TIMEOUT }, async () => {
33
+ const { manifest, origin } = await getCrawlResult();
34
+ assert.equal(manifest.pages.length, 3);
35
+ assert.equal(manifest.pages[0].path, "");
36
+ assert.equal(manifest.pages[0].url, origin + "/");
37
+ assert.equal(manifest.pages[0].title, "Top Page");
38
+ assert.equal(manifest.pages[1].path, "pages/2/");
39
+ assert.equal(manifest.pages[1].title, "Linked Page");
40
+ assert.equal(manifest.pages[2].path, "pages/3/");
41
+ assert.equal(manifest.pages[2].title, "Other & \"Page\"");
42
+ assert.ok(manifest.pages[1].originalUrls.includes(origin + "/page.html"));
43
+ });
44
+
45
+ test("merged page entries can be extracted", { timeout: TEST_TIMEOUT }, async () => {
46
+ const { entries } = await getCrawlResult();
47
+ const entry = entries.find(entry => entry.filename == "pages/2/index.html");
48
+ const content = await entry.getData(new TextWriter());
49
+ assert.ok(content.includes("Linked Page"));
50
+ });
51
+
52
+ test("--crawl-save-archive-dedup replaces duplicate resources with symlink aliases", { timeout: TEST_TIMEOUT }, async () => {
53
+ const { manifest, entries } = await getCrawlResult(true);
54
+ const aliasNames = Object.keys(manifest.aliases || {});
55
+ assert.equal(aliasNames.length, 2);
56
+ for (const aliasName of aliasNames) {
57
+ const canonicalFilename = manifest.aliases[aliasName];
58
+ assert.ok(!canonicalFilename.startsWith("pages/"));
59
+ assert.ok(entries.find(entry => entry.filename == canonicalFilename));
60
+ const aliasEntry = entries.find(entry => entry.filename == aliasName);
61
+ assert.equal(aliasEntry.unixMode & 0o170000, 0o120000);
62
+ assert.equal(aliasEntry.versionMadeBy >> 8, 3);
63
+ const target = await aliasEntry.getData(new TextWriter());
64
+ assert.equal(resolvePath(aliasName, target), canonicalFilename);
65
+ }
66
+ });
67
+
68
+ test("--crawl-save-archive-dedup requires --crawl-save-archive", { timeout: TEST_TIMEOUT }, async () => {
69
+ await assert.rejects(
70
+ execFileAsync(process.execPath, ["single-file-node.js", "http://localhost/", "--compress-content", "--crawl-save-archive-dedup"], { cwd: cliDirectory }),
71
+ error => error.stderr.includes("--crawl-save-archive-dedup requires --crawl-save-archive"));
72
+ });
73
+
74
+ test("--crawl-save-archive-mark-unarchived-links sets the manifest flag", { timeout: TEST_TIMEOUT }, async () => {
75
+ const { manifest } = await getCrawlResult(true);
76
+ assert.equal(manifest.markUnarchivedLinks, true);
77
+ const { manifest: defaultManifest } = await getCrawlResult();
78
+ assert.equal(defaultManifest.markUnarchivedLinks, undefined);
79
+ });
80
+
81
+ test("--crawl-save-archive-toc stores a table of contents page", { timeout: TEST_TIMEOUT }, async () => {
82
+ const { entries, entryNames } = await getCrawlResult(true);
83
+ assert.equal(entryNames.indexOf("sfz-toc.html"), entryNames.length - 2);
84
+ const tocEntry = entries.find(entry => entry.filename == "sfz-toc.html");
85
+ const content = await tocEntry.getData(new TextWriter());
86
+ assert.ok(content.includes("<a href=\"index.html\">Top Page</a>"));
87
+ assert.ok(content.includes("<a href=\"pages/2/index.html\">Linked Page</a>"));
88
+ assert.ok(content.includes("<a href=\"pages/3/index.html\">Other &amp; &quot;Page&quot;</a>"));
89
+ const { entryNames: defaultEntryNames } = await getCrawlResult();
90
+ assert.ok(!defaultEntryNames.includes("sfz-toc.html"));
91
+ });
92
+
93
+ test("--crawl-save-archive-toc requires --crawl-save-archive", { timeout: TEST_TIMEOUT }, async () => {
94
+ await assert.rejects(
95
+ execFileAsync(process.execPath, ["single-file-node.js", "http://localhost/", "--compress-content", "--crawl-save-archive-toc"], { cwd: cliDirectory }),
96
+ error => error.stderr.includes("--crawl-save-archive-toc requires --crawl-save-archive"));
97
+ });
98
+
99
+ test("--crawl-save-archive-mark-unarchived-links requires --crawl-save-archive", { timeout: TEST_TIMEOUT }, async () => {
100
+ await assert.rejects(
101
+ execFileAsync(process.execPath, ["single-file-node.js", "http://localhost/", "--compress-content", "--crawl-save-archive-mark-unarchived-links"], { cwd: cliDirectory }),
102
+ error => error.stderr.includes("--crawl-save-archive-mark-unarchived-links requires --crawl-save-archive"));
103
+ });
104
+
105
+ test("--crawl-save-archive requires --compress-content", { timeout: TEST_TIMEOUT }, async () => {
106
+ await assert.rejects(
107
+ execFileAsync(process.execPath, ["single-file-node.js", "http://localhost/", "--crawl-save-archive"], { cwd: cliDirectory }),
108
+ error => error.stderr.includes("--crawl-save-archive requires --compress-content"));
109
+ });
110
+
111
+ function getCrawlResult(dedup = false) {
112
+ if (!crawlPromises.has(dedup)) {
113
+ crawlPromises.set(dedup, runCrawl(dedup));
114
+ }
115
+ return crawlPromises.get(dedup);
116
+ }
117
+
118
+ async function runCrawl(dedup) {
119
+ const server = createServer((request, response) => {
120
+ const { pathname } = new URL(request.url, "http://localhost");
121
+ if (pathname === "/") {
122
+ servePage(response, "Top Page", `
123
+ <a href="/page.html">page</a>
124
+ <a href="/other.html">other</a>`);
125
+ } else if (pathname === "/page.html") {
126
+ servePage(response, "Linked Page");
127
+ } else if (pathname === "/other.html") {
128
+ servePage(response, "Other & \"Page\"");
129
+ } else if (pathname === "/shared.css") {
130
+ response.writeHead(200, { "content-type": "text/css" }).end("body { background-color: aliceblue; }");
131
+ } else {
132
+ response.writeHead(404).end();
133
+ }
134
+ });
135
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
136
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
137
+ try {
138
+ const origin = "http://localhost:" + server.address().port;
139
+ const { stderr } = await execFileAsync(process.execPath, [
140
+ "single-file-node.js", origin + "/", join(directory, "archive.html"),
141
+ "--crawl-links",
142
+ "--crawl-save-archive",
143
+ "--compress-content",
144
+ "--max-parallel-workers", "1",
145
+ ...(dedup ? ["--crawl-save-archive-dedup", "--crawl-save-archive-mark-unarchived-links", "--crawl-save-archive-toc"] : [])
146
+ ], { cwd: cliDirectory });
147
+ const filenames = await readdir(directory);
148
+ const data = new Uint8Array(await readFile(join(directory, "archive.html")));
149
+ configure({ useWebWorkers: false });
150
+ const zipReader = new ZipReader(new Uint8ArrayReader(data));
151
+ const entries = await zipReader.getEntries();
152
+ const entryNames = entries.map(entry => entry.filename);
153
+ const manifestEntry = entries.find(entry => entry.filename == "sfz-pages.json");
154
+ const manifest = manifestEntry ? JSON.parse(await manifestEntry.getData(new TextWriter())) : undefined;
155
+ return { filenames, entries, entryNames, manifest, data, origin, stderr };
156
+ } finally {
157
+ await rm(directory, { recursive: true });
158
+ server.close();
159
+ }
160
+ }
161
+
162
+ function resolvePath(baseFilename, relativePath) {
163
+ const segments = baseFilename.split("/").slice(0, -1);
164
+ for (const segment of relativePath.split("/")) {
165
+ if (segment == "..") {
166
+ segments.pop();
167
+ } else {
168
+ segments.push(segment);
169
+ }
170
+ }
171
+ return segments.join("/");
172
+ }
173
+
174
+ function servePage(response, title, body = "") {
175
+ response.writeHead(200, { "content-type": "text/html" })
176
+ .end(`<html><head><title>${title}</title><link rel="stylesheet" href="/shared.css"></head><body>${body}</body></html>`);
177
+ }
@@ -75,6 +75,19 @@ test("mail and script links are not crawled as external links", { timeout: TEST_
75
75
  }
76
76
  });
77
77
 
78
+ test("crawl options require --crawl-links", { timeout: TEST_TIMEOUT }, async () => {
79
+ await assert.rejects(
80
+ execFileAsync(process.execPath, [
81
+ "single-file-node.js", "http://localhost/",
82
+ "--crawl-no-parent",
83
+ "--crawl-max-depth", "2",
84
+ "--crawl-rewrite-rule", "a b"
85
+ ], { cwd: cliDirectory }),
86
+ error => error.stderr.includes("--crawl-no-parent requires --crawl-links") &&
87
+ error.stderr.includes("--crawl-max-depth requires --crawl-links") &&
88
+ error.stderr.includes("--crawl-rewrite-rule requires --crawl-links"));
89
+ });
90
+
78
91
  function getCrawlResult() {
79
92
  if (!crawlPromise) {
80
93
  crawlPromise = runCrawl();
@@ -0,0 +1,55 @@
1
+ import { test } from "node:test";
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";
5
+
6
+ configure({ useWebWorkers: false });
7
+
8
+ test("the stored table of contents groups pages by URL path segments", async () => {
9
+ const archive = await createArchive([
10
+ { url: "https://example.com/", title: "Home" },
11
+ { url: "https://example.com/docs/api/reader.html", title: "Reader" },
12
+ { url: "https://example.com/docs/api/writer.html", title: "" },
13
+ { url: "https://example.com/docs/guide.html", title: "Guide <em> & \"quotes\"" }
14
+ ]);
15
+ const toc = await readEntry(archive, "sfz-toc.html");
16
+ assert.ok(toc.includes("<a href=\"index.html\">Home</a>"));
17
+ assert.ok(toc.includes("<details open><summary>docs</summary>"));
18
+ assert.ok(toc.includes("<summary>api</summary>"));
19
+ assert.ok(toc.includes("<a href=\"pages/2/index.html\">Reader</a>"));
20
+ assert.ok(toc.includes(">https://example.com/docs/api/writer.html</a>"), "empty title falls back to the URL");
21
+ assert.ok(toc.includes("Guide &lt;em&gt; &amp; &quot;quotes&quot;"), "titles are escaped");
22
+ });
23
+
24
+ test("the table of contents groups by origin when the crawl crossed hosts", async () => {
25
+ const archive = await createArchive([
26
+ { url: "https://example.com/", title: "Home" },
27
+ { url: "https://other.example/about.html", title: "About" }
28
+ ]);
29
+ const toc = await readEntry(archive, "sfz-toc.html");
30
+ assert.ok(toc.includes("<summary>https://example.com</summary>"));
31
+ assert.ok(toc.includes("<summary>https://other.example</summary>"));
32
+ });
33
+
34
+ async function createArchive(pages) {
35
+ return createPagesArchive(pages.map(page => ({
36
+ url: page.url,
37
+ originalUrls: [page.url],
38
+ title: page.title,
39
+ getData: () => createPageData(page)
40
+ })), { tocPage: true, selfExtractingArchive: false });
41
+ }
42
+
43
+ async function createPageData(page) {
44
+ const zipWriter = new ZipWriter(new Uint8ArrayWriter());
45
+ await zipWriter.add("index.html", new TextReader("<!DOCTYPE html><html><head><title>" + page.title + "</title></head><body></body></html>"));
46
+ return zipWriter.close();
47
+ }
48
+
49
+ async function readEntry(archive, filename) {
50
+ const zipReader = new ZipReader(new Uint8ArrayReader(archive));
51
+ const entries = await zipReader.getEntries();
52
+ const entry = entries.find(entry => entry.filename == filename);
53
+ assert.ok(entry, filename + " entry exists");
54
+ return entry.getData(new TextWriter());
55
+ }
@@ -1,29 +0,0 @@
1
- name: Publish Docker image
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- jobs:
8
- docker:
9
- runs-on: ubuntu-latest
10
- steps:
11
- -
12
- name: Set up QEMU
13
- uses: docker/setup-qemu-action@v3
14
- -
15
- name: Set up Docker Buildx
16
- uses: docker/setup-buildx-action@v3
17
- -
18
- name: Login to Docker Hub
19
- uses: docker/login-action@v3
20
- with:
21
- username: ${{ secrets.DOCKER_USERNAME }}
22
- password: ${{ secrets.DOCKER_PASSWORD }}
23
- -
24
- name: Build and push
25
- uses: docker/build-push-action@v6
26
- with:
27
- push: true
28
- tags: ${{ secrets.DOCKER_USERNAME }}/singlefile:latest
29
- platforms: linux/amd64,linux/arm64