chore: normalize formatting for workers

This commit is contained in:
Cory Dransfeldt 2024-10-19 19:53:31 -07:00
parent 2f6cfbe7ae
commit 2cd835d31b
No known key found for this signature in database
14 changed files with 879 additions and 604 deletions

View file

@ -1,39 +1,40 @@
const scriptName = '/js/script.js'
const endpoint = '/api/event'
const scriptName = "/js/script.js";
const endpoint = "/api/event";
addEventListener("fetch", (event) => {
event.passThroughOnException()
event.respondWith(handleRequest(event))
})
event.passThroughOnException();
event.respondWith(handleRequest(event));
});
async function handleRequest(event) {
const url = new URL(event.request.url)
const pathname = url.pathname
const url = new URL(event.request.url);
const pathname = url.pathname;
if (pathname === scriptName) {
return getScript(event)
return getScript(event);
} else if (pathname === endpoint) {
return postData(event)
return postData(event);
}
return new Response(null, { status: 404 })
return new Response(null, { status: 404 });
}
async function getScript(event) {
const cache = caches.default
let response = await cache.match(event.request)
const cache = caches.default;
let response = await cache.match(event.request);
if (!response) {
const scriptUrl =
'https://plausible.io/js/plausible.outbound-links.tagged-events.js'
response = await fetch(scriptUrl)
if (response.ok) event.waitUntil(cache.put(event.request, response.clone()))
"https://plausible.io/js/plausible.outbound-links.tagged-events.js";
response = await fetch(scriptUrl);
if (response.ok)
event.waitUntil(cache.put(event.request, response.clone()));
}
return response
return response;
}
async function postData(event) {
const request = new Request(event.request)
request.headers.delete('cookie')
return await fetch('https://plausible.io/api/event', request)
const request = new Request(event.request);
request.headers.delete("cookie");
return await fetch("https://plausible.io/api/event", request);
}

View file

@ -1,84 +1,101 @@
import { createClient } from '@supabase/supabase-js'
import { createClient } from "@supabase/supabase-js";
const RATE_LIMIT = 5
const TIME_FRAME = 60 * 60 * 1000
const ipSubmissions = new Map()
const RATE_LIMIT = 5;
const TIME_FRAME = 60 * 60 * 1000;
const ipSubmissions = new Map();
export default {
async fetch(request, env) {
if (request.method === 'POST') {
const ip = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || request.headers.get('Remote-Addr')
const currentTime = Date.now()
if (request.method === "POST") {
const ip =
request.headers.get("CF-Connecting-IP") ||
request.headers.get("X-Forwarded-For") ||
request.headers.get("Remote-Addr");
const currentTime = Date.now();
if (!ipSubmissions.has(ip)) ipSubmissions.set(ip, [])
if (!ipSubmissions.has(ip)) ipSubmissions.set(ip, []);
const submissions = ipSubmissions.get(ip).filter(time => currentTime - time < TIME_FRAME)
const submissions = ipSubmissions
.get(ip)
.filter((time) => currentTime - time < TIME_FRAME);
if (submissions.length >= RATE_LIMIT) return Response.redirect('https://coryd.dev/rate-limit', 301)
if (submissions.length >= RATE_LIMIT)
return Response.redirect("https://coryd.dev/rate-limit", 301);
submissions.push(currentTime)
ipSubmissions.set(ip, submissions)
submissions.push(currentTime);
ipSubmissions.set(ip, submissions);
try {
const formData = await request.formData()
const name = formData.get('name')
const email = formData.get('email')
const message = formData.get('message')
const hpName = formData.get('hp_name')
if (hpName) return new Response('Spam detected', { status: 400 })
if (!name || !email || !message) return new Response('Invalid input', { status: 400 })
const formData = await request.formData();
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
const hpName = formData.get("hp_name");
if (hpName) return new Response("Spam detected", { status: 400 });
if (!name || !email || !message)
return new Response("Invalid input", { status: 400 });
const emailDomain = email.split('@')[1].toLowerCase()
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)
const emailDomain = email.split("@")[1].toLowerCase();
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
const { data: blockedDomains, error: domainError } = await supabase
.from('blocked_domains')
.select('domain_name')
.from("blocked_domains")
.select("domain_name");
if (domainError) throw new Error(`Failed to fetch blocked domains: ${domainError.message}`)
if (domainError)
throw new Error(
`Failed to fetch blocked domains: ${domainError.message}`
);
const domainList = blockedDomains.map(item => item['domain_name'].toLowerCase())
const domainList = blockedDomains.map((item) =>
item["domain_name"].toLowerCase()
);
if (domainList.includes(emailDomain)) return new Response('Email domain is blocked.', { status: 400 })
if (domainList.includes(emailDomain))
return new Response("Email domain is blocked.", { status: 400 });
const { error } = await supabase.from('contacts').insert([
{ name, email, message, replied: false }
])
const { error } = await supabase
.from("contacts")
.insert([{ name, email, message, replied: false }]);
if (error) throw error
if (error) throw error;
const forwardEmailApiKey = env.FORWARDEMAIL_API_KEY
const authHeader = 'Basic ' + btoa(`${forwardEmailApiKey}:`)
const forwardEmailApiKey = env.FORWARDEMAIL_API_KEY;
const authHeader = "Basic " + btoa(`${forwardEmailApiKey}:`);
const emailData = new URLSearchParams({
from: `${name} <hi@admin.coryd.dev>`,
to: 'hi@coryd.dev',
to: "hi@coryd.dev",
subject: `${message}`,
text: `Name: ${name}\nEmail: ${email}\nMessage: ${message}`,
replyTo: email
}).toString()
const response = await fetch('https://api.forwardemail.net/v1/emails', {
method: 'POST',
replyTo: email,
}).toString();
const response = await fetch("https://api.forwardemail.net/v1/emails", {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': authHeader
"Content-Type": "application/x-www-form-urlencoded",
Authorization: authHeader,
},
body: emailData
})
body: emailData,
});
if (!response.ok) {
const errorText = await response.text()
console.error('Email API response error:', response.status, errorText)
throw new Error(`Failed to send email: ${errorText}`)
const errorText = await response.text();
console.error(
"Email API response error:",
response.status,
errorText
);
throw new Error(`Failed to send email: ${errorText}`);
}
return Response.redirect('https://coryd.dev/contact/success', 301)
return Response.redirect("https://coryd.dev/contact/success", 301);
} catch (error) {
console.error('Error:', error.message)
return Response.redirect('https://coryd.dev/broken', 301)
console.error("Error:", error.message);
return Response.redirect("https://coryd.dev/broken", 301);
}
} else {
return Response.redirect('https://coryd.dev/not-allowed', 301)
return Response.redirect("https://coryd.dev/not-allowed", 301);
}
}
}
},
};

View file

@ -1,77 +1,79 @@
import { createClient } from '@supabase/supabase-js'
import { fetchDataByUrl, fetchGlobals } from './utils/fetchers.js'
import { createClient } from "@supabase/supabase-js";
import { fetchDataByUrl, fetchGlobals } from "./utils/fetchers.js";
import {
generateArtistHTML,
generateBookHTML,
generateGenreHTML,
generateMetadata,
generateWatchingHTML
} from './utils/generators.js'
import { updateDynamicContent } from './utils/updaters.js'
generateWatchingHTML,
} from "./utils/generators.js";
import { updateDynamicContent } from "./utils/updaters.js";
const BASE_URL = 'https://coryd.dev'
const NOT_FOUND_URL = `${BASE_URL}/404`
const BASE_URL = "https://coryd.dev";
const NOT_FOUND_URL = `${BASE_URL}/404`;
export default {
async fetch(request, env) {
const url = new URL(request.url)
const path = url.pathname.replace(/\/$/, '')
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)
let data, type
const url = new URL(request.url);
const path = url.pathname.replace(/\/$/, "");
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
let data, type;
if (path === '/books' || path === '/books/') return fetch(`${BASE_URL}/books/`)
if (path.startsWith('/books/years/')) return fetch(`${BASE_URL}${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)
type = 'movie'
} else if (path.startsWith('/watching/shows/')) {
data = await fetchDataByUrl(supabase, 'optimized_shows', path)
type = 'show'
} else if (path.startsWith('/music/artists/')) {
data = await fetchDataByUrl(supabase, 'optimized_artists', path)
type = 'artist'
} else if (path.startsWith('/music/genres/')) {
data = await fetchDataByUrl(supabase, 'optimized_genres', path)
type = 'genre'
} else if (path.startsWith('/books/')) {
data = await fetchDataByUrl(supabase, 'optimized_books', path)
type = 'book'
if (path.startsWith("/watching/movies/")) {
data = await fetchDataByUrl(supabase, "optimized_movies", path);
type = "movie";
} else if (path.startsWith("/watching/shows/")) {
data = await fetchDataByUrl(supabase, "optimized_shows", path);
type = "show";
} else if (path.startsWith("/music/artists/")) {
data = await fetchDataByUrl(supabase, "optimized_artists", path);
type = "artist";
} else if (path.startsWith("/music/genres/")) {
data = await fetchDataByUrl(supabase, "optimized_genres", path);
type = "genre";
} else if (path.startsWith("/books/")) {
data = await fetchDataByUrl(supabase, "optimized_books", path);
type = "book";
} else {
return Response.redirect(NOT_FOUND_URL, 302)
return Response.redirect(NOT_FOUND_URL, 302);
}
if (!data) return Response.redirect(NOT_FOUND_URL, 302)
if (!data) return Response.redirect(NOT_FOUND_URL, 302);
const globals = await fetchGlobals(supabase)
let mediaHtml
const globals = await fetchGlobals(supabase);
let mediaHtml;
switch (type) {
case 'artist':
mediaHtml = generateArtistHTML(data, globals)
break
case 'genre':
mediaHtml = generateGenreHTML(data, globals)
break
case 'book':
mediaHtml = generateBookHTML(data, globals)
break
case "artist":
mediaHtml = generateArtistHTML(data, globals);
break;
case "genre":
mediaHtml = generateGenreHTML(data, globals);
break;
case "book":
mediaHtml = generateBookHTML(data, globals);
break;
default:
mediaHtml = generateWatchingHTML(data, globals, type)
break
mediaHtml = generateWatchingHTML(data, globals, type);
break;
}
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 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({
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
})
"Content-Type": "text/html",
"Cache-Control":
"public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
});
return new Response(html, { headers })
}
}
return new Response(html, { headers });
},
};

View file

@ -1,10 +1,14 @@
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
const getCountryName = (countryCode) => regionNames.of(countryCode.trim()) || countryCode.trim()
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(', ')
}
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(", ");
};

View file

@ -1,19 +1,26 @@
export const fetchDataByUrl = async (supabase, table, url) => {
const { data, error } = await supabase.from(table).select('*').eq('url', url).single()
const { data, error } = await supabase
.from(table)
.select("*")
.eq("url", url)
.single();
if (error) {
console.error(`Error fetching from ${table}:`, error)
return null
console.error(`Error fetching from ${table}:`, error);
return null;
}
return data
}
return data;
};
export const fetchGlobals = async (supabase) => {
const { data, error } = await supabase.from('optimized_globals').select('*').single()
const { data, error } = await supabase
.from("optimized_globals")
.select("*")
.single();
if (error) {
console.error('Error fetching globals:', error)
return {}
console.error("Error fetching globals:", error);
return {};
}
return data
}
return data;
};

View file

@ -1,4 +1,9 @@
import markdownIt from 'markdown-it'
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 })
export const formatDate = (date) =>
new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
export const md = markdownIt({ html: true, linkify: true });

View file

@ -1,111 +1,161 @@
import truncateHtml from 'truncate-html'
import { convert } from 'html-to-text'
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'
import { parseCountryField } from "./countries.js";
import { formatDate, md } from "./formatters.js";
import { ICON_MAP } from "./icons.js";
const warningBanner = `<div class="banner warning"><p>${ICON_MAP['alertTriangle']}There are probably spoilers after this banner — this is a warning about them.</p></div>`
const warningBanner = `<div class="banner warning"><p>${ICON_MAP["alertTriangle"]}There are probably spoilers after this banner — this is a warning about them.</p></div>`;
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)' }
]
{
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'))
.filter(({ key }) => !(isGenre && key === "artists"))
.map(({ key, category, icon, title }) => {
const items = data[key]
if (!items || items.length === 0) return ''
const items = data[key];
if (!items || items.length === 0) return "";
return `
<div class="associated-media">
<p class="${category}">${ICON_MAP[icon]} ${title}</p>
<ul>
${items
.map(item => {
const name = item.name || item.title
const url = item.url
const year = item.year ? ` (${item.year})` : ''
const author = item.author ? ` by ${item.author}` : ''
.map((item) => {
const name = item.name || item.title;
const url = item.url;
const year = item.year ? ` (${item.year})` : "";
const author = item.author ? ` by ${item.author}` : "";
const totalPlays = item.total_plays
? ` <strong class="highlight-text">${item.total_plays}</strong> ${item.total_plays === 1 ? 'play' : 'plays'}`
: ''
let listItemContent = name
? ` <strong class="highlight-text">${
item.total_plays
}</strong> ${item.total_plays === 1 ? "play" : "plays"}`
: "";
let listItemContent = name;
if (key === 'artists' || key === 'related_artists') {
return `<li><a href="${url}">${name}</a>${totalPlays}</li>`
} else if (key === 'movies' || key === 'shows' || key === 'related_shows') {
listItemContent = `${name}${year}`
} else if (key === 'related_books') {
listItemContent = `${name}${author}`
if (key === "artists" || key === "related_artists") {
return `<li><a href="${url}">${name}</a>${totalPlays}</li>`;
} else if (
key === "movies" ||
key === "shows" ||
key === "related_shows"
) {
listItemContent = `${name}${year}`;
} else if (key === "related_books") {
listItemContent = `${name}${author}`;
}
return `<li><a href="${url}">${listItemContent}</a></li>`
return `<li><a href="${url}">${listItemContent}</a></li>`;
})
.join('')}
.join("")}
</ul>
</div>`
</div>`;
})
.join('')
}
.join("");
};
const generateMediaLinks = (data, type, count = 10) => {
if (!data || !type) return ''
if (!data || !type) return "";
const dataSlice = data.slice(0, count)
if (dataSlice.length === 0) return null
const dataSlice = data.slice(0, count);
if (dataSlice.length === 0) return null;
const buildLink = (item) => {
switch (type) {
case 'genre':
return `<a href="${item['genre_url']}">${item['genre_name']}</a>`
case 'artist':
return `<a href="${item['url']}">${item['name']}</a>`
case 'book':
return `<a href="${item['url']}">${item['title']}</a>`
case "genre":
return `<a href="${item["genre_url"]}">${item["genre_name"]}</a>`;
case "artist":
return `<a href="${item["url"]}">${item["name"]}</a>`;
case "book":
return `<a href="${item["url"]}">${item["title"]}</a>`;
default:
return ''
return "";
}
}
};
if (dataSlice.length === 1) return buildLink(dataSlice[0])
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]
const links = dataSlice.map(buildLink);
const allButLast = links.slice(0, -1).join(", ");
const last = links[links.length - 1];
return `${allButLast} and ${last}`
}
return `${allButLast} and ${last}`;
};
export const generateArtistHTML = (artist, globals) => {
const playLabel = artist?.['total_plays'] === 1 ? 'play' : 'plays'
const concertsList = artist['concerts']?.length
const playLabel = artist?.["total_plays"] === 1 ? "play" : "plays";
const concertsList = artist["concerts"]?.length
? `<hr />
<p id="concerts" class="concerts">
${ICON_MAP['deviceSpeaker']}
${ICON_MAP["deviceSpeaker"]}
I've seen this artist live!
</p>
<ul>${artist['concerts'].map(generateConcertModal).join('')}</ul>`
: ''
const albumsTable = artist['albums']?.length
<ul>${artist["concerts"].map(generateConcertModal).join("")}</ul>`
: "";
const albumsTable = artist["albums"]?.length
? `<table>
<tr><th>Album</th><th>Plays</th><th>Year</th></tr>
${artist['albums'].map(album => `
${artist["albums"]
.map(
(album) => `
<tr>
<td>${album['name']}</td>
<td>${album['total_plays'] || 0}</td>
<td>${album['release_year']}</td>
</tr>`).join('')}
<td>${album["name"]}</td>
<td>${album["total_plays"] || 0}</td>
<td>${album["release_year"]}</td>
</tr>`
)
.join("")}
</table>
<p><em>These are the albums by this artist that are in my collection, not necessarily a comprehensive discography.</em></p>
`
: ''
: "";
return `
<a class="icon-link" href="/music">${ICON_MAP.arrowLeft} Back to music</a>
@ -113,64 +163,100 @@ export const generateArtistHTML = (artist, globals) => {
<div class="artist-display">
<img
srcset="
${globals['cdn_url']}${artist['image']}?class=w200&type=webp 200w,
${globals['cdn_url']}${artist['image']}?class=w600&type=webp 400w,
${globals['cdn_url']}${artist['image']}?class=w800&type=webp 800w
${globals["cdn_url"]}${artist["image"]}?class=w200&type=webp 200w,
${globals["cdn_url"]}${artist["image"]}?class=w600&type=webp 400w,
${globals["cdn_url"]}${artist["image"]}?class=w800&type=webp 800w
"
sizes="(max-width: 450px) 200px,
(max-width: 850px) 400px,
800px"
src="${globals['cdn_url']}${artist['image']}?class=w200&type=webp"
alt="${artist['name']} / ${artist['country']}"
src="${globals["cdn_url"]}${artist["image"]}?class=w200&type=webp"
alt="${artist["name"]} / ${artist["country"]}"
loading="eager"
decoding="async"
width="200"
height="200"
/>
<div class="artist-meta">
<p class="title"><strong>${artist['name']}</strong></p>
<p class="sub-meta country">${ICON_MAP['mapPin']} ${parseCountryField(artist['country'])}</p>
${artist['favorite'] ? `<p class="sub-meta favorite">${ICON_MAP['heart']} This is one of my favorite artists!</p>` : ''}
${artist['tattoo'] ? `<p class="sub-meta tattoo">${ICON_MAP['needle']} I have a tattoo inspired by this artist!</p>` : ''}
${artist['total_plays'] ? `<p class="sub-meta"><strong class="highlight-text">${artist['total_plays']} ${playLabel}</strong></p>` : ''}
<p class="sub-meta">${artist['genre'] ? `<a href="${artist['genre']['url']}">${artist['genre']['name']}</a>` : ''}</p>
<p class="title"><strong>${artist["name"]}</strong></p>
<p class="sub-meta country">${ICON_MAP["mapPin"]} ${parseCountryField(
artist["country"]
)}</p>
${
artist["favorite"]
? `<p class="sub-meta favorite">${ICON_MAP["heart"]} This is one of my favorite artists!</p>`
: ""
}
${
artist["tattoo"]
? `<p class="sub-meta tattoo">${ICON_MAP["needle"]} I have a tattoo inspired by this artist!</p>`
: ""
}
${
artist["total_plays"]
? `<p class="sub-meta"><strong class="highlight-text">${artist["total_plays"]} ${playLabel}</strong></p>`
: ""
}
<p class="sub-meta">${
artist["genre"]
? `<a href="${artist["genre"]["url"]}">${artist["genre"]["name"]}</a>`
: ""
}</p>
</div>
</div>
${generateAssociatedMediaHTML(artist)}
${artist['description'] ? `
${
artist["description"]
? `
<h2>Overview</h2>
<div data-toggle-content class="text-toggle-hidden">${md.render(artist['description'])}</div>
<button data-toggle-button>Show more</button>` : ''
<div data-toggle-content class="text-toggle-hidden">${md.render(
artist["description"]
)}</div>
<button data-toggle-button>Show more</button>`
: ""
}
${concertsList}
${albumsTable}
</article>
`
}
`;
};
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 <strong class="highlight-text">${formatDate(book['date_finished'])}</strong>`
: percentage
? `<div class="progress-bar-wrapper" title="${percentage}">
const alt = `${book["title"]}${
book["author"] ? ` by ${book["author"]}` : ""
}`;
const percentage = book["progress"] ? `${book["progress"]}%` : "";
const status =
book["status"] === "finished"
? `Finished on <strong class="highlight-text">${formatDate(
book["date_finished"]
)}</strong>`
: percentage
? `<div class="progress-bar-wrapper" title="${percentage}">
<div style="width:${percentage}" class="progress-bar"></div>
</div>`
: ''
: "";
return `
<a class="icon-link" href="/books">${ICON_MAP['arrowLeft']} Back to books</a>
<a class="icon-link" href="/books">${
ICON_MAP["arrowLeft"]
} Back to books</a>
<article class="book-focus">
<div class="book-display">
<img
srcset="
${globals['cdn_url']}${book['image']}?class=verticalsm&type=webp 200w,
${globals['cdn_url']}${book['image']}?class=verticalmd&type=webp 400w,
${globals['cdn_url']}${book['image']}?class=verticalbase&type=webp 800w
${globals["cdn_url"]}${
book["image"]
}?class=verticalsm&type=webp 200w,
${globals["cdn_url"]}${
book["image"]
}?class=verticalmd&type=webp 400w,
${globals["cdn_url"]}${
book["image"]
}?class=verticalbase&type=webp 800w
"
sizes="(max-width: 450px) 203px, (max-width: 850px) 406px, 812px"
src="${globals['cdn_url']}${book['image']}?class=verticalsm&type=webp"
src="${globals["cdn_url"]}${book["image"]}?class=verticalsm&type=webp"
alt="${alt}"
loading="lazy"
decoding="async"
@ -178,118 +264,161 @@ export const generateBookHTML = (book, globals) => {
height="307"
/>
<div class="book-meta">
<p class="title"><strong>${book['title']}</strong></p>
${book['rating'] ? `<p>${book['rating']}</p>` : ''}
${book['author'] ? `<p class="sub-meta">By ${book['author']}</p>` : ''}
${book['favorite'] ? `<p class="sub-meta favorite">${ICON_MAP['heart']} This is one of my favorite books!</p>` : ''}
${book['tattoo'] ? `<p class="sub-meta tattoo">${ICON_MAP['needle']} I have a tattoo inspired by this book!</p>` : ''}
${status ? `<p class="sub-meta">${status}</p>` : ''}
<p class="title"><strong>${book["title"]}</strong></p>
${book["rating"] ? `<p>${book["rating"]}</p>` : ""}
${
book["author"] ? `<p class="sub-meta">By ${book["author"]}</p>` : ""
}
${
book["favorite"]
? `<p class="sub-meta favorite">${ICON_MAP["heart"]} This is one of my favorite books!</p>`
: ""
}
${
book["tattoo"]
? `<p class="sub-meta tattoo">${ICON_MAP["needle"]} I have a tattoo inspired by this book!</p>`
: ""
}
${status ? `<p class="sub-meta">${status}</p>` : ""}
</div>
</div>
${book['review'] ? `${warningBanner}<h2>My thoughts</h2><p>${book['review']}</p>` : ''}
${
book["review"]
? `${warningBanner}<h2>My thoughts</h2><p>${book["review"]}</p>`
: ""
}
${generateAssociatedMediaHTML(book)}
<h2>Overview</h2>
<p>${md.render(book['description'])}</p>
<p>${md.render(book["description"])}</p>
</article>
`
}
`;
};
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)
const artistCount = genre["artists"]?.length || 0;
const connectingWords = artistCount > 1 ? "artists are" : "artist is";
const mediaLinks = generateMediaLinks(genre["artists"], "artist", 5);
return `
<a class="icon-link" href="/music">${ICON_MAP['arrowLeft']} Back to music</a>
<h2>${genre['name']}</h2>
<a class="icon-link" href="/music">${
ICON_MAP["arrowLeft"]
} Back to music</a>
<h2>${genre["name"]}</h2>
<article class="genre-focus">
${mediaLinks ? `
<p>My top <strong class="highlight-text">${genre['name']}</strong> ${connectingWords} ${mediaLinks}. I've listened to <strong class="highlight-text">${genre['total_plays']}</strong> tracks from this genre.</p>
<hr />` : ''}
${
mediaLinks
? `
<p>My top <strong class="highlight-text">${genre["name"]}</strong> ${connectingWords} ${mediaLinks}. I've listened to <strong class="highlight-text">${genre["total_plays"]}</strong> tracks from this genre.</p>
<hr />`
: ""
}
${generateAssociatedMediaHTML(genre, true)}
${genre['description'] ? `
${
genre["description"]
? `
<h3>Overview</h3>
<div data-toggle-content class="text-toggle-hidden">
${md.render(genre['description'])}
<p><a href="${genre['wiki_link']}">Continue reading at Wikipedia.</a></p>
${md.render(genre["description"])}
<p><a href="${
genre["wiki_link"]
}">Continue reading at Wikipedia.</a></p>
<p><em>Wikipedia content provided under the terms of the <a href="https://creativecommons.org/licenses/by-sa/3.0/">Creative Commons BY-SA license</a>.</em></p>
</div>
<button data-toggle-button>Show more</button>` : ''}
<button data-toggle-button>Show more</button>`
: ""
}
</article>
`
}
`;
};
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`
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, {
description = convert(
truncateHtml(md.render(description), 100, {
byWords: true,
ellipsis: '...'
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()
{ 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
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']}`
title = `${data["title"] || globals["site_name"]}`;
}
return {
title,
description,
'og:title': title,
'og:description': description,
'og:image': ogImage,
'og:url': canonicalUrl,
'canonical': canonicalUrl
}
}
"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'])
const isShow = type === "show";
const label = isShow ? "show" : "movie";
const lastWatched =
media["last_watched"] || (isShow && media["episode"]?.["last_watched_at"]);
return `
<a class="icon-link" href="/watching">${ICON_MAP.arrowLeft} Back to watching</a>
<a class="icon-link" href="/watching">${
ICON_MAP.arrowLeft
} Back to watching</a>
<article class="watching focus">
<img
srcset="
${globals['cdn_url']}${media['backdrop']}?class=bannersm&type=webp 256w,
${globals['cdn_url']}${media['backdrop']}?class=bannermd&type=webp 512w,
${globals['cdn_url']}${media['backdrop']}?class=bannerbase&type=webp 1024w
${globals["cdn_url"]}${
media["backdrop"]
}?class=bannersm&type=webp 256w,
${globals["cdn_url"]}${
media["backdrop"]
}?class=bannermd&type=webp 512w,
${globals["cdn_url"]}${
media["backdrop"]
}?class=bannerbase&type=webp 1024w
"
sizes="(max-width: 450px) 256px,
(max-width: 850px) 512px,
1024px"
src="${globals['cdn_url']}${media['backdrop']}?class=bannersm&type=webp"
alt="${media['title']} / ${media['year']}"
src="${globals["cdn_url"]}${media["backdrop"]}?class=bannersm&type=webp"
alt="${media["title"]} / ${media["year"]}"
class="image-banner"
loading="eager"
decoding="async"
@ -297,44 +426,82 @@ export const generateWatchingHTML = (media, globals, type) => {
height="180"
/>
<div class="meta">
<p class="title"><strong>${media['title']}</strong> (${media['year']})</p>
${media['favorite'] ? `<p class="sub-meta favorite">${ICON_MAP['heart']} This is one of my favorite ${label}s!</p>` : ''}
${media['tattoo'] ? `<p class="sub-meta tattoo">${ICON_MAP['needle']} I have a tattoo inspired by this ${label}!</p>` : ''}
${media['collected'] ? `<p class="sub-meta collected">${ICON_MAP['circleCheck']} This ${label} is in my collection!</p>` : ''}
${lastWatched ? `<p class="sub-meta">Last watched on <strong class="highlight-text">${formatDate(lastWatched)}</strong></p>` : ''}
<p class="title"><strong>${media["title"]}</strong> (${
media["year"]
})</p>
${
media["favorite"]
? `<p class="sub-meta favorite">${ICON_MAP["heart"]} This is one of my favorite ${label}s!</p>`
: ""
}
${
media["tattoo"]
? `<p class="sub-meta tattoo">${ICON_MAP["needle"]} I have a tattoo inspired by this ${label}!</p>`
: ""
}
${
media["collected"]
? `<p class="sub-meta collected">${ICON_MAP["circleCheck"]} This ${label} is in my collection!</p>`
: ""
}
${
lastWatched
? `<p class="sub-meta">Last watched on <strong class="highlight-text">${formatDate(
lastWatched
)}</strong></p>`
: ""
}
</div>
${media['review'] ? `${warningBanner}<h2>My thoughts</h2><p>${md.render(media['review'])}</p>` : ''}
${
media["review"]
? `${warningBanner}<h2>My thoughts</h2><p>${md.render(
media["review"]
)}</p>`
: ""
}
${generateAssociatedMediaHTML(media)}
${media['description'] ? `<h2>Overview</h2><p>${md.render(media['description'])}</p>` : ''}
${
media["description"]
? `<h2>Overview</h2><p>${md.render(media["description"])}</p>`
: ""
}
</article>
`
}
`;
};
export const generateConcertModal = (concert) => {
const venue = concert['venue_name']
? concert['venue_latitude'] && concert['venue_longitude']
? `<a href="https://www.openstreetmap.org/?mlat=${concert['venue_latitude']}&mlon=${concert['venue_longitude']}#map=18/${concert['venue_latitude']}/${concert['venue_longitude']}">${concert['venue_name_short']}</a>`
: concert['venue_name_short']
: ''
const venue = concert["venue_name"]
? concert["venue_latitude"] && concert["venue_longitude"]
? `<a href="https://www.openstreetmap.org/?mlat=${concert["venue_latitude"]}&mlon=${concert["venue_longitude"]}#map=18/${concert["venue_latitude"]}/${concert["venue_longitude"]}">${concert["venue_name_short"]}</a>`
: concert["venue_name_short"]
: "";
const notesModal = concert['notes']
? `<input class="modal-input" id="${concert['id']}" type="checkbox" tabindex="0" />
<label class="modal-toggle" for="${concert['id']}">${ICON_MAP['infoCircle']}</label>
const notesModal = concert["notes"]
? `<input class="modal-input" id="${
concert["id"]
}" type="checkbox" tabindex="0" />
<label class="modal-toggle" for="${concert["id"]}">${
ICON_MAP["infoCircle"]
}</label>
<div class="modal-wrapper">
<div class="modal-body">
<label class="modal-close" for="${concert['id']}">${ICON_MAP['circleX']}</label>
<label class="modal-close" for="${concert["id"]}">${
ICON_MAP["circleX"]
}</label>
<div>
<h3>Notes</h3>
${md.render(concert['notes'])}
${md.render(concert["notes"])}
</div>
</div>
</div>`
: ''
: "";
return `
<li>
<strong class="highlight-text">${formatDate(concert['date'])}</strong> at ${venue}
<strong class="highlight-text">${formatDate(
concert["date"]
)}</strong> at ${venue}
${notesModal}
</li>
`
}
`;
};

View file

@ -15,4 +15,4 @@ export const ICON_MAP = {
mapPin: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-map-pin"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /><path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z" /></svg>`,
needle: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-needle"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21c-.667 -.667 3.262 -6.236 11.785 -16.709a3.5 3.5 0 1 1 5.078 4.791c-10.575 8.612 -16.196 12.585 -16.863 11.918z"/><path d="M17.5 6.5l-1 1"/></svg>`,
movie: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-movie"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6h16M4 12h16M4 18h16M10 4v2M14 4v2M10 12v2M14 12v2M10 18v2M14 18v2"/></svg>`,
}
};

View file

@ -1,29 +1,49 @@
import { parseHTML } from 'linkedom'
import { parseHTML } from "linkedom";
export const updateDynamicContent = (html, metadata, mediaHtml) => {
const { document } = parseHTML(html)
const { document } = parseHTML(html);
const titleTag = document.querySelector('title[data-dynamic="title"]')
if (titleTag) titleTag['textContent'] = metadata['title']
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'] },
]
{
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 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 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
const pageElement = document.querySelector('[data-dynamic="page"]');
if (pageElement) pageElement.innerHTML = mediaHtml;
return document.toString()
}
return document.toString();
};

View file

@ -1,138 +1,161 @@
import { XMLParser } from 'fast-xml-parser'
import { convert } from 'html-to-text'
import { createClient } from '@supabase/supabase-js'
import { XMLParser } from "fast-xml-parser";
import { convert } from "html-to-text";
import { createClient } from "@supabase/supabase-js";
const BASE_URL = 'https://coryd.dev'
const BASE_URL = "https://coryd.dev";
export default {
async scheduled(event, env, ctx) {
await handleMastodonPost(env)
await handleMastodonPost(env);
},
async fetch(request, env, ctx) {
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 })
if (request.headers.get('x-webhook-token') !== env.WEBHOOK_SECRET) return new Response('Unauthorized', { status: 401 })
if (request.method !== "POST")
return new Response("Method Not Allowed", { status: 405 });
if (request.headers.get("x-webhook-token") !== env.WEBHOOK_SECRET)
return new Response("Unauthorized", { status: 401 });
await handleMastodonPost(env)
await handleMastodonPost(env);
return new Response('Worker triggered by successful build.', { status: 200 })
}
}
return new Response("Worker triggered by successful build.", {
status: 200,
});
},
};
async function handleMastodonPost(env) {
const mastodonApiUrl = 'https://follow.coryd.dev/api/v1/statuses'
const accessToken = env.MASTODON_ACCESS_TOKEN
const rssFeedUrl = 'https://coryd.dev/feeds/syndication'
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)
const mastodonApiUrl = "https://follow.coryd.dev/api/v1/statuses";
const accessToken = env.MASTODON_ACCESS_TOKEN;
const rssFeedUrl = "https://coryd.dev/feeds/syndication";
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
try {
const latestItems = await fetchRSSFeed(rssFeedUrl)
const latestItems = await fetchRSSFeed(rssFeedUrl);
for (let i = latestItems.length - 1; i >= 0; i--) {
const item = latestItems[i]
const existingPost = await env.RSS_TO_MASTODON_NAMESPACE.get(item.link)
const item = latestItems[i];
const existingPost = await env.RSS_TO_MASTODON_NAMESPACE.get(item.link);
if (existingPost) continue
if (existingPost) continue;
const title = item.title
const link = item.link
const maxLength = 500
const title = item.title;
const link = item.link;
const maxLength = 500;
const plainTextDescription = convert(item.description, {
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' }
]
})
{ selector: "a", options: { ignoreHref: true } },
{ selector: "h1", options: { uppercase: false } },
{ selector: "h2", options: { uppercase: false } },
{ selector: "h3", options: { uppercase: false } },
{ selector: "*", format: "block" },
],
});
const cleanedDescription = plainTextDescription.replace(/\s+/g, ' ').trim()
const content = truncateContent(title, cleanedDescription, link, maxLength)
const cleanedDescription = plainTextDescription
.replace(/\s+/g, " ")
.trim();
const content = truncateContent(
title,
cleanedDescription,
link,
maxLength
);
const mastodonPostUrl = await postToMastodon(mastodonApiUrl, accessToken, content)
const mastodonPostUrl = await postToMastodon(
mastodonApiUrl,
accessToken,
content
);
const timestamp = new Date().toISOString()
const timestamp = new Date().toISOString();
await env.RSS_TO_MASTODON_NAMESPACE.put(link, timestamp)
await env.RSS_TO_MASTODON_NAMESPACE.put(link, timestamp);
if (link.includes('coryd.dev/posts')) {
const slug = link.replace(BASE_URL, '')
await addMastodonUrlToPost(supabase, slug, mastodonPostUrl)
if (link.includes("coryd.dev/posts")) {
const slug = link.replace(BASE_URL, "");
await addMastodonUrlToPost(supabase, slug, mastodonPostUrl);
}
console.log(`Posted stored URL: ${link}`)
console.log(`Posted stored URL: ${link}`);
}
console.log('RSS processed successfully')
console.log("RSS processed successfully");
} catch (error) {
console.error('Error in scheduled event:', error)
console.error("Error in scheduled event:", error);
}
}
async function addMastodonUrlToPost(supabase, slug, mastodonPostUrl) {
const { data, error } = await supabase
.from('posts')
.from("posts")
.update({ mastodon_url: mastodonPostUrl })
.eq('slug', slug)
.eq("slug", slug);
if (error) {
console.error('Error updating post:', error)
console.error("Error updating post:", error);
} else {
console.log(`Updated post with Mastodon URL: ${mastodonPostUrl}`)
console.log(`Updated post with Mastodon URL: ${mastodonPostUrl}`);
}
}
function truncateContent(title, description, link, maxLength) {
const baseLength = `${title}\n\n${link}`.length
const availableSpace = maxLength - baseLength - 4
let truncatedDescription = description
const baseLength = `${title}\n\n${link}`.length;
const availableSpace = maxLength - baseLength - 4;
let truncatedDescription = description;
if (description.length > availableSpace) truncatedDescription = description.substring(0, availableSpace).split(' ').slice(0, -1).join(' ') + '...'
if (description.length > availableSpace)
truncatedDescription =
description
.substring(0, availableSpace)
.split(" ")
.slice(0, -1)
.join(" ") + "...";
return `${title}\n\n${truncatedDescription}\n\n${link}`
return `${title}\n\n${truncatedDescription}\n\n${link}`;
}
async function fetchRSSFeed(rssFeedUrl) {
const response = await fetch(rssFeedUrl)
const rssText = await response.text()
const parser = new XMLParser()
const rssData = parser.parse(rssText)
const items = rssData.rss.channel.item
const response = await fetch(rssFeedUrl);
const rssText = await response.text();
const parser = new XMLParser();
const rssData = parser.parse(rssText);
const items = rssData.rss.channel.item;
let latestItems = []
let latestItems = [];
items.forEach(item => {
const title = item.title
const link = item.link
const description = item.description
latestItems.push({ title, link, description })
})
items.forEach((item) => {
const title = item.title;
const link = item.link;
const description = item.description;
latestItems.push({ title, link, description });
});
return latestItems
return latestItems;
}
async function postToMastodon(apiUrl, accessToken, content) {
const response = await fetch(apiUrl, {
method: 'POST',
method: "POST",
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status: content }),
})
});
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Error posting to Mastodon: ${response.statusText} - ${errorText}`)
const errorText = await response.text();
throw new Error(
`Error posting to Mastodon: ${response.statusText} - ${errorText}`
);
}
const responseData = await response.json()
const responseData = await response.json();
console.log('Posted to Mastodon successfully.')
console.log("Posted to Mastodon successfully.");
return responseData.url
return responseData.url;
}

View file

@ -1,33 +1,45 @@
import { createClient } from '@supabase/supabase-js'
import { createClient } from "@supabase/supabase-js";
export default {
async fetch(request, env) {
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
const { data, error } = await supabase
.from('optimized_latest_listen')
.select('*')
.single()
.from("optimized_latest_listen")
.select("*")
.single();
const headers = {
"Content-Type": "application/json",
"Cache-Control": "public, s-maxage=360, stale-while-revalidate=1080",
}
};
if (error) {
console.error('Error fetching data:', error)
return new Response(JSON.stringify({ error: "Failed to fetch the latest track" }), { headers })
console.error("Error fetching data:", error);
return new Response(
JSON.stringify({ error: "Failed to fetch the latest track" }),
{ headers }
);
}
if (!data) return new Response(JSON.stringify({ message: "No recent tracks found" }), { headers })
if (!data)
return new Response(
JSON.stringify({ message: "No recent tracks found" }),
{ headers }
);
const genreEmoji = data.genre_emoji
const emoji = data.artist_emoji || genreEmoji
const genreEmoji = data.genre_emoji;
const emoji = data.artist_emoji || genreEmoji;
return new Response(JSON.stringify({
content: `${emoji || '🎧'} ${data.track_name} by <a href="https://coryd.dev${data.url}">${data.artist_name}</a>`,
}), { headers })
}
}
return new Response(
JSON.stringify({
content: `${emoji || "🎧"} ${
data.track_name
} by <a href="https://coryd.dev${data.url}">${data.artist_name}</a>`,
}),
{ headers }
);
},
};

View file

@ -1,17 +1,20 @@
export default {
async scheduled(event, env, ctx) {
const deployHookUrl = env.DEPLOY_HOOK_URL
const deployHookUrl = env.DEPLOY_HOOK_URL;
const response = await fetch(deployHookUrl, {
method: 'POST',
})
method: "POST",
});
if (!response.ok) {
const errorText = await response.text()
console.error(`Error triggering deploy: ${response.statusText}`, errorText)
return
const errorText = await response.text();
console.error(
`Error triggering deploy: ${response.statusText}`,
errorText
);
return;
}
console.log('Deploy triggered successfully')
}
}
console.log("Deploy triggered successfully");
},
};

View file

@ -1,240 +1,250 @@
import { createClient } from '@supabase/supabase-js'
import { DateTime } from 'luxon'
import slugify from 'slugify'
import { createClient } from "@supabase/supabase-js";
import { DateTime } from "luxon";
import slugify from "slugify";
const sanitizeMediaString = (str) => {
const sanitizedString = str
.normalize('NFD')
.replace(/[\u0300-\u036f\u2010\-\.\?\(\)\[\]\{\}]/g, '')
.replace(/\.{3}/g, '')
.normalize("NFD")
.replace(/[\u0300-\u036f\u2010\-\.\?\(\)\[\]\{\}]/g, "")
.replace(/\.{3}/g, "");
return slugify(sanitizedString, {
replacement: '-',
replacement: "-",
remove: /[#,&,+()$~%.'":*?<>{}]/g,
lower: true,
})
}
});
};
const sendEmail = async (subject, text, authHeader, maxRetries = 3) => {
const emailData = new URLSearchParams({
from: 'coryd.dev <hi@admin.coryd.dev>',
to: 'hi@coryd.dev',
from: "coryd.dev <hi@admin.coryd.dev>",
to: "hi@coryd.dev",
subject: subject,
text: text,
}).toString()
}).toString();
let attempt = 0
let success = false
let attempt = 0;
let success = false;
while (attempt < maxRetries && !success) {
attempt++
attempt++;
try {
const response = await fetch('https://api.forwardemail.net/v1/emails', {
method: 'POST',
const response = await fetch("https://api.forwardemail.net/v1/emails", {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': authHeader,
"Content-Type": "application/x-www-form-urlencoded",
Authorization: authHeader,
},
body: emailData,
})
});
if (!response.ok) {
const responseText = await response.text()
console.error(`Attempt ${attempt}: Email API response error:`, response.status, responseText)
throw new Error(`Failed to send email: ${responseText}`)
const responseText = await response.text();
console.error(
`Attempt ${attempt}: Email API response error:`,
response.status,
responseText
);
throw new Error(`Failed to send email: ${responseText}`);
}
console.log('Email sent successfully on attempt', attempt)
success = true
console.log("Email sent successfully on attempt", attempt);
success = true;
} catch (error) {
console.error(`Attempt ${attempt}: Error sending email:`, error.message)
console.error(`Attempt ${attempt}: Error sending email:`, error.message);
if (attempt < maxRetries) {
console.log(`Retrying email send (attempt ${attempt + 1}/${maxRetries})...`)
console.log(
`Retrying email send (attempt ${attempt + 1}/${maxRetries})...`
);
} else {
console.error('All attempts to send email failed.')
console.error("All attempts to send email failed.");
}
}
}
return success
}
return success;
};
export default {
async fetch(request, env) {
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const FORWARDEMAIL_API_KEY = env.FORWARDEMAIL_API_KEY
const ACCOUNT_ID_PLEX = env.ACCOUNT_ID_PLEX
const supabase = createClient(supabaseUrl, supabaseKey)
const authHeader = 'Basic ' + btoa(`${FORWARDEMAIL_API_KEY}:`)
const url = new URL(request.url)
const params = url.searchParams
const id = params.get('id')
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY;
const FORWARDEMAIL_API_KEY = env.FORWARDEMAIL_API_KEY;
const ACCOUNT_ID_PLEX = env.ACCOUNT_ID_PLEX;
const supabase = createClient(supabaseUrl, supabaseKey);
const authHeader = "Basic " + btoa(`${FORWARDEMAIL_API_KEY}:`);
const url = new URL(request.url);
const params = url.searchParams;
const id = params.get("id");
if (!id) return new Response(JSON.stringify({ status: 'Bad request' }), {
headers: { 'Content-Type': 'application/json' },
})
if (!id)
return new Response(JSON.stringify({ status: "Bad request" }), {
headers: { "Content-Type": "application/json" },
});
if (id !== ACCOUNT_ID_PLEX) return new Response(JSON.stringify({ status: 'Forbidden' }), {
headers: { 'Content-Type': 'application/json' },
})
if (id !== ACCOUNT_ID_PLEX)
return new Response(JSON.stringify({ status: "Forbidden" }), {
headers: { "Content-Type": "application/json" },
});
const contentType = request.headers.get('Content-Type') || ''
if (!contentType.includes('multipart/form-data')) return new Response(
JSON.stringify({
status: 'Bad request',
message: 'Invalid Content-Type. Expected multipart/form-data.',
}),
{ headers: { 'Content-Type': 'application/json' } }
)
const contentType = request.headers.get("Content-Type") || "";
if (!contentType.includes("multipart/form-data"))
return new Response(
JSON.stringify({
status: "Bad request",
message: "Invalid Content-Type. Expected multipart/form-data.",
}),
{ headers: { "Content-Type": "application/json" } }
);
try {
const data = await request.formData()
const payload = JSON.parse(data.get('payload'))
const data = await request.formData();
const payload = JSON.parse(data.get("payload"));
if (payload?.event === 'media.scrobble') {
const artistName = payload['Metadata']['grandparentTitle']
const albumName = payload['Metadata']['parentTitle']
const trackName = payload['Metadata']['title']
const listenedAt = Math.floor(DateTime.now().toSeconds())
const artistKey = sanitizeMediaString(artistName)
const albumKey = `${artistKey}-${sanitizeMediaString(albumName)}`
if (payload?.event === "media.scrobble") {
const artistName = payload["Metadata"]["grandparentTitle"];
const albumName = payload["Metadata"]["parentTitle"];
const trackName = payload["Metadata"]["title"];
const listenedAt = Math.floor(DateTime.now().toSeconds());
const artistKey = sanitizeMediaString(artistName);
const albumKey = `${artistKey}-${sanitizeMediaString(albumName)}`;
let { data: artistData, error: artistError } = await supabase
.from('artists')
.select('*')
.ilike('name_string', artistName)
.single()
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single();
if (artistError && artistError.code === 'PGRST116') {
if (artistError && artistError.code === "PGRST116") {
const { error: insertArtistError } = await supabase
.from('artists')
.from("artists")
.insert([
{
mbid: null,
art: '4cef75db-831f-4f5d-9333-79eaa5bb55ee',
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
name: artistName,
slug: '/music',
slug: "/music",
tentative: true,
total_plays: 0,
},
])
]);
if (insertArtistError) {
console.error('Error inserting artist: ', insertArtistError.message)
console.error(
"Error inserting artist: ",
insertArtistError.message
);
return new Response(
JSON.stringify({
status: 'error',
status: "error",
message: insertArtistError.message,
}),
{ headers: { 'Content-Type': 'application/json' } }
)
{ headers: { "Content-Type": "application/json" } }
);
}
await sendEmail(
'New tentative artist record',
"New tentative artist record",
`A new tentative artist record was inserted:\n\nArtist: ${artistName}\nKey: ${artistKey}`,
authHeader
)
;({ data: artistData, error: artistError } = await supabase
.from('artists')
.select('*')
.ilike('name_string', artistName)
.single())
);
({ data: artistData, error: artistError } = await supabase
.from("artists")
.select("*")
.ilike("name_string", artistName)
.single());
}
if (artistError) {
console.error('Error fetching artist:', artistError.message)
console.error("Error fetching artist:", artistError.message);
return new Response(
JSON.stringify({ status: 'error', message: artistError.message }),
{ headers: { 'Content-Type': 'application/json' } }
)
JSON.stringify({ status: "error", message: artistError.message }),
{ headers: { "Content-Type": "application/json" } }
);
}
let { data: albumData, error: albumError } = await supabase
.from('albums')
.select('*')
.ilike('key', albumKey)
.single()
.from("albums")
.select("*")
.ilike("key", albumKey)
.single();
if (albumError && albumError.code === 'PGRST116') {
if (albumError && albumError.code === "PGRST116") {
const { error: insertAlbumError } = await supabase
.from('albums')
.from("albums")
.insert([
{
mbid: null,
art: '4cef75db-831f-4f5d-9333-79eaa5bb55ee',
art: "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
key: albumKey,
name: albumName,
tentative: true,
total_plays: 0,
artist: artistData.id,
},
])
]);
if (insertAlbumError) {
console.error('Error inserting album:', insertAlbumError.message)
console.error("Error inserting album:", insertAlbumError.message);
return new Response(
JSON.stringify({
status: 'error',
status: "error",
message: insertAlbumError.message,
}),
{ headers: { 'Content-Type': 'application/json' } }
)
{ headers: { "Content-Type": "application/json" } }
);
}
await sendEmail(
'New tentative album record',
"New tentative album record",
`A new tentative album record was inserted:\n\nAlbum: ${albumName}\nKey: ${albumKey}\nArtist: ${artistName}`,
authHeader
)
;({ data: albumData, error: albumError } = await supabase
.from('albums')
.select('*')
.ilike('key', albumKey)
.single())
);
({ data: albumData, error: albumError } = await supabase
.from("albums")
.select("*")
.ilike("key", albumKey)
.single());
}
if (albumError) {
console.error('Error fetching album:', albumError.message)
console.error("Error fetching album:", albumError.message);
return new Response(
JSON.stringify({ status: 'error', message: albumError.message }),
{ headers: { 'Content-Type': 'application/json' } }
)
JSON.stringify({ status: "error", message: albumError.message }),
{ headers: { "Content-Type": "application/json" } }
);
}
const { error: listenError } = await supabase.from('listens').insert([
const { error: listenError } = await supabase.from("listens").insert([
{
artist_name: artistData['name_string'] || artistName,
album_name: albumData['name'] || albumName,
artist_name: artistData["name_string"] || artistName,
album_name: albumData["name"] || albumName,
track_name: trackName,
listened_at: listenedAt,
album_key: albumKey,
},
])
]);
if (listenError) {
console.error('Error inserting listen:', listenError.message)
console.error("Error inserting listen:", listenError.message);
return new Response(
JSON.stringify({ status: 'error', message: listenError.message }),
{ headers: { 'Content-Type': 'application/json' } }
)
JSON.stringify({ status: "error", message: listenError.message }),
{ headers: { "Content-Type": "application/json" } }
);
}
console.log('Listen record inserted successfully')
console.log("Listen record inserted successfully");
}
return new Response(JSON.stringify({ status: 'success' }), {
headers: { 'Content-Type': 'application/json' },
})
return new Response(JSON.stringify({ status: "success" }), {
headers: { "Content-Type": "application/json" },
});
} catch (e) {
console.error('Error processing request:', e.message)
console.error("Error processing request:", e.message);
return new Response(
JSON.stringify({ status: 'error', message: e.message }),
{ headers: { 'Content-Type': 'application/json' } }
)
JSON.stringify({ status: "error", message: e.message }),
{ headers: { "Content-Type": "application/json" } }
);
}
},
}
};

View file

@ -1,47 +1,51 @@
import { createClient } from '@supabase/supabase-js'
import { createClient } from "@supabase/supabase-js";
export default {
async fetch(request, env) {
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);
try {
const { data, error } = await supabase
.from('optimized_sitemap')
.select('url, lastmod, changefreq, priority')
.from("optimized_sitemap")
.select("url, lastmod, changefreq, priority");
if (error) {
console.error('Error fetching sitemap data:', error)
return new Response('Error fetching sitemap data', { status: 500 })
console.error("Error fetching sitemap data:", error);
return new Response("Error fetching sitemap data", { status: 500 });
}
const sitemapXml = generateSitemapXml(data)
const sitemapXml = generateSitemapXml(data);
return new Response(sitemapXml, {
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
"Content-Type": "application/xml",
"Access-Control-Allow-Origin": "*",
},
})
});
} catch (error) {
console.error('Unexpected error:', error)
return new Response('Internal Server Error', { status: 500 })
console.error("Unexpected error:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
}
},
};
function generateSitemapXml(data) {
const urls = data.map(({ url, lastmod, changefreq, priority }) => `
const urls = data
.map(
({ url, lastmod, changefreq, priority }) => `
<url>
<loc>${url}</loc>
${lastmod ? `<lastmod>${new Date(lastmod).toISOString()}</lastmod>` : ''}
${lastmod ? `<lastmod>${new Date(lastmod).toISOString()}</lastmod>` : ""}
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
</url>
`).join('')
`
)
.join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
</urlset>`;
}