single-file-core 1.5.115 → 1.5.117
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.
- package/core/helper.js +34 -13
- package/core/index.js +19 -5
- package/core/lib/processor-helper-common.js +42 -18
- package/core/lib/processor-helper-inline.js +15 -10
- package/core/lib/processor-helper.js +44 -19
- package/doc/singlefile-archive.md +123 -26
- package/modules/css-fonts-minifier.js +191 -33
- package/modules/css-rules-minifier.js +9 -2
- package/package.json +2 -2
- package/processors/compression/compression-display.js +23 -2
- package/processors/compression/compression-packager.js +2 -11
- package/processors/compression/compression.js +33 -9
- package/processors/hooks/content/content-hooks-frames-web.js +27 -4
- package/test/sfz-harness/README.md +5 -2
- package/test/sfz-harness/adopted-stylesheets-hook.js +240 -0
- package/test/sfz-harness/css-fonts-minifier.js +235 -0
- package/test/sfz-harness/css-property-filter.js +85 -0
- package/test/sfz-harness/format-rules.js +31 -1
- package/test/sfz-harness/inlined-functions.js +82 -0
|
@@ -31,7 +31,8 @@ import {
|
|
|
31
31
|
ZipReader
|
|
32
32
|
} from "./../../vendor/zip/zip.js";
|
|
33
33
|
import {
|
|
34
|
-
createArchive
|
|
34
|
+
createArchive,
|
|
35
|
+
escapeHTML
|
|
35
36
|
} from "./compression.js";
|
|
36
37
|
|
|
37
38
|
const browser = globalThis.browser;
|
|
@@ -232,13 +233,3 @@ function getTOCContent(pages) {
|
|
|
232
233
|
pages.map(page => "<li><a href=\"" + escapeHTML(page.url) + "\">" + escapeHTML(page.title || page.url) + "</a></li>").join("") +
|
|
233
234
|
"</ul></nav>";
|
|
234
235
|
}
|
|
235
|
-
|
|
236
|
-
// the prelude declares the windows-1252 charset, non-ASCII characters must be
|
|
237
|
-
// encoded as HTML entities to survive it
|
|
238
|
-
function escapeHTML(value) {
|
|
239
|
-
return Array.from(value).map(character => {
|
|
240
|
-
const codePoint = character.codePointAt(0);
|
|
241
|
-
return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
|
|
242
|
-
"&#" + codePoint + ";" : character;
|
|
243
|
-
}).join("");
|
|
244
|
-
}
|
|
@@ -151,15 +151,27 @@ const PROCESS_OPTION_NAMES = [
|
|
|
151
151
|
export {
|
|
152
152
|
process,
|
|
153
153
|
createArchive,
|
|
154
|
+
escapeHTML,
|
|
154
155
|
PROCESS_OPTION_NAMES
|
|
155
156
|
};
|
|
156
157
|
|
|
157
158
|
async function process(pageData, options, lastModDate = new Date()) {
|
|
158
159
|
let script;
|
|
160
|
+
// The worker is configured before anything else, and outside the extension it is turned off
|
|
161
|
+
// rather than left alone. Given no address, zip.js resolves its default one against the page
|
|
162
|
+
// being saved, so the browser asks the CAPTURED SITE for a file that site has never heard of:
|
|
163
|
+
// three 404s in the user's own server logs for every archive, and then a fallback to the main
|
|
164
|
+
// thread anyway, which is where the work was always going to happen. Choosing the fallback
|
|
165
|
+
// costs nothing that was ever gained and asks the site for nothing.
|
|
166
|
+
const extensionContext = Boolean(browser && browser.runtime && browser.runtime.getURL);
|
|
167
|
+
if (extensionContext) {
|
|
168
|
+
configure({ workerURI: "/lib/single-file-z-worker.js" });
|
|
169
|
+
} else {
|
|
170
|
+
configure({ useWebWorkers: false });
|
|
171
|
+
}
|
|
159
172
|
if (options.zipScript) {
|
|
160
173
|
script = options.zipScript;
|
|
161
|
-
} else if (
|
|
162
|
-
configure({ workerURI: "/lib/single-file-z-worker.js" });
|
|
174
|
+
} else if (extensionContext) {
|
|
163
175
|
script = await (await fetch(browser.runtime.getURL(SCRIPT_PATH))).text();
|
|
164
176
|
}
|
|
165
177
|
return createArchive(pageData, options, script, zipWriter => {
|
|
@@ -253,7 +265,10 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
|
|
|
253
265
|
if (!options.extractDataFromPageTags || options.extractDataFromPageTags[0] != "<plaintext>") {
|
|
254
266
|
const textContent = TEXT_DECODER.decode(data.subarray(startOffset));
|
|
255
267
|
if (options.extractDataFromPageTags) {
|
|
256
|
-
|
|
268
|
+
// the rung is matched on its start tag, not on the identity of the array holding it:
|
|
269
|
+
// the option is set from EXTRA_DATA_TAGS internally, but a caller passing an equal
|
|
270
|
+
// pair of its own would otherwise index the regexps with -1
|
|
271
|
+
const tagIndex = getExtraDataTagIndex(options.extractDataFromPageTags);
|
|
257
272
|
const regExpsTag = EXTRA_DATA_REGEXPS[tagIndex];
|
|
258
273
|
if (textContent.match(regExpsTag[0]) || textContent.match(regExpsTag[1])) {
|
|
259
274
|
return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, tagIndex + 1);
|
|
@@ -619,7 +634,7 @@ function getHTMLHeadData(pageData, options) {
|
|
|
619
634
|
let pageContent = "";
|
|
620
635
|
// the title is left out of a password-protected archive: manifest.json carries it too and
|
|
621
636
|
// is encrypted, so emitting it here would publish what the password is meant to cover
|
|
622
|
-
const title = options.password ? "" :
|
|
637
|
+
const title = options.password ? "" : escapeHTML(pageData.title || "");
|
|
623
638
|
pageContent += "<title>" + title + "</title>";
|
|
624
639
|
// the canonical link publishes the URL the archive was saved from, for the same reason
|
|
625
640
|
// the title above is left out of a password-protected archive
|
|
@@ -641,16 +656,25 @@ function getHTMLHeadData(pageData, options) {
|
|
|
641
656
|
return pageContent;
|
|
642
657
|
}
|
|
643
658
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
659
|
+
// every piece of text in the prelude goes through this, wherever it is assembled: the prelude
|
|
660
|
+
// declares a single-byte charset, and numeric character references are ASCII bytes, so they
|
|
661
|
+
// survive it and any parser decodes them back to the original text
|
|
662
|
+
function escapeHTML(value) {
|
|
663
|
+
return Array.from(value).map(character => {
|
|
648
664
|
const codePoint = character.codePointAt(0);
|
|
649
|
-
return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" ?
|
|
665
|
+
return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
|
|
650
666
|
"&#" + codePoint + ";" : character;
|
|
651
667
|
}).join("");
|
|
652
668
|
}
|
|
653
669
|
|
|
670
|
+
function getExtraDataTagIndex(extractDataFromPageTags) {
|
|
671
|
+
const tagIndex = EXTRA_DATA_TAGS.findIndex(([startTag]) => startTag == extractDataFromPageTags[0]);
|
|
672
|
+
if (tagIndex == -1) {
|
|
673
|
+
throw new Error("Unknown data tags: " + extractDataFromPageTags[0]);
|
|
674
|
+
}
|
|
675
|
+
return tagIndex;
|
|
676
|
+
}
|
|
677
|
+
|
|
654
678
|
function findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, indexExtractDataFromPageTags = 0) {
|
|
655
679
|
const regExpsTag = EXTRA_DATA_REGEXPS[indexExtractDataFromPageTags];
|
|
656
680
|
const plaintextTag = EXTRA_DATA_TAGS[indexExtractDataFromPageTags][0] == "<plaintext>";
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
|
|
84
84
|
let dispatchScrollEvent;
|
|
85
85
|
let adoptedStylesheetsData = new WeakMap();
|
|
86
|
+
const shadowRootsData = new WeakMap();
|
|
86
87
|
|
|
87
88
|
init();
|
|
88
89
|
new MutationObserver(init).observe(document, { childList: true });
|
|
@@ -534,14 +535,28 @@
|
|
|
534
535
|
};
|
|
535
536
|
CSSStyleSheet.prototype.deleteRule.toString = function () { return "function deleteRule() { [native code] }"; };
|
|
536
537
|
|
|
538
|
+
// the listener below is reached through the host element, and a closed shadow root is
|
|
539
|
+
// not reachable from it, so the roots are recorded as they are created
|
|
540
|
+
const originalAttachShadow = Element.prototype.attachShadow;
|
|
541
|
+
Element.prototype.attachShadow = function (init) {
|
|
542
|
+
try {
|
|
543
|
+
const shadowRoot = originalAttachShadow.apply(this, [init]);
|
|
544
|
+
shadowRootsData.set(this, shadowRoot);
|
|
545
|
+
return shadowRoot;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
error.stack = error.message + "\n" + " \n" + error.stack.trim().split("\n").slice(-1).join("\n");
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
Element.prototype.attachShadow.toString = function () { return "function attachShadow() { [native code] }"; };
|
|
552
|
+
setFunctionName(Element.prototype.attachShadow, "attachShadow");
|
|
553
|
+
|
|
537
554
|
function getAdoptedStylesheetsListener(event) {
|
|
538
|
-
const shadowRoot = event.target.shadowRoot;
|
|
555
|
+
const shadowRoot = event.target.shadowRoot || shadowRootsData.get(event.target);
|
|
539
556
|
event.stopPropagation();
|
|
540
557
|
if (shadowRoot) {
|
|
541
558
|
shadowRoot.addEventListener(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, getAdoptedStylesheetsListener, { capture: true });
|
|
542
|
-
shadowRoot.addEventListener(UNREGISTER_GET_ADOPTED_STYLESHEETS_REQUEST_EVENT,
|
|
543
|
-
shadowRoot.removeEventListener(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, getAdoptedStylesheetsListener);
|
|
544
|
-
}, { once: true });
|
|
559
|
+
shadowRoot.addEventListener(UNREGISTER_GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, unregisterGetAdoptedStylesheetsListener, { once: true });
|
|
545
560
|
const adoptedStyleSheets = Array.from(shadowRoot.adoptedStyleSheets).map(stylesheet => {
|
|
546
561
|
if (adoptedStylesheetsData.has(stylesheet)) {
|
|
547
562
|
return adoptedStylesheetsData.get(stylesheet);
|
|
@@ -555,6 +570,14 @@
|
|
|
555
570
|
}
|
|
556
571
|
}
|
|
557
572
|
|
|
573
|
+
// the root is read from the event rather than captured, so one function serves every shadow root:
|
|
574
|
+
// a closure per root would be a distinct callback each time and would accumulate instead of being
|
|
575
|
+
// the no-op that re-adding an already registered listener is. The capture flag has to be repeated
|
|
576
|
+
// here because it is part of what identifies the listener to remove
|
|
577
|
+
function unregisterGetAdoptedStylesheetsListener(event) {
|
|
578
|
+
event.currentTarget.removeEventListener(GET_ADOPTED_STYLESHEETS_REQUEST_EVENT, getAdoptedStylesheetsListener, { capture: true });
|
|
579
|
+
}
|
|
580
|
+
|
|
558
581
|
async function getDetailObject(fontFamily, src, descriptors) {
|
|
559
582
|
const detail = {};
|
|
560
583
|
detail["font-family"] = fontFamily;
|
|
@@ -18,7 +18,7 @@ deno run --allow-read test/sfz-harness/format-rules.js
|
|
|
18
18
|
|
|
19
19
|
## The suites
|
|
20
20
|
|
|
21
|
-
CI runs these
|
|
21
|
+
CI runs these. Each prints one `PASS`/`FAIL` line per check and exits non-zero if
|
|
22
22
|
any check failed.
|
|
23
23
|
|
|
24
24
|
| Script | What it covers |
|
|
@@ -26,7 +26,10 @@ any check failed.
|
|
|
26
26
|
| `format-rules.js` | The rules of the format: charset round trip, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
|
|
27
27
|
| `stored-trigger.js` | That a stored (uncompressed) entry whose bytes contain a rung's pattern moves the writer to the right rung. |
|
|
28
28
|
| `check-determinism.js` | That the same inputs produce the same bytes, and that the levers which should change the output do. |
|
|
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
|
|
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
|
+
| `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
|
+
| `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
|
+
| `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. |
|
|
30
33
|
|
|
31
34
|
## The tools
|
|
32
35
|
|
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
// A shadow root's constructed stylesheets cannot be read from the extension's own
|
|
25
|
+
// world, so the page-world hook reads them and answers a request event. It used to
|
|
26
|
+
// resolve the root with `event.target.shadowRoot`, which is null for a CLOSED root,
|
|
27
|
+
// and then answered nothing at all. The extension still captured the root's content,
|
|
28
|
+
// because it reaches closed roots through openOrClosedShadowRoot, so a closed root
|
|
29
|
+
// came back in the saved page with its markup intact and its styles gone, with no
|
|
30
|
+
// error anywhere. Measured on 2026-08-30 in Firefox and in Chrome, and again after
|
|
31
|
+
// the fix, with tmp/closed-shadow-root-probe.html.
|
|
32
|
+
//
|
|
33
|
+
// The hook now records every root as it is attached and falls back to that record.
|
|
34
|
+
// This test drives the hook's own listener over a stub of the one browser rule the
|
|
35
|
+
// bug turns on: attachShadow returns the root to its caller either way, and leaves
|
|
36
|
+
// host.shadowRoot null when the mode is closed.
|
|
37
|
+
|
|
38
|
+
const REQUEST_EVENT = "single-file-request-get-adopted-stylesheets";
|
|
39
|
+
const RESPONSE_EVENT = "single-file-response-get-adopted-stylesheets";
|
|
40
|
+
const UNREGISTER_EVENT = "single-file-unregister-request-get-adopted-stylesheets";
|
|
41
|
+
const HOOK_PATH = new URL("../../processors/hooks/content/content-hooks-frames-web.js", import.meta.url);
|
|
42
|
+
|
|
43
|
+
let failures = 0;
|
|
44
|
+
|
|
45
|
+
const documentListeners = new Map();
|
|
46
|
+
installStubs();
|
|
47
|
+
const originalAttachShadow = globalThis.Element.prototype.attachShadow;
|
|
48
|
+
new Function(await Deno.readTextFile(HOOK_PATH))();
|
|
49
|
+
const listener = documentListeners.get(REQUEST_EVENT);
|
|
50
|
+
|
|
51
|
+
check("the hook registers a listener for the request event", Boolean(listener));
|
|
52
|
+
check("the hook patches attachShadow", globalThis.Element.prototype.attachShadow !== originalAttachShadow);
|
|
53
|
+
check("the patched attachShadow still looks native",
|
|
54
|
+
globalThis.Element.prototype.attachShadow.toString() === "function attachShadow() { [native code] }" &&
|
|
55
|
+
globalThis.Element.prototype.attachShadow.name === "attachShadow");
|
|
56
|
+
|
|
57
|
+
// an open root: the host exposes it, which is the path that always worked
|
|
58
|
+
const openHost = new StubElement();
|
|
59
|
+
const openRoot = openHost.attachShadow({ mode: "open" });
|
|
60
|
+
adopt(openRoot, ".open { color: red }");
|
|
61
|
+
check("attachShadow returns the root for an open mode", openRoot instanceof StubShadowRoot);
|
|
62
|
+
check("an open root answers with its stylesheets",
|
|
63
|
+
sameStrings(request(openHost, openRoot), [".open { color: red }"]));
|
|
64
|
+
|
|
65
|
+
// a closed root: the host exposes nothing, so only the record can resolve it
|
|
66
|
+
const closedHost = new StubElement();
|
|
67
|
+
const closedRoot = closedHost.attachShadow({ mode: "closed" });
|
|
68
|
+
adopt(closedRoot, ".closed { color: blue }");
|
|
69
|
+
check("a closed root is hidden from its host", closedHost.shadowRoot === null);
|
|
70
|
+
check("attachShadow returns the root for a closed mode", closedRoot instanceof StubShadowRoot);
|
|
71
|
+
check("a closed root answers with its stylesheets",
|
|
72
|
+
sameStrings(request(closedHost, closedRoot), [".closed { color: blue }"]));
|
|
73
|
+
|
|
74
|
+
// the same, for a sheet the page never passed through replaceSync, so that the
|
|
75
|
+
// answer comes from cssRules rather than from the recorded text
|
|
76
|
+
const rulesHost = new StubElement();
|
|
77
|
+
const rulesRoot = rulesHost.attachShadow({ mode: "closed" });
|
|
78
|
+
rulesRoot.adoptedStyleSheets = [{ cssRules: [{ cssText: ".rules { color: green }" }] }];
|
|
79
|
+
check("a closed root answers from cssRules when the text was not recorded",
|
|
80
|
+
sameStrings(request(rulesHost, rulesRoot), [".rules { color: green }"]));
|
|
81
|
+
|
|
82
|
+
// an empty root still registers the hook's listener on itself, which is what lets a
|
|
83
|
+
// NESTED host inside it be reached later; only the answer is withheld
|
|
84
|
+
const emptyHost = new StubElement();
|
|
85
|
+
const emptyRoot = emptyHost.attachShadow({ mode: "closed" });
|
|
86
|
+
check("an empty root answers nothing", request(emptyHost, emptyRoot) === undefined);
|
|
87
|
+
check("an empty root is still listened to for nested hosts", emptyRoot.listenerCount(REQUEST_EVENT) > 0);
|
|
88
|
+
|
|
89
|
+
// a target that is neither a host nor a recorded root must not throw
|
|
90
|
+
check("an unrelated target is ignored", request(new StubElement(), new StubShadowRoot()) === undefined);
|
|
91
|
+
|
|
92
|
+
// the cleanup the hook installs on each root it answers for. Both halves of it were dead:
|
|
93
|
+
// the unregister handler was a fresh closure per handshake, so it was never the no-op that
|
|
94
|
+
// re-adding a registered listener is and one was left behind per root per save, and it
|
|
95
|
+
// removed the request listener without the capture flag it had been added with, which
|
|
96
|
+
// matches nothing at all
|
|
97
|
+
const repeatHost = new StubElement();
|
|
98
|
+
const repeatRoot = repeatHost.attachShadow({ mode: "open" });
|
|
99
|
+
adopt(repeatRoot, ".repeat { color: teal }");
|
|
100
|
+
request(repeatHost, repeatRoot);
|
|
101
|
+
const unregisterAfterFirst = repeatRoot.listenerCount(UNREGISTER_EVENT);
|
|
102
|
+
request(repeatHost, repeatRoot);
|
|
103
|
+
request(repeatHost, repeatRoot);
|
|
104
|
+
check("one handshake installs one unregister listener", unregisterAfterFirst === 1);
|
|
105
|
+
check("repeated handshakes do not accumulate unregister listeners",
|
|
106
|
+
repeatRoot.listenerCount(UNREGISTER_EVENT) === 1);
|
|
107
|
+
check("the root is listened to for requests until it is unregistered",
|
|
108
|
+
repeatRoot.listenerCount(REQUEST_EVENT) === 1);
|
|
109
|
+
repeatRoot.dispatchEvent(new globalThis.CustomEvent(UNREGISTER_EVENT, { bubbles: true }));
|
|
110
|
+
check("unregistering removes the request listener the hook added with capture",
|
|
111
|
+
repeatRoot.listenerCount(REQUEST_EVENT) === 0);
|
|
112
|
+
check("the unregister listener removes itself once it has fired",
|
|
113
|
+
repeatRoot.listenerCount(UNREGISTER_EVENT) === 0);
|
|
114
|
+
|
|
115
|
+
console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
|
|
116
|
+
Deno.exit(failures ? 1 : 0);
|
|
117
|
+
|
|
118
|
+
function request(target, listeningRoot) {
|
|
119
|
+
let answer;
|
|
120
|
+
const responseListener = event => answer = event.detail.adoptedStyleSheets;
|
|
121
|
+
listeningRoot.addEventListener(RESPONSE_EVENT, responseListener);
|
|
122
|
+
listener({ target, stopPropagation() { } });
|
|
123
|
+
listeningRoot.removeEventListener(RESPONSE_EVENT, responseListener);
|
|
124
|
+
return answer;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function adopt(root, text) {
|
|
128
|
+
const sheet = new globalThis.CSSStyleSheet();
|
|
129
|
+
sheet.replaceSync(text);
|
|
130
|
+
root.adoptedStyleSheets = [sheet];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sameStrings(value, expected) {
|
|
134
|
+
return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function check(label, condition) {
|
|
138
|
+
if (!condition) {
|
|
139
|
+
failures++;
|
|
140
|
+
}
|
|
141
|
+
console.log((condition ? "PASS" : "FAIL") + " " + label);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// the stub below models only what the hook touches while it installs itself and
|
|
145
|
+
// while it answers one request; it is deliberately not a DOM
|
|
146
|
+
function StubShadowRoot() {
|
|
147
|
+
this.adoptedStyleSheets = [];
|
|
148
|
+
this.listeners = new Map();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function StubElement() {
|
|
152
|
+
this.shadowRoot = null;
|
|
153
|
+
this.listeners = new Map();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function installStubs() {
|
|
157
|
+
// a listener is identified by type, callback AND capture: adding one that is already
|
|
158
|
+
// registered is a no-op, and removing one with a different capture flag matches nothing.
|
|
159
|
+
// The stub has to model both, because those are the two rules the cleanup code got wrong
|
|
160
|
+
const listenerMethods = {
|
|
161
|
+
addEventListener(type, listener, options) {
|
|
162
|
+
const capture = Boolean(options && options.capture);
|
|
163
|
+
if (!this.listeners.has(type)) {
|
|
164
|
+
this.listeners.set(type, []);
|
|
165
|
+
}
|
|
166
|
+
const listeners = this.listeners.get(type);
|
|
167
|
+
if (!listeners.some(entry => entry.listener === listener && entry.capture === capture)) {
|
|
168
|
+
listeners.push({ listener, capture, once: Boolean(options && options.once) });
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
removeEventListener(type, listener, options) {
|
|
172
|
+
const capture = Boolean(options && options.capture);
|
|
173
|
+
const listeners = this.listeners.get(type) || [];
|
|
174
|
+
const index = listeners.findIndex(entry => entry.listener === listener && entry.capture === capture);
|
|
175
|
+
if (index !== -1) {
|
|
176
|
+
listeners.splice(index, 1);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
dispatchEvent(event) {
|
|
180
|
+
event.currentTarget = this;
|
|
181
|
+
(this.listeners.get(event.type) || []).slice().forEach(entry => {
|
|
182
|
+
if (entry.once) {
|
|
183
|
+
this.removeEventListener(event.type, entry.listener, { capture: entry.capture });
|
|
184
|
+
}
|
|
185
|
+
entry.listener(event);
|
|
186
|
+
});
|
|
187
|
+
event.currentTarget = null;
|
|
188
|
+
return true;
|
|
189
|
+
},
|
|
190
|
+
listenerCount(type) {
|
|
191
|
+
return (this.listeners.get(type) || []).length;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
Object.assign(StubShadowRoot.prototype, listenerMethods);
|
|
195
|
+
Object.assign(StubElement.prototype, listenerMethods);
|
|
196
|
+
|
|
197
|
+
// the rule the bug turns on: the caller gets the root whatever the mode, while the
|
|
198
|
+
// host exposes it only when the mode is open
|
|
199
|
+
StubElement.prototype.attachShadow = function (init) {
|
|
200
|
+
const shadowRoot = new StubShadowRoot();
|
|
201
|
+
shadowRoot.host = this;
|
|
202
|
+
this.shadowRoot = init && init.mode === "closed" ? null : shadowRoot;
|
|
203
|
+
return shadowRoot;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const recordedText = new WeakMap();
|
|
207
|
+
class StubCSSStyleSheet {
|
|
208
|
+
replaceSync(text) {
|
|
209
|
+
recordedText.set(this, text);
|
|
210
|
+
}
|
|
211
|
+
get cssRules() {
|
|
212
|
+
return [{ cssText: recordedText.get(this) }];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
globalThis.Element = StubElement;
|
|
217
|
+
globalThis.CSSStyleSheet = StubCSSStyleSheet;
|
|
218
|
+
globalThis.CustomEvent = class CustomEvent {
|
|
219
|
+
constructor(type, init = {}) {
|
|
220
|
+
this.type = type;
|
|
221
|
+
this.detail = init.detail;
|
|
222
|
+
this.bubbles = Boolean(init.bubbles);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
globalThis.Event = class Event { constructor(type) { this.type = type; } };
|
|
226
|
+
globalThis.UIEvent = globalThis.Event;
|
|
227
|
+
globalThis.screen = { width: 0, height: 0 };
|
|
228
|
+
globalThis.MutationObserver = class MutationObserver { observe() { } };
|
|
229
|
+
globalThis.fetch = () => Promise.reject(new Error("the hook test makes no request"));
|
|
230
|
+
globalThis.document = {
|
|
231
|
+
addEventListener(type, listener) {
|
|
232
|
+
documentListeners.set(type, listener);
|
|
233
|
+
},
|
|
234
|
+
removeEventListener() { },
|
|
235
|
+
dispatchEvent() { return true; },
|
|
236
|
+
querySelectorAll: () => [],
|
|
237
|
+
documentElement: {},
|
|
238
|
+
fonts: { add() { }, delete() { }, clear() { }, forEach() { } }
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
// What happens when even the union cannot answer changed with it. Switching pruning off for the
|
|
15
|
+
// whole document was never a policy about uncertainty — a page naming its families plainly has
|
|
16
|
+
// always dropped a face it had not drawn yet, and only a page holding one unreadable value anywhere
|
|
17
|
+
// was spared, all of it. So the rendered list decides in that case too: a family the browser
|
|
18
|
+
// resolved when it drew the page is in that list whatever the stylesheets can be made to say. The
|
|
19
|
+
// one case that still keeps everything is the one where the rendering itself is missing.
|
|
20
|
+
//
|
|
21
|
+
// The other var() defects this pins, all found alongside it:
|
|
22
|
+
// - custom property names are case-sensitive; they were lowercased with the family names, so
|
|
23
|
+
// var(--ProbeFont) never resolved, not even from :root
|
|
24
|
+
// - the "font" shorthand's var() regexp required var(--name) exactly, so a fallback in it made
|
|
25
|
+
// the shorthand unreadable and switched pruning off
|
|
26
|
+
// - a var() fallback is one raw token, so a list in it was pushed as a single mangled name and
|
|
27
|
+
// the families it holds were dropped
|
|
28
|
+
import * as cssTree from "../../vendor/css-tree.js";
|
|
29
|
+
|
|
30
|
+
// the module reaches core/helper.js, which pulls in the frame hooks, and those install themselves
|
|
31
|
+
// against window and document as they are evaluated: the stubs go in before the dynamic import
|
|
32
|
+
globalThis.window = globalThis;
|
|
33
|
+
globalThis.document = {};
|
|
34
|
+
globalThis.Document = class Document { };
|
|
35
|
+
globalThis.MutationObserver = class MutationObserver { observe() { } };
|
|
36
|
+
const { process: removeUnusedFonts } = await import("../../modules/css-fonts-minifier.js");
|
|
37
|
+
|
|
38
|
+
const FONT_FACES = `
|
|
39
|
+
@font-face{font-family:"UsedOne";src:url(one.woff2)}
|
|
40
|
+
@font-face{font-family:"UsedTwo";src:url(two.woff2)}
|
|
41
|
+
@font-face{font-family:"UsedThree";src:url(three.woff2)}
|
|
42
|
+
@font-face{font-family:"Unused";src:url(unused.woff2)}`;
|
|
43
|
+
|
|
44
|
+
// what the rendered page reports as actually drawn: [family, weight, style, variant]. A family
|
|
45
|
+
// missing from this list is dropped whatever the stylesheets say, so it holds every family the
|
|
46
|
+
// tests below may legitimately keep
|
|
47
|
+
const USED_FONTS = [
|
|
48
|
+
["usedone", "400", "normal", "normal"],
|
|
49
|
+
["usedtwo", "400", "normal", "normal"],
|
|
50
|
+
["usedthree", "400", "normal", "normal"]
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const ALL_FAMILIES = ["usedone", "usedtwo", "usedthree", "unused"];
|
|
54
|
+
|
|
55
|
+
// what survives when the stylesheets cannot say which family a value names: the faces the page
|
|
56
|
+
// declares AND the rendering reports having drawn with. It is USED_FONTS that decides there, so a
|
|
57
|
+
// face nothing drew is dropped even though nothing could be shown to name it either
|
|
58
|
+
const RENDERED_FAMILIES = ["usedone", "usedtwo", "usedthree"];
|
|
59
|
+
|
|
60
|
+
let failures = 0;
|
|
61
|
+
|
|
62
|
+
check("a plain family name prunes the rest",
|
|
63
|
+
run({ rules: ".card p{font-family:\"UsedOne\",serif}" }),
|
|
64
|
+
["usedone"]);
|
|
65
|
+
|
|
66
|
+
check("a property declared on :root resolves through the computed style",
|
|
67
|
+
run({ rules: ":root{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font),serif}", computed: { "--probe-font": "\"UsedOne\"" } }),
|
|
68
|
+
["usedone"]);
|
|
69
|
+
|
|
70
|
+
check("a property declared on a descendant resolves through the declared values",
|
|
71
|
+
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font),serif}" }),
|
|
72
|
+
["usedone"]);
|
|
73
|
+
|
|
74
|
+
check("a mixed-case property name is not lowercased away",
|
|
75
|
+
run({ rules: ".card{--ProbeFont:\"UsedOne\"}.card p{font-family:var(--ProbeFont),serif}" }),
|
|
76
|
+
["usedone"]);
|
|
77
|
+
|
|
78
|
+
check("a property declared inside a media query resolves too",
|
|
79
|
+
run({ rules: "@media screen{.card{--probe-font:\"UsedOne\"}}.card p{font-family:var(--probe-font),serif}" }),
|
|
80
|
+
["usedone"]);
|
|
81
|
+
|
|
82
|
+
check("several declared values are all kept as candidates",
|
|
83
|
+
run({ rules: ".card{--probe-font:\"UsedOne\"}.note{--probe-font:\"UsedTwo\"}p{font-family:var(--probe-font),serif}" }),
|
|
84
|
+
["usedone", "usedtwo"]);
|
|
85
|
+
|
|
86
|
+
check("a fallback list is parsed as a list, not as one name",
|
|
87
|
+
run({ rules: ":root{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font,\"UsedTwo\",\"UsedThree\"),serif}", computed: { "--probe-font": "\"UsedOne\"" } }),
|
|
88
|
+
["usedone", "usedtwo", "usedthree"]);
|
|
89
|
+
|
|
90
|
+
check("the font shorthand resolves a uniquely declared property",
|
|
91
|
+
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font)}" }),
|
|
92
|
+
["usedone"]);
|
|
93
|
+
|
|
94
|
+
check("the font shorthand resolves a property written with a fallback",
|
|
95
|
+
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font,serif)}" }),
|
|
96
|
+
["usedone"]);
|
|
97
|
+
|
|
98
|
+
// The other half of the contract: what happens when the value genuinely cannot be determined. The
|
|
99
|
+
// stylesheets no longer decide there — the rendering does. The browser resolved the value when it
|
|
100
|
+
// drew the page, so whatever the property named is in the list of fonts reported as used, and a
|
|
101
|
+
// face absent from that list was drawn by nothing.
|
|
102
|
+
//
|
|
103
|
+
// It used to keep EVERY declared face, and not as a decision about uncertainty: a page naming its
|
|
104
|
+
// families plainly has always dropped a face it had not drawn yet, and only a page holding one
|
|
105
|
+
// value that happened not to parse was spared — the whole document, over one unreadable name. The
|
|
106
|
+
// case these checks still guard is the one below them, where the rendering itself is unavailable.
|
|
107
|
+
check("a property declared nowhere falls back to the fonts that were drawn",
|
|
108
|
+
run({ rules: ".card p{font-family:var(--set-by-script),serif}" }),
|
|
109
|
+
RENDERED_FAMILIES);
|
|
110
|
+
|
|
111
|
+
// the shorthand cannot be substituted with several candidate values, but its var() sits in family
|
|
112
|
+
// position, so the parser hands it back as the family and the union answers it there instead
|
|
113
|
+
check("a font shorthand with several candidates resolves through the family",
|
|
114
|
+
run({ rules: ".card{--probe-font:\"UsedOne\"}.note{--probe-font:\"UsedTwo\"}p{font:italic 1em var(--probe-font)}" }),
|
|
115
|
+
["usedone", "usedtwo"]);
|
|
116
|
+
|
|
117
|
+
// a property holding the whole shorthand is the case the union must NOT touch: its values are not
|
|
118
|
+
// family lists, and reading them as such would name families the document never had
|
|
119
|
+
check("a property holding a whole shorthand falls back to the fonts that were drawn",
|
|
120
|
+
run({ rules: ".card{--font:italic 1em \"UsedOne\"}.note{--font:italic 1em \"UsedTwo\"}p{font:var(--font)}" }),
|
|
121
|
+
RENDERED_FAMILIES);
|
|
122
|
+
|
|
123
|
+
check("a var() nested in a fallback falls back to the fonts that were drawn",
|
|
124
|
+
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--other,var(--probe-font),serif)}" }),
|
|
125
|
+
RENDERED_FAMILIES);
|
|
126
|
+
|
|
127
|
+
// the check above leaves the family undetermined because the OUTER property is declared nowhere.
|
|
128
|
+
// A var() written in the font-family itself is split by the AST walk, which hands each branch over
|
|
129
|
+
// separately, so nesting alone was never the problem there. The chain that did give up is a var()
|
|
130
|
+
// inside a property VALUE: it was read one level deep and whatever it named stayed unresolved,
|
|
131
|
+
// which switched pruning off for the whole document
|
|
132
|
+
check("a property whose value is another declared property resolves through it",
|
|
133
|
+
run({ rules: ".card{--first-font:var(--second-font)}.note{--second-font:\"UsedOne\"}.card p{font-family:var(--first-font),serif}" }),
|
|
134
|
+
["usedone"]);
|
|
135
|
+
|
|
136
|
+
// splitting that value on every comma cut this one into "var(--second-font" and "\"UsedTwo\")",
|
|
137
|
+
// two names that resolve to nothing, and the document went undetermined over a value it holds in full
|
|
138
|
+
check("a value holding a var() with its own fallback is split on the top-level comma",
|
|
139
|
+
run({ rules: ".card{--first-font:var(--second-font,\"UsedTwo\"),serif}.note{--second-font:\"UsedOne\"}.card p{font-family:var(--first-font)}" }),
|
|
140
|
+
["usedone", "usedtwo"]);
|
|
141
|
+
|
|
142
|
+
// two properties naming each other resolve for ever without the guard: this check hangs rather
|
|
143
|
+
// than fails when it regresses
|
|
144
|
+
check("a property naming itself through another one falls back to the fonts that were drawn",
|
|
145
|
+
run({ rules: ".card{--first-font:var(--second-font)}.note{--second-font:var(--first-font)}.card p{font-family:var(--first-font),serif}" }),
|
|
146
|
+
RENDERED_FAMILIES);
|
|
147
|
+
|
|
148
|
+
// The rendered-fonts list is what says a declared face is really drawn, and an EMPTY one is not
|
|
149
|
+
// the same answer as a short one: every rendered element has a computed font-family, so an empty
|
|
150
|
+
// list means the computed styles could not be read at all. It happens for real. A frame whose
|
|
151
|
+
// contentDocument is unreachable is re-parsed from its srcdoc with DOMParser
|
|
152
|
+
// (processors/frame-tree/content/content-frame-tree.js), and that document is never rendered, so
|
|
153
|
+
// it reports no font and the frame lost EVERY face it declared — measured on derstandard.at,
|
|
154
|
+
// where the newsletter box inside such a frame fell back to a system font, and on MDN, where the
|
|
155
|
+
// text in the CSS-demo frame reflowed. The families are named right there in the frame's own CSS.
|
|
156
|
+
check("a document that reports no rendered font keeps every font",
|
|
157
|
+
run({ rules: ".card p{font-family:\"UsedOne\",serif}", usedFonts: [] }),
|
|
158
|
+
ALL_FAMILIES);
|
|
159
|
+
|
|
160
|
+
check("a property whose value is another undetermined property falls back to the fonts that were drawn",
|
|
161
|
+
run({ rules: ".card{--probe-font:var(--set-by-script)}.card p{font-family:var(--probe-font),serif}" }),
|
|
162
|
+
RENDERED_FAMILIES);
|
|
163
|
+
|
|
164
|
+
// An unquoted family name is a sequence of identifier tokens, and the walk that joins them has to
|
|
165
|
+
// resume after the LAST of them. Resuming after the first pushed every word but that one again as a
|
|
166
|
+
// family in its own right, so "Helvetica Neue Light" also claimed fonts named "Neue Light" and
|
|
167
|
+
// "Light". It only ever kept too much, which is why it went unnoticed; a page declaring a family
|
|
168
|
+
// whose name is the tail of another one paid for it.
|
|
169
|
+
const TAIL_FACES = `
|
|
170
|
+
@font-face{font-family:"Helvetica Neue Light";src:url(one.woff2)}
|
|
171
|
+
@font-face{font-family:"Neue Light";src:url(two.woff2)}
|
|
172
|
+
@font-face{font-family:"Light";src:url(three.woff2)}`;
|
|
173
|
+
|
|
174
|
+
const TAIL_USED_FONTS = [
|
|
175
|
+
["helvetica neue light", "400", "normal", "normal"],
|
|
176
|
+
["neue light", "400", "normal", "normal"],
|
|
177
|
+
["light", "400", "normal", "normal"]
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
check("a multi-word family name is read whole",
|
|
181
|
+
run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:Helvetica Neue Light,serif}" }),
|
|
182
|
+
["helvetica neue light"]);
|
|
183
|
+
|
|
184
|
+
check("a quoted name is still matched by its quoted declaration",
|
|
185
|
+
run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:\"Neue Light\",serif}" }),
|
|
186
|
+
["neue light"]);
|
|
187
|
+
|
|
188
|
+
// the token that ends a name is not always the comma: a string or a number ends it too, and the
|
|
189
|
+
// walk used to append the token's absent `name` to the family as the text "undefined"
|
|
190
|
+
check("a name is ended by a token that is not an identifier",
|
|
191
|
+
run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:Light \"Neue Light\"}" }),
|
|
192
|
+
["neue light", "light"]);
|
|
193
|
+
|
|
194
|
+
console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
|
|
195
|
+
Deno.exit(failures ? 1 : 0);
|
|
196
|
+
|
|
197
|
+
function run({ rules, computed, faces = FONT_FACES, usedFonts = USED_FONTS }) {
|
|
198
|
+
const stylesheet = cssTree.parse(faces + rules);
|
|
199
|
+
const options = { usedFonts };
|
|
200
|
+
const originalGetComputedStyle = globalThis.getComputedStyle;
|
|
201
|
+
if (computed) {
|
|
202
|
+
options.doc = { body: {} };
|
|
203
|
+
globalThis.getComputedStyle = () => ({ getPropertyValue: name => computed[name] || "" });
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
removeUnusedFonts(createStubDocument(), [{ stylesheet }], [], options);
|
|
207
|
+
} finally {
|
|
208
|
+
globalThis.getComputedStyle = originalGetComputedStyle;
|
|
209
|
+
}
|
|
210
|
+
const families = [];
|
|
211
|
+
stylesheet.children.forEach(ruleData => {
|
|
212
|
+
if (ruleData.type == "Atrule" && ruleData.name == "font-face") {
|
|
213
|
+
const declaration = ruleData.block.children.filter(node => node.property == "font-family").tail;
|
|
214
|
+
families.push(cssTree.generate(declaration.data.value).replace(/^"|"$/g, "").toLowerCase());
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return families;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function check(label, actual, expected) {
|
|
221
|
+
const ok = actual.length == expected.length && actual.every((family, index) => family == expected[index]);
|
|
222
|
+
if (!ok) {
|
|
223
|
+
failures++;
|
|
224
|
+
}
|
|
225
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual.join(", ") || "(none)"}${ok ? "" : " (expected " + expected.join(", ") + ")"}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// the module only reaches the document to borrow a <style> element for unescaping content values
|
|
229
|
+
// and to read the body text, so a full DOM is not needed here
|
|
230
|
+
function createStubDocument() {
|
|
231
|
+
return {
|
|
232
|
+
createElement: () => ({ textContent: "", remove() { } }),
|
|
233
|
+
body: { appendChild() { }, innerText: "" }
|
|
234
|
+
};
|
|
235
|
+
}
|