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.
Files changed (77) hide show
  1. package/core/helper.js +5 -2
  2. package/modules/css-fonts-minifier.js +34 -13
  3. package/modules/template-formatter.js +8 -2
  4. package/package.json +10 -2
  5. package/vendor/zip/zip.js +4 -0
  6. package/.github/workflows/publish.yml +0 -88
  7. package/.github/workflows/test.yml +0 -31
  8. package/.github/workflows/vendor.yml +0 -39
  9. package/css-tree-build/README.md +0 -39
  10. package/css-tree-build/build.js +0 -66
  11. package/css-tree-build/package-lock.json +0 -511
  12. package/css-tree-build/package.json +0 -13
  13. package/deno.lock +0 -19
  14. package/doc/assets/singlefile-archive-byte-map.svg +0 -276
  15. package/doc/assets/singlefile-archive-lenses.svg +0 -245
  16. package/doc/singlefile-archive.md +0 -1968
  17. package/eslint.config.mjs +0 -74
  18. package/test/capture/README.md +0 -74
  19. package/test/capture/canonical-link.js +0 -50
  20. package/test/capture/common.js +0 -73
  21. package/test/capture/deferred-content-options.js +0 -71
  22. package/test/capture/dom.js +0 -26
  23. package/test/capture/maff-metadata.js +0 -205
  24. package/test/capture/resource-cap.js +0 -79
  25. package/test/capture/script-uri-sanitization.js +0 -82
  26. package/test/fixtures/README.md +0 -18
  27. package/test/fixtures/classic-digest.html +0 -5
  28. package/test/fixtures/generate-fixtures.sh +0 -31
  29. package/test/fixtures/multi-page-dedup.zip.html +0 -70
  30. package/test/fixtures/multi-page.zip.html +0 -26
  31. package/test/fixtures/single-page.zip.html +0 -11
  32. package/test/fixtures/site/multi-page/alpha.html +0 -2
  33. package/test/fixtures/site/multi-page/beta.html +0 -2
  34. package/test/fixtures/site/multi-page/index.html +0 -2
  35. package/test/fixtures/site/multi-page/sub/gamma.html +0 -2
  36. package/test/fixtures/site/multi-page-dedup/index.html +0 -2
  37. package/test/fixtures/site/multi-page-dedup/logo.png +0 -0
  38. package/test/fixtures/site/multi-page-dedup/one.html +0 -2
  39. package/test/fixtures/site/multi-page-dedup/shared.css +0 -3
  40. package/test/fixtures/site/multi-page-dedup/two.html +0 -2
  41. package/test/run.js +0 -109
  42. package/test/sfz-harness/README.md +0 -87
  43. package/test/sfz-harness/adopted-stylesheets-hook.js +0 -240
  44. package/test/sfz-harness/byte-map.js +0 -137
  45. package/test/sfz-harness/charset-round-trip.js +0 -161
  46. package/test/sfz-harness/check-determinism.js +0 -57
  47. package/test/sfz-harness/common.js +0 -154
  48. package/test/sfz-harness/content-type-sniffing.js +0 -83
  49. package/test/sfz-harness/css-fonts-minifier.js +0 -235
  50. package/test/sfz-harness/css-property-filter.js +0 -113
  51. package/test/sfz-harness/dom-stub.js +0 -13
  52. package/test/sfz-harness/entry-compression.js +0 -77
  53. package/test/sfz-harness/filename-characters.js +0 -55
  54. package/test/sfz-harness/filename-max-length.js +0 -69
  55. package/test/sfz-harness/font-face-composite.js +0 -135
  56. package/test/sfz-harness/format-rules.js +0 -617
  57. package/test/sfz-harness/gen-e2e-page.js +0 -14
  58. package/test/sfz-harness/inlined-functions.js +0 -82
  59. package/test/sfz-harness/option-wiring.js +0 -60
  60. package/test/sfz-harness/pages-archive.js +0 -318
  61. package/test/sfz-harness/pages-router.js +0 -143
  62. package/test/sfz-harness/relocation-cost.js +0 -94
  63. package/test/sfz-harness/search-triggers.js +0 -35
  64. package/test/sfz-harness/smoke.js +0 -10
  65. package/test/sfz-harness/stored-trigger.js +0 -95
  66. package/test/sfz-harness/trigger-seeds.json +0 -120
  67. package/test/sfz-harness/zip64.js +0 -77
  68. package/zip-build/README.md +0 -56
  69. package/zip-build/lib/fflate-streams.js +0 -47
  70. package/zip-build/lib/fflate.js +0 -2692
  71. package/zip-build/lib/zip-vendor-worker.js +0 -3
  72. package/zip-build/lib/zip-vendor.js +0 -3
  73. package/zip-build/lib/zip.js +0 -25
  74. package/zip-build/package-lock.json +0 -665
  75. package/zip-build/package.json +0 -15
  76. package/zip-build/reserved-property-names.json +0 -10630
  77. 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
- };