diff --git a/public/images/favicon.png b/public/images/favicon.png new file mode 100644 index 0000000..5656cc8 Binary files /dev/null and b/public/images/favicon.png differ diff --git a/public/images/og.png b/public/images/og.png new file mode 100644 index 0000000..5c62fb9 Binary files /dev/null and b/public/images/og.png differ diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index ad1d8c7..5991d93 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -3,6 +3,7 @@ import { getAllPosts, getPostBySlug } from "@/lib/blog"; import { siteConfig } from "@/lib/config"; import { MDXRemote } from "next-mdx-remote/rsc"; import { mdxComponents } from "@/components/blog/MDXComponents"; +import { JsonLd } from "@/components/JsonLd"; import rehypeHighlight from "rehype-highlight"; import Link from "next/link"; import type { Metadata } from "next"; @@ -19,9 +20,29 @@ export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; const post = getPostBySlug(slug); if (!post) return {}; + + const url = `${siteConfig.url}/blog/${slug}`; + const image = post.image ?? siteConfig.seo.ogImage; + return { - title: `${post.title} | ${siteConfig.name}`, + title: post.title, description: post.description, + alternates: { canonical: `/blog/${slug}` }, + openGraph: { + type: "article", + title: post.title, + description: post.description, + url, + publishedTime: post.date || undefined, + authors: [siteConfig.name], + images: [{ url: image, alt: post.title }], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.description, + images: [image], + }, }; } @@ -35,8 +56,31 @@ export default async function BlogPostPage({ params }: Props) { const prev = idx < allPosts.length - 1 ? allPosts[idx + 1] : null; const next = idx > 0 ? allPosts[idx - 1] : null; + const url = `${siteConfig.url}/blog/${slug}`; + const articleSchema = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + description: post.description, + datePublished: post.date || undefined, + author: { "@type": "Person", name: siteConfig.name, url: siteConfig.url }, + image: post.image ?? siteConfig.seo.ogImage, + mainEntityOfPage: url, + url, + }; + const breadcrumbSchema = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Blog", item: `${siteConfig.url}/blog` }, + { "@type": "ListItem", position: 2, name: post.title, item: url }, + ], + }; + return (
+ + v.startsWith("http"), +); + +const personSchema = { + "@context": "https://schema.org", + "@type": "Person", + name: siteConfig.name, + url: siteConfig.url, + jobTitle: siteConfig.seo.jobTitle, + description: siteConfig.description, + ...(sameAs.length ? { sameAs } : {}), +}; + +const websiteSchema = { + "@context": "https://schema.org", + "@type": "WebSite", + name: siteConfig.name, + url: siteConfig.url, + author: { "@type": "Person", name: siteConfig.name }, +}; + const inter = Inter({ variable: "--font-inter", subsets: ["latin"], @@ -19,9 +42,13 @@ const jetbrainsMono = JetBrains_Mono({ }); export const metadata: Metadata = { - title: siteConfig.name, + title: { + default: siteConfig.name, + template: `%s | ${siteConfig.name}`, + }, description: siteConfig.description, metadataBase: new URL(siteConfig.url), + alternates: { canonical: "/" }, openGraph: { title: siteConfig.name, description: siteConfig.description, @@ -30,7 +57,7 @@ export const metadata: Metadata = { type: "website", images: [ { - url: "/images/og.png", + url: siteConfig.seo.ogImage, width: 1200, height: 630, alt: siteConfig.name, @@ -41,7 +68,7 @@ export const metadata: Metadata = { card: "summary_large_image", title: siteConfig.name, description: siteConfig.description, - images: ["/images/og.png"], + images: [siteConfig.seo.ogImage], }, icons: { icon: [ @@ -66,6 +93,8 @@ export default function RootLayout({ className={`${inter.variable} ${jetbrainsMono.variable}`} > + + ${title}. ${description} + +## Pages + +- [Home](${url}/): About, projects, and contact for ${name} +- [Blog](${url}/blog): Posts on code, design, and building things on the web +- [Stack](${url}/stack): Self-hosted services and homelab infrastructure +- [Interests](${url}/interests): Live feed of activity across the web + +## Blog posts +${posts + .map((p) => `- [${p.title}](${url}/blog/${p.slug}): ${p.description}`) + .join("\n")} +`; + + return new Response(body, { + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..4e05301 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,17 @@ +import type { MetadataRoute } from "next"; +import { siteConfig } from "@/lib/config"; + +// ponytail: single allow-all rule covers AI crawlers (GPTBot, PerplexityBot, +// ClaudeBot, Google-Extended, etc.) too — we WANT them for GEO. Add per-bot +// rules only if a specific crawler ever needs blocking. +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: "/api/", + }, + sitemap: `${siteConfig.url}/sitemap.xml`, + host: siteConfig.url, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..7ecc437 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,23 @@ +import type { MetadataRoute } from "next"; +import { siteConfig } from "@/lib/config"; +import { getAllPosts } from "@/lib/blog"; + +export default function sitemap(): MetadataRoute.Sitemap { + const base = siteConfig.url; + + const staticRoutes = ["", "/blog", "/stack", "/interests"].map((path) => ({ + url: `${base}${path}`, + lastModified: new Date(), + changeFrequency: "weekly" as const, + priority: path === "" ? 1 : 0.7, + })); + + const posts = getAllPosts().map((post) => ({ + url: `${base}/blog/${post.slug}`, + lastModified: post.date ? new Date(post.date) : new Date(), + changeFrequency: "monthly" as const, + priority: 0.6, + })); + + return [...staticRoutes, ...posts]; +} diff --git a/src/app/stack/page.tsx b/src/app/stack/page.tsx index 357f812..bfc9ced 100644 --- a/src/app/stack/page.tsx +++ b/src/app/stack/page.tsx @@ -1,10 +1,10 @@ -import { siteConfig } from "@/lib/config"; import type { Metadata } from "next"; import { StackPageClient } from "./StackPageClient"; export const metadata: Metadata = { - title: `Stack | ${siteConfig.name}`, + title: "Stack", description: "Self-hosted services and infrastructure I run.", + alternates: { canonical: "/stack" }, }; export default function StackPage() { diff --git a/src/components/JsonLd.tsx b/src/components/JsonLd.tsx new file mode 100644 index 0000000..829a3d0 --- /dev/null +++ b/src/components/JsonLd.tsx @@ -0,0 +1,9 @@ +// Renders a JSON-LD structured-data script. Used for Person/WebSite/BlogPosting. +export function JsonLd({ data }: { data: Record }) { + return ( +