Skip to content
Closed
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
87 changes: 87 additions & 0 deletions BLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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.",
image: "./og.png", // optional but recommended
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:
- `metadata.image` supports `./og.png` and is used for OG/Twitter cards.
- Asset URLs are served from `/content/...` automatically.

Gotchas:
- Asset responses are cached with `Cache-Control: public, max-age=31536000, immutable`.
Rename files when updating assets.
- `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. Add `image: "./og.png"` and place the file next to the post.
4. Reference images with `./` paths.
5. Keep `draft: true` until ready to publish.
335 changes: 335 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

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

const nextConfig: NextConfig = {
reactCompiler: true,
outputFileTracingIncludes: {
"/content/[...path]": ["./src/content/**/*"],
},
};

const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
remarkPlugins: [
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
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,27 @@
"@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",
"mime-types": "^2.1.35",
"next": "^16.1.6",
"reading-time": "^1.5.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"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": {
"@biomejs/biome": "2.3.13",
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/mime-types": "^2.1.4",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
Expand Down
120 changes: 104 additions & 16 deletions src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,140 @@
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 {
getBlogPost,
getBlogPosts,
getContentAssetBasePath,
importBlogPostModule,
parseBlogPostMetadata,
resolveImportPathForSlug,
resolveContentAssetPath,
} 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 ogImage = metadata.image
? absoluteUrl(resolveContentAssetPath(post.importPath, metadata.image))
: undefined;

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: ogImage ? [{ url: ogImage }] : undefined,
},
twitter: {
card: ogImage ? "summary_large_image" : "summary",
title: metadata.title,
description: metadata.summary,
images: ogImage ? [ogImage] : 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 assetBasePath = getContentAssetBasePath(importPath);

const resolvedImage = metadata.image
? absoluteUrl(resolveContentAssetPath(importPath, metadata.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: resolvedImage ? [resolvedImage] : undefined,
};

const mdxComponents = {
img: (props) => <MDXImage {...props} assetBasePath={assetBasePath} />,
Image: (props) => <MDXImage {...props} assetBasePath={assetBasePath} />,
} 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
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