single-file-core 1.5.117 → 1.5.118

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.
@@ -71,7 +71,8 @@ const ANONYMOUS_LAYER_PLACEHOLDER = "\u0000";
71
71
 
72
72
  export {
73
73
  process,
74
- isUnsupportedPropertyValue
74
+ isUnsupportedPropertyValue,
75
+ isUnsupportedVendorValue
75
76
  };
76
77
 
77
78
  function isUnsupportedPropertyValue(property, value) {
@@ -79,6 +80,30 @@ function isUnsupportedPropertyValue(property, value) {
79
80
  return Boolean(!match.matched && match.error && match.error.name !== UNKNOWN_PROPERTY_ERROR_NAME);
80
81
  }
81
82
 
83
+ // A vendor-prefixed VALUE is dropped only when this browser cannot actually use it. Dropping every
84
+ // one of them was the overreach: `display:-ms-flexbox` is genuinely dead here and worth removing,
85
+ // but `display:-webkit-box` is alive and load-bearing — `-webkit-line-clamp` does nothing without
86
+ // it, so a page keeps its clamp rules and silently stops clamping. On anandabazar.com that expanded
87
+ // 80 clamped headlines by a line each and moved the page 150px, with nothing wrong-looking left in
88
+ // the saved CSS. Four of the five sites in a fifteen-site sweep that use line-clamp were affected.
89
+ function isUnsupportedVendorValue(property, name) {
90
+ if (!name.startsWith(VENDOR_PREFIX)) {
91
+ return false;
92
+ }
93
+ // unknown fails open, the same rule the unknown-property branch follows: a dropped valid
94
+ // declaration breaks rendering, a kept invalid one is ignored. With no browser to ask, keep.
95
+ if (!globalThis.CSS || !globalThis.CSS.supports) {
96
+ return false;
97
+ }
98
+ try {
99
+ return !globalThis.CSS.supports(property, name);
100
+ // eslint-disable-next-line no-unused-vars
101
+ } catch (error) {
102
+ // a value CSS.supports will not even take as an argument tells us nothing either way
103
+ return false;
104
+ }
105
+ }
106
+
82
107
  function process(doc, stylesheets) {
83
108
  const docContext = {
84
109
  doc,
@@ -497,7 +522,8 @@ function collectDeclarationItemsForElement(element, docContext) {
497
522
  hasValueChildNodes &&
498
523
  value.children.size == 1) {
499
524
  if (value.children.head.data.name) {
500
- isInvalidValue = value.children.head.data.name.startsWith(VENDOR_PREFIX) || INVALID_CSS_ESCAPE_TEST.test(value.children.head.data.name);
525
+ const name = value.children.head.data.name;
526
+ isInvalidValue = isUnsupportedVendorValue(property, name) || INVALID_CSS_ESCAPE_TEST.test(name);
501
527
  } if (!property.startsWith(VENDOR_PREFIX) && value.children.head.data.value) {
502
528
  try {
503
529
  isInvalidValue = isUnsupportedPropertyValue(property, value);
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.117",
3
+ "version": "1.5.118",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "scripts": {
8
- "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js",
8
+ "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js",
9
9
  "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
10
  "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
11
  "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
@@ -29,6 +29,8 @@ any check failed.
29
29
  | `option-wiring.js` | That every option `compression.js` reads is either declared as a caller option or classified as internal, and that `single-file.js` still builds its argument from that declaration. Guards the layer the other suites sit below. |
30
30
  | `css-property-filter.js` | That the declaration filter keeps a property css-tree's dictionary does not know (`stop-color`, `flood-opacity`, anything newer than the pinned build) and still drops a genuinely invalid value. |
31
31
  | `adopted-stylesheets-hook.js` | That the page-world hook answers the adopted-stylesheets request for a CLOSED shadow root, which its host does not expose. |
32
+ | `inlined-functions.js` | That a function serialized into a self-extracting archive names nothing outside itself. An import survives bundling and still reads correctly, and the archive then throws a bare `ReferenceError` and renders nothing. |
33
+ | `pages-archive.js` | That `createPagesArchive` packs several pages into one archive correctly: the first page at the root and the others in folders, the manifest, the symlink a deduplicated entry leaves behind, and the escaping of crawled titles in both tables of contents. |
32
34
  | `css-fonts-minifier.js` | That `removeUnusedFonts` reads the font families it prunes on correctly: a `var()` family resolved from the values the document declares and not only from the ones the body inherits, every font kept when the value is genuinely undetermined, and a multi-word family name that does not also claim a font named after its own tail. |
33
35
 
34
36
  ## The tools
@@ -10,7 +10,7 @@
10
10
  // The rule this pins: unknown must fail open. A dropped valid declaration breaks rendering; a kept
11
11
  // invalid one is ignored by the browser.
12
12
  import * as cssTree from "../../vendor/css-tree.js";
13
- import { isUnsupportedPropertyValue } from "../../modules/css-rules-minifier.js";
13
+ import { isUnsupportedPropertyValue, isUnsupportedVendorValue } from "../../modules/css-rules-minifier.js";
14
14
 
15
15
  // valid declarations whose property the vendored css-tree does not know. Every one of these was
16
16
  // deleted before the fix. The SVG paint-server and filter properties are the ones that matter in
@@ -77,6 +77,34 @@ const wrongValue = cssTree.lexer.matchProperty("margin-trim", cssTree.parse("blo
77
77
  check("unknown property reports SyntaxReferenceError", unknownProperty.error && unknownProperty.error.name, "SyntaxReferenceError");
78
78
  check("wrong value reports SyntaxMatchError", wrongValue.error && wrongValue.error.name, "SyntaxMatchError");
79
79
 
80
+ // The same failure as above, one step earlier and on VALUES rather than properties. Before the fix
81
+ // the call site dropped any single-identifier value beginning with "-", asking nothing: the test
82
+ // for a dead `display:-ms-flexbox` also deleted a live `display:-webkit-box`. That one costs more
83
+ // than it looks, because `-webkit-line-clamp` does nothing without it and both of ITS declarations
84
+ // survive — being unknown properties, they already fail open — so the rule keeps a clamp it no
85
+ // longer applies. Four of the five sites in a fifteen-site sweep that use line-clamp were affected;
86
+ // on one of them 80 headlines each grew a line and the page moved 150px.
87
+ //
88
+ // CSS.supports is the authority and Deno has none, so it is stubbed here. That is also the point of
89
+ // the last group: with no browser to ask, this must KEEP, which is the same fail-open rule as above.
90
+ const CHROME_SUPPORTS = new Set(["display:-webkit-box", "display:-webkit-inline-box", "-webkit-box-orient:vertical"]);
91
+ const originalCSS = globalThis.CSS;
92
+ globalThis.CSS = { supports: (property, value) => CHROME_SUPPORTS.has(property + ":" + value) };
93
+ try {
94
+ // alive in this browser, and load-bearing
95
+ check("vendor value kept: display: -webkit-box", isUnsupportedVendorValue("display", "-webkit-box"), false);
96
+ check("vendor value kept: display: -webkit-inline-box", isUnsupportedVendorValue("display", "-webkit-inline-box"), false);
97
+ // dead in this browser, and the reason the check exists at all — the fix must not disable it
98
+ check("vendor value dropped: display: -ms-flexbox", isUnsupportedVendorValue("display", "-ms-flexbox"), true);
99
+ check("vendor value dropped: display: -moz-box", isUnsupportedVendorValue("display", "-moz-box"), true);
100
+ // not vendor-prefixed, so this predicate must not have an opinion either way
101
+ check("non-vendor value untouched: display: flex", isUnsupportedVendorValue("display", "flex"), false);
102
+ check("non-vendor value untouched: color: nonsense", isUnsupportedVendorValue("color", "nonsense"), false);
103
+ } finally {
104
+ globalThis.CSS = originalCSS;
105
+ }
106
+ check("no browser to ask keeps the value", isUnsupportedVendorValue("display", "-ms-flexbox"), false);
107
+
80
108
  if (failed) {
81
109
  console.log("FAILED");
82
110
  Deno.exit(1);
@@ -0,0 +1,177 @@
1
+ // createPagesArchive packs several single-page archives into one. Nothing exercised it until this
2
+ // file: the module was reachable only through a crawl, so a change to the folder layout, the
3
+ // manifest, the deduplication or the table of contents broke nothing that anyone ran.
4
+ //
5
+ // Three of its rules are worth stating, because they look arbitrary in the code:
6
+ //
7
+ // - the first page is stored at the ROOT and the others under pages/N/. The root page is what a
8
+ // reader opens, so it cannot be moved into a folder without changing every relative URL the
9
+ // capture already resolved.
10
+ // - a duplicate entry becomes a SYMLINK rather than being dropped. The router resolves it from
11
+ // the alias map in the manifest and never reads it, but a plain unzip has to produce complete
12
+ // page folders, and only a symlink gives both.
13
+ // - the titles written into the table of contents are CRAWLED, so they are attacker-controlled
14
+ // text going into an href attribute and into element content. Both escapers are checked here.
15
+ import "./dom-stub.js";
16
+ import { makePageData, makeOptions, runProcess } from "./common.js";
17
+ import { createPagesArchive } from "../../processors/compression/compression-packager.js";
18
+ import { ZipReader, BlobReader, TextWriter } from "../../vendor/zip/zip.js";
19
+
20
+ // a title as it comes back from a crawl: the quote closes the href it is written into, the angle
21
+ // bracket opens an element, and the ampersand is what a naive escaper double-encodes
22
+ const HOSTILE_TITLE = "Intro & \"start\" <b>";
23
+ const SYMLINK_UNIX_MODE = 0o120777;
24
+
25
+ let failed = false;
26
+
27
+ const pages = [
28
+ await makePage(1, { url: "https://example.com/docs/intro.html", title: HOSTILE_TITLE, originalUrls: ["https://example.com/docs/"] }),
29
+ await makePage(2, { url: "https://example.com/docs/api/reference.html", title: "Reference" })
30
+ ];
31
+
32
+ {
33
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions()));
34
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
35
+ check("the first page is stored at the root of the archive", entries.has("index.html"), true);
36
+ check("a later page is stored in a folder of its own", entries.has("pages/2/index.html"), true);
37
+ check("the manifest names the path of every page",
38
+ manifest.pages.map(page => page.path).join(" "), " pages/2/");
39
+ check("the manifest names the url of every page",
40
+ manifest.pages.map(page => page.url).join(" "), "https://example.com/docs/intro.html https://example.com/docs/api/reference.html");
41
+ check("the manifest keeps the title a page was saved with", manifest.pages[0].title, HOSTILE_TITLE);
42
+ // a page reached through several urls has to answer to all of them, or a link to the url the
43
+ // crawler did not settle on leaves the archive
44
+ check("the manifest keeps the urls a page was reached by",
45
+ (manifest.pages[0].originalUrls || []).join(" "), "https://example.com/docs/");
46
+ }
47
+
48
+ // the router reads these two out of the manifest, and "auto" is the absence of a choice rather
49
+ // than a value: writing it would pin the default of the day into every archive
50
+ {
51
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ markUnarchivedLinks: true, pageTransitions: "slide" })));
52
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
53
+ check("the manifest records that unarchived links are marked", manifest.markUnarchivedLinks, true);
54
+ check("the manifest records the page transition it was given", manifest.pageTransitions, "slide");
55
+ }
56
+
57
+ {
58
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ pageTransitions: "auto" })));
59
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
60
+ check("the default page transition is not written to the manifest", "pageTransitions" in manifest, false);
61
+ }
62
+
63
+ // Both fixtures declare the same stylesheet, so pages/2/styles.css is byte-for-byte the entry
64
+ // already written at the root.
65
+ {
66
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ dedupPages: true })));
67
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
68
+ const duplicate = entries.get("pages/2/styles.css");
69
+ check("a repeated entry is still present after deduplication", Boolean(duplicate), true);
70
+ check("the repeated entry points at the one that was kept",
71
+ await readEntry(entries, "pages/2/styles.css"), "../../styles.css");
72
+ // without the mode, tar and unzip write the path as the FILE CONTENT and the page folder ends
73
+ // up holding a text file where a stylesheet belongs
74
+ check("the repeated entry carries the unix symlink mode",
75
+ duplicate.externalFileAttributes >>> 16, SYMLINK_UNIX_MODE);
76
+ // read through a default, so that a manifest with no aliases at all reports as a failed check
77
+ // rather than throwing and taking every check after it down with it
78
+ check("the manifest maps the repeated entry to the one it aliases",
79
+ (manifest.aliases || {})["pages/2/styles.css"], "styles.css");
80
+ check("an entry that is not repeated is left alone",
81
+ "pages/2/index.html" in (manifest.aliases || {}), false);
82
+ }
83
+
84
+ {
85
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions()));
86
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
87
+ check("nothing is aliased when deduplication is off", "aliases" in manifest, false);
88
+ check("a repeated entry is stored whole when deduplication is off",
89
+ (await readEntry(entries, "pages/2/styles.css")).includes("font-family"), true);
90
+ }
91
+
92
+ {
93
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ tocPage: true })));
94
+ const toc = await readEntry(entries, "sfz-toc.html");
95
+ check("the table of contents page is stored when it is asked for", entries.has("sfz-toc.html"), true);
96
+ check("the table of contents links to the page at the root", toc.includes("href=\"index.html\""), true);
97
+ check("the table of contents links to the page in its folder", toc.includes("href=\"pages/2/index.html\""), true);
98
+ // the escaped form has to be there AND the raw form has to be absent: a title written twice,
99
+ // once escaped and once not, passes any check that only looks for the escaped one
100
+ check("a crawled title is escaped into the table of contents",
101
+ toc.includes("Intro &amp; &quot;start&quot; &lt;b&gt;"), true);
102
+ check("a crawled title is not also written raw", toc.includes(HOSTILE_TITLE), false);
103
+ // the groups are details/summary and nothing else on purpose: the page has to stay usable
104
+ // after a plain unzip, where no script runs
105
+ check("pages are grouped by the segments of their path",
106
+ toc.includes("<details open><summary>docs</summary>"), true);
107
+ check("the table of contents needs no script", toc.includes("<script"), false);
108
+ }
109
+
110
+ {
111
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions()));
112
+ check("no table of contents page is stored when it is not asked for", entries.has("sfz-toc.html"), false);
113
+ }
114
+
115
+ // one origin is the whole archive's origin and adding it to every path would say nothing; two
116
+ // origins make it the first thing that tells two pages apart
117
+ {
118
+ const mixedPages = [pages[0], await makePage(3, { url: "https://other.example.org/notes.html", title: "Notes" })];
119
+ const entries = await readArchive(await createPagesArchive(mixedPages, packagerOptions({ tocPage: true })));
120
+ const toc = await readEntry(entries, "sfz-toc.html");
121
+ check("pages from several origins are grouped by origin",
122
+ toc.includes("<summary>https://example.com</summary>"), true);
123
+ }
124
+
125
+ // the prelude list is read without decompressing anything, by tools that never extract the
126
+ // archive, so it is the only place the pages are named in plain text
127
+ {
128
+ const bytes = await createPagesArchive(pages, packagerOptions({ pageList: true }));
129
+ const prelude = new TextDecoder("windows-1252").decode(bytes);
130
+ check("the prelude lists the pages when the page list is asked for",
131
+ prelude.includes("<a href=\"https://example.com/docs/api/reference.html\">Reference</a>"), true);
132
+ // anchored on the link, not on the escaped text alone: the same title is also written into the
133
+ // wrapper's own <title>, which the writer escapes the same way, so a search for the escaped
134
+ // form anywhere in the archive passes even when the page list itself is written raw
135
+ check("the prelude escapes a crawled title too",
136
+ prelude.includes("<a href=\"https://example.com/docs/intro.html\">Intro &#38; &#34;start&#34; &#60;b&#62;</a>"), true);
137
+ check("the prelude is not written when the page list is not asked for",
138
+ new TextDecoder("windows-1252").decode(await createPagesArchive(pages, packagerOptions())).includes("<nav><ul>"), false);
139
+ }
140
+
141
+ console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
142
+ Deno.exit(failed ? 1 : 0);
143
+
144
+ // each page of a multi-page archive is a single-page archive, so the fixtures are built by the
145
+ // writer the rest of the harness already covers
146
+ async function makePage(seed, { url, title, originalUrls }) {
147
+ const pageData = makePageData(seed, 2 * 1024);
148
+ pageData.title = title;
149
+ const { bytes } = await runProcess(pageData, makeOptions({ url }));
150
+ return { url, title, originalUrls, getData: async () => bytes };
151
+ }
152
+
153
+ function packagerOptions(overrides = {}) {
154
+ return {
155
+ selfExtractingArchive: true,
156
+ extractDataFromPage: true,
157
+ zipScript: "/* zip script stub */",
158
+ ...overrides
159
+ };
160
+ }
161
+
162
+ async function readArchive(bytes) {
163
+ const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
164
+ const entries = await zipReader.getEntries();
165
+ await zipReader.close();
166
+ return new Map(entries.map(entry => [entry.filename, entry]));
167
+ }
168
+
169
+ function readEntry(entries, filename) {
170
+ return entries.get(filename).getData(new TextWriter());
171
+ }
172
+
173
+ function check(label, actual, expected) {
174
+ const ok = actual === expected;
175
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
176
+ failed ||= !ok;
177
+ }