diff --git a/app/(sidebar)/posts/[slug]/page.tsx b/app/(sidebar)/posts/[slug]/page.tsx
index dbefb681..1296c0fe 100644
--- a/app/(sidebar)/posts/[slug]/page.tsx
+++ b/app/(sidebar)/posts/[slug]/page.tsx
@@ -3,8 +3,9 @@ import { getRelatedPosts } from "@/utils/content/related";
import { getPostPreviewData } from "@/utils/content/preview";
import { SITE_URL } from "@/config/site";
import RenderPost from "@/components/posts/RenderPost";
-import RelatedPosts from "@/components/posts/RelatedPosts";
import MarkdownContent from "@/components/posts/MarkdownContent";
+import { extractToc } from "@/utils/content/toc";
+import { splitLeadingImage } from "@/utils/content/hero";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
@@ -68,6 +69,7 @@ const PostPage = async ({ params }: { params: Params }) => {
}
const postContent = getPostContent(slug);
+ const { hero, body } = splitLeadingImage(postContent.content);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
@@ -95,10 +97,12 @@ const PostPage = async ({ params }: { params: Params }) => {
next={post.next}
slug={slug}
wordcount={post.wordcount}
+ toc={extractToc(postContent.content)}
+ related={getRelatedPosts(slug)}
+ hero={hero ? : undefined}
>
-
+
-
>
);
};
diff --git a/app/globals.css b/app/globals.css
index 40636066..7676accd 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -304,10 +304,9 @@ body {
}
.prose img:not(.not-prose) {
- width: auto;
- max-width: 100%;
- max-height: 65vh;
- margin: 0 auto;
+ width: 100%;
+ height: auto;
+ margin: 0;
display: block;
border-radius: 0.2rem;
box-shadow:
diff --git a/components/posts/RenderPost.tsx b/components/posts/RenderPost.tsx
index 18ffafd8..bee828b1 100644
--- a/components/posts/RenderPost.tsx
+++ b/components/posts/RenderPost.tsx
@@ -1,8 +1,12 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { ParsedPost, PostMetadata } from "@/types/post";
+import type { RelatedPost } from "@/utils/content/related";
+import type { TocEntry } from "@/utils/content/toc";
import { extractTags } from "@/utils/content/tags";
import PostViewTracker from "./PostViewTracker";
+import RelatedPosts from "./RelatedPosts";
+import TableOfContents from "./TableOfContents";
interface RenderPostProps {
post: ParsedPost;
@@ -10,15 +14,24 @@ interface RenderPostProps {
next: PostMetadata | null;
slug: string | null;
wordcount?: number;
+ toc?: TocEntry[];
+ related?: RelatedPost[];
+ hero?: ReactNode;
children: ReactNode;
}
+const proseClasses =
+ "prose dark:prose-invert dark:text-japanese-shironezu text-base leading-relaxed max-w-none prose-headings:scroll-mt-8 selection:bg-japanese-unoharairo/30 dark:selection:bg-japanese-murasakisuishiyou/20 prose-a:text-japanese-sumiiro prose-a:decoration-japanese-soshoku/50 prose-a:hover:text-japanese-sumiiro/70 prose-a:hover:decoration-japanese-sumiiro prose-headings:text-japanese-sumiiro dark:prose-headings:text-japanese-murasakisuishiyou";
+
const RenderPost = ({
post,
prev,
next,
slug,
wordcount,
+ toc,
+ related,
+ hero,
children,
}: RenderPostProps) => {
const { title, date } = post.data;
@@ -28,106 +41,94 @@ const RenderPost = ({
? Math.max(1, Math.round(wordcount / 200))
: null;
+ const meta = [
+ date &&
+ new Date(date as string).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ readingTime && `${readingTime} min read`,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+
+ const heading = (
+
+ {title as string}
+
+ );
+
return (
-
-
-
- {slug ? (
-
-
- {title as string}
-
-
- ) : (
-
- {title as string}
-
- )}
+
+
+ {slug ? {heading} : heading}
+ {meta && {meta}
}
+
-
- {date && (
-
- {new Date(date as string).toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- day: "numeric",
- })}
-
- )}
- {tags.length > 0 && (
- <>
-
- ·
-
- {tags.map((tag) => (
-
- #{tag}
-
- ))}
- >
- )}
- {readingTime && (
- <>
-
- ·
-
-
- {readingTime} min read
-
- >
- )}
+ {hero && (
+
{hero}
+ )}
+
+ {toc &&
}
+
+
{children}
+
+
+ )}
-
- {children}
-
-
+ {related && related.length > 0 &&
}
-
- {prev ? (
-
-
- Previous
-
-
- {prev.title}
-
-
- ) : (
-
- )}
- {next ? (
-
-
- Next
-
-
- {next.title}
-
-
- ) : (
-
- )}
-
+
+ {prev ? (
+
+
+ Previous
+
+
+ {prev.title}
+
+
+ ) : (
+
+ )}
+ {next ? (
+
+
+ Next
+
+
+ {next.title}
+
+
+ ) : (
+
+ )}
+
+
);
};
diff --git a/components/posts/TableOfContents.tsx b/components/posts/TableOfContents.tsx
new file mode 100644
index 00000000..fb11b79f
--- /dev/null
+++ b/components/posts/TableOfContents.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { TocEntry } from "@/utils/content/toc";
+
+// Desktop-only rail rendered to the right of the post column. Scrollspy is
+// scroll-position based (topmost heading above the reading line) rather than
+// IntersectionObserver, so exactly one entry is active at all times.
+const TableOfContents = ({ items }: { items: TocEntry[] }) => {
+ const [activeId, setActiveId] = useState
(null);
+
+ useEffect(() => {
+ if (items.length === 0) return;
+
+ let ticking = false;
+ const update = () => {
+ ticking = false;
+ let current: string | null = null;
+ for (const item of items) {
+ const el = document.getElementById(item.id);
+ if (el && el.getBoundingClientRect().top <= 120) {
+ current = item.id;
+ }
+ }
+ setActiveId(current);
+ };
+ const onScroll = () => {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(update);
+ }
+ };
+
+ update();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, [items]);
+
+ if (items.length === 0) return null;
+
+ return (
+ <>
+ {/* Desktop: rail in the whitespace right of the post column. */}
+
+
+ {/* Mobile/tablet: collapsed block between title and body. */}
+
+
+ Contents
+
+
+
+ >
+ );
+};
+
+export default TableOfContents;
diff --git a/components/posts/markdownConfig.tsx b/components/posts/markdownConfig.tsx
index a46e06ed..b1ea1c14 100644
--- a/components/posts/markdownConfig.tsx
+++ b/components/posts/markdownConfig.tsx
@@ -1,6 +1,7 @@
import type { Components } from "react-markdown";
import type { PluggableList } from "unified";
import rehypeRaw from "rehype-raw";
+import rehypeSlug from "rehype-slug";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
@@ -11,6 +12,7 @@ import "katex/dist/katex.min.css";
export const remarkPlugins: PluggableList = [remarkMath, remarkGfm];
export const rehypePlugins: PluggableList = [
rehypeRaw,
+ rehypeSlug,
[rehypeKatex, { strict: false }],
];
diff --git a/package.json b/package.json
index 24edb515..0d5badbf 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"dotenv": "^16.4.5",
+ "github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"image-size": "^2.0.2",
"katex": "^0.16.22",
@@ -46,6 +47,7 @@
"react-markdown": "^9.0.1",
"rehype-katex": "^7.0.0",
"rehype-raw": "^7.0.0",
+ "rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0b39f5a5..ff00b38e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -46,6 +46,9 @@ importers:
dotenv:
specifier: ^16.4.5
version: 16.6.1
+ github-slugger:
+ specifier: ^2.0.0
+ version: 2.0.0
gray-matter:
specifier: ^4.0.3
version: 4.0.3
@@ -85,6 +88,9 @@ importers:
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
+ rehype-slug:
+ specifier: ^6.0.0
+ version: 6.0.0
rehype-stringify:
specifier: ^10.0.1
version: 10.0.1
@@ -2165,6 +2171,9 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
+ github-slugger@2.0.0:
+ resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -2239,6 +2248,9 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+ hast-util-heading-rank@3.0.0:
+ resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
+
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
@@ -2257,6 +2269,9 @@ packages:
hast-util-to-parse5@8.0.0:
resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==}
+ hast-util-to-string@3.0.1:
+ resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
+
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
@@ -3121,6 +3136,9 @@ packages:
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
+ rehype-slug@6.0.0:
+ resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
+
rehype-stringify@10.0.1:
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
@@ -5630,6 +5648,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -5719,6 +5741,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
+ github-slugger@2.0.0: {}
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -5806,6 +5830,10 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
+ hast-util-heading-rank@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -5874,6 +5902,10 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
+ hast-util-to-string@3.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
@@ -6977,6 +7009,14 @@ snapshots:
hast-util-raw: 9.1.0
vfile: 6.0.3
+ rehype-slug@6.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ github-slugger: 2.0.0
+ hast-util-heading-rank: 3.0.0
+ hast-util-to-string: 3.0.1
+ unist-util-visit: 5.0.0
+
rehype-stringify@10.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -7352,8 +7392,8 @@ snapshots:
tinyglobby@0.2.17:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
tinyrainbow@3.1.0: {}
diff --git a/utils/content/hero.test.ts b/utils/content/hero.test.ts
new file mode 100644
index 00000000..06d8da2b
--- /dev/null
+++ b/utils/content/hero.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { splitLeadingImage } from "./hero";
+
+describe("splitLeadingImage", () => {
+ it("splits a leading image from the body", () => {
+ const md = "\n\nFirst para.\n";
+ expect(splitLeadingImage(md)).toEqual({
+ hero: "",
+ body: "\nFirst para.\n",
+ });
+ });
+
+ it("skips blank lines before the image", () => {
+ const md = "\n\n\ntext";
+ expect(splitLeadingImage(md).hero).toBe("");
+ });
+
+ it("returns null hero when the post starts with text", () => {
+ const md = "Hello.\n\n\n";
+ expect(splitLeadingImage(md)).toEqual({ hero: null, body: md });
+ });
+
+ it("ignores images that share a line with other text", () => {
+ const md = "intro \n";
+ expect(splitLeadingImage(md).hero).toBeNull();
+ });
+});
diff --git a/utils/content/hero.ts b/utils/content/hero.ts
new file mode 100644
index 00000000..93958a05
--- /dev/null
+++ b/utils/content/hero.ts
@@ -0,0 +1,17 @@
+// A post that opens with a standalone image treats it as a hero: it renders
+// above the table of contents instead of below it.
+const IMAGE_LINE = /^!\[[^\]]*\]\([^)]*\)$/;
+
+export function splitLeadingImage(markdown: string): {
+ hero: string | null;
+ body: string;
+} {
+ const lines = markdown.split("\n");
+ let i = 0;
+ while (i < lines.length && lines[i].trim() === "") i++;
+
+ if (i < lines.length && IMAGE_LINE.test(lines[i].trim())) {
+ return { hero: lines[i], body: lines.slice(i + 1).join("\n") };
+ }
+ return { hero: null, body: markdown };
+}
diff --git a/utils/content/toc.test.ts b/utils/content/toc.test.ts
new file mode 100644
index 00000000..8da10dad
--- /dev/null
+++ b/utils/content/toc.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { extractToc } from "./toc";
+
+describe("extractToc", () => {
+ it("extracts h2 and h3 headings with github slugs", () => {
+ const md = "# Title\n\n## First Section\n\ntext\n\n### Sub Thing\n";
+ expect(extractToc(md)).toEqual([
+ { id: "first-section", text: "First Section", depth: 2 },
+ { id: "sub-thing", text: "Sub Thing", depth: 3 },
+ ]);
+ });
+
+ it("ignores headings inside fenced code blocks", () => {
+ const md = "## Real\n\n```md\n## Not a heading\n```\n\n## Also Real\n";
+ expect(extractToc(md).map((e) => e.text)).toEqual(["Real", "Also Real"]);
+ });
+
+ it("strips inline markdown from heading text", () => {
+ const md = "## Using `pnpm` with **force**\n\n## [A link](https://x.com)\n";
+ expect(extractToc(md)).toEqual([
+ { id: "using-pnpm-with-force", text: "Using pnpm with force", depth: 2 },
+ { id: "a-link", text: "A link", depth: 2 },
+ ]);
+ });
+
+ it("deduplicates repeated headings like github-slugger", () => {
+ const md = "## Notes\n\n## Notes\n";
+ expect(extractToc(md).map((e) => e.id)).toEqual(["notes", "notes-1"]);
+ });
+
+ it("ignores h1 and h4+", () => {
+ const md = "# Top\n\n#### Deep\n\n## Kept\n";
+ expect(extractToc(md).map((e) => e.text)).toEqual(["Kept"]);
+ });
+});
diff --git a/utils/content/toc.ts b/utils/content/toc.ts
new file mode 100644
index 00000000..1808a09c
--- /dev/null
+++ b/utils/content/toc.ts
@@ -0,0 +1,49 @@
+import GithubSlugger from "github-slugger";
+
+export interface TocEntry {
+ id: string;
+ text: string;
+ depth: 2 | 3;
+}
+
+// Mirror how rehype-slug sees a heading: plain text content, with inline
+// markdown syntax stripped. Keeps ids in sync with the rendered anchors.
+const stripInline = (text: string): string =>
+ text
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
+ .replace(/`([^`]*)`/g, "$1")
+ .replace(/(\*\*|__)(.*?)\1/g, "$2")
+ .replace(/(\*|_)(.*?)\1/g, "$2")
+ .replace(/~~(.*?)~~/g, "$1")
+ .replace(/<[^>]+>/g, "")
+ .trim();
+
+// Extracts ## and ### headings, skipping fenced code blocks.
+export function extractToc(markdown: string): TocEntry[] {
+ const slugger = new GithubSlugger();
+ const entries: TocEntry[] = [];
+ let inCodeBlock = false;
+
+ for (const line of markdown.split("\n")) {
+ if (/^\s*(```|~~~)/.test(line)) {
+ inCodeBlock = !inCodeBlock;
+ continue;
+ }
+ if (inCodeBlock) continue;
+
+ const match = line.match(/^(#{2,3})\s+(.+?)\s*#*\s*$/);
+ if (!match) continue;
+
+ const text = stripInline(match[2]);
+ if (!text) continue;
+
+ entries.push({
+ id: slugger.slug(text),
+ text,
+ depth: match[1].length as 2 | 3,
+ });
+ }
+
+ return entries;
+}