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.
- package/core/helper.js +12 -7
- package/core/index.js +19 -5
- package/core/lib/processor-helper-common.js +42 -18
- package/core/lib/processor-helper-inline.js +2 -10
- package/core/lib/processor-helper.js +25 -18
- package/doc/singlefile-archive.md +123 -26
- package/modules/css-fonts-minifier.js +114 -24
- 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.js +12 -1
- 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 +184 -0
- package/test/sfz-harness/css-property-filter.js +85 -0
- package/test/sfz-harness/format-rules.js +28 -0
- package/test/sfz-harness/inlined-functions.js +82 -0
|
@@ -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\((--[^)
|
|
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
|
-
|
|
81
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|