├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintrc.json ├── .github └── workflows │ ├── nodejs.yml │ └── publish.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── custom-elements.json ├── examples ├── index.html └── pull.html ├── package-lock.json ├── package.json ├── src ├── include-fragment-element-define.ts ├── include-fragment-element.ts └── index.ts ├── test └── test.js ├── tsconfig.json └── web-test-runner.config.js /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "22" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "yarn install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "plugin:github/browser", 5 | "plugin:github/recommended", 6 | "plugin:github/typescript", 7 | "plugin:custom-elements/recommended" 8 | ], 9 | "rules": { 10 | "custom-elements/tag-name-matches-class": [ 11 | "error", 12 | { 13 | "suffix": "Element" 14 | } 15 | ], 16 | "custom-elements/define-tag-after-class-definition": "off", 17 | "custom-elements/no-method-prefixed-with-on": "off", 18 | "custom-elements/expose-class-on-global": "off", 19 | "import/extensions": ["error", "always"], 20 | "import/no-unresolved": "off" 21 | }, 22 | "overrides": [ 23 | { 24 | "files": "src/*-define.ts", 25 | "rules": { 26 | "@typescript-eslint/no-namespace": "off" 27 | } 28 | }, 29 | { 30 | "files": "test/**/*.js", 31 | "rules": { 32 | "github/unescaped-html-literal": "off", 33 | "github/no-inner-html": "off", 34 | "i18n-text/no-en": "off" 35 | }, 36 | "env": { 37 | "mocha": true 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: push 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Use Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 22.x 17 | - name: npm install, build, and test 18 | run: | 19 | npm install 20 | npm run build --if-present 21 | npm test 22 | env: 23 | CI: true 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | id-token: write 10 | 11 | jobs: 12 | publish-npm: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | registry-url: https://registry.npmjs.org/ 20 | cache: npm 21 | - run: npm ci 22 | - run: npm test 23 | - run: npm version ${TAG_NAME} --git-tag-version=false 24 | env: 25 | TAG_NAME: ${{ github.event.release.tag_name }} 26 | - run: npm whoami; npm --ignore-scripts publish --provenance 27 | env: 28 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/primer-reviewers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2018 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <include-fragment> element 2 | 3 | A Client Side Includes tag. 4 | 5 | ## Installation 6 | 7 | ``` 8 | $ npm install --save @github/include-fragment-element 9 | ``` 10 | 11 | ## Usage 12 | 13 | All `include-fragment` elements must have a `src` attribute from which to retrieve an HTML element fragment. 14 | 15 | The initial page load should include fallback content to be displayed if the resource could not be fetched immediately. 16 | 17 | ```js 18 | import '@github/include-fragment-element' 19 | ``` 20 | 21 | **Original** 22 | 23 | ``` html 24 |
25 | 26 |

Loading tip…

27 |
28 |
29 | ``` 30 | 31 | On page load, the `include-fragment` element fetches the URL, the response is parsed into an HTML element, which replaces the `include-fragment` element entirely. 32 | 33 | **Result** 34 | 35 | ``` html 36 |
37 |

You look nice today

38 |
39 | ``` 40 | 41 | The server must respond with an HTML fragment to replace the `include-fragment` element. It should not contain _another_ `include-fragment` element or the server will be polled in an infinite loop. 42 | 43 | ### Other Attributes 44 | 45 | #### accept 46 | 47 | This attribute tells `` what to send as the `Accept` header, as part of the fetch request. If omitted, or if set to an empty value, the default behaviour will be `text/html`. It is important that the server responds with HTML, but you may wish to change the accept header to help negotiate the right content with the server. 48 | 49 | #### loading 50 | 51 | This indicates _when_ the contents should be fetched: 52 | 53 | - `eager`: Fetches and load the content immediately, regardless of whether or not the `` is currently within the visible viewport (this is the default value). 54 | - `lazy`: Defers fetching and loading the content until the `` tag reaches a calculated distance from the viewport. The intent is to avoid the network and storage bandwidth needed to handle the content until it's reasonably certain that it will be needed. 55 | 56 | ### Errors 57 | 58 | If the URL fails to load, the `include-fragment` element is left in the page and tagged with an `is-error` CSS class that can be used for styling. 59 | 60 | ### Events 61 | 62 | Request lifecycle events are dispatched on the `` element. 63 | 64 | - `loadstart` - The server fetch has started. 65 | - `load` - The request completed successfully. 66 | - `error` - The request failed. 67 | - `loadend` - The request has completed. 68 | - `include-fragment-replace` (cancelable) - The success response has been parsed. It comes with `event.detail.fragment` that will replace the current element. 69 | - `include-fragment-replaced` - The element has been replaced by the fragment. 70 | 71 | ```js 72 | const loader = document.querySelector('include-fragment') 73 | const container = loader.parentElement 74 | loader.addEventListener('loadstart', () => container.classList.add('is-loading')) 75 | loader.addEventListener('loadend', () => container.classList.remove('is-loading')) 76 | loader.addEventListener('load', () => container.classList.add('is-success')) 77 | loader.addEventListener('error', () => container.classList.add('is-error')) 78 | ``` 79 | 80 | ### Options 81 | 82 | Attribute | Options | Description 83 | --- | --- | --- 84 | `src` | URL string | Required URL from which to load the replacement HTML element fragment. 85 | 86 | 87 | ### Deferred loading 88 | 89 | The request for replacement markup from the server starts when the `src` attribute becomes available on the `` element. Most often this will happen at page load when the element is rendered. However, if we omit the `src` attribute until some later time, we can defer loading the content at all. 90 | 91 | The [``][menu] element uses this technique to defer loading menu content until the menu is first opened. 92 | 93 | [menu]: https://github.com/github/details-menu-element 94 | 95 | ## Patterns 96 | 97 | Deferring the display of markup is typically done in the following usage patterns. 98 | 99 | - A user action begins a slow running background job on the server, like backing up files stored on the server. While the backup job is running, a progress bar is shown to the user. When it's complete, the include-fragment element is replaced with a link to the backup files. 100 | 101 | - The first time a user visits a page that contains a time-consuming piece of markup to generate, a loading indicator is displayed. When the markup is finished building on the server, it's stored in memcache and sent to the browser to replace the include-fragment loader. Subsequent visits to the page render the cached markup directly, without going through a include-fragment element. 102 | 103 | ### CSP Trusted Types 104 | 105 | You can call `setCSPTrustedTypesPolicy(policy: TrustedTypePolicy | Promise | null)` from JavaScript to set a [CSP trusted types policy](https://web.dev/trusted-types/), which can perform (synchronous) filtering or rejection of the `fetch` response before it is inserted into the page: 106 | 107 | ```ts 108 | import IncludeFragmentElement from "include-fragment-element"; 109 | import DOMPurify from "dompurify"; // Using https://github.com/cure53/DOMPurify 110 | 111 | // This policy removes all HTML markup except links. 112 | const policy = trustedTypes.createPolicy("links-only", { 113 | createHTML: (htmlText: string) => { 114 | return DOMPurify.sanitize(htmlText, { 115 | ALLOWED_TAGS: ["a"], 116 | ALLOWED_ATTR: ["href"], 117 | RETURN_TRUSTED_TYPE: true, 118 | }); 119 | }, 120 | }); 121 | IncludeFragmentElement.setCSPTrustedTypesPolicy(policy); 122 | ``` 123 | 124 | The policy has access to the `fetch` response object. Due to platform constraints, only synchronous information from the response (in addition to the HTML text body) can be used in the policy: 125 | 126 | ```ts 127 | import IncludeFragmentElement from "include-fragment-element"; 128 | 129 | const policy = trustedTypes.createPolicy("require-server-header", { 130 | createHTML: (htmlText: string, response: Response) => { 131 | if (response.headers.get("X-Server-Sanitized") !== "sanitized=true") { 132 | // Note: this will reject the contents, but the error may be caught before it shows in the JS console. 133 | throw new Error("Rejecting HTML that was not marked by the server as sanitized."); 134 | } 135 | return htmlText; 136 | }, 137 | }); 138 | IncludeFragmentElement.setCSPTrustedTypesPolicy(policy); 139 | ``` 140 | 141 | Note that: 142 | 143 | - Only a single policy can be set, shared by all `IncludeFragmentElement` fetches. 144 | - You should call `setCSPTrustedTypesPolicy()` ahead of any other load of `include-fragment-element` in your code. 145 | - If your policy itself requires asynchronous work to construct, you can also pass a `Promise`. 146 | - Pass `null` to remove the policy. 147 | - Not all browsers [support the trusted types API in JavaScript](https://caniuse.com/mdn-api_trustedtypes). You may want to use the [recommended tinyfill](https://github.com/w3c/trusted-types#tinyfill) to construct a policy without causing issues in other browsers. 148 | 149 | ## Relation to Server Side Includes 150 | 151 | This declarative approach is very similar to [SSI](http://en.wikipedia.org/wiki/Server_Side_Includes) or [ESI](http://en.wikipedia.org/wiki/Edge_Side_Includes) directives. In fact, an edge implementation could replace the markup before its actually delivered to the client. 152 | 153 | ``` html 154 | 155 |

Counting commits…

156 |
157 | ``` 158 | 159 | A proxy may attempt to fetch and replace the fragment if the request finishes before the timeout. Otherwise the tag is delivered to the client. This library only implements the client side aspect. 160 | 161 | ## Browser support 162 | 163 | Browsers without native [custom element support][support] require a [polyfill][]. Legacy browsers require various other polyfills. See [`examples/index.html`][example] for details. 164 | 165 | [example]: https://github.com/github/include-fragment-element/blob/master/examples/index.html#L5-L14 166 | 167 | - Chrome 168 | - Firefox 169 | - Safari 170 | - Microsoft Edge 171 | 172 | [support]: https://caniuse.com/#feat=custom-elementsv1 173 | [polyfill]: https://github.com/webcomponents/custom-elements 174 | 175 | ## Development 176 | 177 | ``` 178 | npm install 179 | npm test 180 | ``` 181 | 182 | ## License 183 | 184 | Distributed under the MIT license. See LICENSE for details. 185 | -------------------------------------------------------------------------------- /custom-elements.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "readme": "", 4 | "modules": [ 5 | { 6 | "kind": "javascript-module", 7 | "path": "dist/bundle.js", 8 | "declarations": [ 9 | { 10 | "kind": "variable", 11 | "name": "IncludeFragmentElement", 12 | "default": "class extends HTMLElement {\n constructor() {\n super(...arguments);\n _IncludeFragmentElement_instances.add(this);\n _IncludeFragmentElement_busy.set(this, false);\n _IncludeFragmentElement_observer.set(this, new IntersectionObserver((entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const { target } = entry;\n __classPrivateFieldGet(this, _IncludeFragmentElement_observer, \"f\").unobserve(target);\n if (!(target instanceof IncludeFragmentElement))\n return;\n if (target.loading === \"lazy\") {\n __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_handleData).call(this);\n }\n }\n }\n }, {\n rootMargin: \"0px 0px 256px 0px\",\n threshold: 0.01\n }));\n }\n static define(tag = \"include-fragment\", registry = customElements) {\n registry.define(tag, this);\n return this;\n }\n static setCSPTrustedTypesPolicy(policy) {\n cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy);\n }\n static get observedAttributes() {\n return [\"src\", \"loading\"];\n }\n get src() {\n const src = this.getAttribute(\"src\");\n if (src) {\n const link = this.ownerDocument.createElement(\"a\");\n link.href = src;\n return link.href;\n } else {\n return \"\";\n }\n }\n set src(val) {\n this.setAttribute(\"src\", val);\n }\n get loading() {\n if (this.getAttribute(\"loading\") === \"lazy\")\n return \"lazy\";\n return \"eager\";\n }\n set loading(value) {\n this.setAttribute(\"loading\", value);\n }\n get accept() {\n return this.getAttribute(\"accept\") || \"\";\n }\n set accept(val) {\n this.setAttribute(\"accept\", val);\n }\n get data() {\n return __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_getStringOrErrorData).call(this);\n }\n attributeChangedCallback(attribute, oldVal) {\n if (attribute === \"src\") {\n if (this.isConnected && this.loading === \"eager\") {\n __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_handleData).call(this);\n }\n } else if (attribute === \"loading\") {\n if (this.isConnected && oldVal !== \"eager\" && this.loading === \"eager\") {\n __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_handleData).call(this);\n }\n }\n }\n connectedCallback() {\n if (!this.shadowRoot) {\n this.attachShadow({ mode: \"open\" });\n const style = document.createElement(\"style\");\n style.textContent = `:host {display: block;}`;\n this.shadowRoot.append(style, document.createElement(\"slot\"));\n }\n if (this.src && this.loading === \"eager\") {\n __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_handleData).call(this);\n }\n if (this.loading === \"lazy\") {\n __classPrivateFieldGet(this, _IncludeFragmentElement_observer, \"f\").observe(this);\n }\n }\n request() {\n const src = this.src;\n if (!src) {\n throw new Error(\"missing src\");\n }\n return new Request(src, {\n method: \"GET\",\n credentials: \"same-origin\",\n headers: {\n Accept: this.accept || \"text/html\"\n }\n });\n }\n load() {\n return __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_getStringOrErrorData).call(this);\n }\n fetch(request) {\n return fetch(request);\n }\n refetch() {\n privateData.delete(this);\n __classPrivateFieldGet(this, _IncludeFragmentElement_instances, \"m\", _IncludeFragmentElement_handleData).call(this);\n }\n}" 13 | }, 14 | { 15 | "kind": "variable", 16 | "name": "dist_default", 17 | "default": "IncludeFragmentElement" 18 | } 19 | ], 20 | "exports": [ 21 | { 22 | "kind": "js", 23 | "name": "IncludeFragmentElement", 24 | "declaration": { 25 | "name": "IncludeFragmentElement", 26 | "module": "dist/bundle.js" 27 | } 28 | }, 29 | { 30 | "kind": "js", 31 | "name": "default", 32 | "declaration": { 33 | "name": "dist_default", 34 | "module": "dist/bundle.js" 35 | } 36 | } 37 | ] 38 | }, 39 | { 40 | "kind": "javascript-module", 41 | "path": "dist/include-fragment-element-define.js", 42 | "declarations": [], 43 | "exports": [ 44 | { 45 | "kind": "js", 46 | "name": "default", 47 | "declaration": { 48 | "name": "IncludeFragmentElement", 49 | "module": "dist/include-fragment-element-define.js" 50 | } 51 | }, 52 | { 53 | "kind": "js", 54 | "name": "*", 55 | "declaration": { 56 | "name": "*", 57 | "package": "./include-fragment-element.js" 58 | } 59 | } 60 | ] 61 | }, 62 | { 63 | "kind": "javascript-module", 64 | "path": "dist/include-fragment-element.js", 65 | "declarations": [ 66 | { 67 | "kind": "class", 68 | "description": "", 69 | "name": "IncludeFragmentElement", 70 | "members": [ 71 | { 72 | "kind": "method", 73 | "name": "define", 74 | "static": true, 75 | "parameters": [ 76 | { 77 | "name": "tag", 78 | "default": "'include-fragment'" 79 | }, 80 | { 81 | "name": "registry", 82 | "default": "customElements" 83 | } 84 | ] 85 | }, 86 | { 87 | "kind": "method", 88 | "name": "setCSPTrustedTypesPolicy", 89 | "static": true, 90 | "parameters": [ 91 | { 92 | "name": "policy" 93 | } 94 | ] 95 | }, 96 | { 97 | "kind": "field", 98 | "name": "src" 99 | }, 100 | { 101 | "kind": "field", 102 | "name": "loading" 103 | }, 104 | { 105 | "kind": "field", 106 | "name": "accept" 107 | }, 108 | { 109 | "kind": "field", 110 | "name": "data", 111 | "readonly": true 112 | }, 113 | { 114 | "kind": "method", 115 | "name": "request" 116 | }, 117 | { 118 | "kind": "method", 119 | "name": "load" 120 | }, 121 | { 122 | "kind": "method", 123 | "name": "fetch", 124 | "parameters": [ 125 | { 126 | "name": "request" 127 | } 128 | ] 129 | }, 130 | { 131 | "kind": "method", 132 | "name": "refetch" 133 | } 134 | ], 135 | "attributes": [ 136 | { 137 | "name": "src" 138 | }, 139 | { 140 | "name": "loading" 141 | } 142 | ], 143 | "superclass": { 144 | "name": "HTMLElement" 145 | }, 146 | "customElement": true 147 | } 148 | ], 149 | "exports": [ 150 | { 151 | "kind": "js", 152 | "name": "IncludeFragmentElement", 153 | "declaration": { 154 | "name": "IncludeFragmentElement", 155 | "module": "dist/include-fragment-element.js" 156 | } 157 | } 158 | ] 159 | }, 160 | { 161 | "kind": "javascript-module", 162 | "path": "dist/index.js", 163 | "declarations": [], 164 | "exports": [ 165 | { 166 | "kind": "js", 167 | "name": "IncludeFragmentElement", 168 | "declaration": { 169 | "name": "IncludeFragmentElement", 170 | "module": "dist/index.js" 171 | } 172 | }, 173 | { 174 | "kind": "js", 175 | "name": "default", 176 | "declaration": { 177 | "name": "IncludeFragmentElement", 178 | "module": "dist/index.js" 179 | } 180 | }, 181 | { 182 | "kind": "js", 183 | "name": "*", 184 | "declaration": { 185 | "name": "*", 186 | "package": "./include-fragment-element-define.js" 187 | } 188 | } 189 | ] 190 | }, 191 | { 192 | "kind": "javascript-module", 193 | "path": "test/test.js", 194 | "declarations": [], 195 | "exports": [] 196 | }, 197 | { 198 | "kind": "javascript-module", 199 | "path": "src/include-fragment-element-define.ts", 200 | "declarations": [], 201 | "exports": [ 202 | { 203 | "kind": "js", 204 | "name": "default", 205 | "declaration": { 206 | "name": "IncludeFragmentElement", 207 | "module": "src/include-fragment-element-define.ts" 208 | } 209 | }, 210 | { 211 | "kind": "js", 212 | "name": "*", 213 | "declaration": { 214 | "name": "*", 215 | "package": "./include-fragment-element.js" 216 | } 217 | } 218 | ] 219 | }, 220 | { 221 | "kind": "javascript-module", 222 | "path": "src/include-fragment-element.ts", 223 | "declarations": [ 224 | { 225 | "kind": "class", 226 | "description": "", 227 | "name": "IncludeFragmentElement", 228 | "members": [ 229 | { 230 | "kind": "method", 231 | "name": "define", 232 | "static": true, 233 | "parameters": [ 234 | { 235 | "name": "tag", 236 | "default": "'include-fragment'" 237 | }, 238 | { 239 | "name": "registry", 240 | "default": "customElements" 241 | } 242 | ] 243 | }, 244 | { 245 | "kind": "method", 246 | "name": "setCSPTrustedTypesPolicy", 247 | "static": true, 248 | "return": { 249 | "type": { 250 | "text": "void" 251 | } 252 | }, 253 | "parameters": [ 254 | { 255 | "name": "policy", 256 | "type": { 257 | "text": "CSPTrustedTypesPolicy | Promise | null" 258 | } 259 | } 260 | ] 261 | }, 262 | { 263 | "kind": "field", 264 | "name": "src", 265 | "type": { 266 | "text": "string" 267 | } 268 | }, 269 | { 270 | "kind": "field", 271 | "name": "loading", 272 | "type": { 273 | "text": "'eager' | 'lazy'" 274 | } 275 | }, 276 | { 277 | "kind": "field", 278 | "name": "accept", 279 | "type": { 280 | "text": "string" 281 | } 282 | }, 283 | { 284 | "kind": "field", 285 | "name": "data", 286 | "type": { 287 | "text": "Promise" 288 | }, 289 | "readonly": true 290 | }, 291 | { 292 | "kind": "field", 293 | "name": "#busy", 294 | "privacy": "private", 295 | "type": { 296 | "text": "boolean" 297 | }, 298 | "default": "false" 299 | }, 300 | { 301 | "kind": "method", 302 | "name": "request", 303 | "return": { 304 | "type": { 305 | "text": "Request" 306 | } 307 | } 308 | }, 309 | { 310 | "kind": "method", 311 | "name": "load", 312 | "return": { 313 | "type": { 314 | "text": "Promise" 315 | } 316 | } 317 | }, 318 | { 319 | "kind": "method", 320 | "name": "fetch", 321 | "return": { 322 | "type": { 323 | "text": "Promise" 324 | } 325 | }, 326 | "parameters": [ 327 | { 328 | "name": "request", 329 | "type": { 330 | "text": "RequestInfo" 331 | } 332 | } 333 | ] 334 | }, 335 | { 336 | "kind": "method", 337 | "name": "refetch" 338 | }, 339 | { 340 | "kind": "field", 341 | "name": "#observer", 342 | "privacy": "private", 343 | "default": "new IntersectionObserver(\n entries => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const {target} = entry\n this.#observer.unobserve(target)\n if (!(target instanceof IncludeFragmentElement)) return\n if (target.loading === 'lazy') {\n this.#handleData()\n }\n }\n }\n },\n {\n // Currently the threshold is set to 256px from the bottom of the viewport\n // with a threshold of 0.1. This means the element will not load until about\n // 2 keyboard-down-arrow presses away from being visible in the viewport,\n // giving us some time to fetch it before the contents are made visible\n rootMargin: '0px 0px 256px 0px',\n threshold: 0.01,\n },\n )" 344 | }, 345 | { 346 | "kind": "method", 347 | "name": "#handleData", 348 | "return": { 349 | "type": { 350 | "text": "Promise" 351 | } 352 | } 353 | }, 354 | { 355 | "kind": "method", 356 | "name": "#getData", 357 | "return": { 358 | "type": { 359 | "text": "Promise" 360 | } 361 | } 362 | }, 363 | { 364 | "kind": "method", 365 | "name": "#getStringOrErrorData", 366 | "return": { 367 | "type": { 368 | "text": "Promise" 369 | } 370 | } 371 | }, 372 | { 373 | "kind": "method", 374 | "name": "#task", 375 | "return": { 376 | "type": { 377 | "text": "Promise" 378 | } 379 | }, 380 | "parameters": [ 381 | { 382 | "name": "eventsToDispatch", 383 | "type": { 384 | "text": "string[]" 385 | } 386 | } 387 | ] 388 | }, 389 | { 390 | "kind": "method", 391 | "name": "#fetchDataWithEvents", 392 | "return": { 393 | "type": { 394 | "text": "Promise" 395 | } 396 | } 397 | } 398 | ], 399 | "events": [ 400 | { 401 | "name": "include-fragment-replace", 402 | "type": { 403 | "text": "CustomEvent" 404 | } 405 | }, 406 | { 407 | "name": "include-fragment-replaced", 408 | "type": { 409 | "text": "CustomEvent" 410 | } 411 | }, 412 | { 413 | "name": "eventType", 414 | "type": { 415 | "text": "Event" 416 | } 417 | } 418 | ], 419 | "attributes": [ 420 | { 421 | "name": "src" 422 | }, 423 | { 424 | "name": "loading" 425 | } 426 | ], 427 | "superclass": { 428 | "name": "HTMLElement" 429 | }, 430 | "customElement": true 431 | } 432 | ], 433 | "exports": [ 434 | { 435 | "kind": "js", 436 | "name": "IncludeFragmentElement", 437 | "declaration": { 438 | "name": "IncludeFragmentElement", 439 | "module": "src/include-fragment-element.ts" 440 | } 441 | } 442 | ] 443 | }, 444 | { 445 | "kind": "javascript-module", 446 | "path": "src/index.ts", 447 | "declarations": [], 448 | "exports": [ 449 | { 450 | "kind": "js", 451 | "name": "IncludeFragmentElement", 452 | "declaration": { 453 | "name": "IncludeFragmentElement", 454 | "module": "src/index.ts" 455 | } 456 | }, 457 | { 458 | "kind": "js", 459 | "name": "default", 460 | "declaration": { 461 | "name": "IncludeFragmentElement", 462 | "module": "src/index.ts" 463 | } 464 | }, 465 | { 466 | "kind": "js", 467 | "name": "*", 468 | "declaration": { 469 | "name": "*", 470 | "package": "./include-fragment-element-define.js" 471 | } 472 | } 473 | ] 474 | } 475 | ] 476 | } 477 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | include-fragment demo 6 | 7 | 8 | Loading 9 | 10 | 11 |
12 | Click to unfold a lazy include-fragment 13 | Loading 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/pull.html: -------------------------------------------------------------------------------- 1 | Works. 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/include-fragment-element", 3 | "version": "6.0.0", 4 | "main": "dist/index.js", 5 | "module": "dist/index.js", 6 | "type": "module", 7 | "types": "dist/index.d.ts", 8 | "license": "MIT", 9 | "repository": "github/include-fragment-element", 10 | "files": [ 11 | "dist" 12 | ], 13 | "exports": { 14 | ".": "./dist/index.js", 15 | "./define": "./dist/index.js", 16 | "./include-fragment": "./dist/include-fragment-element.js", 17 | "./include-fragment/define": "./dist/include-fragment-element-define.js" 18 | }, 19 | "scripts": { 20 | "clean": "rm -rf dist", 21 | "lint": "eslint . --ext .js,.ts && tsc --noEmit", 22 | "lint:fix": "eslint --fix . --ext .js,.ts", 23 | "prebuild": "npm run clean && npm run lint && mkdir dist", 24 | "bundle": "esbuild --bundle dist/index.js --keep-names --outfile=dist/bundle.js --format=esm", 25 | "build": "tsc && npm run bundle && npm run manifest", 26 | "prepublishOnly": "npm run build", 27 | "pretest": "npm run build", 28 | "test": "web-test-runner", 29 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'", 30 | "manifest": "custom-elements-manifest analyze" 31 | }, 32 | "prettier": "@github/prettier-config", 33 | "devDependencies": { 34 | "@custom-elements-manifest/analyzer": "^0.8.3", 35 | "@github/prettier-config": "^0.0.6", 36 | "@open-wc/testing": "^3.2.2", 37 | "@web/dev-server-esbuild": "^0.4.1", 38 | "@web/test-runner": "^0.19.0", 39 | "@web/test-runner-playwright": "^0.11.0", 40 | "esbuild": "^0.25.2", 41 | "eslint": "^8.42.0", 42 | "eslint-plugin-custom-elements": "^0.0.8", 43 | "eslint-plugin-github": "^4.8.0", 44 | "typescript": "^5.1.3" 45 | }, 46 | "customElements": "custom-elements.json", 47 | "eslintIgnore": [ 48 | "dist/" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/include-fragment-element-define.ts: -------------------------------------------------------------------------------- 1 | import {IncludeFragmentElement} from './include-fragment-element.js' 2 | 3 | const root = (typeof globalThis !== 'undefined' ? globalThis : window) as typeof window 4 | try { 5 | root.IncludeFragmentElement = IncludeFragmentElement.define() 6 | } catch (e: unknown) { 7 | if ( 8 | !(root.DOMException && e instanceof DOMException && e.name === 'NotSupportedError') && 9 | !(e instanceof ReferenceError) 10 | ) { 11 | throw e 12 | } 13 | } 14 | 15 | type JSXBase = JSX.IntrinsicElements extends {span: unknown} 16 | ? JSX.IntrinsicElements 17 | : Record> 18 | declare global { 19 | interface Window { 20 | IncludeFragmentElement: typeof IncludeFragmentElement 21 | } 22 | interface HTMLElementTagNameMap { 23 | 'include-fragment': IncludeFragmentElement 24 | } 25 | namespace JSX { 26 | interface IntrinsicElements { 27 | ['include-fragment']: JSXBase['span'] & Partial> 28 | } 29 | } 30 | } 31 | 32 | export default IncludeFragmentElement 33 | export * from './include-fragment-element.js' 34 | -------------------------------------------------------------------------------- /src/include-fragment-element.ts: -------------------------------------------------------------------------------- 1 | interface CachedData { 2 | src: string 3 | data: Promise 4 | } 5 | const privateData = new WeakMap() 6 | 7 | function isWildcard(accept: string | null) { 8 | return accept && !!accept.split(',').find(x => x.match(/^\s*\*\/\*/)) 9 | } 10 | 11 | // CSP trusted types: We don't want to add `@types/trusted-types` as a 12 | // dependency, so we use the following types as a stand-in. 13 | interface CSPTrustedTypesPolicy { 14 | createHTML: (s: string, response: Response) => CSPTrustedHTMLToStringable 15 | } 16 | // Note: basically every object (and some primitives) in JS satisfy this 17 | // `CSPTrustedHTMLToStringable` interface, but this is the most compatible shape 18 | // we can use. 19 | interface CSPTrustedHTMLToStringable { 20 | toString: () => string 21 | } 22 | let cspTrustedTypesPolicyPromise: Promise | null = null 23 | 24 | export class IncludeFragmentElement extends HTMLElement { 25 | static define(tag = 'include-fragment', registry = customElements) { 26 | registry.define(tag, this) 27 | return this 28 | } 29 | 30 | // Passing `null` clears the policy. 31 | static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise | null): void { 32 | cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy) 33 | } 34 | 35 | static get observedAttributes(): string[] { 36 | return ['src', 'loading'] 37 | } 38 | 39 | get src(): string { 40 | const src = this.getAttribute('src') 41 | if (src) { 42 | const link = this.ownerDocument!.createElement('a') 43 | link.href = src 44 | return link.href 45 | } else { 46 | return '' 47 | } 48 | } 49 | 50 | set src(val: string) { 51 | this.setAttribute('src', val) 52 | } 53 | 54 | get loading(): 'eager' | 'lazy' { 55 | if (this.getAttribute('loading') === 'lazy') return 'lazy' 56 | return 'eager' 57 | } 58 | 59 | set loading(value: 'eager' | 'lazy') { 60 | this.setAttribute('loading', value) 61 | } 62 | 63 | get accept(): string { 64 | return this.getAttribute('accept') || '' 65 | } 66 | 67 | set accept(val: string) { 68 | this.setAttribute('accept', val) 69 | } 70 | 71 | // We will return string or error for API backwards compatibility. We can consider 72 | // returning TrustedHTML in the future. 73 | get data(): Promise { 74 | return this.#getStringOrErrorData() 75 | } 76 | 77 | #busy = false 78 | 79 | attributeChangedCallback(attribute: string, oldVal: string | null): void { 80 | if (attribute === 'src') { 81 | // Source changed after attached so replace element. 82 | if (this.isConnected && this.loading === 'eager') { 83 | this.#handleData() 84 | } 85 | } else if (attribute === 'loading') { 86 | // Loading mode changed to Eager after attached so replace element. 87 | if (this.isConnected && oldVal !== 'eager' && this.loading === 'eager') { 88 | this.#handleData() 89 | } 90 | } 91 | } 92 | 93 | connectedCallback(): void { 94 | if (!this.shadowRoot) { 95 | this.attachShadow({mode: 'open'}) 96 | const style = document.createElement('style') 97 | style.textContent = `:host {display: block;}` 98 | this.shadowRoot!.append(style, document.createElement('slot')) 99 | } 100 | if (this.src && this.loading === 'eager') { 101 | this.#handleData() 102 | } 103 | if (this.loading === 'lazy') { 104 | this.#observer.observe(this) 105 | } 106 | } 107 | 108 | request(): Request { 109 | const src = this.src 110 | if (!src) { 111 | throw new Error('missing src') 112 | } 113 | 114 | return new Request(src, { 115 | method: 'GET', 116 | credentials: 'same-origin', 117 | headers: { 118 | Accept: this.accept || 'text/html', 119 | }, 120 | }) 121 | } 122 | 123 | load(): Promise { 124 | return this.#getStringOrErrorData() 125 | } 126 | 127 | fetch(request: RequestInfo): Promise { 128 | return fetch(request) 129 | } 130 | 131 | refetch() { 132 | privateData.delete(this) 133 | this.#handleData() 134 | } 135 | 136 | #observer = new IntersectionObserver( 137 | entries => { 138 | for (const entry of entries) { 139 | if (entry.isIntersecting) { 140 | const {target} = entry 141 | this.#observer.unobserve(target) 142 | if (!(target instanceof IncludeFragmentElement)) return 143 | if (target.loading === 'lazy') { 144 | this.#handleData() 145 | } 146 | } 147 | } 148 | }, 149 | { 150 | // Currently the threshold is set to 256px from the bottom of the viewport 151 | // with a threshold of 0.1. This means the element will not load until about 152 | // 2 keyboard-down-arrow presses away from being visible in the viewport, 153 | // giving us some time to fetch it before the contents are made visible 154 | rootMargin: '0px 0px 256px 0px', 155 | threshold: 0.01, 156 | }, 157 | ) 158 | 159 | async #handleData(): Promise { 160 | if (this.#busy) return 161 | this.#busy = true 162 | this.#observer.unobserve(this) 163 | try { 164 | const data = await this.#getData() 165 | if (data instanceof Error) { 166 | throw data 167 | } 168 | // Until TypeScript is natively compatible with CSP trusted types, we 169 | // have to treat this as a string here. 170 | // https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1246 171 | const dataTreatedAsString = data as string 172 | 173 | const template = document.createElement('template') 174 | // eslint-disable-next-line github/no-inner-html 175 | template.innerHTML = dataTreatedAsString 176 | const fragment = document.importNode(template.content, true) 177 | const canceled = !this.dispatchEvent( 178 | new CustomEvent('include-fragment-replace', { 179 | cancelable: true, 180 | detail: {fragment}, 181 | }), 182 | ) 183 | if (canceled) { 184 | this.#busy = false 185 | return 186 | } 187 | 188 | this.replaceWith(fragment) 189 | this.dispatchEvent(new CustomEvent('include-fragment-replaced')) 190 | } catch { 191 | this.classList.add('is-error') 192 | } finally { 193 | this.#busy = false 194 | } 195 | } 196 | 197 | async #getData(): Promise { 198 | const src = this.src 199 | const cachedData = privateData.get(this) 200 | if (cachedData && cachedData.src === src) { 201 | return cachedData.data 202 | } else { 203 | let data: Promise 204 | if (src) { 205 | data = this.#fetchDataWithEvents() 206 | } else { 207 | data = Promise.reject(new Error('missing src')) 208 | } 209 | privateData.set(this, {src, data}) 210 | return data 211 | } 212 | } 213 | 214 | async #getStringOrErrorData(): Promise { 215 | const data = await this.#getData() 216 | if (data instanceof Error) { 217 | throw data 218 | } 219 | return data.toString() 220 | } 221 | 222 | // Functional stand in for the W3 spec "queue a task" paradigm 223 | async #task(eventsToDispatch: string[], error?: Error): Promise { 224 | await new Promise(resolve => setTimeout(resolve, 0)) 225 | for (const eventType of eventsToDispatch) { 226 | this.dispatchEvent(error ? new CustomEvent(eventType, {detail: {error}}) : new Event(eventType)) 227 | } 228 | } 229 | 230 | async #fetchDataWithEvents(): Promise { 231 | // We mimic the same event order as , including the spec 232 | // which states events must be dispatched after "queue a task". 233 | // https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element 234 | try { 235 | await this.#task(['loadstart']) 236 | const response = await this.fetch(this.request()) 237 | if (response.status !== 200) { 238 | throw new Error(`Failed to load resource: the server responded with a status of ${response.status}`) 239 | } 240 | const ct = response.headers.get('Content-Type') 241 | if (!isWildcard(this.accept) && (!ct || !ct.includes(this.accept ? this.accept : 'text/html'))) { 242 | throw new Error(`Failed to load resource: expected ${this.accept || 'text/html'} but was ${ct}`) 243 | } 244 | 245 | const responseText: string = await response.text() 246 | let data: string | CSPTrustedHTMLToStringable = responseText 247 | if (cspTrustedTypesPolicyPromise) { 248 | const cspTrustedTypesPolicy = await cspTrustedTypesPolicyPromise 249 | data = cspTrustedTypesPolicy.createHTML(responseText, response) 250 | } 251 | 252 | // Dispatch `load` and `loadend` async to allow 253 | // the `load()` promise to resolve _before_ these 254 | // events are fired. 255 | this.#task(['load', 'loadend']) 256 | return data 257 | } catch (error) { 258 | // Dispatch `error` and `loadend` async to allow 259 | // the `load()` promise to resolve _before_ these 260 | // events are fired. 261 | this.#task(['error', 'loadend'], error as Error) 262 | throw error 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {IncludeFragmentElement} from './include-fragment-element.js' 2 | 3 | export {IncludeFragmentElement} 4 | export default IncludeFragmentElement 5 | 6 | export * from './include-fragment-element-define.js' 7 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import {assert} from '@open-wc/testing' 2 | import {default as IncludeFragmentElement} from '../src/index.ts' 3 | 4 | let count 5 | const responses = { 6 | '/hello': function () { 7 | return new Response('
hello
', { 8 | status: 200, 9 | headers: { 10 | 'Content-Type': 'text/html; charset=utf-8', 11 | }, 12 | }) 13 | }, 14 | '/slow-hello': async function () { 15 | await new Promise(resolve => { 16 | setTimeout(resolve, 100) 17 | }) 18 | return responses['/hello']() 19 | }, 20 | '/one-two': function () { 21 | return new Response('

one

two

', { 22 | status: 200, 23 | headers: { 24 | 'Content-Type': 'text/html', 25 | }, 26 | }) 27 | }, 28 | '/blank-type': function () { 29 | return new Response('
hello
', { 30 | status: 200, 31 | headers: { 32 | 'Content-Type': null, 33 | }, 34 | }) 35 | }, 36 | '/x-server-sanitized': function () { 37 | return new Response('This response should be marked as sanitized using a custom header!', { 38 | status: 200, 39 | headers: { 40 | 'Content-Type': 'text/html; charset=utf-8', 41 | 'X-Server-Sanitized': 'sanitized=true', 42 | }, 43 | }) 44 | }, 45 | '/boom': function () { 46 | return new Response('boom', { 47 | status: 500, 48 | }) 49 | }, 50 | '/count': function () { 51 | count++ 52 | return new Response(`${count}`, { 53 | status: 200, 54 | headers: { 55 | 'Content-Type': 'text/html', 56 | }, 57 | }) 58 | }, 59 | '/fragment': function (request) { 60 | if (request.headers.get('Accept') === 'text/fragment+html') { 61 | return new Response('
fragment
', { 62 | status: 200, 63 | headers: { 64 | 'Content-Type': 'text/fragment+html', 65 | }, 66 | }) 67 | } else { 68 | return new Response('406', { 69 | status: 406, 70 | }) 71 | } 72 | }, 73 | '/test.js': function () { 74 | return new Response('alert("what")', { 75 | status: 200, 76 | headers: { 77 | 'Content-Type': 'text/javascript', 78 | }, 79 | }) 80 | }, 81 | } 82 | 83 | function when(el, eventType) { 84 | return new Promise(function (resolve) { 85 | el.addEventListener(eventType, resolve) 86 | }) 87 | } 88 | 89 | setup(function () { 90 | count = 0 91 | window.IncludeFragmentElement.prototype.fetch = function (request) { 92 | const pathname = new URL(request.url, window.location.origin).pathname 93 | return Promise.resolve(responses[pathname](request)) 94 | } 95 | }) 96 | 97 | suite('include-fragment-element', function () { 98 | teardown(() => { 99 | document.body.innerHTML = '' 100 | }) 101 | 102 | test('create from document.createElement', function () { 103 | const el = document.createElement('include-fragment') 104 | assert.equal('INCLUDE-FRAGMENT', el.nodeName) 105 | }) 106 | 107 | test('create from constructor', function () { 108 | const el = new window.IncludeFragmentElement() 109 | assert.equal('INCLUDE-FRAGMENT', el.nodeName) 110 | }) 111 | 112 | test('src property', function () { 113 | const el = document.createElement('include-fragment') 114 | assert.equal(null, el.getAttribute('src')) 115 | assert.equal('', el.src) 116 | 117 | el.src = '/hello' 118 | assert.equal('/hello', el.getAttribute('src')) 119 | const link = document.createElement('a') 120 | link.href = '/hello' 121 | assert.equal(link.href, el.src) 122 | }) 123 | 124 | test('initial data is in error state', function () { 125 | const el = document.createElement('include-fragment') 126 | 127 | return el.data['catch'](function (error) { 128 | assert.ok(error) 129 | }) 130 | }) 131 | 132 | test('data with src property', async function () { 133 | const el = document.createElement('include-fragment') 134 | el.src = '/hello' 135 | 136 | const html = await el.data 137 | assert.equal('
hello
', html) 138 | }) 139 | 140 | test('skips cache when using refetch', async function () { 141 | const el = document.createElement('include-fragment') 142 | el.src = '/count' 143 | 144 | let data = await el.data 145 | assert.equal('1', data) 146 | 147 | el.refetch() 148 | 149 | data = await el.data 150 | assert.equal('2', data) 151 | }) 152 | 153 | test('data with src attribute', async function () { 154 | const el = document.createElement('include-fragment') 155 | el.setAttribute('src', '/hello') 156 | 157 | const html = await el.data 158 | assert.equal('
hello
', html) 159 | }) 160 | 161 | test('setting data with src property multiple times', async function () { 162 | const el = document.createElement('include-fragment') 163 | el.src = '/count' 164 | 165 | const text = await el.data 166 | assert.equal('1', text) 167 | el.src = '/count' 168 | const text2 = await el.data 169 | assert.equal('1', text2) 170 | }) 171 | 172 | test('setting data with src attribute multiple times', async function () { 173 | const el = document.createElement('include-fragment') 174 | el.setAttribute('src', '/count') 175 | 176 | const text = await el.data 177 | assert.equal('1', text) 178 | el.setAttribute('src', '/count') 179 | const text2 = await el.data 180 | assert.equal('1', text2) 181 | }) 182 | 183 | test('throws on incorrect Content-Type', async function () { 184 | const el = document.createElement('include-fragment') 185 | el.setAttribute('src', '/test.js') 186 | 187 | try { 188 | await el.data 189 | throw new Error('el.data did not throw') 190 | } catch (error) { 191 | assert.match(error, /expected text\/html but was text\/javascript/) 192 | } 193 | }) 194 | 195 | test('throws on non-matching Content-Type', async function () { 196 | const el = document.createElement('include-fragment') 197 | el.setAttribute('accept', 'text/fragment+html') 198 | el.setAttribute('src', '/hello') 199 | 200 | try { 201 | await el.data 202 | throw new Error('el.data did not throw') 203 | } catch (error) { 204 | assert.match(error, /expected text\/fragment\+html but was text\/html; charset=utf-8/) 205 | } 206 | }) 207 | 208 | test('throws on 406', async function () { 209 | const el = document.createElement('include-fragment') 210 | el.setAttribute('src', '/fragment') 211 | 212 | try { 213 | await el.data 214 | throw new Error('el.data did not throw') 215 | } catch (error) { 216 | assert.match(error, /the server responded with a status of 406/) 217 | } 218 | }) 219 | 220 | test('data is not writable', async function () { 221 | const el = document.createElement('include-fragment') 222 | let data 223 | try { 224 | data = await el.data 225 | } catch { 226 | data = null 227 | } 228 | assert.ok(data !== 42) 229 | try { 230 | el.data = 42 231 | } catch (e) { 232 | assert.ok(e) 233 | } finally { 234 | let data2 235 | try { 236 | data2 = await el.data 237 | } catch { 238 | data2 = null 239 | } 240 | assert.ok(data2 !== 42) 241 | } 242 | }) 243 | 244 | test('data is not configurable', async function () { 245 | const el = document.createElement('include-fragment') 246 | let data 247 | try { 248 | data = await el.data 249 | } catch { 250 | data = null 251 | } 252 | assert.ok(data !== undefined) 253 | try { 254 | delete el.data 255 | } catch (e) { 256 | assert.ok(e) 257 | } finally { 258 | let data2 259 | try { 260 | data2 = await el.data 261 | } catch { 262 | data2 = null 263 | } 264 | assert.ok(data2 !== undefined) 265 | } 266 | }) 267 | 268 | test('replaces element on 200 status', async function () { 269 | const div = document.createElement('div') 270 | div.innerHTML = 'loading' 271 | document.body.appendChild(div) 272 | 273 | await when(div.firstChild, 'load') 274 | assert.equal(document.querySelector('include-fragment'), null) 275 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 276 | }) 277 | 278 | test('does not replace element if it has no parent', async function () { 279 | const div = document.createElement('div') 280 | div.innerHTML = 'loading' 281 | document.body.appendChild(div) 282 | 283 | const fragment = div.firstChild 284 | fragment.remove() 285 | fragment.src = '/hello' 286 | 287 | let didRun = false 288 | 289 | window.addEventListener('unhandledrejection', function () { 290 | assert.ok(false) 291 | }) 292 | 293 | fragment.addEventListener('loadstart', () => { 294 | didRun = true 295 | }) 296 | 297 | setTimeout(() => { 298 | assert.ok(!didRun) 299 | div.appendChild(fragment) 300 | }, 10) 301 | 302 | await when(fragment, 'load') 303 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 304 | }) 305 | 306 | test('replaces with several new elements on 200 status', async function () { 307 | const div = document.createElement('div') 308 | div.innerHTML = 'loading' 309 | document.body.appendChild(div) 310 | 311 | await when(div.firstChild, 'load') 312 | assert.equal(document.querySelector('include-fragment'), null) 313 | assert.equal(document.querySelector('#one').textContent, 'one') 314 | assert.equal(document.querySelector('#two').textContent, 'two') 315 | }) 316 | 317 | test('replaces with response with accept header for any', async function () { 318 | const div = document.createElement('div') 319 | div.innerHTML = 'loading' 320 | document.body.appendChild(div) 321 | 322 | await when(div.firstChild, 'load') 323 | assert.equal(document.querySelector('include-fragment'), null) 324 | assert.match(document.body.textContent, /alert\("what"\)/) 325 | }) 326 | 327 | test('replaces with response with the right accept header', async function () { 328 | const div = document.createElement('div') 329 | div.innerHTML = 'loading' 330 | document.body.appendChild(div) 331 | 332 | await when(div.firstChild, 'load') 333 | assert.equal(document.querySelector('include-fragment'), null) 334 | assert.equal(document.querySelector('#fragment').textContent, 'fragment') 335 | }) 336 | 337 | test('error event is not cancelable or bubbles', async function () { 338 | const div = document.createElement('div') 339 | div.innerHTML = 'loading' 340 | document.body.appendChild(div) 341 | 342 | const event = await when(div.firstChild, 'error') 343 | assert.equal(event.bubbles, false) 344 | assert.equal(event.cancelable, false) 345 | assert.instanceOf(event.detail.error, Error) 346 | assert.equal(event.detail.error.message, 'Failed to load resource: the server responded with a status of 500') 347 | }) 348 | 349 | test('adds is-error class on 500 status', async function () { 350 | const div = document.createElement('div') 351 | div.innerHTML = 'loading' 352 | document.body.appendChild(div) 353 | 354 | await when(div.firstChild, 'error') 355 | return assert.ok(document.querySelector('include-fragment').classList.contains('is-error')) 356 | }) 357 | 358 | test('adds is-error class on mising Content-Type', async function () { 359 | const div = document.createElement('div') 360 | div.innerHTML = 'loading' 361 | document.body.appendChild(div) 362 | 363 | await when(div.firstChild, 'error') 364 | return assert.ok(document.querySelector('include-fragment').classList.contains('is-error')) 365 | }) 366 | 367 | test('adds is-error class on incorrect Content-Type', async function () { 368 | const div = document.createElement('div') 369 | div.innerHTML = 'loading' 370 | document.body.appendChild(div) 371 | 372 | await when(div.firstChild, 'error') 373 | return assert.ok(document.querySelector('include-fragment').classList.contains('is-error')) 374 | }) 375 | 376 | test('replaces element when src attribute is changed', async function () { 377 | const elem = document.createElement('include-fragment') 378 | document.body.appendChild(elem) 379 | 380 | setTimeout(function () { 381 | elem.src = '/hello' 382 | }, 10) 383 | 384 | await when(elem, 'load') 385 | assert.equal(document.querySelector('include-fragment'), null) 386 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 387 | }) 388 | 389 | test('fires replaced event', async function () { 390 | const elem = document.createElement('include-fragment') 391 | document.body.appendChild(elem) 392 | 393 | setTimeout(function () { 394 | elem.src = '/hello' 395 | }, 10) 396 | 397 | await when(elem, 'include-fragment-replaced') 398 | assert.equal(document.querySelector('include-fragment'), null) 399 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 400 | }) 401 | 402 | test('fires events for include-fragment node replacement operations for fragment manipulation', async function () { 403 | const elem = document.createElement('include-fragment') 404 | document.body.appendChild(elem) 405 | 406 | setTimeout(function () { 407 | elem.src = '/hello' 408 | }, 10) 409 | 410 | elem.addEventListener('include-fragment-replace', event => { 411 | event.detail.fragment.querySelector('*').textContent = 'hey' 412 | }) 413 | 414 | await when(elem, 'include-fragment-replaced') 415 | assert.equal(document.querySelector('include-fragment'), null) 416 | assert.equal(document.querySelector('#replaced').textContent, 'hey') 417 | }) 418 | 419 | test('does not replace node if event was canceled ', async function () { 420 | const elem = document.createElement('include-fragment') 421 | document.body.appendChild(elem) 422 | 423 | setTimeout(function () { 424 | elem.src = '/hello' 425 | }, 10) 426 | 427 | elem.addEventListener('include-fragment-replace', event => { 428 | event.preventDefault() 429 | }) 430 | 431 | await when(elem, 'load') 432 | assert(document.querySelector('include-fragment'), 'Node should not be replaced') 433 | }) 434 | 435 | suite('event order', () => { 436 | const originalSetTimeout = window.setTimeout 437 | setup(() => { 438 | // Emulate some kind of timer clamping 439 | let i = 60 440 | window.setTimeout = (fn, ms, ...rest) => originalSetTimeout.call(window, fn, ms + (i -= 20), ...rest) 441 | }) 442 | teardown(() => { 443 | window.setTimeout = originalSetTimeout 444 | }) 445 | 446 | test('loading events fire in guaranteed order', async function () { 447 | const elem = document.createElement('include-fragment') 448 | const order = [] 449 | const connected = [] 450 | const events = [ 451 | (async () => { 452 | await when(elem, 'loadend') 453 | order.push('loadend') 454 | connected.push(elem.isConnected) 455 | })(), 456 | (async () => { 457 | await when(elem, 'load') 458 | order.push('load') 459 | connected.push(elem.isConnected) 460 | })(), 461 | (async () => { 462 | await when(elem, 'loadstart') 463 | order.push('loadstart') 464 | connected.push(elem.isConnected) 465 | })(), 466 | ] 467 | elem.src = '/hello' 468 | document.body.appendChild(elem) 469 | 470 | await Promise.all(events) 471 | assert.deepStrictEqual(order, ['loadstart', 'load', 'loadend']) 472 | assert.deepStrictEqual(connected, [true, false, false]) 473 | }) 474 | }) 475 | 476 | test('sets loading to "eager" by default', function () { 477 | const div = document.createElement('div') 478 | div.innerHTML = 'loading' 479 | document.body.appendChild(div) 480 | 481 | assert(div.firstChild.loading, 'eager') 482 | }) 483 | 484 | test('loading will return "eager" even if set to junk value', function () { 485 | const div = document.createElement('div') 486 | div.innerHTML = 'loading' 487 | document.body.appendChild(div) 488 | 489 | assert(div.firstChild.loading, 'eager') 490 | }) 491 | 492 | test('loading=lazy loads if already visible on page', async function () { 493 | const div = document.createElement('div') 494 | div.innerHTML = 'loading' 495 | document.body.appendChild(div) 496 | await when(div.firstChild, 'include-fragment-replaced') 497 | assert.equal(document.querySelector('include-fragment'), null) 498 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 499 | }) 500 | 501 | test('loading=lazy does not load if not visible on page', function () { 502 | const div = document.createElement('div') 503 | div.innerHTML = 'loading' 504 | div.hidden = true 505 | document.body.appendChild(div) 506 | return Promise.race([ 507 | (async () => { 508 | await when(div.firstChild, 'load') 509 | throw new Error(' loaded too early') 510 | })(), 511 | new Promise(resolve => setTimeout(resolve, 100)), 512 | ]) 513 | }) 514 | 515 | test('loading=lazy does not load when src is changed', function () { 516 | const div = document.createElement('div') 517 | div.innerHTML = 'loading' 518 | div.hidden = true 519 | document.body.appendChild(div) 520 | div.firstChild.src = '/hello' 521 | return Promise.race([ 522 | (async () => { 523 | await when(div.firstChild, 'load') 524 | throw new Error(' loaded too early') 525 | })(), 526 | new Promise(resolve => setTimeout(resolve, 100)), 527 | ]) 528 | }) 529 | 530 | test('loading=lazy loads as soon as element visible on page', async function () { 531 | const div = document.createElement('div') 532 | div.innerHTML = 'loading' 533 | div.hidden = true 534 | let failed = false 535 | document.body.appendChild(div) 536 | const fail = () => (failed = true) 537 | div.firstChild.addEventListener('load', fail) 538 | 539 | setTimeout(function () { 540 | div.hidden = false 541 | div.firstChild.removeEventListener('load', fail) 542 | }, 100) 543 | 544 | await when(div.firstChild, 'load') 545 | assert.ok(!failed, 'Load occurred too early') 546 | }) 547 | 548 | test('loading=lazy does not observably change during load cycle', async function () { 549 | const div = document.createElement('div') 550 | div.innerHTML = 'loading' 551 | const elem = div.firstChild 552 | document.body.appendChild(div) 553 | 554 | await when(elem, 'loadstart') 555 | assert.equal(elem.loading, 'lazy', 'loading mode changed observably') 556 | }) 557 | 558 | test('loading=lazy can be switched to eager to load', async function () { 559 | const div = document.createElement('div') 560 | div.innerHTML = 'loading' 561 | div.hidden = true 562 | let failed = false 563 | document.body.appendChild(div) 564 | const fail = () => (failed = true) 565 | div.firstChild.addEventListener('load', fail) 566 | 567 | setTimeout(function () { 568 | div.firstChild.loading = 'eager' 569 | div.firstChild.removeEventListener('load', fail) 570 | }, 100) 571 | 572 | await when(div.firstChild, 'load') 573 | assert.ok(!failed, 'Load occurred too early') 574 | }) 575 | 576 | test('loading=lazy wont load twice even if load is manually called', async function () { 577 | const div = document.createElement('div') 578 | div.innerHTML = 'loading' 579 | div.hidden = true 580 | document.body.appendChild(div) 581 | let loadCount = 0 582 | div.firstChild.addEventListener('loadstart', () => (loadCount += 1)) 583 | const load = div.firstChild.load() 584 | setTimeout(() => { 585 | div.hidden = false 586 | }, 0) 587 | 588 | const replacedPromise = when(div.firstChild, 'include-fragment-replaced') 589 | 590 | await load 591 | await replacedPromise 592 | assert.equal(loadCount, 1, 'Load occurred too many times') 593 | assert.equal(document.querySelector('include-fragment'), null) 594 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 595 | }) 596 | 597 | test('include-fragment-replaced is only called once', async function () { 598 | const div = document.createElement('div') 599 | div.hidden = true 600 | document.body.append(div) 601 | 602 | div.innerHTML = `loading` 603 | div.firstChild.addEventListener('include-fragment-replaced', () => (loadCount += 1)) 604 | 605 | let loadCount = 0 606 | setTimeout(() => { 607 | div.hidden = false 608 | }, 0) 609 | 610 | await when(div.firstChild, 'include-fragment-replaced') 611 | assert.equal(loadCount, 1, 'Load occurred too many times') 612 | assert.equal(document.querySelector('include-fragment'), null) 613 | assert.equal(document.querySelector('#replaced').textContent, 'hello') 614 | }) 615 | 616 | suite('CSP trusted types', () => { 617 | teardown(() => { 618 | IncludeFragmentElement.setCSPTrustedTypesPolicy(null) 619 | }) 620 | 621 | test('can set a pass-through mock CSP trusted types policy', async function () { 622 | let policyCalled = false 623 | IncludeFragmentElement.setCSPTrustedTypesPolicy({ 624 | createHTML: htmlText => { 625 | policyCalled = true 626 | return htmlText 627 | }, 628 | }) 629 | 630 | const el = document.createElement('include-fragment') 631 | el.src = '/hello' 632 | 633 | const data = await el.data 634 | assert.equal('
hello
', data) 635 | assert.ok(policyCalled) 636 | }) 637 | 638 | test('can set and clear a mutating mock CSP trusted types policy', async function () { 639 | let policyCalled = false 640 | IncludeFragmentElement.setCSPTrustedTypesPolicy({ 641 | createHTML: () => { 642 | policyCalled = true 643 | return 'replacement' 644 | }, 645 | }) 646 | 647 | const el = document.createElement('include-fragment') 648 | el.src = '/hello' 649 | const data = await el.data 650 | assert.equal('replacement', data) 651 | assert.ok(policyCalled) 652 | 653 | IncludeFragmentElement.setCSPTrustedTypesPolicy(null) 654 | const el2 = document.createElement('include-fragment') 655 | el2.src = '/hello' 656 | const data2 = await el2.data 657 | assert.equal('
hello
', data2) 658 | }) 659 | 660 | test('can set a real CSP trusted types policy in Chromium', async function () { 661 | let policyCalled = false 662 | const policy = globalThis.trustedTypes.createPolicy('test1', { 663 | createHTML: htmlText => { 664 | policyCalled = true 665 | return htmlText 666 | }, 667 | }) 668 | IncludeFragmentElement.setCSPTrustedTypesPolicy(policy) 669 | 670 | const el = document.createElement('include-fragment') 671 | el.src = '/hello' 672 | const data = await el.data 673 | assert.equal('
hello
', data) 674 | assert.ok(policyCalled) 675 | }) 676 | 677 | test('can reject data using a mock CSP trusted types policy', async function () { 678 | IncludeFragmentElement.setCSPTrustedTypesPolicy({ 679 | createHTML: () => { 680 | throw new Error('Rejected data!') 681 | }, 682 | }) 683 | 684 | const el = document.createElement('include-fragment') 685 | el.src = '/hello' 686 | try { 687 | await el.data 688 | assert.ok(false) 689 | } catch (error) { 690 | assert.match(error, /Rejected data!/) 691 | } 692 | }) 693 | 694 | test('can access headers using a mock CSP trusted types policy', async function () { 695 | IncludeFragmentElement.setCSPTrustedTypesPolicy({ 696 | createHTML: (htmlText, response) => { 697 | if (response.headers.get('X-Server-Sanitized') !== 'sanitized=true') { 698 | // Note: this will reject the contents, but the error may be caught before it shows in the JS console. 699 | throw new Error('Rejecting HTML that was not marked by the server as sanitized.') 700 | } 701 | return htmlText 702 | }, 703 | }) 704 | 705 | const el = document.createElement('include-fragment') 706 | el.src = '/hello' 707 | try { 708 | await el.data 709 | assert.ok(false) 710 | } catch (error) { 711 | assert.match(error, /Rejecting HTML that was not marked by the server as sanitized./) 712 | } 713 | 714 | const el2 = document.createElement('include-fragment') 715 | el2.src = '/x-server-sanitized' 716 | 717 | const data2 = await el2.data 718 | assert.equal('This response should be marked as sanitized using a custom header!', data2) 719 | }) 720 | }) 721 | }) 722 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "target": "es2017", 5 | "strict": true, 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "outDir": "dist", 9 | "lib": ["dom", "dom.iterable", "es2020"], 10 | "removeComments": true 11 | }, 12 | "files": ["src/index.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | import {esbuildPlugin} from '@web/dev-server-esbuild' 2 | export default { 3 | files: ['test/*'], 4 | nodeResolve: true, 5 | plugins: [esbuildPlugin({ts: true, target: 'es2020'})], 6 | testFramework: { 7 | config: { 8 | ui: 'tdd', 9 | timeout: 500, 10 | }, 11 | }, 12 | } 13 | --------------------------------------------------------------------------------