single-file-cli 2.7.1 → 2.8.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.7.1";
1
+ export const version = "2.8.0";
package/options.js CHANGED
@@ -126,6 +126,7 @@ const OPTIONS_INFO = [{
126
126
  "move-styles-in-head": { description: "Move style elements outside the head element into the head element", type: "boolean" },
127
127
  "group-duplicate-images": { description: "Group duplicate images into CSS custom properties", type: "boolean", defaultValue: true },
128
128
  "max-size-duplicate-images": { description: "Maximum size in bytes of duplicate images stored as CSS custom properties", type: "number", defaultValue: 512 * 1024 },
129
+ "image-reduction-factor": { description: "Divide the dimensions of PNG, JPEG and WEBP images by this factor in order to reduce the size of the page (e.g. 2 halves them)", type: "number", defaultValue: 1 },
129
130
  "group-duplicate-stylesheets": { description: "Group duplicate inline stylesheets into a single stylesheet in order to reduce the size of the page", type: "boolean", defaultValue: false }
130
131
  }, {
131
132
  "compress-content": { description: "Create a ZIP file instead of an HTML file", type: "boolean" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -0,0 +1,96 @@
1
+ /* global URL */
2
+
3
+ // A misconfigured server answers a font or image URL with an HTML error page and a 200 status
4
+ // (money.rediff.com does it for its six Roboto faces). The fetcher used to trust the declared type,
5
+ // so a plain save embedded the HTML as src:url(data:text/html;base64,...) inside the @font-face,
6
+ // while --compress-content dropped it because only that path tests the bytes with FontFace. An
7
+ // HTML body at a media URL is never a usable resource, so both modes now treat it like a 404.
8
+ //
9
+ // The controls are a real font and a real image on the same page: they must still be embedded.
10
+
11
+ import { test } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { createServer } from "node:http";
14
+ import { execFile } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import process from "node:process";
21
+ import { Buffer } from "node:buffer";
22
+ const { configure, ZipReader, Uint8ArrayReader } = await importLibModule("single-file-archive.js");
23
+ import { cliDirectory, importLibModule } from "../target.js";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const TEST_TIMEOUT = 120000;
27
+ const FONT_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "fidelity", "pages", "fonts", "block.ttf");
28
+ const RED_DOT = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64");
29
+ const ERROR_PAGE = "<!doctype html><html><head><title>Not found</title></head><body><h1>Not found</h1></body></html>";
30
+ const PAGE = "<html><head><style>" +
31
+ "@font-face{font-family:\"Fake\";src:url(/fonts/fake.woff2) format(\"woff2\")}" +
32
+ "@font-face{font-family:\"Real\";src:url(/fonts/block.ttf) format(\"truetype\")}" +
33
+ "h1{font-family:\"Real\",serif}p{font-family:\"Fake\",serif}" +
34
+ "</style></head><body><h1>Head</h1><p>Body</p>" +
35
+ "<img id=\"broken\" src=\"/images/photo.png\"><img id=\"kept\" src=\"/images/dot.png\"></body></html>";
36
+
37
+ const capturePromises = new Map();
38
+
39
+ test("an HTML page served at a font URL is dropped from a plain save", { timeout: TEST_TIMEOUT }, async () => {
40
+ const content = (await getCaptureResult(false)).toString("utf8");
41
+ assert.ok(!content.includes("data:text/html"), "an HTML body was embedded as a resource");
42
+ assert.match(content, /font-family:\s*"?Fake"?;\s*src:\s*[;}]/, "the unusable font source was not removed");
43
+ assert.match(content, /font-family:\s*"?Real"?;\s*src:\s*url\("?data:font\/ttf;base64,/, "the real font was not embedded");
44
+ });
45
+
46
+ test("an HTML page served at an image URL is treated like a missing image in a plain save", { timeout: TEST_TIMEOUT }, async () => {
47
+ const content = (await getCaptureResult(false)).toString("utf8");
48
+ assert.match(content, /id="?broken"?\s+src="?data:,"?/, "the HTML body was kept as the image source");
49
+ assert.match(content, /id="?kept"?\s+src="?data:image\/png;base64,/, "the real image was not embedded");
50
+ });
51
+
52
+ test("an HTML page served at a media URL gets no archive entry", { timeout: TEST_TIMEOUT }, async () => {
53
+ const data = await getCaptureResult(true);
54
+ configure({ useWebWorkers: false });
55
+ const zipReader = new ZipReader(new Uint8ArrayReader(new Uint8Array(data)));
56
+ const entryNames = (await zipReader.getEntries()).map(entry => entry.filename);
57
+ assert.deepEqual(entryNames.filter(name => name.startsWith("fonts/")).length, 1, "entries: " + entryNames.join(", "));
58
+ assert.deepEqual(entryNames.filter(name => name.startsWith("images/")).length, 1, "entries: " + entryNames.join(", "));
59
+ });
60
+
61
+ function getCaptureResult(compressContent) {
62
+ if (!capturePromises.has(compressContent)) {
63
+ capturePromises.set(compressContent, runCapture(compressContent));
64
+ }
65
+ return capturePromises.get(compressContent);
66
+ }
67
+
68
+ async function runCapture(compressContent) {
69
+ const font = await readFile(FONT_PATH);
70
+ const server = createServer((request, response) => {
71
+ const { pathname } = new URL(request.url, "http://localhost");
72
+ if (pathname === "/") {
73
+ response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
74
+ } else if (pathname === "/fonts/block.ttf") {
75
+ response.writeHead(200, { "content-type": "font/ttf" }).end(font);
76
+ } else if (pathname === "/images/dot.png") {
77
+ response.writeHead(200, { "content-type": "image/png" }).end(RED_DOT);
78
+ } else {
79
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(ERROR_PAGE);
80
+ }
81
+ });
82
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
83
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
84
+ try {
85
+ const outputPath = join(directory, compressContent ? "out.zip.html" : "out.html");
86
+ const args = ["single-file-node.js", "http://localhost:" + server.address().port + "/", outputPath];
87
+ if (compressContent) {
88
+ args.push("--compress-content");
89
+ }
90
+ await execFileAsync(process.execPath, args, { cwd: cliDirectory });
91
+ return await readFile(outputPath);
92
+ } finally {
93
+ await rm(directory, { recursive: true });
94
+ server.close();
95
+ }
96
+ }
@@ -0,0 +1,45 @@
1
+ /* global setTimeout */
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, rm, readFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import process from "node:process";
12
+ import { cliDirectory } from "../target.js";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ // The server answers after the network of the blank page went idle, and sends
17
+ // the body of the page later still. A wait satisfied by the blank page captures
18
+ // the document as soon as it commits, while it has a head and no body yet.
19
+ test("a page whose server answers late is captured once loaded", { timeout: 120000 }, async () => {
20
+ const server = createServer((request, response) => {
21
+ if (request.url == "/") {
22
+ setTimeout(() => {
23
+ response.writeHead(200, { "content-type": "text/html" });
24
+ response.write("<!doctype html><html><head><title>late</title>");
25
+ setTimeout(() => response.end("</head><body><p id=late>body arrived late</p></body></html>"), 3000);
26
+ }, 3000);
27
+ } else {
28
+ response.writeHead(404);
29
+ response.end();
30
+ }
31
+ });
32
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
33
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
34
+ try {
35
+ const url = "http://localhost:" + server.address().port + "/";
36
+ const output = join(directory, "out.html");
37
+ await execFileAsync(process.execPath, ["single-file-node.js", url, output], { cwd: cliDirectory });
38
+ const content = await readFile(output, "utf8");
39
+ assert.ok(content.includes("body arrived late"), "the page was captured before its body arrived");
40
+ } finally {
41
+ await rm(directory, { recursive: true });
42
+ server.closeAllConnections();
43
+ server.close();
44
+ }
45
+ });