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 +22 -6
- package/core/lib/processor-helper-inline.js +13 -0
- package/core/lib/processor-helper.js +19 -1
- package/modules/css-fonts-minifier.js +86 -18
- package/modules/css-rules-minifier.js +28 -2
- package/package.json +2 -2
- package/processors/compression/compression-packager.js +2 -11
- package/processors/compression/compression.js +21 -8
- package/test/sfz-harness/README.md +2 -0
- package/test/sfz-harness/css-fonts-minifier.js +62 -11
- package/test/sfz-harness/css-property-filter.js +29 -1
- package/test/sfz-harness/format-rules.js +3 -1
- package/test/sfz-harness/pages-archive.js +177 -0
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,
|
|
412
|
-
getUsedFont(getComputedStyle(win, element, ":first-letter"),
|
|
413
|
-
getUsedFont(getComputedStyle(win, element, ":before"),
|
|
414
|
-
getUsedFont(getComputedStyle(win, element, ":after"),
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
102
|
+
if (usedFontsUnknown) {
|
|
97
103
|
unusedFonts = [];
|
|
98
104
|
} else {
|
|
99
105
|
filteredUsedFonts = new Map();
|
|
100
|
-
fontsInfo.used.forEach(fontNames => fontNames.forEach(familyName =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
208
|
-
//
|
|
209
|
-
if (!
|
|
210
|
-
const
|
|
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
|
-
|
|
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
|
|
221
|
-
return
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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 ? "" :
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
check("a property holding a whole shorthand
|
|
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
|
-
|
|
121
|
+
RENDERED_FAMILIES);
|
|
104
122
|
|
|
105
|
-
check("a var() nested in a fallback
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 & "start" <b>"), 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 & "start" <b></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
|
+
}
|