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
10 changes: 7 additions & 3 deletions app/(sidebar)/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 ? <MarkdownContent content={hero} /> : undefined}
>
<MarkdownContent content={postContent.content} />
<MarkdownContent content={body} />
</RenderPost>
<RelatedPosts posts={getRelatedPosts(slug)} />
</>
);
};
Expand Down
7 changes: 3 additions & 4 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
185 changes: 93 additions & 92 deletions components/posts/RenderPost.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
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;
prev: PostMetadata | null;
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;
Expand All @@ -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 = (
<h1 className="font-bold text-2xl md:text-4xl tracking-tight text-balance text-japanese-sumiiro dark:text-japanese-nyuhakushoku">
{title as string}
</h1>
);

return (
<div>
<div className="p-5 bg-japanese-hakuji dark:bg-dark-tag shadow-xs rounded-md dark:text-japanese-nyuhakushoku">
<div className="text-center mb-4">
{slug ? (
<Link href={`/posts/${slug}`}>
<h2 className="font-bold text-xl md:text-2xl text-japanese-sumiiro dark:text-japanese-nyuhakushoku">
{title as string}
</h2>
</Link>
) : (
<h2 className="font-bold text-xl md:text-2xl text-japanese-sumiiro dark:text-japanese-nyuhakushoku">
{title as string}
</h2>
)}
<div className="relative max-w-[65ch] mx-auto">
<header className="mb-10">
{slug ? <Link href={`/posts/${slug}`}>{heading}</Link> : heading}
{meta && <p className="mt-2 text-sm text-japanese-ginnezu">{meta}</p>}
</header>

<div className="flex items-center justify-center gap-2 mt-1 flex-wrap">
{date && (
<span className="text-japanese-ginnezu dark:text-japanese-ginnezu text-xs">
{new Date(date as string).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
)}
{tags.length > 0 && (
<>
<span className="text-japanese-shiraumenezu dark:text-japanese-sumiiro/50 text-xs">
·
</span>
{tags.map((tag) => (
<Link
href={`/tags/${tag}`}
key={tag}
className="text-xs text-japanese-ginnezu dark:text-japanese-ginnezu hover:text-japanese-sumiiro dark:hover:text-japanese-shironezu transition-colors duration-150"
>
#{tag}
</Link>
))}
</>
)}
{readingTime && (
<>
<span className="text-japanese-shiraumenezu dark:text-japanese-sumiiro/50 text-xs">
·
</span>
<span className="text-japanese-ginnezu dark:text-japanese-ginnezu text-xs">
{readingTime} min read
</span>
</>
)}
{hero && (
<div className={`${proseClasses} mb-8 [&_figure]:my-0`}>{hero}</div>
)}

{toc && <TableOfContents items={toc} />}

<article className={proseClasses}>{children}</article>

<footer className="mt-12 pt-5 border-t border-light-border dark:border-dark-border text-xs">
{(tags.length > 0 || slug) && (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-japanese-ginnezu">
{tags.map((tag) => (
<Link
href={`/tags/${tag}`}
key={tag}
className="hover:text-japanese-sumiiro dark:hover:text-japanese-shironezu transition-colors duration-150"
>
#{tag}
</Link>
))}
{slug && (
<>
<span className="text-japanese-shiraumenezu dark:text-japanese-sumiiro/50 text-xs">
·
</span>
<span className="ml-auto">
<PostViewTracker slug={slug} />
</>
</span>
)}
</div>
</div>
)}

<article className="prose dark:prose-invert dark:text-japanese-shironezu text-sm md:text-base leading-relaxed max-w-none 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">
{children}
</article>
</div>
{related && related.length > 0 && <RelatedPosts posts={related} />}

<div className="mt-8 flex justify-between text-xs gap-4">
{prev ? (
<div className="flex flex-col flex-1">
<p className="text-light-text/40 dark:text-dark-text/40 mb-1">
Previous
</p>
<Link
href={`/posts/${prev.slug}`}
className="text-light-text dark:text-dark-text hover:text-light-accent dark:hover:text-dark-accent transition-colors"
>
{prev.title}
</Link>
</div>
) : (
<div className="flex-1" />
)}
{next ? (
<div className="flex flex-col flex-1 text-right">
<p className="text-light-text/40 dark:text-dark-text/40 mb-1">
Next
</p>
<Link
href={`/posts/${next.slug}`}
className="text-light-text dark:text-dark-text hover:text-light-accent dark:hover:text-dark-accent transition-colors"
>
{next.title}
</Link>
</div>
) : (
<div className="flex-1" />
)}
</div>
<div className="mt-8 flex justify-between gap-4">
{prev ? (
<div className="flex flex-col flex-1">
<p className="text-light-text/40 dark:text-dark-text/40 mb-1">
Previous
</p>
<Link
href={`/posts/${prev.slug}`}
className="text-light-text dark:text-dark-text hover:text-light-accent dark:hover:text-dark-accent transition-colors"
>
{prev.title}
</Link>
</div>
) : (
<div className="flex-1" />
)}
{next ? (
<div className="flex flex-col flex-1 text-right">
<p className="text-light-text/40 dark:text-dark-text/40 mb-1">
Next
</p>
<Link
href={`/posts/${next.slug}`}
className="text-light-text dark:text-dark-text hover:text-light-accent dark:hover:text-dark-accent transition-colors"
>
{next.title}
</Link>
</div>
) : (
<div className="flex-1" />
)}
</div>
</footer>
</div>
);
};
Expand Down
88 changes: 88 additions & 0 deletions components/posts/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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. */}
<nav
aria-label="Table of contents"
className="hidden xl:block absolute left-full top-0 bottom-0 ml-12 w-52"
>
<ul className="sticky top-24 max-h-[75vh] overflow-y-auto space-y-1.5 text-xs border-l border-light-border dark:border-dark-border pl-4">
{items.map((item) => (
<li key={item.id} className={item.depth === 3 ? "pl-3" : ""}>
<a
href={`#${item.id}`}
className={`block transition-colors duration-150 ${
activeId === item.id
? "text-japanese-sumiiro dark:text-japanese-nyuhakushoku"
: "text-japanese-ginnezu hover:text-japanese-sumiiro dark:hover:text-japanese-shironezu"
}`}
>
{item.text}
</a>
</li>
))}
</ul>
</nav>

{/* Mobile/tablet: collapsed block between title and body. */}
<details className="xl:hidden mb-8 text-sm text-japanese-ginnezu">
<summary className="cursor-pointer select-none w-fit hover:text-japanese-sumiiro dark:hover:text-japanese-shironezu transition-colors duration-150">
Contents
</summary>
<ul className="mt-2 space-y-1.5 border-l border-light-border dark:border-dark-border pl-4">
{items.map((item) => (
<li key={item.id} className={item.depth === 3 ? "pl-3" : ""}>
<a
href={`#${item.id}`}
className="block hover:text-japanese-sumiiro dark:hover:text-japanese-shironezu transition-colors duration-150"
>
{item.text}
</a>
</li>
))}
</ul>
</details>
</>
);
};

export default TableOfContents;
2 changes: 2 additions & 0 deletions components/posts/markdownConfig.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 }],
];

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading