single-file-core 1.6.1 → 1.6.3

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 (79) hide show
  1. package/core/filename.js +62 -0
  2. package/core/helper.js +9 -32
  3. package/modules/css-fonts-minifier.js +34 -13
  4. package/modules/template-formatter.js +8 -2
  5. package/package.json +10 -2
  6. package/processors/compression/compression-constants.js +28 -0
  7. package/processors/compression/compression.js +3 -1
  8. package/.github/workflows/publish.yml +0 -88
  9. package/.github/workflows/test.yml +0 -31
  10. package/.github/workflows/vendor.yml +0 -39
  11. package/css-tree-build/README.md +0 -39
  12. package/css-tree-build/build.js +0 -66
  13. package/css-tree-build/package-lock.json +0 -511
  14. package/css-tree-build/package.json +0 -13
  15. package/deno.lock +0 -19
  16. package/doc/assets/singlefile-archive-byte-map.svg +0 -276
  17. package/doc/assets/singlefile-archive-lenses.svg +0 -245
  18. package/doc/singlefile-archive.md +0 -1968
  19. package/eslint.config.mjs +0 -74
  20. package/test/capture/README.md +0 -74
  21. package/test/capture/canonical-link.js +0 -50
  22. package/test/capture/common.js +0 -73
  23. package/test/capture/deferred-content-options.js +0 -71
  24. package/test/capture/dom.js +0 -26
  25. package/test/capture/maff-metadata.js +0 -205
  26. package/test/capture/resource-cap.js +0 -79
  27. package/test/capture/script-uri-sanitization.js +0 -82
  28. package/test/fixtures/README.md +0 -18
  29. package/test/fixtures/classic-digest.html +0 -5
  30. package/test/fixtures/generate-fixtures.sh +0 -31
  31. package/test/fixtures/multi-page-dedup.zip.html +0 -70
  32. package/test/fixtures/multi-page.zip.html +0 -26
  33. package/test/fixtures/single-page.zip.html +0 -11
  34. package/test/fixtures/site/multi-page/alpha.html +0 -2
  35. package/test/fixtures/site/multi-page/beta.html +0 -2
  36. package/test/fixtures/site/multi-page/index.html +0 -2
  37. package/test/fixtures/site/multi-page/sub/gamma.html +0 -2
  38. package/test/fixtures/site/multi-page-dedup/index.html +0 -2
  39. package/test/fixtures/site/multi-page-dedup/logo.png +0 -0
  40. package/test/fixtures/site/multi-page-dedup/one.html +0 -2
  41. package/test/fixtures/site/multi-page-dedup/shared.css +0 -3
  42. package/test/fixtures/site/multi-page-dedup/two.html +0 -2
  43. package/test/run.js +0 -109
  44. package/test/sfz-harness/README.md +0 -87
  45. package/test/sfz-harness/adopted-stylesheets-hook.js +0 -240
  46. package/test/sfz-harness/byte-map.js +0 -137
  47. package/test/sfz-harness/charset-round-trip.js +0 -161
  48. package/test/sfz-harness/check-determinism.js +0 -57
  49. package/test/sfz-harness/common.js +0 -154
  50. package/test/sfz-harness/content-type-sniffing.js +0 -83
  51. package/test/sfz-harness/css-fonts-minifier.js +0 -235
  52. package/test/sfz-harness/css-property-filter.js +0 -113
  53. package/test/sfz-harness/dom-stub.js +0 -13
  54. package/test/sfz-harness/entry-compression.js +0 -77
  55. package/test/sfz-harness/filename-characters.js +0 -55
  56. package/test/sfz-harness/filename-max-length.js +0 -69
  57. package/test/sfz-harness/font-face-composite.js +0 -135
  58. package/test/sfz-harness/format-rules.js +0 -617
  59. package/test/sfz-harness/gen-e2e-page.js +0 -14
  60. package/test/sfz-harness/inlined-functions.js +0 -82
  61. package/test/sfz-harness/option-wiring.js +0 -60
  62. package/test/sfz-harness/pages-archive.js +0 -318
  63. package/test/sfz-harness/pages-router.js +0 -143
  64. package/test/sfz-harness/relocation-cost.js +0 -94
  65. package/test/sfz-harness/search-triggers.js +0 -35
  66. package/test/sfz-harness/smoke.js +0 -10
  67. package/test/sfz-harness/stored-trigger.js +0 -95
  68. package/test/sfz-harness/trigger-seeds.json +0 -120
  69. package/test/sfz-harness/zip64.js +0 -77
  70. package/zip-build/README.md +0 -56
  71. package/zip-build/lib/fflate-streams.js +0 -47
  72. package/zip-build/lib/fflate.js +0 -2692
  73. package/zip-build/lib/zip-vendor-worker.js +0 -3
  74. package/zip-build/lib/zip-vendor.js +0 -9
  75. package/zip-build/lib/zip.js +0 -25
  76. package/zip-build/package-lock.json +0 -665
  77. package/zip-build/package.json +0 -15
  78. package/zip-build/reserved-property-names.json +0 -10630
  79. package/zip-build/rollup.config.js +0 -61
@@ -1,77 +0,0 @@
1
- // Whether an entry is deflated or stored was decided by its extension alone, and the extension is
2
- // only a guess: core/index.js takes it from the content type when the type is in its map and from
3
- // the URL otherwise. CONTENT_TYPE_EXTENSIONS has no entry for text/javascript, so a module served
4
- // as text/javascript from a ".ts" URL — every Vite dev server — was named ".ts", matched
5
- // NO_COMPRESSION_EXTENSIONS, and went into the archive uncompressed: measured on a real capture at
6
- // 2260 bytes where the same bytes named ".js" deflate to 70.
7
- //
8
- // The rule is now: a textual content type is authoritative when the server sent one, and the
9
- // extension list decides everything else. The second half is what the octet-stream rows pin — the
10
- // sniffer in core/util.js only covers image, font, video and audio, so an image format it cannot
11
- // recognize keeps application/octet-stream, and deflating it must stay off the table.
12
-
13
- import "./dom-stub.js";
14
- const { process } = await import("./../../processors/compression/compression.js");
15
- const { ZipReader, BlobReader } = await import("./../../vendor/zip/zip.js");
16
-
17
- const TEXT = "console.log(\"probe\");\n".repeat(100);
18
- const BINARY = "\x89PNG\r\n\x1a\n" + "\x00\x01\x02\x03".repeat(100);
19
-
20
- // [label, extension, contentType, expected compression]
21
- const CASES = [
22
- ["script named .ts, served as text/javascript", ".ts", "text/javascript", "deflate"],
23
- ["script named .js, served as text/javascript", ".js", "text/javascript", "deflate"],
24
- ["script named .ts, served as application/javascript", ".ts", "application/javascript", "deflate"],
25
- ["script named .php, no content type", ".php", undefined, "deflate"],
26
- ["image named .png, served as image/png", ".png", "image/png", "stored"],
27
- ["image named .png, no content type", ".png", undefined, "stored"],
28
- ["image named .avif, sniffing failed", ".avif", "application/octet-stream", "stored"],
29
- ["font named .woff2, served as font/woff2", ".woff2", "font/woff2", "stored"],
30
- ["image named .svg, served as image/svg+xml", ".svg", "image/svg+xml", "deflate"]
31
- ];
32
-
33
- let failed = false;
34
-
35
- function check(label, actual, expected) {
36
- const ok = actual === expected;
37
- console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
38
- failed ||= !ok;
39
- }
40
-
41
- async function getEntries(resource, options = {}) {
42
- const pageData = {
43
- title: "fixture",
44
- doctype: "<!DOCTYPE html>",
45
- content: "<html><body><p>fixture</p></body></html>",
46
- resources: { stylesheets: [], images: [resource] }
47
- };
48
- const blob = await process(pageData, {
49
- selfExtractingArchive: false,
50
- extractDataFromPage: false,
51
- url: "https://example.com/",
52
- ...options
53
- }, new Date(1755129600000));
54
- const reader = new ZipReader(new BlobReader(blob), { useWebWorkers: false });
55
- const entries = await reader.getEntries();
56
- await reader.close();
57
- return entries;
58
- }
59
-
60
- for (const [label, extension, contentType, expected] of CASES) {
61
- const content = expected == "deflate" ? TEXT : BINARY;
62
- const [entry] = (await getEntries({ name: "resource" + extension, extension, contentType, content }))
63
- .filter(({ filename }) => filename.startsWith("resource"));
64
- check(label, entry.compressionMethod === 0 ? "stored" : "deflate", expected);
65
- }
66
-
67
- // disableCompression still overrides everything, content type included
68
- const [storedEntry] = (await getEntries({ name: "resource.js", extension: ".js", contentType: "text/javascript", content: TEXT }, { disableCompression: true }))
69
- .filter(({ filename }) => filename.startsWith("resource"));
70
- check("disableCompression stores a text/javascript entry", storedEntry.compressionMethod === 0 ? "stored" : "deflate", "stored");
71
-
72
- if (failed) {
73
- console.log("FAILED");
74
- Deno.exit(1);
75
- } else {
76
- console.log("PASSED");
77
- }
@@ -1,55 +0,0 @@
1
- // getValidFilename replaced each character class with "[X]+", so a RUN collapsed to one character:
2
- // "C++" was saved as "C+" and "Really???" as "Really?". The quantifier was inherited rather than
3
- // chosen — before a842fe1 (issue #1614) there was a single loop mapping every invalid character to
4
- // one "_", where collapsing a run is the point. The full-width lookalike loop was written by
5
- // copying that line, and a lookalike maps one for one, so the run must survive.
6
- //
7
- // The fallback loop keeps its "+": a run of control characters still becomes a single "_", which
8
- // also matches the download retry ladder in the extensions, where the lookalike rung replaces per
9
- // character (LOOKALIKE_CHARACTERS) and the non-ASCII rung collapses ("[^\x00-\x7F]+").
10
-
11
- globalThis.window = globalThis;
12
- globalThis.document = {};
13
- globalThis.Document = class Document { };
14
- globalThis.MutationObserver = class MutationObserver { observe() { } };
15
- const { getValidFilename } = await import("./../../core/helper.js");
16
-
17
- // [input, expected]
18
- const CASES = [
19
- ["C++ vs C++", "C++ vs C++"],
20
- ["Really???", "Really???"],
21
- ["Why?? 50%%", "Why?? 50%%"],
22
- ["**bold** and ~~strike~~", "**bold** and ~~strike~~"],
23
- ["a:b::c", "a:b::c"],
24
- ["<<x>>", "<<x>>"],
25
- ["one ? here", "one ? here"],
26
- ["normal title", "normal title"],
27
- ["Wait... what?", "Wait... what?"]
28
- ];
29
-
30
- let failed = false;
31
-
32
- function check(label, actual, expected) {
33
- const ok = actual === expected;
34
- console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
35
- failed ||= !ok;
36
- }
37
-
38
- for (const [input, expected] of CASES) {
39
- check(JSON.stringify(input), getValidFilename(input), expected);
40
- }
41
-
42
- // the characters with no lookalike take the fallback, and there a run is still collapsed
43
- check("a run of control characters collapses to one replacement", getValidFilename("a\x00\x01\x02b"), "a_b");
44
- check("a control character run is collapsed, lookalikes next to it are not", getValidFilename("a\x00\x01b??"), "a_b??");
45
-
46
- // a custom mapping keeps both behaviours: one for one when a replacement is given, collapsed when not
47
- check("custom lookalike replaces per character", getValidFilename("a##b", ["#"], "_", ["#"]), "a##b");
48
- check("custom class without a lookalike collapses", getValidFilename("a##b", ["#"], "_", []), "a_b");
49
-
50
- if (failed) {
51
- console.log("FAILED");
52
- Deno.exit(1);
53
- } else {
54
- console.log("PASSED");
55
- }
@@ -1,69 +0,0 @@
1
- // formatFilename truncates an over-long filename to filenameMaxLength and then appends an ellipsis
2
- // and the extension, but the budget it truncated to only ever subtracted the extension. The
3
- // ellipsis was never counted, so every truncated filename came out over the limit — by one
4
- // character in "char" mode, by the 3 bytes of U+2026 in "bytes" mode. Harmless at the default 192,
5
- // but a user who sets the limit to the filesystem maximum of 255 gets 258 and the download is
6
- // refused, which sends the save down the download-util.js retry ladder for no reason.
7
- //
8
- // The negative budget was worse than an overrun. truncateText slices a Blob, and Blob.slice reads a
9
- // negative start as an offset from the END, so a limit smaller than the extension returned nearly
10
- // the whole 800-byte filename instead of nothing.
11
-
12
- // template-formatter.js reaches core/helper.js, which pulls in the frame hooks, and those install
13
- // themselves against window and document as they are evaluated: the stubs go in before the import
14
- globalThis.window = globalThis;
15
- globalThis.document = {};
16
- globalThis.Document = class Document { };
17
- globalThis.MutationObserver = class MutationObserver { observe() { } };
18
- const { formatFilename } = await import("../../modules/template-formatter.js");
19
-
20
- const LONG_TITLE = "a".repeat(400);
21
- const MULTIBYTE_TITLE = "é".repeat(400);
22
-
23
- let failed = false;
24
-
25
- function check(label, actual, expected) {
26
- const ok = actual === expected;
27
- console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
28
- failed ||= !ok;
29
- }
30
-
31
- async function getFilename(title, filenameMaxLengthUnit, filenameMaxLength) {
32
- return formatFilename("", null, {
33
- url: "https://example.com/page",
34
- filenameTemplate: title + ".html",
35
- filenameReplacementCharacter: "_",
36
- filenameReplacedCharacters: [],
37
- filenameReplacementCharacters: [],
38
- backgroundSave: true,
39
- filenameMaxLengthUnit,
40
- filenameMaxLength
41
- });
42
- }
43
-
44
- async function getSize(title, unit, maxLength) {
45
- const filename = await getFilename(title, unit, maxLength);
46
- return unit == "bytes" ? new Blob([filename]).size : filename.length;
47
- }
48
-
49
- for (const maxLength of [192, 255]) {
50
- check(`ascii title, ${maxLength} bytes`, await getSize(LONG_TITLE, "bytes", maxLength) <= maxLength, true);
51
- check(`multibyte title, ${maxLength} bytes`, await getSize(MULTIBYTE_TITLE, "bytes", maxLength) <= maxLength, true);
52
- check(`ascii title, ${maxLength} chars`, await getSize(LONG_TITLE, "char", maxLength) <= maxLength, true);
53
- check(`multibyte title, ${maxLength} chars`, await getSize(MULTIBYTE_TITLE, "char", maxLength) <= maxLength, true);
54
- }
55
-
56
- // the extension and the ellipsis alone are longer than the limit, so the result cannot fit. What it
57
- // must not do is grow: before the fix the byte case returned 812 bytes for a limit of 4
58
- check("limit shorter than the extension keeps only the suffix", await getFilename(LONG_TITLE, "bytes", 4), "….html");
59
- check("limit shorter than the extension, chars", await getFilename(LONG_TITLE, "char", 3), "….html");
60
-
61
- // nothing above the limit is truncated at all, ellipsis included
62
- check("a filename at the limit is left alone", await getFilename("a".repeat(187), "bytes", 192), "a".repeat(187) + ".html");
63
-
64
- if (failed) {
65
- console.log("FAILED");
66
- Deno.exit(1);
67
- } else {
68
- console.log("PASSED");
69
- }
@@ -1,135 +0,0 @@
1
- // Several @font-face rules that declare the same family with the same style descriptors are one
2
- // composite face, not a stack where the last rule wins. CSS Fonts 4 §5.2: "When the matched face is
3
- // a composite face, user agents must use the procedure above on each of the faces in the composite
4
- // face in reverse order of @font-face rule definition", selecting a face only "if the effective
5
- // character map supports the character in question". §4.5.1 says the same for the case where the
6
- // ranges are identical rather than merely overlapping: "If the unicode ranges overlap for a set of
7
- // @font-face rules with the same family and style descriptor values, the rules are ordered in the
8
- // reverse order they were defined; the last rule defined is the first to be checked for a given
9
- // character." So the later rule wins per character, and a character it has no glyph for falls back
10
- // to the earlier rule of the same family.
11
- //
12
- // Dropping the earlier rule therefore loses glyphs. science.org declares icomoon twice, weight 400
13
- // style normal in both, first a 103-codepoint icon set and then a 30-codepoint one holding only the
14
- // slideshow arrows. The banner close button is content:"\e928", which lives in the first font only,
15
- // so keeping the last rule alone rendered a tofu box reading "E9 28" where the X had been.
16
- //
17
- // Pooling the sources of every rule sharing a key breaks it the same way even when both rules are
18
- // kept: one winning source gets written into all of them, so both rules end up naming the same font
19
- // and the other one is gone just as surely. Each rule keeps its own sources.
20
- //
21
- // Rules that are duplicates outright, same key and same src, are still emitted once: they are the
22
- // same member of the composite declared twice, so dropping one changes nothing.
23
- import * as cssTree from "../../vendor/css-tree.js";
24
-
25
- // helper.js reaches the frame hooks, which install themselves against window and document as they
26
- // are evaluated: the stubs go in before the dynamic import
27
- globalThis.window = globalThis;
28
- globalThis.document = {};
29
- globalThis.Document = class Document { };
30
- globalThis.MutationObserver = class MutationObserver { observe() { } };
31
- const { getProcessorHelperCommonClass } = await import("../../core/lib/processor-helper-common.js");
32
-
33
- const ProcessorHelperCommon = getProcessorHelperCommonClass({}, cssTree);
34
-
35
- // the real subclasses pick one source out of the list and rewrite the rule with it; the contract
36
- // under test is which rules survive and which sources each one is handed, so this records that and
37
- // keeps every rule
38
- class TestProcessorHelper extends ProcessorHelperCommon {
39
- constructor() {
40
- super();
41
- this.processedRules = [];
42
- }
43
- async processFontFaceRule(ruleData, fontInfo) {
44
- this.processedRules.push({
45
- family: this.getPropertyValue(ruleData, "font-family"),
46
- sources: fontInfo.map(source => source.src)
47
- });
48
- return true;
49
- }
50
- }
51
-
52
- async function run(css) {
53
- const helper = new TestProcessorHelper();
54
- const stylesheets = new Map([[0, { stylesheet: cssTree.parse(css) }]]);
55
- await helper.removeAlternativeFonts({}, stylesheets, new Map(), new Map());
56
- const remaining = [];
57
- stylesheets.get(0).stylesheet.children.forEach(ruleData => {
58
- if (ruleData.type == "Atrule" && ruleData.name == "font-face") {
59
- remaining.push(helper.getPropertyValue(ruleData, "src"));
60
- }
61
- });
62
- return { processed: helper.processedRules, remaining };
63
- }
64
-
65
- let failures = 0;
66
-
67
- // a rule that was dropped when it should have been kept leaves a hole in the list, and reporting
68
- // that as a failed comparison is more use than throwing on the way to the assertion
69
- function sourcesOf(processedRules, index) {
70
- const rule = processedRules[index];
71
- return rule ? rule.sources : null;
72
- }
73
-
74
- function check(label, actual, expected) {
75
- const pass = JSON.stringify(actual) == JSON.stringify(expected);
76
- console.log((pass ? "PASS " : "FAIL ") + label + ": " + JSON.stringify(actual));
77
- if (!pass) {
78
- console.log(" expected: " + JSON.stringify(expected));
79
- failures++;
80
- }
81
- }
82
-
83
- const TWO_FACES = `
84
- @font-face{font-family:icomoon;src:url(big.ttf) format("truetype"),url(big.woff) format("woff");font-weight:400;font-style:normal}
85
- @font-face{font-family:icomoon;src:url(small.ttf) format("truetype"),url(small.woff) format("woff");font-weight:400;font-style:normal}`;
86
-
87
- const two = await run(TWO_FACES);
88
- check("both members of the composite face are kept", two.processed.length, 2);
89
- check("the earlier rule keeps its own sources", sourcesOf(two.processed, 0).sort(), ["url(big.ttf)format(\"truetype\")", "url(big.woff)format(\"woff\")"]);
90
- check("the later rule keeps its own sources", (sourcesOf(two.processed, 1) || []).sort(), ["url(small.ttf)format(\"truetype\")", "url(small.woff)format(\"woff\")"]);
91
- check("neither rule is removed from the stylesheet", two.remaining.length, 2);
92
-
93
- const DUPLICATE = `
94
- @font-face{font-family:icomoon;src:url(one.woff) format("woff");font-weight:400;font-style:normal}
95
- @font-face{font-family:icomoon;src:url(one.woff) format("woff");font-weight:400;font-style:normal}`;
96
-
97
- const duplicate = await run(DUPLICATE);
98
- check("an outright duplicate rule is emitted once", duplicate.processed.length, 1);
99
- check("the duplicate is removed from the stylesheet", duplicate.remaining.length, 1);
100
-
101
- // unicode-range is part of the font key, so the subsetting idiom was never affected by the
102
- // shadowing bug; it is pinned here because the fix moved what the key is used for
103
- const RANGES = `
104
- @font-face{font-family:sub;src:url(latin.woff2) format("woff2");unicode-range:U+0-7F}
105
- @font-face{font-family:sub;src:url(greek.woff2) format("woff2");unicode-range:U+370-3FF}`;
106
-
107
- const ranges = await run(RANGES);
108
- check("faces split by unicode-range are all kept", ranges.processed.length, 2);
109
- check("each range keeps its own source", ranges.processed.map(rule => rule.sources), [["url(latin.woff2)format(\"woff2\")"], ["url(greek.woff2)format(\"woff2\")"]]);
110
-
111
- // a rule declaring the same source twice contributes it once, in the position its later declaration
112
- // gives it. The sources are compared after the separating comma is stripped, so a repeat is
113
- // recognised wherever it sits: the value is split by a regexp that keeps that comma, and comparing
114
- // the raw pieces made the last source in a list unequal to the same source anywhere before it
115
- const REPEATED = `
116
- @font-face{font-family:repeat;src:url(a.woff) format("woff"),url(b.woff) format("woff"),url(a.woff) format("woff"),url(c.woff) format("woff");font-weight:400}`;
117
-
118
- const repeated = await run(REPEATED);
119
- check("a source repeated inside one rule is listed once", (sourcesOf(repeated.processed, 0) || []).length, 3);
120
-
121
- const REPEATED_LAST = `
122
- @font-face{font-family:repeat;src:url(a.woff) format("woff"),url(b.woff) format("woff"),url(a.woff) format("woff");font-weight:400}`;
123
-
124
- const repeatedLast = await run(REPEATED_LAST);
125
- check("a repeat in last position is recognised too", (sourcesOf(repeatedLast.processed, 0) || []).length, 2);
126
- // the list is held in reverse of the order it is written back in, so the entry the rule declares
127
- // last comes first here: a.woff keeps the position its second declaration gives it
128
- check("the repeat keeps the position of its later declaration", sourcesOf(repeatedLast.processed, 0), ["url(a.woff)format(\"woff\")", "url(b.woff)format(\"woff\")"]);
129
-
130
- if (failures) {
131
- console.log("\n" + failures + " check(s) failed");
132
- Deno.exit(1);
133
- } else {
134
- console.log("\nall checks passed");
135
- }