single-file-core 1.5.116 → 1.5.118

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 CHANGED
@@ -408,10 +408,10 @@ function getElementsInfo(win, doc, element, options, data = { usedFonts: new Map
408
408
  }
409
409
  }
410
410
  if (options.removeUnusedFonts) {
411
- getUsedFont(computedStyle, options, data.usedFonts);
412
- getUsedFont(getComputedStyle(win, element, ":first-letter"), options, data.usedFonts);
413
- getUsedFont(getComputedStyle(win, element, ":before"), options, data.usedFonts);
414
- getUsedFont(getComputedStyle(win, element, ":after"), options, data.usedFonts);
411
+ getUsedFont(computedStyle, data.usedFonts);
412
+ getUsedFont(getComputedStyle(win, element, ":first-letter"), data.usedFonts);
413
+ getUsedFont(getComputedStyle(win, element, ":before"), data.usedFonts);
414
+ getUsedFont(getComputedStyle(win, element, ":after"), data.usedFonts);
415
415
  }
416
416
  }
417
417
  }
@@ -611,12 +611,28 @@ function getResourcesInfo(win, doc, element, options, data, elementHidden, compu
611
611
  }
612
612
  }
613
613
 
614
- function getUsedFont(computedStyle, options, usedFonts) {
614
+ // Every family in the computed font-family is recorded, and the set of faces the document actually
615
+ // resolved is deliberately NOT consulted. Narrowing the list to the loaded faces is the obvious
616
+ // idea — a fallback stack names every family the page might need and only some are used — and it
617
+ // was written in 2019, wired to an option nothing ever set, and dead until it was reconnected here.
618
+ // Reconnected, it saved nothing measurable on nine real pages and cost fidelity twice: a face
619
+ // matched on its declared style is not found when the browser SYNTHESIZED that style, so a family
620
+ // drawn in an italic it declares no face for was dropped from the page that draws it; and a webfont
621
+ // that merely failed to load on the capturing machine would be dropped from the archive for good.
622
+ // It belongs with the unicode-range work, which needs the same information and can weigh a measured
623
+ // benefit against those, not here.
624
+ function getUsedFont(computedStyle, usedFonts) {
615
625
  if (computedStyle) {
616
626
  const fontStyle = computedStyle.getPropertyValue("font-style") || "normal";
617
627
  computedStyle.getPropertyValue("font-family").split(",").forEach(fontFamilyName => {
618
628
  fontFamilyName = normalizeFontFamily(fontFamilyName);
619
- if (!options.loadedFonts || options.loadedFonts.find(font => normalizeFontFamily(font.family) == fontFamilyName && font.style == fontStyle)) {
629
+ // an element with no computed font-family at all is one whose styles could not be read,
630
+ // not one drawn in a family with no name. It happens to every element of a frame that
631
+ // was re-parsed from its srcdoc, because the computed style is asked of the parent
632
+ // window and the document it belongs to is not the one being rendered. Recorded, it
633
+ // turns "nothing is known here" into a list of length one, which reads downstream as a
634
+ // complete answer and prunes every face the frame declares
635
+ if (fontFamilyName) {
620
636
  const fontWeight = getFontWeight(computedStyle.getPropertyValue("font-weight"));
621
637
  const fontVariant = computedStyle.getPropertyValue("font-variant") || "normal";
622
638
  const value = [fontFamilyName, fontWeight, fontStyle, fontVariant];
@@ -44,6 +44,10 @@ const EMPTY_URL_SOURCE = /^url\(["']?data:[^,]*,?["']?\)/;
44
44
  const LOCAL_SOURCE = "local(";
45
45
  const FONT_MAX_LOAD_DELAY = 5000;
46
46
  const DUPLICATE_STYLESHEET_ATTRIBUTE_NAME = "data-sf-duplicate-stylesheet-ref";
47
+ // the attributes that say how the link fetched its stylesheet, which the style element now holding
48
+ // that stylesheet has no use for. Everything else identified the element in the page
49
+ const LINK_FETCH_ATTRIBUTE_NAMES = ["rel", "href", "type", "media", "as", "crossorigin", "integrity",
50
+ "referrerpolicy", "hreflang", "sizes", "imagesrcset", "imagesizes", "fetchpriority"];
47
51
 
48
52
  let util;
49
53
 
@@ -162,6 +166,15 @@ function getProcessorHelperClass(utilInstance) {
162
166
  if (stylesheetInfo) {
163
167
  stylesheets.delete(linkElement);
164
168
  const styleElement = doc.createElement("style");
169
+ // the element is replaced rather than rewritten, so whatever identified it in
170
+ // the page has to be carried over: an id a script still looks up, a class a
171
+ // selector still matches. The attributes left out are the ones that describe
172
+ // the fetch the style element no longer performs
173
+ Array.from(linkElement.attributes).forEach(({ name, value }) => {
174
+ if (!LINK_FETCH_ATTRIBUTE_NAMES.includes(name.toLowerCase())) {
175
+ styleElement.setAttribute(name, value);
176
+ }
177
+ });
165
178
  if (stylesheetInfo.mediaText) {
166
179
  styleElement.media = stylesheetInfo.mediaText;
167
180
  }
@@ -36,6 +36,7 @@ const CANVAS_TAG_FOUND = /<canvas/gi;
36
36
  const EMPTY_URL_SOURCE = /^url\(["']?data:[^,]*,?["']?\)/;
37
37
  const LOCAL_SOURCE = "local(";
38
38
  const FONT_MAX_LOAD_DELAY = 5000;
39
+ const LINK_OWN_ATTRIBUTE_NAMES = ["rel", "type", "href", "media"];
39
40
 
40
41
  let util;
41
42
 
@@ -100,6 +101,7 @@ function getProcessorHelperClass(utilInstance) {
100
101
  replaceStylesheets(doc, stylesheets, options, resources) {
101
102
  const entries = Array.from(stylesheets).reverse();
102
103
  const linkElements = new Map();
104
+ const sharedStyleElements = new Map();
103
105
  Array.from(new Set(options.inlineStylesheetsRefs.values())).forEach(stylesheetRefIndex => {
104
106
  const linkElement = doc.createElement("link");
105
107
  linkElement.setAttribute("rel", "stylesheet");
@@ -117,6 +119,11 @@ function getProcessorHelperClass(utilInstance) {
117
119
  : cssTree.parse(content, { context: "stylesheet", parseCustomProperty: true });
118
120
  resources.stylesheets.set(resources.stylesheets.size, { name, content: this.generateStylesheetContent(stylesheet, options) });
119
121
  linkElements.set(stylesheetRefIndex, linkElement);
122
+ // the element the duplicates were folded into is not itself a duplicate, so it is
123
+ // absent from inlineStylesheetsRefs and would keep its content inline: the archive
124
+ // would then hold the same stylesheet twice, once as the entry the links point at
125
+ // and once in the page
126
+ sharedStyleElements.set(styleElement, stylesheetRefIndex);
120
127
  });
121
128
  for (const [key, stylesheetInfo] of entries) {
122
129
  if (key.urlNode) {
@@ -135,11 +142,22 @@ function getProcessorHelperClass(utilInstance) {
135
142
  resources.stylesheets.set(resources.stylesheets.size, { name, stylesheet: stylesheetInfo.stylesheet, url: stylesheetInfo.url });
136
143
  } else {
137
144
  const styleElement = key.element;
138
- const stylesheetRefIndex = options.inlineStylesheetsRefs.get(styleElement);
145
+ const stylesheetRefIndex = options.inlineStylesheetsRefs.has(styleElement)
146
+ ? options.inlineStylesheetsRefs.get(styleElement)
147
+ : sharedStyleElements.get(styleElement);
139
148
  if (stylesheetRefIndex === undefined) {
140
149
  styleElement.textContent = this.generateStylesheetContent(stylesheetInfo.stylesheet, options);
141
150
  } else {
142
151
  const linkElement = linkElements.get(stylesheetRefIndex).cloneNode(true);
152
+ // the element is replaced rather than rewritten, so whatever identified it in
153
+ // the page has to be carried over: an id a script still looks up, a class a
154
+ // selector still matches. The attributes left out are the ones that describe
155
+ // the link itself, which the code around here sets
156
+ Array.from(styleElement.attributes).forEach(({ name, value }) => {
157
+ if (!LINK_OWN_ATTRIBUTE_NAMES.includes(name.toLowerCase())) {
158
+ linkElement.setAttribute(name, value);
159
+ }
160
+ });
143
161
  if (stylesheetInfo.mediaText) {
144
162
  linkElement.media = stylesheetInfo.mediaText;
145
163
  }
@@ -92,19 +92,33 @@ function process(doc, stylesheets, styles, options) {
92
92
  fontsInfo.used = fontsInfo.used.map(fontNames => fontNames.map(familyName => resolveFamilyName(familyName, options)));
93
93
  fontsInfo.used = fontsInfo.used.map(fontNames => helper.flatten(fontNames));
94
94
  const variableFound = fontsInfo.used.find(fontNames => fontNames.find(fontName => fontName.match(/^var\(--/)));
95
+ // an empty list of rendered fonts does not mean the document uses none: every rendered element
96
+ // has a computed font-family, so an empty list means the computed styles could not be read at
97
+ // all. A frame whose contentDocument is unreachable is re-parsed from its srcdoc with
98
+ // DOMParser, and that document is never rendered, so it reports nothing and every face it
99
+ // declares would be dropped
100
+ const usedFontsUnknown = !options.usedFonts || !options.usedFonts.length;
95
101
  let unusedFonts, filteredUsedFonts;
96
- if (variableFound) {
102
+ if (usedFontsUnknown) {
97
103
  unusedFonts = [];
98
104
  } else {
99
105
  filteredUsedFonts = new Map();
100
- fontsInfo.used.forEach(fontNames => fontNames.forEach(familyName => {
101
- if (fontsInfo.declared.find(fontInfo => fontInfo.fontFamily == familyName)) {
102
- const optionalData = options.usedFonts && options.usedFonts.filter(fontInfo => fontInfo[0] == familyName);
103
- if (optionalData && optionalData.length) {
104
- filteredUsedFonts.set(familyName, optionalData);
105
- }
106
- }
107
- }));
106
+ fontsInfo.used.forEach(fontNames => fontNames.forEach(familyName =>
107
+ keepDeclaredFontIfRendered(familyName, fontsInfo, filteredUsedFonts, options)));
108
+ // A family named through a value that could not be resolved cannot be looked up in the
109
+ // stylesheets but the browser resolved it when it drew the page, so whatever it named is
110
+ // in the list of fonts actually rendered, and that list answers for it.
111
+ //
112
+ // Giving up on the whole document instead, which is what an unresolved name used to do,
113
+ // kept every declared face on any page holding one unreadable value anywhere while every
114
+ // other page pruned as usual. That was not a policy about uncertainty: a page that names
115
+ // its families plainly has always dropped a face it had not drawn yet, and only a page
116
+ // whose value happened not to parse was spared. The difference came from a parse failure,
117
+ // so it is the rendered list that decides in both cases now.
118
+ if (variableFound) {
119
+ fontsInfo.declared.forEach(fontInfo =>
120
+ keepDeclaredFontIfRendered(fontInfo.fontFamily, fontsInfo, filteredUsedFonts, options));
121
+ }
108
122
  unusedFonts = fontsInfo.declared.filter(fontInfo => !filteredUsedFonts.has(fontInfo.fontFamily));
109
123
  }
110
124
  const docChars = Array.from(new Set(docContent)).map(char => char.charCodeAt(0)).sort((value1, value2) => value1 - value2);
@@ -120,6 +134,17 @@ function process(doc, stylesheets, styles, options) {
120
134
  return stats;
121
135
  }
122
136
 
137
+ // a face is kept when the page declares it and the browser reports having drawn with it: the
138
+ // stylesheets say what exists, the rendered list says what was needed
139
+ function keepDeclaredFontIfRendered(familyName, fontsInfo, filteredUsedFonts, options) {
140
+ if (fontsInfo.declared.find(fontInfo => fontInfo.fontFamily == familyName)) {
141
+ const optionalData = options.usedFonts.filter(fontInfo => fontInfo[0] == familyName);
142
+ if (optionalData.length) {
143
+ filteredUsedFonts.set(familyName, optionalData);
144
+ }
145
+ }
146
+ }
147
+
123
148
  function getFontsInfo(cssRules, fontsInfo, options) {
124
149
  cssRules.forEach(ruleData => {
125
150
  if (ruleData.type == "Atrule" && (ruleData.name == "media" || ruleData.name == "supports" || ruleData.name == "layer" || ruleData.name == "container") && ruleData.block && ruleData.block.children) {
@@ -200,25 +225,68 @@ function getCustomPropertyValues(name, options) {
200
225
  return values;
201
226
  }
202
227
 
203
- function resolveFamilyName(familyName, options) {
228
+ function resolveFamilyName(familyName, options, resolvedProperties = new Set()) {
204
229
  const matchedVar = familyName.match(REGEXP_CUSTOM_PROPERTY_FAMILY);
205
230
  if (matchedVar) {
231
+ const propertyName = matchedVar[1];
206
232
  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);
233
+ // a property naming itself, directly or through another one, would resolve for ever: the
234
+ // chain already walked is carried down the branch so it stops instead
235
+ if (!resolvedProperties.has(propertyName)) {
236
+ const properties = new Set(resolvedProperties);
237
+ properties.add(propertyName);
238
+ const values = getCustomPropertyValues(propertyName, options);
211
239
  if (values) {
212
- const families = helper.flatten(values.map(value => splitFamilyNames(value)));
213
- return fallback ? families.concat(splitFamilyNames(fallback)) : families;
240
+ const families = helper.flatten(values.map(value => splitFamilyNames(value, options, properties)));
241
+ const fallbackFamilies = fallback ? splitFamilyNames(fallback, options, properties) : [];
242
+ // the browser takes the property or the fallback, so knowing one branch is not
243
+ // knowing the value: a var() left unresolved in either one keeps the family
244
+ // undetermined, exactly as it was before the nested one could be read at all
245
+ if (!families.concat(fallbackFamilies).some(testUnresolvedFamilyName)) {
246
+ return families.concat(fallbackFamilies);
247
+ }
214
248
  }
215
249
  }
216
250
  }
217
251
  return familyName;
218
252
  }
219
253
 
220
- function splitFamilyNames(value) {
221
- return value.split(",").map(familyName => normalizeFamilyName(familyName)).filter(familyName => familyName);
254
+ function testUnresolvedFamilyName(familyName) {
255
+ return typeof familyName == "string" && familyName.startsWith("var(");
256
+ }
257
+
258
+ function splitFamilyNames(value, options, resolvedProperties) {
259
+ const familyNames = splitValues(value).map(familyName => normalizeFamilyName(familyName)).filter(familyName => familyName);
260
+ return options
261
+ ? helper.flatten(familyNames.map(familyName => resolveFamilyName(familyName, options, resolvedProperties)))
262
+ : familyNames;
263
+ }
264
+
265
+ // a family list is split on its top-level commas only: the commas inside a var() belong to that
266
+ // var(), and the ones inside a quoted name belong to the name. Splitting on every comma is what
267
+ // made a var() nested in a fallback unreadable, and it also broke a family named "Foo, Bar"
268
+ function splitValues(value) {
269
+ const values = [];
270
+ let depth = 0, quote, start = 0;
271
+ for (let index = 0; index < value.length; index++) {
272
+ const character = value.charAt(index);
273
+ if (quote) {
274
+ if (character == quote && value.charAt(index - 1) != "\\") {
275
+ quote = null;
276
+ }
277
+ } else if (character == "\"" || character == "'") {
278
+ quote = character;
279
+ } else if (character == "(") {
280
+ depth++;
281
+ } else if (character == ")") {
282
+ depth--;
283
+ } else if (character == "," && !depth) {
284
+ values.push(value.substring(start, index));
285
+ start = index + 1;
286
+ }
287
+ }
288
+ values.push(value.substring(start));
289
+ return values;
222
290
  }
223
291
 
224
292
  function filterUnusedFonts(cssRules, declaredFonts, unusedFonts, filteredUsedFonts, docChars) {
@@ -71,7 +71,8 @@ const ANONYMOUS_LAYER_PLACEHOLDER = "\u0000";
71
71
 
72
72
  export {
73
73
  process,
74
- isUnsupportedPropertyValue
74
+ isUnsupportedPropertyValue,
75
+ isUnsupportedVendorValue
75
76
  };
76
77
 
77
78
  function isUnsupportedPropertyValue(property, value) {
@@ -79,6 +80,30 @@ function isUnsupportedPropertyValue(property, value) {
79
80
  return Boolean(!match.matched && match.error && match.error.name !== UNKNOWN_PROPERTY_ERROR_NAME);
80
81
  }
81
82
 
83
+ // A vendor-prefixed VALUE is dropped only when this browser cannot actually use it. Dropping every
84
+ // one of them was the overreach: `display:-ms-flexbox` is genuinely dead here and worth removing,
85
+ // but `display:-webkit-box` is alive and load-bearing — `-webkit-line-clamp` does nothing without
86
+ // it, so a page keeps its clamp rules and silently stops clamping. On anandabazar.com that expanded
87
+ // 80 clamped headlines by a line each and moved the page 150px, with nothing wrong-looking left in
88
+ // the saved CSS. Four of the five sites in a fifteen-site sweep that use line-clamp were affected.
89
+ function isUnsupportedVendorValue(property, name) {
90
+ if (!name.startsWith(VENDOR_PREFIX)) {
91
+ return false;
92
+ }
93
+ // unknown fails open, the same rule the unknown-property branch follows: a dropped valid
94
+ // declaration breaks rendering, a kept invalid one is ignored. With no browser to ask, keep.
95
+ if (!globalThis.CSS || !globalThis.CSS.supports) {
96
+ return false;
97
+ }
98
+ try {
99
+ return !globalThis.CSS.supports(property, name);
100
+ // eslint-disable-next-line no-unused-vars
101
+ } catch (error) {
102
+ // a value CSS.supports will not even take as an argument tells us nothing either way
103
+ return false;
104
+ }
105
+ }
106
+
82
107
  function process(doc, stylesheets) {
83
108
  const docContext = {
84
109
  doc,
@@ -497,7 +522,8 @@ function collectDeclarationItemsForElement(element, docContext) {
497
522
  hasValueChildNodes &&
498
523
  value.children.size == 1) {
499
524
  if (value.children.head.data.name) {
500
- isInvalidValue = value.children.head.data.name.startsWith(VENDOR_PREFIX) || INVALID_CSS_ESCAPE_TEST.test(value.children.head.data.name);
525
+ const name = value.children.head.data.name;
526
+ isInvalidValue = isUnsupportedVendorValue(property, name) || INVALID_CSS_ESCAPE_TEST.test(name);
501
527
  } if (!property.startsWith(VENDOR_PREFIX) && value.children.head.data.value) {
502
528
  try {
503
529
  isInvalidValue = isUnsupportedPropertyValue(property, value);
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.116",
3
+ "version": "1.5.118",
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 && 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",
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 && deno run --allow-read test/sfz-harness/pages-archive.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",
@@ -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 => {
@@ -622,7 +634,7 @@ function getHTMLHeadData(pageData, options) {
622
634
  let pageContent = "";
623
635
  // the title is left out of a password-protected archive: manifest.json carries it too and
624
636
  // is encrypted, so emitting it here would publish what the password is meant to cover
625
- const title = options.password ? "" : getPageTitle(pageData);
637
+ const title = options.password ? "" : escapeHTML(pageData.title || "");
626
638
  pageContent += "<title>" + title + "</title>";
627
639
  // the canonical link publishes the URL the archive was saved from, for the same reason
628
640
  // the title above is left out of a password-protected archive
@@ -644,12 +656,13 @@ function getHTMLHeadData(pageData, options) {
644
656
  return pageContent;
645
657
  }
646
658
 
647
- function getPageTitle(pageData) {
648
- // numeric character references are ASCII bytes, so the title survives the single-byte
649
- // charset universal mode declares, and any parser decodes them back to the original text
650
- 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 => {
651
664
  const codePoint = character.codePointAt(0);
652
- return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" ?
665
+ return codePoint < 32 || codePoint > 126 || character == "&" || character == "<" || character == ">" || character == "\"" ?
653
666
  "&#" + codePoint + ";" : character;
654
667
  }).join("");
655
668
  }
@@ -29,6 +29,8 @@ any check failed.
29
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
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
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
+ | `inlined-functions.js` | That a function serialized into a self-extracting archive names nothing outside itself. An import survives bundling and still reads correctly, and the archive then throws a bare `ReferenceError` and renders nothing. |
33
+ | `pages-archive.js` | That `createPagesArchive` packs several pages into one archive correctly: the first page at the root and the others in folders, the manifest, the symlink a deduplicated entry leaves behind, and the escaping of crawled titles in both tables of contents. |
32
34
  | `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. |
33
35
 
34
36
  ## The tools
@@ -11,6 +11,13 @@
11
11
  // so it can only keep too much, never too little — and options.usedFonts, which comes from the
12
12
  // rendered computed styles, gates the result anyway.
13
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
+ //
14
21
  // The other var() defects this pins, all found alongside it:
15
22
  // - custom property names are case-sensitive; they were lowercased with the family names, so
16
23
  // var(--ProbeFont) never resolved, not even from :root
@@ -45,6 +52,11 @@ const USED_FONTS = [
45
52
 
46
53
  const ALL_FAMILIES = ["usedone", "usedtwo", "usedthree", "unused"];
47
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
+
48
60
  let failures = 0;
49
61
 
50
62
  check("a plain family name prunes the rest",
@@ -83,12 +95,18 @@ check("the font shorthand resolves a property written with a fallback",
83
95
  run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font,serif)}" }),
84
96
  ["usedone"]);
85
97
 
86
- // the conservative half of the contract: when the value genuinely cannot be determined, every
87
- // declared font stays, including the one no rule names. These are the cases the union does not
88
- // cover, and getting them wrong loses fonts from the saved page rather than merely wasting bytes
89
- check("a property declared nowhere keeps every font",
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",
90
108
  run({ rules: ".card p{font-family:var(--set-by-script),serif}" }),
91
- ALL_FAMILIES);
109
+ RENDERED_FAMILIES);
92
110
 
93
111
  // the shorthand cannot be substituted with several candidate values, but its var() sits in family
94
112
  // position, so the parser hands it back as the family and the union answers it there instead
@@ -97,18 +115,51 @@ check("a font shorthand with several candidates resolves through the family",
97
115
  ["usedone", "usedtwo"]);
98
116
 
99
117
  // a property holding the whole shorthand is the case the union must NOT touch: its values are not
100
- // family lists, and reading them as such would drop every font in the document
101
- check("a property holding a whole shorthand keeps every font",
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",
102
120
  run({ rules: ".card{--font:italic 1em \"UsedOne\"}.note{--font:italic 1em \"UsedTwo\"}p{font:var(--font)}" }),
103
- ALL_FAMILIES);
121
+ RENDERED_FAMILIES);
104
122
 
105
- check("a var() nested in a fallback keeps every font",
123
+ check("a var() nested in a fallback falls back to the fonts that were drawn",
106
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: [] }),
107
158
  ALL_FAMILIES);
108
159
 
109
- check("a property whose value is another undetermined property keeps every font",
160
+ check("a property whose value is another undetermined property falls back to the fonts that were drawn",
110
161
  run({ rules: ".card{--probe-font:var(--set-by-script)}.card p{font-family:var(--probe-font),serif}" }),
111
- ALL_FAMILIES);
162
+ RENDERED_FAMILIES);
112
163
 
113
164
  // An unquoted family name is a sequence of identifier tokens, and the walk that joins them has to
114
165
  // resume after the LAST of them. Resuming after the first pushed every word but that one again as a
@@ -10,7 +10,7 @@
10
10
  // The rule this pins: unknown must fail open. A dropped valid declaration breaks rendering; a kept
11
11
  // invalid one is ignored by the browser.
12
12
  import * as cssTree from "../../vendor/css-tree.js";
13
- import { isUnsupportedPropertyValue } from "../../modules/css-rules-minifier.js";
13
+ import { isUnsupportedPropertyValue, isUnsupportedVendorValue } from "../../modules/css-rules-minifier.js";
14
14
 
15
15
  // valid declarations whose property the vendored css-tree does not know. Every one of these was
16
16
  // deleted before the fix. The SVG paint-server and filter properties are the ones that matter in
@@ -77,6 +77,34 @@ const wrongValue = cssTree.lexer.matchProperty("margin-trim", cssTree.parse("blo
77
77
  check("unknown property reports SyntaxReferenceError", unknownProperty.error && unknownProperty.error.name, "SyntaxReferenceError");
78
78
  check("wrong value reports SyntaxMatchError", wrongValue.error && wrongValue.error.name, "SyntaxMatchError");
79
79
 
80
+ // The same failure as above, one step earlier and on VALUES rather than properties. Before the fix
81
+ // the call site dropped any single-identifier value beginning with "-", asking nothing: the test
82
+ // for a dead `display:-ms-flexbox` also deleted a live `display:-webkit-box`. That one costs more
83
+ // than it looks, because `-webkit-line-clamp` does nothing without it and both of ITS declarations
84
+ // survive — being unknown properties, they already fail open — so the rule keeps a clamp it no
85
+ // longer applies. Four of the five sites in a fifteen-site sweep that use line-clamp were affected;
86
+ // on one of them 80 headlines each grew a line and the page moved 150px.
87
+ //
88
+ // CSS.supports is the authority and Deno has none, so it is stubbed here. That is also the point of
89
+ // the last group: with no browser to ask, this must KEEP, which is the same fail-open rule as above.
90
+ const CHROME_SUPPORTS = new Set(["display:-webkit-box", "display:-webkit-inline-box", "-webkit-box-orient:vertical"]);
91
+ const originalCSS = globalThis.CSS;
92
+ globalThis.CSS = { supports: (property, value) => CHROME_SUPPORTS.has(property + ":" + value) };
93
+ try {
94
+ // alive in this browser, and load-bearing
95
+ check("vendor value kept: display: -webkit-box", isUnsupportedVendorValue("display", "-webkit-box"), false);
96
+ check("vendor value kept: display: -webkit-inline-box", isUnsupportedVendorValue("display", "-webkit-inline-box"), false);
97
+ // dead in this browser, and the reason the check exists at all — the fix must not disable it
98
+ check("vendor value dropped: display: -ms-flexbox", isUnsupportedVendorValue("display", "-ms-flexbox"), true);
99
+ check("vendor value dropped: display: -moz-box", isUnsupportedVendorValue("display", "-moz-box"), true);
100
+ // not vendor-prefixed, so this predicate must not have an opinion either way
101
+ check("non-vendor value untouched: display: flex", isUnsupportedVendorValue("display", "flex"), false);
102
+ check("non-vendor value untouched: color: nonsense", isUnsupportedVendorValue("color", "nonsense"), false);
103
+ } finally {
104
+ globalThis.CSS = originalCSS;
105
+ }
106
+ check("no browser to ask keeps the value", isUnsupportedVendorValue("display", "-ms-flexbox"), false);
107
+
80
108
  if (failed) {
81
109
  console.log("FAILED");
82
110
  Deno.exit(1);
@@ -2,7 +2,9 @@ import "./dom-stub.js";
2
2
  import { makePageData, makeOptions, runProcess, mulberry32 } from "./common.js";
3
3
  import { ZipReader, BlobReader } from "../../vendor/zip/zip.js";
4
4
 
5
- const TITLE = "日本語 café & <b>";
5
+ // the quote is there because the escaper the title shares with the table of contents encodes
6
+ // it for an attribute value, where it matters, and a title has to round-trip through that too
7
+ const TITLE = "日本語 — café & <b> \"quoted\"";
6
8
  const PDF = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n");
7
9
 
8
10
  let failed = false;
@@ -0,0 +1,177 @@
1
+ // createPagesArchive packs several single-page archives into one. Nothing exercised it until this
2
+ // file: the module was reachable only through a crawl, so a change to the folder layout, the
3
+ // manifest, the deduplication or the table of contents broke nothing that anyone ran.
4
+ //
5
+ // Three of its rules are worth stating, because they look arbitrary in the code:
6
+ //
7
+ // - the first page is stored at the ROOT and the others under pages/N/. The root page is what a
8
+ // reader opens, so it cannot be moved into a folder without changing every relative URL the
9
+ // capture already resolved.
10
+ // - a duplicate entry becomes a SYMLINK rather than being dropped. The router resolves it from
11
+ // the alias map in the manifest and never reads it, but a plain unzip has to produce complete
12
+ // page folders, and only a symlink gives both.
13
+ // - the titles written into the table of contents are CRAWLED, so they are attacker-controlled
14
+ // text going into an href attribute and into element content. Both escapers are checked here.
15
+ import "./dom-stub.js";
16
+ import { makePageData, makeOptions, runProcess } from "./common.js";
17
+ import { createPagesArchive } from "../../processors/compression/compression-packager.js";
18
+ import { ZipReader, BlobReader, TextWriter } from "../../vendor/zip/zip.js";
19
+
20
+ // a title as it comes back from a crawl: the quote closes the href it is written into, the angle
21
+ // bracket opens an element, and the ampersand is what a naive escaper double-encodes
22
+ const HOSTILE_TITLE = "Intro & \"start\" <b>";
23
+ const SYMLINK_UNIX_MODE = 0o120777;
24
+
25
+ let failed = false;
26
+
27
+ const pages = [
28
+ await makePage(1, { url: "https://example.com/docs/intro.html", title: HOSTILE_TITLE, originalUrls: ["https://example.com/docs/"] }),
29
+ await makePage(2, { url: "https://example.com/docs/api/reference.html", title: "Reference" })
30
+ ];
31
+
32
+ {
33
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions()));
34
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
35
+ check("the first page is stored at the root of the archive", entries.has("index.html"), true);
36
+ check("a later page is stored in a folder of its own", entries.has("pages/2/index.html"), true);
37
+ check("the manifest names the path of every page",
38
+ manifest.pages.map(page => page.path).join(" "), " pages/2/");
39
+ check("the manifest names the url of every page",
40
+ manifest.pages.map(page => page.url).join(" "), "https://example.com/docs/intro.html https://example.com/docs/api/reference.html");
41
+ check("the manifest keeps the title a page was saved with", manifest.pages[0].title, HOSTILE_TITLE);
42
+ // a page reached through several urls has to answer to all of them, or a link to the url the
43
+ // crawler did not settle on leaves the archive
44
+ check("the manifest keeps the urls a page was reached by",
45
+ (manifest.pages[0].originalUrls || []).join(" "), "https://example.com/docs/");
46
+ }
47
+
48
+ // the router reads these two out of the manifest, and "auto" is the absence of a choice rather
49
+ // than a value: writing it would pin the default of the day into every archive
50
+ {
51
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ markUnarchivedLinks: true, pageTransitions: "slide" })));
52
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
53
+ check("the manifest records that unarchived links are marked", manifest.markUnarchivedLinks, true);
54
+ check("the manifest records the page transition it was given", manifest.pageTransitions, "slide");
55
+ }
56
+
57
+ {
58
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ pageTransitions: "auto" })));
59
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
60
+ check("the default page transition is not written to the manifest", "pageTransitions" in manifest, false);
61
+ }
62
+
63
+ // Both fixtures declare the same stylesheet, so pages/2/styles.css is byte-for-byte the entry
64
+ // already written at the root.
65
+ {
66
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ dedupPages: true })));
67
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
68
+ const duplicate = entries.get("pages/2/styles.css");
69
+ check("a repeated entry is still present after deduplication", Boolean(duplicate), true);
70
+ check("the repeated entry points at the one that was kept",
71
+ await readEntry(entries, "pages/2/styles.css"), "../../styles.css");
72
+ // without the mode, tar and unzip write the path as the FILE CONTENT and the page folder ends
73
+ // up holding a text file where a stylesheet belongs
74
+ check("the repeated entry carries the unix symlink mode",
75
+ duplicate.externalFileAttributes >>> 16, SYMLINK_UNIX_MODE);
76
+ // read through a default, so that a manifest with no aliases at all reports as a failed check
77
+ // rather than throwing and taking every check after it down with it
78
+ check("the manifest maps the repeated entry to the one it aliases",
79
+ (manifest.aliases || {})["pages/2/styles.css"], "styles.css");
80
+ check("an entry that is not repeated is left alone",
81
+ "pages/2/index.html" in (manifest.aliases || {}), false);
82
+ }
83
+
84
+ {
85
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions()));
86
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
87
+ check("nothing is aliased when deduplication is off", "aliases" in manifest, false);
88
+ check("a repeated entry is stored whole when deduplication is off",
89
+ (await readEntry(entries, "pages/2/styles.css")).includes("font-family"), true);
90
+ }
91
+
92
+ {
93
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ tocPage: true })));
94
+ const toc = await readEntry(entries, "sfz-toc.html");
95
+ check("the table of contents page is stored when it is asked for", entries.has("sfz-toc.html"), true);
96
+ check("the table of contents links to the page at the root", toc.includes("href=\"index.html\""), true);
97
+ check("the table of contents links to the page in its folder", toc.includes("href=\"pages/2/index.html\""), true);
98
+ // the escaped form has to be there AND the raw form has to be absent: a title written twice,
99
+ // once escaped and once not, passes any check that only looks for the escaped one
100
+ check("a crawled title is escaped into the table of contents",
101
+ toc.includes("Intro &amp; &quot;start&quot; &lt;b&gt;"), true);
102
+ check("a crawled title is not also written raw", toc.includes(HOSTILE_TITLE), false);
103
+ // the groups are details/summary and nothing else on purpose: the page has to stay usable
104
+ // after a plain unzip, where no script runs
105
+ check("pages are grouped by the segments of their path",
106
+ toc.includes("<details open><summary>docs</summary>"), true);
107
+ check("the table of contents needs no script", toc.includes("<script"), false);
108
+ }
109
+
110
+ {
111
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions()));
112
+ check("no table of contents page is stored when it is not asked for", entries.has("sfz-toc.html"), false);
113
+ }
114
+
115
+ // one origin is the whole archive's origin and adding it to every path would say nothing; two
116
+ // origins make it the first thing that tells two pages apart
117
+ {
118
+ const mixedPages = [pages[0], await makePage(3, { url: "https://other.example.org/notes.html", title: "Notes" })];
119
+ const entries = await readArchive(await createPagesArchive(mixedPages, packagerOptions({ tocPage: true })));
120
+ const toc = await readEntry(entries, "sfz-toc.html");
121
+ check("pages from several origins are grouped by origin",
122
+ toc.includes("<summary>https://example.com</summary>"), true);
123
+ }
124
+
125
+ // the prelude list is read without decompressing anything, by tools that never extract the
126
+ // archive, so it is the only place the pages are named in plain text
127
+ {
128
+ const bytes = await createPagesArchive(pages, packagerOptions({ pageList: true }));
129
+ const prelude = new TextDecoder("windows-1252").decode(bytes);
130
+ check("the prelude lists the pages when the page list is asked for",
131
+ prelude.includes("<a href=\"https://example.com/docs/api/reference.html\">Reference</a>"), true);
132
+ // anchored on the link, not on the escaped text alone: the same title is also written into the
133
+ // wrapper's own <title>, which the writer escapes the same way, so a search for the escaped
134
+ // form anywhere in the archive passes even when the page list itself is written raw
135
+ check("the prelude escapes a crawled title too",
136
+ prelude.includes("<a href=\"https://example.com/docs/intro.html\">Intro &#38; &#34;start&#34; &#60;b&#62;</a>"), true);
137
+ check("the prelude is not written when the page list is not asked for",
138
+ new TextDecoder("windows-1252").decode(await createPagesArchive(pages, packagerOptions())).includes("<nav><ul>"), false);
139
+ }
140
+
141
+ console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
142
+ Deno.exit(failed ? 1 : 0);
143
+
144
+ // each page of a multi-page archive is a single-page archive, so the fixtures are built by the
145
+ // writer the rest of the harness already covers
146
+ async function makePage(seed, { url, title, originalUrls }) {
147
+ const pageData = makePageData(seed, 2 * 1024);
148
+ pageData.title = title;
149
+ const { bytes } = await runProcess(pageData, makeOptions({ url }));
150
+ return { url, title, originalUrls, getData: async () => bytes };
151
+ }
152
+
153
+ function packagerOptions(overrides = {}) {
154
+ return {
155
+ selfExtractingArchive: true,
156
+ extractDataFromPage: true,
157
+ zipScript: "/* zip script stub */",
158
+ ...overrides
159
+ };
160
+ }
161
+
162
+ async function readArchive(bytes) {
163
+ const zipReader = new ZipReader(new BlobReader(new Blob([bytes])));
164
+ const entries = await zipReader.getEntries();
165
+ await zipReader.close();
166
+ return new Map(entries.map(entry => [entry.filename, entry]));
167
+ }
168
+
169
+ function readEntry(entries, filename) {
170
+ return entries.get(filename).getData(new TextWriter());
171
+ }
172
+
173
+ function check(label, actual, expected) {
174
+ const ok = actual === expected;
175
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
176
+ failed ||= !ok;
177
+ }