Personal blog focused on interaction and animation experiments. Next.js 15
App Router + MDX, styled after benji.org (ported from the benji replica
and agentation projects), deployed on Cloudflare Workers with D1.
Detailed docs (中文): docs/architecture.md covers the full technical architecture and deployment pitfalls; docs/selection-contract.md specifies the data-block/data-atomic contract and the custom selection engine built on it.
pnpm dev # Next dev server (D1 binding via miniflare)
pnpm build # Next production build (posts must be SSG)
pnpm preview # OpenNext build + local workerd preview
pnpm deploy # OpenNext build + deploy to Cloudflare
pnpm db:migrate:local # apply D1 migrations locally
pnpm db:migrate:remote # apply D1 migrations to the remote databaseAdd content/posts/<slug>.mdx. Frontmatter (validated by zod in
src/lib/content.ts):
---
title: Post title # required
date: 2026-07-06 # required, ISO date
description: One sentence. # required (used in lists, RSS, OpenGraph)
tags: [meta] # optional
draft: true # optional; hidden in production builds
math: true # optional; enables KaTeX ($...$ and $$...$$)
---Everything in GFM works: footnotes, tables, task lists. Code fences are
highlighted at build time by shiki (rehype-pretty-code, github-light);
```tsx title="file.tsx" adds a title bar. A copy button is added on the
client.
<Figure src alt caption width height />— block image (markdownrenders the same way)<Video src caption poster autoPlay /><Demo caption>…</Demo>— wrapper for interactive client components; guarantees thedata-atomiccontract<Notation label variant>…</Notation>— rough-notation bracket + floating label on scroll<SectionHeading id title />— hairline divider with inset label<CTACard href meta>…</CTACard><LikeButton />,<ViewCounter />— D1-backed stats islands (slug comes from context; they also render in the post header by default)<Poll id="..." />— D1-backed one-vote poll; polls are defined insrc/lib/polls.ts(single source for both the component and worker-side vote validation)<Chat />— live chat room over a WebSocket to the standaloneweave-chatworker (Durable Object + hibernation API; single fixed room, last 100 messages persisted in the DO's SQLite storage; nicknames are exclusive while online — a duplicate join is closed with code 4409)
Posts are fully static (SSG, dynamic = "error"); interactivity comes from
client islands that call /api/posts/[slug]/{stats,view,like} and
/api/polls/[pollId] route handlers backed by D1 (src/lib/db.ts is the
only file that touches the database). Slug validation in the worker uses the
build-time allowlist src/lib/post-index.json (regenerated by
scripts/gen-content-index.mjs on every build — the worker has no
content/ filesystem); poll/option validation uses src/lib/polls.ts.
Adding a new interactive component for use in post bodies:
- Write a
"use client"component undersrc/components/mdx/(useusePostSlug()fromPostProviderif it needs to know its post). - If it needs the database: add queries to
src/lib/db.ts, a migration undermigrations/, and a route handler undersrc/app/api/. - Register it in
mdx-components.tsxso posts can use it without imports. - Embed it inside
<Demo>in the post (or self-markdata-atomicand add the name toATOMIC_COMPONENTSinrehype-data-block.ts).
<Poll> is the reference implementation of this recipe.
<Chat> deliberately does NOT go through the blog worker: Next route
handlers can't return WebSocket 101 upgrades, and demo components should
stay decoupled from the core. The backend is a standalone worker in
chat-worker/ (own wrangler config, own deploy, Durable Object ChatRoom
with the WebSocket Hibernation API). The wire contract lives in
chat-worker/src/protocol.ts and is imported by both the DO and the client
component. The blog's build/deploy pipeline is untouched (chat-worker/ is
excluded from the blog tsconfig); removing the chat means deleting
chat-worker/, Chat.tsx, its CSS block and the scope registration.
npx wrangler dev --config chat-worker/wrangler.jsonc --port 8788 # local
npx wrangler deploy --config chat-worker/wrangler.jsonc # deployIn next dev the component connects to http://localhost:8788 (override
with NEXT_PUBLIC_CHAT_ORIGIN); in production it connects to
https://chat.xinghan.me, which only accepts the
origins listed in chat-worker/wrangler.jsonc vars.ALLOWED_ORIGINS.
Article pages ship a fully custom text selection: drag draws sharp
rectangles that morph into a rounded highlight on release, the shape stays
one continuous polygon across code blocks / lists / headings (band
decomposition — line bands plus gap bands that always overlap, so the
outline can't split or self-intersect), fully-covered lines extend to the
column's left edge (bullets, blockquote borders and pre padding sit inside
the highlight, keeping the left edge flush across block kinds), and
components (figures, demos, formulas, tables) are selected as whole units
with a rounded ring. Full
keyboard navigation (word/line/block/document granularities, goal column),
Cmd/Ctrl+A, and copy that emits source form ($latex$, ).
Fine-pointer devices only; touch keeps native selection.
The DOM contract it consumes:
src/lib/rehype-data-block.tsmarks every leaf text block (p, headings, li, pre, blockquote, figcaption) with sequentialdata-blockattributes. Blocks never nest — the selection layout flat-walks[data-block]in document order.- Media and interactive components render with
data-atomicon their outermost element (Figure/Video/Demo/CTACard);src/lib/rehype-atomic.tsadditionally atomizes KaTeX (inline + display) and tables and attachesdata-raw(what a copy yields). Atomic subtrees are single selectable units; their internals keep native interaction. - UI chrome inside a block (e.g. the copy button) must carry
aria-hidden="true"— the layout walker skips aria-hidden subtrees.
Verify after content/pipeline changes:
document.querySelectorAll('[data-block] [data-block]').length === 0.
open-next.config.tsuses the static-assets incremental cache (read-only) because everything is SSG. Introduce R2/KV cache only if ISR is added.- The three "Failed to copy hast-util-* / property-information" errors during
opennextjs-cloudflare buildcome from its workerd-condition external package pass; they are harmless here since MDX never renders at runtime. images.unoptimizedis set (no Next image optimizer on Workers); switch to a Cloudflare Images loader if needed.