single-file-core 1.6.0 → 1.6.2
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 +5 -2
- package/modules/css-fonts-minifier.js +34 -13
- package/modules/template-formatter.js +8 -2
- package/package.json +10 -2
- package/vendor/zip/zip.js +4 -0
- package/.github/workflows/publish.yml +0 -88
- package/.github/workflows/test.yml +0 -31
- package/.github/workflows/vendor.yml +0 -39
- package/css-tree-build/README.md +0 -39
- package/css-tree-build/build.js +0 -66
- package/css-tree-build/package-lock.json +0 -511
- package/css-tree-build/package.json +0 -13
- package/deno.lock +0 -19
- package/doc/assets/singlefile-archive-byte-map.svg +0 -276
- package/doc/assets/singlefile-archive-lenses.svg +0 -245
- package/doc/singlefile-archive.md +0 -1968
- package/eslint.config.mjs +0 -74
- package/test/capture/README.md +0 -74
- package/test/capture/canonical-link.js +0 -50
- package/test/capture/common.js +0 -73
- package/test/capture/deferred-content-options.js +0 -71
- package/test/capture/dom.js +0 -26
- package/test/capture/maff-metadata.js +0 -205
- package/test/capture/resource-cap.js +0 -79
- package/test/capture/script-uri-sanitization.js +0 -82
- package/test/fixtures/README.md +0 -18
- package/test/fixtures/classic-digest.html +0 -5
- package/test/fixtures/generate-fixtures.sh +0 -31
- package/test/fixtures/multi-page-dedup.zip.html +0 -70
- package/test/fixtures/multi-page.zip.html +0 -26
- package/test/fixtures/single-page.zip.html +0 -11
- package/test/fixtures/site/multi-page/alpha.html +0 -2
- package/test/fixtures/site/multi-page/beta.html +0 -2
- package/test/fixtures/site/multi-page/index.html +0 -2
- package/test/fixtures/site/multi-page/sub/gamma.html +0 -2
- package/test/fixtures/site/multi-page-dedup/index.html +0 -2
- package/test/fixtures/site/multi-page-dedup/logo.png +0 -0
- package/test/fixtures/site/multi-page-dedup/one.html +0 -2
- package/test/fixtures/site/multi-page-dedup/shared.css +0 -3
- package/test/fixtures/site/multi-page-dedup/two.html +0 -2
- package/test/run.js +0 -109
- package/test/sfz-harness/README.md +0 -87
- package/test/sfz-harness/adopted-stylesheets-hook.js +0 -240
- package/test/sfz-harness/byte-map.js +0 -137
- package/test/sfz-harness/charset-round-trip.js +0 -161
- package/test/sfz-harness/check-determinism.js +0 -57
- package/test/sfz-harness/common.js +0 -154
- package/test/sfz-harness/content-type-sniffing.js +0 -83
- package/test/sfz-harness/css-fonts-minifier.js +0 -235
- package/test/sfz-harness/css-property-filter.js +0 -113
- package/test/sfz-harness/dom-stub.js +0 -13
- package/test/sfz-harness/entry-compression.js +0 -77
- package/test/sfz-harness/filename-characters.js +0 -55
- package/test/sfz-harness/filename-max-length.js +0 -69
- package/test/sfz-harness/font-face-composite.js +0 -135
- package/test/sfz-harness/format-rules.js +0 -617
- package/test/sfz-harness/gen-e2e-page.js +0 -14
- package/test/sfz-harness/inlined-functions.js +0 -82
- package/test/sfz-harness/option-wiring.js +0 -60
- package/test/sfz-harness/pages-archive.js +0 -318
- package/test/sfz-harness/pages-router.js +0 -143
- package/test/sfz-harness/relocation-cost.js +0 -94
- package/test/sfz-harness/search-triggers.js +0 -35
- package/test/sfz-harness/smoke.js +0 -10
- package/test/sfz-harness/stored-trigger.js +0 -95
- package/test/sfz-harness/trigger-seeds.json +0 -120
- package/test/sfz-harness/zip64.js +0 -77
- package/zip-build/README.md +0 -56
- package/zip-build/lib/fflate-streams.js +0 -47
- package/zip-build/lib/fflate.js +0 -2692
- package/zip-build/lib/zip-vendor-worker.js +0 -3
- package/zip-build/lib/zip-vendor.js +0 -3
- package/zip-build/lib/zip.js +0 -25
- package/zip-build/package-lock.json +0 -665
- package/zip-build/package.json +0 -15
- package/zip-build/reserved-property-names.json +0 -10630
- package/zip-build/rollup.config.js +0 -61
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { makePageData, makeOptions, runProcess, freezeDate, buildZip, generateContent, sameBytes, firstDifference } from "./common.js";
|
|
2
|
-
|
|
3
|
-
const SEED = 1;
|
|
4
|
-
const SIZE = 512 * 1024;
|
|
5
|
-
let failed = false;
|
|
6
|
-
|
|
7
|
-
function report(label, ok, detail = "") {
|
|
8
|
-
console.log(`${ok ? "PASS" : "FAIL"} ${label}${detail ? " — " + detail : ""}`);
|
|
9
|
-
failed ||= !ok;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
{
|
|
13
|
-
const restoreDate = freezeDate();
|
|
14
|
-
try {
|
|
15
|
-
const first = await runProcess(makePageData(SEED, SIZE), makeOptions());
|
|
16
|
-
const second = await runProcess(makePageData(SEED, SIZE), makeOptions());
|
|
17
|
-
report("process() deterministic with frozen Date", sameBytes(first.bytes, second.bytes),
|
|
18
|
-
`sizes ${first.bytes.length}/${second.bytes.length}, first diff at ${firstDifference(first.bytes, second.bytes)}`);
|
|
19
|
-
} finally {
|
|
20
|
-
restoreDate();
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
{
|
|
25
|
-
const first = await runProcess(makePageData(SEED, SIZE), makeOptions());
|
|
26
|
-
await new Promise(resolve => setTimeout(resolve, 5));
|
|
27
|
-
const second = await runProcess(makePageData(SEED, SIZE), makeOptions());
|
|
28
|
-
report("process() NON-deterministic with real Date (archiveTime)", !sameBytes(first.bytes, second.bytes),
|
|
29
|
-
"confirms archiveTime must be pinned across retries");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const content = generateContent(SEED, SIZE);
|
|
33
|
-
const engines = [
|
|
34
|
-
{ label: "vendor CompressionStream", config: { useCompressionStream: true } },
|
|
35
|
-
{ label: "vendor level 1 (no CompressionStream)", config: { useCompressionStream: false, level: 1 } },
|
|
36
|
-
{ label: "vendor level 9 (no CompressionStream)", config: { useCompressionStream: false, level: 9 } }
|
|
37
|
-
];
|
|
38
|
-
const outputs = [];
|
|
39
|
-
for (const { label, config } of engines) {
|
|
40
|
-
const first = await buildZip(content, config);
|
|
41
|
-
const second = await buildZip(content, config);
|
|
42
|
-
report(`${label} deterministic`, sameBytes(first, second), `${first.length} bytes`);
|
|
43
|
-
outputs.push({ label, bytes: first });
|
|
44
|
-
}
|
|
45
|
-
const storeFallback = outputs[1].bytes.length >= content.length;
|
|
46
|
-
report("no silent store fallback without CompressionStream", !storeFallback, `${outputs[1].bytes.length} bytes for ${content.length} input`);
|
|
47
|
-
const levelLever = !storeFallback && !sameBytes(await rawEntryData(outputs[1].bytes), await rawEntryData(outputs[2].bytes));
|
|
48
|
-
console.log(`INFO level lever ${levelLever ? "AVAILABLE (levels produce distinct streams)" : "ABSENT (native streams ignore level; perturbation levers: store/deflate flip + text input tweaks)"}`);
|
|
49
|
-
|
|
50
|
-
async function rawEntryData(bytes) {
|
|
51
|
-
const { ZipReader, BlobReader, Uint8ArrayWriter } = await import("../../vendor/zip/zip.js");
|
|
52
|
-
const reader = new ZipReader(new BlobReader(new Blob([bytes])));
|
|
53
|
-
const [entry] = await reader.getEntries();
|
|
54
|
-
return entry.getData(new Uint8ArrayWriter(), { passThrough: true });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
Deno.exit(failed ? 1 : 0);
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { process } from "../../processors/compression/compression.js";
|
|
2
|
-
import { configure, ZipWriter, BlobWriter, TextReader } from "../../vendor/zip/zip.js";
|
|
3
|
-
|
|
4
|
-
configure({ useWebWorkers: false });
|
|
5
|
-
|
|
6
|
-
const RealDate = Date;
|
|
7
|
-
const FIXED_TIME = 1755129600000;
|
|
8
|
-
const FIXED_DATE = new RealDate(FIXED_TIME);
|
|
9
|
-
|
|
10
|
-
const WORDS = ("lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt " +
|
|
11
|
-
"ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco").split(" ");
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
process,
|
|
15
|
-
RealDate,
|
|
16
|
-
FIXED_TIME,
|
|
17
|
-
FIXED_DATE,
|
|
18
|
-
mulberry32,
|
|
19
|
-
generateContent,
|
|
20
|
-
makePageData,
|
|
21
|
-
makeOptions,
|
|
22
|
-
runProcess,
|
|
23
|
-
freezeDate,
|
|
24
|
-
buildZip,
|
|
25
|
-
blobBytes,
|
|
26
|
-
sameBytes,
|
|
27
|
-
firstDifference,
|
|
28
|
-
scanDelimiters
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
function mulberry32(seed) {
|
|
32
|
-
let state = seed >>> 0;
|
|
33
|
-
return () => {
|
|
34
|
-
state = (state + 0x6D2B79F5) >>> 0;
|
|
35
|
-
let t = state;
|
|
36
|
-
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
37
|
-
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
38
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function generateContent(seed, targetLength) {
|
|
43
|
-
const rand = mulberry32(seed);
|
|
44
|
-
const parts = ["seed " + seed];
|
|
45
|
-
let length = 0;
|
|
46
|
-
while (length < targetLength) {
|
|
47
|
-
const token = rand() < 0.25 ?
|
|
48
|
-
((rand() * 0x100000000) >>> 0).toString(36) :
|
|
49
|
-
WORDS[(rand() * WORDS.length) | 0];
|
|
50
|
-
parts.push(token);
|
|
51
|
-
length += token.length + 1;
|
|
52
|
-
if (!(parts.length % 16)) {
|
|
53
|
-
parts.push("\n");
|
|
54
|
-
length += 2;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return parts.join(" ");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function makePageData(seed, targetLength) {
|
|
61
|
-
return {
|
|
62
|
-
title: "fixture " + seed,
|
|
63
|
-
doctype: "<!DOCTYPE html>",
|
|
64
|
-
content: "<html><body><p>" + generateContent(seed, targetLength) + "</p></body></html>",
|
|
65
|
-
resources: {
|
|
66
|
-
stylesheets: [{ name: "styles.css", extension: ".css", content: "body { font-family: serif; color: rgb(20, 20, 20) }" }],
|
|
67
|
-
images: []
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function makeOptions(overrides = {}) {
|
|
73
|
-
return {
|
|
74
|
-
selfExtractingArchive: true,
|
|
75
|
-
extractDataFromPage: true,
|
|
76
|
-
zipScript: "/* zip script stub */",
|
|
77
|
-
url: "https://example.com/",
|
|
78
|
-
...overrides
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function runProcess(pageData, options, lastModDate = FIXED_DATE) {
|
|
83
|
-
const blob = await process(pageData, options, lastModDate);
|
|
84
|
-
return {
|
|
85
|
-
bytes: await blobBytes(blob),
|
|
86
|
-
fallbackTag: options.extractDataFromPageTags ? options.extractDataFromPageTags[0] : null,
|
|
87
|
-
extractionDisabled: options.extractDataFromPage === false
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function freezeDate() {
|
|
92
|
-
class FrozenDate extends RealDate {
|
|
93
|
-
constructor(...args) {
|
|
94
|
-
if (args.length) {
|
|
95
|
-
super(...args);
|
|
96
|
-
} else {
|
|
97
|
-
super(FIXED_TIME);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
static now() {
|
|
101
|
-
return FIXED_TIME;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
globalThis.Date = FrozenDate;
|
|
105
|
-
return () => {
|
|
106
|
-
globalThis.Date = RealDate;
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function buildZip(content, { useCompressionStream, level }) {
|
|
111
|
-
const writer = new ZipWriter(new BlobWriter("application/zip"), {
|
|
112
|
-
useCompressionStream,
|
|
113
|
-
level,
|
|
114
|
-
lastModDate: FIXED_DATE
|
|
115
|
-
});
|
|
116
|
-
await writer.add("data.txt", new TextReader(content));
|
|
117
|
-
return blobBytes(await writer.close());
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function blobBytes(blob) {
|
|
121
|
-
return new Uint8Array(await blob.arrayBuffer());
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function sameBytes(a, b) {
|
|
125
|
-
if (a.length != b.length) {
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
return firstDifference(a, b) == -1;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function firstDifference(a, b) {
|
|
132
|
-
const length = Math.min(a.length, b.length);
|
|
133
|
-
for (let index = 0; index < length; index++) {
|
|
134
|
-
if (a[index] != b[index]) {
|
|
135
|
-
return index;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return a.length == b.length ? -1 : length;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function scanDelimiters(bytes) {
|
|
142
|
-
const text = new TextDecoder("windows-1252").decode(bytes);
|
|
143
|
-
const found = [];
|
|
144
|
-
for (const [name, regExp] of [
|
|
145
|
-
["comment open", /<!--/i],
|
|
146
|
-
["comment close", /--!?>/i]
|
|
147
|
-
]) {
|
|
148
|
-
const match = text.match(regExp);
|
|
149
|
-
if (match) {
|
|
150
|
-
found.push({ name, index: match.index });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return found;
|
|
154
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// `core/util.js` used to trust any Content-Type the server sent and sniff the bytes only when the
|
|
2
|
-
// header was missing or `application/octet-stream`. science.org serves its woff2 files as
|
|
3
|
-
// `text/plain;charset=UTF-8` — 12 of them — and every one was embedded as `data:text/plain`, which
|
|
4
|
-
// costs twice: a browser with no `format()` hint in the `@font-face` rule has nothing left to
|
|
5
|
-
// identify the font by, and the SFZ writer deflates a file that is already Brotli-compressed
|
|
6
|
-
// because it decides compression from the content type.
|
|
7
|
-
//
|
|
8
|
-
// The rule now: a magic-byte match wins over the header, and the header is kept only when nothing
|
|
9
|
-
// matches. That raises the bar for the sniffer's own rules, which is why `video/mp2t` no longer
|
|
10
|
-
// matches on a single `0x47` byte — as a last resort behind a missing header that was tolerable,
|
|
11
|
-
// as an override of a correct header it would relabel any video whose first byte is `G`. It now
|
|
12
|
-
// wants the sync byte at the 188-byte packet stride, and the case below is the regression guard.
|
|
13
|
-
/* global Response */
|
|
14
|
-
|
|
15
|
-
import "./dom-stub.js";
|
|
16
|
-
// util.js pulls in the page-world hooks, which register a document listener as they are evaluated.
|
|
17
|
-
// None of it is exercised here; the stubs exist so that importing util.js is possible at all
|
|
18
|
-
globalThis.window = globalThis.window || {};
|
|
19
|
-
globalThis.document = globalThis.document || {};
|
|
20
|
-
globalThis.Document = globalThis.Document || class { };
|
|
21
|
-
globalThis.MutationObserver = globalThis.MutationObserver || class {
|
|
22
|
-
observe() { }
|
|
23
|
-
};
|
|
24
|
-
const { getInstance } = await import("./../../core/util.js");
|
|
25
|
-
|
|
26
|
-
const FONT_URL = "https://example.com/font";
|
|
27
|
-
const WOFF2 = bytes([0x77, 0x4F, 0x46, 0x32], 64);
|
|
28
|
-
const PNG = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 64);
|
|
29
|
-
const AVIF = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 64);
|
|
30
|
-
const WEBP2 = bytes([0x77, 0x70, 0x32, 0x20], 64);
|
|
31
|
-
const HEIF_MIF1 = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x69, 0x66, 0x31], 64);
|
|
32
|
-
const NOT_A_TRANSPORT_STREAM = bytes([0x47, 0x53, 0x54, 0x00], 512);
|
|
33
|
-
|
|
34
|
-
let failed = false;
|
|
35
|
-
|
|
36
|
-
// [label, bytes, expectedType, the Content-Type the server sent, the type that must reach the data URI]
|
|
37
|
-
const CASES = [
|
|
38
|
-
["a woff2 served as text/plain is embedded as font/woff2", WOFF2, "font", "text/plain;charset=UTF-8", "font/woff2"],
|
|
39
|
-
["a woff2 served as font/woff2 is unchanged", WOFF2, "font", "font/woff2", "font/woff2"],
|
|
40
|
-
["a woff2 served with no type at all is embedded as font/woff2", WOFF2, "font", undefined, "font/woff2"],
|
|
41
|
-
["a png served as text/plain is embedded as image/png", PNG, "image", "text/plain", "image/png"],
|
|
42
|
-
["an avif served as application/octet-stream is embedded as image/avif", AVIF, "image", "application/octet-stream", "image/avif"],
|
|
43
|
-
// "mif1" is the generic HEIF brand and an AVIF may carry it too, so it identifies nothing on
|
|
44
|
-
// its own: claiming HEIC here would relabel an AVIF as a format no browser decodes
|
|
45
|
-
["an ambiguous HEIF brand keeps the type the server sent", HEIF_MIF1, "image", "image/avif", "image/avif"],
|
|
46
|
-
// nothing matches these bytes, so the header is all there is and it has to survive
|
|
47
|
-
["a format the sniffer does not know keeps the type the server sent", WEBP2, "image", "image/webp2", "image/webp2"],
|
|
48
|
-
["a format the sniffer does not know and no type falls back to octet-stream", WEBP2, "image", undefined, "application/octet-stream"],
|
|
49
|
-
// the byte is 0x47, the packet stride is not, so this is not a transport stream
|
|
50
|
-
["a video starting with G is not relabelled as mp2t", NOT_A_TRANSPORT_STREAM, "video", "video/quicktime", "video/quicktime"]
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
for (const [label, data, expectedType, sentContentType, expectedContentType] of CASES) {
|
|
54
|
-
const { data: dataURI } = await fetchContent(data, expectedType, sentContentType);
|
|
55
|
-
check(label, readDataURIType(dataURI), expectedContentType);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
|
|
59
|
-
Deno.exit(failed ? 1 : 0);
|
|
60
|
-
|
|
61
|
-
function fetchContent(data, expectedType, contentType) {
|
|
62
|
-
const util = getInstance({
|
|
63
|
-
fetch: async () => new Response(data, { headers: contentType ? { "content-type": contentType } : {} })
|
|
64
|
-
});
|
|
65
|
-
return util.getContent(FONT_URL, { asBinary: true, inline: true, expectedType });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function readDataURIType(dataURI) {
|
|
69
|
-
const indexSeparator = dataURI.indexOf(";");
|
|
70
|
-
return dataURI.substring("data:".length, indexSeparator == -1 ? dataURI.indexOf(",") : indexSeparator);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function bytes(signature, length) {
|
|
74
|
-
const value = new Uint8Array(length);
|
|
75
|
-
value.set(signature);
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function check(label, actual, expected) {
|
|
80
|
-
const ok = actual === expected;
|
|
81
|
-
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
|
|
82
|
-
failed ||= !ok;
|
|
83
|
-
}
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
// removeUnusedFonts decides which @font-face rules to keep by reading the font-family declarations
|
|
2
|
-
// out of the stylesheets. When a family is written as var(--name), the value has to be resolved
|
|
3
|
-
// first, and the only resolution the module had was getComputedStyle(body).getPropertyValue(name).
|
|
4
|
-
// Custom properties inherit downward, so that answers for a property declared on :root and returns
|
|
5
|
-
// nothing for one declared on any descendant — and a single unresolved variable anywhere sets
|
|
6
|
-
// unusedFonts to [], which switches pruning off for the WHOLE document. One declaration moved from
|
|
7
|
-
// :root to .card took a probe page from 7,611 to 43,648 bytes with every unused font embedded.
|
|
8
|
-
//
|
|
9
|
-
// The fix keeps the computed value as the first answer and falls back to the union of every value
|
|
10
|
-
// the document declares for that property. The union is a superset of what any one element sees,
|
|
11
|
-
// so it can only keep too much, never too little — and options.usedFonts, which comes from the
|
|
12
|
-
// rendered computed styles, gates the result anyway.
|
|
13
|
-
//
|
|
14
|
-
// What happens when even the union cannot answer changed with it. Switching pruning off for the
|
|
15
|
-
// whole document was never a policy about uncertainty — a page naming its families plainly has
|
|
16
|
-
// always dropped a face it had not drawn yet, and only a page holding one unreadable value anywhere
|
|
17
|
-
// was spared, all of it. So the rendered list decides in that case too: a family the browser
|
|
18
|
-
// resolved when it drew the page is in that list whatever the stylesheets can be made to say. The
|
|
19
|
-
// one case that still keeps everything is the one where the rendering itself is missing.
|
|
20
|
-
//
|
|
21
|
-
// The other var() defects this pins, all found alongside it:
|
|
22
|
-
// - custom property names are case-sensitive; they were lowercased with the family names, so
|
|
23
|
-
// var(--ProbeFont) never resolved, not even from :root
|
|
24
|
-
// - the "font" shorthand's var() regexp required var(--name) exactly, so a fallback in it made
|
|
25
|
-
// the shorthand unreadable and switched pruning off
|
|
26
|
-
// - a var() fallback is one raw token, so a list in it was pushed as a single mangled name and
|
|
27
|
-
// the families it holds were dropped
|
|
28
|
-
import * as cssTree from "../../vendor/css-tree.js";
|
|
29
|
-
|
|
30
|
-
// the module reaches core/helper.js, which pulls in the frame hooks, and those install themselves
|
|
31
|
-
// against window and document as they are evaluated: the stubs go in before the dynamic import
|
|
32
|
-
globalThis.window = globalThis;
|
|
33
|
-
globalThis.document = {};
|
|
34
|
-
globalThis.Document = class Document { };
|
|
35
|
-
globalThis.MutationObserver = class MutationObserver { observe() { } };
|
|
36
|
-
const { process: removeUnusedFonts } = await import("../../modules/css-fonts-minifier.js");
|
|
37
|
-
|
|
38
|
-
const FONT_FACES = `
|
|
39
|
-
@font-face{font-family:"UsedOne";src:url(one.woff2)}
|
|
40
|
-
@font-face{font-family:"UsedTwo";src:url(two.woff2)}
|
|
41
|
-
@font-face{font-family:"UsedThree";src:url(three.woff2)}
|
|
42
|
-
@font-face{font-family:"Unused";src:url(unused.woff2)}`;
|
|
43
|
-
|
|
44
|
-
// what the rendered page reports as actually drawn: [family, weight, style, variant]. A family
|
|
45
|
-
// missing from this list is dropped whatever the stylesheets say, so it holds every family the
|
|
46
|
-
// tests below may legitimately keep
|
|
47
|
-
const USED_FONTS = [
|
|
48
|
-
["usedone", "400", "normal", "normal"],
|
|
49
|
-
["usedtwo", "400", "normal", "normal"],
|
|
50
|
-
["usedthree", "400", "normal", "normal"]
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
const ALL_FAMILIES = ["usedone", "usedtwo", "usedthree", "unused"];
|
|
54
|
-
|
|
55
|
-
// what survives when the stylesheets cannot say which family a value names: the faces the page
|
|
56
|
-
// declares AND the rendering reports having drawn with. It is USED_FONTS that decides there, so a
|
|
57
|
-
// face nothing drew is dropped even though nothing could be shown to name it either
|
|
58
|
-
const RENDERED_FAMILIES = ["usedone", "usedtwo", "usedthree"];
|
|
59
|
-
|
|
60
|
-
let failures = 0;
|
|
61
|
-
|
|
62
|
-
check("a plain family name prunes the rest",
|
|
63
|
-
run({ rules: ".card p{font-family:\"UsedOne\",serif}" }),
|
|
64
|
-
["usedone"]);
|
|
65
|
-
|
|
66
|
-
check("a property declared on :root resolves through the computed style",
|
|
67
|
-
run({ rules: ":root{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font),serif}", computed: { "--probe-font": "\"UsedOne\"" } }),
|
|
68
|
-
["usedone"]);
|
|
69
|
-
|
|
70
|
-
check("a property declared on a descendant resolves through the declared values",
|
|
71
|
-
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font),serif}" }),
|
|
72
|
-
["usedone"]);
|
|
73
|
-
|
|
74
|
-
check("a mixed-case property name is not lowercased away",
|
|
75
|
-
run({ rules: ".card{--ProbeFont:\"UsedOne\"}.card p{font-family:var(--ProbeFont),serif}" }),
|
|
76
|
-
["usedone"]);
|
|
77
|
-
|
|
78
|
-
check("a property declared inside a media query resolves too",
|
|
79
|
-
run({ rules: "@media screen{.card{--probe-font:\"UsedOne\"}}.card p{font-family:var(--probe-font),serif}" }),
|
|
80
|
-
["usedone"]);
|
|
81
|
-
|
|
82
|
-
check("several declared values are all kept as candidates",
|
|
83
|
-
run({ rules: ".card{--probe-font:\"UsedOne\"}.note{--probe-font:\"UsedTwo\"}p{font-family:var(--probe-font),serif}" }),
|
|
84
|
-
["usedone", "usedtwo"]);
|
|
85
|
-
|
|
86
|
-
check("a fallback list is parsed as a list, not as one name",
|
|
87
|
-
run({ rules: ":root{--probe-font:\"UsedOne\"}.card p{font-family:var(--probe-font,\"UsedTwo\",\"UsedThree\"),serif}", computed: { "--probe-font": "\"UsedOne\"" } }),
|
|
88
|
-
["usedone", "usedtwo", "usedthree"]);
|
|
89
|
-
|
|
90
|
-
check("the font shorthand resolves a uniquely declared property",
|
|
91
|
-
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font)}" }),
|
|
92
|
-
["usedone"]);
|
|
93
|
-
|
|
94
|
-
check("the font shorthand resolves a property written with a fallback",
|
|
95
|
-
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font:italic 1em var(--probe-font,serif)}" }),
|
|
96
|
-
["usedone"]);
|
|
97
|
-
|
|
98
|
-
// The other half of the contract: what happens when the value genuinely cannot be determined. The
|
|
99
|
-
// stylesheets no longer decide there — the rendering does. The browser resolved the value when it
|
|
100
|
-
// drew the page, so whatever the property named is in the list of fonts reported as used, and a
|
|
101
|
-
// face absent from that list was drawn by nothing.
|
|
102
|
-
//
|
|
103
|
-
// It used to keep EVERY declared face, and not as a decision about uncertainty: a page naming its
|
|
104
|
-
// families plainly has always dropped a face it had not drawn yet, and only a page holding one
|
|
105
|
-
// value that happened not to parse was spared — the whole document, over one unreadable name. The
|
|
106
|
-
// case these checks still guard is the one below them, where the rendering itself is unavailable.
|
|
107
|
-
check("a property declared nowhere falls back to the fonts that were drawn",
|
|
108
|
-
run({ rules: ".card p{font-family:var(--set-by-script),serif}" }),
|
|
109
|
-
RENDERED_FAMILIES);
|
|
110
|
-
|
|
111
|
-
// the shorthand cannot be substituted with several candidate values, but its var() sits in family
|
|
112
|
-
// position, so the parser hands it back as the family and the union answers it there instead
|
|
113
|
-
check("a font shorthand with several candidates resolves through the family",
|
|
114
|
-
run({ rules: ".card{--probe-font:\"UsedOne\"}.note{--probe-font:\"UsedTwo\"}p{font:italic 1em var(--probe-font)}" }),
|
|
115
|
-
["usedone", "usedtwo"]);
|
|
116
|
-
|
|
117
|
-
// a property holding the whole shorthand is the case the union must NOT touch: its values are not
|
|
118
|
-
// family lists, and reading them as such would name families the document never had
|
|
119
|
-
check("a property holding a whole shorthand falls back to the fonts that were drawn",
|
|
120
|
-
run({ rules: ".card{--font:italic 1em \"UsedOne\"}.note{--font:italic 1em \"UsedTwo\"}p{font:var(--font)}" }),
|
|
121
|
-
RENDERED_FAMILIES);
|
|
122
|
-
|
|
123
|
-
check("a var() nested in a fallback falls back to the fonts that were drawn",
|
|
124
|
-
run({ rules: ".card{--probe-font:\"UsedOne\"}.card p{font-family:var(--other,var(--probe-font),serif)}" }),
|
|
125
|
-
RENDERED_FAMILIES);
|
|
126
|
-
|
|
127
|
-
// the check above leaves the family undetermined because the OUTER property is declared nowhere.
|
|
128
|
-
// A var() written in the font-family itself is split by the AST walk, which hands each branch over
|
|
129
|
-
// separately, so nesting alone was never the problem there. The chain that did give up is a var()
|
|
130
|
-
// inside a property VALUE: it was read one level deep and whatever it named stayed unresolved,
|
|
131
|
-
// which switched pruning off for the whole document
|
|
132
|
-
check("a property whose value is another declared property resolves through it",
|
|
133
|
-
run({ rules: ".card{--first-font:var(--second-font)}.note{--second-font:\"UsedOne\"}.card p{font-family:var(--first-font),serif}" }),
|
|
134
|
-
["usedone"]);
|
|
135
|
-
|
|
136
|
-
// splitting that value on every comma cut this one into "var(--second-font" and "\"UsedTwo\")",
|
|
137
|
-
// two names that resolve to nothing, and the document went undetermined over a value it holds in full
|
|
138
|
-
check("a value holding a var() with its own fallback is split on the top-level comma",
|
|
139
|
-
run({ rules: ".card{--first-font:var(--second-font,\"UsedTwo\"),serif}.note{--second-font:\"UsedOne\"}.card p{font-family:var(--first-font)}" }),
|
|
140
|
-
["usedone", "usedtwo"]);
|
|
141
|
-
|
|
142
|
-
// two properties naming each other resolve for ever without the guard: this check hangs rather
|
|
143
|
-
// than fails when it regresses
|
|
144
|
-
check("a property naming itself through another one falls back to the fonts that were drawn",
|
|
145
|
-
run({ rules: ".card{--first-font:var(--second-font)}.note{--second-font:var(--first-font)}.card p{font-family:var(--first-font),serif}" }),
|
|
146
|
-
RENDERED_FAMILIES);
|
|
147
|
-
|
|
148
|
-
// The rendered-fonts list is what says a declared face is really drawn, and an EMPTY one is not
|
|
149
|
-
// the same answer as a short one: every rendered element has a computed font-family, so an empty
|
|
150
|
-
// list means the computed styles could not be read at all. It happens for real. A frame whose
|
|
151
|
-
// contentDocument is unreachable is re-parsed from its srcdoc with DOMParser
|
|
152
|
-
// (processors/frame-tree/content/content-frame-tree.js), and that document is never rendered, so
|
|
153
|
-
// it reports no font and the frame lost EVERY face it declared — measured on derstandard.at,
|
|
154
|
-
// where the newsletter box inside such a frame fell back to a system font, and on MDN, where the
|
|
155
|
-
// text in the CSS-demo frame reflowed. The families are named right there in the frame's own CSS.
|
|
156
|
-
check("a document that reports no rendered font keeps every font",
|
|
157
|
-
run({ rules: ".card p{font-family:\"UsedOne\",serif}", usedFonts: [] }),
|
|
158
|
-
ALL_FAMILIES);
|
|
159
|
-
|
|
160
|
-
check("a property whose value is another undetermined property falls back to the fonts that were drawn",
|
|
161
|
-
run({ rules: ".card{--probe-font:var(--set-by-script)}.card p{font-family:var(--probe-font),serif}" }),
|
|
162
|
-
RENDERED_FAMILIES);
|
|
163
|
-
|
|
164
|
-
// An unquoted family name is a sequence of identifier tokens, and the walk that joins them has to
|
|
165
|
-
// resume after the LAST of them. Resuming after the first pushed every word but that one again as a
|
|
166
|
-
// family in its own right, so "Helvetica Neue Light" also claimed fonts named "Neue Light" and
|
|
167
|
-
// "Light". It only ever kept too much, which is why it went unnoticed; a page declaring a family
|
|
168
|
-
// whose name is the tail of another one paid for it.
|
|
169
|
-
const TAIL_FACES = `
|
|
170
|
-
@font-face{font-family:"Helvetica Neue Light";src:url(one.woff2)}
|
|
171
|
-
@font-face{font-family:"Neue Light";src:url(two.woff2)}
|
|
172
|
-
@font-face{font-family:"Light";src:url(three.woff2)}`;
|
|
173
|
-
|
|
174
|
-
const TAIL_USED_FONTS = [
|
|
175
|
-
["helvetica neue light", "400", "normal", "normal"],
|
|
176
|
-
["neue light", "400", "normal", "normal"],
|
|
177
|
-
["light", "400", "normal", "normal"]
|
|
178
|
-
];
|
|
179
|
-
|
|
180
|
-
check("a multi-word family name is read whole",
|
|
181
|
-
run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:Helvetica Neue Light,serif}" }),
|
|
182
|
-
["helvetica neue light"]);
|
|
183
|
-
|
|
184
|
-
check("a quoted name is still matched by its quoted declaration",
|
|
185
|
-
run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:\"Neue Light\",serif}" }),
|
|
186
|
-
["neue light"]);
|
|
187
|
-
|
|
188
|
-
// the token that ends a name is not always the comma: a string or a number ends it too, and the
|
|
189
|
-
// walk used to append the token's absent `name` to the family as the text "undefined"
|
|
190
|
-
check("a name is ended by a token that is not an identifier",
|
|
191
|
-
run({ faces: TAIL_FACES, usedFonts: TAIL_USED_FONTS, rules: "p{font-family:Light \"Neue Light\"}" }),
|
|
192
|
-
["neue light", "light"]);
|
|
193
|
-
|
|
194
|
-
console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
|
|
195
|
-
Deno.exit(failures ? 1 : 0);
|
|
196
|
-
|
|
197
|
-
function run({ rules, computed, faces = FONT_FACES, usedFonts = USED_FONTS }) {
|
|
198
|
-
const stylesheet = cssTree.parse(faces + rules);
|
|
199
|
-
const options = { usedFonts };
|
|
200
|
-
const originalGetComputedStyle = globalThis.getComputedStyle;
|
|
201
|
-
if (computed) {
|
|
202
|
-
options.doc = { body: {} };
|
|
203
|
-
globalThis.getComputedStyle = () => ({ getPropertyValue: name => computed[name] || "" });
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
removeUnusedFonts(createStubDocument(), [{ stylesheet }], [], options);
|
|
207
|
-
} finally {
|
|
208
|
-
globalThis.getComputedStyle = originalGetComputedStyle;
|
|
209
|
-
}
|
|
210
|
-
const families = [];
|
|
211
|
-
stylesheet.children.forEach(ruleData => {
|
|
212
|
-
if (ruleData.type == "Atrule" && ruleData.name == "font-face") {
|
|
213
|
-
const declaration = ruleData.block.children.filter(node => node.property == "font-family").tail;
|
|
214
|
-
families.push(cssTree.generate(declaration.data.value).replace(/^"|"$/g, "").toLowerCase());
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
return families;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function check(label, actual, expected) {
|
|
221
|
-
const ok = actual.length == expected.length && actual.every((family, index) => family == expected[index]);
|
|
222
|
-
if (!ok) {
|
|
223
|
-
failures++;
|
|
224
|
-
}
|
|
225
|
-
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual.join(", ") || "(none)"}${ok ? "" : " (expected " + expected.join(", ") + ")"}`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// the module only reaches the document to borrow a <style> element for unescaping content values
|
|
229
|
-
// and to read the body text, so a full DOM is not needed here
|
|
230
|
-
function createStubDocument() {
|
|
231
|
-
return {
|
|
232
|
-
createElement: () => ({ textContent: "", remove() { } }),
|
|
233
|
-
body: { appendChild() { }, innerText: "" }
|
|
234
|
-
};
|
|
235
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
// The CSS-removal option runs every declaration through css-tree's lexer and drops the ones it
|
|
2
|
-
// judges invalid. css-tree reports two different failures the same way — a real value mismatch
|
|
3
|
-
// (SyntaxMatchError) and a property it simply has no entry for (SyntaxReferenceError) — so the
|
|
4
|
-
// original `!matchProperty(...).matched` deleted valid declarations whose property is missing from
|
|
5
|
-
// the vendored dictionary. That silently flattened every CSS-defined SVG gradient: stop-opacity was
|
|
6
|
-
// dropped, both stops became opaque, and the gradient rendered as a solid block. It hid for a long
|
|
7
|
-
// time because stop-color survived alongside it — a var() value takes a different branch and is
|
|
8
|
-
// never handed to the lexer at all, so the colour was right and only the fade was gone.
|
|
9
|
-
//
|
|
10
|
-
// The rule this pins: unknown must fail open. A dropped valid declaration breaks rendering; a kept
|
|
11
|
-
// invalid one is ignored by the browser.
|
|
12
|
-
import * as cssTree from "../../vendor/css-tree.js";
|
|
13
|
-
import { isUnsupportedPropertyValue, isUnsupportedVendorValue } from "../../modules/css-rules-minifier.js";
|
|
14
|
-
|
|
15
|
-
// valid declarations whose property the vendored css-tree does not know. Every one of these was
|
|
16
|
-
// deleted before the fix. The SVG paint-server and filter properties are the ones that matter in
|
|
17
|
-
// practice; the last two are here as the moving target — any property newer than the vendored
|
|
18
|
-
// dictionary lands in this bucket, and the list will keep growing
|
|
19
|
-
const MUST_KEEP = [
|
|
20
|
-
["stop-color", "#2a78d6"],
|
|
21
|
-
["stop-opacity", ".20"],
|
|
22
|
-
["stop-opacity", "0"],
|
|
23
|
-
["flood-color", "red"],
|
|
24
|
-
["flood-opacity", "0.5"],
|
|
25
|
-
["lighting-color", "white"],
|
|
26
|
-
["text-box-trim", "trim-both"],
|
|
27
|
-
["corner-shape", "squircle"]
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
// declarations css-tree knows and correctly rejects. These keep the filter honest: the fix must not
|
|
31
|
-
// turn the check off, only stop it firing on properties css-tree has never heard of
|
|
32
|
-
const MUST_DROP = [
|
|
33
|
-
["margin-trim", "block"],
|
|
34
|
-
["opacity", "not-a-number"],
|
|
35
|
-
["color", "12px"]
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
// properties close enough to the broken ones to be worth stating outright, because they are the
|
|
39
|
-
// reason the bug looked like an SVG quirk rather than a dictionary gap: css-tree does know these,
|
|
40
|
-
// so gradients that used them were unaffected and the failure looked arbitrary
|
|
41
|
-
const KNOWN_GOOD = [
|
|
42
|
-
["fill-opacity", "0.5"],
|
|
43
|
-
["stroke-opacity", "0.5"],
|
|
44
|
-
["fill", "red"],
|
|
45
|
-
["vector-effect", "non-scaling-stroke"]
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
let failed = false;
|
|
49
|
-
|
|
50
|
-
function check(label, actual, expected) {
|
|
51
|
-
const ok = actual === expected;
|
|
52
|
-
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
|
|
53
|
-
failed ||= !ok;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function isDropped(property, declaration) {
|
|
57
|
-
return isUnsupportedPropertyValue(property, cssTree.parse(declaration, { context: "value" }));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
for (const [property, declaration] of MUST_KEEP) {
|
|
61
|
-
check(`kept: ${property}: ${declaration}`, isDropped(property, declaration), false);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
for (const [property, declaration] of MUST_DROP) {
|
|
65
|
-
check(`dropped: ${property}: ${declaration}`, isDropped(property, declaration), true);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
for (const [property, declaration] of KNOWN_GOOD) {
|
|
69
|
-
check(`kept: ${property}: ${declaration}`, isDropped(property, declaration), false);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// the distinction the fix rests on. If a css-tree upgrade ever collapses these two error types into
|
|
73
|
-
// one, the predicate above cannot tell "unknown" from "wrong" any more and needs rewriting rather
|
|
74
|
-
// than adjusting
|
|
75
|
-
const unknownProperty = cssTree.lexer.matchProperty("stop-opacity", cssTree.parse("0", { context: "value" }));
|
|
76
|
-
const wrongValue = cssTree.lexer.matchProperty("margin-trim", cssTree.parse("block", { context: "value" }));
|
|
77
|
-
check("unknown property reports SyntaxReferenceError", unknownProperty.error && unknownProperty.error.name, "SyntaxReferenceError");
|
|
78
|
-
check("wrong value reports SyntaxMatchError", wrongValue.error && wrongValue.error.name, "SyntaxMatchError");
|
|
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
|
-
|
|
108
|
-
if (failed) {
|
|
109
|
-
console.log("FAILED");
|
|
110
|
-
Deno.exit(1);
|
|
111
|
-
} else {
|
|
112
|
-
console.log("PASSED");
|
|
113
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
// compression.js captures DOMParser from globalThis when the module is evaluated and Deno has
|
|
2
|
-
// none, so this module is imported before it. The stub is enough to tell whether the text body
|
|
3
|
-
// is written at all; it does not reproduce what a real DOM extraction produces.
|
|
4
|
-
globalThis.DOMParser = class {
|
|
5
|
-
parseFromString(content) {
|
|
6
|
-
return {
|
|
7
|
-
body: {
|
|
8
|
-
innerText: content.replace(/<[^>]*>/g, " "),
|
|
9
|
-
querySelectorAll: () => []
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
};
|