From ef4badf89b22ea327967e374590e5f087f043946 Mon Sep 17 00:00:00 2001 From: Cameron Otsuka Date: Tue, 25 Aug 2026 11:43:28 -0700 Subject: [PATCH] create reusable components --- src/components/entryarticle.astro | 28 + src/components/head/base.astro | 10 +- src/components/ratingdistribution.astro | 5 +- src/components/ui/contentlist.astro | 7 +- src/content.config.ts | 6 +- src/pages/articles/[date]-[id]/index.astro | 32 +- .../articles/[date]-[id]/opengraph.png.ts | 25 +- src/pages/feed.xml.ts | 15 +- src/pages/podcasts/[id]/index.astro | 31 +- src/pages/podcasts/[id]/opengraph.png.ts | 24 +- src/pages/reviews/[type]/[id]/index.astro | 29 +- .../reviews/[type]/[id]/opengraph.png.ts | 24 +- src/utils/generateContentUrl.ts | 41 +- src/utils/generateOpenGraphImage.ts | 563 +++++------------- src/utils/generateStarRating.ts | 11 +- src/utils/getEntrySubtitle.ts | 30 + src/utils/globals.ts | 11 +- src/utils/ogFonts.ts | 82 +++ src/utils/ogLayout.test.ts | 82 +++ src/utils/ogLayout.ts | 193 ++++++ 20 files changed, 677 insertions(+), 572 deletions(-) create mode 100644 src/components/entryarticle.astro create mode 100644 src/utils/getEntrySubtitle.ts create mode 100644 src/utils/ogFonts.ts create mode 100644 src/utils/ogLayout.test.ts create mode 100644 src/utils/ogLayout.ts diff --git a/src/components/entryarticle.astro b/src/components/entryarticle.astro new file mode 100644 index 0000000..ca3dac2 --- /dev/null +++ b/src/components/entryarticle.astro @@ -0,0 +1,28 @@ +--- +import Article from '@layouts/article.astro'; +import Metadata from '@components/metadata.astro'; +import getEntrySubtitle from '@utils/getEntrySubtitle'; +import { type SiteCollectionEntry } from '@utils/globals'; + +interface Props { + entry: SiteCollectionEntry; + category: string; +} + +const { entry, category } = Astro.props; +--- + +
+

+ {entry.data.title} +

+ + +
diff --git a/src/components/head/base.astro b/src/components/head/base.astro index 12b0917..2d7daff 100644 --- a/src/components/head/base.astro +++ b/src/components/head/base.astro @@ -10,6 +10,7 @@ interface Props { } const canonicalURL = new URL(Astro.url.pathname, Astro.site); +const ogImageURL = new URL('opengraph.png', canonicalURL); const favIcon = await getImage({ src: FavIcon, width: 48, @@ -54,14 +55,11 @@ const { title, description } = Astro.props; - - + + diff --git a/src/components/ratingdistribution.astro b/src/components/ratingdistribution.astro index 8ce1ecb..8e6bc7d 100644 --- a/src/components/ratingdistribution.astro +++ b/src/components/ratingdistribution.astro @@ -1,9 +1,10 @@ --- import { getCollection } from 'astro:content'; +import { RATINGS } from '@utils/generateStarRating'; const reviews = await getCollection('reviews'); -const ratings = [1, 2, 3, 4, 5] as const; -const counts = [0, 0, 0, 0, 0]; +const ratings = RATINGS; +const counts = Array.from({ length: ratings.length }).fill(0); for (const { data } of reviews) { counts[data.rating - 1]++; diff --git a/src/components/ui/contentlist.astro b/src/components/ui/contentlist.astro index eb9b134..09585cc 100644 --- a/src/components/ui/contentlist.astro +++ b/src/components/ui/contentlist.astro @@ -3,6 +3,7 @@ import { getCollection, type CollectionKey } from 'astro:content'; import Badge from '@components/ui/badge.astro'; import sortByDate from '@utils/sortByDate'; import generateContentUrl from '@utils/generateContentUrl'; +import getEntrySubtitle from '@utils/getEntrySubtitle'; interface Props { collection: CollectionKey; @@ -27,11 +28,7 @@ const displayedEntries =
{entry.data.type} - {entry.data.description - ? entry.data.description - : 'publication' in entry.data && entry.data.publication - ? `${entry.data.publication.name} ${entry.data.publication.issue}-${entry.data.publication.volume}` - : ''} + {getEntrySubtitle(entry.data, true)}
)) diff --git a/src/content.config.ts b/src/content.config.ts index ce469ee..ef82f6b 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,6 +1,7 @@ import { defineCollection, type SchemaContext } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; +import { RATINGS } from '@utils/generateStarRating'; const baseSchema = (image: SchemaContext['image']) => z.object({ @@ -48,7 +49,10 @@ const reviews = defineCollection({ loader: glob({ pattern: '**/*.{md,mdx}', base: 'content/reviews' }), schema: ({ image }) => baseSchema(image).extend({ - rating: z.int().gt(0).lte(5), + rating: z + .int() + .gt(Math.min(...RATINGS) - 1) + .lte(Math.max(...RATINGS)), type: z.enum(['book', 'game', 'movie', 'music', 'show']), }), }); diff --git a/src/pages/articles/[date]-[id]/index.astro b/src/pages/articles/[date]-[id]/index.astro index 6ee1a92..b92e078 100644 --- a/src/pages/articles/[date]-[id]/index.astro +++ b/src/pages/articles/[date]-[id]/index.astro @@ -1,34 +1,14 @@ --- -import { getCollection, render } from 'astro:content'; -import Article from '@layouts/article.astro'; -import Metadata from '@components/metadata.astro'; -import formatDate from '@utils/formatDate'; +import { render } from 'astro:content'; +import EntryArticle from '@components/entryarticle.astro'; +import { getContentStaticPaths } from '@utils/generateContentUrl'; -export async function getStaticPaths() { - const articles = await getCollection('articles'); - return articles.map((article) => ({ - params: { date: formatDate(article.data.date), id: article.id }, - props: { entry: article }, - })); -} +export const getStaticPaths = getContentStaticPaths('articles'); const { entry } = Astro.props; const { Content } = await render(entry); --- -
-

- {entry.data.title} -

- + -
+ diff --git a/src/pages/articles/[date]-[id]/opengraph.png.ts b/src/pages/articles/[date]-[id]/opengraph.png.ts index 7ac35e4..d5fa49d 100644 --- a/src/pages/articles/[date]-[id]/opengraph.png.ts +++ b/src/pages/articles/[date]-[id]/opengraph.png.ts @@ -1,20 +1,13 @@ import type { APIRoute } from 'astro'; -import { getCollection } from 'astro:content'; -import formatDate from '@utils/formatDate'; import generateOpenGraphImage from '@utils/generateOpenGraphImage'; +import { getContentStaticPaths } from '@utils/generateContentUrl'; +import getEntrySubtitle from '@utils/getEntrySubtitle'; -export const GET = (async ({ props, url }) => { - const entry = props.entry; - const subtitle = entry.data.publication - ? `${entry.data.publication.name} ${entry.data.publication.issue}-${entry.data.publication.volume}` - : entry.data.description; - return generateOpenGraphImage(entry.data.title, subtitle, url.origin); -}) satisfies APIRoute; +export const getStaticPaths = getContentStaticPaths('articles'); -export async function getStaticPaths() { - const articles = await getCollection('articles'); - return articles.map((article) => ({ - params: { date: formatDate(article.data.date), id: article.id }, - props: { entry: article }, - })); -} +export const GET = (({ props, url }) => + generateOpenGraphImage( + props.entry.data.title, + getEntrySubtitle(props.entry.data), + url.origin, + )) satisfies APIRoute; diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts index 6daf3f5..999ecc1 100644 --- a/src/pages/feed.xml.ts +++ b/src/pages/feed.xml.ts @@ -1,11 +1,12 @@ import { getContainerRenderer as getMDXRenderer } from '@astrojs/mdx/container-renderer'; import rss, { type RSSFeedItem } from '@astrojs/rss'; -import { siteDescription, siteTitle } from '@utils/globals'; +import { SITE_COLLECTIONS, siteDescription, siteTitle } from '@utils/globals'; import { type APIContext } from 'astro'; import { getCollection, render } from 'astro:content'; import { loadRenderers } from 'astro:container'; import { experimental_AstroContainer as AstroContainer } from 'astro/container'; import generateContentUrl from '@utils/generateContentUrl'; +import getEntrySubtitle from '@utils/getEntrySubtitle'; import generateStarRating from '@utils/generateStarRating'; import resolveRelativeUrls from '@utils/resolveRelativeUrls'; @@ -14,10 +15,9 @@ export async function GET(context: APIContext) { throw new Error('Site URL is required for RSS feed generation'); } - const articles = await getCollection('articles'); - const podcasts = await getCollection('podcasts'); - const reviews = await getCollection('reviews'); - const collections = [...articles, ...podcasts, ...reviews]; + const collections = ( + await Promise.all(SITE_COLLECTIONS.map((name) => getCollection(name))) + ).flat(); const renderers = await loadRenderers([getMDXRenderer()]); const container = await AstroContainer.create({ renderers }); @@ -46,10 +46,7 @@ export async function GET(context: APIContext) { } default: title = item.data.title; - description = - 'publication' in item.data && item.data.publication - ? `${item.data.publication.name} ${item.data.publication.issue}-${item.data.publication.volume}` - : (item.data.description ?? ''); + description = getEntrySubtitle(item.data); break; } diff --git a/src/pages/podcasts/[id]/index.astro b/src/pages/podcasts/[id]/index.astro index 19c0aec..497395a 100644 --- a/src/pages/podcasts/[id]/index.astro +++ b/src/pages/podcasts/[id]/index.astro @@ -1,33 +1,14 @@ --- -import { getCollection, render } from 'astro:content'; -import Article from '@layouts/article.astro'; -import Metadata from '@components/metadata.astro'; +import { render } from 'astro:content'; +import EntryArticle from '@components/entryarticle.astro'; +import { getContentStaticPaths } from '@utils/generateContentUrl'; -export async function getStaticPaths() { - const podcasts = await getCollection('podcasts'); - return podcasts.map((podcast) => ({ - params: { id: podcast.id }, - props: { entry: podcast }, - })); -} +export const getStaticPaths = getContentStaticPaths('podcasts'); const { entry } = Astro.props; const { Content } = await render(entry); --- -
-

- {entry.data.title} -

- + -
+ diff --git a/src/pages/podcasts/[id]/opengraph.png.ts b/src/pages/podcasts/[id]/opengraph.png.ts index 08df090..c9ed5cc 100644 --- a/src/pages/podcasts/[id]/opengraph.png.ts +++ b/src/pages/podcasts/[id]/opengraph.png.ts @@ -1,19 +1,13 @@ import type { APIRoute } from 'astro'; -import { getCollection } from 'astro:content'; import generateOpenGraphImage from '@utils/generateOpenGraphImage'; +import { getContentStaticPaths } from '@utils/generateContentUrl'; +import getEntrySubtitle from '@utils/getEntrySubtitle'; -export const GET = (async ({ props, url }) => { - const entry = props.entry; - const subtitle = entry.data.publication - ? `${entry.data.publication.name} ${entry.data.publication.issue}-${entry.data.publication.volume}` - : entry.data.description; - return generateOpenGraphImage(entry.data.title, subtitle, url.origin); -}) satisfies APIRoute; +export const getStaticPaths = getContentStaticPaths('podcasts'); -export async function getStaticPaths() { - const podcasts = await getCollection('podcasts'); - return podcasts.map((podcast) => ({ - params: { id: podcast.id }, - props: { entry: podcast }, - })); -} +export const GET = (({ props, url }) => + generateOpenGraphImage( + props.entry.data.title, + getEntrySubtitle(props.entry.data), + url.origin, + )) satisfies APIRoute; diff --git a/src/pages/reviews/[type]/[id]/index.astro b/src/pages/reviews/[type]/[id]/index.astro index c634180..ae9e5c9 100644 --- a/src/pages/reviews/[type]/[id]/index.astro +++ b/src/pages/reviews/[type]/[id]/index.astro @@ -1,31 +1,14 @@ --- -import { getCollection, render } from 'astro:content'; -import Article from '@layouts/article.astro'; -import Metadata from '@components/metadata.astro'; +import { render } from 'astro:content'; +import EntryArticle from '@components/entryarticle.astro'; +import { getContentStaticPaths } from '@utils/generateContentUrl'; -export async function getStaticPaths() { - const reviews = await getCollection('reviews'); - return reviews.map((review) => ({ - params: { type: review.data.type, id: review.id }, - props: { entry: review }, - })); -} +export const getStaticPaths = getContentStaticPaths('reviews'); const { entry } = Astro.props; const { Content } = await render(entry); --- -
-

- {entry.data.title} -

- + -
+ diff --git a/src/pages/reviews/[type]/[id]/opengraph.png.ts b/src/pages/reviews/[type]/[id]/opengraph.png.ts index 6d7df34..ea0022e 100644 --- a/src/pages/reviews/[type]/[id]/opengraph.png.ts +++ b/src/pages/reviews/[type]/[id]/opengraph.png.ts @@ -1,21 +1,13 @@ import type { APIRoute } from 'astro'; -import { getCollection } from 'astro:content'; import generateOpenGraphImage from '@utils/generateOpenGraphImage'; +import { getContentStaticPaths } from '@utils/generateContentUrl'; import generateStarRating from '@utils/generateStarRating'; -export const GET = (async ({ props, url }) => { - const entry = props.entry; - return generateOpenGraphImage( - entry.data.title, - generateStarRating(entry.data.rating), - url.origin, - ); -}) satisfies APIRoute; +export const getStaticPaths = getContentStaticPaths('reviews'); -export async function getStaticPaths() { - const reviews = await getCollection('reviews'); - return reviews.map((review) => ({ - params: { type: review.data.type, id: review.id }, - props: { entry: review }, - })); -} +export const GET = (({ props, url }) => + generateOpenGraphImage( + props.entry.data.title, + generateStarRating(props.entry.data.rating), + url.origin, + )) satisfies APIRoute; diff --git a/src/utils/generateContentUrl.ts b/src/utils/generateContentUrl.ts index 1b40046..2ab90f1 100644 --- a/src/utils/generateContentUrl.ts +++ b/src/utils/generateContentUrl.ts @@ -1,13 +1,44 @@ +import { getCollection } from 'astro:content'; import formatDate from '@utils/formatDate'; -import { type SiteCollectionEntry } from '@utils/globals'; +import { type SiteCollection, type SiteCollectionEntry } from '@utils/globals'; -export default function generateContentUrl(item: SiteCollectionEntry): string { +export function getContentParams( + item: SiteCollectionEntry, +): Record { switch (item.collection) { case 'articles': - return `/articles/${formatDate(item.data.date)}-${item.id}/`; + return { date: formatDate(item.data.date), id: item.id }; case 'podcasts': - return `/podcasts/${item.id}/`; + return { id: item.id }; case 'reviews': - return `/reviews/${item.data.type}/${item.id}/`; + return { type: item.data.type, id: item.id }; + } +} + +export default function generateContentUrl(item: SiteCollectionEntry): string { + switch (item.collection) { + case 'articles': { + const { date, id } = getContentParams(item); + return `/articles/${date}-${id}/`; + } + case 'podcasts': { + const { id } = getContentParams(item); + return `/podcasts/${id}/`; + } + case 'reviews': { + const { type, id } = getContentParams(item); + return `/reviews/${type}/${id}/`; + } } } + +/** Builds an Astro `getStaticPaths` export for a collection's detail routes. */ +export function getContentStaticPaths(collection: SiteCollection) { + return async () => { + const entries = await getCollection(collection); + return entries.map((entry) => ({ + params: getContentParams(entry), + props: { entry }, + })); + }; +} diff --git a/src/utils/generateOpenGraphImage.ts b/src/utils/generateOpenGraphImage.ts index 6e0c071..eca4af9 100644 --- a/src/utils/generateOpenGraphImage.ts +++ b/src/utils/generateOpenGraphImage.ts @@ -1,25 +1,17 @@ -import { fontData } from 'astro:assets'; import { ImageResponse } from '@vercel/og'; -import type { Font } from 'satori'; -import { siteAuthor, siteTitle } from '@utils/globals'; - -type ResolvedOgFont = { - name: string; - assetUrl: string; - weight: Font['weight']; - style: Font['style']; -}; - -type WrappedOgText = { - text: string; - lineCount: number; -}; - -type ResolvedOgLayout = { - headline: WrappedOgText; - description: string | null; - isReviewRating: boolean; -}; +import { siteAuthor } from '@utils/globals'; +import { + CONTENT_BLOCK, + DESCRIPTION_FONT_SIZE, + DESCRIPTION_LINE_HEIGHT, + DESCRIPTION_MAX_WIDTH, + MAX_DESCRIPTION_LINES, + TITLE_DESCRIPTION_GAP, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + resolveOgLayout, +} from '@utils/ogLayout'; +import { createOgFonts } from '@utils/ogFonts'; const OG_IMAGE_WIDTH = 1200; const OG_IMAGE_HEIGHT = 630; @@ -29,12 +21,6 @@ const SAFE_TEXT_ZONE = { width: 960, height: 460, }; -const CONTENT_BLOCK = { - x: 40, - y: 12, - width: 880, - height: 320, -}; const BRAND_BLOCK = { x: 360, y: 382, @@ -44,414 +30,159 @@ const BRAND_BLOCK = { const BACKGROUND_COLOR = '#e6e2d6'; const TEXT_COLOR = '#000000'; const ACCENT_COLOR = '#82273d'; -const TITLE_FONT_SIZE = 60; -const TITLE_MAX_WIDTH = 860; -const TITLE_MAX_LINES = 4; -const TITLE_LINE_HEIGHT = 1.05; -const DESCRIPTION_FONT_SIZE = 28; -const DESCRIPTION_MAX_WIDTH = 860; -const DESCRIPTION_LINE_HEIGHT = 1.28; -const MAX_DESCRIPTION_LINES = 2; -const TITLE_DESCRIPTION_GAP = 28; - -function getAstroFontVariant( - name: string, - cssVariable: keyof typeof fontData, - weight: string, - style: string, -): ResolvedOgFont { - const font = fontData[cssVariable].find( - (font) => font.weight === weight && font.style === style, - ); - - if (!font?.weight || !font.style) { - throw new Error( - `Could not find font for ${cssVariable} with weight=${weight} and style=${style}`, - ); - } - - const source = font.src.find((src) => src.format === 'woff'); - const assetUrl = source?.url; - if (!assetUrl?.startsWith('/_astro/fonts/')) { - throw new Error( - `Could not find a WOFF Astro font URL for ${cssVariable} with weight=${weight} and style=${style}: ${JSON.stringify(font.src)}`, - ); - } +type OgStyle = Record; - return { - name, - assetUrl, - weight: Number(font.weight) as Font['weight'], - style: font.style as Font['style'], +type OgElement = { + type: string; + props: { + style?: OgStyle; + children?: OgElement | OgElement[] | string; + [key: string]: unknown; }; -} - -async function loadAstroFontData( - assetUrl: string, - siteOrigin: string, -): Promise { - const buildPath = `dist${assetUrl}`; - const buildFile = Bun.file(buildPath); - - if (await buildFile.exists()) { - return buildFile.arrayBuffer(); - } - - const response = await fetch(new URL(assetUrl, siteOrigin)); - - if (!response.ok) { - throw new Error( - `Failed to fetch Astro font asset: ${assetUrl} (${response.status} ${response.statusText})`, - ); - } +}; - return response.arrayBuffer(); +function el( + type: string, + style: OgStyle, + children: OgElement | OgElement[] | string = '', +): OgElement { + return { type, props: { style, children, key: null } }; } -function createOgFonts(siteOrigin: string): Promise> { - const variants = [ - getAstroFontVariant('Public Sans', '--font-sans', '400', 'normal'), - getAstroFontVariant('Public Sans', '--font-sans', '700', 'normal'), - getAstroFontVariant('Source Serif 4', '--font-serif', '400', 'normal'), - getAstroFontVariant('DejaVu Mono', '--font-fallback', '400', 'normal'), - ]; +export default async function generateOpenGraphImage( + title: string, + description: string, + siteOrigin: string, +) { + const layout = resolveOgLayout(title, description); - return Promise.all( - variants.map(async (font) => ({ - name: font.name, - data: await loadAstroFontData(font.assetUrl, siteOrigin), - weight: font.weight, - style: font.style, - })), + const headline = el( + 'h1', + { + margin: 0, + maxWidth: 860, + fontSize: TITLE_FONT_SIZE, + fontWeight: 700, + lineHeight: TITLE_LINE_HEIGHT, + letterSpacing: '-0.04em', + whiteSpace: 'pre-wrap', + }, + layout.headline.text, ); -} - -function normalizeOgText(value: string): string { - return value.replace(/\s+/g, ' ').trim(); -} - -function countCharacters(value: string): number { - return Array.from(value).length; -} - -function isReviewRatingDescription(value: string): boolean { - return /^[★☆]{5}$/.test(value); -} - -function trimToLength(value: string, maxLength: number): string { - if (countCharacters(value) <= maxLength) { - return value; - } - - const characters = Array.from(value) - .slice(0, maxLength - 1) - .join(''); - - return `${characters.trimEnd()}…`; -} - -function estimateCharsPerLine(fontSize: number, maxWidth: number): number { - return Math.max(12, Math.floor(maxWidth / (fontSize * 0.54))); -} - -function wrapTextIntoLines(value: string, maxCharsPerLine: number): string[] { - const normalized = normalizeOgText(value); - - if (!normalized) { - return []; - } - - const words = normalized.split(' '); - const lines: string[] = []; - let currentLine = ''; - - for (const word of words) { - const candidate = currentLine ? `${currentLine} ${word}` : word; - if (!currentLine || countCharacters(candidate) <= maxCharsPerLine) { - currentLine = candidate; - continue; - } - - lines.push(currentLine); - currentLine = word; - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; -} - -function formatTextToMaxLines( - value: string, - maxCharsPerLine: number, - maxLines: number, -): WrappedOgText { - const lines = wrapTextIntoLines(value, maxCharsPerLine); - - if (lines.length <= maxLines) { - return { - text: lines.join('\n'), - lineCount: lines.length, - }; - } - - const truncatedLines = lines.slice(0, maxLines); - truncatedLines[maxLines - 1] = trimToLength( - truncatedLines[maxLines - 1], - maxCharsPerLine, + const ratingDescription = el( + 'div', + { + display: 'flex', + width: '100%', + justifyContent: 'center', + marginTop: TITLE_DESCRIPTION_GAP, + }, + el( + 'p', + { + margin: 0, + fontSize: TITLE_FONT_SIZE, + fontWeight: 700, + lineHeight: TITLE_LINE_HEIGHT, + textAlign: 'center', + }, + layout.description ?? '', + ), ); - if (!truncatedLines[maxLines - 1].endsWith('…')) { - truncatedLines[maxLines - 1] = `${truncatedLines[maxLines - 1].trimEnd()}…`; - } - - return { - text: truncatedLines.join('\n'), - lineCount: truncatedLines.length, - }; -} - -function estimateContentHeight( - headlineLineCount: number, - descriptionLineCount: number, - descriptionFontSize = DESCRIPTION_FONT_SIZE, - descriptionLineHeight = DESCRIPTION_LINE_HEIGHT, -): number { - let height = headlineLineCount * TITLE_FONT_SIZE * TITLE_LINE_HEIGHT; - - if (descriptionLineCount > 0) { - height += - TITLE_DESCRIPTION_GAP + - descriptionLineCount * descriptionFontSize * descriptionLineHeight; - } - - return Math.ceil(height); -} - -function resolveOgLayout(title: string, description: string): ResolvedOgLayout { - const normalizedTitle = normalizeOgText(title) || siteTitle; - const normalizedDescription = normalizeOgText(description); - const headline = formatTextToMaxLines( - normalizedTitle, - estimateCharsPerLine(TITLE_FONT_SIZE, TITLE_MAX_WIDTH), - TITLE_MAX_LINES, + const textDescription = el( + 'p', + { + margin: `${TITLE_DESCRIPTION_GAP}px 0 0`, + maxWidth: DESCRIPTION_MAX_WIDTH, + fontSize: DESCRIPTION_FONT_SIZE, + lineHeight: DESCRIPTION_LINE_HEIGHT, + fontFamily: 'Source Serif 4, DejaVu Mono', + overflow: 'hidden', + textOverflow: 'ellipsis', + lineClamp: MAX_DESCRIPTION_LINES, + }, + layout.description ?? '', ); - if (!normalizedDescription) { - return { headline, description: null, isReviewRating: false }; - } - - if (isReviewRatingDescription(normalizedDescription)) { - if ( - estimateContentHeight( - headline.lineCount, - 1, - TITLE_FONT_SIZE, - TITLE_LINE_HEIGHT, - ) <= CONTENT_BLOCK.height - ) { - return { - headline, - description: normalizedDescription, - isReviewRating: true, - }; - } - - return { - headline, - description: null, - isReviewRating: true, - }; - } - - if ( - estimateContentHeight(headline.lineCount, MAX_DESCRIPTION_LINES) <= - CONTENT_BLOCK.height - ) { - return { + const contentBlock = el( + 'div', + { + display: 'flex', + position: 'absolute', + left: CONTENT_BLOCK.x, + top: CONTENT_BLOCK.y, + width: CONTENT_BLOCK.width, + height: CONTENT_BLOCK.height, + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + textAlign: 'left', + }, + [ headline, - description: normalizedDescription, - isReviewRating: false, - }; - } - - return { - headline, - description: null, - isReviewRating: false, - }; -} + ...(layout.description + ? [layout.isReviewRating ? ratingDescription : textDescription] + : []), + ], + ); -export default async function generateOpenGraphImage( - title: string, - description: string, - siteOrigin: string, -) { - const layout = resolveOgLayout(title, description); + const brandBlock = el( + 'div', + { + display: 'flex', + position: 'absolute', + left: BRAND_BLOCK.x, + top: BRAND_BLOCK.y, + width: BRAND_BLOCK.width, + height: BRAND_BLOCK.height, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + textTransform: 'uppercase', + }, + [ + el('div', { + borderTop: `2px solid ${ACCENT_COLOR}`, + width: BRAND_BLOCK.width, + marginBottom: 8, + }), + el('div', { fontSize: 20, letterSpacing: '0.1em' }, siteAuthor.name), + ], + ); - return new ImageResponse( + const safeZone = el( + 'div', { - type: 'div', - props: { - style: { - display: 'flex', - position: 'relative', - backgroundColor: BACKGROUND_COLOR, - color: TEXT_COLOR, - width: '100%', - height: '100%', - fontFamily: 'Public Sans', - }, - children: [ - { - type: 'div', - props: { - style: { - display: 'flex', - position: 'absolute', - left: SAFE_TEXT_ZONE.x, - top: SAFE_TEXT_ZONE.y, - width: SAFE_TEXT_ZONE.width, - height: SAFE_TEXT_ZONE.height, - }, - children: [ - { - type: 'div', - props: { - style: { - display: 'flex', - position: 'absolute', - left: CONTENT_BLOCK.x, - top: CONTENT_BLOCK.y, - width: CONTENT_BLOCK.width, - height: CONTENT_BLOCK.height, - flexDirection: 'column', - alignItems: 'flex-start', - justifyContent: 'center', - textAlign: 'left', - }, - children: [ - { - type: 'h1', - props: { - style: { - margin: 0, - maxWidth: TITLE_MAX_WIDTH, - fontSize: TITLE_FONT_SIZE, - fontWeight: 700, - lineHeight: TITLE_LINE_HEIGHT, - letterSpacing: '-0.04em', - whiteSpace: 'pre-wrap', - }, - children: layout.headline.text, - }, - }, - ...(layout.description - ? layout.isReviewRating - ? [ - { - type: 'div', - props: { - style: { - display: 'flex', - width: '100%', - justifyContent: 'center', - marginTop: TITLE_DESCRIPTION_GAP, - }, - children: [ - { - type: 'p', - props: { - style: { - margin: 0, - fontSize: TITLE_FONT_SIZE, - fontWeight: 700, - lineHeight: TITLE_LINE_HEIGHT, - textAlign: 'center', - }, - children: layout.description, - }, - }, - ], - }, - }, - ] - : [ - { - type: 'p', - props: { - style: { - margin: `${TITLE_DESCRIPTION_GAP}px 0 0`, - maxWidth: DESCRIPTION_MAX_WIDTH, - fontSize: DESCRIPTION_FONT_SIZE, - lineHeight: DESCRIPTION_LINE_HEIGHT, - fontFamily: 'Source Serif 4, DejaVu Mono', - overflow: 'hidden', - textOverflow: 'ellipsis', - lineClamp: MAX_DESCRIPTION_LINES, - }, - children: layout.description, - }, - }, - ] - : []), - ], - }, - }, - { - type: 'div', - props: { - style: { - display: 'flex', - position: 'absolute', - left: BRAND_BLOCK.x, - top: BRAND_BLOCK.y, - width: BRAND_BLOCK.width, - height: BRAND_BLOCK.height, - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - textTransform: 'uppercase', - }, - children: [ - { - type: 'div', - props: { - style: { - borderTop: `2px solid ${ACCENT_COLOR}`, - width: BRAND_BLOCK.width, - marginBottom: 8, - }, - }, - }, - { - type: 'div', - props: { - style: { - fontSize: 20, - letterSpacing: '0.1em', - }, - children: siteAuthor.name, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - key: null, + display: 'flex', + position: 'absolute', + left: SAFE_TEXT_ZONE.x, + top: SAFE_TEXT_ZONE.y, + width: SAFE_TEXT_ZONE.width, + height: SAFE_TEXT_ZONE.height, }, + [contentBlock, brandBlock], + ); + + const root = el( + 'div', { - width: OG_IMAGE_WIDTH, - height: OG_IMAGE_HEIGHT, - fonts: await createOgFonts(siteOrigin), + display: 'flex', + position: 'relative', + backgroundColor: BACKGROUND_COLOR, + color: TEXT_COLOR, + width: '100%', + height: '100%', + fontFamily: 'Public Sans', }, + safeZone, ); + + return new ImageResponse(root, { + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + fonts: await createOgFonts(siteOrigin), + }); } diff --git a/src/utils/generateStarRating.ts b/src/utils/generateStarRating.ts index b85a6b3..663d972 100644 --- a/src/utils/generateStarRating.ts +++ b/src/utils/generateStarRating.ts @@ -1,5 +1,14 @@ +export const RATINGS = [1, 2, 3, 4, 5] as const; + +const MIN_RATING = Math.min(...RATINGS); +const MAX_RATING = Math.max(...RATINGS); + export default function generateStarRating(rating: number): string { - if (Number.isInteger(rating) && rating >= 1 && rating <= 5) { + if ( + Number.isInteger(rating) && + rating >= MIN_RATING && + rating <= MAX_RATING + ) { const filledStars = '★'.repeat(rating); const unfilledStars = '☆'.repeat(5 - rating); return filledStars + unfilledStars; diff --git a/src/utils/getEntrySubtitle.ts b/src/utils/getEntrySubtitle.ts new file mode 100644 index 0000000..67385fc --- /dev/null +++ b/src/utils/getEntrySubtitle.ts @@ -0,0 +1,30 @@ +import { type SiteEntrySchema } from '@utils/globals'; + +function formatPublication(publication: { + name: string; + issue: number; + volume: number; +}): string { + return `${publication.name} ${publication.issue}-${publication.volume}`; +} + +/** + * Returns the subtitle shown for an entry across the site: the publication + * citation when present, otherwise the description. Detail pages, OG images, + * and feeds use this default; listings pass `preferDescription` to show full + * descriptions instead of citations. + */ +export default function getEntrySubtitle( + entryData: SiteEntrySchema, + preferDescription = false, +): string { + if (preferDescription && entryData.description) { + return entryData.description; + } + + if ('publication' in entryData && entryData.publication) { + return formatPublication(entryData.publication); + } + + return entryData.description ?? ''; +} diff --git a/src/utils/globals.ts b/src/utils/globals.ts index 9f04544..fab0825 100644 --- a/src/utils/globals.ts +++ b/src/utils/globals.ts @@ -7,12 +7,11 @@ export const siteAuthor = { name: 'Cameron Otsuka', email: 'cameron@otsuka.haus', }; -export type SiteCollectionEntry = CollectionEntry< - 'articles' | 'podcasts' | 'reviews' ->; -export type SiteEntrySchema = InferEntrySchema< - 'articles' | 'podcasts' | 'reviews' ->; +export const SITE_COLLECTIONS = ['articles', 'podcasts', 'reviews'] as const; + +export type SiteCollection = (typeof SITE_COLLECTIONS)[number]; +export type SiteCollectionEntry = CollectionEntry; +export type SiteEntrySchema = InferEntrySchema; export const menuItems: { title: string; url: string }[] = [ { title: 'Articles', url: '/articles' }, diff --git a/src/utils/ogFonts.ts b/src/utils/ogFonts.ts new file mode 100644 index 0000000..a80562a --- /dev/null +++ b/src/utils/ogFonts.ts @@ -0,0 +1,82 @@ +import { fontData } from 'astro:assets'; +import type { Font } from 'satori'; + +type ResolvedOgFont = { + name: string; + assetUrl: string; + weight: Font['weight']; + style: Font['style']; +}; + +function getAstroFontVariant( + name: string, + cssVariable: keyof typeof fontData, + weight: string, + style: string, +): ResolvedOgFont { + const font = fontData[cssVariable].find( + (font) => font.weight === weight && font.style === style, + ); + + if (!font?.weight || !font.style) { + throw new Error( + `Could not find font for ${cssVariable} with weight=${weight} and style=${style}`, + ); + } + + const source = font.src.find((src) => src.format === 'woff'); + const assetUrl = source?.url; + + if (!assetUrl?.startsWith('/_astro/fonts/')) { + throw new Error( + `Could not find a WOFF Astro font URL for ${cssVariable} with weight=${weight} and style=${style}: ${JSON.stringify(font.src)}`, + ); + } + + return { + name, + assetUrl, + weight: Number(font.weight) as Font['weight'], + style: font.style as Font['style'], + }; +} + +async function loadAstroFontData( + assetUrl: string, + siteOrigin: string, +): Promise { + const buildPath = `dist${assetUrl}`; + const buildFile = Bun.file(buildPath); + + if (await buildFile.exists()) { + return buildFile.arrayBuffer(); + } + + const response = await fetch(new URL(assetUrl, siteOrigin)); + + if (!response.ok) { + throw new Error( + `Failed to fetch Astro font asset: ${assetUrl} (${response.status} ${response.statusText})`, + ); + } + + return response.arrayBuffer(); +} + +export function createOgFonts(siteOrigin: string): Promise> { + const variants = [ + getAstroFontVariant('Public Sans', '--font-sans', '400', 'normal'), + getAstroFontVariant('Public Sans', '--font-sans', '700', 'normal'), + getAstroFontVariant('Source Serif 4', '--font-serif', '400', 'normal'), + getAstroFontVariant('DejaVu Mono', '--font-fallback', '400', 'normal'), + ]; + + return Promise.all( + variants.map(async (font) => ({ + name: font.name, + data: await loadAstroFontData(font.assetUrl, siteOrigin), + weight: font.weight, + style: font.style, + })), + ); +} diff --git a/src/utils/ogLayout.test.ts b/src/utils/ogLayout.test.ts new file mode 100644 index 0000000..582568d --- /dev/null +++ b/src/utils/ogLayout.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { + CONTENT_BLOCK, + estimateCharsPerLine, + estimateContentHeight, + normalizeOgText, + resolveOgLayout, + TITLE_FONT_SIZE, + TITLE_MAX_LINES, +} from '@utils/ogLayout'; + +describe('normalizeOgText', () => { + test('collapses whitespace and trims ends', () => { + expect(normalizeOgText(' a \n\t b c ')).toBe('a b c'); + }); + + test('empty input stays empty', () => { + expect(normalizeOgText(' ')).toBe(''); + }); +}); + +describe('estimateCharsPerLine', () => { + test('scales inversely with font size', () => { + const wide = estimateCharsPerLine(28, 860); + const narrow = estimateCharsPerLine(60, 860); + expect(wide).toBeGreaterThan(narrow); + expect(narrow).toBeGreaterThanOrEqual(12); + }); +}); + +describe('estimateContentHeight', () => { + test('adds gap only when description lines exist', () => { + const withoutDescription = estimateContentHeight(1, 0); + const withDescription = estimateContentHeight(1, 2); + expect(withDescription).toBeGreaterThan(withoutDescription); + }); +}); + +describe('resolveOgLayout', () => { + test('falls back to site title when title is blank', () => { + const { headline } = resolveOgLayout(' ', ''); + expect(headline.text.length).toBeGreaterThan(0); + }); + + test('caps headline at the maximum line count', () => { + const longTitle = 'word '.repeat(100).trim(); + const { headline } = resolveOgLayout(longTitle, ''); + expect(headline.lineCount).toBeLessThanOrEqual(TITLE_MAX_LINES); + expect(headline.text.endsWith('…')).toBe(true); + }); + + test('drops descriptions that would overflow the content block', () => { + const longTitle = 'word '.repeat(100).trim(); + const layout = resolveOgLayout(longTitle, 'A short description.'); + expect(layout.description).toBeNull(); + + const fits = resolveOgLayout('Short title', 'A short description.'); + expect(fits.description).toBe('A short description.'); + expect(fits.isReviewRating).toBe(false); + }); + + test('detects five-star review ratings', () => { + const layout = resolveOgLayout('Some Book', '★★★☆☆'); + expect(layout.isReviewRating).toBe(true); + expect(layout.description).toBe('★★★☆☆'); + }); + + test('drops star ratings when the headline fills the block', () => { + const longTitle = 'word '.repeat(100).trim(); + const layout = resolveOgLayout(longTitle, '★★★☆☆'); + expect(layout.isReviewRating).toBe(true); + expect(layout.description).toBeNull(); + }); + + test('headline always fits within the content block height budget', () => { + const longTitle = 'word '.repeat(200).trim(); + const { headline } = resolveOgLayout(longTitle, ''); + const height = estimateContentHeight(headline.lineCount, 0); + expect(height).toBeLessThanOrEqual(CONTENT_BLOCK.height); + expect(TITLE_FONT_SIZE).toBeGreaterThan(0); + }); +}); diff --git a/src/utils/ogLayout.ts b/src/utils/ogLayout.ts new file mode 100644 index 0000000..8726773 --- /dev/null +++ b/src/utils/ogLayout.ts @@ -0,0 +1,193 @@ +import { siteTitle } from '@utils/globals'; + +export type WrappedOgText = { + text: string; + lineCount: number; +}; + +export type ResolvedOgLayout = { + headline: WrappedOgText; + description: string | null; + isReviewRating: boolean; +}; + +export const CONTENT_BLOCK = { + x: 40, + y: 12, + width: 880, + height: 320, +}; +export const TITLE_FONT_SIZE = 60; +export const TITLE_MAX_WIDTH = 860; +export const TITLE_MAX_LINES = 4; +export const TITLE_LINE_HEIGHT = 1.05; +export const DESCRIPTION_FONT_SIZE = 28; +export const DESCRIPTION_MAX_WIDTH = 860; +export const DESCRIPTION_LINE_HEIGHT = 1.28; +export const MAX_DESCRIPTION_LINES = 2; +export const TITLE_DESCRIPTION_GAP = 28; + +export function normalizeOgText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function countCharacters(value: string): number { + return Array.from(value).length; +} + +function trimToLength(value: string, maxLength: number): string { + if (countCharacters(value) <= maxLength) { + return value; + } + + const characters = Array.from(value) + .slice(0, maxLength - 1) + .join(''); + + return `${characters.trimEnd()}…`; +} + +export function estimateCharsPerLine( + fontSize: number, + maxWidth: number, +): number { + return Math.max(12, Math.floor(maxWidth / (fontSize * 0.54))); +} + +function wrapTextIntoLines(value: string, maxCharsPerLine: number): string[] { + const normalized = normalizeOgText(value); + + if (!normalized) { + return []; + } + + const words = normalized.split(' '); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + const candidate = currentLine ? `${currentLine} ${word}` : word; + + if (!currentLine || countCharacters(candidate) <= maxCharsPerLine) { + currentLine = candidate; + continue; + } + + lines.push(currentLine); + currentLine = word; + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + +function formatTextToMaxLines( + value: string, + maxCharsPerLine: number, + maxLines: number, +): WrappedOgText { + const lines = wrapTextIntoLines(value, maxCharsPerLine); + + if (lines.length <= maxLines) { + return { + text: lines.join('\n'), + lineCount: lines.length, + }; + } + + const truncatedLines = lines.slice(0, maxLines); + truncatedLines[maxLines - 1] = trimToLength( + truncatedLines[maxLines - 1], + maxCharsPerLine, + ); + + if (!truncatedLines[maxLines - 1].endsWith('…')) { + truncatedLines[maxLines - 1] = `${truncatedLines[maxLines - 1].trimEnd()}…`; + } + + return { + text: truncatedLines.join('\n'), + lineCount: truncatedLines.length, + }; +} + +export function estimateContentHeight( + headlineLineCount: number, + descriptionLineCount: number, + descriptionFontSize = DESCRIPTION_FONT_SIZE, + descriptionLineHeight = DESCRIPTION_LINE_HEIGHT, +): number { + let height = headlineLineCount * TITLE_FONT_SIZE * TITLE_LINE_HEIGHT; + + if (descriptionLineCount > 0) { + height += + TITLE_DESCRIPTION_GAP + + descriptionLineCount * descriptionFontSize * descriptionLineHeight; + } + + return Math.ceil(height); +} + +function isReviewRatingDescription(value: string): boolean { + return /^[★☆]{5}$/.test(value); +} + +export function resolveOgLayout( + title: string, + description: string, +): ResolvedOgLayout { + const normalizedTitle = normalizeOgText(title) || siteTitle; + const normalizedDescription = normalizeOgText(description); + const headline = formatTextToMaxLines( + normalizedTitle, + estimateCharsPerLine(TITLE_FONT_SIZE, TITLE_MAX_WIDTH), + TITLE_MAX_LINES, + ); + + if (!normalizedDescription) { + return { headline, description: null, isReviewRating: false }; + } + + if (isReviewRatingDescription(normalizedDescription)) { + if ( + estimateContentHeight( + headline.lineCount, + 1, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + ) <= CONTENT_BLOCK.height + ) { + return { + headline, + description: normalizedDescription, + isReviewRating: true, + }; + } + + return { + headline, + description: null, + isReviewRating: true, + }; + } + + if ( + estimateContentHeight(headline.lineCount, MAX_DESCRIPTION_LINES) <= + CONTENT_BLOCK.height + ) { + return { + headline, + description: normalizedDescription, + isReviewRating: false, + }; + } + + return { + headline, + description: null, + isReviewRating: false, + }; +} -- 2.55.0