I built "Tsumugu," a Zero Config documentation server for the AI era — connecting Markdown, HTML, MDX, and OpenAPI with Semantic AST

I built "Tsumugu," a Zero Config documentation server for the AI era — connecting Markdown, HTML, MDX, and OpenAPI with Semantic AST

In AI-assisted development, there are more opportunities to write documents such as design documents and specifications. However, simply placing Markdown files does not provide a particularly good "reading" experience. So I created a tool called "Tsumugu" that allows you to publish Markdown, HTML, MDX, and OpenAPI as easy-to-read documentation sites with zero configuration. In this article, I will introduce the background of its development, the design centered around Semantic AST, and the development style in the AI era.
2026.08.03

This page has been translated by machine translation. View original

In AI-assisted development, I find myself writing documentation more than ever before. Design documents, specifications, research notes, ADRs. Projects where both AI-generated and self-written content is managed together with code in a docs directory are no longer uncommon, I think.

However, the more documentation accumulates, the worse the experience of reading it becomes. I usually have it open in Zed, but an editor is a tool for writing. It's fast at searching and editing, but it's not suited for getting an overview, navigating between related pages, or reading comfortably.

The format was also a point of concern. Markdown is easy for both humans and AI to work with, and it's sufficient in most situations. However, when you want to include diagrams or build rich layouts, you start wanting the expressiveness of HTML or MDX.

What I wanted was a tool that would turn anything placed in docs—whether Markdown, HTML, or MDX—into a documentation site ready to read as-is. With a table of contents, full-text search, no configuration files needed. Easy for both humans and AI to work with. I looked around, but couldn't find exactly that.

So I decided to build it myself—that's how Tsumugu got started. In this article, I'll write about the background and design behind building it.

Just place it in docs

npx tsumugu dev docs

That's all the setup there is. The directory structure maps directly to URLs.

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 it's quickest to just see it in action.

https://37108.github.io/tsumugu/

This site itself is also served by Tsumugu. If you clone the repository and run pnpm docs, the same pipeline distributed via npm will start up locally.

I had no intention of creating a configuration file from the start. Because it's a hassle.

When all I want to do is read docs, I found it frustrating to have to start by looking up how to write a configuration file. Install it, read the README, create config.ts, register plugins, and only then does local development start up. I fairly often exhaust my motivation before getting that far. I just want it to work when I run it.

So I scattered everything that might become configuration elsewhere.

  • Document root → command argument. Defaults to ./docs if omitted
  • Title, description, order, visibility → the document's own Front Matter
  • Site name → the top page's title
  • renderer and theme configuration → preset. Write in TypeScript if replacing
  • Host, port, output destination, origin → flags on the command that uses them

When I later noted this in an ADR, I counted "so what would go in tsumugu.config.ts?" and truly nothing remained. At most something like a composition API rewritten as strings, and if that's all it is, it's not needed.

There's also a side benefit. Accidents where behavior changes due to an unfamiliar configuration file inherited from a parent directory don't happen. If you're not going looking for something, you can't accidentally find it either.

Making Markdown, HTML, and OpenAPI all the same AST

Tsumugu absorbs format differences at the entry point, and from there on deals only with "what that fragment means." Markdown, HTML, MDX, and OpenAPI all flow through after being converted into a Semantic AST.

The pipeline is unidirectional. The Scanner finds files and creates Documents, the Renderer converts them to a Semantic AST, the Transformer rewrites the AST, the Theme assembles 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 the Renderer knows about formats. Files that pass through it all become the same Semantic AST, and subsequent processing is unaware of whether something "was originally Markdown" or "this was OpenAPI."

What's in the Semantic AST is information about what that fragment means. There are no presentation-purpose elements like div, span, or section. Presentation is the Theme's responsibility.

There was the option of using the DOM directly without an intermediate tree, but that would crush Markdown into HTML representations. If I leaned toward the Markdown parser's AST, HTML would get crushed instead. The moment you lean one way, the other becomes a second-class citizen, so I put something that's neither in between.

The place where this benefit is most clearly visible was OpenAPI. What tsumugu-renderer-openapi does is:

  • Convert tags to headings
  • Convert operations to headings and sections of "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—without being written anywhere in the implementation. The Theme has no knowledge of the concept of OpenAPI.

Not dropping 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—along with the reason and original text. Nothing is silently discarded. The fact that Tsumugu doesn't support something and the fact that the author made a mistake are different matters.

Where this design has the most impact is actually on machine-facing output. documents.json, llms.txt, search.json, and sitemap.xml are all generated from the Semantic AST after Transformer application. HTML is never scraped from already-rendered output.

If HTML were the source, simply swapping out a theme would change the text AI reads. Structure also gets lost once things become HTML. If I prepared separate data structures for humans and AI, they would inevitably drift apart somewhere, and I didn't want that.

The handling of hidden is also an extension of this. Documents with hidden: true are included in documents.json with a flag. Because if someone asks "what's in this project?", it's right to be honest about their existence. On the other hand, they are excluded from llms.txt, sitemap.xml, and search.json. Being listed there constitutes a recommendation to "please read this" or "please index this," which would go against the intent of hidden. Incidentally, 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 extension points: renderer, transformer, theme, serializer, and plugin. I have not provided a catch-all entry point like lifecycle hooks.

Core has no dependencies on OpenAPI, Mermaid, search, or build. When adding a new format, only a package increases—Core doesn't change. I use this to judge whether the separation with Renderer as the boundary is still effective.

Content does not execute

Tsumugu's security model can be explained in one sentence.

Content does not execute.

This policy alone has not changed from the beginning. I divide the people involved into 3 parties and decide what to trust from each.

Who What
The person who ran tsumugu Everything. It's a command they ran themselves on their own machine
The document author Text only. Markup and scripts are not trusted
Anyone who can reach the port Nothing

The important one is the middle.

It's not just me writing the documentation. Contributors' work, vendored files, generated artifacts, and what AI has written. A tool that executes documents makes everyone who wrote them a Code Owner. Now that we live in an era where AI writes documentation, this boundary has become weightier than before. That's why Tsumugu separates the authority to write text from the authority to execute code.

The implementation follows accordingly. HTML is converted to a 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 is trustedHtml. Moreover, this API requires you to pass a string explaining "why this can be trusted" when calling it. The only three things currently passing through this are:

  • Tsumugu's own stylesheet
  • Tsumugu's own scripts
  • The author's markup when --trust is specified

The same is communicated to the browser. All responses come with a CSP based on default-src 'none', and the only thing in the default script-src is the SHA-256 hashes of scripts generated by Tsumugu itself.

default-src 'none';
script-src
  'sha256-…(page client)'
  'sha256-…(dev live reload)';
connect-src 'self'

One page client always, plus one more for live reload only when running the dev server. That's it.

Neither nonce nor 'self' is used. nonce would allow any script the server marks to pass through, and 'self' permits all JavaScript from the same origin, which means .js files placed in the docs directory would also become execution targets. Neither fit with the premise of not trusting authors.

With a hash, even a 1-byte difference prevents execution. Scripts placed by authors, scripts swapped out in transit, and rewrites of Tsumugu's own scripts—the browser rejects all of them. Even if server-side defenses are completely bypassed, the browser stops it at the end.

MDX sits on top of this as well. MDX is not an extension of Markdown; it is a programming language that mixes JavaScript into Markdown. import is executed, expressions are evaluated, and components run. Rendering MDX as-is means executing documents as code.

So Tsumugu parses .mdx as genuine 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 dropping information, and not executing. As a way to present a document whose execution has been declined, I think this is the most honest approach.

On top of that, I prepared --trust. This is not a flag to enable features, but a declaration by the Operator themselves: "the contents of this document root are mine, so I trust them as code." The default is always OFF, no inference is made, and what was trusted on startup is displayed in the terminal. The scope is limited to within the document root and does not extend to the network.

When specified, three things change:

  • The retained raw markup is output as-is
  • The author's JavaScript can be executed
  • .mdx is evaluated at build time

Evaluated MDX is incorporated into the Semantic AST as static HTML. So search, table of contents, documents.json, and llms.txt all look at the post-evaluation document. React or the MDX runtime is never served to readers. To the very end, what is returned is static documentation.

The reason I didn't provide a setting like trust: true in Front Matter is the same. If the untrusted side could declare "please trust me," the boundary would be reversed. It's the Operator, not the author, who decides trust.

I decided to draw diagrams myself

To include a diagram in Markdown, you use a fenced block tagged mermaid. The first Tsumugu displayed these as code blocks. The source was preserved, but the diagram wouldn't render.

Serving Mermaid's script to the browser wasn't an option given the constraints of the previous section. It would mean several MB of JavaScript reading and executing document content. The remaining option was drawing 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 Flowchart with 5 nodes, 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 trivially possible in a browser, but not in jsdom. So the calculation breaks down. I tried setting up a shim for text measurement, but it only changed where the errors appeared. I also tried flowchart.htmlLabels: false, the equivalent top-level setting, and %%{init}%% inside documents—foreignObject remained.

This is not a problem on Mermaid's side; it's my requirements that are unusual, trying to run something designed for a browser in a place with no browser. That said, I couldn't bring myself to add 177MB and a headless browser to a tool I wanted to start up with a single npx line.

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 the package.json dependencies is tsumugu-core.

This is not a replacement for Mermaid. It can 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 cannot be drawn. subgraph, classDef, style, and %%{init}%% are not accepted either. It's a small thing—a rebuild of a tiny fraction of the original, adapted to my constraints.

When something falls outside the subset, the code block is displayed as-is, and a Diagnostic communicates what couldn't be drawn. Having a page become entirely unreadable because one diagram couldn't be rendered is the failure I most want to avoid.

SVG is embedded inline rather than as <img>. This way, the diagram's colors follow currentColor, so it automatically adapts to the reader's light/dark setting. Text inside diagrams can also be selected and shows up in the browser's find function.

Diagrams have role="img" and aria-label, with the description placed in a visually hidden figcaption. The reason SVG's own <title> and <desc> are not used is that the Serializer treats title as a raw-text element that disables escaping, and it can't distinguish between HTML's title and SVG's title. I judged it safer to write in ordinary HTML rather than introducing namespace concepts to the last place where escaping is decided.

For the description text, accTitle / accDescr is used if present; otherwise it's generated from the diagram's content. I didn't think it was right to immediately show warnings to someone who simply pasted an existing Mermaid block.

The diagram source is retained within the node. So search, documents.json, and llms.txt can all read the diagram as text. Readers who can't see the diagram and models reading the corpus receive the same thing.

There are also 2 escape routes when the subset isn't enough.

One is to export the diagram as an .svg, place it in docs, and reference it from Markdown as ![pipeline](./pipeline.svg). Assets are served directly alongside documents, so there's no upper limit on diagram complexity. Whether it's Mermaid CLI, Excalidraw, or a Figma export—any of these is fine, and if you use fill="currentColor" inside the SVG, it will follow dark mode too. However, since no diagram text remains in the body, any content you want in search or llms.txt will need to be supplemented with alt text or surrounding prose.

The other is to write in HTML or MDX after enabling --trust. Once you've declared that your docs are your own, you can load real Mermaid on the page or draw with components. Since MDX is evaluated at build time and becomes static HTML, any diagram text that comes out of that process will appear in search and the table of contents, and no runtime is served to readers. If you want to write a page where diagrams are the main focus, this approach is more straightforward.

The reason I only drew 2 types myself is that I prioritized having diagrams appear with nothing added to npx tsumugu dev docs. Once you want to do something more elaborate, I think the right move is to shift to one of the two options above.

Search was written without adding dependencies

The starting point was wanting search to work in static output too. What tsumugu build produces is a tree of files to be placed on a host, with no server standing by to answer on each keystroke. An option that submits a form for each query would work anywhere, but I didn't want to call something "search" if the page jumps every time you search. That meant fetching the index once and then filtering in the browser.

However, this ran into the CSP from the previous section. Since I had fixed the allowed hashes to 2, the search script needed to fit within one of those.

The finished client was 2.6KB and 46 lines. No frameworks, no bundlers, no build step. What was written is served as-is and hashed as-is. What you see in view-source is the same as what's in the repository. I didn't use a search library because each one came with index format lock-in and version tracking, and they were all larger than this entire client.

Matching is substring-based, ignoring case and accents (lowercased, NFKD, combining characters stripped). It's not fuzzy search. When document search starts guessing, the page you're looking for by name gets buried beneath the results.

The ranking works like this:

  • Split the query on whitespace, and all terms must match. Entering 2 words narrows results, doesn't expand them
  • Match in section heading > document title > body. Match at word start > match within word
  • Ties broken by document order. At most 3 results per document out of 12. Long pages don't fill the list

The scoring function is embedded directly from the TypeScript that unit tests call into the script. Because I wanted the ranking running in the browser and the ranking the tests see to be aligned.

In environments without JavaScript, the search box behaves as <form method="get" action="/search"> and navigates to /search. This page doesn't answer queries—it lists all documents. Because having 2 implementations of matching would eventually mean 2 searches that disagree. If JavaScript is present, search is instant; if not, search becomes a page. Either way, no control is left that does nothing when activated.

That said, I'm not satisfied with the current implementation. Substring matching is weak against word inflections, and in languages like Japanese where words aren't separated by spaces, it sometimes doesn't cut where intended. The index also grows linearly with document count. For this repository's documents, /search.json is about 145KB. Since it's fetched once and cached, it's not a practical problem, but it won't hold if the scale increases by an order of magnitude.

I think search is the most important feature in a documentation site. Tables of contents and navigation are ultimately just aids for reaching the page you're looking for. When the reading experience breaks down, it's usually because that's where it failed.

It's also where I most want to make improvements next. How to quickly build a full-text search index at build time that requires no server and completes on the client side. I haven't written an RFC yet, so neither the shape nor the approach is decided. There should be a path that simultaneously satisfies "no server required," "works properly in Japanese," and "index doesn't balloon too much"—and that's what I'm exploring right now.

From saving to seeing the screen change

The experience of writing is almost entirely determined by the time from saving to seeing the screen change. 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 caching is split into 3 layers with invalidation keys defined for each. Loaded documents are invalidated by size and modification time; post-theme body and outline by content hash; serialized pages by a signature of "what the page depends on outside that document."

Until that last layer was added, a 1000-document project took 2.8 seconds per save. Because every page had navigation, fixing any single file meant rebuilding all pages. I had a vague sense that it was "somehow slow," but I didn't realize the cause until after I wrote the benchmark.

I haven't written a single line myself

Not a single line of this project's code was written by me. All implementation was entrusted to Claude Code and Codex.

That said, it's not a story of "AI made everything"—the time I actually spent went into thinking through specifications, revisiting the design, and making decisions. Previously, most of my time went to writing code, but it feels more accurate to say that time has been replaced by design and review.

My rough flow now goes like this:

  1. Flesh out requirements with grill-with-docs
  2. Distill them into a spec with to-spec
  3. Break them into issues with to-tickets
  4. Implement with implement

It's nothing special—just Matt Pocock's Skills chained together with an orchestration skill of my own. This time it was a documentation server so I didn't use them, but in my regular work I also load in Skills for design and frontend.

  • 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 code and direct refactoring, or discuss design, but I almost never trace through hundreds of lines of implementation from top to bottom.

In exchange, I've been spending more time on tests. Tsumugu now has over 930 tests. Unit and integration tests of course, the 2 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, the tests fail. For web applications, I finally check with my own eyes in an actual browser to see if anything feels off.

My current sense is that while the target of review has shifted from code to artifacts, that doesn't mean responsibility for quality can now be handed over to AI.

Closing

Tsumugu is still pre-alpha. Versions start from 0, and public APIs may change with each release.
When you serve what you read every day with your own tool, the slow parts and the hard-to-read parts become things you can't leave alone. For a while, I intend to keep iterating by repeating this cycle.

Share this article