├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ ├── build.yml.bak │ ├── deno-dom.yml │ └── main.yml ├── .gitignore ├── .gitmodules ├── Cargo.toml ├── LICENSE ├── README.md ├── bench ├── README.md ├── bench-dom.sh ├── bench-jsdom.js ├── bench-native-dom.ts ├── bench-native-parse.ts ├── bench-parse.sh ├── bench-parse5.js ├── bench-wasm-dom.ts ├── bench-wasm-parse.ts ├── c.html ├── package-lock.json └── package.json ├── build └── deno-wasm │ ├── .gitignore │ ├── README.md │ ├── deno-wasm.d.ts │ ├── deno-wasm.js │ ├── deno-wasm_bg.d.ts │ ├── deno-wasm_bg.js │ └── deno-wasm_bg.wasm ├── deno-dom-native.ts ├── deno-dom-wasm-noinit.ts ├── deno-dom-wasm.ts ├── deno.jsonc ├── deno.lock ├── design.md ├── html-parser ├── Makefile ├── README.md ├── cli-test │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── core │ ├── Cargo.toml │ └── src │ │ ├── lib.rs │ │ └── rcdom.rs ├── plugin │ ├── Cargo.toml │ ├── build.rs │ └── src │ │ └── lib.rs └── wasm │ ├── Cargo.toml │ ├── README.md │ └── src │ └── lib.rs ├── native.test.ts ├── src ├── api.ts ├── constructor-lock.ts ├── deserialize.ts ├── dom │ ├── document-fragment.ts │ ├── document.ts │ ├── dom-parser.ts │ ├── element.ts │ ├── elements │ │ └── html-template-element.ts │ ├── html-collection.ts │ ├── node-list.ts │ ├── node.ts │ ├── selectors │ │ ├── custom-api.ts │ │ ├── nwsapi-types.ts │ │ ├── nwsapi.js │ │ ├── selectors.ts │ │ ├── sizzle-types.ts │ │ └── sizzle.js │ ├── string-cache.ts │ ├── utils-types.ts │ └── utils.ts └── parser.ts ├── test ├── units.ts ├── units │ ├── CharacterData.ts │ ├── Document-cloneNode.ts │ ├── Document.ts │ ├── DocumentFragment.ts │ ├── Element-append.ts │ ├── Element-children-sync-childNodes.ts │ ├── Element-classList.ts │ ├── Element-closest.ts │ ├── Element-firstElementChild.ts │ ├── Element-getElementsBy.ts │ ├── Element-getElementsByTagName-wildcard.ts │ ├── Element-id.ts │ ├── Element-lastElementChild.ts │ ├── Element-localName.ts │ ├── Element-matches.ts │ ├── Element-outerHTML.ts │ ├── Element-prepend.ts │ ├── Element-set-innerHTML.ts │ ├── Element-toggleAttribute.ts │ ├── HTMLElement-dataset.ts │ ├── HTMLElement-innerText.ts │ ├── HTMLTemplateElement.ts │ ├── NamedNodeMap.ts │ ├── Node-appendChild.ts │ ├── Node-compareDocumentPosition.ts │ ├── Node-events.ts │ ├── Node-insertBefore.ts │ ├── Node-nodeType-constants.ts │ ├── Node-nodeValue.ts │ ├── Node-nodesAndTextNodes-ancestor-check.ts │ ├── Node-nodesAndTextNodes-sibllings.ts │ ├── Node-removeChild.ts │ ├── Node-replaceWith-child.ts │ ├── NodeList-compatible-api.ts │ ├── adjacent-siblings.ts │ ├── basic.html │ ├── case-insensitive-attributes.ts │ ├── child-element-count.ts │ ├── cloneNode.ts │ ├── collections-toString.ts │ ├── comments-in-outerhtml.ts │ ├── comments-outside-html-test.ts │ ├── comments-outside-html.html │ ├── instanceof.ts │ ├── large-child-count.ts │ ├── noscript-has-domtree.ts │ ├── parse-empty-template.ts │ ├── querySelector-dom-type.ts │ ├── querySelectorAll-selector-list.ts │ ├── remove-attribute-delete.ts │ └── throws-dom-exception.ts ├── wpt-runner-worker.ts ├── wpt-runner.ts └── wpt.ts └── wasm.test.ts /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/base:debian 2 | 3 | # Install deno 4 | ENV DENO_INSTALL=/deno 5 | RUN mkdir -p /deno \ 6 | && curl -fsSL https://deno.land/install.sh | sh \ 7 | && chown -R vscode /deno 8 | 9 | ENV PATH=${DENO_INSTALL}/bin:${PATH} \ 10 | DENO_DIR=${DENO_INSTALL}/.cache/deno 11 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Deno", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "customizations": { 7 | "vscode": { 8 | "settings": { 9 | "editor.defaultFormatter": "denoland.vscode-deno", 10 | "deno.lint": true, 11 | "deno.enable": true 12 | }, 13 | "extensions": ["denoland.vscode-deno"] 14 | }, 15 | "remoteUser": "vscode" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml.bak: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | name: ${{ matrix.kind }} ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | timeout-minutes: 60 10 | strategy: 11 | matrix: 12 | os: [macOS-latest, windows-latest, ubuntu-latest] 13 | 14 | env: 15 | GH_ACTIONS: true 16 | RUST_BACKTRACE: full 17 | DENO_BUILD_MODE: release 18 | 19 | steps: 20 | - name: Clone repository 21 | uses: actions/checkout@v1 22 | 23 | - name: Install rust 24 | uses: hecrj/setup-rust-action@v1 25 | with: 26 | rust-version: "1.43.1" 27 | 28 | - name: Log versions 29 | run: | 30 | node -v 31 | rustc --version 32 | cargo --version 33 | 34 | - name: Cache cargo registry 35 | uses: actions/cache@v1 36 | with: 37 | path: ~/.cargo/registry 38 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 39 | 40 | - name: Cache cargo index 41 | uses: actions/cache@v1 42 | with: 43 | path: ~/.cargo/git 44 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 45 | 46 | - name: Cache cargo build 47 | uses: actions/cache@v1 48 | with: 49 | path: target 50 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 51 | 52 | - name: Remove Some Cache 53 | if: matrix.os == 'windows-latest' 54 | run: | 55 | rm target/release/gn_root -Recurse -ErrorAction Ignore 56 | rm target/debug/gn_root -Recurse -ErrorAction Ignore 57 | 58 | # - name: Install python 59 | # uses: actions/setup-python@v1 60 | # with: 61 | # python-version: "2.7.x" 62 | # architecture: x64 63 | 64 | # - name: Install ubuntu deps 65 | # if: matrix.os == 'ubuntu-latest' 66 | # run: | 67 | # sudo apt-get update 68 | # sudo apt-get install libdbus-1-dev x11-xserver-utils wmctrl libxtst-dev cmake libc-dev libx11-dev libxcb1-dev 69 | 70 | - name: Build 71 | env: 72 | RUST_BACKTRACE: 1 73 | run: cargo build 74 | 75 | -------------------------------------------------------------------------------- /.github/workflows/deno-dom.yml: -------------------------------------------------------------------------------- 1 | name: Deno DOM 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | os: 14 | - name: ubuntu-latest 15 | - name: macos-latest 16 | #- name: windows-latest 17 | runs-on: ${{ matrix.os.name }} 18 | 19 | steps: 20 | - name: Install Deno 21 | uses: denoland/setup-deno@v1 22 | with: 23 | deno-version: v1.x 24 | 25 | - name: Get Deno DOM 26 | uses: actions/checkout@v2 27 | 28 | - name: Check code formatting 29 | run: deno fmt --check --unstable 30 | 31 | # We check in a separate step because dynamic imports 32 | # no longer type check at runtime which we need for 33 | # invoking the unit tests 34 | - name: Check TypeScript types 35 | run: deno task type-check 36 | 37 | - name: Run tests 38 | run: deno test --no-check --allow-read --allow-net wasm.test.ts 39 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths: 6 | - "Cargo.toml" 7 | - "html-parser/core/**" 8 | - "html-parser/plugin/**" 9 | - ".github/workflows/**" 10 | pull_request: 11 | branches-ignore: [master] 12 | paths: 13 | - "Cargo.toml" 14 | - "html-parser/core/**" 15 | - "html-parser/plugin/**" 16 | - ".github/workflows/**" 17 | 18 | jobs: 19 | build: 20 | name: ${{ matrix.kind }} ${{ matrix.os }} 21 | runs-on: ${{ matrix.os }} 22 | timeout-minutes: 60 23 | strategy: 24 | matrix: 25 | os: [macOS-latest, windows-latest, ubuntu-latest] 26 | 27 | env: 28 | GH_ACTIONS: true 29 | RUST_BACKTRACE: full 30 | DENO_BUILD_MODE: release 31 | 32 | steps: 33 | - name: Clone repository 34 | uses: actions/checkout@v1 35 | 36 | - name: Install rust 37 | uses: hecrj/setup-rust-action@v1 38 | with: 39 | rust-version: "1.60.0" 40 | 41 | - name: Log versions 42 | run: | 43 | rustc --version 44 | cargo --version 45 | 46 | - name: Cache cargo registry 47 | uses: actions/cache@v1 48 | with: 49 | path: ~/.cargo/registry 50 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 51 | 52 | - name: Cache cargo index 53 | uses: actions/cache@v1 54 | with: 55 | path: ~/.cargo/git 56 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 57 | 58 | - name: Cache cargo build 59 | uses: actions/cache@v1 60 | with: 61 | path: target 62 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 63 | 64 | - name: Remove Some Cache 65 | if: matrix.os == 'windows-latest' 66 | run: | 67 | rm target/release/gn_root -Recurse -ErrorAction Ignore 68 | rm target/debug/gn_root -Recurse -ErrorAction Ignore 69 | 70 | - name: Build 71 | env: 72 | RUST_BACKTRACE: 1 73 | run: cargo clean && cargo build --release 74 | 75 | - name: Build ARM64 Linux 76 | if: matrix.os == 'ubuntu-latest' 77 | env: 78 | RUST_BACKTRACE: 1 79 | shell: bash 80 | run: | 81 | # Keep the Linux x86_64 build artifact around since 82 | # we'll be clearing it to build the ARM64 artifact 83 | cp target/release/libplugin.so . 84 | 85 | target=aarch64-unknown-linux-gnu 86 | arm64_cargo_config=$(cat <&2 112 | exit 1 113 | ;; 114 | esac 115 | 116 | sudo apt install libgcc-s1:arm64 libc6:arm64 libc6-dev:arm64 "$gcc_lib:arm64" -y 117 | 118 | # Make sure the ARM64 dependency libraries are found by the linker 119 | ( 120 | cd /usr/lib/aarch64-linux-gnu 121 | for lib in gcc_s util rt pthread m dl c; do 122 | for file in lib$lib.so.*; do 123 | link_path=lib$lib.so 124 | if [[ -f $file && ! -e $link_path ]]; then 125 | sudo ln -s "$file" "$link_path" 126 | fi 127 | done 128 | done 129 | ) 130 | 131 | if cargo clean && cargo build --release; then 132 | lib=target/$target/release/libplugin.so 133 | cp "$lib" "${lib%.so}-linux-aarch64.so" 134 | else 135 | echo "Failed to build Linux ARM64 target" 136 | fi 137 | 138 | # Put the x86_64 build artifact back 139 | install -D libplugin.so target/release/libplugin.so 140 | 141 | - name: Release Plugin 142 | uses: softprops/action-gh-release@master 143 | env: 144 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 145 | with: 146 | tag_name: "release draft" 147 | draft: true 148 | files: | 149 | target/release/libplugin.dylib 150 | target/release/plugin.dll 151 | target/release/libplugin.so 152 | target/aarch64-unknown-linux-gnu/release/libplugin-linux-aarch64.so 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | html-parser/*/target 2 | build/linux-x86_64 3 | Cargo.lock 4 | node_modules 5 | .deno_plugins 6 | /target 7 | *~ 8 | /.vim 9 | /.vscode 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "wpt"] 2 | path = wpt 3 | url = https://github.com/web-platform-tests/wpt.git 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "html-parser/plugin", 4 | ] 5 | 6 | exclude = [ 7 | "html-parser/cli-test", 8 | "html-parser/wasm", 9 | ] 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 b-fuze 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deno DOM 2 | 3 | An implementation of the browser DOM—primarily for SSR—in Deno. Implemented with 4 | Rust, WASM, and obviously, Deno/TypeScript. 5 | 6 | ## Example 7 | 8 | ```typescript 9 | import { DOMParser, Element } from "jsr:@b-fuze/deno-dom"; 10 | 11 | // non-JSR wasm url import: https://deno.land/x/deno_dom/deno-dom-wasm.ts 12 | // non-JSR native url import: https://deno.land/x/deno_dom/deno-dom-native.ts 13 | 14 | const doc = new DOMParser().parseFromString( 15 | ` 16 |

Hello World!

17 |

Hello from Deno!

18 | `, 19 | "text/html", 20 | ); 21 | 22 | const p = doc.querySelector("p")!; 23 | 24 | console.log(p.textContent); // "Hello from Deno!" 25 | console.log(p.childNodes[1].textContent); // "Deno!" 26 | 27 | p.innerHTML = "DOM in Deno is pretty cool"; 28 | console.log(p.children[0].outerHTML); // "Deno" 29 | ``` 30 | 31 | Deno DOM has **two** backends, WASM and native using Deno native plugins. Both 32 | APIs are **identical**, the difference being only in performance. The WASM 33 | backend works with all Deno restrictions, but the native backend requires the 34 | `--unstable-ffi --allow-ffi --allow-env --allow-read --allow-net=deno.land` 35 | flags. A shorter version could be `--unstable-ffi -A`, but that allows all 36 | permissions so you'd have to assess your risk and requirements. You can switch 37 | between them by importing either `jsr:@b-fuze/deno-dom` for WASM or 38 | `jsr:@b-fuze/deno-dom/native` for the native binary. 39 | 40 | Deno DOM is still under development, but is fairly usable for basic HTML 41 | manipulation needs. 42 | 43 | ### WebAssembly Startup Penalty 44 | 45 | Deno suffers an initial startup penalty in Deno DOM WASM due to Top Level Await 46 | (TLA) preparing the WASM parser. As an alternative to running the initiation on 47 | startup, you can initialize Deno DOM's parser on-demand yourself when you need 48 | it by importing from `jsr:@b-fuze/deno-dom/wasm-noinit`. Example: 49 | 50 | ```typescript 51 | import { DOMParser, initParser } from "jsr:@b-fuze/deno-dom/wasm-noinit"; 52 | 53 | // ...and when you need Deno DOM make sure you initialize the parser... 54 | await initParser(); 55 | 56 | // Then you can use Deno DOM as you would normally 57 | const doc = new DOMParser().parseFromString( 58 | ` 59 |

Lorem ipsum dolor...

60 | `, 61 | "text/html", 62 | ); 63 | ``` 64 | 65 | ## Documentation 66 | 67 | Refer to MDN (Mozilla Developer Network) for documentation. If there are 68 | inconsistencies (that aren't a result of legacy APIs) file an issue. 69 | 70 | ## Goals 71 | 72 | - HTML parser in Deno 73 | - Fast 74 | - Mirror _most_ supported DOM APIs as closely as possible 75 | - Provide specific APIs in addition to DOM APIs to make certain operations more 76 | efficient, like controlling Shadow DOM 77 | - Use cutting-edge JS features like private class members, optional chaining, 78 | etc 79 | 80 | ## Non-Goals 81 | 82 | - Headless browser implementation 83 | - Ability to run JS embedded in documents (` 28 |
((a,b) => a < b)(1337, 42 & 0);
29 | `, 30 | "text/html", 31 | ); 32 | 33 | const script = doc.querySelector("script")!; 34 | const div = doc.querySelector("div")!; 35 | assertEquals( 36 | script.outerHTML, 37 | ``, 38 | ); 39 | assertEquals( 40 | div.outerHTML, 41 | `
((a,b) => a < b)(1337, 42 & 0);
`, 42 | ); 43 | }); 44 | 45 | Deno.test("Element.outerHTML void elements don't print their contents", () => { 46 | const doc = new DOMParser().parseFromString( 47 | `
unclosed`, 48 | "text/html", 49 | ); 50 | 51 | const body = doc.querySelector("body")!; 52 | assertEquals( 53 | body.outerHTML, 54 | `
unclosed
`, 55 | ); 56 | 57 | const img = doc.querySelector("img")!; 58 | img.append(Object.assign(doc.createElement("div"), { 59 | textContent: "you shouldn't be here", 60 | })); 61 | 62 | assertEquals(img.outerHTML, ``); 63 | assertEquals(img.innerHTML, `
you shouldn't be here
`); 64 | }); 65 | 66 | Deno.test("Element.outerHTML won't overflow the stack for deeply nested HTML", () => { 67 | const html = new Array(2000) 68 | .fill("
Hello ") 69 | .reduce((acc, tag, i) => tag + (i + 1) + acc + "
", ""); 70 | const doc = new DOMParser().parseFromString(html, "text/html"); 71 | 72 | const htmlElement = doc.documentElement!; 73 | assertEquals(htmlElement.outerHTML.length > 0, true); 74 | }); 75 | 76 | Deno.test("Element.outerHTML can be set to replace element", () => { 77 | const doc = new DOMParser().parseFromString( 78 | ` 79 |
80 |
1st
second
81 |
hello
82 | `, 83 | "text/html", 84 | ); 85 | const parent = doc.querySelector(".parent")!; 86 | const child = doc.querySelector(".child")!; 87 | const otherParent = doc.querySelector(".otherparent")!; 88 | const otherChild = doc.querySelector(".otherchild")!; 89 | const tbody = doc.querySelector("tbody")!; 90 | const tr = tbody.children[0]; 91 | 92 | const newHTML = 93 | `foo fibtext nodesbar`; 94 | const serializedNewHTML = newHTML.replace("qux", '"qux"'); 95 | child.outerHTML = newHTML; 96 | 97 | assertEquals(child.parentNode, null); 98 | assertEquals( 99 | Array.from(parent.childNodes).find((node) => node === child), 100 | undefined, 101 | ); 102 | assertEquals(parent.children.length, 2); 103 | assertEquals(parent.childNodes.length, 4); 104 | assertEquals(parent.innerHTML, serializedNewHTML); 105 | 106 | const newChild = parent.children[0]; 107 | newChild.outerHTML = `goodbye`; 108 | assertEquals(newChild.parentNode, null); 109 | assertEquals( 110 | Array.from(parent.childNodes).find((node) => node === newChild), 111 | undefined, 112 | ); 113 | assertEquals(parent.children.length, 1); 114 | assertEquals(parent.childNodes.length, 4); 115 | assertEquals( 116 | parent.innerHTML, 117 | "goodbye" + serializedNewHTML.slice(serializedNewHTML.indexOf("text")), 118 | ); 119 | 120 | assertEquals(tbody.innerHTML, `hello`); 121 | tr.outerHTML = `goodbye`; 122 | 123 | assertEquals(tr.parentNode, null); 124 | assertEquals( 125 | Array.from(tbody.childNodes).find((node) => node === tr), 126 | undefined, 127 | ); 128 | assertEquals(tbody.children.length, 1); 129 | assertEquals(tbody.childNodes.length, 1); 130 | assertEquals(tbody.innerHTML, `goodbye`); 131 | 132 | otherChild.outerHTML = ``; 133 | 134 | assertEquals(otherChild.parentNode, null); 135 | assertEquals(otherChild.parentElement, null); 136 | assertEquals( 137 | Array.from(otherParent.childNodes).find((node) => node === otherChild), 138 | undefined, 139 | ); 140 | assertEquals(otherParent.children.length, 2); 141 | assertEquals(otherParent.childNodes.length, 4); 142 | assertEquals( 143 | otherParent.outerHTML, 144 | `
1st
`, 145 | ); 146 | 147 | const solitaryDiv = doc.createElement("div"); 148 | solitaryDiv.outerHTML = `no-op`; 149 | 150 | assertEquals(solitaryDiv.parentNode, null); 151 | assertEquals(solitaryDiv.outerHTML, `
`); 152 | 153 | const frag = doc.createDocumentFragment(); 154 | const fragChild = doc.createElement("div"); 155 | frag.appendChild(fragChild); 156 | assertEquals(fragChild.parentNode, frag); 157 | assertEquals(frag.childNodes[0].nodeName, "DIV"); 158 | assertEquals(frag.childNodes.length, 1); 159 | 160 | fragChild.outerHTML = ` 161 | 162 | 163 | only text nodes allowed 164 | 165 | `.replace(/\s{2,}/g, ""); 166 | 167 | assertEquals(fragChild.parentNode, null); 168 | assertEquals(frag.children.length, 2); 169 | assertEquals(frag.childNodes.length, 4); 170 | assertEquals( 171 | Array.from(frag.childNodes).map((node) => node.nodeName).join("-"), 172 | "ASIDE-#comment-#text-BUTTON", 173 | ); 174 | 175 | assertThrows(() => { 176 | doc.documentElement!.outerHTML = "
not new document element
"; 177 | }); 178 | }); 179 | 180 | Deno.test( 181 | "setting Element.innerHTML should not escape