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.
@@ -41,7 +41,9 @@ const REGEXP_COMMA = /\s*,\s*/;
41
41
  const REGEXP_DASH = /-/;
42
42
  const REGEXP_QUESTION_MARK = /\?/g;
43
43
  const REGEXP_STARTS_U_PLUS = /^U\+/i;
44
- const REGEXP_CUSTOM_PROPERTY = /var\((--[^),]*)\)/g;
44
+ const REGEXP_CUSTOM_PROPERTY = /var\(\s*(--[^\s,)]+)\s*(?:,[^)]*)?\)/g;
45
+ const REGEXP_CUSTOM_PROPERTY_FAMILY = /^var\(\s*(--[^\s,)]+)\s*(?:,(.*))?\)$/;
46
+ const REGEXP_CUSTOM_PROPERTY_NAME = /^--/;
45
47
  const VALID_FONT_STYLES = [/^normal$/, /^italic$/, /^oblique$/, /^oblique\s+/];
46
48
  // a family name kept when the "font" shorthand cannot be read: it resolves to nothing, so it
47
49
  // survives the substitution below and marks the fonts as undetermined instead of unused
@@ -54,9 +56,19 @@ export {
54
56
  function process(doc, stylesheets, styles, options) {
55
57
  const stats = { rules: { processed: 0, discarded: 0 }, fonts: { processed: 0, discarded: 0 } };
56
58
  const fontsInfo = { declared: [], used: [] };
59
+ const customProperties = new Map();
57
60
  const workStyleElement = doc.createElement("style");
58
61
  let docContent = "";
59
62
  doc.body.appendChild(workStyleElement);
63
+ // the custom properties are collected first: a family or a "font" shorthand can be resolved
64
+ // with a property that a later stylesheet, or a style attribute, declares
65
+ stylesheets.forEach(stylesheetInfo => {
66
+ if (stylesheetInfo.stylesheet && stylesheetInfo.stylesheet.children) {
67
+ getCustomPropertiesInfo(stylesheetInfo.stylesheet.children, customProperties);
68
+ }
69
+ });
70
+ styles.forEach(declarations => getCustomProperties(declarations.children, customProperties));
71
+ options = Object.assign({}, options, { customProperties });
60
72
  stylesheets.forEach(stylesheetInfo => {
61
73
  if (stylesheetInfo.stylesheet) {
62
74
  const cssRules = stylesheetInfo.stylesheet.children;
@@ -77,17 +89,8 @@ function process(doc, stylesheets, styles, options) {
77
89
  });
78
90
  workStyleElement.remove();
79
91
  docContent += doc.body.innerText;
80
- if (globalThis.getComputedStyle && options.doc) {
81
- fontsInfo.used = fontsInfo.used.map(fontNames => fontNames.map(familyName => {
82
- const matchedVar = familyName.match(/^var\((--.*)\)$/);
83
- if (matchedVar && matchedVar[1]) {
84
- const computedFamilyName = globalThis.getComputedStyle(options.doc.body).getPropertyValue(matchedVar[1]);
85
- return (computedFamilyName && computedFamilyName.split(",").map(name => helper.normalizeFontFamily(name))) || familyName;
86
- }
87
- return familyName;
88
- }));
89
- fontsInfo.used = fontsInfo.used.map(fontNames => helper.flatten(fontNames));
90
- }
92
+ fontsInfo.used = fontsInfo.used.map(fontNames => fontNames.map(familyName => resolveFamilyName(familyName, options)));
93
+ fontsInfo.used = fontsInfo.used.map(fontNames => helper.flatten(fontNames));
91
94
  const variableFound = fontsInfo.used.find(fontNames => fontNames.find(fontName => fontName.match(/^var\(--/)));
92
95
  let unusedFonts, filteredUsedFonts;
93
96
  if (variableFound) {
@@ -141,6 +144,83 @@ function getFontsInfo(cssRules, fontsInfo, options) {
141
144
  });
142
145
  }
143
146
 
147
+ function getCustomPropertiesInfo(cssRules, customProperties) {
148
+ cssRules.forEach(ruleData => {
149
+ if (ruleData.type == "Atrule" && ruleData.name == "import" && ruleData.prelude && ruleData.prelude.children && ruleData.prelude.children.head.data.importedChildren) {
150
+ getCustomPropertiesInfo(ruleData.prelude.children.head.data.importedChildren, customProperties);
151
+ } else if (ruleData.type == "Atrule" && (ruleData.name == "media" || ruleData.name == "supports" || ruleData.name == "layer" || ruleData.name == "container") && ruleData.block && ruleData.block.children) {
152
+ getCustomPropertiesInfo(ruleData.block.children, customProperties);
153
+ } else if (ruleData.type == "Rule" && ruleData.block && ruleData.block.children) {
154
+ getCustomProperties(ruleData.block.children, customProperties);
155
+ }
156
+ });
157
+ }
158
+
159
+ function getCustomProperties(declarations, customProperties) {
160
+ if (declarations) {
161
+ declarations.forEach(declaration => {
162
+ if (declaration.property && declaration.property.match(REGEXP_CUSTOM_PROPERTY_NAME)) {
163
+ try {
164
+ const value = cssTree.generate(declaration.value).trim();
165
+ if (value) {
166
+ let values = customProperties.get(declaration.property);
167
+ if (!values) {
168
+ values = new Set();
169
+ customProperties.set(declaration.property, values);
170
+ }
171
+ values.add(value);
172
+ }
173
+ // eslint-disable-next-line no-unused-vars
174
+ } catch (error) {
175
+ // ignored
176
+ }
177
+ }
178
+ });
179
+ }
180
+ }
181
+
182
+ function getCustomPropertyValues(name, options) {
183
+ let values;
184
+ if (globalThis.getComputedStyle && options.doc) {
185
+ const computedValue = globalThis.getComputedStyle(options.doc.body).getPropertyValue(name);
186
+ if (computedValue && computedValue.trim()) {
187
+ values = [computedValue];
188
+ }
189
+ }
190
+ if (!values) {
191
+ // the property is not inherited by the body: it is declared on a descendant, or in a
192
+ // media query that does not apply, so the value seen by the element using it cannot be
193
+ // determined here. Every value declared for it in the document is taken as a candidate,
194
+ // which still discards the fonts named by none of them
195
+ const declaredValues = options.customProperties && options.customProperties.get(name);
196
+ if (declaredValues && declaredValues.size) {
197
+ values = Array.from(declaredValues);
198
+ }
199
+ }
200
+ return values;
201
+ }
202
+
203
+ function resolveFamilyName(familyName, options) {
204
+ const matchedVar = familyName.match(REGEXP_CUSTOM_PROPERTY_FAMILY);
205
+ if (matchedVar) {
206
+ const fallback = matchedVar[2];
207
+ // a var() nested in the fallback cannot be split on its commas, so the family is left
208
+ // undetermined rather than read as a list of broken names
209
+ if (!fallback || !fallback.includes("var(")) {
210
+ const values = getCustomPropertyValues(matchedVar[1], options);
211
+ if (values) {
212
+ const families = helper.flatten(values.map(value => splitFamilyNames(value)));
213
+ return fallback ? families.concat(splitFamilyNames(fallback)) : families;
214
+ }
215
+ }
216
+ }
217
+ return familyName;
218
+ }
219
+
220
+ function splitFamilyNames(value) {
221
+ return value.split(",").map(familyName => normalizeFamilyName(familyName)).filter(familyName => familyName);
222
+ }
223
+
144
224
  function filterUnusedFonts(cssRules, declaredFonts, unusedFonts, filteredUsedFonts, docChars) {
145
225
  const removedRules = [];
146
226
  for (let cssRule = cssRules.head; cssRule; cssRule = cssRule.next) {
@@ -266,7 +346,7 @@ function getFontFamilyNames(declarations, options) {
266
346
  } else {
267
347
  fontFamilyName = cssTree.generate(fontFamilyName.data.value);
268
348
  if (fontFamilyName) {
269
- fontFamilyNames.push(helper.normalizeFontFamily(fontFamilyName));
349
+ fontFamilyNames.push(normalizeFamilyName(fontFamilyName));
270
350
  }
271
351
  }
272
352
  }
@@ -280,7 +360,7 @@ function getFontFamilyNames(declarations, options) {
280
360
  value = cssTree.parse(resolvedFontValue, { context: "value" });
281
361
  }
282
362
  const parsedFont = fontPropertyParser.parse(value);
283
- parsedFont.family.forEach(familyName => fontFamilyNames.push(helper.normalizeFontFamily(familyName)));
363
+ parsedFont.family.forEach(familyName => fontFamilyNames.push(normalizeFamilyName(familyName)));
284
364
  // eslint-disable-next-line no-unused-vars
285
365
  } catch (error) {
286
366
  // the shorthand is unreadable, and dropping it here would count the fonts it names as
@@ -294,12 +374,17 @@ function getFontFamilyNames(declarations, options) {
294
374
  }
295
375
 
296
376
  function resolveCustomProperties(value, options) {
297
- if (globalThis.getComputedStyle && options.doc) {
298
- const computedStyle = globalThis.getComputedStyle(options.doc.body);
299
- return value.replace(REGEXP_CUSTOM_PROPERTY, (property, name) => computedStyle.getPropertyValue(name) || property);
300
- } else {
301
- return value;
302
- }
377
+ return value.replace(REGEXP_CUSTOM_PROPERTY, (property, name) => {
378
+ const values = getCustomPropertyValues(name, options);
379
+ // the shorthand is parsed as a whole, so it can only be substituted with a single value:
380
+ // with several candidates the families it names stay undetermined
381
+ return values && values.length == 1 ? values[0] : property;
382
+ });
383
+ }
384
+
385
+ function normalizeFamilyName(familyName = "") {
386
+ // custom property names are case-sensitive, unlike family names
387
+ return familyName.match(REGEXP_CUSTOM_PROPERTY_FAMILY) ? familyName.trim() : helper.normalizeFontFamily(familyName);
303
388
  }
304
389
 
305
390
  function parseFamilyNames(fontFamilyNameTokenData, fontFamilyNames) {
@@ -307,16 +392,19 @@ function parseFamilyNames(fontFamilyNameTokenData, fontFamilyNames) {
307
392
  while (nextToken) {
308
393
  if (nextToken.data.type == "Identifier") {
309
394
  let familyName = nextToken.data.name;
395
+ // an unquoted family name is a sequence of identifiers, and the walk has to resume
396
+ // after the last of them: resuming after the first pushes every word but that one
397
+ // again as a family of its own, so "Foo Bar" also claims a font-face named "Bar"
310
398
  let nextIdentifierToken = nextToken.next;
311
- while (nextIdentifierToken && nextIdentifierToken.data.type != "Operator" && nextIdentifierToken.data.value != ",") {
399
+ while (nextIdentifierToken && nextIdentifierToken.data.type == "Identifier") {
312
400
  familyName += " " + nextIdentifierToken.data.name;
313
401
  nextIdentifierToken = nextIdentifierToken.next;
314
402
  }
315
403
  fontFamilyNames.push(helper.normalizeFontFamily(familyName));
316
- nextToken = nextToken.next;
404
+ nextToken = nextIdentifierToken;
317
405
  } else if (nextToken.data.type == "Function" && nextToken.data.name == "var" && nextToken.data.children) {
318
406
  const varName = nextToken.data.children.head.data.name;
319
- fontFamilyNames.push(helper.normalizeFontFamily("var(" + varName + ")"));
407
+ fontFamilyNames.push("var(" + varName + ")");
320
408
  let nextValueToken = nextToken.data.children.head.next;
321
409
  while (nextValueToken && nextValueToken.data.type == "Operator" && nextValueToken.data.value == ",") {
322
410
  nextValueToken = nextValueToken.next;
@@ -326,7 +414,9 @@ function parseFamilyNames(fontFamilyNameTokenData, fontFamilyNames) {
326
414
  if (fallbackToken.data.children) {
327
415
  parseFamilyNames(fallbackToken.data, fontFamilyNames);
328
416
  } else {
329
- fontFamilyNames.push(helper.normalizeFontFamily(fallbackToken.data.value));
417
+ // the fallback of a var() is parsed as a single raw token, so it still has to
418
+ // be split into the list of families it may hold
419
+ splitFamilyNames(String(fallbackToken.data.value)).forEach(familyName => fontFamilyNames.push(familyName));
330
420
  }
331
421
  }
332
422
  nextToken = nextToken.next;
@@ -51,6 +51,7 @@ const SELECTOR_LIST_CONTEXT = "selectorList";
51
51
  const STYLESHEET_CONTEXT = "stylesheet";
52
52
  const SELECTOR_CONTEXT = "selector";
53
53
  const DECLARATION_LIST_CONTEXT = "declarationList";
54
+ const UNKNOWN_PROPERTY_ERROR_NAME = "SyntaxReferenceError";
54
55
  const PARSE_CSS_ERROR_MESSAGE = "Failed to parse CSS";
55
56
  const QSA_ERROR_MESSAGE = "Failed to match selector";
56
57
  const PRELUDE_SEPARATOR = ",";
@@ -69,9 +70,15 @@ const INVALID_CSS_ESCAPE_TEST = /\\(?![0-9a-fA-F]{1,6}\s|[^0-9a-zA-Z])/;
69
70
  const ANONYMOUS_LAYER_PLACEHOLDER = "\u0000";
70
71
 
71
72
  export {
72
- process
73
+ process,
74
+ isUnsupportedPropertyValue
73
75
  };
74
76
 
77
+ function isUnsupportedPropertyValue(property, value) {
78
+ const match = cssTree.lexer.matchProperty(property, value);
79
+ return Boolean(!match.matched && match.error && match.error.name !== UNKNOWN_PROPERTY_ERROR_NAME);
80
+ }
81
+
75
82
  function process(doc, stylesheets) {
76
83
  const docContext = {
77
84
  doc,
@@ -493,7 +500,7 @@ function collectDeclarationItemsForElement(element, docContext) {
493
500
  isInvalidValue = value.children.head.data.name.startsWith(VENDOR_PREFIX) || INVALID_CSS_ESCAPE_TEST.test(value.children.head.data.name);
494
501
  } if (!property.startsWith(VENDOR_PREFIX) && value.children.head.data.value) {
495
502
  try {
496
- isInvalidValue = !cssTree.lexer.matchProperty(property, value).matched;
503
+ isInvalidValue = isUnsupportedPropertyValue(property, value);
497
504
  } catch {
498
505
  // ignored
499
506
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.115",
3
+ "version": "1.5.116",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "scripts": {
8
- "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js",
8
+ "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js",
9
9
  "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
10
  "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
11
  "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
@@ -23,13 +23,34 @@
23
23
 
24
24
  /* global DOMParser, setTimeout */
25
25
 
26
- import { getDoctypeString } from "./../../core/lib/doctype.js";
27
-
28
26
  export {
29
27
  display
30
28
  };
31
29
 
30
+ // the functions below are serialized with toString() and pasted into the page the archive
31
+ // generates, where module scope does not exist: everything they call must be declared inside
32
+ // them. An import would survive bundling and then be an undefined free variable at runtime
32
33
  async function display(document, docContent, { disableFramePointerEvents, inPlace } = {}) {
34
+ function getDoctypeString(doc) {
35
+ const docType = doc.doctype;
36
+ let docTypeString = "";
37
+ if (docType) {
38
+ docTypeString = "<!DOCTYPE " + docType.nodeName;
39
+ if (docType.publicId) {
40
+ docTypeString += " PUBLIC \"" + docType.publicId + "\"";
41
+ if (docType.systemId) {
42
+ docTypeString += " \"" + docType.systemId + "\"";
43
+ }
44
+ } else if (docType.systemId) {
45
+ docTypeString += " SYSTEM \"" + docType.systemId + "\"";
46
+ }
47
+ if (docType.internalSubset) {
48
+ docTypeString += " [" + docType.internalSubset + "]";
49
+ }
50
+ docTypeString += ">";
51
+ }
52
+ return docTypeString;
53
+ }
33
54
  docContent = docContent.replace(/<noscript/gi, "<template disabled-noscript");
34
55
  docContent = docContent.replace(/<\/noscript/gi, "</template");
35
56
  const doc = (new DOMParser()).parseFromString(docContent, "text/html");
@@ -253,7 +253,10 @@ async function createArchive(pageData, options, script, writeEntries, lastModDat
253
253
  if (!options.extractDataFromPageTags || options.extractDataFromPageTags[0] != "<plaintext>") {
254
254
  const textContent = TEXT_DECODER.decode(data.subarray(startOffset));
255
255
  if (options.extractDataFromPageTags) {
256
- const tagIndex = EXTRA_DATA_TAGS.indexOf(options.extractDataFromPageTags);
256
+ // the rung is matched on its start tag, not on the identity of the array holding it:
257
+ // the option is set from EXTRA_DATA_TAGS internally, but a caller passing an equal
258
+ // pair of its own would otherwise index the regexps with -1
259
+ const tagIndex = getExtraDataTagIndex(options.extractDataFromPageTags);
257
260
  const regExpsTag = EXTRA_DATA_REGEXPS[tagIndex];
258
261
  if (textContent.match(regExpsTag[0]) || textContent.match(regExpsTag[1])) {
259
262
  return findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, tagIndex + 1);
@@ -651,6 +654,14 @@ function getPageTitle(pageData) {
651
654
  }).join("");
652
655
  }
653
656
 
657
+ function getExtraDataTagIndex(extractDataFromPageTags) {
658
+ const tagIndex = EXTRA_DATA_TAGS.findIndex(([startTag]) => startTag == extractDataFromPageTags[0]);
659
+ if (tagIndex == -1) {
660
+ throw new Error("Unknown data tags: " + extractDataFromPageTags[0]);
661
+ }
662
+ return tagIndex;
663
+ }
664
+
654
665
  function findExtraDataTags(textContent, pageData, options, script, writeEntries, lastModDate, indexExtractDataFromPageTags = 0) {
655
666
  const regExpsTag = EXTRA_DATA_REGEXPS[indexExtractDataFromPageTags];
656
667
  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
+ }