diff --git a/.nvmrc b/.nvmrc
index 8fdd954d..2edeafb0 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-22
\ No newline at end of file
+20
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index d876dfd1..dc9a6ddf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"@11ty/eleventy": "v3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
"@cdransf/eleventy-plugin-tabler-icons": "^2.0.3",
- "@supabase/supabase-js": "^2.45.4",
+ "@supabase/supabase-js": "^2.45.5",
"autoprefixer": "^10.4.20",
"cssnano": "^7.0.6",
"dotenv-flow": "^4.1.0",
@@ -43,7 +43,7 @@
"truncate-html": "^1.1.2"
},
"engines": {
- "node": "22.x"
+ "node": "20.x"
}
},
"node_modules/@11ty/dependency-tree": {
@@ -579,9 +579,9 @@
}
},
"node_modules/@supabase/auth-js": {
- "version": "2.65.0",
- "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz",
- "integrity": "sha512-+wboHfZufAE2Y612OsKeVP4rVOeGZzzMLD/Ac3HrTQkkY4qXNjI6Af9gtmxwccE5nFvTiF114FEbIQ1hRq5uUw==",
+ "version": "2.65.1",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.1.tgz",
+ "integrity": "sha512-IA7i2Xq2SWNCNMKxwmPlHafBQda0qtnFr8QnyyBr+KaSxoXXqEzFCnQ1dGTy6bsZjVBgXu++o3qrDypTspaAPw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -589,9 +589,9 @@
}
},
"node_modules/@supabase/functions-js": {
- "version": "2.4.1",
- "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.1.tgz",
- "integrity": "sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA==",
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.3.tgz",
+ "integrity": "sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -612,9 +612,9 @@
}
},
"node_modules/@supabase/postgrest-js": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.1.tgz",
- "integrity": "sha512-EOSEZFm5pPuCPGCmLF1VOCS78DfkSz600PBuvBND/IZmMciJ1pmsS3ss6TkB6UkuvTybYiBh7gKOYyxoEO3USA==",
+ "version": "1.16.2",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.2.tgz",
+ "integrity": "sha512-dA/CIrSO2YDQ6ABNpbvEg9DwBMMbuKfWaFuZAU9c66PenoLSoIoyXk1Yq/wC2XISgEIqaMHmTrDAAsO80kjHqg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -622,9 +622,9 @@
}
},
"node_modules/@supabase/realtime-js": {
- "version": "2.10.2",
- "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.2.tgz",
- "integrity": "sha512-qyCQaNg90HmJstsvr2aJNxK2zgoKh9ZZA8oqb7UT2LCh3mj9zpa3Iwu167AuyNxsxrUE8eEJ2yH6wLCij4EApA==",
+ "version": "2.10.7",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.7.tgz",
+ "integrity": "sha512-OLI0hiSAqQSqRpGMTUwoIWo51eUivSYlaNBgxsXZE7PSoWh12wPRdVt0psUMaUzEonSB85K21wGc7W5jHnT6uA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -635,9 +635,9 @@
}
},
"node_modules/@supabase/storage-js": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.0.tgz",
- "integrity": "sha512-iZenEdO6Mx9iTR6T7wC7sk6KKsoDPLq8rdu5VRy7+JiT1i8fnqfcOr6mfF2Eaqky9VQzhP8zZKQYjzozB65Rig==",
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz",
+ "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -645,18 +645,18 @@
}
},
"node_modules/@supabase/supabase-js": {
- "version": "2.45.4",
- "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.4.tgz",
- "integrity": "sha512-E5p8/zOLaQ3a462MZnmnz03CrduA5ySH9hZyL03Y+QZLIOO4/Gs8Rdy4ZCKDHsN7x0xdanVEWWFN3pJFQr9/hg==",
+ "version": "2.45.5",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.5.tgz",
+ "integrity": "sha512-xTPsv33Hcj6C38SXa4nKobwEwkNQuwcCKtcuBsDT6bvphl1VUAO3x2QoLOuuglJzk2Oaf3WcVsvRcxXNE8PG/g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@supabase/auth-js": "2.65.0",
- "@supabase/functions-js": "2.4.1",
+ "@supabase/auth-js": "2.65.1",
+ "@supabase/functions-js": "2.4.3",
"@supabase/node-fetch": "2.6.15",
- "@supabase/postgrest-js": "1.16.1",
- "@supabase/realtime-js": "2.10.2",
- "@supabase/storage-js": "2.7.0"
+ "@supabase/postgrest-js": "1.16.2",
+ "@supabase/realtime-js": "2.10.7",
+ "@supabase/storage-js": "2.7.1"
}
},
"node_modules/@trysound/sax": {
diff --git a/package.json b/package.json
index 89ff45d2..1588d6c7 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {
- "node": "22.x"
+ "node": "20.x"
},
"scripts": {
"start": "eleventy --serve",
@@ -36,7 +36,7 @@
"@11ty/eleventy": "v3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
"@cdransf/eleventy-plugin-tabler-icons": "^2.0.3",
- "@supabase/supabase-js": "^2.45.4",
+ "@supabase/supabase-js": "^2.45.5",
"autoprefixer": "^10.4.20",
"cssnano": "^7.0.6",
"dotenv-flow": "^4.1.0",
diff --git a/workers/dynamic-pages/index.js b/workers/dynamic-pages/index.js
index 1dcc95ce..034215ef 100644
--- a/workers/dynamic-pages/index.js
+++ b/workers/dynamic-pages/index.js
@@ -1,396 +1,16 @@
import { createClient } from '@supabase/supabase-js'
-import { parseHTML } from 'linkedom'
-import markdownIt from 'markdown-it'
-import truncateHtml from 'truncate-html'
-import { convert } from 'html-to-text'
+import { fetchDataByUrl, fetchGlobals } from './utils/fetchers.js'
+import {
+ generateArtistHTML,
+ generateBookHTML,
+ generateGenreHTML,
+ generateMetadata,
+ generateWatchingHTML
+} from './utils/generators.js'
+import { updateDynamicContent } from './utils/updaters.js'
-const md = markdownIt({ html: true, linkify: true })
-const ICON_MAP = {
- alertTriangle: ``,
- arrowLeft: ``,
- article: ``,
- books: ``,
- circleCheck: ``,
- circleX: ``,
- deviceSpeaker: ``,
- deviceTvOld: ``,
- film: ``,
- headphones: ``,
- heart: ``,
- infoCircle: ``,
- link: ``,
- mapPin: ``,
- needle: ``,
- movie: ``,
-}
-
-const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
-const getCountryName = (countryCode) => regionNames.of(countryCode.trim()) || countryCode.trim()
-const parseCountryField = (countryField) => {
- if (!countryField) return null
- const delimiters = [',', '/', '&', 'and']
- let countries = [countryField]
- delimiters.forEach(delimiter => countries = countries.flatMap(country => country.split(delimiter)))
- return countries.map(getCountryName).join(', ')
-}
-
-const generateMediaLinks = (data, type, count = 10) => {
- if (!data || !type) return ''
-
- const dataSlice = data.slice(0, count)
- if (dataSlice.length === 0) return null
-
- const buildLink = (item) => {
- switch (type) {
- case 'genre':
- return `${item['genre_name']}`
- case 'artist':
- return `${item['name']}`
- case 'book':
- return `${item['title']}`
- default:
- return ''
- }
- }
-
- if (dataSlice.length === 1) return buildLink(dataSlice[0])
-
- const links = dataSlice.map(buildLink)
- const allButLast = links.slice(0, -1).join(', ')
- const last = links[links.length - 1]
-
- return `${allButLast} and ${last}`
-}
-
-async function fetchDataByUrl(supabase, table, url) {
- const { data, error } = await supabase.from(table).select('*').eq('url', url).single()
-
- if (error) {
- console.error(`Error fetching from ${table}:`, error)
- return null
- }
-
- return data
-}
-
-const formatDate = (date) => new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
-
-async function fetchGlobals(supabase) {
- const { data, error } = await supabase.from('optimized_globals').select('*').single()
- if (error) {
- console.error('Error fetching globals:', error)
- return {}
- }
- return data
-}
-
-function generateMetadata(data, type, globals) {
- let title = globals['site_name']
- let description = data['description'] || globals['site_description']
- const canonicalUrl = data['url'] ? `${globals['url']}${data['url']}` : globals['url']
- const ogImage = `${globals['cdn_url']}${data['image'] || globals['avatar']}?class=w800`
-
- description = convert(truncateHtml(md.render(description), 100, {
- byWords: true,
- ellipsis: '...'
- }),
- {
- wordwrap: false,
- selectors: [
- { selector: 'a', options: { ignoreHref: true } },
- { selector: 'h1', options: { uppercase: false } },
- { selector: 'h2', options: { uppercase: false } },
- { selector: 'h3', options: { uppercase: false } },
- { selector: '*', format: 'block' }
- ]
- }).replace(/\s+/g, ' ').trim()
-
- switch (type) {
- case 'artist':
- title = `Artists / ${data['name']} / ${globals['site_name']}`
- break
- case 'genre':
- title = `Genre / ${data['name']} / ${globals['site_name']}`
- break
- case 'book':
- title = `Books / ${data['title']} by ${data.author} / ${globals['site_name']}`
- break
- case 'movie':
- title = `Movies / ${data['title']} (${data.year}) / ${globals['site_name']}`
- break
- case 'show':
- title = `Shows / ${data['title']} / ${globals['site_name']}`
- break
- default:
- title = `${data['title'] || globals['site_name']}`
- }
-
- return {
- title,
- description,
- 'og:title': title,
- 'og:description': description,
- 'og:image': ogImage,
- 'og:url': canonicalUrl,
- 'canonical': canonicalUrl
- }
-}
-
-function updateDynamicContent(html, metadata, mediaHtml) {
- const { document } = parseHTML(html)
-
- const titleTag = document.querySelector('title[data-dynamic="title"]')
- if (titleTag) titleTag['textContent'] = metadata['title']
-
- const dynamicMetaSelectors = [
- { selector: 'meta[data-dynamic="description"]', attribute: 'content', value: metadata['description'] },
- { selector: 'meta[data-dynamic="og:title"]', attribute: 'content', value: metadata['og:title'] },
- { selector: 'meta[data-dynamic="og:description"]', attribute: 'content', value: metadata['og:description'] },
- { selector: 'meta[data-dynamic="og:image"]', attribute: 'content', value: metadata['og:image'] },
- { selector: 'meta[data-dynamic="og:url"]', attribute: 'content', value: metadata['canonical'] },
- ]
-
- dynamicMetaSelectors.forEach(({ selector, attribute, value }) => {
- const element = document.querySelector(selector)
- if (element) element.setAttribute(attribute, value)
- })
-
- const canonicalLink = document.querySelector('link[rel="canonical"]')
- if (canonicalLink) canonicalLink.setAttribute('href', metadata['canonical'])
-
- const pageElement = document.querySelector('[data-dynamic="page"]')
- if (pageElement) pageElement.innerHTML = mediaHtml
-
- return document.toString()
-}
-
-const warningBanner = `
${ICON_MAP['alertTriangle']}There are probably spoilers after this banner — this is a warning about them.
`
-
-function generateAssociatedMediaHTML(data, isGenre = false) {
- const sections = [
- { key: 'artists', icon: 'headphones', category: 'music', title: 'Related Artist(s)' },
- { key: 'books', icon: 'books', category: 'books', title: 'Related Book(s)' },
- { key: 'genres', icon: 'headphones', category: 'music', title: 'Related Genre(s)' },
- { key: 'related_movies', icon: 'film', category: 'movies', title: 'Related Movie(s)' },
- { key: 'posts', icon: 'article', category: 'article', title: 'Related Post(s)' },
- { key: 'shows', icon: 'deviceTvOld', category: 'tv', title: 'Related Show(s)' }
- ]
-
- return sections
- .filter(({ key }) => !(isGenre && key === 'artists'))
- .map(({ key, category, icon, title }) =>
- data[key] && data[key].length
- ? ``
- : ''
- )
- .join('')
-}
-
-function generateWatchingHTML(media, globals, type) {
- const isShow = type === 'show'
- const label = isShow ? 'show' : 'movie'
- const lastWatched = media['last_watched'] || (isShow && media['episode']?.['last_watched_at'])
-
- return `
- ${ICON_MAP.arrowLeft} Back to watching
-
-
-
- ${media['review'] ? `${warningBanner}My thoughts
${md.render(media['review'])}
` : ''}
- ${generateAssociatedMediaHTML(media)}
- ${media['description'] ? `Overview
${md.render(media['description'])}
` : ''}
-
- `
-}
-
-function generateConcertModal(concert) {
- const venue = concert['venue_name']
- ? concert['venue_latitude'] && concert['venue_longitude']
- ? `${concert['venue_name_short']}`
- : concert['venue_name_short']
- : ''
-
- const notesModal = concert['notes']
- ? `
-
-
-
-
-
-
Notes
- ${md.render(concert['notes'])}
-
-
-
`
- : ''
-
- return `
-
- ${formatDate(concert['date'])} at ${venue}
- ${notesModal}
-
- `
-}
-
-function generateArtistHTML(artist, globals) {
- const playLabel = artist?.['total_plays'] === 1 ? 'play' : 'plays'
- const concertsList = artist['concerts']?.length
- ? `
-
- ${ICON_MAP['deviceSpeaker']}
- I've seen this artist live!
-
- ${artist['concerts'].map(generateConcertModal).join('')}
`
- : ''
- const albumsTable = artist['albums']?.length
- ? `
- Album | Plays | Year |
- ${artist['albums'].map(album => `
-
- ${album['name']} |
- ${album['total_plays'] || 0} |
- ${album['release_year']} |
-
`).join('')}
-
- These are the albums by this artist that are in my collection, not necessarily a comprehensive discography.
- `
- : ''
-
- return `
- ${ICON_MAP.arrowLeft} Back to music
-
-
-
![${artist['name']} / ${artist['country']}](${globals['cdn_url']}${artist['image']}?class=w200&type=webp)
-
-
- ${artist['description'] ? `
- Overview
- ${md.render(artist['description'])}
- ` : ''
- }
- ${concertsList}
- ${albumsTable}
-
- `
-}
-
-function generateBookHTML(book, globals) {
- const alt = `${book['title']}${book['author'] ? ` by ${book['author']}` : ''}`
- const percentage = book['progress'] ? `${book['progress']}%` : ''
- const status = book['status'] === 'finished'
- ? `Finished on ${formatDate(book['date_finished'])}`
- : percentage
- ? ``
- : ''
-
- return `
- ${ICON_MAP['arrowLeft']} Back to books
-
-
-

-
-
- ${book['review'] ? `${warningBanner}My thoughts
${book['review']}
` : ''}
- ${generateAssociatedMediaHTML(book)}
- Overview
- ${md.render(book['description'])}
-
- `
-}
-
-function generateGenreHTML(genre) {
- const artistCount = genre['artists']?.length || 0
- const connectingWords = artistCount > 1 ? 'artists are' : 'artist is'
- const mediaLinks = generateMediaLinks(genre['artists'], 'artist', 5)
-
- return `
- ${ICON_MAP['arrowLeft']} Back to music
- ${genre['name']}
-
- ${mediaLinks ? `
- My top ${genre['name']} ${connectingWords} ${mediaLinks}. I've listened to ${genre['total_plays']} tracks from this genre.
-
` : ''}
- ${generateAssociatedMediaHTML(genre, true)}
- ${genre['description'] ? `
- Overview
-
- ${md.render(genre['description'])}
-
Continue reading at Wikipedia.
-
Wikipedia content provided under the terms of the Creative Commons BY-SA license.
-
- ` : ''}
-
- `
-}
+const BASE_URL = 'https://coryd.dev'
+const NOT_FOUND_URL = `${BASE_URL}/404`
export default {
async fetch(request, env) {
@@ -401,8 +21,8 @@ export default {
const supabase = createClient(supabaseUrl, supabaseKey)
let data, type
- if (path === '/books' || path === '/books/') return fetch('https://coryd.dev/books/')
- if (path.startsWith('/books/years/')) return fetch(`https://coryd.dev${path}`)
+ if (path === '/books' || path === '/books/') return fetch(`${BASE_URL}/books/`)
+ if (path.startsWith('/books/years/')) return fetch(`${BASE_URL}${path}`)
if (path.startsWith('/watching/movies/')) {
data = await fetchDataByUrl(supabase, 'optimized_movies', path)
@@ -420,10 +40,10 @@ export default {
data = await fetchDataByUrl(supabase, 'optimized_books', path)
type = 'book'
} else {
- return Response.redirect('https://coryd.dev/404', 302)
+ return Response.redirect(NOT_FOUND_URL, 302)
}
- if (!data) return Response.redirect('https://coryd.dev/404', 302)
+ if (!data) return Response.redirect(NOT_FOUND_URL, 302)
const globals = await fetchGlobals(supabase)
let mediaHtml
@@ -443,9 +63,8 @@ export default {
break
}
- const templateResponse = await fetch('https://coryd.dev/dynamic.html')
+ const templateResponse = await fetch(`${BASE_URL}/dynamic.html`)
const template = await templateResponse.text()
-
const metadata = generateMetadata(data, type, globals)
const html = updateDynamicContent(template, metadata, mediaHtml)
const headers = new Headers({
diff --git a/workers/dynamic-pages/utils/countries.js b/workers/dynamic-pages/utils/countries.js
new file mode 100644
index 00000000..64f096f9
--- /dev/null
+++ b/workers/dynamic-pages/utils/countries.js
@@ -0,0 +1,10 @@
+const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
+const getCountryName = (countryCode) => regionNames.of(countryCode.trim()) || countryCode.trim()
+
+export const parseCountryField = (countryField) => {
+ if (!countryField) return null
+ const delimiters = [',', '/', '&', 'and']
+ let countries = [countryField]
+ delimiters.forEach(delimiter => countries = countries.flatMap(country => country.split(delimiter)))
+ return countries.map(getCountryName).join(', ')
+}
\ No newline at end of file
diff --git a/workers/dynamic-pages/utils/fetchers.js b/workers/dynamic-pages/utils/fetchers.js
new file mode 100644
index 00000000..d9df71d9
--- /dev/null
+++ b/workers/dynamic-pages/utils/fetchers.js
@@ -0,0 +1,19 @@
+export const fetchDataByUrl = async (supabase, table, url) => {
+ const { data, error } = await supabase.from(table).select('*').eq('url', url).single()
+
+ if (error) {
+ console.error(`Error fetching from ${table}:`, error)
+ return null
+ }
+
+ return data
+}
+
+export const fetchGlobals = async (supabase) => {
+ const { data, error } = await supabase.from('optimized_globals').select('*').single()
+ if (error) {
+ console.error('Error fetching globals:', error)
+ return {}
+ }
+ return data
+}
\ No newline at end of file
diff --git a/workers/dynamic-pages/utils/formatters.js b/workers/dynamic-pages/utils/formatters.js
new file mode 100644
index 00000000..50be2bfd
--- /dev/null
+++ b/workers/dynamic-pages/utils/formatters.js
@@ -0,0 +1,4 @@
+import markdownIt from 'markdown-it'
+
+export const formatDate = (date) => new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
+export const md = markdownIt({ html: true, linkify: true })
\ No newline at end of file
diff --git a/workers/dynamic-pages/utils/generators.js b/workers/dynamic-pages/utils/generators.js
new file mode 100644
index 00000000..c4f7aa96
--- /dev/null
+++ b/workers/dynamic-pages/utils/generators.js
@@ -0,0 +1,340 @@
+import truncateHtml from 'truncate-html'
+import { convert } from 'html-to-text'
+
+import { parseCountryField } from './countries.js'
+import { formatDate, md } from './formatters.js'
+import { ICON_MAP } from './icons.js'
+
+const warningBanner = `${ICON_MAP['alertTriangle']}There are probably spoilers after this banner — this is a warning about them.
`
+const generateAssociatedMediaHTML = (data, isGenre = false) => {
+ const sections = [
+ { key: 'artists', icon: 'headphones', category: 'music', title: 'Related Artist(s)' },
+ { key: 'related_artists', icon: 'headphones', category: 'music', title: 'Related Artist(s)' },
+ { key: 'genres', icon: 'headphones', category: 'music', title: 'Related Genre(s)' },
+ { key: 'movies', icon: 'film', category: 'movies', title: 'Related Movie(s)' },
+ { key: 'shows', icon: 'deviceTvOld', category: 'tv', title: 'Related Show(s)' },
+ { key: 'related_shows', icon: 'deviceTvOld', category: 'tv', title: 'Related Show(s)' },
+ { key: 'posts', icon: 'article', category: 'article', title: 'Related Post(s)' },
+ { key: 'related_books', icon: 'books', category: 'books', title: 'Related Book(s)' }
+ ]
+
+ return sections
+ .filter(({ key }) => !(isGenre && key === 'artists'))
+ .map(({ key, category, icon, title }) => {
+ const items = data[key]
+ if (!items || items.length === 0) return ''
+
+ return `
+ `
+ })
+ .join('')
+}
+const generateMediaLinks = (data, type, count = 10) => {
+ if (!data || !type) return ''
+
+ const dataSlice = data.slice(0, count)
+ if (dataSlice.length === 0) return null
+
+ const buildLink = (item) => {
+ switch (type) {
+ case 'genre':
+ return `${item['genre_name']}`
+ case 'artist':
+ return `${item['name']}`
+ case 'book':
+ return `${item['title']}`
+ default:
+ return ''
+ }
+ }
+
+ if (dataSlice.length === 1) return buildLink(dataSlice[0])
+
+ const links = dataSlice.map(buildLink)
+ const allButLast = links.slice(0, -1).join(', ')
+ const last = links[links.length - 1]
+
+ return `${allButLast} and ${last}`
+}
+
+export const generateArtistHTML = (artist, globals) => {
+ const playLabel = artist?.['total_plays'] === 1 ? 'play' : 'plays'
+ const concertsList = artist['concerts']?.length
+ ? `
+
+ ${ICON_MAP['deviceSpeaker']}
+ I've seen this artist live!
+
+ ${artist['concerts'].map(generateConcertModal).join('')}
`
+ : ''
+ const albumsTable = artist['albums']?.length
+ ? `
+ Album | Plays | Year |
+ ${artist['albums'].map(album => `
+
+ ${album['name']} |
+ ${album['total_plays'] || 0} |
+ ${album['release_year']} |
+
`).join('')}
+
+ These are the albums by this artist that are in my collection, not necessarily a comprehensive discography.
+ `
+ : ''
+
+ return `
+ ${ICON_MAP.arrowLeft} Back to music
+
+
+
![${artist['name']} / ${artist['country']}](${globals['cdn_url']}${artist['image']}?class=w200&type=webp)
+
+
+ ${generateAssociatedMediaHTML(artist)}
+ ${artist['description'] ? `
+ Overview
+ ${md.render(artist['description'])}
+ ` : ''
+ }
+ ${concertsList}
+ ${albumsTable}
+
+ `
+}
+
+export const generateBookHTML = (book, globals) => {
+ const alt = `${book['title']}${book['author'] ? ` by ${book['author']}` : ''}`
+ const percentage = book['progress'] ? `${book['progress']}%` : ''
+ const status = book['status'] === 'finished'
+ ? `Finished on ${formatDate(book['date_finished'])}`
+ : percentage
+ ? ``
+ : ''
+
+ return `
+ ${ICON_MAP['arrowLeft']} Back to books
+
+
+

+
+
+ ${book['review'] ? `${warningBanner}My thoughts
${book['review']}
` : ''}
+ ${generateAssociatedMediaHTML(book)}
+ Overview
+ ${md.render(book['description'])}
+
+ `
+}
+
+export const generateGenreHTML = (genre) => {
+ const artistCount = genre['artists']?.length || 0
+ const connectingWords = artistCount > 1 ? 'artists are' : 'artist is'
+ const mediaLinks = generateMediaLinks(genre['artists'], 'artist', 5)
+
+ return `
+ ${ICON_MAP['arrowLeft']} Back to music
+ ${genre['name']}
+
+ ${mediaLinks ? `
+ My top ${genre['name']} ${connectingWords} ${mediaLinks}. I've listened to ${genre['total_plays']} tracks from this genre.
+
` : ''}
+ ${generateAssociatedMediaHTML(genre, true)}
+ ${genre['description'] ? `
+ Overview
+
+ ${md.render(genre['description'])}
+
Continue reading at Wikipedia.
+
Wikipedia content provided under the terms of the Creative Commons BY-SA license.
+
+ ` : ''}
+
+ `
+}
+
+export const generateMetadata = (data, type, globals) => {
+ let title = globals['site_name']
+ let description = data['description'] || globals['site_description']
+ const canonicalUrl = data['url'] ? `${globals['url']}${data['url']}` : globals['url']
+ const ogImage = `${globals['cdn_url']}${data['image'] || globals['avatar']}?class=w800`
+
+ description = convert(truncateHtml(md.render(description), 100, {
+ byWords: true,
+ ellipsis: '...'
+ }),
+ {
+ wordwrap: false,
+ selectors: [
+ { selector: 'a', options: { ignoreHref: true } },
+ { selector: 'h1', options: { uppercase: false } },
+ { selector: 'h2', options: { uppercase: false } },
+ { selector: 'h3', options: { uppercase: false } },
+ { selector: '*', format: 'block' }
+ ]
+ }).replace(/\s+/g, ' ').trim()
+
+ switch (type) {
+ case 'artist':
+ title = `Artists / ${data['name']} / ${globals['site_name']}`
+ break
+ case 'genre':
+ title = `Genre / ${data['name']} / ${globals['site_name']}`
+ break
+ case 'book':
+ title = `Books / ${data['title']} by ${data.author} / ${globals['site_name']}`
+ break
+ case 'movie':
+ title = `Movies / ${data['title']} (${data.year}) / ${globals['site_name']}`
+ break
+ case 'show':
+ title = `Shows / ${data['title']} / ${globals['site_name']}`
+ break
+ default:
+ title = `${data['title'] || globals['site_name']}`
+ }
+
+ return {
+ title,
+ description,
+ 'og:title': title,
+ 'og:description': description,
+ 'og:image': ogImage,
+ 'og:url': canonicalUrl,
+ 'canonical': canonicalUrl
+ }
+}
+
+export const generateWatchingHTML = (media, globals, type) => {
+ const isShow = type === 'show'
+ const label = isShow ? 'show' : 'movie'
+ const lastWatched = media['last_watched'] || (isShow && media['episode']?.['last_watched_at'])
+
+ return `
+ ${ICON_MAP.arrowLeft} Back to watching
+
+
+
+ ${media['review'] ? `${warningBanner}My thoughts
${md.render(media['review'])}
` : ''}
+ ${generateAssociatedMediaHTML(media)}
+ ${media['description'] ? `Overview
${md.render(media['description'])}
` : ''}
+
+ `
+}
+
+export const generateConcertModal = (concert) => {
+ const venue = concert['venue_name']
+ ? concert['venue_latitude'] && concert['venue_longitude']
+ ? `${concert['venue_name_short']}`
+ : concert['venue_name_short']
+ : ''
+
+ const notesModal = concert['notes']
+ ? `
+
+
+
+
+
+
Notes
+ ${md.render(concert['notes'])}
+
+
+
`
+ : ''
+
+ return `
+
+ ${formatDate(concert['date'])} at ${venue}
+ ${notesModal}
+
+ `
+}
\ No newline at end of file
diff --git a/workers/dynamic-pages/utils/icons.js b/workers/dynamic-pages/utils/icons.js
new file mode 100644
index 00000000..d2bcac0d
--- /dev/null
+++ b/workers/dynamic-pages/utils/icons.js
@@ -0,0 +1,18 @@
+export const ICON_MAP = {
+ alertTriangle: ``,
+ arrowLeft: ``,
+ article: ``,
+ books: ``,
+ circleCheck: ``,
+ circleX: ``,
+ deviceSpeaker: ``,
+ deviceTvOld: ``,
+ film: ``,
+ headphones: ``,
+ heart: ``,
+ infoCircle: ``,
+ link: ``,
+ mapPin: ``,
+ needle: ``,
+ movie: ``,
+}
\ No newline at end of file
diff --git a/workers/dynamic-pages/utils/updaters.js b/workers/dynamic-pages/utils/updaters.js
new file mode 100644
index 00000000..e579266e
--- /dev/null
+++ b/workers/dynamic-pages/utils/updaters.js
@@ -0,0 +1,29 @@
+import { parseHTML } from 'linkedom'
+
+export const updateDynamicContent = (html, metadata, mediaHtml) => {
+ const { document } = parseHTML(html)
+
+ const titleTag = document.querySelector('title[data-dynamic="title"]')
+ if (titleTag) titleTag['textContent'] = metadata['title']
+
+ const dynamicMetaSelectors = [
+ { selector: 'meta[data-dynamic="description"]', attribute: 'content', value: metadata['description'] },
+ { selector: 'meta[data-dynamic="og:title"]', attribute: 'content', value: metadata['og:title'] },
+ { selector: 'meta[data-dynamic="og:description"]', attribute: 'content', value: metadata['og:description'] },
+ { selector: 'meta[data-dynamic="og:image"]', attribute: 'content', value: metadata['og:image'] },
+ { selector: 'meta[data-dynamic="og:url"]', attribute: 'content', value: metadata['canonical'] },
+ ]
+
+ dynamicMetaSelectors.forEach(({ selector, attribute, value }) => {
+ const element = document.querySelector(selector)
+ if (element) element.setAttribute(attribute, value)
+ })
+
+ const canonicalLink = document.querySelector('link[rel="canonical"]')
+ if (canonicalLink) canonicalLink.setAttribute('href', metadata['canonical'])
+
+ const pageElement = document.querySelector('[data-dynamic="page"]')
+ if (pageElement) pageElement.innerHTML = mediaHtml
+
+ return document.toString()
+}
\ No newline at end of file