blog.hangerthem.com/app/feed.xml/route.ts
HangerThem fd96e4b7fc 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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:22:40 +02:00

67 lines
2 KiB
TypeScript

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 lastModified = (p: PostMeta) => new Date(p.updated ?? p.date)
function escapeXml(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export async function GET(req: Request) {
const posts = getAllPostsMeta()
const updated =
posts.length > 0
? new Date(Math.max(...posts.map((p) => lastModified(p).getTime()))).toISOString()
: new Date().toISOString()
const entries = posts
.map((p) => {
const url = `${SITE_URL}/posts/${p.slug}`
const date = new Date(p.date).toISOString()
const modified = lastModified(p).toISOString()
return `
<entry>
<title>${escapeXml(p.title)}</title>
<link href="${url}" rel="alternate" type="text/html" />
<id>${url}</id>
<published>${date}</published>
<updated>${modified}</updated>
<content type="text/html" src="${url}" />
<summary>${escapeXml(p.description)}</summary>
</entry>`
})
.join('')
const feed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>${SITE_URL}/</id>
<title>${escapeXml(SITE_NAME)}</title>
<updated>${updated}</updated>
<author>
<name>${escapeXml(AUTHOR.name)}
<uri>${AUTHOR.url}</uri></name>
</author>
<link href="${SITE_URL}/feed.xml" rel="self" type="application/atom+xml" />
<link href="${SITE_URL}" rel="alternate" type="text/html" />
<rights>© ${new Date().getFullYear()} ${escapeXml(AUTHOR.name)}</rights>
${entries}
</feed>`
const accept = req.headers.get('accept') || ''
const contentType = accept.includes('application/atom+xml')
? 'application/atom+xml; charset=utf-8'
: 'application/xml; charset=utf-8'
return new Response(feed, {
headers: { 'Content-Type': contentType },
})
}