home

blog

trace

about

design

portfolio

Copyright © 2026|All rights reserved by Yvan

One Article, the Whole Writing System

2026年06月20日9 分钟
0 浏览
codemdx

Last winter I tore this blog down to the studs. Not because it was broken — it worked fine. But every time I opened one of my own posts I felt a low, persistent wrongness: the lines were too long, the code blocks had no breathing room, the images loaded into empty rectangles. Reading my own writing felt like squinting at a handwritten letter under a parking-lot fluorescent. Eventually the discomfort won and I sat down to rebuild the whole rendering stack.

This article is the living record of that rebuild. Not documentation, not a changelog — a real essay I actually wanted to write, one that happens to exercise every layout element the system knows how to render.

Why immersive reading is worth the trouble

I used to think a blog's substance was its content, and its form was just packaging. That's a little naive. Form is part of the content. The same paragraph set at 700px feels measured and easy; at 1200px your eyes are crossing the room with every line, and without noticing it you've closed the tab.

Typography has a concept called measure — the ideal line width for comfortable reading. Print tradition puts it at 45–75 characters; in practice that's about 12–14 words per line. This isn't a matter of taste. It's physiology: the eye can't re-acquire the next line cleanly when it has to travel too far1. Constraining body text to that range is the smallest design decision I've made with the largest return.

Reading is not decoding symbols — it's letting language build images inside you. Typography's job is to make that process frictionless, to keep attention on meaning rather than mechanics.

Beyond line width there's line height, weight contrast, paragraph spacing. Each one quietly affects how long a reader stays. I'm not going to list them all here; that would be tedious. The better demonstration is this: what you're reading right now is the sum of those choices, and either they work or they don't.


How the system works

The blog is built on Next.js with Velite handling the content layer. Each .mdx file goes through two passes.

  1. Velite compile phase: extracts frontmatter, builds a table-of-contents tree, compiles MDX into a serializable function string.
  2. Render phase: Next.js on the server deserializes the function string, injects the custom component map, and emits HTML.

The table-of-contents data is produced by s.toc() at build time. Heading IDs are injected by rehype-slug during MDX compilation. At runtime the sidebar dot syncs with the reader's scroll position via IntersectionObserver — no matter how long the article, the system always knows where you are2.

The full pipeline in one table:

PhaseToolOutput
Content ingestionVeliteStructured JSON + compiled MDX
Route renderingNext.js App RouterServer-side HTML
TOC highlightingIntersectionObserverActive heading ID
Syntax highlightingprism-react-rendererThemed token HTML
Image zoomCustom lightboxFull-screen overlay

How custom components get injected

MDX's most useful property is that it lets you write JSX directly inside Markdown. As long as the component names are mapped at render time, writing <Callout> in a post produces a real interactive component — not an ignored tag.

That map lives in a file called mdx-components.tsx. Every new component gets registered there once, and the entire site can use it immediately in any article.


A writer's view: what building blocks are available

Enough about infrastructure. What's it like to write with this system? I think of the components in three tiers by how much they interrupt the flow.

Lightweight inline elements

Most of the time it's plain Markdown: bold for a term that needs weight, italic for a phrase you'd stress in speech, strikethrough for an assumption that didn't survive contact with reality, or inline code for a symbol like useActiveHeading(ids) that should read as exact text. These take no conscious thought; your fingers learn them.

When you want to call out a technical name with slightly more visual weight than inline code, there's InlineBlock. You already saw it above with s.toc() — it adds a touch more presence, useful for API names or CLI commands you want to stand out without a full code block.

Links come in two flavors: internal ones like the article index, and external ones like the Next.js site. Both get an underline animation on hover — small, but it signals that something is there.

Block-level layout

Unordered lists work for parallel items without an inherent order:

  • Typography decisions (measure, line height, weight)
    • Body width: ~680px
    • Line height: 1.75–1.8
    • Body font size: 17–18px
  • Component system (Callout, CodeBlock, Gallery)
  • Build pipeline (Velite → Next.js → CDN)

Ordered lists work for steps or priority:

  1. Write a rough draft — ignore formatting entirely
  2. Find the structure and decide on heading levels
  3. Add components where emphasis genuinely helps
  4. Read through once more and delete every redundant sentence

Task lists are good for tracking work, like the checklist for this rebuild:

  • Set body measure and base type size
  • Wire up prism-react-renderer syntax highlighting
  • Build sidebar TOC with scroll-tracking highlight
  • Finish image lightbox
  • Fine-tune dark mode (still in progress)

Semantic callout boxes

Some content needs a stronger signal than a paragraph can provide. That's what Callout is for.

JSX components in MDX files need a blank line before and after them. Without it the Markdown parser treats the component as inline content inside a paragraph, and rendering breaks in confusing ways. This is a spec requirement, not a quirk of this setup.

Velite's hot reload is genuinely fast. Save an .mdx file and the browser refreshes in well under a second. The writing experience is nearly indistinguishable from editing plain Markdown locally.

Never put backtick characters or template-literal interpolations (${}) inside a codeString template literal. They break the outer string and cause a compile error that can be hard to trace.

Do not import components directly inside .mdx files. This blog injects every component through the global components map; a manual import will fail at Velite's serialization step with a cryptic error.


Code: the skeleton of a technical essay

The hardest part of writing technical posts isn't the explanation — it's the code excerpts. Too little and the reader can't follow along; too much and the article becomes a diff file that no one finishes. My current rule: only show the essential core, and use line highlighting to pull attention exactly where it needs to go.

Here's a minimal example with no highlighted lines:

1
export function greet(name) {
2
return "Hi, " + name;
3
}

And here's the same pattern with a filename and line highlighting on the lines that actually matter — lines 2 and 3 are where the logic lives:

use-active-heading.ts

1
export function useActiveHeading(ids) {
2
const [active, setActive] = useState("");
3
return active;
4
}

Line highlighting does the work a sentence of prose would otherwise have to do. The reader's eye lands in the right place before they've read a single word of explanation.


Images: giving the eye somewhere to rest

Dense text needs visual rhythm. An image isn't decoration — it's a rest beat. Click any image here and the page steps back: background dims, image fills the screen, nothing else competes for attention.

Here's the article's cover image, which you can click to expand:

Warm-toned light haze, the cover image for this article
Warm-toned light haze, the cover image for this article

When I want to show several photographs together without each one claiming its own full-width row, I use the Gallery component:

Gallery lays them out in a horizontal strip with swipe support — right for travel writing or anything where sequence matters but order doesn't.

Motion has its place too. The Video component handles short loops without needing an external embed:

A dithered cloud loop — motion without sound


Interactive content

Sometimes reading isn't enough and you want to try something. The Sandpack component embeds a full in-browser code editor directly in the article. No local environment needed — open the post, change the code, see the result:

Loading playground…

For tutorial-style writing this is the difference between a recipe and a kitchen. The reader doesn't have to leave to find out if the thing works.


Details worth tucking away

Not every reader needs every layer of context. Background that matters to a newcomer is noise to someone who already knows it. The Details component lets you fold that material away and let each reader decide whether to open it:

Why Velite instead of Contentlayer

This pattern is borrowed from good technical documentation. The main narrative stays clean; the depth is there when you want it.


What the rebuild was actually for

Three weeks to rebuild a personal blog sounds like a lot. It probably was. But the rebuild was never the point. The point was a specific feeling: open a post I wrote, and be able to read it comfortably. That's the whole brief.

It sounds simple. It turns out to be demanding, because you're two people at once — the author who made every choice, and the reader who has to live with them. Switching between those roles takes more out of you than any individual technical problem.

But when it finally coheres, there's a quiet satisfaction that isn't quite pride and isn't quite relief. It's something more like: this is what I meant.


Footnotes

  1. Robert Bringhurst discusses this at length in The Elements of Typographic Style. Digital typography research consistently supports the 45–75 character line-length ceiling; beyond that threshold, readers lose their place when returning to the start of the next line. ↩

  2. The TOC data is extracted by Velite's s.toc() at build time. Heading IDs are injected by rehype-slug during MDX compilation. The two align through a shared slug convention, ensuring that clicking a TOC entry scrolls accurately to the right heading. ↩

Made By Yvan
个人博客搭建历程