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
|
-
|
|
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.
|
|
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 & "start" <b>"), 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 & "start" <b></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
|
+
}
|