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.
@@ -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 (browser && browser.runtime && browser.runtime.getURL) {
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
- const tagIndex = EXTRA_DATA_TAGS.indexOf(options.extractDataFromPageTags);
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 ? "" : getPageTitle(pageData);
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
- function getPageTitle(pageData) {
645
- // numeric character references are ASCII bytes, so the title survives the single-byte
646
- // charset universal mode declares, and any parser decodes them back to the original text
647
- return Array.from(pageData.title || "").map(character => {
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 three. Each prints one `PASS`/`FAIL` line per check and exits non-zero if
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 three suites sit below. |
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
+ }