-
Notifications
You must be signed in to change notification settings - Fork 40
/
Copy pathindex.ts
257 lines (228 loc) · 7.92 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
interface CachedData {
src: string
data: Promise<string | CSPTrustedHTMLToStringable>
}
const privateData = new WeakMap<IncludeFragmentElement, CachedData>()
function isWildcard(accept: string | null) {
return accept && !!accept.split(',').find(x => x.match(/^\s*\*\/\*/))
}
// CSP trusted types: We don't want to add `@types/trusted-types` as a
// dependency, so we use the following types as a stand-in.
interface CSPTrustedTypesPolicy {
createHTML: (s: string, response: Response) => CSPTrustedHTMLToStringable
}
// Note: basically every object (and some primitives) in JS satisfy this
// `CSPTrustedHTMLToStringable` interface, but this is the most compatible shape
// we can use.
interface CSPTrustedHTMLToStringable {
toString: () => string
}
let cspTrustedTypesPolicyPromise: Promise<CSPTrustedTypesPolicy> | null = null
export default class IncludeFragmentElement extends HTMLElement {
// Passing `null` clears the policy.
static setCSPTrustedTypesPolicy(policy: CSPTrustedTypesPolicy | Promise<CSPTrustedTypesPolicy> | null): void {
cspTrustedTypesPolicyPromise = policy === null ? policy : Promise.resolve(policy)
}
static get observedAttributes(): string[] {
return ['src', 'loading']
}
get src(): string {
const src = this.getAttribute('src')
if (src) {
const link = this.ownerDocument!.createElement('a')
link.href = src
return link.href
} else {
return ''
}
}
set src(val: string) {
this.setAttribute('src', val)
}
get loading(): 'eager' | 'lazy' {
if (this.getAttribute('loading') === 'lazy') return 'lazy'
return 'eager'
}
set loading(value: 'eager' | 'lazy') {
this.setAttribute('loading', value)
}
get accept(): string {
return this.getAttribute('accept') || ''
}
set accept(val: string) {
this.setAttribute('accept', val)
}
// We will return string or error for API backwards compatibility. We can consider
// returning TrustedHTML in the future.
get data(): Promise<string> {
return this.#getStringData()
}
#busy = false
attributeChangedCallback(attribute: string, oldVal: string | null): void {
if (attribute === 'src') {
// Source changed after attached so replace element.
if (this.isConnected && this.loading === 'eager') {
this.#handleData()
}
} else if (attribute === 'loading') {
// Loading mode changed to Eager after attached so replace element.
if (this.isConnected && oldVal !== 'eager' && this.loading === 'eager') {
this.#handleData()
}
}
}
constructor() {
super()
const shadowRoot = this.attachShadow({mode: 'open'})
const style = document.createElement('style')
style.textContent = `:host {display: block;}`
shadowRoot.append(style, document.createElement('slot'))
}
connectedCallback(): void {
if (this.src && this.loading === 'eager') {
this.#handleData()
}
if (this.loading === 'lazy') {
this.#observer.observe(this)
}
}
request(): Request {
const src = this.src
if (!src) {
throw new Error('missing src')
}
return new Request(src, {
method: 'GET',
credentials: 'same-origin',
headers: {
Accept: this.accept || 'text/html'
}
})
}
load(): Promise<string> {
return this.#getStringData()
}
fetch(request: RequestInfo): Promise<Response> {
return fetch(request)
}
#observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
const {target} = entry
this.#observer.unobserve(target)
if (!(target instanceof IncludeFragmentElement)) return
if (target.loading === 'lazy') {
this.#handleData()
}
}
}
},
{
// Currently the threshold is set to 256px from the bottom of the viewport
// with a threshold of 0.1. This means the element will not load until about
// 2 keyboard-down-arrow presses away from being visible in the viewport,
// giving us some time to fetch it before the contents are made visible
rootMargin: '0px 0px 256px 0px',
threshold: 0.01
}
)
async #handleData(): Promise<void> {
if (this.#busy) return
this.#busy = true
this.#observer.unobserve(this)
try {
const data = await this.#getData()
if (data instanceof Error) {
throw data
}
// Until TypeScript is natively compatible with CSP trusted types, we
// have to treat this as a string here.
// https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1246
const dataTreatedAsString = data as string
const template = document.createElement('template')
// eslint-disable-next-line github/no-inner-html
template.innerHTML = dataTreatedAsString
const fragment = document.importNode(template.content, true)
const canceled = !this.dispatchEvent(
new CustomEvent('include-fragment-replace', {cancelable: true, detail: {fragment}})
)
if (canceled) return
this.replaceWith(fragment)
this.dispatchEvent(new CustomEvent('include-fragment-replaced'))
} catch {
this.classList.add('is-error')
}
}
async #getData(): Promise<string | CSPTrustedHTMLToStringable> {
const src = this.src
const cachedData = privateData.get(this)
if (cachedData && cachedData.src === src) {
return cachedData.data
} else {
let data: Promise<string | CSPTrustedHTMLToStringable>
if (src) {
data = this.#fetchDataWithEvents()
} else {
data = Promise.reject(new Error('missing src'))
}
privateData.set(this, {src, data})
return data
}
}
async #getStringData(): Promise<string> {
return (await this.#getData()).toString()
}
// Functional stand in for the W3 spec "queue a task" paradigm
async #task(eventsToDispatch: string[]): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
for (const eventType of eventsToDispatch) {
this.dispatchEvent(new Event(eventType))
}
}
async #fetchDataWithEvents(): Promise<string | CSPTrustedHTMLToStringable> {
// We mimic the same event order as <img>, including the spec
// which states events must be dispatched after "queue a task".
// https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element
try {
await this.#task(['loadstart'])
const response = await this.fetch(this.request())
if (response.status !== 200) {
throw new Error(`Failed to load resource: the server responded with a status of ${response.status}`)
}
const ct = response.headers.get('Content-Type')
if (!isWildcard(this.accept) && (!ct || !ct.includes(this.accept ? this.accept : 'text/html'))) {
throw new Error(`Failed to load resource: expected ${this.accept || 'text/html'} but was ${ct}`)
}
const responseText: string = await response.text()
let data: string | CSPTrustedHTMLToStringable = responseText
if (cspTrustedTypesPolicyPromise) {
const cspTrustedTypesPolicy = await cspTrustedTypesPolicyPromise
data = cspTrustedTypesPolicy.createHTML(responseText, response)
}
// Dispatch `load` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
// events are fired.
this.#task(['load', 'loadend'])
return data
} catch (error) {
// Dispatch `error` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
// events are fired.
this.#task(['error', 'loadend'])
throw error
}
}
}
declare global {
interface Window {
IncludeFragmentElement: typeof IncludeFragmentElement
}
interface HTMLElementTagNameMap {
'include-fragment': IncludeFragmentElement
}
}
if (!window.customElements.get('include-fragment')) {
window.IncludeFragmentElement = IncludeFragmentElement
window.customElements.define('include-fragment', IncludeFragmentElement)
}