├── .gitignore ├── .vscode └── settings.json ├── tsup.config.ts ├── tsconfig.json ├── biome.json ├── LICENSE ├── vitest.config.ts ├── package.json ├── src ├── browser.ts ├── browser.test.ts ├── pipeable-dom.ts ├── pipeable-dom.test.tsx ├── jsx.ts ├── jsx.test.ts └── jsx-types.ts ├── README.md └── pnpm-lock.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | __screenshots__ 4 | coverage 5 | dist 6 | node_modules 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "[markdown]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[html]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/browser.ts", "src/pipeable-dom.ts", "src/jsx.ts"], 5 | dts: true, 6 | format: ["esm"], 7 | platform: "neutral", 8 | external: ["pipeable-dom"], 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "types": ["vite/client"], 6 | "module": "Node16", 7 | "target": "ES2022", 8 | "moduleResolution": "Node16", 9 | "isolatedModules": true, 10 | "declaration": true, 11 | "noEmit": true, 12 | "baseUrl": "./", 13 | "jsx": "react-jsx", 14 | "jsxImportSource": "pipeable-dom", 15 | "paths": { 16 | "pipeable-dom": ["./src/pipeable-dom.ts"], 17 | "pipeable-dom/jsx": ["./src/jsx.ts"], 18 | "pipeable-dom/jsx-runtime": ["./src/jsx.ts"], 19 | "pipeable-dom/jsx-dev-runtime": ["./src/jsx.ts"] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["__screenshots__", "coverage", "dist", "node_modules"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "style": { 24 | "noCommaOperator": "off", 25 | "noNonNullAssertion": "off", 26 | "noParameterAssign": "off", 27 | "useConst": "off" 28 | }, 29 | "suspicious": { 30 | "noAssignInExpressions": "off", 31 | "noDoubleEquals": "off", 32 | "noExplicitAny": "off", 33 | "useValidTypeof": "off" 34 | } 35 | } 36 | }, 37 | "javascript": { 38 | "formatter": { 39 | "quoteStyle": "double" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jacob Ebey 4 | Copyright (c) 2021 eBay Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | browser: { 7 | enabled: true, 8 | headless: true, 9 | name: "chromium", 10 | provider: "playwright", 11 | // https://playwright.dev 12 | providerOptions: {}, 13 | }, 14 | }, 15 | plugins: [ 16 | tsconfigPaths(), 17 | { 18 | name: "server", 19 | configureServer(server) { 20 | return () => { 21 | server.middlewares.use(async (request, response, next) => { 22 | const url = new URL(request.originalUrl || "/", "http://test.com"); 23 | switch (url.pathname) { 24 | case "/style": { 25 | await new Promise((resolve) => 26 | setTimeout( 27 | resolve, 28 | Number.parseInt(url.searchParams.get("delay") || "0"), 29 | ), 30 | ); 31 | const style = url.searchParams.get("style"); 32 | 33 | response.setHeader("Content-Type", "text/css"); 34 | response.end(style); 35 | break; 36 | } 37 | case "/script": { 38 | await new Promise((resolve) => 39 | setTimeout( 40 | resolve, 41 | Number.parseInt(url.searchParams.get("delay") || "0"), 42 | ), 43 | ); 44 | const script = url.searchParams.get("script"); 45 | 46 | response.setHeader("Content-Type", "application/javascript"); 47 | response.end(script); 48 | break; 49 | } 50 | default: { 51 | next(); 52 | } 53 | } 54 | }); 55 | }; 56 | }, 57 | }, 58 | ], 59 | }); 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pipeable-dom", 3 | "version": "0.0.32", 4 | "type": "module", 5 | "description": "", 6 | "files": ["dist", "LICENSE", "README.md"], 7 | "types": "dist/pipeable-dom.d.ts", 8 | "main": "dist/pipeable-dom.js", 9 | "exports": { 10 | ".": { 11 | "types": "./dist/pipeable-dom.d.ts", 12 | "node": { 13 | "module-sync": "./dist/pipeable-dom.js", 14 | "default": "./dist/pipeable-dom.js" 15 | }, 16 | "default": "./dist/pipeable-dom.js" 17 | }, 18 | "./browser": { 19 | "types": "./dist/browser.d.ts", 20 | "node": { 21 | "module-sync": "./dist/browser.js", 22 | "default": "./dist/browser.js" 23 | }, 24 | "default": "./dist/browser.js" 25 | }, 26 | "./jsx": { 27 | "types": "./dist/jsx.d.ts", 28 | "node": { 29 | "module-sync": "./dist/jsx.js", 30 | "default": "./dist/jsx.js" 31 | }, 32 | "default": "./dist/jsx.js" 33 | }, 34 | "./jsx-runtime": { 35 | "types": "./dist/jsx.d.ts", 36 | "node": { 37 | "module-sync": "./dist/jsx.js", 38 | "default": "./dist/jsx.js" 39 | }, 40 | "default": "./dist/jsx.js" 41 | }, 42 | "./jsx-dev-runtime": { 43 | "types": "./dist/jsx.d.ts", 44 | "node": { 45 | "module-sync": "./dist/jsx.js", 46 | "default": "./dist/jsx.js" 47 | }, 48 | "default": "./dist/jsx.js" 49 | }, 50 | "./package.json": "./package.json" 51 | }, 52 | "scripts": { 53 | "build": "tsup", 54 | "format": "biome check --write .", 55 | "test": "vitest" 56 | }, 57 | "keywords": [], 58 | "author": "", 59 | "license": "ISC", 60 | "devDependencies": { 61 | "@biomejs/biome": "1.9.4", 62 | "@testing-library/dom": "10.4.0", 63 | "@vitest/browser": "2.1.3", 64 | "@vitest/coverage-v8": "2.1.3", 65 | "playwright": "1.48.1", 66 | "tsup": "8.3.0", 67 | "typescript": "5.6.3", 68 | "vite": "5.4.9", 69 | "vite-tsconfig-paths": "5.0.1", 70 | "vitest": "2.1.3", 71 | "wireit": "0.14.9" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | import { DOMStream } from "pipeable-dom"; 2 | import type { JSXNode } from "pipeable-dom/jsx"; 3 | import { render } from "pipeable-dom/jsx"; 4 | 5 | const INNER_HTML = "innerHTML"; 6 | const OUTER_HTML = "outerHTML"; 7 | 8 | export type SwapType = 9 | | "beforebegin" 10 | | "afterbegin" 11 | | "beforeend" 12 | | "afterend" 13 | | "outerHTML" 14 | | "innerHTML"; 15 | 16 | export const swap = async ( 17 | target: Element, 18 | swap: SwapType, 19 | newContent: 20 | | JSXNode 21 | | Response 22 | | ReadableStream 23 | | ReadableStream, 24 | onAppend?: (node: Node) => void, 25 | ) => { 26 | let body: ReadableStream; 27 | if (newContent instanceof Response) { 28 | if (!newContent.body) { 29 | throw new Error("Response body is not readable"); 30 | } 31 | body = newContent.body.pipeThrough(new TextDecoderStream()); 32 | } else if (newContent instanceof ReadableStream) { 33 | let decoder = new TextDecoder(); 34 | body = newContent.pipeThrough( 35 | new TransformStream({ 36 | transform(chunk, controller) { 37 | if (typeof chunk === "string") { 38 | controller.enqueue(chunk); 39 | } else { 40 | controller.enqueue(decoder.decode(chunk, { stream: true })); 41 | } 42 | }, 43 | }), 44 | ); 45 | } else { 46 | body = render(newContent); 47 | } 48 | 49 | swap = swap || OUTER_HTML; 50 | let insertBefore: Node = document.createComment(""); 51 | 52 | if (swap == "afterbegin") { 53 | target.prepend(insertBefore); 54 | } else if (swap == "afterend") { 55 | target.after(insertBefore); 56 | } else if (swap == "beforebegin") { 57 | target.before(insertBefore); 58 | } else if (swap == "beforeend") { 59 | target.append(insertBefore); 60 | } else if (swap != OUTER_HTML && swap != INNER_HTML) { 61 | throw new Error(`Unknown swap value: ${swap}`); 62 | } 63 | 64 | try { 65 | let processedFirstChunk = false; 66 | let transition: ViewTransition | undefined; 67 | let donePromise = body.pipeThrough(new DOMStream()).pipeTo( 68 | new WritableStream({ 69 | write(node) { 70 | if (!processedFirstChunk) { 71 | if (swap == OUTER_HTML) { 72 | target!.after(insertBefore); 73 | target!.remove(); 74 | } else if (swap == INNER_HTML) { 75 | target!.innerHTML = ""; 76 | target!.append(insertBefore); 77 | } 78 | processedFirstChunk = true; 79 | } 80 | 81 | insertBefore.parentElement!.insertBefore(node, insertBefore); 82 | onAppend?.(node); 83 | }, 84 | }), 85 | ); 86 | await donePromise; 87 | await transition?.finished; 88 | } finally { 89 | if (insertBefore?.parentElement) { 90 | insertBefore.parentElement.removeChild(insertBefore); 91 | } 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pipeable-dom 2 | 3 | An HTML parser and JSX runtime allowing for HTML to be streamed into a live DOM. 4 | 5 | Sizes: 6 | 7 | - `pipeable-dom` - ![bundle size image](https://deno.bundlejs.com/badge?q=pipeable-dom@0.0.10) 8 | - `pipeable-dom/jsx` - ![bundle size image](https://deno.bundlejs.com/badge?q=pipeable-dom@0.0.10/jsx) 9 | - `pipeable-dom` + `pipeable-dom/jsx` - ![bundle size image](https://deno.bundlejs.com/badge?q=pipeable-dom@0.0.10,pipeable-dom@0.0.10/jsx) 10 | 11 | ## `DOMStream` 12 | 13 | A `TransformStream` that implements lookahead preloading and browser document request rendering semantics. 14 | 15 | ```mermaid 16 | sequenceDiagram 17 | participant HTMLSource 18 | participant SourceDOM 19 | participant LoadingProcess 20 | participant TargetDOM 21 | 22 | HTMLSource->>SourceDOM: Stream HEAD node 23 | SourceDOM->>TargetDOM: Move HEAD node 24 | HTMLSource->>SourceDOM: Stream BODY node 25 | SourceDOM->>TargetDOM: Move BODY node 26 | HTMLSource->>SourceDOM: Stream IMG1 node (blocking) 27 | SourceDOM->>LoadingProcess: Start loading IMG1 28 | 29 | par Process IMG1 and look ahead 30 | LoadingProcess->>LoadingProcess: Load IMG1 31 | and 32 | LoadingProcess-->>HTMLSource: Request next nodes 33 | HTMLSource->>SourceDOM: Stream IMG2 node (blocking) 34 | LoadingProcess-->>SourceDOM: Preload IMG2 35 | end 36 | HTMLSource->>SourceDOM: Stream P node (non-blocking) 37 | 38 | LoadingProcess->>TargetDOM: Move IMG1 (after loading) 39 | LoadingProcess->>TargetDOM: Move IMG2 (after loading) 40 | SourceDOM->>TargetDOM: Move P node 41 | 42 | Note over HTMLSource,TargetDOM: Streaming continues... 43 | ``` 44 | 45 | This is a derivative of [@MarkdoDevTeam](https://x.com/MarkoDevTeam)'s [writable-dom](https://github.com/marko-js/writable-dom). 46 | 47 | ## `import "pipeable-dom/browser"` 48 | 49 | A stateless JSX runtime that renders to an async HTML stream. 50 | 51 | It supports: 52 | 53 | the react-jsx runtime 54 | 55 | - sync and async functional components 56 | - sync and async generator components 57 | - there is no event / callback system, therefor 58 | - callbacks such as onclick accept strings and render the JS in the attribute 59 | 60 | ### `swap(target: Element, swap: SwapType, newContent: JSXNode): Promise` 61 | 62 | A function to update the DOM using the JSX runtime as the template. 63 | 64 | #### `SwapType` 65 | 66 | Swap type is inspired by the [`hx-swap`](https://htmx.org/attributes/hx-swap/) attribute from [HTMX](https://htmx.org/). 67 | 68 | Allowed values: 69 | 70 | - `beforebegin` - Place the new content before the existing node 71 | - `afterbegin` - Place the new content as the first item in the existing node 72 | - `beforeend` - Place the new content as the last item in the existing node 73 | - `afterend` - Place the new content after the existing node 74 | - `outerHTML` - Replace the existing node 75 | - `innerHTML` - Replace the existing node content 76 | -------------------------------------------------------------------------------- /src/browser.test.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from "@testing-library/dom"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import { swap } from "./browser.js"; 5 | import { jsx } from "./jsx.js"; 6 | 7 | describe("swap", () => { 8 | it("can swap with ReadableStream", async () => { 9 | const container = document.createElement("div"); 10 | const target = document.createElement("div"); 11 | target.innerHTML = "initial"; 12 | container.appendChild(target); 13 | document.body.appendChild(container); 14 | 15 | await swap( 16 | target, 17 | "outerHTML", 18 | new ReadableStream({ 19 | start(controller) { 20 | controller.enqueue('
hello
'); 21 | controller.close(); 22 | }, 23 | }), 24 | ); 25 | expect(container.innerHTML).toBe('
hello
'); 26 | }); 27 | 28 | it("can stream response chunks", async () => { 29 | const container = document.createElement("div"); 30 | const target = document.createElement("div"); 31 | target.innerHTML = "initial"; 32 | container.appendChild(target); 33 | document.body.appendChild(container); 34 | 35 | await swap( 36 | target, 37 | "outerHTML", 38 | new ReadableStream({ 39 | async start(controller) { 40 | await new Promise((resolve) => setTimeout(resolve, 100)); 41 | controller.enqueue( 42 | new TextEncoder().encode('
aaaaaaaaa
'), 43 | ); 44 | await waitFor(() => target.querySelector(".a")); 45 | await new Promise((resolve) => setTimeout(resolve, 100)); 46 | controller.enqueue( 47 | new TextEncoder().encode('
bbbbbbbb
'), 48 | ); 49 | await waitFor(() => target.querySelector(".b")); 50 | controller.close(); 51 | }, 52 | }), 53 | ); 54 | }); 55 | 56 | it("can swap with ReadableStream", async () => { 57 | const container = document.createElement("div"); 58 | const target = document.createElement("div"); 59 | target.innerHTML = "initial"; 60 | container.appendChild(target); 61 | document.body.appendChild(container); 62 | 63 | await swap( 64 | target, 65 | "outerHTML", 66 | new ReadableStream({ 67 | start(controller) { 68 | controller.enqueue( 69 | new TextEncoder().encode('
hello
'), 70 | ); 71 | controller.close(); 72 | }, 73 | }), 74 | ); 75 | expect(container.innerHTML).toBe('
hello
'); 76 | }); 77 | 78 | it("can swap outerHTML", async () => { 79 | const container = document.createElement("div"); 80 | const target = document.createElement("div"); 81 | target.innerHTML = "initial"; 82 | container.appendChild(target); 83 | document.body.appendChild(container); 84 | 85 | await swap( 86 | target, 87 | "outerHTML", 88 | jsx("div", { class: "test", children: "hello" }), 89 | ); 90 | expect(container.innerHTML).toBe('
hello
'); 91 | }); 92 | 93 | it("can swap innerHTML", async () => { 94 | const container = document.createElement("div"); 95 | const target = document.createElement("div"); 96 | target.innerHTML = "initial"; 97 | container.appendChild(target); 98 | document.body.appendChild(container); 99 | 100 | await swap( 101 | target, 102 | "innerHTML", 103 | jsx("div", { class: "test", children: "hello" }), 104 | ); 105 | expect(container.innerHTML).toBe( 106 | '
hello
', 107 | ); 108 | }); 109 | 110 | it("can swap beforebegin", async () => { 111 | const container = document.createElement("div"); 112 | const target = document.createElement("div"); 113 | target.innerHTML = "initial"; 114 | container.appendChild(target); 115 | document.body.appendChild(container); 116 | 117 | await swap( 118 | target, 119 | "beforebegin", 120 | jsx("div", { class: "test", children: "hello" }), 121 | ); 122 | expect(container.innerHTML).toBe( 123 | '
hello
initial
', 124 | ); 125 | }); 126 | 127 | it("can swap afterbegin", async () => { 128 | const container = document.createElement("div"); 129 | const target = document.createElement("div"); 130 | target.innerHTML = "initial"; 131 | container.appendChild(target); 132 | document.body.appendChild(container); 133 | 134 | await swap( 135 | target, 136 | "afterbegin", 137 | jsx("div", { class: "test", children: "hello" }), 138 | ); 139 | expect(container.innerHTML).toBe( 140 | '
hello
initial
', 141 | ); 142 | }); 143 | 144 | it("can swap beforeend", async () => { 145 | const container = document.createElement("div"); 146 | const target = document.createElement("div"); 147 | target.innerHTML = "initial"; 148 | container.appendChild(target); 149 | document.body.appendChild(container); 150 | 151 | await swap( 152 | target, 153 | "beforeend", 154 | jsx("div", { class: "test", children: "hello" }), 155 | ); 156 | expect(container.innerHTML).toBe( 157 | '
initial
hello
', 158 | ); 159 | }); 160 | 161 | it("can swap afterend", async () => { 162 | const container = document.createElement("div"); 163 | const target = document.createElement("div"); 164 | target.innerHTML = "initial"; 165 | container.appendChild(target); 166 | document.body.appendChild(container); 167 | 168 | await swap( 169 | target, 170 | "afterend", 171 | jsx("div", { class: "test", children: "hello" }), 172 | ); 173 | expect(container.innerHTML).toBe( 174 | '
initial
hello
', 175 | ); 176 | }); 177 | 178 | it("throws for unknown swap values", async () => { 179 | const container = document.createElement("div"); 180 | const target = document.createElement("div"); 181 | target.innerHTML = "initial"; 182 | container.appendChild(target); 183 | document.body.appendChild(container); 184 | 185 | await expect(() => 186 | swap( 187 | target, 188 | "invalid" as any, 189 | jsx("div", { class: "test", children: "hello" }), 190 | ), 191 | ).rejects.toThrowError("Unknown swap value: invalid"); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /src/pipeable-dom.ts: -------------------------------------------------------------------------------- 1 | const doc = document; 2 | const head = doc.head; 3 | const MODULE_PRELOAD = "modulepreload"; 4 | const LINK = "LINK"; 5 | const PRELOAD = "preload"; 6 | const SCRIPT = "SCRIPT"; 7 | const STYLE = "STYLE"; 8 | const STYLESHEET = "stylesheet"; 9 | const SRC = "src"; 10 | const MODULE = "module"; 11 | const HREF = "href"; 12 | const REL = "rel"; 13 | const TYPE = "type"; 14 | const CURRENT_NODE = "currentNode"; 15 | const PARENT_NODE = "parentNode"; 16 | const HAS_ATTRIBUTE = "hasAttribute"; 17 | const MEDIA = "media"; 18 | const TAG_NAME = "tagName"; 19 | const NULL: null = null; 20 | const NODE_TYPE = "nodeType"; 21 | const APPEND_CHILD = "appendChild"; 22 | const PROMISE = Promise; 23 | 24 | const appendInlineTextIfNeeded = ( 25 | pendingText: Text | null, 26 | inlineTextHostNode: Node | null, 27 | ) => pendingText && inlineTextHostNode?.[APPEND_CHILD](pendingText); 28 | 29 | const instanceOf = ( 30 | value: unknown, 31 | Constructor: new (...args: any[]) => T, 32 | ): value is T => value instanceof Constructor; 33 | 34 | const isInlineHost = (node: Node, tagName?: string): node is Element => { 35 | tagName = (node as Element)?.[TAG_NAME]; 36 | return ( 37 | (tagName == SCRIPT && !(node as HTMLScriptElement)[SRC]) || tagName == STYLE 38 | ); 39 | }; 40 | 41 | const isBlocking = ( 42 | node: Node, 43 | ): node is HTMLScriptElement | HTMLLinkElement => { 44 | let isPotentialScriptOrLinkElement = ( 45 | node: Node, 46 | ): node is HTMLScriptElement & HTMLLinkElement => { 47 | return node[NODE_TYPE] == 1; 48 | }; 49 | return ( 50 | /*#__INLINE__*/ isPotentialScriptOrLinkElement(node) && 51 | ((node[TAG_NAME] == SCRIPT && 52 | node[SRC] && 53 | !( 54 | node.noModule || 55 | node[TYPE] == MODULE || 56 | node[HAS_ATTRIBUTE]("async") || 57 | node[HAS_ATTRIBUTE]("defer") 58 | )) || 59 | (node[TAG_NAME] == LINK && 60 | node[REL] == STYLESHEET && 61 | (!node[MEDIA] || matchMedia(node[MEDIA]).matches))) 62 | ); 63 | }; 64 | 65 | const getPreloadLink = ( 66 | node: Node, 67 | link?: HTMLLinkElement | null, 68 | ): HTMLLinkElement | null => { 69 | link = doc.createElement(LINK) as HTMLLinkElement; 70 | let isElement = (node: Node): node is Element => { 71 | return node[NODE_TYPE] == 1; 72 | }; 73 | if (!(/*#__INLINE__*/ isElement(node))) { 74 | return NULL; 75 | } 76 | 77 | if (node[TAG_NAME] == "IMG") { 78 | if (instanceOf(node, HTMLImageElement) && node[SRC]) { 79 | link[REL] = PRELOAD; 80 | link[HREF] = node[SRC]; 81 | link.as = "image"; 82 | } else { 83 | link = NULL; 84 | } 85 | } else if (node[TAG_NAME] == LINK) { 86 | if (instanceOf(node, HTMLLinkElement) && node[HREF]) { 87 | if (node[REL] == STYLESHEET) { 88 | link![REL] = PRELOAD; 89 | link![HREF] = node[HREF]; 90 | link!.as = STYLE; 91 | if (node[MEDIA]) { 92 | link![MEDIA] = node[MEDIA]; 93 | } 94 | } else if (node[REL] == MODULE_PRELOAD) { 95 | link![REL] = MODULE_PRELOAD; 96 | link![HREF] = node[HREF]; 97 | } else { 98 | link = NULL; 99 | } 100 | } else { 101 | link = NULL; 102 | } 103 | } else if (node[TAG_NAME] == SCRIPT) { 104 | if (instanceOf(node, HTMLScriptElement) && node[SRC]) { 105 | link[HREF] = node[SRC]; 106 | if (node[TYPE] == MODULE) { 107 | link[REL] = MODULE_PRELOAD; 108 | } else { 109 | link[REL] = PRELOAD; 110 | link.as = SCRIPT; 111 | } 112 | if (node[TYPE]) { 113 | link[TYPE] = node[TYPE]; 114 | } 115 | if (!node.noModule) { 116 | link.setAttribute("crossorigin", ""); 117 | } 118 | } else { 119 | link = NULL; 120 | } 121 | } 122 | 123 | return link; 124 | }; 125 | 126 | export class DOMStream extends TransformStream { 127 | constructor() { 128 | let tmpDoc = doc.implementation.createHTMLDocument(); 129 | let walker = tmpDoc.createTreeWalker( 130 | (tmpDoc.write("