--- /dev/null
+---
+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;
+---
+
+<Article
+ title={entry.data.title}
+ description={getEntrySubtitle(entry.data)}
+ publishedTime={entry.data.date.toISOString()}
+ modifiedTime={entry.data.modified?.toISOString() ??
+ entry.data.date.toISOString()}
+ tags={entry.data.tags}
+>
+ <h2 data-pagefind-meta="title" data-pagefind-filter={`category:${category}`}>
+ {entry.data.title}
+ </h2>
+ <Metadata entryData={entry.data} />
+ <slot />
+</Article>
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
+const ogImageURL = new URL('opengraph.png', canonicalURL);
const favIcon = await getImage({
src: FavIcon,
width: 48,
<meta name="twitter:description" content={description} />
<meta
name="twitter:image"
- content={new URL('opengraph.png', canonicalURL)}
+ content={ogImageURL}
data-pagefind-default-meta="image[content]"
/>
-<meta property="og:image" content={new URL('opengraph.png', canonicalURL)} />
-<meta
- property="og:image:secure_url"
- content={new URL('opengraph.png', canonicalURL)}
-/>
+<meta property="og:image" content={ogImageURL} />
+<meta property="og:image:secure_url" content={ogImageURL} />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
---
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<number>({ length: ratings.length }).fill(0);
for (const { data } of reviews) {
counts[data.rating - 1]++;
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;
</dt>
<dd>
<Badge>{entry.data.type}</Badge>
- {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)}
</dd>
</>
))
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({
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']),
}),
});
---
-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);
---
-<Article
- title={entry.data.title}
- description={'publication' in entry.data && entry.data.publication
- ? `${entry.data.publication.name} ${entry.data.publication.issue}-${entry.data.publication.volume}`
- : (entry.data.description ?? '')}
- publishedTime={entry.data.date.toISOString()}
- modifiedTime={entry.data.modified?.toISOString() ??
- entry.data.date.toISOString()}
- tags={entry.data.tags}
->
- <h2 data-pagefind-meta="title" data-pagefind-filter="category:article">
- {entry.data.title}
- </h2>
- <Metadata entryData={entry.data} />
+<EntryArticle entry={entry} category="article">
<Content />
-</Article>
+</EntryArticle>
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;
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';
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 });
}
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;
}
---
-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);
---
-<Article
- title={entry.data.title}
- description={'publication' in entry.data && entry.data.publication
- ? `${entry.data.publication.name} ${entry.data.publication.issue}-${entry.data.publication.volume}`
- : (entry.data.description ?? '')}
- publishedTime={entry.data.date.toISOString()}
- modifiedTime={entry.data.modified?.toISOString() ??
- entry.data.date.toISOString()}
- tags={entry.data.tags}
->
- <h2 data-pagefind-meta="title" data-pagefind-filter="category:podcast">
- {entry.data.title}
- </h2>
- <Metadata entryData={entry.data} />
+<EntryArticle entry={entry} category="podcast">
<Content />
-</Article>
+</EntryArticle>
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;
---
-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);
---
-<Article
- title={entry.data.title}
- description={entry.data.description ?? ''}
- publishedTime={entry.data.date.toISOString()}
- modifiedTime={entry.data.modified?.toISOString() ??
- entry.data.date.toISOString()}
- tags={entry.data.tags}
->
- <h2 data-pagefind-meta="title" data-pagefind-filter="category:review">
- {entry.data.title}
- </h2>
- <Metadata entryData={entry.data} />
+<EntryArticle entry={entry} category="review">
<Content />
-</Article>
+</EntryArticle>
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;
+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<string, string> {
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 },
+ }));
+ };
+}
-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;
width: 960,
height: 460,
};
-const CONTENT_BLOCK = {
- x: 40,
- y: 12,
- width: 880,
- height: 320,
-};
const BRAND_BLOCK = {
x: 360,
y: 382,
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<string, string | number>;
- 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<ArrayBuffer> {
- 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<Array<Font>> {
- 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),
+ });
}
+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;
--- /dev/null
+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 ?? '';
+}
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<SiteCollection>;
+export type SiteEntrySchema = InferEntrySchema<SiteCollection>;
export const menuItems: { title: string; url: string }[] = [
{ title: 'Articles', url: '/articles' },
--- /dev/null
+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<ArrayBuffer> {
+ 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<Array<Font>> {
+ 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,
+ })),
+ );
+}
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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,
+ };
+}