blog.hangerthem.com/app/metadata.ts
HangerThem 64ae30b545 feat(seo): sitemap, robots, canonical URLs, OG images and JSON-LD
- Add sitemap.ts and robots.ts
- pageMetadata() helper: consistent title, canonical URL, Open Graph,
  Twitter card and feed alternate on every page
- Generated OG images: a site card, and per post either the cover image
  or a branded title card (pre-rendered at build)
- BlogPosting JSON-LD on post pages
- Fail fast with a clear error when SITE_URL is missing

Content loading:
- Validate post frontmatter at build time and normalize dates to ISO
  (fixes invalid <time datetime> values)
- Add draft: true support (drafts only render in development)
- Cache markdown rendering so metadata and page share one render
- Replace the three copy-pasted about/projects/setup pages with a shared
  MarkdownPage component
2026-10-19 14:22:40 +02:00

84 lines
2.3 KiB
TypeScript

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(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: SITE_NAME,
description: SITE_DESCRIPTION,
url: SITE_URL,
siteName: SITE_NAME,
},
twitter: {
card: 'summary_large_image',
title: SITE_NAME,
description: SITE_DESCRIPTION,
},
appleWebApp: {
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,
},
}
}