|
| 1 | +const $ = selector => document.querySelector(selector) |
| 2 | +const $$ = selector => document.querySelectorAll(selector) |
| 3 | + |
| 4 | +const loadedScripts = [] |
| 5 | + |
| 6 | +function loadScript(src) { |
| 7 | + if (loadedScripts.includes(src)) return Promise.resolve() |
| 8 | + |
| 9 | + return new Promise(function (resolve, reject) { |
| 10 | + const s = document.createElement('script') |
| 11 | + let r = false |
| 12 | + s.type = 'text/javascript' |
| 13 | + s.src = src |
| 14 | + s.async = true |
| 15 | + s.onerror = function (err) { |
| 16 | + reject(err, s) |
| 17 | + } |
| 18 | + s.onload = s.onreadystatechange = function () { |
| 19 | + // console.log(this.readyState); // uncomment this line to see which ready states are called. |
| 20 | + if (!r && (!this.readyState || this.readyState === 'complete')) { |
| 21 | + r = true |
| 22 | + loadedScripts.push(src) |
| 23 | + resolve() |
| 24 | + } |
| 25 | + } |
| 26 | + const t = document.getElementsByTagName('script')[0] |
| 27 | + t.parentElement.insertBefore(s, t) |
| 28 | + }) |
| 29 | +} |
| 30 | + |
| 31 | +// youtube functionality |
| 32 | +function createYoutubeFrame(id) { |
| 33 | + const html = |
| 34 | + "<div id='lightbox'><a href='#'><svg width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='#fff' stroke-width='1' stroke-linecap='square' stroke-linejoin='arcs'><line x1='18' y1='6' x2='6' y2='18'></line><line x1='6' y1='6' x2='18' y2='18'></line></svg></a> <section> <div> <iframe src='https://www.youtube.com/embed/" + |
| 35 | + id + |
| 36 | + "?autoplay=1' width='560' height='315' frameborder='0' allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture' allowfullscreen></iframe></div></section></div>" |
| 37 | + const fragment = document.createRange().createContextualFragment(html) |
| 38 | + document.body.appendChild(fragment) |
| 39 | + |
| 40 | + $('#lightbox a').addEventListener( |
| 41 | + 'click', |
| 42 | + function (e) { |
| 43 | + e.preventDefault() |
| 44 | + const lightbox = $('#lightbox') |
| 45 | + lightbox.parentNode.removeChild(lightbox) |
| 46 | + }, |
| 47 | + { once: true } |
| 48 | + ) |
| 49 | +} |
| 50 | + |
| 51 | +$$('.youtube-link').forEach(function (link) { |
| 52 | + link.addEventListener('click', function (e) { |
| 53 | + e.preventDefault() |
| 54 | + const id = this.getAttribute('data-id') |
| 55 | + createYoutubeFrame(id) |
| 56 | + }) |
| 57 | +}) |
| 58 | + |
| 59 | +class LiteYTEmbed extends window.HTMLElement { |
| 60 | + async connectedCallback() { |
| 61 | + this.videoId = this.getAttribute('videoid') |
| 62 | + |
| 63 | + let playBtnEl = this.querySelector('.lty-playbtn') |
| 64 | + // A label for the button takes priority over a [playlabel] attribute on the custom-element |
| 65 | + this.playLabel = (playBtnEl && playBtnEl.textContent.trim()) || this.getAttribute('playlabel') || 'Play' |
| 66 | + |
| 67 | + const isWebpSupported = await LiteYTEmbed.checkWebPSupport() |
| 68 | + |
| 69 | + this.posterUrl = isWebpSupported |
| 70 | + ? `https://i.ytimg.com/vi_webp/${this.videoId}/hqdefault.webp` |
| 71 | + : `https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg` |
| 72 | + |
| 73 | + // Warm the connection for the poster image |
| 74 | + LiteYTEmbed.addPrefetch('preload', this.posterUrl, 'image') |
| 75 | + |
| 76 | + this.style.backgroundImage = `url("${this.posterUrl}")` |
| 77 | + |
| 78 | + // Set up play button, and its visually hidden label |
| 79 | + if (!playBtnEl) { |
| 80 | + playBtnEl = document.createElement('button') |
| 81 | + playBtnEl.type = 'button' |
| 82 | + playBtnEl.classList.add('lty-playbtn') |
| 83 | + this.append(playBtnEl) |
| 84 | + } |
| 85 | + if (!playBtnEl.textContent) { |
| 86 | + const playBtnLabelEl = document.createElement('span') |
| 87 | + playBtnLabelEl.className = 'lyt-visually-hidden' |
| 88 | + playBtnLabelEl.textContent = this.playLabel |
| 89 | + playBtnEl.append(playBtnLabelEl) |
| 90 | + } |
| 91 | + |
| 92 | + // On hover (or tap), warm up the TCP connections we're (likely) about to use. |
| 93 | + this.addEventListener('pointerover', LiteYTEmbed.warmConnections, { once: true }) |
| 94 | + |
| 95 | + // Once the user clicks, add the real iframe and drop our play button |
| 96 | + // TODO: In the future we could be like amp-youtube and silently swap in the iframe during idle time |
| 97 | + // We'd want to only do this for in-viewport or near-viewport ones: https://github.com/ampproject/amphtml/pull/5003 |
| 98 | + this.addEventListener('click', e => this.addIframe()) |
| 99 | + } |
| 100 | + |
| 101 | + static addPrefetch(kind, url, as) { |
| 102 | + const linkEl = document.createElement('link') |
| 103 | + linkEl.rel = kind |
| 104 | + linkEl.href = url |
| 105 | + if (as) { |
| 106 | + linkEl.as = as |
| 107 | + } |
| 108 | + document.head.append(linkEl) |
| 109 | + } |
| 110 | + |
| 111 | + /** |
| 112 | + * Check WebP support for the user |
| 113 | + */ |
| 114 | + static checkWebPSupport() { |
| 115 | + if (typeof LiteYTEmbed.hasWebPSupport !== 'undefined') { return Promise.resolve(LiteYTEmbed.hasWebPSupport) } |
| 116 | + |
| 117 | + return new Promise(resolve => { |
| 118 | + const resolveAndSaveValue = value => { |
| 119 | + LiteYTEmbed.hasWebPSupport = value |
| 120 | + resolve(value) |
| 121 | + } |
| 122 | + |
| 123 | + const img = new window.Image() |
| 124 | + img.onload = () => resolveAndSaveValue(true) |
| 125 | + img.onerror = () => resolveAndSaveValue(false) |
| 126 | + img.src = 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=' |
| 127 | + }) |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * Begin pre-connecting to warm up the iframe load |
| 132 | + * Since the embed's network requests load within its iframe, |
| 133 | + * preload/prefetch'ing them outside the iframe will only cause double-downloads. |
| 134 | + * So, the best we can do is warm up a few connections to origins that are in the critical path. |
| 135 | + * |
| 136 | + * Maybe `<link rel=preload as=document>` would work, but it's unsupported: http://crbug.com/593267 |
| 137 | + * But TBH, I don't think it'll happen soon with Site Isolation and split caches adding serious complexity. |
| 138 | + */ |
| 139 | + static warmConnections() { |
| 140 | + if (LiteYTEmbed.preconnected) return |
| 141 | + |
| 142 | + // The iframe document and most of its subresources come right off youtube.com |
| 143 | + LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube-nocookie.com') |
| 144 | + // The botguard script is fetched off from google.com |
| 145 | + LiteYTEmbed.addPrefetch('preconnect', 'https://www.google.com') |
| 146 | + |
| 147 | + // Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling. |
| 148 | + LiteYTEmbed.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net') |
| 149 | + LiteYTEmbed.addPrefetch('preconnect', 'https://static.doubleclick.net') |
| 150 | + |
| 151 | + LiteYTEmbed.preconnected = true |
| 152 | + } |
| 153 | + |
| 154 | + addIframe() { |
| 155 | + const params = new URLSearchParams(this.getAttribute('params') || []) |
| 156 | + params.append('autoplay', '1') |
| 157 | + |
| 158 | + const iframeEl = document.createElement('iframe') |
| 159 | + iframeEl.width = 560 |
| 160 | + iframeEl.height = 315 |
| 161 | + // No encoding necessary as [title] is safe. https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#:~:text=Safe%20HTML%20Attributes%20include |
| 162 | + iframeEl.title = this.playLabel |
| 163 | + iframeEl.allow = 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture' |
| 164 | + iframeEl.allowFullscreen = true |
| 165 | + // AFAIK, the encoding here isn't necessary for XSS, but we'll do it only because this is a URL |
| 166 | + // https://stackoverflow.com/q/64959723/89484 |
| 167 | + iframeEl.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(this.videoId)}?${params.toString()}` |
| 168 | + this.append(iframeEl) |
| 169 | + |
| 170 | + this.classList.add('lyt-activated') |
| 171 | + |
| 172 | + // Set focus for a11y |
| 173 | + this.querySelector('iframe').focus() |
| 174 | + } |
| 175 | +} |
| 176 | +// Register custome element |
| 177 | +window.customElements.define('lite-youtube', LiteYTEmbed) |
| 178 | + |
| 179 | +// Show share only when needed |
| 180 | +const intersectionObserverOptions = { |
| 181 | + rootMargin: '0px', |
| 182 | + threshold: 1.0 |
| 183 | +} |
| 184 | + |
| 185 | +const $share = document.getElementById('share') |
| 186 | + |
| 187 | +if ($share) { |
| 188 | + const $articlePagination = document.getElementById('article-pagination') |
| 189 | + const $footer = $('footer') |
| 190 | + const elementToObserve = $articlePagination || $footer |
| 191 | + |
| 192 | + const onIntersect = function (entries) { |
| 193 | + const [entry] = entries |
| 194 | + const hide = entry.boundingClientRect.top <= 0 || entry.isIntersecting |
| 195 | + $share.classList.toggle('u-none', hide) |
| 196 | + } |
| 197 | + |
| 198 | + const observer = new window.IntersectionObserver( |
| 199 | + onIntersect, |
| 200 | + intersectionObserverOptions |
| 201 | + ) |
| 202 | + |
| 203 | + observer.observe(elementToObserve) |
| 204 | +} |
| 205 | + |
| 206 | +const ALGOLIA_APPLICATION_ID = 'QK9VV9YO5F' |
| 207 | +const ALGOLIA_SEARCH_ONLY_API_KEY = '247bb355c786b6e9f528bc382cab3039' |
| 208 | +let algoliaIndex |
| 209 | + |
| 210 | +const $form = $('.ais-SearchBox-form') |
| 211 | +const $input = $('.ais-SearchBox-input') |
| 212 | +const $reset = $('.ais-SearchBox-reset') |
| 213 | +const $hits = $('#hits') |
| 214 | + |
| 215 | +function getAlgoliaIndex() { |
| 216 | + if (algoliaIndex) return algoliaIndex |
| 217 | + console.log('🚀 ~ file: scripts.js ~ line 222 ~ getAlgoliaIndex ~ algoliaIndex', algoliaIndex) |
| 218 | + |
| 219 | + const algoliaClient = window.algoliasearch(ALGOLIA_APPLICATION_ID, ALGOLIA_SEARCH_ONLY_API_KEY, { |
| 220 | + _useRequestCache: true |
| 221 | + }) |
| 222 | + algoliaIndex = algoliaClient.initIndex('prod_blog_content') |
| 223 | + return algoliaIndex |
| 224 | +} |
| 225 | + |
| 226 | +$form.addEventListener('submit', function (e) { |
| 227 | + e.preventDefault() |
| 228 | +}) |
| 229 | + |
| 230 | +$reset.addEventListener('click', function (e) { |
| 231 | + $input.value = '' |
| 232 | + $hits.innerHTML = '' |
| 233 | +}) |
| 234 | + |
| 235 | +$input.addEventListener('input', async function (e) { |
| 236 | + await loadScript('https://cdn.jsdelivr.net/npm/[email protected]/dist/algoliasearch-lite.umd.js') |
| 237 | + |
| 238 | + const { value } = e.target |
| 239 | + if (value === '') { |
| 240 | + $hits.innerHTML = '' |
| 241 | + $reset.setAttribute('hidden') |
| 242 | + } |
| 243 | + |
| 244 | + $reset.removeAttribute('hidden') |
| 245 | + $hits.removeAttribute('hidden') |
| 246 | + |
| 247 | + const algoliaIndex = getAlgoliaIndex() |
| 248 | + algoliaIndex.search(value, { |
| 249 | + hitsPerPage: 3 |
| 250 | + }).then(({ hits }) => { |
| 251 | + let hitsHtml = '' |
| 252 | + hits.forEach(hit => { |
| 253 | + const { |
| 254 | + link, |
| 255 | + _highlightResult: { |
| 256 | + title: { value: title }, |
| 257 | + description: { value: description } |
| 258 | + } |
| 259 | + } = hit |
| 260 | + |
| 261 | + hitsHtml += ` |
| 262 | + <li class='ais-Hits-item'> |
| 263 | + <a href='${link}'> |
| 264 | + ${title} |
| 265 | + <div> |
| 266 | + <small>${description}</small> |
| 267 | + </div> |
| 268 | + </a> |
| 269 | + </li>` |
| 270 | + }) |
| 271 | + |
| 272 | + $hits.innerHTML = hitsHtml |
| 273 | + }) |
| 274 | +}) |
| 275 | + |
| 276 | +// Table Of Contents script |
| 277 | +function initTableOfContents() { |
| 278 | + const firstTableOfContentsElement = $('#TableOfContents-container li') |
| 279 | + if (!firstTableOfContentsElement) return null |
| 280 | + |
| 281 | + // activate first element of table of contents |
| 282 | + firstTableOfContentsElement.classList.add('active') |
| 283 | + // get all links from table of contents |
| 284 | + const links = $$('#TableOfContents-container li a') |
| 285 | + |
| 286 | + const changeBgLinks = entries => { |
| 287 | + entries.forEach(entry => { |
| 288 | + const { target, isIntersecting, intersectionRatio } = entry |
| 289 | + if (isIntersecting && intersectionRatio >= 0.5) { |
| 290 | + const id = target.getAttribute('id') |
| 291 | + $('#TableOfContents-container li.active').classList.remove('active') |
| 292 | + $(`nav li a[href="#${id}"]`).parentElement.classList.add('active') |
| 293 | + |
| 294 | + links.forEach(link => { |
| 295 | + link.addEventListener('click', (e) => { |
| 296 | + $('#TableOfContents-container li.active').classList.remove('active') |
| 297 | + link.parentElement.classList.add('active') |
| 298 | + }) |
| 299 | + }) |
| 300 | + } |
| 301 | + }) |
| 302 | + } |
| 303 | + |
| 304 | + const options = { |
| 305 | + threshold: 0.5, |
| 306 | + rootMargin: '50px 0px -55% 0px' |
| 307 | + } |
| 308 | + |
| 309 | + const observer = new window.IntersectionObserver(changeBgLinks, options) |
| 310 | + |
| 311 | + const articleTitles = $$('#article-content h2') |
| 312 | + articleTitles.forEach(section => observer.observe(section)) |
| 313 | +} |
| 314 | + |
| 315 | +initTableOfContents() |
| 316 | + |
0 commit comments