Shiki renders code on the server, Mermaid loads only on pages with a diagram, rendered HTML is cached per blob sha, and one misplaced import that shipped the whole pipeline to the browser is gone.
A docs page in GitBasedDocs can hold highlighted code, Mermaid diagrams and KaTeX math. The libraries behind those are big, and I wanted readers to download almost none of them. This is how the render pipeline got there, including the part the second UI critique caught me getting wrong.
Shiki on the server
The first version highlighted code with rehype-highlight, which is highlight.js underneath. It uses regex grammars, so TypeScript generics and JSX often came out the wrong colour, and it had no way to mark lines or show a diff.
I swapped it for Shiki through @shikijs/rehype (commit 6fa3b44). Shiki uses the TextMate grammars VS Code uses, so code looks the way writers saw it in their editor. It runs on the server and writes plain <span>s with colours. Readers get no highlighter script at all.
A few settings keep it cheap. lazy: true loads a grammar the first time a page uses that language. An unknown fence language falls back to plain text instead of failing the render. The official transformers add line marks and diff marks, and a fence can carry a file title:
```ts title="lib/auth.ts" {2}
export function check(user: User) {
return user.active // [!code highlight]
}
```
A small custom transformer moves that title onto the code block's header and strips the theme's inline background, so every block sits on the same --code-surface colour in both themes.
Mermaid in the browser, only when a page has a diagram
Mermaid is the one library I could not move to the server. The server-side options, rehype-mermaid and mermaid-cli, need a headless browser in the app container, and that is too heavy for a single self-hosted instance.
So the server does the cheap half (commit d0a7d25). A ````
</div> fence becomes a In the browser, components/viewer/article.tsx looks for those boxes. If a page has none, nothing loads. If it has one, it runs await import("mermaid") and draws. Mermaid runs with securityLevel: "strict", so labels are sanitized and click handlers are off. A diagram that fails to parse shows its source and the first line of the error, never an empty box.
Flipping light and dark redraws every diagram, through a MutationObserver on the <html> class. Each draw takes a number, and an older draw stops at its next await if a newer one started, so a fast double toggle cannot leave the wrong theme on screen.
Wide diagrams
The second UI critique found that Mermaid scales a diagram to 100% of the column. A wide flowchart shrank until its labels were about 7px, and about 3px on a phone.
The fix in a4ba82e lets a diagram shrink to 75% of its natural size and no further. Past that, the box scrolls sideways. An Expand button opens one shared native <dialog> that fits the diagram to the screen, never below 60%, and returns focus to the button on close. The flowchart on the docs overview went from about 7px labels to 12px inline.
One render per page version
Each page version should render once, so renderCached in lib/render/markdown.ts keeps the HTML in memory. The key is the page id, its blob sha, a draft flag and a project "generation": the page count, the newest update time and a short hash of the asset list. An edited file gets a new blob sha and misses. When any page or image in the project changes, the generation changes, so a dimmed wikilink or a missing-image box re-renders once its target shows up.
The cache holds 300 entries and drops the oldest first. It lives on globalThis, so every route bundle shares one map, and the "clear cache" action in the admin danger zone empties the same map the pages read. A RENDER_VERSION constant goes into every key. I bump it whenever the pipeline's output changes, so HTML from an older pipeline is never served. It is at 8 now.
The mistake: the pipeline shipped to the browser
The critique flagged this one as P1. The Markdown pipeline (parse5, micromark, Shiki, KaTeX) was ending up in the browser bundle. The cause was one function. pageHref, which builds a page URL, lived in lib/render/markdown.ts. The search palette is a client component, and it imported pageHref from there. Importing one function pulled the whole module into the browser.
The fix in c60581a moved pageHref into its own 9-line file, lib/render/paths.ts, and pointed the search palette, the sidebar and the project switcher there. markdown.ts re-exports it for server code.
After the change, a production build showed no parse5, micromark, Shiki or hast in any chunk the doc page loads: 205K of JavaScript, uncompressed. The only KaTeX strings left sit in Mermaid's lazy chunks.
I did not add an import "server-only" guard to markdown.ts, which is the usual way to stop this from happening again. The runnable checks import that file directly with bun lib/render/markdown.check.ts, and the guard would break them. A comment at the top of the file says it is server-only and why the guard is missing.
This entry belongs to
GitBasedDocs
A private docs site that reads Markdown from one GitHub repo. Write in Obsidian, push, and a few seconds later the pages are live for the people you shared them with. Each project is a folder in the repo, and a reader sees only the projects an admin linked to their account. Anyone else gets the same 404 as a project that does not exist. A webhook starts a delta sync that fetches only changed files, a timed recheck covers a missing webhook, and pages are served from SQLite, so reading never calls GitHub. It renders the Markdown Obsidian and GitHub both know: Shiki code, Mermaid diagrams, KaTeX math and Obsidian callouts, plus wikilinks and image embeds. Next.js 16, React 19, next-auth v4 and Drizzle on SQLite, about 9,200 lines of TypeScript, MIT.
Every entry on this project
13 build notes, in order, including the ones where nothing worked.
Read the series