single-file-core 1.5.128 → 1.5.130
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/index.js +32 -19
- package/core/infobar.js +35 -2
- package/core/lib/processor-helper-inline.js +1 -1
- package/core/lib/processor-helper.js +1 -1
- package/deno.lock +19 -0
- package/doc/singlefile-archive.md +25 -7
- package/eslint.config.mjs +14 -0
- package/package.json +2 -2
- package/processors/compression/compression-packager.js +4 -4
- package/processors/compression/compression.js +2 -1
- package/single-file.js +1 -1
- package/test/capture/README.md +74 -0
- package/test/capture/canonical-link.js +50 -0
- package/test/capture/common.js +72 -0
- package/test/capture/dom.js +26 -0
- package/test/capture/maff-metadata.js +205 -0
- package/test/capture/resource-cap.js +79 -0
- package/test/capture/script-uri-sanitization.js +82 -0
- package/test/run.js +109 -0
- package/test/sfz-harness/README.md +13 -2
- package/test/sfz-harness/pages-archive.js +41 -5
- package/test/sfz-harness/pages-router.js +27 -1
- package/test/sfz-harness/relocation-cost.js +94 -0
- package/vendor/zip/z-worker.js +1 -1
- package/vendor/zip/zip.js +706 -950
- package/vendor/zip/zip.min.js +1 -1
- package/zip-build/package-lock.json +4 -4
- package/zip-build/package.json +1 -1
- package/zip-build/reserved-property-names.json +4 -0
package/core/index.js
CHANGED
|
@@ -31,6 +31,7 @@ const DEBUG = false;
|
|
|
31
31
|
const Set = globalThis.Set;
|
|
32
32
|
const Map = globalThis.Map;
|
|
33
33
|
const JSON = globalThis.JSON;
|
|
34
|
+
const URL = globalThis.URL;
|
|
34
35
|
|
|
35
36
|
let util;
|
|
36
37
|
|
|
@@ -160,7 +161,7 @@ const STAGES = [{
|
|
|
160
161
|
{ action: "cleanupPage" }
|
|
161
162
|
],
|
|
162
163
|
parallel: [
|
|
163
|
-
{ option: "
|
|
164
|
+
{ option: "readMaffMetadata", action: "readMAFFMetaData" },
|
|
164
165
|
{ action: "setDocInfo" }
|
|
165
166
|
]
|
|
166
167
|
}, {
|
|
@@ -421,6 +422,9 @@ const SHADOWROOT_DELEGATES_FOCUS = "shadowrootdelegatesfocus";
|
|
|
421
422
|
const SHADOWROOT_CLONABLE = "shadowrootclonable";
|
|
422
423
|
const SHADOWROOT_SERIALIZABLE = "shadowrootserializable";
|
|
423
424
|
const SCRIPT_OPTIONS = "data-single-file-options";
|
|
425
|
+
const JAVASCRIPT_URI_PROTOCOL = "javascript:";
|
|
426
|
+
const DISABLED_SCRIPT_URI = "javascript:void(0)";
|
|
427
|
+
const SCRIPT_URI_ATTRIBUTE_NAMES = ["href", "src", "action", "formaction", "data"];
|
|
424
428
|
const UTF8_CHARSET = "utf-8";
|
|
425
429
|
const TAINTED_CANVAS_WARNING_MESSAGE = "SingleFile: canvas elements tainted by a cross-origin resource, dropped from the page:";
|
|
426
430
|
|
|
@@ -448,7 +452,7 @@ class Processor {
|
|
|
448
452
|
initialize() {
|
|
449
453
|
this.options.saveDate = new Date();
|
|
450
454
|
this.options.saveUrl = this.options.url;
|
|
451
|
-
if (this.options.
|
|
455
|
+
if (this.options.readMaffMetadata) {
|
|
452
456
|
this.maffMetaDataPromise = this.batchRequest.addURL(util.resolveURL("index.rdf", this.options.baseURI || this.options.url), { expectedType: "document" });
|
|
453
457
|
}
|
|
454
458
|
this.maxResources = this.batchRequest.getMaxResources();
|
|
@@ -464,7 +468,7 @@ class Processor {
|
|
|
464
468
|
content = await util.getContent(this.baseURI, {
|
|
465
469
|
inline: !this.options.compressContent,
|
|
466
470
|
maxResourceSize: this.options.maxResourceSize,
|
|
467
|
-
maxResourceSizeEnabled: this.options.maxResourceSizeEnabled,
|
|
471
|
+
maxResourceSizeEnabled: this.options.maxResourceSizeEnabled && !this.options.rootDocument,
|
|
468
472
|
charset,
|
|
469
473
|
frameId: this.options.windowId,
|
|
470
474
|
resourceReferrer: this.options.resourceReferrer,
|
|
@@ -497,7 +501,7 @@ class Processor {
|
|
|
497
501
|
}
|
|
498
502
|
this.workStyleElement = this.doc.createElement("style");
|
|
499
503
|
this.doc.body.appendChild(this.workStyleElement);
|
|
500
|
-
this.onEventAttributeNames = getOnEventAttributeNames(this.doc);
|
|
504
|
+
this.onEventAttributeNames = new Set(getOnEventAttributeNames(this.doc));
|
|
501
505
|
}
|
|
502
506
|
|
|
503
507
|
finalize() {
|
|
@@ -676,7 +680,7 @@ class Processor {
|
|
|
676
680
|
optionsElement.type = "application/json";
|
|
677
681
|
optionsElement.setAttribute(SCRIPT_OPTIONS, "");
|
|
678
682
|
optionsElement.textContent = JSON.stringify({
|
|
679
|
-
saveUrl: this.options.
|
|
683
|
+
saveUrl: this.options.saveUrl,
|
|
680
684
|
saveDate: this.options.saveDate.getTime(),
|
|
681
685
|
visitDate: (this.options.visitDate || this.options.saveDate).getTime(),
|
|
682
686
|
filenameTemplate: this.options.filenameTemplate,
|
|
@@ -852,18 +856,16 @@ class Processor {
|
|
|
852
856
|
}
|
|
853
857
|
|
|
854
858
|
removeEmbedScripts() {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
element.setAttribute("src", DISABLED_SCRIPT);
|
|
866
|
-
}
|
|
859
|
+
this.doc.querySelectorAll("*").forEach(element => {
|
|
860
|
+
Array.from(element.attributes).forEach(attribute => {
|
|
861
|
+
const localName = attribute.localName || attribute.name;
|
|
862
|
+
const attributeName = localName.toLowerCase();
|
|
863
|
+
if (this.onEventAttributeNames.has(attributeName)) {
|
|
864
|
+
element.removeAttributeNS(attribute.namespaceURI, localName);
|
|
865
|
+
} else if (SCRIPT_URI_ATTRIBUTE_NAMES.includes(attributeName) && isScriptURI(attribute.value)) {
|
|
866
|
+
element.setAttributeNS(attribute.namespaceURI, attribute.name, DISABLED_SCRIPT_URI);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
867
869
|
});
|
|
868
870
|
const scriptElements = this.doc.querySelectorAll("script:not([type=\"application/ld+json\"]):not([" + SCRIPT_OPTIONS + "])");
|
|
869
871
|
this.stats.set("discarded", "scripts", scriptElements.length);
|
|
@@ -1647,7 +1649,7 @@ class Processor {
|
|
|
1647
1649
|
this.doc.documentElement.style.removeProperty("-sf-min-height");
|
|
1648
1650
|
}
|
|
1649
1651
|
|
|
1650
|
-
async
|
|
1652
|
+
async readMAFFMetaData() {
|
|
1651
1653
|
const maffMetaData = await this.maffMetaDataPromise;
|
|
1652
1654
|
if (maffMetaData && maffMetaData.content) {
|
|
1653
1655
|
const NAMESPACE_RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
|
|
@@ -1655,7 +1657,10 @@ class Processor {
|
|
|
1655
1657
|
const originalURLElement = maffDoc.querySelector("RDF > Description > originalurl");
|
|
1656
1658
|
const archiveTimeElement = maffDoc.querySelector("RDF > Description > archivetime");
|
|
1657
1659
|
if (originalURLElement) {
|
|
1658
|
-
|
|
1660
|
+
const value = originalURLElement.getAttributeNS(NAMESPACE_RDF, "resource");
|
|
1661
|
+
if (value) {
|
|
1662
|
+
this.options.saveUrl = value;
|
|
1663
|
+
}
|
|
1659
1664
|
}
|
|
1660
1665
|
if (archiveTimeElement) {
|
|
1661
1666
|
const value = archiveTimeElement.getAttributeNS(NAMESPACE_RDF, "resource");
|
|
@@ -1709,6 +1714,14 @@ function normalizeURL(url) {
|
|
|
1709
1714
|
}
|
|
1710
1715
|
}
|
|
1711
1716
|
|
|
1717
|
+
function isScriptURI(value) {
|
|
1718
|
+
try {
|
|
1719
|
+
return new URL(value).protocol == JAVASCRIPT_URI_PROTOCOL;
|
|
1720
|
+
} catch {
|
|
1721
|
+
return false;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1712
1725
|
function getOnEventAttributeNames(doc) {
|
|
1713
1726
|
const element = doc.body || doc.createElement("div");
|
|
1714
1727
|
const attributeNames = [];
|
package/core/infobar.js
CHANGED
|
@@ -75,6 +75,21 @@ const INFOBAR_STYLES = `
|
|
|
75
75
|
animation-iteration-count: 2;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
.infobar:not(:focus-within):not(.infobar-focus)::after {
|
|
79
|
+
content: "";
|
|
80
|
+
position: absolute;
|
|
81
|
+
inset: -2px;
|
|
82
|
+
border: 2px solid #dd6a00;
|
|
83
|
+
border-radius: inherit;
|
|
84
|
+
opacity: 0;
|
|
85
|
+
pointer-events: none;
|
|
86
|
+
animation-name: ripple;
|
|
87
|
+
animation-duration: 3s;
|
|
88
|
+
animation-timing-function: ease-out;
|
|
89
|
+
animation-delay: 2s;
|
|
90
|
+
animation-iteration-count: 3;
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
.infobar:valid, .infobar:not(:focus-within):not(.infobar-focus) .infobar-content {
|
|
79
94
|
display: none;
|
|
80
95
|
}
|
|
@@ -126,10 +141,28 @@ const INFOBAR_STYLES = `
|
|
|
126
141
|
|
|
127
142
|
@keyframes flash {
|
|
128
143
|
0%, 100% {
|
|
129
|
-
|
|
144
|
+
background-color: #737373;
|
|
130
145
|
}
|
|
131
146
|
50% {
|
|
132
|
-
|
|
147
|
+
background-color: #dd6a00;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@keyframes ripple {
|
|
152
|
+
0% {
|
|
153
|
+
transform: scale(1);
|
|
154
|
+
opacity: 1;
|
|
155
|
+
}
|
|
156
|
+
45%, 100% {
|
|
157
|
+
transform: scale(2);
|
|
158
|
+
opacity: 0;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@media (prefers-reduced-motion: reduce) {
|
|
163
|
+
.infobar,
|
|
164
|
+
.infobar:not(:focus-within):not(.infobar-focus)::after {
|
|
165
|
+
animation-name: none;
|
|
133
166
|
}
|
|
134
167
|
}
|
|
135
168
|
|
|
@@ -562,7 +562,7 @@ function getProcessorHelperClass(utilInstance) {
|
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
setMetaCSP(metaElement) {
|
|
565
|
-
metaElement.content = "default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:; object-src 'self' data:; frame-src 'self' data:;";
|
|
565
|
+
metaElement.content = "default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:; object-src 'self' data:; frame-src 'self' data:; form-action 'none'; base-uri 'none';";
|
|
566
566
|
}
|
|
567
567
|
|
|
568
568
|
removeUnusedStylesheets(doc) {
|
|
@@ -488,7 +488,7 @@ function getProcessorHelperClass(utilInstance) {
|
|
|
488
488
|
}
|
|
489
489
|
|
|
490
490
|
setMetaCSP(metaElement) {
|
|
491
|
-
metaElement.content = "default-src 'none'; connect-src 'self' data: blob:; font-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline' data: blob:; frame-src 'self' data: blob:; media-src 'self' data: blob:; script-src 'self' 'unsafe-inline' data: blob:; object-src 'self' data: blob:;";
|
|
491
|
+
metaElement.content = "default-src 'none'; connect-src 'self' data: blob:; font-src 'self' data: blob:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline' data: blob:; frame-src 'self' data: blob:; media-src 'self' data: blob:; script-src 'self' 'unsafe-inline' data: blob:; object-src 'self' data: blob:; form-action 'none'; base-uri 'none';";
|
|
492
492
|
}
|
|
493
493
|
|
|
494
494
|
removeUnusedStylesheets() {
|
package/deno.lock
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "5",
|
|
3
|
+
"specifiers": {
|
|
4
|
+
"jsr:@b-fuze/deno-dom@0.1.56": "0.1.56"
|
|
5
|
+
},
|
|
6
|
+
"jsr": {
|
|
7
|
+
"@b-fuze/deno-dom@0.1.56": {
|
|
8
|
+
"integrity": "8030e2dc1d8750f1682b53462ab893d9c3470f2287feecbe22f44a88c54ab148"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"workspace": {
|
|
12
|
+
"packageJson": {
|
|
13
|
+
"dependencies": [
|
|
14
|
+
"npm:@eslint/js@^9.39.5",
|
|
15
|
+
"npm:eslint@^10.9.1"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -1079,13 +1079,31 @@ trailing bytes open it (§8.1). The parser closes the open
|
|
|
1079
1079
|
comment or element at end of file, and `</body></html>` are implied, so the page
|
|
1080
1080
|
renders the same.
|
|
1081
1081
|
|
|
1082
|
-
Relocation
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1082
|
+
Relocation moves the element rather than copying it, but it is not a move at constant
|
|
1083
|
+
size, and wherever there is an element to move it costs bytes. The appended placement
|
|
1084
|
+
emits the wrapper terminator, the element and the end tags, the element plus 17; the
|
|
1085
|
+
relocated placement emits none of those and reserves room ahead of the archive instead,
|
|
1086
|
+
`Math.ceil(length * 1.01) + 32` bytes in the reference writer, where *length* is the
|
|
1087
|
+
element with its tags. The net is that reservation less the element and less the 17
|
|
1088
|
+
bytes, so about one percent of the element plus fifteen: what relocation costs is the
|
|
1089
|
+
margin, not a second copy. Measured on elements from 61 to 17577 bytes the formula holds
|
|
1090
|
+
to within a few bytes, the residual being the element itself changing length between the
|
|
1091
|
+
two passes, since the reservation lengthens the prologue and moves every
|
|
1092
|
+
central-directory offset with it. The wrapper rung sets the constant: fifteen bytes
|
|
1093
|
+
behind a comment, nine behind `</script>` or `]]></svg>`, six behind `</plaintext>`.
|
|
1094
|
+
With extraction disabled there is no element and nothing is reserved, so suppressing the
|
|
1095
|
+
appended run drops those 17 bytes and nothing else.
|
|
1096
|
+
|
|
1097
|
+
The two cases a writer meets differ by an order of magnitude, and the budget is what
|
|
1098
|
+
separates them. A relocation forced by `preventAppendedData` acts on whatever element
|
|
1099
|
+
exists, which on a small archive is small: 16 bytes on a 2848-byte ZIP region, 35 bytes
|
|
1100
|
+
on a 1.3 MB one. A relocation the budget triggers cannot be cheap, because it happens
|
|
1101
|
+
only once the element no longer fits: at the default 16361 that means an element past
|
|
1102
|
+
16344 bytes, and 185 bytes measured on a 12.7 MB region is near the least it can cost.
|
|
1103
|
+
It keeps rising from there, since a relocated element sits in the prologue and no comment
|
|
1104
|
+
ceiling bounds it — at the ratio above, a 40 MB archive carries roughly 57 KB of element
|
|
1105
|
+
and costs roughly 590 bytes. A writer sizing a file should compute the cost from the
|
|
1106
|
+
element it produced rather than quote any of these figures.
|
|
1089
1107
|
|
|
1090
1108
|
### 5.3 Offset bookkeeping
|
|
1091
1109
|
|
package/eslint.config.mjs
CHANGED
|
@@ -56,5 +56,19 @@ export default [
|
|
|
56
56
|
rules: {
|
|
57
57
|
"no-console": "off"
|
|
58
58
|
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
files: ["test/capture/**", "test/run.js"],
|
|
62
|
+
languageOptions: {
|
|
63
|
+
globals: {
|
|
64
|
+
Deno: "readonly",
|
|
65
|
+
Response: "readonly",
|
|
66
|
+
TextDecoder: "readonly",
|
|
67
|
+
URL: "readonly"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
rules: {
|
|
71
|
+
"no-console": "off"
|
|
72
|
+
}
|
|
59
73
|
}
|
|
60
74
|
];
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "single-file-core",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.130",
|
|
4
4
|
"description": "SingleFile Core",
|
|
5
5
|
"author": "Gildas Lormeau",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "deno run --allow-read
|
|
8
|
+
"test": "deno run --allow-read --allow-run test/run.js",
|
|
9
9
|
"bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
|
|
10
10
|
"bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
|
|
11
11
|
"bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
|
|
@@ -67,7 +67,7 @@ async function createPagesArchive(pages, options) {
|
|
|
67
67
|
}
|
|
68
68
|
const manifest = {
|
|
69
69
|
pages: pages.map((page, pageIndex) => ({
|
|
70
|
-
path: getPagePath(pageIndex),
|
|
70
|
+
path: getPagePath(pageIndex, options.createRootDirectory),
|
|
71
71
|
url: page.url,
|
|
72
72
|
originalUrls: page.originalUrls,
|
|
73
73
|
title: page.title
|
|
@@ -97,7 +97,7 @@ async function createPagesArchive(pages, options) {
|
|
|
97
97
|
const aliases = {};
|
|
98
98
|
const blob = await createArchive(pageData, archiveOptions, options.zipScript, async zipWriter => {
|
|
99
99
|
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
|
100
|
-
const pagePath = getPagePath(pageIndex);
|
|
100
|
+
const pagePath = getPagePath(pageIndex, options.createRootDirectory);
|
|
101
101
|
const zipReader = new ZipReader(new Uint8ArrayReader(await pages[pageIndex].getData()));
|
|
102
102
|
for (const entry of await zipReader.getEntries()) {
|
|
103
103
|
const filename = pagePath + entry.filename;
|
|
@@ -163,8 +163,8 @@ function getRelativePath(filename, targetFilename) {
|
|
|
163
163
|
return "../".repeat(baseSegments.length) + targetSegments.join("/");
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function getPagePath(pageIndex) {
|
|
167
|
-
return pageIndex == 0 ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
|
|
166
|
+
function getPagePath(pageIndex, createRootDirectory) {
|
|
167
|
+
return pageIndex == 0 && !createRootDirectory ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
function getComment(url, options) {
|
package/single-file.js
CHANGED
|
@@ -91,7 +91,7 @@ async function getPageData(options = {}, initOptions, doc, win) {
|
|
|
91
91
|
}
|
|
92
92
|
options.doc = doc;
|
|
93
93
|
options.win = win;
|
|
94
|
-
options.insertCanonicalLink = true;
|
|
94
|
+
options.insertCanonicalLink = options.insertCanonicalLink === undefined ? true : options.insertCanonicalLink;
|
|
95
95
|
|
|
96
96
|
const externalOnProgress = options.onprogress;
|
|
97
97
|
options.onprogress = async event => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Capture harness
|
|
2
|
+
|
|
3
|
+
Tests that drive the real capture pipeline — `getPageData()`, `Processor`, `loadPage`, the batch
|
|
4
|
+
fetch layer — in Deno, with an injected fetch and a parser instead of a browser. Every resource a
|
|
5
|
+
capture asks for is served from a map declared in the suite, so there is no network and no page.
|
|
6
|
+
|
|
7
|
+
It exists because the [SFZ harness](../sfz-harness/README.md) next door covers the archive writer and
|
|
8
|
+
its neighbours, and nothing covered `core/index.js`. A defect in the capture pipeline could only be
|
|
9
|
+
caught by driving Chrome from `single-file-cli`, in another repository, against a published build.
|
|
10
|
+
|
|
11
|
+
Run them with Deno, from the repository root:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npm test
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
or this directory alone, through the same runner:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
deno run --allow-read --allow-run test/run.js capture
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
or one suite at a time, which needs no runner:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
deno run --allow-read test/capture/resource-cap.js
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`common.js` and `dom.js` are named in the runner's `NOT_SUITES` list because they assert
|
|
30
|
+
nothing. Every other `.js` file here is run.
|
|
31
|
+
|
|
32
|
+
Unlike the SFZ harness, these download `@b-fuze/deno-dom` from JSR, so a cold cache needs network.
|
|
33
|
+
The version is pinned in `dom.js` and `deno.lock` carries its integrity hash, so a cold run fetches
|
|
34
|
+
that exact build or fails. `test/*` is ignored by `.gitignore` with one exception per directory, so a
|
|
35
|
+
new test directory needs its own `!` line or nothing in it is ever committed.
|
|
36
|
+
|
|
37
|
+
## The suites
|
|
38
|
+
|
|
39
|
+
| Script | What it covers |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `resource-cap.js` | That `maxResourceSize` applies to what the capture fetches and never to the page document itself. A page supplied as content is untouched, a page fetched by `saveRawPage` is untouched, an image over the cap is still dropped, frame content supplied as data is untouched, and a frame fetched in raw mode is still dropped. The raw-page case is a regression test: the cap used to empty the document, so a 2.5 MB page was saved as 525 bytes with no body, exit code 0 and no warning. |
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
`dom.js` installs the globals core reads when its modules are evaluated — `DOMParser`, `Document`,
|
|
46
|
+
`window`, `MutationObserver`. Import it before core, which is why `common.js` imports `single-file.js`
|
|
47
|
+
dynamically.
|
|
48
|
+
|
|
49
|
+
`common.js` exports `capture(resources, options)`, which returns the saved page as a string. Two
|
|
50
|
+
things about it are forced by core rather than chosen. `init()` builds the util instance once per
|
|
51
|
+
process and returns early ever after, so the injected fetch cannot be swapped per capture: one
|
|
52
|
+
dispatcher is installed and `capture()` points it at the map for the run in progress. And a capture
|
|
53
|
+
that passes no document never runs `preProcessDoc`, so the arrays it would have produced have to be
|
|
54
|
+
supplied empty — `processWorklets` and its neighbours read `.length` with no guard.
|
|
55
|
+
|
|
56
|
+
`frameData(windowId, baseURI, content)` builds the frame data a content script would have captured,
|
|
57
|
+
matched to a frame element carrying the same window id. `html(body, head)` wraps a fixture.
|
|
58
|
+
|
|
59
|
+
## What it cannot test
|
|
60
|
+
|
|
61
|
+
Anything that reads a live document: `preProcessDoc`, `removeHiddenElements` and its marked elements,
|
|
62
|
+
and the `getComputedStyle` callers in `core/infobar.js` and `modules/css-fonts-minifier.js`. Leave
|
|
63
|
+
those options off here. The browser rigs in `single-file-cli` and `single-file-tests` cover them.
|
|
64
|
+
|
|
65
|
+
deno-dom is not a browser parser. It materializes a whole `NodeList` when `children` is read, and
|
|
66
|
+
`buildTrackIdMap` walks the tree child by child, so a fixture with 100k siblings overflows the stack.
|
|
67
|
+
Size a fixture with long text in few elements.
|
|
68
|
+
|
|
69
|
+
## Adding a case
|
|
70
|
+
|
|
71
|
+
Same rule as the SFZ harness: add checks to the suite that already covers the area rather than making
|
|
72
|
+
a file per rule, write the comment that says *why* the rule exists, and confirm the check can fail.
|
|
73
|
+
For `resource-cap.js` that was done by reverting the `&& !this.options.rootDocument` conjunct in
|
|
74
|
+
`core/index.js`: exactly one check goes red, which is the check that names it.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { capture, html } from "./common.js";
|
|
2
|
+
|
|
3
|
+
const PAGE_URL = "https://example.com/page.html";
|
|
4
|
+
const FILE_URL = "file:///tmp/page.html";
|
|
5
|
+
const PAGE = html("<h1>page</h1>");
|
|
6
|
+
|
|
7
|
+
const resources = {
|
|
8
|
+
[PAGE_URL]: { body: PAGE },
|
|
9
|
+
[FILE_URL]: { body: PAGE }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let failed = false;
|
|
13
|
+
|
|
14
|
+
// insertCanonicalLink was forced to true in single-file.js after the options were merged, so it read
|
|
15
|
+
// as an option in three places and could be set from none: the CLI flag was written, measured doing
|
|
16
|
+
// nothing, and removed again rather than shipped. It now defaults to true instead of being forced,
|
|
17
|
+
// which is what makes the flag and the extension config key mean anything.
|
|
18
|
+
{
|
|
19
|
+
const content = await capture(resources, { url: PAGE_URL, content: PAGE });
|
|
20
|
+
check("a canonical link is inserted by default", content.includes("rel=\"canonical\""), true);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
const content = await capture(resources, { url: PAGE_URL, content: PAGE, insertCanonicalLink: false });
|
|
25
|
+
check("insertCanonicalLink false suppresses it", content.includes("rel=\"canonical\""), false);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
const content = await capture(resources, { url: PAGE_URL, content: PAGE, insertCanonicalLink: true });
|
|
30
|
+
check("insertCanonicalLink true keeps it", content.includes("rel=\"canonical\""), true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The href guard is the reason the option is safe to default on: a page saved from disk has no
|
|
34
|
+
// canonical URL to point at, and the element is skipped rather than written with a file: href.
|
|
35
|
+
{
|
|
36
|
+
const content = await capture(resources, { url: FILE_URL, content: PAGE });
|
|
37
|
+
check("a page saved from file: gets no canonical link", content.includes("rel=\"canonical\""), false);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (failed) {
|
|
41
|
+
console.log("FAILED");
|
|
42
|
+
Deno.exit(1);
|
|
43
|
+
}
|
|
44
|
+
console.log("OK");
|
|
45
|
+
|
|
46
|
+
function check(label, actual, expected) {
|
|
47
|
+
const ok = actual === expected;
|
|
48
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
|
|
49
|
+
failed ||= !ok;
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import "./dom.js";
|
|
2
|
+
|
|
3
|
+
const { init, getPageData, helper } = await import("../../single-file.js");
|
|
4
|
+
|
|
5
|
+
const WIN_ID_ATTRIBUTE_NAME = helper.WIN_ID_ATTRIBUTE_NAME;
|
|
6
|
+
|
|
7
|
+
// preProcessDoc fills these from the live document, and it only runs when a doc is passed. A capture
|
|
8
|
+
// driven from here passes none, so the arrays it would have produced have to be supplied empty:
|
|
9
|
+
// processWorklets and its neighbours read .length with no guard.
|
|
10
|
+
const EMPTY_DOC_DATA = {
|
|
11
|
+
adoptedStyleSheets: [],
|
|
12
|
+
canvases: [],
|
|
13
|
+
fonts: [],
|
|
14
|
+
images: [],
|
|
15
|
+
posters: [],
|
|
16
|
+
referrer: "",
|
|
17
|
+
shadowRoots: [],
|
|
18
|
+
stylesheets: [],
|
|
19
|
+
usedFonts: [],
|
|
20
|
+
videos: [],
|
|
21
|
+
worklets: []
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// init() builds the util instance once per process and returns early ever after, so the fetch cannot
|
|
25
|
+
// be swapped per capture. One dispatcher is installed here and capture() points it at the resources
|
|
26
|
+
// of the run in progress; captures are sequential, so nothing races.
|
|
27
|
+
let resources = new Map();
|
|
28
|
+
|
|
29
|
+
const initOptions = {
|
|
30
|
+
fetch: fetchResource,
|
|
31
|
+
frameFetch: fetchResource
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
init(initOptions);
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
capture,
|
|
38
|
+
frameData,
|
|
39
|
+
html,
|
|
40
|
+
WIN_ID_ATTRIBUTE_NAME
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function capture(pageResources, options) {
|
|
44
|
+
resources = pageResources instanceof Map ? pageResources : new Map(Object.entries(pageResources));
|
|
45
|
+
const pageData = await getPageData({ ...EMPTY_DOC_DATA, ...options }, initOptions, null, null);
|
|
46
|
+
return pageData.content;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fetchResource(url) {
|
|
50
|
+
const resource = resources.get(url);
|
|
51
|
+
if (!resource) {
|
|
52
|
+
return Promise.resolve(new Response("", { status: 404 }));
|
|
53
|
+
}
|
|
54
|
+
const contentType = resource.contentType || "text/html";
|
|
55
|
+
return Promise.resolve(new Response(resource.body, {
|
|
56
|
+
status: resource.status || 200,
|
|
57
|
+
headers: { "content-type": contentType }
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A frame whose content was captured by the content script arrives as frame data keyed by the window
|
|
62
|
+
// id its element carries. Outside raw mode this is the only way a frame is ever filled.
|
|
63
|
+
function frameData(windowId, baseURI, content) {
|
|
64
|
+
return { ...EMPTY_DOC_DATA, windowId, baseURI, content, scrollPosition: { x: 0, y: 0 } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// deno-dom materializes a whole NodeList when children is read, and buildTrackIdMap walks the tree
|
|
68
|
+
// child by child, so a fixture with 100k siblings overflows the stack. Size a fixture with long text
|
|
69
|
+
// in few elements, never with many elements.
|
|
70
|
+
function html(body, head = "") {
|
|
71
|
+
return "<!DOCTYPE html><html><head>" + head + "</head><body>" + body + "</body></html>";
|
|
72
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Three modules read globals when they are evaluated, so every one of them has to exist before core
|
|
2
|
+
// is imported: core/util.js captures DOMParser, and processors/hooks/content/content-hooks-frames.js
|
|
3
|
+
// reads globalThis.window, then calls init() and new MutationObserver(init) at module scope. That
|
|
4
|
+
// hook belongs to the page world and does nothing useful here; it only has to load without throwing.
|
|
5
|
+
// Import this module first and import single-file.js dynamically, the way common.js does.
|
|
6
|
+
import { DOMParser, Document, Element } from "jsr:@b-fuze/deno-dom@0.1.56";
|
|
7
|
+
|
|
8
|
+
globalThis.DOMParser = DOMParser;
|
|
9
|
+
globalThis.Document = Document;
|
|
10
|
+
globalThis.window = globalThis;
|
|
11
|
+
globalThis.MutationObserver = class {
|
|
12
|
+
observe() { }
|
|
13
|
+
disconnect() { }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// deno-dom implements neither of these, so removeEmbedScripts throws here and nowhere else. Mapping
|
|
17
|
+
// them onto the qualified-name methods is faithful for what deno-dom can represent, which is only
|
|
18
|
+
// null-namespace attributes: it drops the prefix of xlink:href and lowercases nothing, so the
|
|
19
|
+
// namespaced and mixed-case cases cannot be written as a fixture at all. Those are covered by the
|
|
20
|
+
// browser suite in single-file-cli, which drives a real DOM.
|
|
21
|
+
Element.prototype.setAttributeNS = function (namespaceURI, qualifiedName, value) {
|
|
22
|
+
this.setAttribute(qualifiedName, value);
|
|
23
|
+
};
|
|
24
|
+
Element.prototype.removeAttributeNS = function (namespaceURI, localName) {
|
|
25
|
+
this.removeAttribute(localName);
|
|
26
|
+
};
|