0 ) return ''
+
+ const timestampsForRender = video.timestamps.map( timestamp => {
+ const [ minutes, seconds ] = timestamp.time.split(':')
+
+ return {
+ ...timestamp,
+ inSeconds: (minutes * 60) + Number(seconds)
+ }
+ })
+
+ const timestampButtonsHtml = timestampsForRender.map( timestamp => (/* html */`
+
+ `) ).join('')
+
+ return /* html */`
+
+
+ ${ timestampButtonsHtml }
+
+
+ `
+}
+
+export default async function ( video, options = {} ) {
+ const {
+ coverBottomHtml = ''
+ // classes = 'w-full flex-shrink-0 flex-grow-0 border-2 border-transparent rounded-2xl overflow-hidden'
+ } = options
+
+ // Setup inline player script
+ await this.usingComponent( 'node_modules/can-autoplay/build/can-autoplay.min.js' )
+
+ await this.usingComponent( 'helpers/lite-youtube.js' )
+
+ // Setup inline lazysizes
+ await this.usingComponent( 'node_modules/lazysizes/lazysizes.min.js' )
+
+ // console.log('video', video)
+
+ const posterHtml = renderPoster( video )
+
+ const timestampsHtml = renderTimestamps( video )
+
+ return /* html */`
+
+
+
+
+
+
+
+ ${ posterHtml }
+
+
+
+
+
${ coverBottomHtml }
+
+
+
+
+
+ ${ timestampsHtml }
+
+
+ `
+}
diff --git a/components-eleventy/video/poster.js b/components-eleventy/video/poster.js
new file mode 100644
index 0000000..d78d888
--- /dev/null
+++ b/components-eleventy/video/poster.js
@@ -0,0 +1,32 @@
+
+
+export default function ( video ) {
+ const webpSource = {
+ ...video.thumbnail,
+ srcset: video.thumbnail.srcset.replaceAll('ytimg.com/vi/', 'ytimg.com/vi_webp/').replace(/.png|.jpg|.jpeg/g, '.webp')
+ }
+
+ const mergedSources = {
+ webp: webpSource,
+ jpeg: video.thumbnail
+ }
+
+ return /* html */`
+
+
+ ${ Object.entries( mergedSources ).map( ([ key, source ]) => (/* html */`
+
+ `) ).join('') }
+
+
+
+ `
+}
diff --git a/components-eleventy/video/row.js b/components-eleventy/video/row.js
index 308e9c5..a796970 100644
--- a/components-eleventy/video/row.js
+++ b/components-eleventy/video/row.js
@@ -10,7 +10,7 @@ function getCardType ( video ) {
return VideoCard
}
-export default function ( videos, options = {} ) {
+export default async function ( videos, options = {} ) {
const {
cardWidth = '325',
@@ -23,18 +23,23 @@ export default function ( videos, options = {} ) {
const uid = Math.random().toString(36).substr(2, 9)
const rowId = `row-${ uid }`
+ // Setup inline scroll script
+ await this.usingComponent( 'helpers/scroll.js' )
+
// Setup inline lazysizes
- this.usingComponent( 'helpers/scroll.js' )
+ await this.usingComponent( 'node_modules/lazysizes/lazysizes.min.js' )
// console.log('video', video)
- const cardsHtml = videos.map( video => {
+ const renderedCards = await Promise.all(videos.map( async video => {
const Card = getCardType( video )
// console.log('Card', this.boundComponent(Card)( video ) )
- return this.boundComponent(Card)( video )
- } ).join('')
+ return await this.boundComponent(Card)( video )
+ } ))
+
+ const cardsHtml = renderedCards.join('')
// console.log( 'cardsHtml', cardsHtml )
diff --git a/helpers/app-derived.js b/helpers/app-derived.js
index da98483..ba9f1ca 100644
--- a/helpers/app-derived.js
+++ b/helpers/app-derived.js
@@ -56,4 +56,10 @@ export function getVideoEndpoint ( video ) {
return `/tv/${video.slug}`
}
+export function getRouteType ( routeString ) {
+ // Remove first slash and split by remaining
+ // slashes to get first part of route
+ const [ routeType ] = routeString.substring(1).split('/')
+ return routeType
+}
diff --git a/helpers/lite-youtube.js b/helpers/lite-youtube.js
new file mode 100644
index 0000000..7c4d7f8
--- /dev/null
+++ b/helpers/lite-youtube.js
@@ -0,0 +1,485 @@
+// https://github.com/paulirish/lite-youtube-embed/blob/master/src/lite-yt-embed.js
+
+// import canAutoPlay from 'can-autoplay'
+
+
+/**
+ * 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
+ */
+
+function isObject( maybeObject ) {
+ return maybeObject === Object( maybeObject )
+}
+class LiteYTEmbed extends HTMLElement {
+ constructor() {
+ // Always call super first in constructor
+ super()
+
+ this._uid = Date.now()
+ this.$refs = {}
+
+ this.$refs['timestamps-scroll-container'] = this.querySelector('.player-timestamps-wrapper')
+
+ this.playerLoaded = false
+ this.player = null
+ this.playing = false
+ this.progressInterval = null
+ this.playerTime = 0
+ this.preconnected = false
+
+ this.highlightedTimestampElement = null
+ }
+
+ connectedCallback() {
+ this.videoId = this.getAttribute('videoid')
+ this.videoDataScript = this.querySelector('.video-data')
+ this.video = JSON.parse( this.videoDataScript.innerHTML )
+
+ this.playerContainer = this.querySelector('.player-container')
+ this.playerPoster = this.querySelector('.player-poster')
+
+ // console.log('canAutoplay from connectedCallback', canAutoplay)
+
+ console.log('video', this.video)
+ console.log('this.playerContainer', this.playerContainer)
+
+
+ // Start watchers here
+
+
+ // On hover (or tap), warm up the TCP connections we're (likely) about to use.
+ this.playerContainer.addEventListener('pointerover', this.warmConnections, {once: true})
+
+ if ( this.hasTimestamps() ) {
+ // Array.from( this.querySelectorAll(`.player-timestamps [time]`) )
+
+ this.timestamps().map( timestamp => {
+ const timestampButton = this.querySelector(`.player-timestamps [time="${timestamp.time}"]`)
+
+ timestampButton.addEventListener('click', e => {
+ this.seekTo(timestamp.inSeconds)
+ })
+ })
+ }
+
+ // 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.playerPoster.addEventListener('click', e => {
+ this.startPlayerLoad()
+ })
+
+
+ // Mounted
+
+ this.detectAutoplay()
+ .then( ({ willAutoplay }) => {
+ console.log('willAutoplay', willAutoplay)
+
+ // If we're allowed to autoplay
+ // then start loading the player
+ if ( willAutoplay === true ) {
+ this.startPlayerLoad()
+ }
+ })
+ }
+
+ // // TODO: Support the the user changing the [videoid] attribute
+ // attributeChangedCallback() {
+ // }
+
+ /**
+ * Add a
to the head
+ */
+ // static c(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
+ // }
+
+ addIframe() {
+ const classNames = 'absolute inset-0 h-full w-full object-cover'
+
+ // https://www.youtube-nocookie.com/embed/${video.id}?enablejsapi=1&autoplay=1&modestbranding=1&playsinline=1
+
+ // const params = new URLSearchParams(this.getAttribute('params') || [])
+ // params.append('autoplay', '1')
+
+ const iframeEl = document.createElement('iframe')
+
+ this.$refs['frame'] = iframeEl
+
+ iframeEl.width = '100%'
+ iframeEl.height = '100%'
+ // 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.id = this.frameId()
+ iframeEl.classList.add(...classNames.split(' '))
+ iframeEl.frameborder = '0'
+ 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.video.id)}?enablejsapi=1&autoplay=1&modestbranding=1&playsinline=1`
+
+ // this.append(iframeEl)
+ this.playerContainer.innerHTML = ''
+ this.playerContainer.append( iframeEl )
+
+ this.classList.add('lyt-activated')
+
+ // Set focus for a11y
+ this.querySelector('iframe').focus()
+ }
+
+
+ // Computed methods
+
+ frameId = () => {
+ return `youtube-player-${this.video.id}-${this._uid}`
+ }
+
+ timestamps = () => {
+ return this.video.timestamps.map( timestamp => {
+ const [ minutes, seconds ] = timestamp.time.split(':')
+
+ return {
+ ...timestamp,
+ inSeconds: (minutes * 60) + Number(seconds)
+ }
+ })
+ }
+
+ hasTimestamps = () => {
+ return this.timestamps().length > 0
+ }
+
+ hasPlayer = () => {
+ return this.player !== null
+ }
+
+ activeTimestamp = () => {
+ const currentTime = this.playerTime// / 100
+
+ const reversesTimestamps = [
+ ...this.timestamps()
+ ]
+
+ // reversesTimestamps.reverse()
+
+ let foundTimestamp = null
+
+ for (const timestamp of reversesTimestamps) {
+ const hasStarted = currentTime > 1
+ const currentTimeisAfterPreviousTimestamp = (foundTimestamp !== null) ? currentTime > foundTimestamp.inSeconds : true
+ // const isPastCurrentTime = currentTime > timestamp.inSeconds
+ // const isBeforeCurrentTime = currentTime > timestamp.inSeconds
+ const currentTimeIsBeforeThisTimestamp = currentTime < timestamp.inSeconds
+
+ if (currentTimeisAfterPreviousTimestamp && currentTimeIsBeforeThisTimestamp) {
+ return foundTimestamp
+ }
+
+ foundTimestamp = timestamp
+ }
+
+ // No active timestamp
+ return null
+ }
+
+ highlightActiveTimestamp = () => {
+ const activeClassList = 'border-opacity-100 bg-darkest'
+ const inactiveClassList = 'border-opacity-0 neumorphic-shadow-inner'
+
+ const activeTimestamp = this.activeTimestamp()
+
+ // If there's no active timestamp
+ // then stop
+ if ( activeTimestamp === null ) return
+
+ // console.log('activeTimestamp', activeTimestamp)
+
+ if ( isObject( this.highlightedTimestampElement ) && this.highlightedTimestampElement.time === activeTimestamp.time) return
+
+ // Change active timestamp
+
+ const newActiveTimestamp = this.querySelector(`[time="${activeTimestamp.time}"]`)
+
+ // If there's already a highlited time stamp
+ // then unhighlight it
+ if ( isObject( this.highlightedTimestampElement ) ) {
+ this.highlightedTimestampElement.classList.remove(...activeClassList.split(' '))
+ this.highlightedTimestampElement.classList.add(...inactiveClassList.split(' '))
+ }
+
+ newActiveTimestamp.classList.remove(...inactiveClassList.split(' '))
+ newActiveTimestamp.classList.add(...activeClassList.split(' '))
+
+ // Scroll to new timestamp
+ this.scrollToTimestampButton( newActiveTimestamp )
+
+ this.highlightedTimestampElement = newActiveTimestamp
+
+ // console.log('newActiveTimestamp', newActiveTimestamp)
+ // console.log('this.highlightedTimestampElement', this.highlightedTimestampElement)
+ }
+
+ scrollToTimestampButton ( timestampButton ) {
+
+ // If timestamp button doesn't exist
+ // then stop
+ if ( !isObject( timestampButton ) ) return
+
+ const timestampsScroller = this.$refs['timestamps-scroll-container']
+ // const [ timestampButton ] = this.$refs[`timestamp-${timestamp.time}`]
+
+ // https://stackoverflow.com/a/63773123/1397641
+ const newScrollPosition = timestampButton.offsetLeft - timestampsScroller.offsetLeft
+
+ // console.log('timestampsScroller', timestampsScroller)
+ // console.log('timestampButton', timestampButton)
+ // console.log('newScrollPosition', newScrollPosition)
+
+ timestampsScroller.scroll({ left: newScrollPosition, behavior: 'smooth' })
+ }
+
+ detectAutoplay = async () => {
+
+ // if ( !process.client ) return { willAutoplay: false }
+
+ // const { default: canAutoPlay } = await import('can-autoplay')
+
+ const willAutoplay = await canAutoplay.video()
+ // const willAutoplayMuted = await canAutoPlay.video({ muted: true, inline: true })
+
+ return {
+ willAutoplay: willAutoplay.result
+ }
+ }
+
+ seekTo = async (timestampInSeconds) => {
+
+ if (this.playerLoaded === false) {
+ await this.startPlayerLoad()
+ }
+
+ this.player.seekTo(timestampInSeconds)
+ }
+
+ // async playVideo() {
+
+ // if (this.playerLoaded === false) {
+ // await this.startPlayerLoad()
+ // }
+
+ // this.$nextTick(() => {
+ // // console.log('this.player', JSON.stringify(this.player))
+ // this.player.playVideo()
+ // })
+ // },
+
+ addPrefetch = (kind, url, as) => {
+ // console.log('prefetching', url)
+
+ const linkEl = document.createElement('link')
+
+ linkEl.rel = kind
+ linkEl.href = url
+
+ if (as) {
+ linkEl.as = as;
+ }
+
+ document.head.append(linkEl)
+ }
+
+ warmConnections = () => {
+ if (this.preconnected) return
+
+ console.log('Warming connections')
+
+ // The iframe document and most of its subresources come right off youtube.com
+ this.addPrefetch('preconnect', 'https://www.youtube-nocookie.com')
+ // The botguard script is fetched off from google.com
+ this.addPrefetch('preconnect', 'https://www.google.com')
+
+ // Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling.
+ this.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net')
+ this.addPrefetch('preconnect', 'https://static.doubleclick.net')
+
+ this.preconnected = true
+ }
+
+ startPlayerLoad = async () => {
+ // console.log('Starting player load')
+
+ this.addIframe()
+
+ this.playerLoaded = true
+
+ await this.initializePlayer()
+
+ // this.$nextTick(() => {
+ // this.initializePlayer()
+ // })
+ }
+
+ initializePlayer = async () => {
+ console.log('Initializing player')
+
+ // Clear player
+ this.player = null
+
+ // Clear progession interval
+ clearInterval(this.progressInterval)
+
+ // If there are no timestamps
+ // then stop
+ if ( !this.hasTimestamps() ) {
+ console.log('No timestamps. Skipping Youtube API initialization')
+
+ this.playerLoaded = true
+ return
+ }
+
+ if (typeof YT === 'undefined') {
+ await this.initializeApi()
+ }
+
+ const stateHandlers = {
+ // unstarted
+ '-1': () => null,
+ // ended
+ '0': () => null,
+ // playing
+ '1': this.onPlayerPlaying,
+ // paused
+ '2': this.onPlayerPaused,
+ // buffering
+ '3': () => null,
+ // video cued
+ '4': () => null,
+ }
+
+ // console.log('frame', this.$refs['frame'])
+ // console.log('frame id', this.$refs['frame'].id)
+
+ const onReady = () => new Promise( resolve => {
+
+ // console.log('Started onReady')
+
+ this.player = new YT.Player(this.$refs['frame'].id, {
+ events: {
+ 'onReady': readyEvent => {
+ // console.log('Resolving onReady')
+
+ this.onPlayerReady( readyEvent )
+
+ resolve( readyEvent )
+ },
+ 'onStateChange': event => {
+ // console.log('state changed', event)
+
+ const stateHandler = stateHandlers[String(event.data)]
+ // console.log('stateHandler', stateHandler)
+ stateHandler(event)
+ }
+ }
+ })
+
+ })
+
+ // console.log('Waiting for ready')
+
+ const readyEvent = await onReady()
+
+ // console.log('Youtube Player API ready', readyEvent, JSON.stringify(this.player))
+ }
+
+ initializeApi = () => {
+ return new Promise( resolve => {
+ const tag = document.createElement('script')
+ tag.id = `youtube-api-script-${this._uid}`
+ tag.src = 'https://www.youtube.com/iframe_api'
+
+ const firstScriptTag = document.getElementsByTagName('script')[0]
+ firstScriptTag.parentNode.insertBefore(tag, firstScriptTag)
+
+
+ window.onYouTubeIframeAPIReady = resolve
+ })
+ }
+
+ onPlayerPlaying = () => {
+ // console.log('Player playing')
+ this.playing = true
+
+ this.progressInterval = setInterval(() => {
+ // console.log('this.player.getCurrentTime()', this.player.getCurrentTime())
+
+ // If player is empty
+ // then stop
+ if (this.player === null) {
+ clearInterval(this.progressInterval)
+ return
+ }
+
+ // console.log('this.player', this.player.hasOwnProperty('getCurrentTime'))
+
+ this.playerTime = this.player.getCurrentTime()
+
+ this.highlightActiveTimestamp()
+
+ }, 500)
+ }
+
+ onPlayerPaused = () => {
+ console.log('Player paused')
+ this.playing = false
+
+ clearInterval(this.progressInterval)
+ }
+
+ onPlayerReady (event) {
+ // console.log('Player is ready', event, this.player )
+ }
+}
+// Register custom element
+window.customElements.define('lite-youtube', LiteYTEmbed)
diff --git a/helpers/structured-data.js b/helpers/structured-data.js
new file mode 100644
index 0000000..a4800fd
--- /dev/null
+++ b/helpers/structured-data.js
@@ -0,0 +1,45 @@
+function makeFeaturedAppsString ( featuredApps ) {
+ return featuredApps.slice(0, 5).map(app => app.name).join(', ')
+}
+
+export function buildVideoStructuredData ( video, featuredApps, options ) {
+ // console.log('video', video)
+
+ const {
+ siteUrl
+ } = options
+
+ const thumbnailUrls = video.thumbnail.srcset.split(',').map( srcSetImage => {
+ const [ imageUrl ] = srcSetImage.trim().split(' ')
+
+ return imageUrl
+ })
+
+ const featuredAppsString = makeFeaturedAppsString( featuredApps )
+
+ const embedUrl = new URL( `${ siteUrl }/embed/rich-results-player` )
+
+ embedUrl.searchParams.append( 'youtube-id', video.id )
+ embedUrl.searchParams.append( 'name', video.name )
+
+ return {
+ "@context": "https://schema.org",
+ // https://developers.google.com/search/docs/data-types/video
+ // https://schema.org/VideoObject
+ "@type": "VideoObject",
+ "name": video.name,
+ "description": `Includes the following apps: ${featuredAppsString}`,
+ "thumbnailUrl": thumbnailUrls,
+ // https://en.wikipedia.org/wiki/ISO_8601
+ "uploadDate": video.lastUpdated.raw,
+ // "duration": "PT1M54S", // Need to updaet Youtube API Request for this
+ // "contentUrl": "https://www.example.com/video/123/file.mp4",
+ "embedUrl": embedUrl.href,
+ // "interactionStatistic": {
+ // "@type": "InteractionCounter",
+ // "interactionType": { "@type": "http://schema.org/WatchAction" },
+ // "userInteractionCount": 5647018
+ // },
+ // "regionsAllowed": "US,NL"
+ }
+}
diff --git a/layouts-eleventy/default.11ty.js b/layouts-eleventy/default.11ty.js
index 3a32b14..5c45153 100644
--- a/layouts-eleventy/default.11ty.js
+++ b/layouts-eleventy/default.11ty.js
@@ -1,7 +1,7 @@
import fs from 'fs'
import { JSDOM } from 'jsdom'
-import config from '../nuxt.config'
+import config from '../nuxt.config.js'
console.log('Running Default Layout file')
@@ -94,6 +94,9 @@ const cleanNuxtLayout = ( layout ) => {
// Set link tags
document.querySelector('title').insertAdjacentHTML('afterend', templateVar('link-tags') )
+ // Add meta tags after title node
+ document.querySelector('title').insertAdjacentHTML('afterend', templateVar('structured-data') )
+
// Add meta tags after title node
document.querySelector('title').insertAdjacentHTML('afterend', templateVar('meta-tags') )
@@ -172,11 +175,30 @@ class DefaultLayout {
return Object.values(meta).join('')
}
- generateLinkTags = ( pageLinkTags = [] ) => {
+ generateStructuredData = function ( renderData ) {
+
+ const {
+ structuredData = null
+ } = renderData
+
+ // console.log('renderData', Object.keys(renderData))
+
+ if ( structuredData === null ) return ''
+
+ const structuredDataJson = JSON.stringify( structuredData )
+
+ return ``
+ }
+
+ generateLinkTags = ( renderData ) => {
+
+ const {
+ headLinkTags = []
+ } = renderData
const linkTags = {
...defaultLinkTags,
- ...Object.fromEntries(pageLinkTags.map( mapLinkTag ))
+ ...Object.fromEntries( headLinkTags.map( mapLinkTag ) )
}
return Object.values( linkTags ).join('')
@@ -205,12 +227,15 @@ class DefaultLayout {
// Set link tags
// this.generateLinkTags()
- workingLayoutString = workingLayoutString.replace( templateVar('link-tags'), this.generateLinkTags() )
+ workingLayoutString = workingLayoutString.replace( templateVar('link-tags'), this.generateLinkTags( data ) )
// Add meta tags after title node
// this.generateMetaTags( data )
workingLayoutString = workingLayoutString.replace( templateVar('meta-tags'), this.generateMetaTags( data ) )
+ // Add structured data
+ workingLayoutString = workingLayoutString.replace( templateVar('structured-data'), this.generateStructuredData( data ) )
+
// Set page css
// document.querySelector('head').insertAdjacentHTML('beforeend', this.getCss() )
diff --git a/package-lock.json b/package-lock.json
index 79b08ae..93aca74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"can-autoplay": "^3.0.0",
"chance": "^1.1.7",
"cross-env": "^5.2.0",
+ "esbuild": "^0.11.20",
"jsdom": "^16.4.0",
"lazysizes": "^5.3.0-beta1",
"markdown-it": "^11.0.1",
@@ -26,6 +27,7 @@
"pretty-bytes": "^5.5.0",
"scroll-into-view-if-needed": "^2.2.26",
"slugify": "^1.4.6",
+ "terser": "^4.8.0",
"vue-full-screen-file-drop": "^2.0.0"
},
"devDependencies": {
@@ -5999,8 +6001,7 @@
"node_modules/buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
- "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
- "dev": true
+ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
},
"node_modules/buffer-json": {
"version": "2.0.0",
@@ -6795,8 +6796,7 @@
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/commondir": {
"version": "1.0.1",
@@ -9128,6 +9128,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/esbuild": {
+ "version": "0.11.20",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.11.20.tgz",
+ "integrity": "sha512-QOZrVpN/Yz74xfat0H6euSgn3RnwLevY1mJTEXneukz1ln9qB+ieaerRMzSeETpz/UJWsBMzRVR/andBht5WKw==",
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ }
+ },
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -22997,7 +23006,6 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
- "dev": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -23007,7 +23015,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -23882,7 +23889,6 @@
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz",
"integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==",
- "dev": true,
"dependencies": {
"commander": "^2.20.0",
"source-map": "~0.6.1",
@@ -23994,7 +24000,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -31632,8 +31637,7 @@
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
- "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
- "dev": true
+ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
},
"buffer-json": {
"version": "2.0.0",
@@ -32280,8 +32284,7 @@
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"commondir": {
"version": "1.0.1",
@@ -34229,6 +34232,11 @@
"is-symbol": "^1.0.2"
}
},
+ "esbuild": {
+ "version": "0.11.20",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.11.20.tgz",
+ "integrity": "sha512-QOZrVpN/Yz74xfat0H6euSgn3RnwLevY1mJTEXneukz1ln9qB+ieaerRMzSeETpz/UJWsBMzRVR/andBht5WKw=="
+ },
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -45481,7 +45489,6 @@
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
- "dev": true,
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@@ -45490,8 +45497,7 @@
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
@@ -46198,7 +46204,6 @@
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz",
"integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==",
- "dev": true,
"requires": {
"commander": "^2.20.0",
"source-map": "~0.6.1",
@@ -46208,8 +46213,7 @@
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
diff --git a/package.json b/package.json
index 60d9954..c392de1 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"can-autoplay": "^3.0.0",
"chance": "^1.1.7",
"cross-env": "^5.2.0",
+ "esbuild": "^0.11.20",
"jsdom": "^16.4.0",
"lazysizes": "^5.3.0-beta1",
"markdown-it": "^11.0.1",
@@ -42,6 +43,7 @@
"pretty-bytes": "^5.5.0",
"scroll-into-view-if-needed": "^2.2.26",
"slugify": "^1.4.6",
+ "terser": "^4.8.0",
"vue-full-screen-file-drop": "^2.0.0"
},
"devDependencies": {
diff --git a/pages-eleventy/app.11ty.js b/pages-eleventy/app.11ty.js
index d785ca7..ceba932 100644
--- a/pages-eleventy/app.11ty.js
+++ b/pages-eleventy/app.11ty.js
@@ -2,7 +2,7 @@ import dotenv from 'dotenv'
import config from '../nuxt.config.js'
-import { getAppType } from '../helpers/app-derived.js'
+import { getAppType, getRouteType } from '../helpers/app-derived.js'
import { deviceSupportsApp } from '../helpers/devices.js'
import { makeLastUpdatedFriendly } from '../helpers/parse-date'
@@ -70,15 +70,16 @@ export class AppTemplate {
before: function( data ) {
return data.filter( entry => {
- const appType = getAppType( entry.payload.app )
+ // const [ _, routeType ] = entry.route.split('/')
+ const routeType = getRouteType( entry.route )
- return appType === 'app'
+ return routeType === 'app'
})
}
},
eleventyComputed: {
- title: ({ app: { payload: { app } } }) => {
+ title: ({ app: { payload: { app } } }) => {
// console.log('data', data)
return makeTitle( app )
},
@@ -97,13 +98,15 @@ export class AppTemplate {
}
}
- render( data ) {
+ async render( data ) {
const {
app: { payload: { app, relatedVideos = [] } },
'device-list': deviceList
} = data
+ const hasRelatedVideos = relatedVideos.length > 0
+
// console.log('deviceList', deviceList)
// console.log('video.payload', Object.keys(video.payload))
@@ -123,8 +126,12 @@ export class AppTemplate {
const relatedLinksHtml = renderPageLinksHtml( app.relatedLinks )
+
+ const relatedVideosRowHtml = hasRelatedVideos ? await this.boundComponent(VideoRow)( relatedVideos ) : null
+
return /* html */`
-
+
+
${ data.mainHeading }
@@ -168,7 +175,7 @@ export class AppTemplate {
- ${ relatedVideos.length > 0 ? /* html */`
+ ${ hasRelatedVideos ? /* html */`
@@ -176,7 +183,7 @@ export class AppTemplate {
Related Videos
- ${ this.boundComponent(VideoRow)( relatedVideos ) }
+ ${ relatedVideosRowHtml }
` : '' }
diff --git a/pages-eleventy/formula.11ty.js b/pages-eleventy/formula.11ty.js
index 6d0bc2d..3e34c93 100644
--- a/pages-eleventy/formula.11ty.js
+++ b/pages-eleventy/formula.11ty.js
@@ -2,7 +2,7 @@ import dotenv from 'dotenv'
import config from '../nuxt.config.js'
-import { getAppType } from '../helpers/app-derived.js'
+import { getAppType, getRouteType } from '../helpers/app-derived.js'
import { AppTemplate } from './app.11ty.js'
@@ -33,9 +33,9 @@ class FormulaTemplate extends AppTemplate {
alias: 'app',
before: function( data ) {
return data.filter( entry => {
- const appType = getAppType( entry.payload.app )
+ const routeType = getRouteType( entry.route )
- return appType === 'formula'
+ return routeType === 'formula'
})
}
},
diff --git a/pages-eleventy/tv.11ty.js b/pages-eleventy/tv.11ty.js
index a5d29f3..c316d85 100644
--- a/pages-eleventy/tv.11ty.js
+++ b/pages-eleventy/tv.11ty.js
@@ -1,27 +1,56 @@
import dotenv from 'dotenv'
-import config from '../nuxt.config'
+import config from '../nuxt.config.js'
+import VideoPlayer from '../components-eleventy/video/player.js'
import VideoRow from '../components-eleventy/video/row.js'
-import { isVideo } from '../helpers/app-derived'
+
+import { getRouteType } from '../helpers/app-derived.js'
+import { buildVideoStructuredData } from '../helpers/structured-data.js'
// Setup dotenv
dotenv.config()
-export const makeTitle = function ( video ) {
- return `${ video.name } - ${ config.head.title }`
+export const myChannelId = 'UCB3jOb5QVjX7lYecvyCoTqQ'
+
+export const makeTitle = function ( name ) {
+ // console.log('tvEntry', tvEntry)
+
+ return `${ name } - ${ config.head.title }`
}
-export const makeDescription = function ( video ) {
- if ( video.payload.featuredApps.length === 0 ) return 'Apple Silicon performance and support videos'
+export const makeDescription = function ( tvEntry ) {
+ if ( tvEntry.payload.featuredApps.length === 0 ) return 'Apple Silicon performance and support videos'
- const featuredAppsString = video.payload.featuredApps.slice(0, 5).map(app => app.name).join(', ')
+ const featuredAppsString = tvEntry.payload.featuredApps.slice(0, 5).map(app => app.name).join(', ')
- // console.log('video.payload.featuredApps', video.payload.featuredApps)
+ // console.log('tvEntry.payload.featuredApps', tvEntry.payload.featuredApps)
return `Apple Silicon performance and support videos for ${ featuredAppsString }`
}
+
+function renderFeaturedApps ( featuredApps ) {
+ return /* html */`
+