single-file-cli 2.10.0 → 2.11.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.10.0";
1
+ export const version = "2.11.0";
package/options.js CHANGED
@@ -141,7 +141,7 @@ const OPTIONS_INFO = [{
141
141
  "extract-data-from-page": { description: "Extract compressed data from the page instead of fetching the page in order to create universal self-extracting HTML files", type: "boolean", defaultValue: true },
142
142
  "prevent-appended-data": { description: "Prevent appending data after the compressed data when creating self-extracting HTML files", type: "boolean" },
143
143
  "declare-appended-data": { description: "Declare the data appended after the compressed data as the comment of the ZIP archive, for readers rejecting undeclared trailing bytes (e.g. java.util.zip); ZIP tools then print that data when listing the archive. Has no effect when the extra data is relocated ahead of the compressed data, because nothing is appended then", type: "boolean" },
144
- "max-appended-data-length": { description: "Maximum number of bytes appended after the compressed data when creating self-extracting HTML files, 16361 by default. Readers only tolerate trailing bytes as far back as their end-of-archive scan reaches (16383 bytes for libarchive, 32768 for perl Archive::Zip, 65557 for Python zipfile); the default fits the narrowest. Data over the limit is relocated ahead of the compressed data instead", type: "number" },
144
+ "max-appended-data-length": { description: "Maximum number of bytes appended after the compressed data when creating self-extracting HTML files, 16361 by default. Readers only tolerate trailing bytes as far back as their end-of-archive scan reaches (16383 bytes for libarchive, 32768 for perl Archive::Zip, 65557 for Python zipfile); the default fits the narrowest. Data over the limit is relocated ahead of the compressed data instead. Raising the limit above 65535 silently defeats --declare-appended-data: the declaration is the length of the ZIP comment, a 16-bit field, so a longer run is appended undeclared and without a warning", type: "number" },
145
145
  "embed-screenshot": { description: "Embed a screenshot of the page as a PNG file in the compressed file (self-extracting HTML or ZIP file). When enabled, the resulting file can be read as a ZIP file or a PNG image.", type: "boolean" },
146
146
  "embed-screenshot-options": { description: "Options passed to the CDP method `Page.captureScreenshot()` given as a JSON string (e.g. { \"captureBeyondViewport\": false })", type: "string" },
147
147
  "embedded-image": { description: "Path to a PNG image to embed in the compressed file. Unlike --embed-screenshot it is also compatible with --crawl-save-archive, where it becomes the image of the whole archive.", type: "string" },
@@ -193,6 +193,7 @@ const OPTIONS_INFO = [{
193
193
  }, {
194
194
  "include-bom": { key: "includeBOM", description: "Include the UTF-8 BOM into the HTML page, ignored when the page is compressed unless --extract-data-from-page is disabled and no image is embedded", type: "boolean" },
195
195
  "insert-meta-csp": { key: "insertMetaCSP", description: "Include a <meta> tag with a CSP to avoid potential requests to internet when viewing a page", type: "boolean", defaultValue: true },
196
+ "insert-meta-noindex": { key: "insertMetaNoIndex", description: "Insert a <meta name=robots content=noindex> element, so a saved page served on a public host is not indexed. Ignored when the page already declares noindex", type: "boolean" },
196
197
  "remove-saved-date": { description: "Remove saved date metadata in HTML header", type: "boolean" },
197
198
  "save-original-urls": { key: "saveOriginalURLs", description: "Save the original URLS in the embedded contents", type: "boolean" },
198
199
  "insert-single-file-comment": { description: "Insert a comment in the HTML header with the URL of the page", type: "boolean", defaultValue: true },
@@ -212,6 +213,7 @@ const OPTIONS_INFO = [{
212
213
 
213
214
  "save-raw-page": { description: "Save the original page without interpreting it into the browser", type: "boolean" },
214
215
  "output-json": { description: "Output the result as a JSON string containing the page and network info", type: "boolean" },
216
+ "dump-json": { description: "Write the same JSON as --output-json to stdout, without the page content, and leave the page file alone. Unlike --output-json the page is still written where it would have been, so the two outputs can be combined. Incompatible with --output-json, and with --dump-content unless --output is set, because both write to stdout", type: "boolean" },
215
217
  "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" },
216
218
 
217
219
  }, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -77,6 +77,12 @@ async function initialize(options) {
77
77
  if ((options.embedPdf || options.embeddedPdf || options.embedScreenshot || options.embeddedImage) && !options.compressContent) {
78
78
  throw new Error("--embed-pdf, --embedded-pdf, --embed-screenshot and --embedded-image require --compress-content");
79
79
  }
80
+ if (options.dumpJson && options.outputJson) {
81
+ throw new Error("--dump-json is not compatible with --output-json, which already writes the JSON in place of the page");
82
+ }
83
+ if (options.dumpJson && options.dumpContent && !options.output) {
84
+ throw new Error("--dump-json is not compatible with --dump-content unless --output is set, because both write to stdout");
85
+ }
80
86
  if (options.crawlSaveArchiveDedup && !options.crawlSaveArchive) {
81
87
  throw new Error("--crawl-save-archive-dedup requires --crawl-save-archive");
82
88
  }
@@ -400,6 +406,7 @@ async function capturePage(options) {
400
406
  if (options.archiveFilename) {
401
407
  await writeFile(options.archiveFilename, content);
402
408
  pageData.archiveFilename = options.archiveFilename;
409
+ dumpJsonMetadata(pageData, options);
403
410
  return pageData;
404
411
  }
405
412
  if (options.outputJson) {
@@ -429,6 +436,7 @@ async function capturePage(options) {
429
436
  const outputDirectory = getOutputDirectory(options);
430
437
  pageData.filename = filename.startsWith(outputDirectory) ? filename.substring(outputDirectory.length) : filename;
431
438
  }
439
+ dumpJsonMetadata(pageData, options);
432
440
  return pageData;
433
441
  } catch (error) {
434
442
  errorCount++;
@@ -453,6 +461,18 @@ async function capturePage(options) {
453
461
  }
454
462
  }
455
463
 
464
+ function dumpJsonMetadata(pageData, options) {
465
+ if (options.dumpJson) {
466
+ const metadata = Object.assign({}, pageData);
467
+ delete metadata.content;
468
+ delete metadata.binaryContent;
469
+ delete metadata.doctype;
470
+ delete metadata.viewport;
471
+ delete metadata.comment;
472
+ console.log(JSON.stringify(metadata, null, 2)); // eslint-disable-line no-console
473
+ }
474
+ }
475
+
456
476
  function getOutputDirectory(options) {
457
477
  if (Array.isArray(options.outputDirectory)) {
458
478
  const outputDirectory = options.outputDirectory.pop();
@@ -0,0 +1,111 @@
1
+ /* global URL */
2
+
3
+ // --dump-json exists because --output-json replaces the page with its JSON: given an explicit
4
+ // output path it wrote <output>.json and no page file at all, so a script that wanted the saved
5
+ // page AND its metadata got neither. --dump-json writes the metadata to stdout and leaves the page
6
+ // file exactly where it would have been, so the two outputs combine.
7
+ //
8
+ // Two things have to hold for that to be worth anything. The page file must still be written where
9
+ // it was asked for, and the JSON must NOT carry the page content, which is what made the
10
+ // --output-json file 34 MB in the first place. The network info is the third: it is captured only
11
+ // when the JSON is asked for, so a flag that reaches the writer but not the capture would emit a
12
+ // JSON with the request and response fields silently missing.
13
+
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { createServer } from "node:http";
17
+ import { execFile } from "node:child_process";
18
+ import { promisify } from "node:util";
19
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import process from "node:process";
23
+ import { cliDirectory } from "../target.js";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ const TEST_TIMEOUT = 120000;
27
+ const PAGE = "<html><head><title>Dumped Page</title></head><body><a href=\"/other\">other</a></body></html>";
28
+
29
+ let capturePromise;
30
+
31
+ test("--dump-json still writes the page file where it was asked for", { timeout: TEST_TIMEOUT }, async () => {
32
+ const { pageContent } = await getCaptureResult();
33
+ assert.match(pageContent, /<title>Dumped Page<\/title>/, "the page file is missing or is not the saved page");
34
+ });
35
+
36
+ test("--dump-json writes the metadata to stdout as JSON", { timeout: TEST_TIMEOUT }, async () => {
37
+ const { metadata } = await getCaptureResult();
38
+ assert.equal(metadata.title, "Dumped Page");
39
+ assert.ok(Array.isArray(metadata.links), "the links are missing from the dumped metadata");
40
+ });
41
+
42
+ test("--dump-json leaves the page content out of the JSON", { timeout: TEST_TIMEOUT }, async () => {
43
+ const { metadata } = await getCaptureResult();
44
+ assert.equal(metadata.content, undefined, "the page content was dumped, which is what --output-json already does");
45
+ assert.equal(metadata.binaryContent, undefined, "the page content was dumped as base64");
46
+ });
47
+
48
+ test("--dump-json reaches the network capture, not only the writer", { timeout: TEST_TIMEOUT }, async () => {
49
+ const { metadata } = await getCaptureResult();
50
+ assert.ok(metadata.response, "no response info: the flag reached the writer but not the capture");
51
+ assert.equal(metadata.response.status, 200);
52
+ });
53
+
54
+ test("--dump-json is refused with --output-json", { timeout: TEST_TIMEOUT }, async () => {
55
+ const { stderr } = await runCapture(["--dump-json", "--output-json"], { expectFailure: true });
56
+ assert.match(stderr, /--dump-json is not compatible with --output-json/);
57
+ });
58
+
59
+ test("--dump-json is refused with --dump-content writing to the same stream", { timeout: TEST_TIMEOUT }, async () => {
60
+ const { stderr } = await runCapture(["--dump-json", "--dump-content"], { expectFailure: true, noOutput: true });
61
+ assert.match(stderr, /--dump-json is not compatible with --dump-content/);
62
+ });
63
+
64
+ function getCaptureResult() {
65
+ if (!capturePromise) {
66
+ capturePromise = runDumpCapture();
67
+ }
68
+ return capturePromise;
69
+ }
70
+
71
+ async function runDumpCapture() {
72
+ const { stdout, outputPath, directory } = await runCapture(["--dump-json"], { keep: true });
73
+ try {
74
+ return { metadata: JSON.parse(stdout), pageContent: (await readFile(outputPath)).toString("utf8") };
75
+ } finally {
76
+ await rm(directory, { recursive: true });
77
+ }
78
+ }
79
+
80
+ async function runCapture(extraArguments, { expectFailure = false, noOutput = false, keep = false } = {}) {
81
+ const server = createServer((request, response) => {
82
+ const { pathname } = new URL(request.url, "http://localhost");
83
+ if (pathname === "/") {
84
+ response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
85
+ } else {
86
+ response.writeHead(404).end();
87
+ }
88
+ });
89
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
90
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
91
+ const outputPath = join(directory, "page.html");
92
+ try {
93
+ const args = ["single-file-node.js", "http://localhost:" + server.address().port + "/"];
94
+ if (!noOutput) {
95
+ args.push(outputPath);
96
+ }
97
+ const { stdout, stderr } = await execFileAsync(process.execPath, args.concat(extraArguments), { cwd: cliDirectory, maxBuffer: 64 * 1024 * 1024 });
98
+ assert.ok(!expectFailure, "the run was expected to fail, stderr: " + stderr);
99
+ return { stdout, stderr, outputPath, directory };
100
+ } catch (error) {
101
+ if (!expectFailure) {
102
+ throw error;
103
+ }
104
+ return { stdout: error.stdout || "", stderr: error.stderr || "", outputPath, directory };
105
+ } finally {
106
+ server.close();
107
+ if (!keep) {
108
+ await rm(directory, { recursive: true });
109
+ }
110
+ }
111
+ }
@@ -1,14 +1,18 @@
1
1
  /* global URL */
2
2
 
3
3
  // CSS orders @font-face sources two ways at once, and a saved page has to honour both.
4
- // Within one rule the browser uses the FIRST source that loads. Across duplicate rules sharing the
5
- // same descriptors the LAST rule wins outright: measured in Chrome, only the later rule's font is
6
- // fetched and document.fonts reports the earlier face as "unloaded", so it is never a fallback.
4
+ // Within one rule the browser uses the FIRST source that loads. Across rules sharing the same
5
+ // family, style, weight and stretch the rules form a single COMPOSITE face: they are checked in
6
+ // reverse declaration order for each character, so a character the last rule's font lacks is drawn
7
+ // from an earlier rule's font, under that rule's own descriptors. Measured in Chrome with two
8
+ // generated fonts, one mapping A-Z and one mapping only A: both are fetched, the later font draws
9
+ // A, the earlier draws B, and each glyph carries its own rule's size-adjust. A control with the
10
+ // same page and only the later rule draws B in serif and never fetches the other font, so the
11
+ // fallback is real and not an eager download. size-adjust does NOT split the composite face.
7
12
  // SingleFile merged every rule sharing a font key into one array built with unshift, which reversed
8
- // both axes at once. The within-rule axis then read correctly and the across-rule axis backwards,
9
- // so the save embedded the font from the rule the browser had overridden the right font was
10
- // absent from the file entirely. The two cases below pin the axes against each other, because a fix
11
- // that satisfies only one of them is the bug in the other direction.
13
+ // both axes at once, and a later fix dropped the earlier rule outright, which lost every glyph only
14
+ // its font carried. The cases below pin the axes against each other, because a fix that satisfies
15
+ // only one of them is the bug in the other direction.
12
16
 
13
17
  import { test } from "node:test";
14
18
  import assert from "node:assert/strict";
@@ -29,8 +33,8 @@ const FONTS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..", "fid
29
33
  // three distinct fixtures, told apart in the save by their byte length
30
34
  const FONT_NAMES = ["band.ttf", "bar.ttf", "block.ttf"];
31
35
  // the two Dup rules differ in size-adjust, which getFontKey does not cover, so they share a key
32
- // while rendering differently. That is what makes "keep the last rule" the only correct reduction:
33
- // keeping the first would pair the later rule's font with the earlier rule's metrics
36
+ // while rendering differently. That is what makes per-rule pairing the thing to test: any reduction
37
+ // that keeps one rule pairs its metrics with the other rule's font
34
38
  const PAGE = "<html><head><style>" +
35
39
  "@font-face{font-family:\"Dup\";src:url(/fonts/band.ttf) format(\"truetype\");size-adjust:50%}" +
36
40
  "@font-face{font-family:\"Dup\";src:url(/fonts/bar.ttf) format(\"truetype\");size-adjust:150%}" +
@@ -40,22 +44,37 @@ const PAGE = "<html><head><style>" +
40
44
 
41
45
  let capturePromise;
42
46
 
43
- test("duplicate @font-face rules resolve to the later rule, as the browser does", { timeout: TEST_TIMEOUT }, async () => {
44
- const { embedded, sizes } = await getCaptureResult();
47
+ test("every @font-face rule of a composite face is kept", { timeout: TEST_TIMEOUT }, async () => {
48
+ const { embedded } = await getCaptureResult();
45
49
  const dup = embedded.filter(rule => rule.family === "Dup");
46
- assert.ok(dup.length > 0, "no Dup rule survived the save");
47
- dup.forEach(rule => assert.equal(rule.length, sizes["bar.ttf"],
48
- `a Dup rule embedded ${describe(rule.length, sizes)} instead of bar.ttf, the later rule's font`));
50
+ assert.equal(dup.length, 2, "a rule of the composite face was dropped, so it lost the glyphs only its font carries");
51
+ });
52
+
53
+ test("each rule of a composite face keeps its own source", { timeout: TEST_TIMEOUT }, async () => {
54
+ const { embedded, sizes } = await getCaptureResult();
55
+ const { earlier, later } = getCompositeRules(embedded);
56
+ assert.equal(earlier.length, sizes["band.ttf"],
57
+ `the size-adjust:50% rule embedded ${describe(earlier.length, sizes)} instead of band.ttf, its own source`);
58
+ assert.equal(later.length, sizes["bar.ttf"],
59
+ `the size-adjust:150% rule embedded ${describe(later.length, sizes)} instead of bar.ttf, its own source`);
49
60
  });
50
61
 
51
- test("a shadowed @font-face rule is dropped, with its own descriptors", { timeout: TEST_TIMEOUT }, async () => {
62
+ test("a composite face keeps its declaration order", { timeout: TEST_TIMEOUT }, async () => {
52
63
  const { embedded } = await getCaptureResult();
53
- const dup = embedded.filter(rule => rule.family === "Dup");
54
- assert.equal(dup.length, 1, "the shadowed rule was kept, so the font is embedded twice");
55
- assert.match(dup[0].text, /size-adjust:\s*150%/, "the surviving rule is not the later one");
56
- assert.doesNotMatch(dup[0].text, /size-adjust:\s*50%/, "the earlier rule's metrics survived");
64
+ const { earlier, later } = getCompositeRules(embedded);
65
+ assert.ok(embedded.indexOf(earlier) < embedded.indexOf(later),
66
+ "the rules were reordered, which inverts which font the browser reaches for first");
57
67
  });
58
68
 
69
+ function getCompositeRules(embedded) {
70
+ const dup = embedded.filter(rule => rule.family === "Dup");
71
+ const earlier = dup.find(rule => /size-adjust:\s*50%/.test(rule.text));
72
+ const later = dup.find(rule => /size-adjust:\s*150%/.test(rule.text));
73
+ assert.ok(earlier, "no rule carries the earlier rule's size-adjust:50%");
74
+ assert.ok(later, "no rule carries the later rule's size-adjust:150%");
75
+ return { earlier, later };
76
+ }
77
+
59
78
  test("a single @font-face rule still resolves to its first source", { timeout: TEST_TIMEOUT }, async () => {
60
79
  const { embedded, sizes } = await getCaptureResult();
61
80
  const solo = embedded.filter(rule => rule.family === "Solo");
@@ -0,0 +1,71 @@
1
+ /* global URL */
2
+
3
+ // --insert-meta-noindex is a core option that had no CLI flag at all until now, so nothing here
4
+ // ever proved it arrives. That is the failure this file guards: an option can be declared, parsed
5
+ // and documented and still do nothing, because the value has to survive the whole trip from the
6
+ // command line into the page's own context.
7
+ //
8
+ // The baseline capture is the half that makes it mean something: without the flag the element may
9
+ // not appear, otherwise the assertion above would pass on a page that was going to carry it anyway.
10
+ //
11
+ // Its neighbour insertCanonicalLink deliberately has NO flag. It sits in the same core list, but
12
+ // single-file.js forces it to true on every capture, so a flag for it would be a switch that reads
13
+ // as configurable and cannot turn anything off.
14
+
15
+ import { test } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { createServer } from "node:http";
18
+ import { execFile } from "node:child_process";
19
+ import { promisify } from "node:util";
20
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import process from "node:process";
24
+ import { cliDirectory } from "../target.js";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+ const TEST_TIMEOUT = 120000;
28
+ const PAGE = "<html><head><title>Head Page</title></head><body>content</body></html>";
29
+ const ROBOTS_META = /<meta[^>]*name=["']?robots["']?[^>]*content=["']?noindex/i;
30
+
31
+ const capturePromises = new Map();
32
+
33
+ test("--insert-meta-noindex inserts the robots meta", { timeout: TEST_TIMEOUT }, async () => {
34
+ const content = await getCaptureResult("inserted");
35
+ assert.match(content, ROBOTS_META, "the robots meta is missing, so the flag never reached core");
36
+ });
37
+
38
+ test("the robots meta is not inserted without the flag", { timeout: TEST_TIMEOUT }, async () => {
39
+ const content = await getCaptureResult("baseline");
40
+ assert.doesNotMatch(content, ROBOTS_META, "the robots meta appears without --insert-meta-noindex");
41
+ });
42
+
43
+ function getCaptureResult(variant) {
44
+ if (!capturePromises.has(variant)) {
45
+ capturePromises.set(variant, runCapture(variant == "inserted" ? ["--insert-meta-noindex"] : []));
46
+ }
47
+ return capturePromises.get(variant);
48
+ }
49
+
50
+ async function runCapture(extraArguments) {
51
+ const server = createServer((request, response) => {
52
+ const { pathname } = new URL(request.url, "http://localhost");
53
+ if (pathname === "/") {
54
+ response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
55
+ } else {
56
+ response.writeHead(404).end();
57
+ }
58
+ });
59
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
60
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
61
+ try {
62
+ const outputPath = join(directory, "page.html");
63
+ await execFileAsync(process.execPath, [
64
+ "single-file-node.js", "http://localhost:" + server.address().port + "/", outputPath
65
+ ].concat(extraArguments), { cwd: cliDirectory });
66
+ return (await readFile(outputPath)).toString("utf8");
67
+ } finally {
68
+ await rm(directory, { recursive: true });
69
+ server.close();
70
+ }
71
+ }