I built "Tsumugu", a Zero Config document server for docs in the AI era
This page has been translated by machine translation. View original
With AI-assisted development, I find myself writing documentation more than ever before. Design documents, specifications, research notes, ADRs. Projects where AI-written and self-written content alike are managed together in a docs directory alongside the code are no longer unusual, I think.
However, the more documentation accumulates, the worse the "reading" experience becomes. I normally open things in Zed, but an editor is a tool for writing. While it's fast at searching and editing, it's not suited for getting an overview, following related pages, or reading through things comfortably.
The format was also a headache. Markdown is easy for both humans and AI to work with, and it's sufficient in most situations. But when I want to add diagrams or build richer layouts, I find myself wanting the expressiveness of HTML or MDX.
What I wanted was a tool that would turn a docs directory into a readable documentation site just by placing files there. With a table of contents, full-text search, and no configuration files needed. Easy for both humans and AI to work with. I looked around but couldn't find exactly the right thing.
So I decided to build it myself, and that's how Tsumugu started. In this article, I'll write about the background and design behind it.
Just place your docs files and you're ready
npx tsumugu dev docs
That's all the setup. The directory structure becomes the URL structure directly.
docs/
├── index.md → /
├── guide/
│ ├── index.md → /guide
│ └── getting-started.md → /guide/getting-started
├── reference/api.html → /reference/api
└── images/diagram.svg → served alongside the documents
I think the quickest way is to see it running.
This site itself is also served by Tsumugu.
If you clone the repository and run pnpm docs, the same pipeline as the npm distribution will start up locally.
I omitted configuration by fetching everything that might be configuration.
- Document root: command argument. Defaults to
./docsif omitted - Title, description, ordering, visibility: the document's own Front Matter
- Site name: the top page's title
- renderer and theme composition: preset. Override by writing in TypeScript
- Host, port, output destination, origin: flags of the command that uses them
Handling Markdown, HTML, and OpenAPI with an AST
Tsumugu absorbs format differences at the entry point, and from there it only deals with "what that fragment means." Markdown, HTML, MDX, and OpenAPI are all converted into a Semantic AST before being processed.
The pipeline is one-directional. The Scanner finds files and creates Documents, the Renderer converts them to Semantic ASTs, the Transformer rewrites the AST, the Theme builds a Virtual Tree for display, and the Serializer outputs HTML. No stage goes back upstream, and no stage re-reads files that a previous stage already read.
Only up to the Renderer knows about formats. Any file that passes through it becomes the same Semantic AST, and subsequent processing has no awareness of "this was originally Markdown" or "this was OpenAPI."
What's in the Semantic AST is information about what each fragment means. There are no presentational elements like div, span, or section. Presentation is the Theme's responsibility.
There was the option of not having an intermediate tree and using the DOM directly, but that would collapse Markdown into HTML representation. Aligning with a Markdown parser's AST would in turn collapse HTML. As soon as you lean toward either one, the other becomes a second-class citizen, so I inserted something that is neither.
The benefit is most obvious with OpenAPI. What tsumugu-renderer-openapi does is:
- Convert tags to headings
- Convert operations to headings and sections with "HTTP method + path"
- Convert parameters and responses to tables
- Convert schemas to code blocks
No new node types are added. As a result, API endpoints appear in the table of contents just like regular documents, show up in search, and are included in documents.json and llms.txt. None of this is written anywhere in the implementation. The Theme has no concept of OpenAPI.
Not losing information is also a condition of this design. HTML that can't be converted to meaning is kept as raw-html, and syntax the AST doesn't yet support is kept as unsupported, with both the reason and the original text preserved. Nothing is silently dropped. Tsumugu not supporting something and a writer making a mistake are different things.
Where this design really pays off is actually in machine-readable output. documents.json, llms.txt, search.json, and sitemap.xml are all generated from the Semantic AST after Transformer application. We never scrape the rendered HTML.
If we based things on HTML, changing the theme alone would change the text that AI reads. There's also structure that gets lost once it becomes HTML. If we prepared separate data structures for humans and for AI, they would inevitably drift apart at some point, and I wanted to avoid that.
The handling of hidden is an extension of this. Documents with hidden: true are included in documents.json with a flag. That's because if someone asks "what does this project contain?", we should answer honestly about its existence. On the other hand, they're excluded from llms.txt, sitemap.xml, and search.json. Being listed there is a recommendation to "please read this" or "please index this," which runs counter to the intent of hidden. Note that hidden is not access control. It means "don't show in listings," and anyone who knows the URL can still read it.
There are 5 extensible points: renderer, transformer, theme, serializer, and plugin. There is no catch-all entry point like lifecycle hooks that can do anything.
Core has no dependency on OpenAPI, Mermaid, search, or builds. Adding a new format only adds a package; Core doesn't change. Whether the separation with Renderer as the boundary is still working is judged by this.
Not executing content without permission
Tsumugu's security model is simple.
Content does not execute.
This policy has never changed from the very beginning. I separate the people involved and decide what to trust for each.
| Who | What |
|---|---|
The person who ran tsumugu |
Everything. It's a command they typed themselves on their own machine |
| The document author | Text only. Markup and scripts are not trusted |
| Anyone who can reach the port | Nothing trusted. Sounds like me. |
The author is what matters.
Documents aren't written only by yourself. Things written by contributors, vendored files, generated content, and things written by AI.
A tool that executes documents makes everyone who wrote them a Code Owner. In an era where AI writes documents, this boundary has become weightier than before. That's why Tsumugu separates the permission to write text from the permission to execute code.
The implementation follows this. HTML is converted to Semantic AST, and markup that can't be mapped to meaning is retained as escaped text. <script> outputs a Diagnostic and drops its contents.
There is only one entry point where the Serializer can output raw HTML. That's trustedHtml. And this API requires you to pass a string explaining "why this is trustworthy" when calling it—otherwise it won't pass. The three things actually going through this today are:
- Tsumugu's own stylesheet
- Tsumugu's own scripts
- The author's markup, when
--trustis specified
The same is communicated to the browser. All responses come with a CSP based on default-src 'none', and the only things listed in the default script-src are SHA-256 hashes of scripts generated by Tsumugu itself.
default-src 'none';
script-src
'sha256-…(page client)'
'sha256-…(dev live reload)';
connect-src 'self'
Always one page client, plus one for live reload only during dev server. That's it.
Neither nonce nor 'self' is used. nonce would allow any script the server marks to pass through, and 'self' would permit all JavaScript from the same origin, meaning .js files placed in the docs directory would also become executable. Neither was compatible with the premise of not trusting the author.
With hashes, even one byte different and it won't execute. Scripts placed by the author, scripts swapped out in transit, and modified versions of Tsumugu's own scripts—the browser rejects all of them. Even if the server-side defenses are completely bypassed, the browser stops it at the end.
MDX sits on top of this. MDX is not an extension of Markdown; it's a programming language that mixes JavaScript into Markdown. import statements are executed, expressions are evaluated, and components run. Rendering MDX as-is means executing documents as code.
So Tsumugu parses .mdx as proper MDX syntax, but does not execute it.
| MDX syntax | How Tsumugu handles it |
|---|---|
{expression} |
Displayed as-is, not evaluated |
<Component /> |
Displayed as-is, not rendered |
import / export |
Displayed as-is, not executed |
The parts written as Markdown are exactly the same as .md. Headings, anchors, syntax highlighting, search, and exports are all unchanged. The dynamic parts are simply displayed as formatted source code. Not losing information, not executing. I think this is the most honest way to present a document whose execution has been declined.
On top of this, --trust is provided. This is not a flag that enables a feature; it's the Operator's own declaration that "the contents of this document root are mine, so I trust them as code." The default is always OFF, nothing is inferred, and what was trusted is displayed in the terminal at startup. The scope is limited to within the document root and does not extend to the network.
When specified, three things change:
- Raw markup that was being retained is output as-is
- Author's JavaScript can be executed
.mdxis evaluated at build time
Evaluated MDX is incorporated into the Semantic AST as static HTML. So search, the table of contents, documents.json, and llms.txt all see the post-evaluation document. No React or MDX runtime is delivered to the reader. To the end, what's returned is static documentation.
The reason I didn't provide something like trust: true in Front Matter is the same. If the untrusted side could declare "please trust me," the boundary would be inverted. It's the Operator, not the author, who decides trust.
Rendering Mermaid diagrams ourselves
To include diagrams in Markdown, the convention is a fence tagged mermaid. The early Tsumugu just displayed these as code blocks.
The option of delivering Mermaid's scripts to the browser wasn't available due to the constraints of the previous chapter—it would mean megabytes of JavaScript reading and executing the document's contents. What remained was rendering to SVG on the server side, and there are actually quite a few examples of running Mermaid on top of jsdom. I tried that first.
| Diagram | Result |
|---|---|
sequenceDiagram |
Correct SVG at 450×226. 9–25ms |
graph LR |
A 5-node flowchart with a calculated width of 41,216px |
stateDiagram-v2 |
Same as above |
| Installation | Mermaid 83MB + jsdom 8.3MB, 177MB after resolution |
The cause of the broken flowcharts was foreignObject. Mermaid places labels as HTML inside foreignObject and queries the DOM for text dimensions to determine layout. This is trivial in a browser, but jsdom can't do it. So the calculations break down easily. I tried providing a shim for text measurement, but it only changed where the errors occurred. I also tried flowchart.htmlLabels: false, top-level equivalent settings, and %%{init}%% within documents, but foreignObject remained.
This is not a problem with Mermaid—the requirement of running browser-native code somewhere without a browser is what's unusual. That said, I wasn't inclined to add 177MB and a headless browser to a tool that's supposed to start up with a single npx command.
So I decided to draw what I could, myself. tsumugu-transformer-mermaid parses a subset of Mermaid syntax, performs layout, and outputs SVG. The only entry in package.json's dependencies is tsumugu-core.
This is not a replacement for Mermaid. It can only draw two types: flowcharts (graph / flowchart with TD, TB, LR, RL, BT) and sequence diagrams. Class diagrams, state diagrams, Gantt charts, pie charts, ER diagrams, and journey diagrams are not supported. subgraph, classDef, style, and %%{init}%% are also not accepted. It's a small thing—just a fraction of the original, rebuilt to fit my constraints.
When something falls outside the subset, it's displayed as a code block, and a Diagnostic explains what couldn't be drawn. Having a whole page become unreadable because one diagram couldn't be rendered is the failure I most want to avoid.
SVGs are embedded inline rather than as <img>. This way, diagram colors follow currentColor, so they automatically adapt to the reader's light/dark preference. Text within diagrams is also selectable and findable via the browser's search.
Diagrams have role="img" and aria-label, with descriptions placed in a visually hidden figcaption. The reason I'm not using SVG's own <title> and <desc> is that the Serializer treats title as a raw-text element exempt from escaping, and it can't distinguish between HTML's title and SVG's title. I judged it safer to write in plain HTML than to introduce namespace concepts into the last place where escaping decisions are made.
For description text, accTitle / accDescr values are used if present; otherwise, the description is generated from the diagram's contents. I felt it was wrong to immediately warn people who had simply pasted an existing Mermaid block.
The diagram source is preserved inside the node. So search, documents.json, and llms.txt can all read diagrams as text. Readers who can't see the diagram and models reading a corpus receive the same thing.
There are also escape hatches for when the subset isn't enough.
One option is to export a diagram as an .svg file, place it in docs, and reference it from Markdown as . Assets are served directly alongside documents, so there's no limit to diagram complexity. You can use Mermaid CLI, Excalidraw, or Figma exports—whatever works. Using fill="currentColor" inside the SVG will also follow dark mode. The downside is that diagram text won't be preserved in the document body, so content you want in search or llms.txt needs to be supplemented through alt text or surrounding prose.
The other option is to write in HTML or MDX after specifying --trust. Once you've declared that the docs are yours, you're free to load real Mermaid on the page or render diagrams through components. MDX is evaluated at build time and becomes static HTML, so diagram text from that process appears in search and the table of contents, and no runtime is delivered to the reader. If you want to write a page where diagrams are the main focus, this approach is more straightforward.
The reason I only drew two types myself is that I prioritized having diagrams appear with npx tsumugu dev docs and nothing added. When someone needs something more sophisticated, I think the right move is to switch to one of the two options above.
Implementing search without adding dependencies
The starting point was wanting search to work even in static output. What tsumugu build produces is a tree of files to be hosted; there's no server to respond to each keystroke. Submitting a form for each query would work anywhere, but I don't want to call something "search" if it navigates to a new page every time. That means fetching the index once and then filtering in the browser.
However, this ran into the CSP from the previous chapter. Since I committed to allowing only 2 hashes, search scripts need to fit within that one slot.
The finished client is 2.6KB and 46 lines. No framework, no bundler, no build step. What's written is what's served, and what's served is what's hashed. What you see in view-source is identical to what's in the repository. I didn't include a search library because they all come with their own index format and version-tracking burden, and each one was larger than this entire client.
Matching is substring-based, case-insensitive and accent-insensitive (lowercased, NFKD, combining characters stripped). Fuzzy search is not used. When document search starts guessing, the page you were specifically looking for gets buried.
The ranking works as follows:
- Split the query by whitespace; all terms must match. Adding a second term narrows results, never broadens
- Match in section heading > document title > body. Match at word start > match mid-word
- Ties broken by document order. Max 3 results per document out of 12. Long pages don't flood the list
The scoring function is embedded directly from the TypeScript that unit tests call into the script. I wanted the in-browser ranking and the test-observed ranking to match.
In environments without JavaScript, the search box functions as <form method="get" action="/search"> and navigates to /search. This page doesn't answer the query—it lists all documents. If I maintained two matching implementations, the two search experiences would eventually diverge. With JavaScript, search is instant; without it, search becomes a page. Either way, no control is left that does nothing when pressed.
That said, I'm not satisfied with the current implementation. Substring matching is weak against morphological variation, and in languages like Japanese where words aren't separated by spaces, it doesn't always cut at the intended boundaries. The index also grows linearly with document count. For this repository's docs, /search.json is about 145KB. Since it's fetched once and cached, it's not a practical problem now, but it won't hold if the scale increases by an order of magnitude.
I think search is the most important feature of a documentation site. Tables of contents and navigation are ultimately just aids for reaching the page you're looking for. The reading experience tends to break down exactly when that fails.
It's also where I want to make improvements next. How to quickly build a full-text search index at build time that requires no server and completes entirely on the client side. I haven't written an RFC yet, so neither the form nor the approach is decided. There should be a way to simultaneously satisfy "no server required," "works reasonably well for Japanese," and "index doesn't bloat"—I'm still looking for that.
Live reload experience in the dev server
The experience while writing is almost entirely determined by the time between saving and seeing the change on screen. I measure this.
| Document count | Initial build | Rebuild with no changes | Edit 1 file |
|---|---|---|---|
| 200 | ~490ms | ~20ms | ~20ms |
| 1000 | ~3.9s | ~200ms | ~140ms |
Rebuilds are cheap because the cache is split into 3 layers, each with its own invalidation key. Loaded documents are invalidated by size and modification time; post-theme body and outline by content hash; serialized pages by a signature of "everything external to that document that the page depends on."
Before that last layer was added, a 1,000-document project was taking 2.8 seconds per save. This was because every file edit was causing every page to be rebuilt, due to all pages sharing navigation. I vaguely felt it was "somewhat slow" while working with it, but I didn't identify the cause until after writing the benchmark.
Delegating all coding entirely to AI
Not a single line of code in this project was written by me. All implementation was delegated to Claude Code and Codex.
That said, it's not a story of "AI made everything for me"—what actually consumed my time was thinking through specifications, revisiting design decisions, and making judgment calls. Previously, writing code took up the bulk of my time, but now that time has been replaced by design and review.
Here's roughly the flow I follow now:
- Use
grill-with-docsto surface requirements - Use
to-specto turn them into specs - Use
to-ticketsto break them into issues - Use
implementto implement them
It's nothing special—just Matt Pocock's Skills chained together with my own orchestration skill. I didn't use this for the documentation server specifically, but in my regular work I also load design and frontend-focused Skills:
- ui-ux-pro-max (nextlevelbuilder)
- frontend-design (anthropics/skills)
- high-end-visual-design (leonxlnx/taste-skill)
- web-design-guidelines (vercel-labs/agent-skills)
- writing-guidelines (vercel-labs/agent-skills)
The time I spend on code review has clearly decreased. I partially read through things and give refactoring instructions, or discuss design, but I almost never trace through hundreds of lines of implementation from top to bottom.
Instead, I've started investing more time in tests. Tsumugu now has over 930 tests. Unit and integration tests of course, but also both sample projects in examples/ are actually served on every commit, and if a Diagnostic code not listed in docs/designs/diagnostics.md appears in the implementation, tests fail. For web applications, at the end I check with my own eyes by actually using it in a real browser to see if anything feels off.
My current sense is that the target of review has shifted from code to output—but that doesn't mean responsibility for quality can be handed over to AI.
Closing thoughts
Tsumugu is still pre-alpha. Versions start from 0, and public APIs may change with each release.
When you serve your own daily reading through your own tool, anything slow or hard to read becomes something you can't leave alone. I plan to keep iterating on it this way for a while.