# Pholio > Pholio turns a folder of Markdown into a documentation site: static HTML, one theme in light and dark, search in the browser. Plain PHP 8.2 at build time, zero dependencies, MIT. # Pholio Source: https://pholio.lukaloehr.com/docs > Beautiful documentation, powered by Markdown. One polished theme, fast search, zero dependencies. Pholio turns a folder of Markdown files into a finished documentation site: static HTML, one stylesheet, a few small JavaScript modules, the fonts and a search index. No Composer, no npm, no framework, no build server. PHP runs once at build time and is never needed again. There is one theme, and it is polished down to the details: light and dark schemes, a sidebar with collapsible folders, a table of contents that follows you, keyboard shortcuts and instant search. You write Markdown; Pholio takes care of the rest. > **Info:** Status > > Pholio 0.1.0 builds complete documentation sites. What comes next is on the > [roadmap](https://pholio.lukaloehr.com/docs/roadmap.md). ## What you get - [Markdown in, HTML out](https://pholio.lukaloehr.com/docs/content-format.md): A strict Markdown subset plus a few declarative component tags. Anything unrecognized stops the build. - [Components](https://pholio.lukaloehr.com/docs/components.md): Callouts, cards, tabs, steps, accordions, file trees, type tables and highlighted code, each shown with its source. - [One configuration file](https://pholio.lukaloehr.com/docs/configuration.md): Title, logo, navigation, start page, palette, search, redirects, profiles. Plain PHP data, validated before the build. - [Ready for agents](https://pholio.lukaloehr.com/docs/agents.md): Markdown for every page, llms.txt, skill.md and content negotiation, generated from your content. - [Search without a backend](https://pholio.lukaloehr.com/docs/configuration.md#search): An index built at compile time and ranked in the browser, with typo, prefix and umlaut matching. ## What it deliberately is not Pholio does not read your source code. There is no docblock extraction, no API reference generation, no expression evaluation in content. Your documentation is written by you; Pholio renders it. It is also not MDX. Component tags are data, not programs: they carry attributes, they nest, and they can't import or execute anything. New tags are registered in PHP, where code belongs. # Getting started Source: https://pholio.lukaloehr.com/docs/getting-started > Install Pholio, create a project, write a page, build and preview the site. ## Requirements - PHP 8.2 or newer, command line, with `mbstring` and `ctype`. - PCRE2 10.43 or newer is recommended for full syntax highlighting. It is bundled with PHP (`php -r 'echo PCRE_VERSION;'`). On an older PCRE2 the build still succeeds; the few grammar patterns that need lookbehinds of variable length are switched off, and Pholio warns once per affected grammar that some syntax colors are simplified. That is the whole list for building a site. Node is needed only for the development checks in `verify/`, which never reach a published site. ## Install The install script downloads the latest release, verifies its checksum, checks PHP and PCRE2, and installs Pholio for your user: the release goes into `~/.local/share/pholio` and the `pholio` command is linked into `~/.local/bin`, which needs to be on your `PATH`. ```bash curl -fsSL https://pholio.lukaloehr.com/install.sh | sh ``` The same script is in the repository, so a tag or branch URL works too: `https://raw.githubusercontent.com/luka-loehr/pholio/main/scripts/install.sh`. Options go after `sh -s --`: `--version vX.Y.Z` installs a specific release, `--system` installs into `/usr/local` (may need `sudo`), and `--prune` removes other installed versions. To keep Pholio inside one project instead, install it into a directory and call it through PHP: ```bash curl -fsSL https://pholio.lukaloehr.com/install.sh | sh -s -- --dir vendor/pholio php vendor/pholio/bin/pholio init my-docs ``` Pholio is a drop-in, not a package, so a clone works the same way: `git clone https://github.com/luka-loehr/pholio.git vendor/pholio`. ## Create a project ```bash pholio init my-docs cd my-docs ``` `init` creates the project layout with a small sample site. `--name "My Docs"` sets the site title (default: derived from the directory name, so `my-docs` becomes "My Docs"), and `--lang de` switches the interface to German. It refuses to overwrite existing files and exits 2 unless you pass `--force`. ``` my-docs/ pholio.config.php optional; every key has a default content/ Markdown pages and meta.json files assets/ images and files, published at /assets/ public/ the built site ``` Without a `pholio.config.php`, a directory with a `content/` folder builds with the defaults. [Configuration](https://pholio.lukaloehr.com/docs/configuration.md) lists every key. ## Write a page Content is a folder of Markdown files. A `meta.json` per folder sets its title and the order of its pages. ```text --- title: Exporting a report description: How to save a report as a file. --- Open the report you want to keep, then choose **Export** in the toolbar. ![The export dialog](/assets/images/export.png) Exports contain every page of the report. ``` The format is described in [Content format](https://pholio.lukaloehr.com/docs/content-format.md). ## Build and preview ```bash pholio dev pholio build ``` `dev` builds with drafts, serves the site at `http://127.0.0.1:8080` and rebuilds when the content, the config or `assets/` changes. `build` writes the site into `public/`. Commands without a directory argument work on the current directory; `pholio build my-docs` does the same from the parent. Deploy by copying `public/`; nothing runs on the server. ## Try the demo The repository contains a demo site that uses every component: ```bash git clone https://github.com/luka-loehr/pholio.git cd pholio php bin/pholio build --config examples/demo/pholio.config.php ./scripts/serve-demo.sh ``` ## Commands ``` pholio init [dir] [--name ] [--lang en|de] [--force] pholio build [dir] [--config ] [--profile ] [--content ] [--out ] [--only ] [--dev] [--quiet] [--set =] pholio check [dir] [--config ] [--profile ] [--content ] [--against ] [--dev] [--set =] pholio dev [dir] [--config ] [--profile ] [--content ] [--host 127.0.0.1] [--port 8080] [--no-watch] [--set =] pholio --help | --help | --version ``` `[dir]` is the project directory, default the current directory. `pholio` without arguments prints a short overview and exits 0. | Command | What it does | | --- | --- | | `init` | Creates `pholio.config.php`, `content/` with two sample pages and `assets/images/` | | `build` | Renders the site into `output_dir`. Files already there that Pholio doesn't write are left alone | | `check` | Renders into a temporary directory and compares it with `--against` (default `output_dir`) in both directions. Prints `missing:` (built, not there), `stale:` (content differs) and `extra:` (there, not built) lines; `output.keep` paths are ignored | | `dev` | Builds with drafts into `output_dir`, serves it with PHP's built-in server and rebuilds on changes | | Flag | Effect | | --- | --- | | `--config ` | Configuration file. Default: `/pholio.config.php` if present, otherwise the defaults. Can't be combined with `[dir]` | | `--profile ` | Merge `profiles.` over the configuration | | `--content `, `--out ` | Override `content_dir` and `output_dir`, relative to the current directory | | `--against ` | Directory `check` compares with | | `--only ` | Render only pages whose URL contains this text. For iteration, not for deployment | | `--dev` | Include drafts, the pages whose file name starts with `_` | | `--quiet` | No summary line | | `--set =` | Override a string key, e.g. `--set search.tokenizer=german`. Repeatable | | `--host`, `--port` | Address of the `dev` server. Default `127.0.0.1:8080` | | `--no-watch` | `dev` serves without rebuilding | | `--name`, `--lang`, `--force` | `init`: site title, interface language, overwrite existing files | `build --check` still works as an alias of `check` and prints a deprecation note. ## Exit codes | Code | Meaning | | --- | --- | | `0` | Success; `check` found no difference | | `1` | `check` found differences | | `2` | Usage or configuration error: unknown command, flag or key, a planned key, an invalid value, a missing config file, a directory with neither `pholio.config.php` nor `content/`, an unknown profile, `init` refusing to overwrite | | `3` | Content error: Markdown, component tags, frontmatter, `meta.json`, icons, code languages, an image path outside a copied directory | | `4` | I/O error: the output can't be written or a copy failed | | `70` | Internal error. `PHOLIO_DEBUG=1` prints the stack trace | Errors go to stderr as `pholio: :: ` when a location is known. # Content format Source: https://pholio.lukaloehr.com/docs/content-format > The Markdown Pholio accepts, the component tags it understands and the meta.json tree files. Pholio parses a fixed grammar. There is no fallback: an unknown construct stops the build with exit code 3 and a message naming the file and the line, for example `pholio: content/guide/index.md:12: Unknown component ; allowed: …`. ## Files and URLs Every `*.md` file below `content_dir` is a page (`content.extensions` changes the list). `guide/installation.md` is published at `{docs}/guide/installation`, `guide/index.md` at `{docs}/guide`. URLs have no trailing slash. Files whose name starts with `_` are drafts and only built with `--dev`. ## Frontmatter Every file starts with frontmatter. Values are plain scalars; YAML lists, objects and anchors are errors, and so is any key outside this table (`content.frontmatter_aliases` maps other names onto these). | Key | Required | Meaning | | --- | --- | --- | | `title` | yes | Page title, used in the tree, the breadcrumb and `` | | `description` | no | Shown under the heading and indexed for search | | `keywords` | no | Search terms the page text does not use, such as synonyms or English words: a comma-separated string (`keywords: "herunterladen, download, dark mode"`) or a list of strings (`keywords: [herunterladen, "dark mode"]`, or `- herunterladen` lines below the key); both index the same. Anything else is an error. Only indexed for search, weighted like the title | | `heading` | no | Visible `h1` when it should differ from `title` | | `icon` | no | lucide icon name, used in the sidebar and in cards | | `full` | no | `true` renders the page without the table of contents column | | `updated` | no | Text of the "Last updated" line under the page | | `noindex` | no | `true` keeps the page out of `llms.txt`, `llms-full.txt`, `skill.md` and `sitemap.xml` and adds `<meta name="robots" content="noindex">`; the page is still built | ## Markdown - Headings `##` to `####`. The `h1` comes from the frontmatter, so `#` in the body is an error. Heading ids follow `github-slugger`: lowercase, punctuation removed, spaces to hyphens, non-ASCII letters kept, duplicates suffixed `-1`, `-2`. - Paragraphs, `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, links, images, blockquotes, horizontal rules, hard breaks from two trailing spaces. - Ordered, unordered and nested lists, task lists `- [x]`. - GitHub-flavored tables with column alignment. - Footnotes `[^1]` with their definitions. A plain Markdown image renders as a single bordered image, with its width and height read from the file. Put images into the project's `assets/` folder and reference them either by their published URL, `/assets/images/diagram.png`, or relative to the page, `../assets/images/diagram.png`. Both publish as `/assets/images/diagram.png`. A relative path that points outside a copied directory stops the build. Sites with their own layout keep using `copy` and `content.asset_prefix`, `content.asset_target` and `content.asset_root`. ## Code blocks Fenced code is highlighted at build time with the bundled TextMate grammars and GitHub light and dark themes: `css`, `diff`, `html`, `java`, `javascript` (`js`), `json`, `php`, `shellscript` (`bash`, `sh`), `sql`, `typescript` (`ts`), `xml` and `yaml`. Any other language stops the build. ````text ```ts title="search.ts" lineNumbers const hits = await archive.search('invoice'); ``` ```` | Meta | Effect | | --- | --- | | `title="…"` | File name bar above the code | | `lineNumbers`, `lineNumbers=40` | Line numbers, optionally starting at another value | | `tab="…"` | Consecutive blocks with `tab` are merged into one tab group | | `{1,3-4}` | Accepted for compatibility, no effect | Notation comments at the end of a line, in the comment syntax of the language: `[!code highlight]`, `[!code focus]`, `[!code ++]`, `[!code --]`, and `[!code word:term]` on its own line to highlight a word in the lines below. ## Component tags Tags sit on their own line, attributes are quoted, and nesting works as in HTML. They are data: no expressions, no imports, no evaluation. Attribute values have four notations: - text: `title="Before you start"` - list: `items="macOS|Linux|Windows"`, a literal `|` written as `\|` - boolean: `persist` for true, `persist="false"` for false - number: `defaultIndex="1"` Icons are lucide names such as `book-open`; an unknown name stops the build. | Tag | Attributes | Children | | --- | --- | --- | | `Callout` | `type` (`info`, `warning`, `error`, `success`, `idea`; aliases `warn`, `tip`), `title`, `icon` | Markdown | | `Cards` | none | `Card` | | `Card` | `title` (required), `description`, `href`, `icon` | none | | `Screenshot` | `src` (required), `dark`, `alt` | none | | `ImageZoom` | `src` (required), `alt`, `width`, `height` | none | | `Banner` | `id`, `variant` (`normal`, `rainbow`), `height`, `changeLayout` | Markdown | | `Tabs` | `items`, `groupId`, `persist`, `updateAnchor`, `defaultIndex`, `label` | `Tab` | | `Tab` | `value` | Markdown | | `Accordions` | `type` (`single`, `multiple`), `defaultValue` | `Accordion` | | `Accordion` | `title` (required), `id`, `value` | Markdown | | `Steps` | none | `Step` | | `Step` | none | Markdown | | `Files` | none | `Folder`, `File` | | `Folder` | `name` (required), `defaultOpen`, `disabled` | `Folder`, `File` | | `File` | `name` (required), `icon` | none | | `TypeTable` | none | `TypeProp` | | `TypeProp` | `name`, `type` (both required), `default`, `typeDescription`, `typeDescriptionLink`, `required`, `deprecated` | Markdown | | `InlineTOC` | `label` | none | | `DynamicCodeBlock` | `lang` (required) | verbatim code | ```text <Callout type="warning" title="Before you start"> Back up your configuration. </Callout> <Screenshot src="/images/pipeline.svg" dark="/images/pipeline-dark.svg" alt="The pipeline" /> <Tabs items="macOS|Linux" groupId="os" persist> <Tab value="macOS"> Archives live in `~/Library/Application Support`. </Tab> <Tab value="Linux"> Archives live in `~/.local/share`. </Tab> </Tabs> ``` `Screenshot` shows `dark` in the dark color scheme and `src` in the light one. Without `dark` the same image is shown in both. ## meta.json A folder's `meta.json` names the folder and orders its pages. It is optional; a folder without one lists its pages alphabetically. | Key | Meaning | | --- | --- | | `title`, `description`, `icon` | Folder title, description and icon, used in the sidebar and by `home.cards.from_tree` | | `root` | The folder is a root area with its own sidebar, listed in the area switcher | | `pages` | Order of the entries, see below | | `defaultOpen` | The folder starts expanded in the sidebar | | `collapsible` | `false` keeps the folder permanently expanded | Entries in `pages`: - `"installation"`: a page or subfolder by its file or folder name - `"---Components---"`, `"---[icon]Components---"`: a separator with an optional icon - `"..."`: every remaining entry alphabetically, `"z...a"` in reverse - `"...folder"`: the entries of a subfolder, inlined - `"[Title](https://example.org)"`, `"external:[Title](https://example.org)"`: a link ## What stops the build Exit code 3 with file and line: unknown frontmatter keys or YAML constructs, a `#` heading, unknown component tags or attributes, a tag inside a parent it doesn't belong to, unknown lucide icons, unknown code languages, invalid `meta.json`, duplicate slugs and a folder group name used as a file name. Not checked yet: whether an internal link points at an existing page, and whether an image file exists. A missing image renders without width and height. # Components Source: https://pholio.lukaloehr.com/docs/components > Every component and Markdown feature Pholio renders, each followed by its source. This page uses every component tag and every Markdown construct Pholio accepts. Each example shows the rendered result first and the source that produced it underneath. The attributes are listed in [Content format](https://pholio.lukaloehr.com/docs/content-format.md). ```text <InlineTOC label="On this page" /> ``` ## Callouts A callout has a `type` and an optional `title`; its body is Markdown. Without a type it is `info`. > **Info** > > A callout without a type or a title. > **Info:** Info > > Background information that helps but isn't required. > **Warning:** Warning > > Something that can go wrong if the reader skips it. > **Error:** Error > > Something destructive that can't be undone. > **Success:** Success > > What the reader sees when everything worked. > **Idea:** Idea > > A suggestion worth trying. > **Tip:** Tip > > `tip` is an alias for `info`. > **Warning:** Warn > > `warn` is an alias for `warning`. > **Info:** Custom icon > > `icon` replaces the type's icon with any lucide icon. ```text <Callout> A callout without a type or a title. </Callout> <Callout type="info" title="Info"> Background information that helps but isn't required. </Callout> <Callout type="warning" title="Warning"> Something that can go wrong if the reader skips it. </Callout> <Callout type="error" title="Error"> Something destructive that can't be undone. </Callout> <Callout type="success" title="Success"> What the reader sees when everything worked. </Callout> <Callout type="idea" title="Idea"> A suggestion worth trying. </Callout> <Callout type="tip" title="Tip"> `tip` is an alias for `info`. </Callout> <Callout type="warn" title="Warn"> `warn` is an alias for `warning`. </Callout> <Callout type="info" title="Custom icon" icon="rocket"> `icon` replaces the type's icon with any lucide icon. </Callout> ``` ## Cards Cards group links. Each card can carry a lucide icon; a card without `href` is static. - [Getting started](https://pholio.lukaloehr.com/docs/getting-started.md): Install Pholio and build a first site. - [Configuration](https://pholio.lukaloehr.com/docs/configuration.md): Every key of pholio.config.php. - **Static**: A card without a link or an icon. - **With an icon**: A static card with an icon. ```text <Cards> <Card title="Getting started" description="Install Pholio and build a first site." href="/docs/getting-started" icon="rocket" /> <Card title="Configuration" description="Every key of pholio.config.php." href="/docs/configuration" icon="settings" /> <Card title="Static" description="A card without a link or an icon." /> <Card title="With an icon" description="A static card with an icon." icon="sparkles" /> </Cards> ``` ## Tabs `items` lists the labels, separated by `|`; each `Tab` names its label in `value`. `defaultIndex` picks the tab that starts open, `updateAnchor` writes the choice into the URL hash. ### macOS Run the installer in Terminal. ### Linux Run the installer in any shell. ### Windows Use WSL and run the installer there. ```text <Tabs items="macOS|Linux|Windows" defaultIndex="1" updateAnchor> <Tab value="macOS"> Run the installer in Terminal. </Tab> <Tab value="Linux"> Run the installer in any shell. </Tab> <Tab value="Windows"> Use WSL and run the installer there. </Tab> </Tabs> ``` ### Grouped tabs Tabs with the same `groupId` switch together; `persist` remembers the choice across pages and visits. #### curl ```bash curl -fsSL https://pholio.lukaloehr.com/install.sh | sh ``` #### Git ```bash git clone https://github.com/luka-loehr/pholio.git ``` #### curl The installer links `pholio` into `~/.local/bin`. #### Git Run `php bin/pholio` from the clone. ````text <Tabs groupId="package" items="curl|Git" persist> <Tab value="curl"> ```bash curl -fsSL https://pholio.lukaloehr.com/install.sh | sh ``` </Tab> <Tab value="Git"> ```bash git clone https://github.com/luka-loehr/pholio.git ``` </Tab> </Tabs> <Tabs groupId="package" items="curl|Git" persist> <Tab value="curl"> The installer links `pholio` into `~/.local/bin`. </Tab> <Tab value="Git"> Run `php bin/pholio` from the clone. </Tab> </Tabs> ```` ## Steps Each `Step` is numbered; a heading inside it becomes the step's title. ### 1. Create a project ```bash pholio init my-docs ``` ### 2. Write a page Add a Markdown file below `content/`. ### 3. Build ```bash pholio build my-docs ``` ````text <Steps> <Step> ### Create a project ```bash pholio init my-docs ``` </Step> <Step> ### Write a page Add a Markdown file below `content/`. </Step> <Step> ### Build ```bash pholio build my-docs ``` </Step> </Steps> ```` ## Accordions With `type="single"` one item is open at a time; `defaultValue` opens items at the start. With `type="multiple"` any number can be open at once. ### What does Pholio need? PHP 8.2 with `mbstring` and `ctype`. Nothing else. ### Where does the output run? On any static host. An `.htaccess` for Apache is written alongside. ### Search Built at compile time, ranked in the browser. ### Agents Every page also exists as Markdown, listed in `llms.txt`. ```text <Accordions type="single" defaultValue="What does Pholio need?"> <Accordion title="What does Pholio need?"> PHP 8.2 with `mbstring` and `ctype`. Nothing else. </Accordion> <Accordion title="Where does the output run?"> On any static host. An `.htaccess` for Apache is written alongside. </Accordion> <Accordion title="Search"> Built at compile time, ranked in the browser. </Accordion> <Accordion title="Agents" id="agents-accordion"> Every page also exists as Markdown, listed in `llms.txt`. </Accordion> </Accordions> ``` ## Files A file tree with nested folders. `defaultOpen` expands a folder, `disabled` keeps it closed, `icon` sets a file's icon. - my-docs/ - pholio.config.php - content/ - index.md - meta.json - guide/ - installation.md - assets/ - public/ ```text <Files> <Folder name="my-docs" defaultOpen> <File name="pholio.config.php" icon="settings" /> <Folder name="content" defaultOpen> <File name="index.md" /> <File name="meta.json" icon="braces" /> <Folder name="guide"> <File name="installation.md" /> </Folder> </Folder> <Folder name="assets" /> <Folder name="public" disabled /> </Folder> </Files> ``` ## Type table Options of an API, with types, defaults and expandable descriptions. - `title`: `string`, required. Site name, shown in the header and in `<title>`. - `language`: `"en" | "de"` ([Any other language needs translations](https://pholio.lukaloehr.com/docs/configuration.md)), default `"en"`. Interface language and default search tokenizer. - `logo_size`: `number`, default `24`. Logo width and height in pixels. - `theme.palette`: `array`, deprecated. An old key, shown struck through. ```text <TypeTable> <TypeProp name="title" type="string" required> Site name, shown in the header and in `<title>`. </TypeProp> <TypeProp name="language" type=""en" | "de"" default=""en"" typeDescription="Any other language needs translations" typeDescriptionLink="/docs/configuration"> Interface language and default search tokenizer. </TypeProp> <TypeProp name="logo_size" type="number" default="24"> Logo width and height in pixels. </TypeProp> <TypeProp name="theme.palette" type="array" deprecated> An old key, shown struck through. </TypeProp> </TypeTable> ``` ## Images ### Screenshot with a dark variant `Screenshot` shows `dark` in the dark color scheme and `src` in the light one. Switch the theme to see both. ![Markdown goes through pholio build and comes out as a static site](https://pholio.lukaloehr.com/docs/assets/components/pipeline-light.svg) ```text <Screenshot src="./assets/components/pipeline-light.svg" dark="./assets/components/pipeline-dark.svg" alt="Markdown goes through pholio build and comes out as a static site" /> ``` ### Image zoom `ImageZoom` opens the image enlarged on click. This SVG follows the color scheme by itself, so one file works in light and dark. ![The build pipeline, enlarged on click](https://pholio.lukaloehr.com/docs/assets/components/pipeline.svg) ```text <ImageZoom src="./assets/components/pipeline.svg" alt="The build pipeline, enlarged on click" width="720" height="200" /> ``` ### Markdown image A plain Markdown image renders as one bordered image, sized from the file. ![The build pipeline](https://pholio.lukaloehr.com/docs/assets/components/pipeline.svg) ```text ![The build pipeline](./assets/components/pipeline.svg) ``` ## Code blocks ### Title and line numbers ```ts title="search.ts" import { createSearch } from './search.js'; const search = createSearch(index); const hits = search.query('install'); ``` ````text ```ts title="search.ts" lineNumbers import { createSearch } from './search.js'; const search = createSearch(index); const hits = search.query('install'); ``` ```` ### Line numbers from another start ```php return [ 'title' => 'My docs', ]; ``` ````text ```php lineNumbers=40 return [ 'title' => 'My docs', ]; ``` ```` ### Highlighted lines and words ```js const hits = search.query('install'); render(hits); search.query('theme'); ``` ````text ```js const hits = search.query('install'); render(hits); search.query('theme'); ``` ```` ### Focus ```css .nd-card { border-radius: 12px; padding: 16px; } ``` ````text ```css .nd-card { border-radius: 12px; padding: 16px; } ``` ```` ### Added and removed lines ```yaml title="config.yaml" theme: hotkey: d hotkey: k ``` ````text ```yaml title="config.yaml" theme: hotkey: d hotkey: k ``` ```` ### Diff language ```diff - 'language' => 'en', + 'language' => 'de', 'title' => 'Handbuch', ``` ````text ```diff - 'language' => 'en', + 'language' => 'de', 'title' => 'Handbuch', ``` ```` ### Code tabs Consecutive code blocks with a `tab` attribute are merged into one tab group. #### Build ```bash pholio build ``` #### Check ```bash pholio check ``` #### Dev ```bash pholio dev ``` ````text ```bash tab="Build" pholio build ``` ```bash tab="Check" pholio check ``` ```bash tab="Dev" pholio dev ``` ```` ### Code as tag content `DynamicCodeBlock` takes its code verbatim from the tag's content. ```json { "title": "Pholio", "pages": ["index", "components"] } ``` ```text <DynamicCodeBlock lang="json"> { "title": "Pholio", "pages": ["index", "components"] } </DynamicCodeBlock> ``` ## Tables Columns align left, center or right. | Command | Writes | Exit code | | :--- | :---: | ---: | | `build` | the site | 0 | | `check` | nothing | 1 on differences | | `dev` | the site, with drafts | 0 | ```text | Command | Writes | Exit code | | :--- | :---: | ---: | | `build` | the site | 0 | | `check` | nothing | 1 on differences | | `dev` | the site, with drafts | 0 | ``` ## Text ### Emphasis, code and links **Bold**, *italic*, ***both***, ~~strikethrough~~, `inline code`, an [internal link](https://pholio.lukaloehr.com/docs/configuration.md) and an [external link](https://commonmark.org). ```text **Bold**, *italic*, ***both***, ~~strikethrough~~, `inline code`, an [internal link](/docs/configuration) and an [external link](https://commonmark.org). ``` ### Hard line breaks Two trailing spaces end a line\ without starting a new paragraph. ```text Two trailing spaces end a line·· without starting a new paragraph. ``` The `··` above stands for the two trailing spaces. ### Blockquote > Write the page you wish you had found the first time. ```text > Write the page you wish you had found the first time. ``` ### Lists 1. Ordered lists count themselves. 2. They nest: - an unordered item - another one 3. And continue afterwards. - [x] Task lists - [ ] with open items ```text 1. Ordered lists count themselves. 2. They nest: - an unordered item - another one 3. And continue afterwards. - [x] Task lists - [ ] with open items ``` ### Footnotes Search runs in a Web Worker[^worker] and needs no backend[^backend]. ```text Search runs in a Web Worker[^worker] and needs no backend[^backend]. [^worker]: So typing never blocks the page. [^backend]: The index is a static JSON file. ``` The footnotes are collected at the end of the page. ### Horizontal rule --- ```text --- ``` ## Headings Headings `##` to `####` get anchors and appear in the table of contents. Hover one to copy its link. #### A fourth-level heading ```text ## Headings #### A fourth-level heading ``` ## Banner A `Banner` is an announcement bar across the top of the page, so it sits outside this showcase. Its source looks like this: ```text <Banner id="release" variant="rainbow"> Pholio 0.1.0 is out. </Banner> ``` [^worker]: So typing never blocks the page. [^backend]: The index is a static JSON file. # Configuration Source: https://pholio.lukaloehr.com/docs/configuration > Every key of pholio.config.php, what it does and what happens when you omit it. The configuration is one PHP file returning one array. It holds data only: no classes, no environment lookups, no side effects. It is optional: every key has a default, so a project directory with a `content/` folder builds without one. `pholio init` writes a small `pholio.config.php`, and `pholio.config.example.php` in the repository root shows every key with comments. Pholio looks for the configuration in this order: the file passed with `--config`; otherwise `pholio.config.php` in the project directory (the `[dir]` argument, default the current directory); otherwise the defaults, with paths relative to the project directory. `--config` together with a `[dir]` argument is a usage error. `src/Config.php` validates the file before anything is built. An unknown key, a value of the wrong type or a planned key set to anything but its default stops the command with exit code 2 and names the key path, for example `pholio: unknown key: search.tokenize (did you mean "tokenizer"?)`. ## Rules for values **Paths** (`content_dir`, `output_dir`, `copy` sources, `content.asset_root`, `theme.palette_css`, `redirects_file`) are absolute or relative to the directory of the configuration file, not to the directory you run `pholio` from. **URL paths** start with `/`. Trailing slashes are optional and removed: every generated URL has none, except the site root `/`. A URL with a scheme (`https://…`) is kept as written. **Placeholders** are resolved in URL and text values: - `{site}`: the `title` - `{home}`: the start page URL, from `base_path` - `{docs}`: the docs root URL, from `docs_path` - `{assets}`: the theme asset URL, from `asset_base` **Planned** keys are part of the schema so a configuration that uses them fails with a clear `planned: <key>` message instead of `unknown key`. They accept only their default value until the feature exists. ## Site | Key | Default | Meaning | | --- | --- | --- | | `title` | `Documentation` | Site name: header wordmark, `<title>`, `{site}` | | `title_template` | `{title} – {site}` | Page `<title>` pattern | | `logo` | `null` | Logo URL next to the wordmark. `null` shows the wordmark only | | `logo_size` | `24` | Logo width and height in pixels | | `home_link` | `{home}` | Where the wordmark links | | `base_url` | `null` | Planned: absolute origin for canonical links, Open Graph and a sitemap | | `base_path` | `/` | URL of the start page. `/docs/` when the site sits in a subfolder | | `docs_path` | `null` | URL of the docs root. `null` uses `base_path` | | `docs_root_suffix` | `null` | Path appended to the docs root page, e.g. `/overview`. Needed when the docs root and the start page would share one URL | | `asset_base` | `pholio/` | URL of the theme's CSS, JavaScript, fonts and licenses. Relative to `base_path` unless it starts with `/` or a scheme. The default keeps `/assets/` free for the site's own files | | `language` | `en` | `<html lang>`, the interface strings and the default search tokenizer. Shipped: `en`, `de`. Any other language needs `translations` for every key | | `translations` | `[]` | Interface string overrides, translation key => text. An unknown key is an error | When a start page is configured and `docs_path` equals `base_path`, a content page at the docs root would collide with the start page. The build stops and asks for `docs_root_suffix`, a different `docs_path`, or `home => null`. ## Input and output | Key | Default | Meaning | | --- | --- | --- | | `content_dir` | `content` | Markdown sources and `meta.json` tree files | | `output_dir` | `public` | Directory of the start page's `index.html`. `pholio build` writes into it and deletes nothing | | `content.extensions` | `['md']` | File extensions read as pages, without the dot | | `content.link_prefix` | `null` | URL prefix of internal links in the content that is rewritten to the docs root. With `'/handbook'`, a link to `/handbook/guide` in the content points at `{docs}/guide` in the output. `null` leaves links as written | | `content.asset_prefix` | `null` | URL prefix of image sources in the content that is rewritten to `content.asset_target`. `null` leaves image sources as written | | `content.asset_target` | `{docs}` | URL that `content.asset_prefix` maps to | | `content.asset_root` | `null` | Directory that mirrors `content.asset_target`, used to read image width and height. `null` resolves images against `content_dir` | | `content.frontmatter_aliases` | `[]` | Extra frontmatter names mapped onto allowed ones, e.g. `['date' => 'updated']` | | `copy` | `assets` => `{home}/assets` | Source directory => URL directory, copied into the output verbatim, without `*.md` files. Without the key, `assets/` is published at `/assets/` when it exists; an explicit `copy` replaces that default | | `output.keep` | `[]` | Paths below `output_dir` that Pholio neither writes nor reports in `pholio check`. A directory covers everything below it | Rewriting happens at a path boundary only: `/handbook` matches `/handbook` and `/handbook/guide`, never `/handbooks`. ## Navigation `nav` is the list of header links, rendered in the header and in the mobile drawer. | Key | Default | Meaning | | --- | --- | --- | | `nav` | `[]` | List of header links | | `nav[].title` | required | Link text | | `nav[].href` | required | Link target. Write internal URLs without a trailing slash, as the generated pages have none | | `nav[].active` | `exact` | `exact`: current on that URL only. `prefix`: also current on every page below it | | `nav[].external` | `false` | Opens in a new tab with `rel="noreferrer noopener"` | | `nav[].icon` | `null` | Planned: lucide icon for the link | | `nav[].icon_only` | `false` | Planned: render the link as an icon button | ## Start page `home` describes the landing page at `base_path`. Leave it `null` to make the first content page the root. | Key | Default | Meaning | | --- | --- | --- | | `home` | `null` | The start page, or `null` for none | | `home.title` | `{site}` | `<title>` of the start page | | `home.hero` | `null` | The hero section, or `null` to leave it out | | `home.hero.kicker` | `''` | Small line above the headline | | `home.hero.headline` | required | The headline. `\n` starts a new line | | `home.hero.lead` | `''` | Paragraph under the headline | | `home.hero.image` | `null` | Background image URL | | `home.hero.image_dark` | `null` | Background image URL for the dark scheme | | `home.hero.icon` | `null` | Square icon URL above the kicker | | `home.hero.icon_size` | `48` | Icon width and height in pixels | | `home.hero.buttons` | `[]` | Buttons under the lead | | `home.hero.buttons[].label` | required | Button text | | `home.hero.buttons[].href` | required | Button target | | `home.hero.buttons[].variant` | `primary` | `primary` or `secondary` | | `home.hero.buttons[].icon` | `null` | lucide icon after the label | | `home.cards` | `null` | The card grid, or `null` to leave it out | | `home.cards.title` | `''` | Heading above the grid | | `home.cards.link_label` | `null` | Link text on each card. `null` uses the translation of `Open(home card)` | | `home.cards.from_tree` | `false` | One card per root folder, from its `meta.json` title, description and icon | | `home.cards.items` | `[]` | Explicit cards, in this order | | `home.cards.items[].title` | required | Card title | | `home.cards.items[].description` | `''` | Card text | | `home.cards.items[].href` | required | Card target | | `home.cards.items[].icon` | `null` | lucide icon | ## Theme The theme has a light and a dark color scheme with static colors. `theme.light` and `theme.dark` map single color token names, without the `--color-fd-` prefix, to CSS colors, and `theme.palette_css` adds a stylesheet of your own. The build writes the token maps and then the stylesheet at the palette marker `/* @pholio:palette */` in `theme/css/tokens.css`. The block is unlayered, so it wins over the default tokens, and the stylesheet wins over the token maps. Anything you leave out keeps its default. | Key | Default | Meaning | | --- | --- | --- | | `theme.light` | `[]` | Token => color for the light scheme | | `theme.dark` | `[]` | Token => color for the dark scheme | | `theme.palette_css` | `null` | Stylesheet inserted after the token maps, for palettes a token map can't express | | `theme.font_class` | `''` | Extra class on `<html>` | | `theme.hotkey` | `d` | Key that toggles the color scheme | | `theme.default_scheme` | `system` | Planned: `light` or `dark` as the scheme before the visitor chooses | | `theme.custom_css` | `null` | Planned: stylesheet appended after the theme CSS | The tokens are `background`, `foreground`, `muted`, `muted-foreground`, `popover`, `popover-foreground`, `card`, `card-foreground`, `border`, `primary`, `primary-foreground`, `secondary`, `secondary-foreground`, `accent`, `accent-foreground` and `ring`. ```php title="pholio.config.php" 'theme' => [ 'light' => ['primary' => 'hsl(220 85% 45%)'], 'dark' => ['primary' => 'hsl(220 90% 70%)'], ], ``` > **Warning:** Give tokens as opaque colors > > The theme derives translucent variants of a token with `color-mix(in oklab, …)`, > the way Tailwind v4 does. A token with its own alpha channel multiplies with > those steps and ends up fainter than intended. ## Document head | Key | Default | Meaning | | --- | --- | --- | | `head.icons` | `[]` | `<link>` icons, in this order | | `head.icons[].rel` | required | `icon`, `apple-touch-icon`, … | | `head.icons[].type` | `null` | MIME type | | `head.icons[].sizes` | `null` | `sizes` attribute | | `head.icons[].href` | required | Icon URL | | `head.manifest` | `null` | Web app manifest URL | | `head.theme_color` | `null` | `<meta name="theme-color">` | ## Search The index is written at build time and loaded by the browser the first time the search dialog opens. | Key | Default | Meaning | | --- | --- | --- | | `search.enabled` | `true` | Planned: `false` to remove the search field, the hotkey and the index | | `search.index_path` | `search-index.json` | Index file, relative to the docs root | | `search.tokenizer` | `null` | `english` or `german`. `null` follows `language`: a language starting with `de` gives `german`, anything else `english` | | `search.hotkey` | `['⌘', 'K']` | Keys shown in the search field | Both tokenizers lowercase, fold diacritics and ligatures (`ä` → `a`, `ß` → `ss`), split at everything but letters and digits and also index hyphenated words joined, so "chat export", "chat-export" and "chatexport" find the same page. `german` additionally reads `ae`, `oe` and `ue` as `a`, `o` and `u`, so "Passwörter" and "Passwoerter" meet. Queries drop common stopwords ("wie", "ich", "the", …; `german` drops German and English ones) unless nothing else is left, and match inflected forms loosely, so "logs" also finds "Logging". Words a page does not use itself can be added with the `keywords` frontmatter key. ## Server | Key | Default | Meaning | | --- | --- | --- | | `redirects` | `[]` | Old path => new path, or a list of `from`, `to`, `reason` | | `redirects_file` | `null` | JSON file with a list of `from`, `to`, `reason`, appended to `redirects` | | `server.htaccess` | `true` | Write `.htaccess` into `output_dir` | | `server.csp` | strict default | `Content-Security-Policy` header in `.htaccess`. Must not contain `"` | The generated `.htaccess` is the only server file Pholio writes. It sets security headers and MIME types, maps slashless page URLs to their `index.html`, and turns every redirect into a 301 rule. A redirect's `from` must lie below `base_path`, and its `to` must be a path. ## Agents What a build publishes for AI agents: a Markdown twin of every page, `llms.txt`, `llms-full.txt`, `skill.md`, the agent card, `robots.txt`, `sitemap.xml`, JSON-LD and the discovery headers. [Agents](https://pholio.lukaloehr.com/docs/agents.md) describes each file. | Key | Default | Meaning | | --- | --- | --- | | `site.url` | `null` | Origin the site is served from, e.g. `https://docs.example.org`, without a path (`base_path` adds it). Makes the URLs in the agent files absolute. `sitemap.xml` and `.well-known/agent-card.json` need it and are skipped with a warning while it is `null` | | `site.description` | `null` | One sentence about the site for `llms.txt`, `skill.md`, the agent card and JSON-LD. `null` uses `home.hero.lead`, then the root `meta.json` description | | `agents.enabled` | `true` | `false` turns every agent file and the page actions off | | `agents.markdown` | `true` | `<page>.md` next to every page, and `index.md` for the start page | | `agents.llms_txt` | `true` | `llms.txt` and `.well-known/llms.txt` | | `agents.llms_full_txt` | `true` | `llms-full.txt` and `.well-known/llms-full.txt` | | `agents.skill` | `true` | `skill.md` and the Agent Skills indexes below `.well-known/` | | `agents.agent_card` | `true` | `.well-known/agent-card.json` (needs `site.url`) | | `agents.robots_txt` | `true` | `robots.txt`, unless a `copy` directory or `output.keep` provides one | | `agents.sitemap` | `true` | `sitemap.xml` (needs `site.url`) | | `agents.structured_data` | `true` | JSON-LD in every page's `<head>` | | `agents.headers` | `true` | The `Link` and `X-Llms-Txt` headers and content negotiation in `.htaccess`, the `_headers` file and `pholio dev` | | `agents.page_actions` | `true` | "Copy page" next to the page title, with a menu to copy the page or open it in ChatGPT or Claude. Off when `agents.markdown` is off | | `agents.instructions` | `null` | Text of the `## Notes for agents` section in `llms.txt` and `skill.md`; the heading follows `language` | | `agents.exclude` | `[]` | Globs matched against page URLs (`/guide/internal/*`) and content file paths (`internal/*.md`); matching pages stay out of `llms.txt`, `llms-full.txt` and `skill.md`. `*` also matches `/` | A page with `noindex: true` in its frontmatter is still built, but left out of `llms.txt`, `llms-full.txt`, `skill.md` and `sitemap.xml`, and gets `<meta name="robots" content="noindex">`. ## Profiles | Key | Default | Meaning | | --- | --- | --- | | `profiles` | `[]` | Profile name => partial configuration | `--profile <name>` merges that partial configuration recursively over the base before validation, for example a staging build under another `base_path`. Unknown keys inside every profile are errors even when the profile isn't selected, and profiles can't be nested. ## Extension points | Key | Default | Meaning | | --- | --- | --- | | `slots` | `[]` | Planned: slot name => PHP file returning HTML | | `components` | `[]` | Planned: custom content tags registered in PHP | | `strict_content` | `false` | Planned: stricter content checks, such as a required description | # Agents Source: https://pholio.lukaloehr.com/docs/agents > What a build publishes for AI agents, where it lives and how servers hand it out. Language models read documentation too: coding agents, chat assistants that fetch a page for a user, crawlers that index it. HTML is an expensive way to give them the text. Every Pholio build therefore also publishes the documentation as Markdown, indexes that list every page, a skill that explains how to use them, and the headers that point agents there. All of it comes from the same page tree as the sidebar and the search index: the order from `meta.json`, titles, descriptions and keywords from the frontmatter, drafts left out. There is nothing to maintain by hand, and nothing is written by a model. ## The files Paths are relative to `base_path`. A site served under `/manuals/` publishes `/manuals/llms.txt`. | File | Contents | Needs | | --- | --- | --- | | `<page>.md`, `index.md` | The Markdown twin of every page and of the start page | | | `llms.txt`, `.well-known/llms.txt` | Index of all pages in navigation order | | | `_llms/**.md` | Section indexes, only when `llms.txt` would grow past 100,000 characters | | | `llms-full.txt`, `.well-known/llms-full.txt` | Every page in full | | | `skill.md` | A skill describing the documentation and how to read it | | | `.well-known/agent-skills/index.json` | Agent Skills discovery index 0.2.0, with digests | | | `.well-known/skills/index.json` | The same skills in the 0.1.0 format | | | `.well-known/agent-card.json` | A2A agent card, protocol 0.3 | `site.url` | | `robots.txt` | Allows every crawler and names the sitemap | | | `sitemap.xml` | Every indexable page | `site.url` | | `_headers` | Discovery headers for Cloudflare Pages and Netlify | | Every HTML page also gets JSON-LD (`WebSite`, `TechArticle`, `BreadcrumbList`), a `<link rel="alternate" type="text/markdown">` to its twin and the page actions next to its title. Set `site.url` to the origin the site is served from. Without it, the files use base-path URLs (`/manuals/guide/install.md`), and the sitemap and the agent card, which need absolute URLs, are skipped with a warning. ```php 'site' => [ 'url' => 'https://docs.example.org', 'description' => 'How to install, configure and run Example.', ], ``` ## Markdown twins A page at `/guide/install` has its twin at `/guide/install.md`; the start page's is `index.md`. The twin is the page's own Markdown, cleaned up: ``` > Documentation index: https://docs.example.org/llms.txt, a list of every page in this documentation. # Installation > Install Example with your package manager of choice. ## Requirements … ## Related topics - [Configuration](https://docs.example.org/guide/configuration.md) - Previous: [Overview](https://docs.example.org/guide.md) - Next: [Configuration](https://docs.example.org/guide/configuration.md) ``` The frontmatter becomes the title and the description. Component tags become what they mean in plain Markdown: | Tag | In the twin | | --- | --- | | `Callout` | A blockquote starting with its type and title: `> **Warning:** Careful` | | `Tabs`, `Accordions`, code tabs | One heading per tab or item, a level below the surrounding heading | | `Cards` | A list of links with their descriptions | | `Steps` | Numbered headings, or an ordered list when a step has no heading | | `Files` | A nested list, folders with a trailing slash | | `TypeTable` | A list of properties with type, default and description | | `Screenshot`, `ImageZoom`, images | Images with their alt text and absolute URLs | | `Banner` | A blockquote | | `InlineTOC` | Left out | Links to other pages point at their twins. Notation comments in code blocks (`// [!code highlight]`) are removed. The first line only states where the index is. Pholio writes nothing into a twin that addresses the reader with a command, because assistants that read fetched pages treat instructions in them as a possible prompt injection and may refuse to follow them. ### Language The headings and fixed sentences Pholio writes into these files follow `language`: the index line, "Related topics" with its previous and next links, callout labels, the type table words, the section labels of `llms.txt`, the `Source:` lines of `llms-full.txt`, the whole generated `skill.md` and the agent card's fallback description. A German site gets "Verwandte Themen", "Quelle" and "Hinweise für Agenten". Field names that a specification defines, such as `name` and `description` in `skill.md` or the keys of the agent card, stay as they are, and so does the `## Optional` heading of `llms.txt`, whose name has a meaning in the llms.txt format. ## llms.txt `llms.txt` follows [llmstxt.org](https://llmstxt.org): the site title, the site description as a blockquote, a `## Notes for agents` section when `agents.instructions` is set, one section per top-level folder with a line per page, and the external navigation links under `## Optional`. ``` # Example > How to install, configure and run Example. ## Guide - [Installation](https://docs.example.org/guide/install.md): Install Example with your package manager of choice. ``` The summary is the page's `description`, or its first paragraph cut at 300 characters. When the file would exceed 100,000 characters, `llms.txt` lists section indexes below `_llms/` instead, with their page counts and a sentence saying that together they list every page. A section index that is still too long lists its subfolders' indexes, and a folder with too many pages is split into parts. No page is ever left out. `llms-full.txt` has every listed page in navigation order: its title, a `Source:` line with the page URL, the description and the whole twin. It has no size limit. ## Skills and the agent card `skill.md` is a [SKILL.md](https://agentskills.io/specification) generated from the configuration and the content: what the documentation covers, section by section, how to fetch `llms.txt` and the Markdown pages, and how to search them. The same file is published below `.well-known/agent-skills/<name>/` and listed in the discovery index with its SHA-256 digest, so agents can tell when it changed. To write the skill yourself, put `skill.md` next to `pholio.config.php`. Further skills go into `skills/<name>/SKILL.md`; a skill with the generated skill's name replaces it. Each file needs a one-line `name` and `description` in its frontmatter. `.well-known/agent-card.json` describes the site as an A2A agent with Markdown and plain text as input and output modes and the skills above. The site is static, so the card advertises no endpoint beyond the documentation. ## Choosing what agents see - Drafts (file names starting with `_`) are never listed. - `noindex: true` in the frontmatter keeps a page out of `llms.txt`, `llms-full.txt`, `skill.md` and the sitemap, and adds `<meta name="robots" content="noindex">`. The page and its twin are still built. - `agents.exclude` takes globs on page URLs and content files, for example `['/guide/internal/*', 'drafts/*.md']`. Matching pages stay out of `llms.txt`, `llms-full.txt` and `skill.md`. - Each file can be switched off in the `agents` block, and `agents.enabled => false` switches off all of them. See [Configuration](https://pholio.lukaloehr.com/docs/configuration.md#agents). - A `robots.txt` that a `copy` directory already provides is left alone. ## Serving Agents find the files through headers and ask for Markdown with the `Accept` header. On every response, 404s included, Pholio's server configurations send: - `Link` with `rel="llms-txt"`, `rel="llms-full-txt"`, `rel="agent-card"` and `rel="agent-skills"`, for the files the build wrote - `X-Llms-Txt` with the URL of `llms.txt` - `Vary: Accept, User-Agent` A page URL is answered with its twin for `Accept: text/markdown` and for the user agents of AI assistants fetching a page for a user (`Claude-User`, `ChatGPT-User`, `OAI-SearchBot`, `PerplexityBot`, `Perplexity-User`, `Google-Agent`, `MistralAI-User`, `DuckAssistBot`, `cohere-ai`), and as `text/plain` for `Accept: text/plain`. Browsers get the HTML page as before. OpenAI's agents (`ChatGPT-User`, `OAI-SearchBot`, `GPTBot`) reject the `text/markdown` content type, so they get every twin as `text/plain; charset=utf-8`, both for a page's `.md` URL and for a negotiated page. Everyone else gets `text/markdown`. The page menu's "Open in ChatGPT" prompt names the page's own URL and mentions its `.md` version; "Open in Claude" names the `.md` URL. Only the Markdown files the build writes are served: a page's twin (`x.md` next to `x/index.html`, `index.md` next to `index.html`), `skill.md`, the `_llms/` indexes and the files below `.well-known/`. Any other `.md` file below the site, such as a note left in the output directory, answers 404. **Apache.** The generated `.htaccess` does all of this with `mod_rewrite` and `mod_headers`. **pholio dev** behaves like production. Try it: ```bash curl -H 'Accept: text/markdown' http://127.0.0.1:8080/guide/install curl -A 'Claude-User' http://127.0.0.1:8080/ ``` **Cloudflare Pages, Netlify, Workers static assets** read the `_headers` file, so the headers work there. Choosing the twin per request needs a small edge function: `examples/cloudflare-worker` in the repository is a ready Worker for Cloudflare, and the rules above are all a Netlify Edge Function has to reproduce. Without one, agents still find everything through `llms.txt` and the `.md` URLs. `robots.txt` only counts at the root of a host. When the site lives below a base path, the generated `robots.txt` below it is ignored by crawlers: merge its lines into the `robots.txt` at the host root. ## Checking a site `verify/agent-score.mjs` runs the checks of Mintlify's `mint score` against any running site: `llms.txt` and `llms-full.txt` (format, size, links), `skill.md` and its digests, content negotiation for Markdown and plain text, `robots.txt`, the sitemap, JSON-LD, the `Link` header on a page and on a 404, and response time. ```bash node verify/agent-score.mjs --base https://docs.example.org node verify/agent-score.mjs --demo --min-score 100 ``` `node verify/run.mjs` runs the second line: the demo served by the `pholio dev` router has to score 100. ## An MCP server Pholio's output is static, so it ships no MCP server. A host that runs PHP could add one later without touching the build: a small endpoint that answers `search` from `search-index.json`, which already ranks pages and sections, and `fetch` from the Markdown twins. It would be listed in the agent card and the `Link` header next to the files above. # Architecture Source: https://pholio.lukaloehr.com/docs/architecture > How a folder of Markdown becomes a static site, and why each stage exists. ## The pipeline ``` pholio.config.php Config.php: validate, apply defaults and profile, normalize content/** Markdown files and one meta.json per folder | v lib/Tree.php meta.json + frontmatter -> page tree, root areas, breadcrumbs, prev/next lib/Markdown.php strict parser -> AST lib/Toc.php, Ids.php headings -> table of contents, stable ids lib/Highlight.php fenced code -> highlighted tokens (TextMate grammars) lib/SearchIndex.php blocks -> index documents lib/AgentSite.php page tree + AST -> Markdown twins, llms.txt, skill.md, sitemap, JSON-LD | v lib/Render.php AST -> HTML, via the components components/*.php pure functions, one file per component templates/document.php the html shell | v output_dir/** index.html and <page>.md per page, search-index.json, agent files, theme assets, .htaccess, _headers ``` The files for AI agents are described in [Agents](https://pholio.lukaloehr.com/docs/agents.md). 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 in `data-` and `aria-` 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`. # Roadmap Source: https://pholio.lukaloehr.com/docs/roadmap > What exists, what is next, and what is deliberately out of scope. ## Now **Status: 0.1.0.** `pholio init` creates a project, `pholio dev` previews it while you edit, `pholio build` writes the static site and `pholio check` compares a build with committed output. The content format covers every component on the [components page](https://pholio.lukaloehr.com/docs/components.md), and every build publishes the files AI agents look for. ## Next 1. **Link and image checks.** Stop the build on an internal link to a page that doesn't exist, on a missing image and on a `Screenshot` whose dark image is missing. 2. **The planned keys.** `base_url` with canonical links and Open Graph; `theme.default_scheme` and `theme.custom_css`; `search.enabled`; icon links in `nav`; `strict_content`. 3. **Extension points.** `slots` for sidebar, table of contents and page footer, and `components` for tags registered in PHP. 4. **More languages.** Interface translations beyond English and German, and more bundled grammars for code blocks. 5. **More consumers,** to find the assumptions that only look general. ## Out of scope These are decisions, not omissions. - **No source extraction.** No docblock parsing, no API reference generation. Documentation is written, not derived. - **No MDX.** Tags carry attributes and nest; they never import or evaluate. - **No runtime PHP.** The output is static files. A PHP runtime in production is never required. - **No package manager.** Pholio is copied in, not installed. That is what "zero dependencies" has to mean to be worth saying.