Build a site

Writing pages

The PHPDOC header, auto-loaded data files, built-in tags, and the hooks a plugin registers into.

Project structure

Inside kirigami.root:

  • A file _name.php is a page source; it compiles to name.html in the same directory (leading _ stripped). _index.php → index.html, exactly like this page.
  • A directory whose name starts with _ (_layouts/, _lib/) is skipped during directory-wide builds — use it for partials, layouts, includes, data.
  • Data files (.yaml, .yml, .json, .md) are not compiled; they're loaded by pages via PHPDOC annotations, below. The one exception is an _index.md that starts with annotations: that is a Markdown page.

PHPDOC header

Every page starts with a docblock. Each @key value becomes a PHP variable ($key), available in the page and in the before / after includes.


                        /**
                         * @name     about
                         * @title    About us
                         * @abstract A short description of this page.
                         */
                        
                        echo '<h1>' . str_htmlesc($title) . '</h1>';
                        echo '<p>' . str_htmlesc($abstract) . '</p>';
                    

Define any keys you want — before / after typically read $title, $description, etc. to build <head> metas (though with the seo: block on, you rarely need to by hand — see the config reference).

Markdown pages

A page can be pure Markdown: an _index.md whose first lines are annotations, written like the PHPDOC tags but without the comment around them.


                        @title    Hello, world
                        @type     post
                        @date     2026-09-01
                        @abstract The first post.
                        
                        Some **Markdown** text: the page body.
                    
  • The header is the @tag value lines at the top of the file (leading blank lines are allowed), with the same rules as a PHPDOC block: a value wraps onto indented continuation lines. It ends at the first blank line, or at the first flush-left line that isn't a tag, and that is where the body starts.
  • The body goes through MD::toHtml() and becomes $content, so @type, @indent and the layouts work as they do for a PHP page. PHP in the file is never run. @content other.md in the header replaces the body with another file.
  • An _index.md without a header isn't a page: it stays a data file. So does one next to an _index.php, which is the page of that folder and can load the .md through an annotation.
  • Only _index.md is a page. Other _*.md files are data files, as before.

A Markdown page is the easiest kind for someone else to write, which is why Kiri Studio can create and edit them, and why the blog and kiridoc templates are built on them.

Inherited annotations

A tag written with two @ applies to the page and every page below it: the pages of its subfolders, and the other pages of its own folder when it is an _index.


                        /**
                         * @title Blog
                         * @@type post            // every page under blog/ is a post
                         * @@menu _menu.yaml      // loaded from blog/, wherever the page is
                         */
                    
  • A child's @tag overrides the inherited value for that page only: its own children still get the ancestor's value.
  • A child's @@tag overrides it and passes the new value down.
  • The nearest ancestor wins. Values come from the page file of each folder above (_index.php, else _index.md), up to kirigami.root.
  • A relative data-file value (.yaml, .yml, .json, .md) is resolved against the folder of the page that declared it.
  • It works in PHPDOC blocks and Markdown headers alike. The entries of FS::getChildren() and FS::getBreadcrumb() include inherited values too, so @@breadcrumb true turns the breadcrumb on for a whole section.

Auto-loaded data files

When an annotation value ends in .yaml, .yml, .json, or .md and resolves to a file relative to the page's own directory, it's parsed and injected as structured data instead of a plain string:

Extension Becomes
.yaml / .yml stdClass (or an array of stdClass for sequences), via YAML::parseFile()
.json json_decode() result
.md HTML string, via MD::toHtml()

                        /**
                         * @name     medias
                         * @articles _articles.yaml
                         */
                        
                        foreach ($articles as $a) {
                            echo '<a href="' . str_htmlesc($a->url) . '">' . str_htmlesc($a->title) . '</a>';
                        }
                    

With prepros.network: true, a value starting with http:// / https:// is fetched and parsed the same way — this page's own hub, and /ecosystem/, use exactly that (via curl_get_contents()

  • CACHE, not the auto-load shorthand, since they need a TTL).

Content and indent

  • @content — if a content variable resolves to a non-empty value (typically an auto-loaded .md / .yaml / .json annotation), it's used as-is as the page body, and the PHP file is not executed for output. Good for pure data/markdown pages wrapped by a shared layout.
  • @indent N — prefixes every line of the rendered body with N spaces before before / after wrap it, keeping generated HTML readable inside an indented layout.

                        /**
                         * @name    changelog
                         * @title   Changelog
                         * @content _changelog.md
                         * @indent  4
                         */
                    

Page types

When several pages share the same chrome (a page head, a sidebar, prev/next links), put it in a page type instead of copying it into each page. Declare the type under prepros.types, with a before and/or after file:


                        prepros:
                          before: _layouts/header.php
                          after:  _layouts/footer.php
                          types:
                            article:
                              before: _layouts/types/article.before.php
                              after:  _layouts/types/article.after.php
                    

A page opts in with @type, and then only writes its own content:


                        /**
                         * @title    Folding a crane
                         * @abstract Thirty folds, one sheet, no cuts.
                         * @type     article
                         */
                    

The page is assembled as global before → type before → body → type after → global after. The type's files see the same variables as the page ($title, $abstract, $relroot, …), so they can print the heading, a breadcrumb or navigation built from them. A page has one type at most; a page without @type, or with an unknown one, gets the global wrap only.

This documentation is an example: every page here is @type docs, whose layout draws the sidebar, the page head and the "On this page" box, so a docs page is just its Markdown.

Variables in scope

Injected by PREPROS::render(): every loose key from the kirigami: block ($project, $baseurl, $author, …), every PHPDOC annotation on the current page, plus $relroot (relative path from the page's directory back to kirigami.root — use it for any asset URL that has to work at any depth) and $absurl (the page's absolute URL path, including any subfolder in baseurl — safe as an href / src root).

Render pipeline, per page: resolve PHPDOC + auto-load data → page_info hook → pre_render hook (raw source) → include before + body (or @content) + after → process registered tags → post_render hook → HTML::format() if format: true → write .html.

Built-in tags

Processed after PHP runs, on the assembled HTML:

  • <markdown> … </markdown> — converts its body from Markdown to HTML, stripping common leading indentation first. Every registered MD plugin (below) works inside it. Add prose (<markdown prose>) to wrap the output in .prose (@kirigami/canva's prose partial) — class / id on the tag land on that wrapper. This whole page is one such block.
  • <img asset="path/in/assets-images.jpg" width="450" height="300" cover> — resolves through the image autogenerator, calling IMG::asset() with the same parameters (asset → $path; width / height optional ints; cover presence = true, crop to fill) and swapping asset for the generated src. Any other attribute (alt, class, loading, …) passes straight through. Empty or missing asset leaves the tag untouched.

One authoring gotcha with tags: a tag shown as an example (<markdown>, <img asset>, a plugin tag) must sit in a code span or a fenced block, which the tag pass leaves alone. Written as bare text it runs for real, and a bare </markdown> closes the block around it. An indented code block doesn't count, since <markdown> bodies are indented themselves: use a fence. And never type a real PHP opening tag as example text, short-echo form included: PHP doesn't know it's sitting inside a fenced code example, and runs it for real. Every snippet on this page is written the way it has to be because of that rule — statements only, no opening tag shown.

Registering tags and hooks

Register in a prepros.includes file — this site's own _lib/functions.php is exactly that; it's where kirigami_page_href() and kirigami_breadcrumb_nav(), used across this /docs/ section, live.


                        PREPROS::registerTag('gallery', function (string $tag, array $attrs, string $body): string {
                            // ... return HTML
                        });
                        
                        PREPROS::registerHook('post_render', function (string $html): string {
                            return str_replace('{{build_date}}', date('Y-m-d'), $html);
                        });
                    
Hook Fires $data Return
page_info after PHPDOC parse, before render [$filePath, $pageObject] $pageObject
pre_render before PHP execution raw source string string
post_render after tag processing, before HTML::format() assembled HTML string string

Multiple callbacks per hook run in registration order, chained. The full PREPROS API — mount(), exportFile(), fstat(), backtraceFile(), $config — is in the PHP class library.

MD plugins

MD::registerPlugin('video', fn(array $args, string $body): string => …) — works inside <markdown> blocks, .md data files, and any MD::toHtml() call. Kirigami ships a handful by default (md.plugins.php): {% callout info|success|warning|danger ["Title"] content %}, {% img-asset path [w [h [cover]]] %}, {% codepen <id> [user h] %}, {% checklist ["Title"] … %} — inline, or block form with the body on following lines ending in %}. {% youtube <id> %} / {% vimeo <id> %} moved into @kirigami/plugin-embed — not a default anymore, since resolving the id into a real card (oEmbed lookup, cover thumbnail, play button) needs the plugin's client-side script, not just a Markdown shortcut.

Sass hooks

A plugin package, listed under plugins: in kirigami.yaml, can contribute to sass tasks via @kirigami/sdk:


                        import { on, HOOKS } from '@kirigami/sdk';
                        on(HOOKS.SASS_BEFORE,    (ctx) => '/abs/path/to/before.scss');
                        on(HOOKS.SASS_AFTER,     (ctx) => '/abs/path/to/after.scss');
                        on(HOOKS.SASS_FUNCTIONS, (ctx) => ({ 'my-fn($x)': (args) => /* SassValue */ }));
                    

ctx is { __root, task, exportPath, config }. On a signature collision with a native Sass function, the native one wins. This is exactly how @kirigami/plugin-highlight appends its code theme after every project's own styles, and how esbuild:before / esbuild:after / esbuild:plugins mirror the same idea for JS bundles, plus prepros:html — a waterfall hook transforming each rendered page's HTML (what plugin-highlight's own colouring pass runs on).