Skip to content

Repository files navigation

weave-blog

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.

Commands

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 database

Writing posts

Add 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.

Components available in MDX (no imports needed)

  • <Figure src alt caption width height /> — block image (markdown ![alt](src "caption") renders the same way)
  • <Video src caption poster autoPlay />
  • <Demo caption>…</Demo> — wrapper for interactive client components; guarantees the data-atomic contract
  • <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 in src/lib/polls.ts (single source for both the component and worker-side vote validation)
  • <Chat /> — live chat room over a WebSocket to the standalone weave-chat worker (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)

Interactive components + backend

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:

  1. Write a "use client" component under src/components/mdx/ (use usePostSlug() from PostProvider if it needs to know its post).
  2. If it needs the database: add queries to src/lib/db.ts, a migration under migrations/, and a route handler under src/app/api/.
  3. Register it in mdx-components.tsx so posts can use it without imports.
  4. Embed it inside <Demo> in the post (or self-mark data-atomic and add the name to ATOMIC_COMPONENTS in rehype-data-block.ts).

<Poll> is the reference implementation of this recipe.

Realtime chat (decoupled worker)

<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            # deploy

In 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.

Custom selection engine (src/selection/)

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$, ![alt](src)). Fine-pointer devices only; touch keeps native selection.

The DOM contract it consumes:

  • src/lib/rehype-data-block.ts marks every leaf text block (p, headings, li, pre, blockquote, figcaption) with sequential data-block attributes. Blocks never nest — the selection layout flat-walks [data-block] in document order.
  • Media and interactive components render with data-atomic on their outermost element (Figure/Video/Demo/CTACard); src/lib/rehype-atomic.ts additionally atomizes KaTeX (inline + display) and tables and attaches data-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.

Notes

  • open-next.config.ts uses 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 build come from its workerd-condition external package pass; they are harmless here since MDX never renders at runtime.
  • images.unoptimized is set (no Next image optimizer on Workers); switch to a Cloudflare Images loader if needed.

About

Where the monologue ends, a woven dialogue begins.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages