single-file-core 1.5.89 → 1.5.91

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,88 @@
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 vendor workflow runs on push,
15
+ # so this waits for its run on that exact commit and refuses to publish unless it succeeded
16
+ check_vendor:
17
+ name: 'Check vendor rebuild'
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
+ - name: Wait for the vendor rebuild check of the released commit
41
+ env:
42
+ GH_TOKEN: ${{ github.token }}
43
+ run: |
44
+ sha=$(git rev-parse HEAD)
45
+ echo "released commit: $sha"
46
+ missing=0
47
+ for attempt in $(seq 1 60); do
48
+ run=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/vendor.yml/runs?head_sha=$sha&per_page=1" \
49
+ --jq 'if (.workflow_runs | length) == 0 then "none none none"
50
+ else (.workflow_runs[0] | "\(.status) \(.conclusion) \(.html_url)") end')
51
+ run_status=$(echo "$run" | cut -d' ' -f1)
52
+ conclusion=$(echo "$run" | cut -d' ' -f2)
53
+ url=$(echo "$run" | cut -d' ' -f3)
54
+ if [ "$run_status" = "none" ]; then
55
+ missing=$((missing + 1))
56
+ if [ "$missing" -ge 10 ]; then
57
+ echo "::error::no vendor run for $sha, run the vendor workflow on this commit and publish again"
58
+ exit 1
59
+ fi
60
+ echo "no vendor run found yet for $sha"
61
+ else
62
+ echo "vendor run: status=$run_status conclusion=$conclusion $url"
63
+ if [ "$run_status" = "completed" ]; then
64
+ if [ "$conclusion" = "success" ]; then
65
+ exit 0
66
+ fi
67
+ echo "::error::the vendor rebuild concluded $conclusion for $sha, see $url"
68
+ exit 1
69
+ fi
70
+ fi
71
+ sleep 30
72
+ done
73
+ echo "::error::timed out waiting for the vendor rebuild of $sha"
74
+ exit 1
75
+
76
+ publish_npm:
77
+ needs: check_vendor
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - uses: actions/checkout@v5
81
+ with:
82
+ ref: ${{ github.event.release.tag_name }}
83
+ # Setup .npmrc file to publish to npm
84
+ - uses: actions/setup-node@v5
85
+ with:
86
+ node-version: '24'
87
+ registry-url: 'https://registry.npmjs.org'
88
+ - run: npm publish
@@ -0,0 +1,25 @@
1
+ # .github/workflows/vendor.yml
2
+
3
+ name: Vendor
4
+
5
+ # tag pushes are excluded: creating a release would otherwise re-run the check already done on the branch
6
+ on:
7
+ push:
8
+ branches: ['**']
9
+ pull_request:
10
+
11
+ jobs:
12
+ rebuild:
13
+ name: 'Rebuild vendor/zip'
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: actions/setup-node@v5
18
+ with:
19
+ node-version: '24'
20
+ - run: npm ci
21
+ working-directory: zip-build
22
+ - run: npm run build
23
+ working-directory: zip-build
24
+ # the checked-in files in vendor/zip must be exactly what the sources in zip-build produce
25
+ - run: git diff --exit-code vendor/zip
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.89",
3
+ "version": "1.5.91",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "test": "echo \"Error: no test specified\" && exit 1",
9
+ "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
+ "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
+ "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
12
+ "bump-commit": "git commit -m \"bump up version\" package.json package-lock.json"
9
13
  },
10
14
  "repository": {
11
15
  "type": "git",
@@ -18,4 +22,4 @@
18
22
  "devDependencies": {
19
23
  "eslint": "^9.39.1"
20
24
  }
21
- }
25
+ }
@@ -27,8 +27,13 @@ export {
27
27
 
28
28
  async function router(content, { extract, display }) {
29
29
  const PAGES_PREFIX = "pages/";
30
+ const RESERVED_PREFIX = "sfz-";
30
31
  const PAGES_FILENAME = "sfz-pages.json";
32
+ const TOC_FILENAME = "sfz-toc.html";
31
33
  const ROUTE_PREFIX = "#sfz/";
34
+ // "?" cannot start a zip entry path written by the packager, so reserved
35
+ // routes never collide with page paths
36
+ const TOC_ROUTE = "?toc";
32
37
  const TARGET_ATTRIBUTE = "data-sfz-target";
33
38
  const TARGET_PSEUDO_CLASS = /:target(?![\w-])/g;
34
39
  const VISITED_ATTRIBUTE = "data-sfz-visited";
@@ -41,7 +46,7 @@ async function router(content, { extract, display }) {
41
46
  const UNARCHIVED_TITLE = "Not saved in this archive";
42
47
  const UNARCHIVED_PROTOCOLS = ["http:", "https:"];
43
48
  const PREFETCH_DELAY = 100;
44
- const { zip, document, location, history, CSS, setTimeout, clearTimeout } = globalThis;
49
+ const { zip, document, location, history, CSS, URL, setTimeout, clearTimeout } = globalThis;
45
50
  const cache = new Map();
46
51
  const urlToPath = new Map();
47
52
  const scrollStates = new Map();
@@ -62,6 +67,7 @@ async function router(content, { extract, display }) {
62
67
  throw new Error("Pages data not found");
63
68
  }
64
69
  const manifest = JSON.parse(await pagesEntry.getData(new zip.TextWriter()));
70
+ const tocEntry = entries.find(entry => entry.filename == TOC_FILENAME);
65
71
  const { pages } = manifest;
66
72
  const aliases = new Map(Object.entries(manifest.aliases || {}));
67
73
  pages.forEach(page => {
@@ -97,6 +103,11 @@ async function router(content, { extract, display }) {
97
103
  const node = findAnchor(event.target);
98
104
  if (node && node.href) {
99
105
  const fragment = getFragment(node.href);
106
+ // rewritten TOC links are already hash routes, default navigation
107
+ // triggers hashchange and modified clicks open the deep link natively
108
+ if (fragment && fragment.startsWith(ROUTE_PREFIX) && stripFragment(node.href) == stripFragment(location.href)) {
109
+ return;
110
+ }
100
111
  let path = urlToPath.get(stripFragment(node.href));
101
112
  if (path === undefined && fragment && stripFragment(node.href) == stripFragment(location.href)) {
102
113
  path = currentPath;
@@ -127,7 +138,7 @@ async function router(content, { extract, display }) {
127
138
  const continuityScrolls = captureElementScrolls();
128
139
  const entryId = getEntryId();
129
140
  const { routed, path, fragment } = parseRoute();
130
- const willRender = routed && path != currentPath && Boolean(pages.find(page => page.path == path));
141
+ const willRender = routed && path != currentPath && isRenderablePath(path);
131
142
  // the whole render and scroll sequence runs inside the view transition
132
143
  // so that the crossfade ends on the final scroll position
133
144
  if (willRender && document.startViewTransition && !prefersReducedMotion()) {
@@ -177,13 +188,16 @@ async function router(content, { extract, display }) {
177
188
  if (!routed && !initial) {
178
189
  return false;
179
190
  }
180
- if (path == currentPath || !pages.find(page => page.path == path)) {
191
+ if (path == currentPath || !isRenderablePath(path)) {
181
192
  return false;
182
193
  }
183
194
  const docContent = await getPageContent(path);
184
195
  currentPath = path;
185
196
  await display(document, docContent, { inPlace: true });
186
197
  attachListeners();
198
+ if (path == TOC_ROUTE) {
199
+ rewriteTocLinks();
200
+ }
187
201
  visitedPaths.add(path);
188
202
  markVisitedLinks();
189
203
  if (manifest.markUnarchivedLinks) {
@@ -205,14 +219,24 @@ async function router(content, { extract, display }) {
205
219
  let path = pages[0].path;
206
220
  let fragment;
207
221
  if (routed && hash) {
208
- const route = hash.substring(ROUTE_PREFIX.length);
209
- const indexFragment = route.indexOf("#");
210
- path = decodeURIComponent(indexFragment == -1 ? route : route.substring(0, indexFragment));
211
- fragment = indexFragment == -1 ? undefined : route.substring(indexFragment);
222
+ ({ path, fragment } = parseRouteHash(hash));
212
223
  }
213
224
  return { routed, path, fragment };
214
225
  }
215
226
 
227
+ function parseRouteHash(hash) {
228
+ const route = hash.substring(ROUTE_PREFIX.length);
229
+ const indexFragment = route.indexOf("#");
230
+ return {
231
+ path: decodeURIComponent(indexFragment == -1 ? route : route.substring(0, indexFragment)),
232
+ fragment: indexFragment == -1 ? undefined : route.substring(indexFragment)
233
+ };
234
+ }
235
+
236
+ function isRenderablePath(path) {
237
+ return path == TOC_ROUTE ? Boolean(tocEntry) : Boolean(pages.find(page => page.path == path));
238
+ }
239
+
216
240
  // the cache stores promises so that a click during a hover prefetch awaits
217
241
  // the extraction in flight instead of starting a second one
218
242
  function getPageContent(path) {
@@ -225,6 +249,9 @@ async function router(content, { extract, display }) {
225
249
  }
226
250
 
227
251
  async function extractPageContent(path) {
252
+ if (path == TOC_ROUTE) {
253
+ return tocEntry.getData(new zip.TextWriter());
254
+ }
228
255
  const pageEntries = entries
229
256
  .filter(entry => belongsToPage(entry.filename, path) && !aliases.has(entry.filename))
230
257
  .concat(getAliasEntries(path));
@@ -252,14 +279,37 @@ async function router(content, { extract, display }) {
252
279
 
253
280
  function belongsToPage(filename, path) {
254
281
  return path == "" ?
255
- !filename.startsWith(PAGES_PREFIX) && filename != PAGES_FILENAME :
282
+ !filename.startsWith(PAGES_PREFIX) && !filename.startsWith(RESERVED_PREFIX) :
256
283
  filename.startsWith(path);
257
284
  }
258
285
 
286
+ // the stored TOC links relative page paths so that it works once unzipped,
287
+ // in the archive they are rewritten to hash routes: unrewritten links would
288
+ // resolve against the archive URL and get stamped as unarchived
289
+ function rewriteTocLinks() {
290
+ const pathsByUrl = new Map();
291
+ pages.forEach(page => pathsByUrl.set(new URL(page.path + "index.html", stripFragment(location.href)).href, page.path));
292
+ document.querySelectorAll("a[href]").forEach(anchorElement => {
293
+ const fragment = getFragment(anchorElement.href);
294
+ const path = pathsByUrl.get(stripFragment(anchorElement.href));
295
+ if (path !== undefined) {
296
+ anchorElement.setAttribute("href", ROUTE_PREFIX + path + (fragment || ""));
297
+ }
298
+ });
299
+ }
300
+
301
+ function getLinkPath(href) {
302
+ const fragment = getFragment(href);
303
+ if (fragment && fragment.startsWith(ROUTE_PREFIX) && stripFragment(href) == stripFragment(location.href)) {
304
+ return parseRouteHash(fragment).path;
305
+ }
306
+ return urlToPath.get(stripFragment(href));
307
+ }
308
+
259
309
  function prefetchOnHover(event) {
260
310
  const node = findAnchor(event.target);
261
311
  if (node && node.href) {
262
- const path = urlToPath.get(stripFragment(node.href));
312
+ const path = getLinkPath(node.href);
263
313
  if (path !== undefined && path != currentPath && !cache.has(path)) {
264
314
  clearTimeout(prefetchTimeout);
265
315
  prefetchTimeout = setTimeout(() => getPageContent(path).catch(() => { }), PREFETCH_DELAY);
@@ -380,7 +430,7 @@ async function router(content, { extract, display }) {
380
430
  getPseudoRules(VISITED_PSEUDO_CLASS, VISITED_ATTRIBUTE);
381
431
  document.head.appendChild(styleElement);
382
432
  document.querySelectorAll("a[href]").forEach(anchorElement => {
383
- const path = urlToPath.get(stripFragment(anchorElement.href));
433
+ const path = getLinkPath(anchorElement.href);
384
434
  if (path !== undefined && visitedPaths.has(path)) {
385
435
  anchorElement.setAttribute(VISITED_ATTRIBUTE, "");
386
436
  }
@@ -112,7 +112,6 @@ async function process(pageData, options, lastModDate = new Date()) {
112
112
  async function createArchive(pageData, options, script, writeEntries, lastModDate = new Date()) {
113
113
  const zipDataWriter = new Uint8ArrayWriter();
114
114
  zipDataWriter.init();
115
- zipDataWriter.writable.size = 0;
116
115
  let extraDataOffset, extraData, embeddedImageDataOffset, endTag;
117
116
  if (options.embeddedImage) {
118
117
  options.embeddedImage = new Uint8Array(options.embeddedImage);
@@ -145,10 +144,15 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
145
144
  } else if (!options.embeddedImage && options.embeddedPdf) {
146
145
  await writeData(zipDataWriter.writable, new Uint8Array(options.embeddedPdf));
147
146
  }
148
- const zipWriter = new ZipWriter(zipDataWriter, { bufferedWrite: true, keepOrder: true, lastModDate, useCompressionStream: true });
147
+ // a WritableWriter object is passed instead of the writer so that the ZipWriter
148
+ // never takes ownership of the stream: preventClose is only honored when the
149
+ // caller owns the writable, and the HTML suffix still gets written after it;
150
+ // its size property tells the ZipWriter the offset of the data written so far
149
151
  const startOffset = zipDataWriter.offset;
152
+ const zipWriter = new ZipWriter({ writable: zipDataWriter.writable, size: startOffset }, { bufferedWrite: true, keepOrder: true, lastModDate, useCompressionStream: true });
150
153
  await writeEntries(zipWriter);
151
- const data = await zipWriter.close(null, { preventClose: true });
154
+ await zipWriter.close(undefined, { preventClose: true });
155
+ const data = zipDataWriter.getData();
152
156
  if (options.selfExtractingArchive) {
153
157
  const lfCodes = [];
154
158
  let crc32 = -1;
@@ -418,7 +422,6 @@ function findExtraDataTags(textContent, pageData, options, script, writeEntries,
418
422
  async function writeData(writable, array) {
419
423
  const streamWriter = writable.getWriter();
420
424
  await streamWriter.ready;
421
- writable.size += array.length;
422
425
  await streamWriter.write(array);
423
426
  streamWriter.releaseLock();
424
427
  }
@@ -0,0 +1,56 @@
1
+ # Rebuilding `vendor/zip`
2
+
3
+ The three files in [`../vendor/zip`](../vendor/zip) are custom builds of
4
+ [@zip.js/zip.js](https://www.npmjs.com/package/@zip.js/zip.js) with
5
+ [fflate](https://www.npmjs.com/package/fflate) as `DecompressionStream`
6
+ fallback:
7
+
8
+ - `zip.min.js` — extraction bundle used by self-extracting pages
9
+ (`ZipReader`, `HttpRangeReader`, blob/text/data-URI readers and writers,
10
+ no web workers, fflate fallback for browsers without
11
+ `DecompressionStream`)
12
+ - `zip.js` — creation bundle used when saving pages (full zip-core plus
13
+ `deflateRaw`/`inflateRaw`)
14
+ - `z-worker.js` — web worker script for the creation bundle
15
+
16
+ This directory rebuilds them byte-for-byte from their sources. All
17
+ dependencies are pinned to exact versions by `package.json` and
18
+ `package-lock.json`, and the build embeds no timestamps, so the output is
19
+ reproducible on any platform with a recent Node.js version:
20
+
21
+ ```sh
22
+ cd zip-build
23
+ npm ci
24
+ npm run build
25
+ git diff --exit-code ../vendor/zip
26
+ ```
27
+
28
+ An empty diff means the checked-in files match the sources exactly. The
29
+ GitHub Actions workflow `.github/workflows/vendor.yml` runs the same steps
30
+ on every push.
31
+
32
+ ## Files
33
+
34
+ - `lib/zip.js`, `lib/zip-vendor.js`, `lib/zip-vendor-worker.js` — bundle
35
+ entry points; they import the zip.js sources from the pinned npm package
36
+ - `lib/fflate.js` — vendored [fflate](https://github.com/101arrowz/fflate)
37
+ 0.8.3 (MIT)
38
+ - `lib/fflate-streams.js` — `CompressionStream`/`DecompressionStream`
39
+ adapter over fflate
40
+ - `reserved-property-names.json` — property names protected from terser
41
+ property mangling; generated by `reserved-property-names.js` in the
42
+ [zip.js repository](https://github.com/gildas-lormeau/zip.js) at the
43
+ pinned version (the script is not part of the npm package)
44
+
45
+ ## Updating zip.js
46
+
47
+ 1. Bump `@zip.js/zip.js` in `package.json` and run `npm install`.
48
+ 2. Regenerate `reserved-property-names.json` from a checkout of the zip.js
49
+ repository at the matching tag, with its dev dependencies installed:
50
+
51
+ ```sh
52
+ node --input-type=module -e "import { getReservedPropertyNames } from './reserved-property-names.js'; console.log(JSON.stringify([...getReservedPropertyNames()], null, '\t'))" > path/to/single-file-core/zip-build/reserved-property-names.json
53
+ ```
54
+
55
+ 3. Run `npm run build` and commit the regenerated files in `../vendor/zip`
56
+ together with the changes in this directory.
@@ -0,0 +1,47 @@
1
+ /* global TransformStream */
2
+
3
+ import { Deflate, Inflate } from "./fflate.js";
4
+
5
+ const FORMAT_DEFLATE_RAW = "deflate-raw";
6
+
7
+ class FflateStream extends TransformStream {
8
+ constructor(codec) {
9
+ super({
10
+ start(controller) {
11
+ codec.ondata = chunk => {
12
+ if (chunk.length) {
13
+ controller.enqueue(chunk);
14
+ }
15
+ };
16
+ },
17
+ transform(chunk) {
18
+ codec.push(chunk);
19
+ },
20
+ flush() {
21
+ codec.push(new Uint8Array(0), true);
22
+ }
23
+ });
24
+ }
25
+ }
26
+
27
+ class CompressionStreamFallback extends FflateStream {
28
+ constructor(format, { level } = {}) {
29
+ checkFormat(format);
30
+ super(new Deflate(level === undefined ? {} : { level }));
31
+ }
32
+ }
33
+
34
+ class DecompressionStreamFallback extends FflateStream {
35
+ constructor(format) {
36
+ checkFormat(format);
37
+ super(new Inflate());
38
+ }
39
+ }
40
+
41
+ function checkFormat(format) {
42
+ if (format != FORMAT_DEFLATE_RAW) {
43
+ throw new TypeError("Unsupported compression format: " + format);
44
+ }
45
+ }
46
+
47
+ export { CompressionStreamFallback, DecompressionStreamFallback };