How a folder of Markdown becomes a static site, and why each stage exists.
The pipeline
The files for AI agents are described in Agents. They come from the same page tree as the sidebar and the search index, so they never need maintaining by hand.
Builder.php drives the run and Cli.php maps its errors to exit codes. Every
stage is a pure transformation: nothing reads the filesystem except the tree,
the parser and the image sizes, and nothing writes it except the builder. That
is what lets pholio check render into a temporary directory and compare the
result with a committed build byte for byte.
Why it is shaped this way
PHP at build time only. The output is static HTML served by any web server. The components are pure functions, so the same code could render at request time if a project ever needs it, but nothing in the design assumes a PHP runtime in production.
No dependencies. The only external ingredients are vendored with their
license files: the Inter font, the lucide icon paths, the Shiki grammars and
themes and the values derived from the compiled Tailwind output. The checks in verify/
may use Node and Playwright because they are never deployed.
Fail loud. Unknown Markdown, an unknown component, attribute, icon or code
language, an invalid meta.json, a duplicate slug: each one stops the build with
the file and line. There is no silent fallback, because a documentation site
that renders something wrong is worse than one that refuses to build.
Search without a backend. The index is built at compile time and shipped as
compact JSON: an inverted index from normalized words to pages and sections,
without the body text. The browser loads it the first time a search trigger is
hovered, focused or opened and ranks in a Web Worker: title and phrase matches
first, then a score over fields (title, keywords, description, headings, text)
weighted by how rare each query word is and how much of the query a page covers,
with stopwords dropped and prefix, compound, inflection and typo matching. The
ranking weights are the WEIGHTS object in theme/js/search.js;
node verify/search-query.mjs --explain shows how a query was scored.
Configuration is data. One array, validated against a schema before
anything runs. Components never read the public keys; they read the normalized
internal shape documented in src/Config.php.
Repository layout
| Path | Contents |
|---|---|
bin/pholio | CLI entry point |
src/ | Cli.php, Config.php, Builder.php, Htaccess.php, AgentHeaders.php, DevServer.php, Fs.php, I18n.php |
src/lib/ | Parser, tree, ids, table of contents, highlighter, search index, rendering, agent files, HTML helpers |
src/components/ | One PHP file per component, pure functions |
src/templates/ | The document shell |
src/i18n/ | Interface strings: en.php (every key), de.php |
theme/css/ | notebook.css and its parts, hand-written in cascade order |
theme/js/ | ES modules, one per behavior |
theme/fonts/ | Inter |
vendor-data/ | Shiki grammars and themes, lucide icon data |
licenses/ | License texts of the vendored material, copied into every build |
verify/ | Node checks of the search engine, the search dialog, the agent files and the icons, never shipped |
tests/ | PHP tests, fixtures and the demo snapshot |
docs/ | This documentation, in Pholio's content format |
examples/demo/ | A demo site covering every component |
examples/cloudflare-worker/ | Content negotiation for agents on Cloudflare Workers static assets |
scripts/ | Local checks, run by hand |
Known limits
Every navigation is a full page load, because the output is static HTML. It is
softened by restoring the sidebar's scroll position and open folders from
sessionStorage before the first paint.
Decision record
- 2026-09-12 — The name is Pholio. The generator stopped being an internal build step of one documentation site and became a product with its own repository, its own name and its own branding.
- 2026-09-12 — Markdown plus component tags, not MDX. MDX buys expressions and imports, and pays with a compiler, a component runtime and content that can execute. Tags are data: attributes and nesting, nothing else. New tags are registered in PHP, where code is supposed to live.
- 2026-09-12 — No extraction from source code. No docblock parsing, no API reference generation. Generated reference pages are the part of a documentation site nobody reads and everybody stops maintaining. Pholio renders what you wrote.
- 2026-09-12 — PHP at build time only. The output is static files. The components are still pure functions and could render per request, but nothing in production depends on a PHP runtime.
- 2026-09-12 — Hand-written CSS with component classes. A stylesheet of
compiled utility classes is 110 KB and unreadable. Each element carries one
or a few
nd-*classes instead, each rule group names the utilities it stands for, and state lives indata-andaria-attributes. - 2026-09-12 — Vanilla-JS ports of the Base UI primitives. Dialog, popover, collapsible, scroll area and tabs are small classes that set their state attributes in a fixed order, because the CSS and the animations depend on that order. Copying a React runtime to get five behaviors was not a trade worth making.
- 2026-09-12 — Committed output as the test oracle. The demo build and the highlighter's output are committed. A checklist review finds the differences you thought to look for; a diff against committed output finds the others.
- 2026-09-13 — Files for agents are derived, not written. The Markdown twins, llms.txt, skill.md and the agent card are computed from the page tree and the configuration, the same way every build. No model writes them, so they can't drift from the pages or invent anything. Content negotiation lives in the server configuration Pholio already generates, so the output stays static; an MCP server would need a runtime and is left to hosts that have one.
- 2026-09-12 — One file per commit. Long history, but every change is
readable and revertible on its own. It has already paid for itself while
moving code between repositories with
git subtree split.