]> git.otsuka.systems Git - cotsuka.github.io/commitdiff
create reusable components
authorCameron Otsuka <cameron@otsuka.haus>
Tue, 25 Aug 2026 18:43:28 +0000 (11:43 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Tue, 25 Aug 2026 18:43:28 +0000 (11:43 -0700)
20 files changed:
src/components/entryarticle.astro [new file with mode: 0644]
src/components/head/base.astro
src/components/ratingdistribution.astro
src/components/ui/contentlist.astro
src/content.config.ts
src/pages/articles/[date]-[id]/index.astro
src/pages/articles/[date]-[id]/opengraph.png.ts
src/pages/feed.xml.ts
src/pages/podcasts/[id]/index.astro
src/pages/podcasts/[id]/opengraph.png.ts
src/pages/reviews/[type]/[id]/index.astro
src/pages/reviews/[type]/[id]/opengraph.png.ts
src/utils/generateContentUrl.ts
src/utils/generateOpenGraphImage.ts
src/utils/generateStarRating.ts
src/utils/getEntrySubtitle.ts [new file with mode: 0644]
src/utils/globals.ts
src/utils/ogFonts.ts [new file with mode: 0644]
src/utils/ogLayout.test.ts [new file with mode: 0644]
src/utils/ogLayout.ts [new file with mode: 0644]

diff --git a/src/components/entryarticle.astro b/src/components/entryarticle.astro
new file mode 100644 (file)
index 0000000..ca3dac2
--- /dev/null
@@ -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;
+---
+
+<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>
index 12b091730e782538402397148c6ddc1130adb377..2d7daffba19a597cee9ee44336ec84b6a19bc6e0 100644 (file)
@@ -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;
 <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" />
index 8ce1ecb1309c30f90aedc931ea7c5dc8e3790d87..8e6bc7d39208941e5fec71055307d6f23dbfff6d 100644 (file)
@@ -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<number>({ length: ratings.length }).fill(0);
 
 for (const { data } of reviews) {
   counts[data.rating - 1]++;
index eb9b134a284de974cd7eb055a967b8d27a8642ec..09585cc2eed5572ee1f01ceed01be1dab9b1a342 100644 (file)
@@ -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 =
         </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>
       </>
     ))
index ce469ee54668dfa449d574f058d87556d957be8d..ef82f6b2e80ad5d560b0151839e346f33f9a8852 100644 (file)
@@ -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']),
     }),
 });
index 6ee1a92f1719fac20777b383990541292cae5a6f..b92e07842f768a180a5b9cbb03e1f1240558d5fd 100644 (file)
@@ -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);
 ---
 
-<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>
index 7ac35e43763108a13fe5cfb1971983b1c76688d3..d5fa49d4ada55deadf361736693b4a7ec98a6fe5 100644 (file)
@@ -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;
index 6daf3f52f53d3948b25266eafdeb986160b73a5d..999ecc1524a91d8df296f3d51b1c28fcd352df59 100644 (file)
@@ -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;
     }
 
index 19c0aecba98136d44213911edce146181de530c9..497395ad6d312caf4946b9b6c7b3406df52960b1 100644 (file)
@@ -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);
 ---
 
-<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>
index 08df090c9fda3b81b474b916d8a402a92cf63ae2..c9ed5cc2aa205b854f1a4f65012fa81613f05b24 100644 (file)
@@ -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;
index c634180a036e4905b554e8bab6a5635684357df3..ae9e5c9a9ecbf8658c60067ddeb77a7b54555e82 100644 (file)
@@ -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);
 ---
 
-<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>
index 6d7df3491b952fa10eebbd31904494210d8c5099..ea0022e68265e5d9e033e12f3e34f7c6d7a8d62d 100644 (file)
@@ -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;
index 1b40046852a8e4dccfe57ac2b3da8523d34ccc26..2ab90f15a447a7efa142eca72826336975cc8d2a 100644 (file)
@@ -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<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 },
+    }));
+  };
+}
index 6e0c0713c551ed58aa7e40280f0b7fd371833d20..eca4af9001d5f56f4eb9c599d5778a921c1f12fe 100644 (file)
@@ -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<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),
+  });
 }
index b85a6b3e9562c5807cae2995a034bbc36be0c536..663d97271f5ab4e3b4507e8b6edecad1dc7f58e8 100644 (file)
@@ -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 (file)
index 0000000..67385fc
--- /dev/null
@@ -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 ?? '';
+}
index 9f04544b9245846ae09d31dab362fe9e6affec47..fab0825472ce99e48a1aeadd2dd384c2d9f90c12 100644 (file)
@@ -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<SiteCollection>;
+export type SiteEntrySchema = InferEntrySchema<SiteCollection>;
 
 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 (file)
index 0000000..a80562a
--- /dev/null
@@ -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<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,
+    })),
+  );
+}
diff --git a/src/utils/ogLayout.test.ts b/src/utils/ogLayout.test.ts
new file mode 100644 (file)
index 0000000..582568d
--- /dev/null
@@ -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 (file)
index 0000000..8726773
--- /dev/null
@@ -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,
+  };
+}