Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions BLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Blog Notes

Short guide to writing posts and avoiding common pitfalls.

## Content structure

- Preferred layout (folder per post):
- `src/content/blog/my-post/page.mdx`
- `src/content/blog/my-post/hero.png`
- Flat files also work (less ideal):
- `src/content/blog/my-post.mdx`

The slug is the folder name or filename.

## Required metadata

Each post must export `metadata`:

```mdx
export const metadata = {
title: "Post title",
date: "2025-02-01",
author: "Your Name",
summary: "Short description used for listings + meta tags.",
tags: ["tag", "tag"], // optional
updated: "2025-02-12", // optional
draft: false, // optional (true hides from listings/RSS)
}
```

Gotchas:
- `date` / `updated` must be valid ISO or `YYYY-MM-DD`. Invalid dates fail builds.
- `summary` is required and used for SEO + RSS.

## Co-located assets

Assets live next to the post and are referenced with relative paths:

```mdx
![Diagram](./diagram.png)

<Image src="./hero.png" width={1200} height={630} alt="Hero" />
```

Notes:
- Add `opengraph-image.*` next to the post for Open Graph (PNG/JPG/WEBP/AVIF/GIF).
- Add `twitter-image.*` next to the post for Twitter cards.
- Special files can also be `opengraph-image.tsx` / `twitter-image.tsx` to generate images dynamically.
- Markdown images and `<Image src="./...">` are converted into static imports automatically.

Gotchas:
- `next/image` is used only when width/height are provided and the image is local
(non-SVG). Otherwise it falls back to `<img>` with lazy loading.
- Remote images are not optimized unless you add them to Next's remote image config.

## Markdown features

- GFM tables and footnotes are enabled.
- Syntax highlighting uses Shiki via `rehype-pretty-code`.
- Headings get slugs and clickable anchors.
- Mermaid diagrams are supported with fenced blocks:

```md
```mermaid
flowchart LR
A --> B
```
```

If rendering fails, the raw code block is shown.

## RSS + SEO endpoints

- RSS feed: `/rss.xml`
- Sitemap: `/sitemap.xml`
- Robots: `/robots.txt`

Set `NEXT_PUBLIC_SITE_URL` in production for correct canonical URLs.

## Quick checklist

1. Create `src/content/blog/<slug>/page.mdx`.
2. Add `metadata` with `title`, `date`, `author`, `summary`.
3. (Optional) add `opengraph-image.png` and/or `twitter-image.png` next to the post.
4. Reference images with `./` paths (Markdown) or `src="./..."` (JSX).
5. Keep `draft: true` until ready to publish.
327 changes: 327 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

34 changes: 32 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "node:path";
import createMDX from "@next/mdx";
import type { NextConfig } from "next";

Expand All @@ -6,12 +7,41 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
outputFileTracingIncludes: {
"/blog/[slug]/opengraph-image": ["./src/content/**/*"],
"/blog/[slug]/twitter-image": ["./src/content/**/*"],
},
};

const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
remarkPlugins: [
path.join(process.cwd(), "src/lib/remark-static-image-imports.mjs"),
path.join(process.cwd(), "src/lib/remark-mermaid.mjs"),
"remark-gfm",
],
rehypePlugins: [
"rehype-slug",
[
"rehype-autolink-headings",
{
behavior: "wrap",
properties: {
className: ["heading-anchor"],
},
},
],
[
"rehype-pretty-code",
{
theme: {
light: "github-light",
dark: "github-dark",
},
keepBackground: false,
},
],
],
},
});

Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/typography": "^0.5.19",
"feed": "^4.2.2",
"mermaid": "^11.5.0",
"next": "^16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.1",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^1.29.1",
"three": "^0.182.0",
"unist-util-visit": "^5.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
12 changes: 12 additions & 0 deletions src/app/blog/[slug]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { resolveMetadataImageResponse } from "@/lib/blog-metadata-images";

export const runtime = "nodejs";

export default async function Image({
params,
}: {
params: Promise<{ slug: string }> | { slug: string };
}) {
const resolvedParams = await params;
return resolveMetadataImageResponse(resolvedParams.slug, "opengraph");
}
124 changes: 108 additions & 16 deletions src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,144 @@
import type { MDXComponents } from "mdx/types";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type { ComponentType } from "react";

import MDXImage from "@/components/mdx/MDXImage";
import {
findMetadataImageAsset,
getBlogPost,
getBlogPosts,
importBlogPostModule,
parseBlogPostMetadata,
resolveImportPathForSlug,
} from "@/lib/blog-utils";
import { formatDate } from "@/lib/date";
import { absoluteUrl, siteConfig } from "@/lib/site";

type PageParams = Promise<{ slug: string; importPath?: string }>;
type PageParams = Promise<{ slug: string }>;

type BlogPostModule = {
default: ComponentType;
default: ComponentType<{ components?: MDXComponents }>;
metadata: unknown;
};

export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map(({ slug, importPath }) => ({ slug, importPath }));
return posts.map(({ slug }) => ({ slug }));
}

export async function generateMetadata({
params,
}: {
params: PageParams;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getBlogPost(slug);

if (!post) return {};

const { metadata, date, updatedAt } = post;
const url = absoluteUrl(`/blog/${slug}`);
const ogAsset = await findMetadataImageAsset(post.importPath, "opengraph");
const twitterAsset = await findMetadataImageAsset(post.importPath, "twitter");
const ogImageUrl = ogAsset
? absoluteUrl(`/blog/${slug}/opengraph-image`)
: undefined;
const twitterImageUrl = twitterAsset
? absoluteUrl(`/blog/${slug}/twitter-image`)
: ogImageUrl;

return {
title: metadata.title,
description: metadata.summary,
keywords: metadata.tags,
alternates: {
canonical: url,
},
openGraph: {
type: "article",
title: metadata.title,
description: metadata.summary,
url,
siteName: siteConfig.name,
locale: siteConfig.locale,
publishedTime: date.toISOString(),
modifiedTime: updatedAt?.toISOString(),
authors: [metadata.author],
images: ogImageUrl ? [{ url: ogImageUrl }] : undefined,
},
twitter: {
card: twitterImageUrl ? "summary_large_image" : "summary",
title: metadata.title,
description: metadata.summary,
images: twitterImageUrl ? [twitterImageUrl] : undefined,
},
};
}

export default async function BlogPostPage({ params }: { params: PageParams }) {
const { slug, importPath } = await params;
const { slug } = await params;
const post = await getBlogPost(slug);

const resolvedImportPath =
importPath ?? (await resolveImportPathForSlug(slug));
if (!resolvedImportPath) notFound();
if (!post) notFound();

const module = (await importBlogPostModule<BlogPostModule>(
resolvedImportPath,
).catch(() => null)) as BlogPostModule | null;
const { importPath, metadata, date, updatedAt, readingTime } = post;

const module = (await importBlogPostModule<BlogPostModule>(importPath).catch(
() => null,
)) as BlogPostModule | null;

if (!module?.default) notFound();

const Content = module.default;
const metadata = parseBlogPostMetadata(module.metadata);
const url = absoluteUrl(`/blog/${slug}`);

const ogAsset = await findMetadataImageAsset(importPath, "opengraph");
const resolvedImageUrl = ogAsset
? absoluteUrl(`/blog/${slug}/opengraph-image`)
: undefined;

const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: metadata.title,
description: metadata.summary,
keywords: metadata.tags?.join(", "),
datePublished: date.toISOString(),
dateModified: (updatedAt ?? date).toISOString(),
author: {
"@type": "Person",
name: metadata.author,
},
url,
mainEntityOfPage: url,
image: resolvedImageUrl ? [resolvedImageUrl] : undefined,
};

const mdxComponents = {
img: (props) => <MDXImage {...props} />,
Image: (props) => <MDXImage {...props} />,
} satisfies MDXComponents;

return (
<article className="mx-auto flex max-w-2xl flex-col gap-6 px-6 py-16">
<header className="flex flex-col gap-2">
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD is required for SEO.
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<header className="flex flex-col gap-3">
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{metadata.title}
</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{metadata.date} · {metadata.author}
{metadata.summary}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-500">
<time dateTime={date.toISOString()}>{formatDate(date)}</time> ·{" "}
{metadata.author} · {readingTime}
</p>
</header>
<div className="prose prose-zinc dark:prose-invert">
<Content />
<Content components={mdxComponents} />
</div>
</article>
);
Expand Down
12 changes: 12 additions & 0 deletions src/app/blog/[slug]/twitter-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { resolveMetadataImageResponse } from "@/lib/blog-metadata-images";

export const runtime = "nodejs";

export default async function Image({
params,
}: {
params: Promise<{ slug: string }> | { slug: string };
}) {
const resolvedParams = await params;
return resolveMetadataImageResponse(resolvedParams.slug, "twitter");
}
18 changes: 15 additions & 3 deletions src/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";

import { getBlogPosts } from "@/lib/blog-utils";
import { formatDate } from "@/lib/date";
import { siteConfig } from "@/lib/site";

export const metadata: Metadata = {
title: "Blog",
description: `Notes on what ${siteConfig.author.name} is building and learning.`,
};

export default async function BlogIndexPage() {
const posts = await getBlogPosts();
Expand All @@ -17,16 +25,20 @@ export default async function BlogIndexPage() {
</div>
<ul className="flex flex-col gap-6">
{posts.length > 0 ? (
posts.map(({ slug, metadata }) => (
posts.map(({ slug, metadata, date, readingTime }) => (
<li key={slug} className="flex flex-col gap-1">
<Link
href={`/blog/${slug}`}
className="text-lg font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{metadata.title}
</Link>
<span className="text-sm text-zinc-500 dark:text-zinc-500">
{metadata.date} · {metadata.author}
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{metadata.summary}
</p>
<span className="text-xs text-zinc-500 dark:text-zinc-500">
<time dateTime={date.toISOString()}>{formatDate(date)}</time> ·{" "}
{metadata.author} · {readingTime}
</span>
</li>
))
Expand Down
Loading