single-file-core 1.5.115 → 1.5.116

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,184 @@
1
+ // removeUnusedFonts decides which @font-face rules to keep by reading the font-family declarations
2
+ // out of the stylesheets. When a family is written as var(--name), the value has to be resolved
3
+ // first, and the only resolution the module had was getComputedStyle(body).getPropertyValue(name).
4
+ // Custom properties inherit downward, so that answers for a property declared on :root and returns
5
+ // nothing for one declared on any descendant — and a single unresolved variable anywhere sets
6
+ // unusedFonts to [], which switches pruning off for the WHOLE document. One declaration moved from
7
+ // :root to .card took a probe page from 7,611 to 43,648 bytes with every unused font embedded.
8
+ //
9
+ // The fix keeps the computed value as the first answer and falls back to the union of every value
10
+ // the document declares for that property. The union is a superset of what any one element sees,
11
+ // so it can only keep too much, never too little — and options.usedFonts, which comes from the
12
+ // rendered computed styles, gates the result anyway.
13
+ //
14
+ // The other var() defects this pins, all found alongside it:
15
+ // - custom property names are case-sensitive; they were lowercased with the family names, so
16
+ // var(--ProbeFont) never resolved, not even from :root
17
+ // - the "font" shorthand's var() regexp required var(--name) exactly, so a fallback in it made
18
+ // the shorthand unreadable and switched pruning off
19
+ // - a var() fallback is one raw token, so a list in it was pushed as a single mangled name and
20
+ // the families it holds were dropped
21
+ import * as cssTree from "../../vendor/css-tree.js";
22
+
23
+ // the module reaches core/helper.js, which pulls in the frame hooks, and those install themselves
24
+ // against window and document as they are evaluated: the stubs go in before the dynamic import
25
+ globalThis.window = globalThis;
26
+ globalThis.document = {};
27
+ globalThis.Document = class Document { };
28
+ globalThis.MutationObserver = class MutationObserver { observe() { } };
29
+ const { process: removeUnusedFonts } = await import("../../modules/css-fonts-minifier.js");
30
+
31
+ const FONT_FACES = `
32
+ @font-face{font-family:"UsedOne";src:url(one.woff2)}
33
+ @font-face{font-family:"UsedTwo";src:url(two.woff2)}
34
+ @font-face{font-family:"UsedThree";src:url(three.woff2)}
35
+ @font-face{font-family:"Unused";src:url(unused.woff2)}`;
36
+
37
+ // what the rendered page reports as actually drawn: [family, weight, style, variant]. A family
38
+ // missing from this list is dropped whatever the stylesheets say, so it holds every family the
39
+ // tests below may legitimately keep
40
+ const USED_FONTS = [
41
+ ["usedone", "400", "normal", "normal"],
42
+ ["usedtwo", "400", "normal", "normal"],
43
+ ["usedthree", "400", "normal", "normal"]
44
+ ];
45
+
46
+ const ALL_FAMILIES = ["usedone", "usedtwo", "usedthree", "unused"];
47
+
48
+ let failures = 0;
49
+
50
+ check("a plain family name prunes the rest",
51
+ run({ rules: ".card p{font-family:\"UsedOne\",serif}" }),
52
+ ["usedone"]);
53
+
54
+ check("a property declared on :root resolves through the computed style",
55
+ run({ rules: ":root{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font),serif}", computed: { "--probe-font": "\"UsedOne\"" } }),
56
+ ["usedone"]);
57
+
58
+ check("a property declared on a descendant resolves through the declared values",
59
+ run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font),serif}" }),
60
+ ["usedone"]);
61
+
62
+ check("a mixed-case property name is not lowercased away",
63
+ run({ rules: ".card{--ProbeFont:\"UsedOne\"}.card p{font-family:var(--ProbeFont),serif}" }),
64
+ ["usedone"]);
65
+
66
+ check("a property declared inside a media query resolves too",
67
+ run({ rules: "@media screen{.card{--probe-font:\"UsedOne\"}}.card p{font-family:var(--probe-font),serif}" }),
68
+ ["usedone"]);
69
+
70
+ check("several declared values are all kept as candidates",
71
+ run({ rules: ".card{--probe-font:\"UsedOne\"}.note{--probe-font:\"UsedTwo\"}p{font-family:var(--probe-font),serif}" }),
72
+ ["usedone", "usedtwo"]);
73
+
74
+ check("a fallback list is parsed as a list, not as one name",
75
+ run({ rules: ":root{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font,\"UsedTwo\",\"UsedThree\"),serif}", computed: { "--probe-font": "\"UsedOne\"" } }),
76
+ ["usedone", "usedtwo", "usedthree"]);
77
+
78
+ check("the font shorthand resolves a uniquely declared property",
79
+ run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font)}" }),
80
+ ["usedone"]);
81
+
82
+ check("the font shorthand resolves a property written with a fallback",
83
+ run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font,serif)}" }),
84
+ ["usedone"]);
85
+
86
+ // the conservative half of the contract: when the value genuinely cannot be determined, every
87
+ // declared font stays, including the one no rule names. These are the cases the union does not
88
+ // cover, and getting them wrong loses fonts from the saved page rather than merely wasting bytes
89
+ check("a property declared nowhere keeps every font",
90
+ run({ rules: ".card p{font-family:var(--set-by-script),serif}" }),
91
+ ALL_FAMILIES);
92
+
93
+ // the shorthand cannot be substituted with several candidate values, but its var() sits in family
94
+ // position, so the parser hands it back as the family and the union answers it there instead
95
+ check("a font shorthand with several candidates resolves through the family",
96
+ run({ rules: ".card{--probe-font:\"UsedOne\"}.note{--probe-font:\"UsedTwo\"}p{font:italic 1em var(--probe-font)}" }),
97
+ ["usedone", "usedtwo"]);
98
+
99
+ // a property holding the whole shorthand is the case the union must NOT touch: its values are not
100
+ // family lists, and reading them as such would drop every font in the document
101
+ check("a property holding a whole shorthand keeps every font",
102
+ run({ rules: ".card{--font:italic 1em \"UsedOne\"}.note{--font:italic 1em \"UsedTwo\"}p{font:var(--font)}" }),
103
+ ALL_FAMILIES);
104
+
105
+ check("a var() nested in a fallback keeps every font",
106
+ run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--other,var(--probe-font),serif)}" }),
107
+ ALL_FAMILIES);
108
+
109
+ check("a property whose value is another undetermined property keeps every font",
110
+ run({ rules: ".card{--probe-font:var(--set-by-script)}.card p{font-family:var(--probe-font),serif}" }),
111
+ ALL_FAMILIES);
112
+
113
+ // An unquoted family name is a sequence of identifier tokens, and the walk that joins them has to
114
+ // resume after the LAST of them. Resuming after the first pushed every word but that one again as a
115
+ // family in its own right, so "Helvetica Neue Light" also claimed fonts named "Neue Light" and
116
+ // "Light". It only ever kept too much, which is why it went unnoticed; a page declaring a family
117
+ // whose name is the tail of another one paid for it.
118
+ const TAIL_FACES = `
119
+ @font-face{font-family:"Helvetica Neue Light";src:url(one.woff2)}
120
+ @font-face{font-family:"Neue Light";src:url(two.woff2)}
121
+ @font-face{font-family:"Light";src:url(three.woff2)}`;
122
+
123
+ const TAIL_USED_FONTS = [
124
+ ["helvetica neue light", "400", "normal", "normal"],
125
+ ["neue light", "400", "normal", "normal"],
126
+ ["light", "400", "normal", "normal"]
127
+ ];
128
+
129
+ check("a multi-word family name is read whole",
130
+ run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:Helvetica Neue Light,serif}" }),
131
+ ["helvetica neue light"]);
132
+
133
+ check("a quoted name is still matched by its quoted declaration",
134
+ run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:\"Neue Light\",serif}" }),
135
+ ["neue light"]);
136
+
137
+ // the token that ends a name is not always the comma: a string or a number ends it too, and the
138
+ // walk used to append the token's absent `name` to the family as the text "undefined"
139
+ check("a name is ended by a token that is not an identifier",
140
+ run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:Light \"Neue Light\"}" }),
141
+ ["neue light", "light"]);
142
+
143
+ console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
144
+ Deno.exit(failures ? 1 : 0);
145
+
146
+ function run({ rules, computed, faces = FONT_FACES, usedFonts = USED_FONTS }) {
147
+ const stylesheet = cssTree.parse(faces + rules);
148
+ const options = { usedFonts };
149
+ const originalGetComputedStyle = globalThis.getComputedStyle;
150
+ if (computed) {
151
+ options.doc = { body: {} };
152
+ globalThis.getComputedStyle = () => ({ getPropertyValue: name => computed[name] || "" });
153
+ }
154
+ try {
155
+ removeUnusedFonts(createStubDocument(), [{ stylesheet }], [], options);
156
+ } finally {
157
+ globalThis.getComputedStyle = originalGetComputedStyle;
158
+ }
159
+ const families = [];
160
+ stylesheet.children.forEach(ruleData => {
161
+ if (ruleData.type == "Atrule" && ruleData.name == "font-face") {
162
+ const declaration = ruleData.block.children.filter(node => node.property == "font-family").tail;
163
+ families.push(cssTree.generate(declaration.data.value).replace(/^"|"$/g, "").toLowerCase());
164
+ }
165
+ });
166
+ return families;
167
+ }
168
+
169
+ function check(label, actual, expected) {
170
+ const ok = actual.length == expected.length && actual.every((family, index) => family == expected[index]);
171
+ if (!ok) {
172
+ failures++;
173
+ }
174
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual.join(", ") || "(none)"}${ok ? "" : " (expected " + expected.join(", ") + ")"}`);
175
+ }
176
+
177
+ // the module only reaches the document to borrow a <style> element for unescaping content values
178
+ // and to read the body text, so a full DOM is not needed here
179
+ function createStubDocument() {
180
+ return {
181
+ createElement: () => ({ textContent: "", remove() { } }),
182
+ body: { appendChild() { }, innerText: "" }
183
+ };
184
+ }
@@ -0,0 +1,85 @@
1
+ // The CSS-removal option runs every declaration through css-tree's lexer and drops the ones it
2
+ // judges invalid. css-tree reports two different failures the same way — a real value mismatch
3
+ // (SyntaxMatchError) and a property it simply has no entry for (SyntaxReferenceError) — so the
4
+ // original `!matchProperty(...).matched` deleted valid declarations whose property is missing from
5
+ // the vendored dictionary. That silently flattened every CSS-defined SVG gradient: stop-opacity was
6
+ // dropped, both stops became opaque, and the gradient rendered as a solid block. It hid for a long
7
+ // time because stop-color survived alongside it — a var() value takes a different branch and is
8
+ // never handed to the lexer at all, so the colour was right and only the fade was gone.
9
+ //
10
+ // The rule this pins: unknown must fail open. A dropped valid declaration breaks rendering; a kept
11
+ // invalid one is ignored by the browser.
12
+ import * as cssTree from "../../vendor/css-tree.js";
13
+ import { isUnsupportedPropertyValue } from "../../modules/css-rules-minifier.js";
14
+
15
+ // valid declarations whose property the vendored css-tree does not know. Every one of these was
16
+ // deleted before the fix. The SVG paint-server and filter properties are the ones that matter in
17
+ // practice; the last two are here as the moving target — any property newer than the vendored
18
+ // dictionary lands in this bucket, and the list will keep growing
19
+ const MUST_KEEP = [
20
+ ["stop-color", "#2a78d6"],
21
+ ["stop-opacity", ".20"],
22
+ ["stop-opacity", "0"],
23
+ ["flood-color", "red"],
24
+ ["flood-opacity", "0.5"],
25
+ ["lighting-color", "white"],
26
+ ["text-box-trim", "trim-both"],
27
+ ["corner-shape", "squircle"]
28
+ ];
29
+
30
+ // declarations css-tree knows and correctly rejects. These keep the filter honest: the fix must not
31
+ // turn the check off, only stop it firing on properties css-tree has never heard of
32
+ const MUST_DROP = [
33
+ ["margin-trim", "block"],
34
+ ["opacity", "not-a-number"],
35
+ ["color", "12px"]
36
+ ];
37
+
38
+ // properties close enough to the broken ones to be worth stating outright, because they are the
39
+ // reason the bug looked like an SVG quirk rather than a dictionary gap: css-tree does know these,
40
+ // so gradients that used them were unaffected and the failure looked arbitrary
41
+ const KNOWN_GOOD = [
42
+ ["fill-opacity", "0.5"],
43
+ ["stroke-opacity", "0.5"],
44
+ ["fill", "red"],
45
+ ["vector-effect", "non-scaling-stroke"]
46
+ ];
47
+
48
+ let failed = false;
49
+
50
+ function check(label, actual, expected) {
51
+ const ok = actual === expected;
52
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
53
+ failed ||= !ok;
54
+ }
55
+
56
+ function isDropped(property, declaration) {
57
+ return isUnsupportedPropertyValue(property, cssTree.parse(declaration, { context: "value" }));
58
+ }
59
+
60
+ for (const [property, declaration] of MUST_KEEP) {
61
+ check(`kept: ${property}: ${declaration}`, isDropped(property, declaration), false);
62
+ }
63
+
64
+ for (const [property, declaration] of MUST_DROP) {
65
+ check(`dropped: ${property}: ${declaration}`, isDropped(property, declaration), true);
66
+ }
67
+
68
+ for (const [property, declaration] of KNOWN_GOOD) {
69
+ check(`kept: ${property}: ${declaration}`, isDropped(property, declaration), false);
70
+ }
71
+
72
+ // the distinction the fix rests on. If a css-tree upgrade ever collapses these two error types into
73
+ // one, the predicate above cannot tell "unknown" from "wrong" any more and needs rewriting rather
74
+ // than adjusting
75
+ const unknownProperty = cssTree.lexer.matchProperty("stop-opacity", cssTree.parse("0", { context: "value" }));
76
+ const wrongValue = cssTree.lexer.matchProperty("margin-trim", cssTree.parse("block", { context: "value" }));
77
+ check("unknown property reports SyntaxReferenceError", unknownProperty.error && unknownProperty.error.name, "SyntaxReferenceError");
78
+ check("wrong value reports SyntaxMatchError", wrongValue.error && wrongValue.error.name, "SyntaxMatchError");
79
+
80
+ if (failed) {
81
+ console.log("FAILED");
82
+ Deno.exit(1);
83
+ } else {
84
+ console.log("PASSED");
85
+ }
@@ -207,6 +207,34 @@ function countIdentifiers(text) {
207
207
  check("the cdata rung leaves the appended data placement alone", options.preventAppendedData !== true, true);
208
208
  }
209
209
 
210
+ // the rung a caller names is resolved by its start tag, not by the identity of the array holding
211
+ // it. The option is internal and is normally set from the module's own EXTRA_DATA_TAGS, so an
212
+ // identity match worked by accident; a caller passing an equal pair of its own indexed the regexp
213
+ // table with -1 and threw a bare TypeError out of library internals
214
+ {
215
+ const options = makeOptions({ extractDataFromPageTags: ["<noframes>", "</noframes>"] });
216
+ const pageData = makePageData(31, 4 * 1024);
217
+ let error;
218
+ try {
219
+ await runProcess(pageData, options);
220
+ } catch (caught) {
221
+ error = caught;
222
+ }
223
+ check("a rung named by an equal pair is resolved", error, undefined);
224
+ }
225
+
226
+ {
227
+ const options = makeOptions({ extractDataFromPageTags: ["<marquee>", "</marquee>"] });
228
+ const pageData = makePageData(32, 4 * 1024);
229
+ let message;
230
+ try {
231
+ await runProcess(pageData, options);
232
+ } catch (error) {
233
+ message = error.message;
234
+ }
235
+ check("a rung that is not in the ladder names itself", message, "Unknown data tags: <marquee>");
236
+ }
237
+
210
238
  // the terminator is the whole test for this rung, so a payload holding it must step past
211
239
  {
212
240
  const content = new Uint8Array(4096).fill(0x21);
@@ -0,0 +1,82 @@
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
+ // `createArchive` builds the script of a self-extracting archive by serializing functions with
25
+ // toString() and pasting the text into the generated page. That page has no module scope, so
26
+ // anything such a function names has to be declared inside it. An import is the trap: it survives
27
+ // bundling, the call still reads correctly in the source, and the archive then fails at runtime
28
+ // with a bare ReferenceError while the page stays blank.
29
+ //
30
+ // It is worse than it looks, because a minifier can hide it. `display` called an imported
31
+ // `getDoctypeString`, and terser inlined that helper into the release bundle, so released
32
+ // archives worked while every unminified build produced archives that threw
33
+ // `ReferenceError: getDoctypeString is not defined` and rendered nothing.
34
+ //
35
+ // The invariant below is what makes that impossible to reintroduce: a module holding an inlined
36
+ // function declares that function and nothing else, so there is no module scope to close over.
37
+
38
+ const MODULES = [
39
+ { path: "../../processors/compression/compression-display.js", functions: ["display"] },
40
+ { path: "../../processors/compression/compression-extract.js", functions: ["extract"] },
41
+ { path: "../../processors/compression/compression-router.js", functions: ["router"] }
42
+ ];
43
+ const TOP_LEVEL_DECLARATION = /^(?:export\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/;
44
+
45
+ let failures = 0;
46
+
47
+ for (const { path, functions } of MODULES) {
48
+ const source = await Deno.readTextFile(new URL(path, import.meta.url));
49
+ const name = path.split("/").pop();
50
+ const lines = source.split("\n");
51
+ const imports = lines.filter(line => /^import[\s{]/.test(line) || /^\s*import\s*\(/.test(line));
52
+ check(name + " imports nothing", imports.length === 0, imports.join(" | "));
53
+ const declared = [];
54
+ lines.forEach(line => {
55
+ if (line === line.trimStart()) {
56
+ const match = line.match(TOP_LEVEL_DECLARATION);
57
+ if (match) {
58
+ declared.push(match[1]);
59
+ }
60
+ }
61
+ });
62
+ const unexpected = declared.filter(identifier => !functions.includes(identifier));
63
+ check(name + " declares nothing at module scope but " + functions.join(", "),
64
+ unexpected.length === 0, unexpected.join(", "));
65
+ }
66
+
67
+ // the one inlined function that does live beside module scope, in compression.js, states the rule
68
+ // in a comment and binds every global it uses itself; assert that binding is still there
69
+ const compression = await Deno.readTextFile(new URL("../../processors/compression/compression.js", import.meta.url));
70
+ const getContentBody = compression.slice(compression.indexOf("async function getContent()"));
71
+ check("getContent binds its globals locally",
72
+ /const \{[^}]*\} = globalThis;/.test(getContentBody.slice(0, 2000)));
73
+
74
+ console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
75
+ Deno.exit(failures ? 1 : 0);
76
+
77
+ function check(label, condition, detail) {
78
+ if (!condition) {
79
+ failures++;
80
+ }
81
+ console.log((condition ? "PASS" : "FAIL") + " " + label + (condition || !detail ? "" : ": " + detail));
82
+ }