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 = `` - -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 - ? `
-

${ICON_MAP[icon]} ${title}

- -
` - : '' - ) - .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['title']} / ${media['year']} -
-

${media['title']} (${media['year']})

- ${media['favorite'] ? `

${ICON_MAP['heart']} This is one of my favorite ${label}s!

` : ''} - ${media['tattoo'] ? `

${ICON_MAP['needle']} I have a tattoo inspired by this ${label}!

` : ''} - ${media['collected'] ? `

${ICON_MAP['circleCheck']} This ${label} is in my collection!

` : ''} - ${lastWatched ? `

Last watched on ${formatDate(lastWatched)}

` : ''} -
- ${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'] - ? ` - - ` - : '' - - 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! -

    - ` - : '' - const albumsTable = artist['albums']?.length - ? ` - - ${artist['albums'].map(album => ` - - - - - `).join('')} -
    AlbumPlaysYear
    ${album['name']}${album['total_plays'] || 0}${album['release_year']}
    -

    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']} -
    -

    ${artist['name']}

    -

    ${ICON_MAP['mapPin']} ${parseCountryField(artist['country'])}

    - ${artist['favorite'] ? `

    ${ICON_MAP['heart']} This is one of my favorite artists!

    ` : ''} - ${artist['tattoo'] ? `

    ${ICON_MAP['needle']} I have a tattoo inspired by this artist!

    ` : ''} - ${artist['total_plays'] ? `

    ${artist['total_plays']} ${playLabel}

    ` : ''} -

    ${artist['genre'] ? `${artist['genre']['name']}` : ''}

    -
    -
    - ${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 -
    -
    - ${alt} -
    -

    ${book['title']}

    - ${book['rating'] ? `

    ${book['rating']}

    ` : ''} - ${book['author'] ? `

    By ${book['author']}

    ` : ''} - ${book['favorite'] ? `

    ${ICON_MAP['heart']} This is one of my favorite books!

    ` : ''} - ${book['tattoo'] ? `

    ${ICON_MAP['needle']} I have a tattoo inspired by this book!

    ` : ''} - ${status ? `

    ${status}

    ` : ''} -
    -
    - ${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 = `` +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 ` +
    +

    ${ICON_MAP[icon]} ${title}

    + +
    ` + }) + .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! +

    + ` + : '' + const albumsTable = artist['albums']?.length + ? ` + + ${artist['albums'].map(album => ` + + + + + `).join('')} +
    AlbumPlaysYear
    ${album['name']}${album['total_plays'] || 0}${album['release_year']}
    +

    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']} +
    +

    ${artist['name']}

    +

    ${ICON_MAP['mapPin']} ${parseCountryField(artist['country'])}

    + ${artist['favorite'] ? `

    ${ICON_MAP['heart']} This is one of my favorite artists!

    ` : ''} + ${artist['tattoo'] ? `

    ${ICON_MAP['needle']} I have a tattoo inspired by this artist!

    ` : ''} + ${artist['total_plays'] ? `

    ${artist['total_plays']} ${playLabel}

    ` : ''} +

    ${artist['genre'] ? `${artist['genre']['name']}` : ''}

    +
    +
    + ${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 +
    +
    + ${alt} +
    +

    ${book['title']}

    + ${book['rating'] ? `

    ${book['rating']}

    ` : ''} + ${book['author'] ? `

    By ${book['author']}

    ` : ''} + ${book['favorite'] ? `

    ${ICON_MAP['heart']} This is one of my favorite books!

    ` : ''} + ${book['tattoo'] ? `

    ${ICON_MAP['needle']} I have a tattoo inspired by this book!

    ` : ''} + ${status ? `

    ${status}

    ` : ''} +
    +
    + ${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['title']} / ${media['year']} +
    +

    ${media['title']} (${media['year']})

    + ${media['favorite'] ? `

    ${ICON_MAP['heart']} This is one of my favorite ${label}s!

    ` : ''} + ${media['tattoo'] ? `

    ${ICON_MAP['needle']} I have a tattoo inspired by this ${label}!

    ` : ''} + ${media['collected'] ? `

    ${ICON_MAP['circleCheck']} This ${label} is in my collection!

    ` : ''} + ${lastWatched ? `

    Last watched on ${formatDate(lastWatched)}

    ` : ''} +
    + ${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'] + ? ` + + ` + : '' + + 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