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/.github/workflows/publish.yml +141 -0
- package/Dockerfile +7 -3
- package/README.MD +6 -0
- package/build.sh +15 -1
- package/deno.json +1 -1
- package/dev-build.sh +14 -0
- package/eslint.config.mjs +1 -0
- package/lib/archive-packager.js +225 -0
- package/lib/single-file-archive.js +11 -0
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/options.js +22 -3
- package/package.json +6 -1
- package/single-file-cli-api.js +75 -2
- package/test/e2e/crawl-save-archive.test.js +177 -0
- package/test/e2e/crawl.test.js +13 -0
- package/test/unit/archive-packager.test.js +55 -0
- package/.github/workflows/docker-publish.yml +0 -29
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.1
|
|
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:
|
|
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: "
|
|
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
|
|
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
|
+
"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",
|
package/single-file-cli-api.js
CHANGED
|
@@ -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 & "Page"</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
|
+
}
|
package/test/e2e/crawl.test.js
CHANGED
|
@@ -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 <em> & "quotes""), "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
|