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 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: "enableMaff", action: "insertMAFFMetaData" },
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.enableMaff) {
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.url,
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
- const JAVASCRIPT_URI_PREFIX = "javascript:";
856
- const DISABLED_SCRIPT = "javascript:void(0)";
857
- this.onEventAttributeNames.forEach(attributeName => this.doc.querySelectorAll("[" + attributeName + "]").forEach(element => element.removeAttribute(attributeName)));
858
- this.doc.querySelectorAll("[href]").forEach(element => {
859
- if (element.href && element.href.match && element.href.trim().startsWith(JAVASCRIPT_URI_PREFIX)) {
860
- element.setAttribute("href", DISABLED_SCRIPT);
861
- }
862
- });
863
- this.doc.querySelectorAll("[src]").forEach(element => {
864
- if (element.src && element.src.trim().startsWith(JAVASCRIPT_URI_PREFIX)) {
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 insertMAFFMetaData() {
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
- this.options.saveUrl = originalURLElement.getAttributeNS(NAMESPACE_RDF, "resource");
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
- background-color: #737373;
144
+ background-color: #737373;
130
145
  }
131
146
  50% {
132
- background-color: #dd6a00;
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 is not a move at constant size, and it can end either way. Two effects pull
1083
- against each other: the room the writer sets aside, which in the reference writer is
1084
- `Math.ceil(length * 1.01) + 32` bytes — a percentage of the payload plus a constant, so
1085
- the constant dominates a small payload and the percentage a large one — and the 17 bytes
1086
- of wrapper terminator and end tags it stops emitting. Measured on three files the net ran
1087
- from 9 bytes saved to 190 bytes spent, so a writer sizing a file should quote that range
1088
- rather than a single figure.
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.128",
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 test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/font-face-composite.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/pages-router.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/content-type-sniffing.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js && deno run --allow-read test/sfz-harness/charset-round-trip.js",
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) {
@@ -132,7 +132,8 @@ export {
132
132
  process,
133
133
  createArchive,
134
134
  escapeHTML,
135
- PROCESS_OPTION_NAMES
135
+ PROCESS_OPTION_NAMES,
136
+ DEFAULT_MAX_APPENDED_DATA_LENGTH
136
137
  };
137
138
 
138
139
  async function process(pageData, options, lastModDate = new Date()) {
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
+ };