diff --git a/app/(withRecent)/categories/[categorySlug]/page.tsx b/app/(withRecent)/categories/[categorySlug]/page.tsx index 7eec203..588a4a6 100644 --- a/app/(withRecent)/categories/[categorySlug]/page.tsx +++ b/app/(withRecent)/categories/[categorySlug]/page.tsx @@ -1,4 +1,4 @@ -import { metadata } from '@/app/metadata' +import { pageMetadata } from '@/app/metadata' import { getAllCategories, getCategoryBySlug, getPostsByCategory } from '@/lib/categories' import { dateFormatter } from '@/utils/time' import type { Metadata } from 'next' @@ -26,17 +26,11 @@ export async function generateMetadata({ params }: CategoryPageProps): Promise { - return { - title: `Categories | ${metadata.title!.toString()}`, - description: 'List of categories', - openGraph: { - type: 'website', - title: `Categories | ${metadata.title!.toString()}`, - description: 'List of categories', - url: process.env.SITE_URL + '/categories', - siteName: metadata.title!.toString(), - }, - } + return pageMetadata({ + title: 'Categories', + description: 'All post categories on the blog', + path: '/categories', + }) } export default function CategoriesPage() { diff --git a/app/(withRecent)/page.tsx b/app/(withRecent)/page.tsx index 9e90794..64b978f 100644 --- a/app/(withRecent)/page.tsx +++ b/app/(withRecent)/page.tsx @@ -1,5 +1,8 @@ import { getAllPostsMeta } from '@/lib/posts' import { Post } from '@/components/post/Post' +import type { Metadata } from 'next' + +export const metadata: Metadata = { alternates: { canonical: '/' } } export default function Homepage() { const posts = getAllPostsMeta() diff --git a/app/(withRecent)/tags/[tagSlug]/page.tsx b/app/(withRecent)/tags/[tagSlug]/page.tsx index 83588d2..e05d4f4 100644 --- a/app/(withRecent)/tags/[tagSlug]/page.tsx +++ b/app/(withRecent)/tags/[tagSlug]/page.tsx @@ -1,4 +1,4 @@ -import { metadata } from '@/app/metadata' +import { pageMetadata } from '@/app/metadata' import { getAllTags, getPostsByTag, getTagBySlug } from '@/lib/tags' import { dateFormatter } from '@/utils/time' import type { Metadata } from 'next' @@ -26,17 +26,11 @@ export async function generateMetadata({ params }: TagPageProps): Promise { - return { - title: `Tags | ${metadata.title!.toString()}`, - description: 'List of tags', - openGraph: { - type: 'website', - title: `Tags | ${metadata.title!.toString()}`, - description: 'List of tags', - url: process.env.SITE_URL + '/tags', - siteName: metadata.title!.toString(), - }, - } + return pageMetadata({ title: 'Tags', description: 'All tags used on the blog', path: '/tags' }) } export default function TagsPage() { diff --git a/app/about/page.tsx b/app/about/page.tsx index afecf06..8ce158e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,35 +1,7 @@ -import { metadata } from '@/app/metadata' -import { TableOfContents } from '@/components/post/TableOfContents' -import { getPostBySlug } from '@/lib/posts' -import type { Metadata } from 'next' +import { MarkdownPage, markdownPageMetadata } from '@/components/content/MarkdownPage' -export async function generateMetadata(): Promise { - const { meta } = await getPostBySlug('about', 'content/pages') - return { - title: `${meta.title} | ${metadata.title!.toString()}`, - description: meta.description, - openGraph: { - type: 'website', - title: `${meta.title} | ${metadata.title!.toString()}`, - description: meta.description, - url: process.env.SITE_URL + `/about`, - siteName: metadata.title!.toString(), - }, - } -} - -export default async function AboutPage() { - const { meta, content, tableOfContents } = await getPostBySlug('about', 'content/pages') - - return ( -
-
-

{meta.title}

-

{meta.description}

-
{content}
-
- - -
- ) +export const generateMetadata = () => markdownPageMetadata('about') + +export default function AboutPage() { + return } diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts index 1c027c1..9115efa 100644 --- a/app/feed.xml/route.ts +++ b/app/feed.xml/route.ts @@ -1,11 +1,9 @@ -import { metadata } from '@/app/metadata' +import { AUTHOR, SITE_NAME, SITE_URL } from '@/app/metadata' import { getAllPostsMeta } from '@/lib/posts' import type { PostMeta } from '@/types/Post.type' export const dynamic = 'force-static' -const SITE_URL = process.env.SITE_URL - const lastModified = (p: PostMeta) => new Date(p.updated ?? p.date) function escapeXml(str: string) { @@ -46,14 +44,15 @@ export async function GET(req: Request) { const feed = ` ${SITE_URL}/ - ${escapeXml(metadata.title!.toString())} + ${escapeXml(SITE_NAME)} ${updated} - ${escapeXml(metadata.creator!.toString())} + ${escapeXml(AUTHOR.name)} + ${AUTHOR.url} - © ${new Date().getFullYear()} Frank Borisjuk + © ${new Date().getFullYear()} ${escapeXml(AUTHOR.name)} ${entries} ` diff --git a/app/metadata.ts b/app/metadata.ts index 86a40ff..fe2cb6a 100644 --- a/app/metadata.ts +++ b/app/metadata.ts @@ -1,20 +1,84 @@ import { Metadata } from 'next' +if (!process.env.SITE_URL) { + throw new Error('SITE_URL environment variable is not set (e.g. SITE_URL=https://example.com)') +} + +export const SITE_URL = process.env.SITE_URL.replace(/\/$/, '') +export const SITE_NAME = 'Ephemeris' +export const SITE_DESCRIPTION = 'A blog about me and my interests' +export const AUTHOR = { name: 'Frank Borisjuk', handle: 'hangerthem', url: SITE_URL + '/about' } + +// Child segments replace `alternates` wholesale, so every page re-declares the feed link +const feedAlternate = { 'application/atom+xml': [{ url: '/feed.xml', title: SITE_NAME }] } + export const metadata: Metadata = { - metadataBase: new URL(process.env.SITE_URL!), - title: 'Ephemeris', - description: 'A blog about me and my interests', - creator: 'hangerthem', + metadataBase: new URL(SITE_URL), + title: SITE_NAME, + description: SITE_DESCRIPTION, + creator: AUTHOR.handle, + authors: [{ name: AUTHOR.name, url: AUTHOR.url }], + alternates: { types: feedAlternate }, openGraph: { type: 'website', - title: 'Ephemeris', - description: 'A blog about me and my interests', - url: process.env.SITE_URL, - siteName: 'Ephemeris', + title: SITE_NAME, + description: SITE_DESCRIPTION, + url: SITE_URL, + siteName: SITE_NAME, + }, + twitter: { + card: 'summary_large_image', + title: SITE_NAME, + description: SITE_DESCRIPTION, }, appleWebApp: { - title: 'Ephemeris', + title: SITE_NAME, statusBarStyle: 'default', capable: true, }, } + +type PageMetadataOptions = { + title: string + description: string + /** Path relative to the site root, starting with a slash. */ + path: string + /** Open Graph / Twitter image URL. Defaults to the generated site card. */ + image?: string + openGraph?: Metadata['openGraph'] +} + +// Generated by app/opengraph-image.tsx. Child segments that set `openGraph` don't inherit +// file-based images, and explicit images win over them, so every page names its image here. +const defaultOgImage = '/opengraph-image' + +/** Builds consistent page metadata (title, canonical URL, Open Graph and Twitter card). */ +export function pageMetadata({ + title, + description, + path, + image = defaultOgImage, + openGraph, +}: PageMetadataOptions): Metadata { + const fullTitle = `${title} | ${SITE_NAME}` + return { + title: fullTitle, + description, + alternates: { canonical: path, types: feedAlternate }, + openGraph: { + type: 'website', + title: fullTitle, + description, + url: SITE_URL + path, + siteName: SITE_NAME, + images: image, + ...openGraph, + }, + twitter: { + card: 'summary_large_image', + title: fullTitle, + description, + images: image, + }, + } +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 0000000..344b1ec --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,10 @@ +import { SITE_DESCRIPTION, SITE_NAME } from '@/app/metadata' +import { ogImageSize, renderOgCard } from '@/lib/og-image' + +export const alt = SITE_NAME +export const size = ogImageSize +export const contentType = 'image/png' + +export default function OpengraphImage() { + return renderOgCard({ title: SITE_NAME, description: SITE_DESCRIPTION }) +} diff --git a/app/posts/[slug]/opengraph-image.tsx b/app/posts/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..588a99f --- /dev/null +++ b/app/posts/[slug]/opengraph-image.tsx @@ -0,0 +1,19 @@ +import { getAllPostSlugs, getPostMeta } from '@/lib/posts' +import { ogImageSize, renderOgCard, renderOgCover } from '@/lib/og-image' + +export const size = ogImageSize +export const contentType = 'image/png' + +export function generateStaticParams() { + return getAllPostSlugs().map((slug) => ({ slug })) +} + +/** Uses the post's cover image when it has one, otherwise a generated title card. */ +export default async function OpengraphImage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const meta = getPostMeta(slug) + return ( + (meta.coverImage && renderOgCover(meta.coverImage)) ?? + renderOgCard({ title: meta.title, description: meta.description, eyebrow: meta.category }) + ) +} diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx index ee750ab..4f5d993 100644 --- a/app/posts/[slug]/page.tsx +++ b/app/posts/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { metadata } from '@/app/metadata' +import { AUTHOR, pageMetadata, SITE_NAME, SITE_URL } from '@/app/metadata' import { ImageWrapper } from '@/components/content/ImageWrapper' import { ShareLinks } from '@/components/post/ShareLinks' import { TableOfContents } from '@/components/post/TableOfContents' @@ -21,29 +21,52 @@ export async function generateMetadata({ }): Promise { const { slug } = await params const { meta } = await getPostBySlug(slug) - return { - title: `${meta.title} | ${metadata.title!.toString()}`, + return pageMetadata({ + title: meta.title, description: meta.description, + path: `/posts/${slug}`, + image: `/posts/${slug}/opengraph-image`, openGraph: { type: 'article', - title: `${meta.title} | ${metadata.title!.toString()}`, - description: meta.description, - url: process.env.SITE_URL + `/posts/${slug}`, - images: meta.coverImage ? [`/posts/${meta.coverImage}`] : undefined, + publishedTime: meta.date, + modifiedTime: meta.updated, + authors: [AUTHOR.url], + section: meta.category, tags: meta.tags, - ...(meta.updated && { modifiedTime: new Date(meta.updated).toISOString() }), - siteName: metadata.title!.toString(), }, - } + }) } export default async function Post({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params const { meta, content, tableOfContents } = await getPostBySlug(slug) const { next, previous } = getNextAndPreviousPosts(slug) + const url = `${SITE_URL}/posts/${slug}` + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + headline: meta.title, + description: meta.description, + url, + mainEntityOfPage: url, + datePublished: meta.date, + dateModified: meta.updated ?? meta.date, + image: `${url}/opengraph-image`, + articleSection: meta.category, + keywords: meta.tags.join(', '), + author: { '@type': 'Person', name: AUTHOR.name, url: AUTHOR.url }, + publisher: { '@type': 'Person', name: AUTHOR.name, url: AUTHOR.url }, + isPartOf: { '@type': 'Blog', name: SITE_NAME, url: SITE_URL }, + } return (
+