diff --git a/.eslintignore b/.eslintignore
index c80290ac..50779f44 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -3,4 +3,5 @@ scripts/jquery-1.11.2.min.js
scripts/jquery.colorbox-min.js
scripts/main-scripts.js
scripts/gcse.js
-scripts/v2.js
\ No newline at end of file
+blocks/embed/lite-yt-embed/lite-yt-embed.js
+scripts/v2.js
diff --git a/blocks/embed/embed.css b/blocks/embed/embed.css
new file mode 100644
index 00000000..f32da34e
--- /dev/null
+++ b/blocks/embed/embed.css
@@ -0,0 +1,49 @@
+/*
+* Copyright 2021 Adobe. All rights reserved.
+* This file is licensed to you under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License. You may obtain a copy
+* of the License at http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software distributed under
+* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+* OF ANY KIND, either express or implied. See the License for the specific language
+* governing permissions and limitations under the License.
+*/
+.embed {
+ display: flex;
+ margin: 0 auto 2rem;
+ padding: 0;
+ flex-direction: column;
+ align-items: stretch;
+ max-width: 100%;
+}
+
+.embed > div,
+.embed blockquote,
+.embed iframe {
+ flex: 1;
+}
+
+.embed iframe {
+ border: 1px solid var(--text-color);
+ border-radius: .5rem;
+}
+
+.embed img {
+ max-width: 100%;
+}
+
+.embed.tiktok lite-tiktok {
+ margin: 0 auto;
+ width: 518px;
+ height: 738px;
+}
+
+.embed.instagram {
+ width: 325px;
+ min-height: 739px;
+}
+
+.embed.instagram > div {
+ display: flex;
+}
diff --git a/blocks/embed/embed.js b/blocks/embed/embed.js
new file mode 100644
index 00000000..465c85e9
--- /dev/null
+++ b/blocks/embed/embed.js
@@ -0,0 +1,172 @@
+import { loadCSS, loadScript } from '../../scripts/aem.js';
+
+const FALLBACK_PUBLICATION_DATE = '2013-07-18'; // go-live date for the Franklin site, but could be any value
+
+const getDefaultEmbed = (url) => ``;
+
+const embedYoutubeFacade = async (url) => {
+ loadCSS('/blocks/embed/lite-yt-embed/lite-yt-embed.css');
+ loadScript('/blocks/embed/lite-yt-embed/lite-yt-embed.js');
+
+ const usp = new URLSearchParams(url.search);
+ let videoId = usp.get('v');
+ if (url.origin.includes('youtu.be')) {
+ videoId = url.pathname.substring(1);
+ } else {
+ videoId = url.pathname.split('/').pop();
+ }
+ const wrapper = document.createElement('div');
+ wrapper.setAttribute('itemscope', '');
+ wrapper.setAttribute('itemtype', 'https://schema.org/VideoObject');
+ const litePlayer = document.createElement('lite-youtube');
+ litePlayer.setAttribute('videoid', videoId);
+ wrapper.append(litePlayer);
+
+ try {
+ const response = await fetch(`https://www.youtube.com/oembed?url=http://www.youtube.com/watch?v=${videoId}`);
+ const json = await response.json();
+ wrapper.innerHTML = `
+
+
+
+
+
+ ${wrapper.innerHTML}
+ `;
+ } catch (err) {
+ // Nothing to do, metadata just won't be added to the video
+ }
+ return wrapper.outerHTML;
+};
+
+const embedInstagram = (url) => {
+ const endingSlash = url.pathname.endsWith('/') ? '' : '/';
+ const location = window.location.href.endsWith('.html') ? window.location.href : `${window.location.href}.html`;
+ const src = `${url.origin}${url.pathname}${endingSlash}embed/captioned/?rd=${window.encodeURIComponent(location)}`;
+ const embedHTML = `
+
+
+
+
+ `;
+ return embedHTML;
+};
+
+const embedTwitter = (url) => {
+ loadScript('https://platform.twitter.com/widgets.js');
+ const embedHTML = `
+
+
+
+ `;
+ return embedHTML;
+};
+
+const embedTiktokFacade = async (url) => {
+ loadScript('/blocks/embed/lite-tiktok/lite-tiktok.js', () => {}, { async: true, type: 'module' });
+ const videoId = url.pathname.split('/').pop();
+ try {
+ const request = await fetch(`https://www.tiktok.com/oembed?url=https://www.tiktok.com/video/${videoId}`);
+ const json = await request.json();
+ return `
+
+
+
+
+
+
+
+ `;
+ } catch (err) {
+ return `
+
+
+
+
+ `;
+ }
+};
+
+const EMBEDS_CONFIG = {
+ instagram: embedInstagram,
+ tiktok: embedTiktokFacade,
+ twitter: embedTwitter,
+ youtube: embedYoutubeFacade,
+};
+
+function getPlatform(url) {
+ const [service] = url.hostname.split('.').slice(-2, -1);
+ if (service === 'youtu') {
+ return 'youtube';
+ }
+ return service;
+}
+
+const loadEmbed = async (block, service, url) => {
+ block.classList.toggle('skeleton', true);
+
+ const embed = EMBEDS_CONFIG[service];
+ if (!embed) {
+ block.classList.toggle('generic', true);
+ block.innerHTML = getDefaultEmbed(url);
+ return;
+ }
+
+ try {
+ block.classList.toggle(service, true);
+ try {
+ block.innerHTML = await embed(url);
+ } catch (err) {
+ block.style.display = 'none';
+ } finally {
+ block.classList.toggle('skeleton', false);
+ }
+ } catch (err) {
+ block.style.maxHeight = '0px';
+ }
+};
+
+// Listen for messages from instagram embeds to update the embed height.
+window.addEventListener('message', (ev) => {
+ const iframe = [...document.querySelectorAll('iframe')].find((i) => i.contentWindow === ev.source);
+ if (!iframe) {
+ return;
+ }
+ let data;
+ try {
+ data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
+ } catch (e) {
+ // Nothing to do, the message isn't the one we are looking for
+ return;
+ }
+
+ if (data.type !== 'MEASURE') {
+ return;
+ }
+
+ iframe.closest('.block').style.height = `${data.details.height}px`;
+});
+
+/**
+ * @param {HTMLDivElement} block
+ */
+export default function decorate(block) {
+ const url = new URL(block.querySelector('a').href.replace(/%5C%5C_/, '_'));
+
+ block.textContent = '';
+ const service = getPlatform(url);
+ // Both Youtube and TikTok use an optimized lib that already leverages the intersection observer
+ if (service !== 'tiktok' && service !== 'youtube') {
+ const observer = new IntersectionObserver((entries) => {
+ if (!entries.some((e) => e.isIntersecting)) {
+ return;
+ }
+
+ loadEmbed(block, service, url);
+ observer.unobserve(block);
+ });
+ observer.observe(block);
+ } else {
+ loadEmbed(block, service, url);
+ }
+}
diff --git a/blocks/embed/lite-yt-embed/lite-yt-embed.css b/blocks/embed/lite-yt-embed/lite-yt-embed.css
new file mode 100644
index 00000000..0a24ae76
--- /dev/null
+++ b/blocks/embed/lite-yt-embed/lite-yt-embed.css
@@ -0,0 +1,86 @@
+lite-youtube {
+ background-color: #000;
+ position: relative;
+ display: block;
+ contain: content;
+ background-position: center center;
+ background-size: cover;
+ cursor: pointer;
+ max-width: 720px;
+}
+
+/* gradient */
+lite-youtube::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==);
+ background-position: top;
+ background-repeat: repeat-x;
+ height: 60px;
+ padding-bottom: 50px;
+ width: 100%;
+ transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
+}
+
+/* responsive iframe with a 16:9 aspect ratio
+ thanks https://css-tricks.com/responsive-iframes/
+*/
+lite-youtube::after {
+ content: "";
+ display: block;
+ padding-bottom: calc(100% / (16 / 9));
+}
+lite-youtube > iframe {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ border: 0;
+}
+
+/* play button */
+lite-youtube > .lty-playbtn {
+ display: block;
+ width: 68px;
+ height: 48px;
+ position: absolute;
+ cursor: pointer;
+ transform: translate3d(-50%, -50%, 0);
+ top: 50%;
+ left: 50%;
+ z-index: 1;
+ background-color: transparent;
+ /* YT's actual play button svg */
+ background-image: url('data:image/svg+xml;utf8,');
+ filter: grayscale(100%);
+ transition: filter .1s cubic-bezier(0, 0, 0.2, 1);
+ border: none;
+}
+
+lite-youtube:hover > .lty-playbtn,
+lite-youtube .lty-playbtn:focus {
+ filter: none;
+}
+
+/* Post-click styles */
+lite-youtube.lyt-activated {
+ cursor: unset;
+}
+lite-youtube.lyt-activated::before,
+lite-youtube.lyt-activated > .lty-playbtn {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.lyt-visually-hidden {
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
diff --git a/blocks/embed/lite-yt-embed/lite-yt-embed.js b/blocks/embed/lite-yt-embed/lite-yt-embed.js
new file mode 100644
index 00000000..1e45a303
--- /dev/null
+++ b/blocks/embed/lite-yt-embed/lite-yt-embed.js
@@ -0,0 +1,166 @@
+/**
+ * A lightweight youtube embed. Still should feel the same to the user, just MUCH faster to initialize and paint.
+ *
+ * Thx to these as the inspiration
+ * https://storage.googleapis.com/amp-vs-non-amp/youtube-lazy.html
+ * https://autoplay-youtube-player.glitch.me/
+ *
+ * Once built it, I also found these:
+ * https://github.com/ampproject/amphtml/blob/master/extensions/amp-youtube (👍👍)
+ * https://github.com/Daugilas/lazyYT
+ * https://github.com/vb/lazyframe
+ */
+class LiteYTEmbed extends HTMLElement {
+ connectedCallback() {
+ this.videoId = this.getAttribute('videoid');
+
+ let playBtnEl = this.querySelector('.lty-playbtn');
+ // A label for the button takes priority over a [playlabel] attribute on the custom-element
+ this.playLabel = (playBtnEl && playBtnEl.textContent.trim()) || this.getAttribute('playlabel') || 'Play';
+
+ /**
+ * Lo, the youtube placeholder image! (aka the thumbnail, poster image, etc)
+ *
+ * See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md
+ *
+ * TODO: Do the sddefault->hqdefault fallback
+ * - When doing this, apply referrerpolicy (https://github.com/ampproject/amphtml/pull/3940)
+ * TODO: Consider using webp if supported, falling back to jpg
+ */
+ if (!this.style.backgroundImage) {
+ this.style.backgroundImage = `url("https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg")`;
+ }
+
+ // Set up play button, and its visually hidden label
+ if (!playBtnEl) {
+ playBtnEl = document.createElement('button');
+ playBtnEl.type = 'button';
+ playBtnEl.classList.add('lty-playbtn');
+ this.append(playBtnEl);
+ }
+ if (!playBtnEl.textContent) {
+ const playBtnLabelEl = document.createElement('span');
+ playBtnLabelEl.className = 'lyt-visually-hidden';
+ playBtnLabelEl.textContent = this.playLabel;
+ playBtnEl.append(playBtnLabelEl);
+ }
+ playBtnEl.removeAttribute('href');
+
+ // On hover (or tap), warm up the TCP connections we're (likely) about to use.
+ this.addEventListener('pointerover', LiteYTEmbed.warmConnections, { once: true });
+
+ // Once the user clicks, add the real iframe and drop our play button
+ // TODO: In the future we could be like amp-youtube and silently swap in the iframe during idle time
+ // We'd want to only do this for in-viewport or near-viewport ones: https://github.com/ampproject/amphtml/pull/5003
+ this.addEventListener('click', this.addIframe);
+
+ // Chrome & Edge desktop have no problem with the basic YouTube Embed with ?autoplay=1
+ // However Safari desktop and most/all mobile browsers do not successfully track the user gesture of clicking through the creation/loading of the iframe,
+ // so they don't autoplay automatically. Instead we must load an additional 2 sequential JS files (1KB + 165KB) (un-br) for the YT Player API
+ // TODO: Try loading the the YT API in parallel with our iframe and then attaching/playing it. #82
+ this.needsYTApiForAutoplay = navigator.vendor.includes('Apple') || navigator.userAgent.includes('Mobi');
+ }
+
+ /**
+ * Add a to the head
+ */
+ static addPrefetch(kind, url, as) {
+ const linkEl = document.createElement('link');
+ linkEl.rel = kind;
+ linkEl.href = url;
+ if (as) {
+ linkEl.as = as;
+ }
+ document.head.append(linkEl);
+ }
+
+ /**
+ * Begin pre-connecting to warm up the iframe load
+ * Since the embed's network requests load within its iframe,
+ * preload/prefetch'ing them outside the iframe will only cause double-downloads.
+ * So, the best we can do is warm up a few connections to origins that are in the critical path.
+ *
+ * Maybe `` would work, but it's unsupported: http://crbug.com/593267
+ * But TBH, I don't think it'll happen soon with Site Isolation and split caches adding serious complexity.
+ */
+ static warmConnections() {
+ if (LiteYTEmbed.preconnected) return;
+
+ // The iframe document and most of its subresources come right off youtube.com
+ LiteYTEmbed.addPrefetch('preconnect', 'https://www.youtube-nocookie.com');
+ // The botguard script is fetched off from google.com
+ LiteYTEmbed.addPrefetch('preconnect', 'https://www.google.com');
+
+ // Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling.
+ LiteYTEmbed.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net');
+ LiteYTEmbed.addPrefetch('preconnect', 'https://static.doubleclick.net');
+
+ LiteYTEmbed.preconnected = true;
+ }
+
+ fetchYTPlayerApi() {
+ if (window.YT || (window.YT && window.YT.Player)) return;
+
+ this.ytApiPromise = new Promise((res, rej) => {
+ const el = document.createElement('script');
+ el.src = 'https://www.youtube.com/iframe_api';
+ el.async = true;
+ el.onload = (_) => {
+ YT.ready(res);
+ };
+ el.onerror = rej;
+ this.append(el);
+ });
+ }
+
+ async addYTPlayerIframe(params) {
+ this.fetchYTPlayerApi();
+ await this.ytApiPromise;
+
+ const videoPlaceholderEl = document.createElement('div');
+ this.append(videoPlaceholderEl);
+
+ const paramsObj = Object.fromEntries(params.entries());
+
+ new YT.Player(videoPlaceholderEl, {
+ width: '100%',
+ videoId: this.videoId,
+ playerVars: paramsObj,
+ events: {
+ onReady: (event) => {
+ event.target.playVideo();
+ },
+ },
+ });
+ }
+
+ async addIframe() {
+ if (this.classList.contains('lyt-activated')) return;
+ this.classList.add('lyt-activated');
+
+ const params = new URLSearchParams(this.getAttribute('params') || []);
+ params.append('autoplay', '1');
+ params.append('playsinline', '1');
+
+ // if (this.needsYTApiForAutoplay) {
+ return this.addYTPlayerIframe(params);
+ // }
+
+ const iframeEl = document.createElement('iframe');
+ iframeEl.width = 560;
+ iframeEl.height = 315;
+ // No encoding necessary as [title] is safe. https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#:~:text=Safe%20HTML%20Attributes%20include
+ iframeEl.title = this.playLabel;
+ iframeEl.allow = 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture';
+ iframeEl.allowFullscreen = true;
+ // AFAIK, the encoding here isn't necessary for XSS, but we'll do it only because this is a URL
+ // https://stackoverflow.com/q/64959723/89484
+ iframeEl.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(this.videoId)}?${params.toString()}`;
+ this.append(iframeEl);
+
+ // Set focus for a11y
+ iframeEl.focus();
+ }
+}
+// Register custom element
+customElements.define('lite-youtube', LiteYTEmbed);
diff --git a/scripts/scripts.js b/scripts/scripts.js
index d22ca820..6bd0667b 100644
--- a/scripts/scripts.js
+++ b/scripts/scripts.js
@@ -7,6 +7,7 @@ import {
decorateIcons,
decorateSections,
decorateBlocks,
+ decorateBlock,
decorateTemplateAndTheme,
waitForLCP,
loadBlocks,
@@ -72,6 +73,25 @@ async function decorateTemplates(main) {
}
}
+/**
+ * Builds embed block for inline links to known social platforms.
+ * @param {Element} main The container element
+ */
+function buildEmbedBlocks(main) {
+ const HOSTNAMES = [
+ 'youtube',
+ 'youtu',
+ ];
+ [...main.querySelectorAll(':is(p, div) > a[href]:only-child')]
+ .filter((a) => HOSTNAMES.includes(new URL(a.href).hostname.split('.').slice(-2, -1).pop()))
+ .forEach((a) => {
+ const parent = a.parentElement;
+ const block = buildBlock('embed', { elems: [a] });
+ parent.replaceWith(block);
+ decorateBlock(block);
+ });
+}
+
/**
* Builds all synthetic blocks in a container element.
* @param {Element} main The container element
@@ -79,6 +99,7 @@ async function decorateTemplates(main) {
function buildAutoBlocks(main) {
try {
buildHeroBlock(main);
+ buildEmbedBlocks(main);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Auto Blocking failed', error);