single-file-cli 2.1.2 → 2.2.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.
@@ -0,0 +1,141 @@
1
+ # .github/workflows/publish.yml
2
+
3
+ name: Publish
4
+
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ permissions:
10
+ id-token: write
11
+ contents: read
12
+
13
+ jobs:
14
+ # nothing else guarantees that the released commit was verified: the test workflow runs on push,
15
+ # so this waits for its run on that exact commit and refuses to publish unless it succeeded
16
+ check_tests:
17
+ name: 'Check tests'
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ contents: read
21
+ actions: read
22
+ steps:
23
+ # only used to resolve the tag to a commit, so it runs no code from the repository and keeps no
24
+ # git credentials behind
25
+ - uses: actions/checkout@v5
26
+ with:
27
+ ref: ${{ github.event.release.tag_name }}
28
+ persist-credentials: false
29
+ # the tag is typed in the release UI, so nothing ties it to the version that is about to be
30
+ # published: a mistyped tag, or a bump that was never pushed, publishes a version nobody asked for
31
+ - name: Verify the tag matches the declared version
32
+ env:
33
+ TAG_NAME: ${{ github.event.release.tag_name }}
34
+ run: |
35
+ version=$(jq -r .version package.json)
36
+ if [ "$TAG_NAME" != "v$version" ]; then
37
+ echo "::error::release $TAG_NAME but package.json declares $version"
38
+ exit 1
39
+ fi
40
+ deno_version=$(jq -r .version deno.json)
41
+ if [ "$deno_version" != "$version" ]; then
42
+ echo "::error::package.json declares $version but deno.json declares $deno_version"
43
+ exit 1
44
+ fi
45
+ if ! grep -q "\"$version\"" lib/version.js; then
46
+ echo "::error::lib/version.js does not declare $version"
47
+ exit 1
48
+ fi
49
+ - name: Wait for the test run of the released commit
50
+ env:
51
+ GH_TOKEN: ${{ github.token }}
52
+ run: |
53
+ sha=$(git rev-parse HEAD)
54
+ echo "released commit: $sha"
55
+ missing=0
56
+ for attempt in $(seq 1 60); do
57
+ run=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$sha&per_page=1" \
58
+ --jq 'if (.workflow_runs | length) == 0 then "none none none"
59
+ else (.workflow_runs[0] | "\(.status) \(.conclusion) \(.html_url)") end')
60
+ run_status=$(echo "$run" | cut -d' ' -f1)
61
+ conclusion=$(echo "$run" | cut -d' ' -f2)
62
+ url=$(echo "$run" | cut -d' ' -f3)
63
+ if [ "$run_status" = "none" ]; then
64
+ missing=$((missing + 1))
65
+ if [ "$missing" -ge 10 ]; then
66
+ echo "::error::no test run for $sha, run the test workflow on this commit and publish again"
67
+ exit 1
68
+ fi
69
+ echo "no test run found yet for $sha"
70
+ else
71
+ echo "test run: status=$run_status conclusion=$conclusion $url"
72
+ if [ "$run_status" = "completed" ]; then
73
+ if [ "$conclusion" = "success" ]; then
74
+ exit 0
75
+ fi
76
+ echo "::error::the tests concluded $conclusion for $sha, see $url"
77
+ exit 1
78
+ fi
79
+ fi
80
+ sleep 30
81
+ done
82
+ echo "::error::timed out waiting for the tests of $sha"
83
+ exit 1
84
+
85
+ publish_npm:
86
+ needs: check_tests
87
+ runs-on: ubuntu-latest
88
+ steps:
89
+ - uses: actions/checkout@v5
90
+ with:
91
+ ref: ${{ github.event.release.tag_name }}
92
+ # Setup .npmrc file to publish to npm
93
+ - uses: actions/setup-node@v5
94
+ with:
95
+ node-version: '24'
96
+ registry-url: 'https://registry.npmjs.org'
97
+ - run: npm publish
98
+
99
+ publish_docker:
100
+ name: 'Publish Docker image'
101
+ needs: publish_npm
102
+ runs-on: ubuntu-latest
103
+ steps:
104
+ # the image installs single-file-cli from npm, so building before the registry serves the
105
+ # released version would silently ship the previous one
106
+ - name: Wait for npm to serve the released version
107
+ env:
108
+ TAG_NAME: ${{ github.event.release.tag_name }}
109
+ run: |
110
+ version="${TAG_NAME#v}"
111
+ for attempt in $(seq 1 40); do
112
+ available=$(npm view single-file-cli version 2>/dev/null || true)
113
+ if [ "$available" = "$version" ]; then
114
+ exit 0
115
+ fi
116
+ echo "npm serves ${available:-nothing}, waiting for $version"
117
+ sleep 30
118
+ done
119
+ echo "::error::timed out waiting for npm to serve $version"
120
+ exit 1
121
+ -
122
+ name: Set up QEMU
123
+ uses: docker/setup-qemu-action@v3
124
+ -
125
+ name: Set up Docker Buildx
126
+ uses: docker/setup-buildx-action@v3
127
+ -
128
+ name: Login to Docker Hub
129
+ uses: docker/login-action@v3
130
+ with:
131
+ username: ${{ secrets.DOCKER_USERNAME }}
132
+ password: ${{ secrets.DOCKER_PASSWORD }}
133
+ -
134
+ name: Build and push
135
+ uses: docker/build-push-action@v6
136
+ with:
137
+ push: true
138
+ tags: |
139
+ ${{ secrets.DOCKER_USERNAME }}/singlefile:latest
140
+ ${{ secrets.DOCKER_USERNAME }}/singlefile:${{ github.event.release.tag_name }}
141
+ platforms: linux/amd64,linux/arm64
package/Dockerfile CHANGED
@@ -1,12 +1,16 @@
1
- FROM zenika/alpine-chrome:with-node
1
+ FROM node:24-alpine
2
2
 
3
- RUN npm install --omit=dev single-file-cli
3
+ RUN apk add --no-cache chromium ttf-freefont font-noto-emoji
4
+
5
+ USER node
4
6
 
5
7
  WORKDIR /usr/src/app
6
8
 
9
+ RUN npm install --omit=dev single-file-cli
10
+
7
11
  ENTRYPOINT [ \
8
12
  "npx", \
9
13
  "single-file", \
10
14
  "--browser-executable-path", "/usr/bin/chromium-browser", \
11
15
  "--output-directory", "./out/", \
12
- "--dump-content" ]
16
+ "--dump-content" ]
package/README.MD CHANGED
@@ -143,6 +143,12 @@ Make sure Chrome or a Chromium-based browser is installed in the default folder.
143
143
  single-file https://www.wikipedia.org --crawl-links=true --crawl-inner-links-only=false --crawl-external-links-max-depth=1 --crawl-rewrite-rule="^.*wikipedia.*$"
144
144
  ```
145
145
 
146
+ - Save https://www.wikipedia.org and its internal links into a single self-extracting ZIP file
147
+
148
+ ```sh
149
+ single-file https://www.wikipedia.org wikipedia.html --crawl-links=true --crawl-save-archive=true --compress-content=true
150
+ ```
151
+
146
152
  ## Compile executables
147
153
 
148
154
  - Compile executables into `/dist`
package/build.sh CHANGED
@@ -3,7 +3,7 @@
3
3
  mv package.json package.json.tmp
4
4
  mv deno.json deno.json.tmp
5
5
  mv deno.lock deno.lock.tmp
6
- deno install --vendor --quiet --minimum-dependency-age=0 "npm:single-file-core@1.5.85"
6
+ deno install --vendor --quiet --minimum-dependency-age=0 "npm:single-file-core@1.5.92"
7
7
  mv package.json.tmp package.json
8
8
  mv deno.json.tmp deno.json
9
9
  mv deno.lock.tmp deno.lock
@@ -66,6 +66,20 @@ await build({
66
66
  plugins: [],
67
67
  });
68
68
 
69
+ await build({
70
+ stdin: {
71
+ contents: \"export * from './processors/compression/compression.js'; export * from './vendor/zip/zip.js';\",
72
+ resolveDir: 'node_modules/single-file-core',
73
+ },
74
+ bundle: true,
75
+ outfile: 'lib/single-file-archive.js',
76
+ platform: 'neutral',
77
+ sourcemap: false,
78
+ minify: true,
79
+ format: 'esm',
80
+ plugins: [],
81
+ });
82
+
69
83
  const SCRIPTS = [
70
84
  'lib/single-file.js',
71
85
  'lib/single-file-bootstrap.js',
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@single-file/single-file-cli",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "SingleFile CLI",
5
5
  "exports": {
6
6
  ".": "./single-file-cli-api.js"
package/dev-build.sh CHANGED
@@ -61,6 +61,20 @@ await build({
61
61
  plugins: [],
62
62
  });
63
63
 
64
+ await build({
65
+ stdin: {
66
+ contents: \"export * from './processors/compression/compression.js'; export * from './vendor/zip/zip.js';\",
67
+ resolveDir: 'node_modules/single-file-core',
68
+ },
69
+ bundle: true,
70
+ outfile: 'lib/single-file-archive.js',
71
+ platform: 'neutral',
72
+ sourcemap: false,
73
+ minify: false,
74
+ format: 'esm',
75
+ plugins: [],
76
+ });
77
+
64
78
  const SCRIPTS = [
65
79
  'lib/single-file.js',
66
80
  'lib/single-file-bootstrap.js',
package/eslint.config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import js from "@eslint/js";
2
2
 
3
3
  export default [
4
+ { ignores: ["lib/single-file-archive.js"] },
4
5
  js.configs.recommended,
5
6
  {
6
7
  languageOptions: {
@@ -0,0 +1,225 @@
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
+ const pageData = {
63
+ doctype: "<!DOCTYPE html>",
64
+ content: "",
65
+ title: pages[0].title || "",
66
+ comment: options.insertSingleFileComment ? getComment(pages[0].url, options) : undefined,
67
+ tocContent: getTOCContent(pages)
68
+ };
69
+ const archiveOptions = {
70
+ url: pages[0].url,
71
+ multiPageArchive: true,
72
+ selfExtractingArchive: options.selfExtractingArchive,
73
+ extractDataFromPage: options.extractDataFromPage,
74
+ preventAppendedData: options.preventAppendedData,
75
+ includeBOM: options.includeBOM,
76
+ insertMetaCSP: options.insertMetaCSP,
77
+ insertCanonicalLink: options.insertCanonicalLink,
78
+ insertMetaNoIndex: options.insertMetaNoIndex
79
+ };
80
+ const writtenEntries = options.dedupPages ? new Map() : undefined;
81
+ const aliases = {};
82
+ const blob = await createArchive(pageData, archiveOptions, options.zipScript, async zipWriter => {
83
+ for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
84
+ const pagePath = getPagePath(pageIndex);
85
+ const zipReader = new ZipReader(new Uint8ArrayReader(await pages[pageIndex].getData()));
86
+ for (const entry of await zipReader.getEntries()) {
87
+ const filename = pagePath + entry.filename;
88
+ const rawData = await entry.getData(new Uint8ArrayWriter(), { passThrough: true, checkSignature: false });
89
+ const canonicalFilename = writtenEntries && findDuplicate(writtenEntries, filename, entry, rawData);
90
+ if (canonicalFilename === undefined) {
91
+ await zipWriter.add(filename, new Uint8ArrayReader(rawData), {
92
+ passThrough: true,
93
+ compressionMethod: entry.compressionMethod,
94
+ uncompressedSize: entry.uncompressedSize,
95
+ signature: entry.signature,
96
+ comment: entry.comment,
97
+ lastModDate: entry.lastModDate
98
+ });
99
+ } else {
100
+ // the duplicate becomes a symlink entry so that external
101
+ // extractors still produce complete page folders, the router
102
+ // resolves it from the manifest alias map instead
103
+ aliases[filename] = canonicalFilename;
104
+ await zipWriter.add(filename, new TextReader(getRelativePath(filename, canonicalFilename)), {
105
+ msDosCompatible: false,
106
+ unixMode: SYMLINK_UNIX_MODE,
107
+ level: 0,
108
+ comment: entry.comment,
109
+ lastModDate: entry.lastModDate
110
+ });
111
+ }
112
+ }
113
+ await zipReader.close();
114
+ }
115
+ if (Object.keys(aliases).length) {
116
+ manifest.aliases = aliases;
117
+ }
118
+ if (options.tocPage) {
119
+ await zipWriter.add(TOC_FILENAME, new TextReader(getTOCPageContent(manifest.pages)));
120
+ }
121
+ await zipWriter.add(PAGES_FILENAME, new TextReader(JSON.stringify(manifest, null, 2)));
122
+ });
123
+ return new Uint8Array(await blob.arrayBuffer());
124
+ }
125
+
126
+ function findDuplicate(writtenEntries, filename, entry, rawData) {
127
+ if (entry.directory || !entry.uncompressedSize) {
128
+ return;
129
+ }
130
+ const key = [entry.compressionMethod, entry.uncompressedSize, entry.signature, rawData.length].join(":");
131
+ const candidates = writtenEntries.get(key);
132
+ if (candidates) {
133
+ const match = candidates.find(candidate => equalData(candidate.rawData, rawData));
134
+ if (match) {
135
+ return match.filename;
136
+ }
137
+ candidates.push({ filename, rawData });
138
+ } else {
139
+ writtenEntries.set(key, [{ filename, rawData }]);
140
+ }
141
+ }
142
+
143
+ function equalData(dataLeft, dataRight) {
144
+ return dataLeft.length == dataRight.length && dataLeft.every((value, index) => value == dataRight[index]);
145
+ }
146
+
147
+ function getRelativePath(filename, targetFilename) {
148
+ const baseSegments = filename.split("/").slice(0, -1);
149
+ const targetSegments = targetFilename.split("/");
150
+ while (baseSegments.length && targetSegments.length > 1 && baseSegments[0] == targetSegments[0]) {
151
+ baseSegments.shift();
152
+ targetSegments.shift();
153
+ }
154
+ return "../".repeat(baseSegments.length) + targetSegments.join("/");
155
+ }
156
+
157
+ function getPagePath(pageIndex) {
158
+ return pageIndex == 0 ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
159
+ }
160
+
161
+ function getComment(url, options) {
162
+ return "\n " + COMMENT_HEADER +
163
+ " \n url: " + url +
164
+ (options.removeSavedDate ? " " : " \n saved date: " + new Date()) + "\n";
165
+ }
166
+
167
+ function getTOCPageContent(pages) {
168
+ const origins = new Set(pages.map(page => new URL(page.url).origin));
169
+ const rootGroup = { groups: new Map(), pages: [] };
170
+ pages.forEach(page => {
171
+ const url = new URL(page.url);
172
+ const segments = url.pathname.split("/").slice(1, -1);
173
+ if (origins.size > 1) {
174
+ segments.unshift(url.origin);
175
+ }
176
+ let group = rootGroup;
177
+ segments.forEach(segment => {
178
+ if (!group.groups.has(segment)) {
179
+ group.groups.set(segment, { groups: new Map(), pages: [] });
180
+ }
181
+ group = group.groups.get(segment);
182
+ });
183
+ group.pages.push(page);
184
+ });
185
+ const title = pages[0].title ? TOC_TITLE + " - " + pages[0].title : TOC_TITLE;
186
+ return "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
187
+ "<title>" + escapeUnicodeHTML(title) + "</title><style>" + TOC_STYLE + "</style></head><body><main><h1>" +
188
+ escapeUnicodeHTML(TOC_TITLE) + "</h1>" + getTOCGroupContent(rootGroup) + "</main></body></html>";
189
+ }
190
+
191
+ // nested details/summary groups stay collapsible without scripts on purpose,
192
+ // the page must remain usable after a plain unzip
193
+ function getTOCGroupContent(group) {
194
+ let content = "";
195
+ if (group.pages.length) {
196
+ content += "<ul>" + group.pages.map(page =>
197
+ "<li><a href=\"" + escapeUnicodeHTML(page.path + "index.html") + "\">" + escapeUnicodeHTML(page.title || page.url) + "</a></li>").join("") + "</ul>";
198
+ }
199
+ group.groups.forEach((childGroup, segment) => {
200
+ content += "<details open><summary>" + escapeUnicodeHTML(segment) + "</summary>" + getTOCGroupContent(childGroup) + "</details>";
201
+ });
202
+ return content;
203
+ }
204
+
205
+ // unlike the prelude TOC below, the stored page is a UTF-8 entry: only the
206
+ // markup delimiters need escaping, but crawled titles remain untrusted
207
+ function escapeUnicodeHTML(value) {
208
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
209
+ }
210
+
211
+ function getTOCContent(pages) {
212
+ return "<nav><ul>" +
213
+ pages.map(page => "<li><a href=\"" + escapeHTML(page.url) + "\">" + escapeHTML(page.title || page.url) + "</a></li>").join("") +
214
+ "</ul></nav>";
215
+ }
216
+
217
+ // the prelude declares the windows-1252 charset, non-ASCII characters must be
218
+ // encoded as HTML entities to survive it
219
+ function escapeHTML(value) {
220
+ return Array.from(value).map(character => {
221
+ const codePoint = character.codePointAt(0);
222
+ return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
223
+ "&#" + codePoint + ";" : character;
224
+ }).join("");
225
+ }
package/lib/cdp-client.js CHANGED
@@ -697,7 +697,7 @@ async function capturePageData({ Runtime }, contextId, { options, debugMessages
697
697
  CAPTURE_SCREENSHOT_FUNCTION_NAME,
698
698
  PRINT_TO_PDF_FUNCTION_NAME
699
699
  ])})`;
700
- const { result } = await Promise.race([
700
+ const { result, exceptionDetails } = await Promise.race([
701
701
  Runtime.evaluate({
702
702
  expression: captureScript,
703
703
  awaitPromise: true,
@@ -706,6 +706,12 @@ async function capturePageData({ Runtime }, contextId, { options, debugMessages
706
706
  }),
707
707
  waitForTimeout(captureTimeoutAbortSignal, options.browserCaptureMaxTime, CAPTURE_TIMEOUT_ERROR_MESSAGE, CAPTURE_TIMEOUT_ERROR)
708
708
  ]);
709
+ // `returnByValue` serializes the rejection value to an empty object, so
710
+ // the error is only readable in `exceptionDetails`
711
+ if (exceptionDetails) {
712
+ const { exception, text } = exceptionDetails;
713
+ throw new Error(exception && exception.description ? exception.description : text);
714
+ }
709
715
  if (result.subtype === ERROR_SUBTYPE) {
710
716
  throw new Error(result.description);
711
717
  }