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}
+ +
+ {(tags.length > 0 || slug) && ( +
+ {tags.map((tag) => ( + + #{tag} + + ))} {slug && ( - <> - - · - + - + )}
-
+ )} -
- {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 = "![The School of Athens](/images/athens.jpeg)\n\nFirst para.\n"; + expect(splitLeadingImage(md)).toEqual({ + hero: "![The School of Athens](/images/athens.jpeg)", + body: "\nFirst para.\n", + }); + }); + + it("skips blank lines before the image", () => { + const md = "\n\n![alt](/img.png)\ntext"; + expect(splitLeadingImage(md).hero).toBe("![alt](/img.png)"); + }); + + it("returns null hero when the post starts with text", () => { + const md = "Hello.\n\n![alt](/img.png)\n"; + expect(splitLeadingImage(md)).toEqual({ hero: null, body: md }); + }); + + it("ignores images that share a line with other text", () => { + const md = "intro ![alt](/img.png)\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; +}