@kirigami/php-prepros
v3.0.0
Published
PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.
Maintainers
Readme
@kirigami/php-prepros
PHP preprocessor for the Kirigami static site generator.
Overview
Build full static websites in PHP — with zero server, zero runtime dependency, zero compromise on expressiveness. Write your pages as regular PHP files, annotate them with a PHPDOC header, and let php-prepros compile everything to clean, deployable HTML.
It is the perfect solution for GitHub Pages. Since it runs entirely in Node.js, it is fully compatible with GitHub Actions, allowing you to automate your deployment pipeline effortlessly.
Part of the Kirigami project ecosystem.
Table of contents
- @kirigami/php-prepros
- Overview
- What's new in 3.0.0
- What's new in 2.0.0
- What's new in 1.9.3
- What's new in 1.9.2
- What's new in 1.9.1
- What's new in 1.9.0
- What's new in 1.8.0
- What's new in 1.7.2
- What's new in 1.7.1
- What's new in 1.7.0
- What's new in 1.6.0
- What's new in 1.4.0
- What's new in 1.3.0
- What's new in 1.2.1
- What's new in 1.2.0
- How it works
- Installation
- Configuration —
kirigami.yaml - Writing pages
- JavaScript API
- PHP classes reference
- Plugin system
- Extending the
<markdown>tag - Requirements
- License
What's new in 3.0.0
This release switches YAML:: to the native YAML extension, MD:: to native mdhtml, SCHEMA to native jsonk, and Normalizer to native norm. It also includes page types and request lifecycle hooks.
PHP data files use the native yaml extension backed by LibYAML. Its YAML 1.1 implicit booleans include unquoted y, n, yes, no, on, off, true, and false, including mapping keys. Quote these words when you mean strings (for example, "NO": Norway). YAML::parse() / parseFile() / loadFile() preserve the wrapper’s array/object choice; native yaml_parse() / yaml_parse_file() have their own extension signatures. yaml_load_file() remains a wrapper alias. The project’s kirigami.yaml is parsed separately in Node through struct-walker and js-yaml.
MD:: delegates to the native mdhtml extension (cmark-gfm). Footnotes now use <section class="footnotes" data-footnotes> instead of the old <div class="footnotes">; target .footnotes rather than a specific container tag in custom CSS.
SCHEMA validates through the native jsonk extension (draft 2020-12), keeping its API and the "path: message" error format. It now also supports if/then/else, contains, propertyNames, dependentRequired/dependentSchemas, prefixItems, and $ref to absolute or $id-relative URLs (fetched over the network). Error messages use jsonk's wording, and an additionalProperties: false violation is reported on the parent object instead of the extra property. The previous pure-PHP validator stays available as SCHEMA_LEGACY.
Normalizer now comes from the native norm extension (utf8proc) instead of the bundled pure-PHP polyfill, which stays available as NORMALIZER_LEGACY.
Breaking: the seo.jsonld sub-block is merged into seo:. META and LD now read one set of keys, so the site description, keywords, image, language and person are declared once. JSON-LD is injected whenever the seo: block exists; seo.jsonld is only an on/off switch (default true). To migrate, move the keys of seo.jsonld: {…} up into seo: (jsonld.auto: false becomes jsonld: false) and rename seo.language to seo.lang. kiri rejects the old shapes with a message saying so. Sites with seo: {} and no jsonld now get JSON-LD too; add jsonld: false to keep them without it.
# before # after
seo: seo:
language: fr-CA lang: fr-CA
jsonld: type: ProfessionalService
type: ProfessionalService logo: images/logo.png
logo: images/logo.pngWhat's new in 2.0.0
Breaking:
meta:andjsonld:merged into one top-levelseo:block. The two used to be independent siblings ofkirigami:that happened to share fallback data; they're now one block, one mental model for a project's whole SEO/social surface —META's own keys live directly underseo:, andjsonldis nested inside it as its own sub-block:# before (1.x) meta: favicon: favicon.png jsonld: {} # after (2.0.0) seo: favicon: favicon.png jsonld: {}The two stay independently toggled exactly as before — a project can have META's tags without JSON-LD, or vice versa;
seo: { jsonld: false }(or{ auto: false }) stops just the JSON-LD injection,seo: false(or{ auto: false }at the top level) stops just META's. Every fallback chain (a page's PHPDOC → the block → the loosekirigami:keys) is unchanged, includingjsonld's own keys still feeding META's defaults (jsonld.image,jsonld.lang,jsonld.person, …) — only where the two blocks live inkirigami.yamlchanged, not how resolution works. Migration: renamemeta:toseo:and move thejsonld:block's content under it asseo.jsonld:. Seeseoblock /metaconfig /jsonldconfig.
What's new in 1.9.3
- Dependency bump to
@kirigami/struct-walker1.0.5;homepage+ README pointed at the site (metadata only).
What's new in 1.9.2
- Fixed a real Markdown bug: a list item's source line count was 1:1
with
<li>count, so an indented continuation line with no marker of its own (a soft-wrapped- **foo** text\n more text) fell outside the block-matching regex entirely — the list closed after the first line, the continuation resurfaced as a stray flat<p>, and a new list reopened for the next marker line. Fixed by widening the block regex to also accept a marker-less indented line and, in the per-line loop, appending it to the previous item instead of dropping it.
What's new in 1.9.1
{% img-asset %}no longer crashes the build on an unresolvable path.IMG::asset()'s exception is now caught and turned into an HTML comment, matchingcodepen/checklist's own missing-argument behavior instead of aborting the whole render. This also makes it safe to document the tag — a literal`{% img-asset path … %}`written as prose inside a code span still runs the plugin (code-span protection only swaps the displayed output back to the literal text; the callback itself always executes), so a placeholder path used to throw "Invalid image file." and fail the build.
What's new in 1.9.0
{% youtube %}removed — moved to@kirigami/plugin-embed, which replaces the old plain-iframe output with a real oEmbed-backed card (cover thumbnail, title, play button — no network call until the visitor actually clicks play). If a project used the built-in{% youtube %}, install the plugin; without it, the tag now falls through unresolved ({% youtube ID %}printed as-is) rather than rendering an iframe.codepen/checklist/calloutare unaffected.
What's new in 1.8.0
{% img-asset %}— a new built-in Markdown plugin. Same pipeline as the<img asset>HTML tag (IMG::asset(): resize, cache, publish underimage.dest), usable straight from Markdown text:{% img-asset photo.jpg %} {% img-asset photo.jpg 800 %} {% img-asset photo.jpg 800 600 %} {% img-asset photo.jpg 800 600 cover %}Positional args: source path (relative to
image.source), width, height, and the literalcoverkeyword. Registered inmd.plugins.phpalongsidecodepen/youtube/checklist/callout— available out of the box, drop it withMD::unregisterPlugin('img-asset')if you don't want it.
What's new in 1.7.2
Cleaner formatted output around highlighted code. The de-indent script
PREPROS::injectHead()adds whenprepros.formatis on now flattens the<pre><code>indentationHTML::format()writes for every block, including ones a build-time highlighter has wrapped in<span>s. It works oninnerHTMLline by line and removes only the shared leading run (relative indentation is kept). This lets@kirigami/plugin-highlight1.7.2+ re-indent its markup to line up with the rest of the document instead of leaving it flush-left — the served HTML stays consistently indented, the rendered code is still de-indented before the first paint.<markdown prose>wraps in.prose. With theproseattribute the built-in tag emits<div class="prose"> … </div>so long-form Markdown picks up@kirigami/canva'sstyles/prosetypography with no extra markup. Opt-in (a bare<markdown>is unchanged);class/idon the tag land on the wrapper.
What's new in 1.7.1
- No side effects on import.
kirigami.yamlis now loaded on first use (render()/sitemap()/runenv()/processImages()), not while the module is being imported.import '@kirigami/php-prepros'from a directory with no project no longer throws — which is what madekiri build --help/kiri export --help/kiri run --helpcrash instead of printing their help.
What's new in 1.7.0
METAclass — a<head>SEO / social metadata generator, the companion toLD. Builds the standard tags —<title>,description,keywords,robots,language,generator,author, Open Graph, Twitter Card,<link rel="canonical">, favicon / apple-touch-icon / humans — from each page's PHPDOC, the top-levelmeta:block, and the loosekirigami:/jsonld:keysLDalready reads. It emits only what it can resolve, and leaves any tag the layout already hand-writes untouched.- Opt-in: a top-level
meta:block (emptymeta: {}is enough) switches on automatic injection into every page's<head>.meta: false(or{ auto: false }) keeps the config but stops the injection. - Per-page PHPDOC:
@meta false(skip),@meta_title,@meta_description(falls back to@description/@abstract/@excerpt),@meta_keywords,@meta_image,@meta_robots,@meta_type,@canonical. - Manual builders — always emitted, still de-duplicated:
META::tag(),META::link(),META::raw(),META::tags(). Procedural aliases:meta_tag(),meta_link(),meta_raw(),meta_tags().
Full key reference:
META→metaconfig.- Opt-in: a top-level
What's new in 1.6.0
Managed
<head>(prepros.head). Every rendered page's<head>is now auto-wired: a tiny theme/FOUC guard as the first child (adds thejsclass, applies the storeddata-themebefore first paint), a<link rel="stylesheet">for everysasstask output, and a<script>(nodefer, just before</body>) for everyesbuildtask output — each with a per-page relative path and a?<timestamp>cache-bust. A file already referenced in the page is left alone, so you can still hand-place one. Turn it off withprepros: { head: false }, orhead: falseon a single sass/esbuild task. A template'sheader.phpno longer wires assets at all.HTML::format()indents<pre><code>. A fenced code block's lines are shifted to the block's nesting depth so the HTML source stays readable (relative indentation preserved). The exact leading run is stripped again before it's shown, by a small de-indent scriptprepros.headinjects before</body>(only whenformatis on). Since 1.7.2 the script works oninnerHTMLline by line, so it also flattens blocks a build-time highlighter has wrapped in<span>s. A bare<pre>and<textarea>are still emitted byte-for-byte.
What's new in 1.4.0
A round of fixes to the rough edges that showed up building a full site from scratch — mostly developer-experience, all backward compatible.
HTML::format()keeps<pre>/<textarea>verbatim. Their line breaks, indentation and blank lines are no longer collapsed, so a fenced code block survives the formatter intact —format: trueand Markdown code blocks now coexist. (1.6.0 refines this: a<pre><code>block is re-indented to its nesting depth and de-indented again before display.)- The default Markdown plugins load out of the box.
{% callout %},{% youtube %},{% codepen %}and{% checklist %}are registered automatically (md.plugins.phpis auto-included fromMD), as the docs always said. Drop one withMD::unregisterPlugin('name')or shadow it with your ownMD::registerPlugin(). - Build errors you can actually read. A fatal in a template (a bad call, a
nullargument, aTypeError…) comes back as a structured failure with the message, the offending page and thefile:line— never a bareError: undefined. Thetry/catchnow coversThrowable, not justException. PHP warnings and notices no longer sink an otherwise-clean build: they surface aswarningson the result.kiriprints the message, the page, and the tail of the PHP stderr/debug output on failure. - PHPDOC parsing. A tag value may now wrap onto the following indented
continuation lines instead of being silently truncated at the first line. And
an
@wordwritten in the block's prose is ignored rather than overwriting a real tag — only lines that start with@open a tag. FS::getBreadcrumb()/FS::getChildren()are anchored on the page being rendered (PREPROS::$file), so they return the right trail / child list when called from a layout include, a partial, or a helper function — not only straight from the template. Pass an explicit path to override.{% tag %}inside a code span or code block stays literal (`{% badge %}`renders as text) instead of being expanded — or leaking an unrestored placeholder.IMGnever upscales. A requested size larger than the source is clamped down to the source instead of throwing an opaque encoder error (the AVIF encoder in particular).- Build-time tokens expand at render time.
###YEAR###,###TIMESTAMP###and###TODAY###are substituted when each page is generated, sokiri build/kiri watchpreviews show real values, not the literal token (previously onlykiri exportreplaced them). page_infohook robustness. The first built-in callback accepts either the[$file, $info]pair the hook fires with or the bare$infoobject a later callback receives, so a custompage_infohook can't fatal on the argument shape. See PREPROS hooks.
What's new in 1.3.0
LDclass — a schema.org JSON-LD generator. Collects structured-data nodes during a render and emits them as a single<script type="application/ld+json">@graphin every page's<head>.- Automatic injection is opt-in: add a top-level
jsonld:block tokirigami.yaml(even empty,jsonld: {}) and anOrganization(+Person,WebSite,WebPage,BreadcrumbList) graph is derived from that block plus the loose keys projects already carry (person,jobtitle,email,area,knowsabout,keywords,facebook, …). Nojsonld:block → nothing is injected. - Turn it back off with
jsonld: false/jsonld: { auto: false }, or per page with@ld false; per-page@ld_type/@ld_title/@ld_image/ … tags feed the page node, and aBreadcrumbListis built from the_index.phpancestor trail with no opt-in. - Explicit builders for everything else:
LD::add(),LD::article(),LD::faqPage(),LD::breadcrumb(),LD::ref(), and every schema.org type viaLD::typeName([...]). Procedural aliases:ld_add(),ld_organization(),ld_script(), … - A page that already hand-writes an
application/ld+jsonscript is left untouched.
- Automatic injection is opt-in: add a top-level
What's new in 1.2.1
processImages()JS export — batch resize / palette-extraction through theIMGclass (src/imagebatch.php).@kirigami/kirigami'ssasstask now uses it forimg-asset()/colors(), so the whole toolchain is free of a native image dependency (sharpis gone).IMG::save()takes an optional$quality(0-100) for jpg / webp / avif;nullkeeps the per-format default (82).
What's new in 1.2.0
SCHEMAclass — a pure-PHP, dependency-free JSON Schema validator (Draft-7 style, Ajv-like API:isValid()/validate()/getErrors()).IMG::asset()/IMG::palette()— static helpers powering kirigami-core'simg-asset()andcolors()Sass functions: on-demand resize/convert of a source image, and cached representative-colour extraction.<img asset="…">tag — the HTML-side entry point of the image autogenerator, same parameters asIMG::asset()(see Built-in tags).IMGnow handles vector and exotic formats — SVG, EPS, AI, PDF (rasterized via Imagick), plus HEIC / TIFF / BMP, on top of GD's JPEG / PNG / GIF / WebP / AVIF.MDemoji shortcodes —:rocket:→ 🚀 from a large built-in map, extendable withMD::registerEmoji().MDfootnotes and definition lists —[^1]/[^1]: …, andTerm/: ….MDinline HTML is now sanitized against a tag/attribute allowlist rather than passed through verbatim.STR::normalize()— Unicode NFD + combining-mark stripping;STR::slug($str, $sep = '')now takes a separator (pass'-'for a hyphenated slug).- Bundled
Normalizerpolyfill —ext-intlisn't in the WASM build, so a polyfill keepsNormalizer::normalize()(andSTR::normalize()/slug()) working.
Earlier, in 1.1.x: PREPROS::mount() + the mountPath() / runenv() JS
exports, the SCRAPER and CURL and ARR classes, YAML::loadFile(), the
STR::is_url() / html_entities_decode() / shorthash() / slug() helpers,
the {% youtube %} / {% codepen %} / {% checklist %} MD plugins, and the
@content / @indent PHPDOC annotations.
How it works
@kirigami/php-prepros runs your PHP source files inside a WebAssembly PHP 8.x runtime (@kirigami/php-wasm), entirely in Node.js — no PHP installation required on the host machine.
The lifecycle of a page build looks like this:
_index.php ──▶ PHP (wasm) ──▶ processTags() ──▶ HTML::format() ──▶ index.html
│
├── before.php (optional layout header)
├── after.php (optional layout footer)
└── PHPDOC annotations resolved (yaml / json / md / url)Files are mounted into the WebAssembly virtual filesystem on demand. Only .php, .json, .yaml, .yml, .md, .db, .txt and any extra extensions listed in prepros.mountext are mounted automatically, keeping memory usage low. Anything else can be mounted on demand with PREPROS::mount().
Installation
npm install @kirigami/php-preprosConfiguration — kirigami.yaml
Every project must have a kirigami.yaml at its root. The preprocessor reads it at startup and throws if it is absent or invalid.
@kirigami/php-prepros consumes project data, SEO, preprocessing, image settings, and task metadata for managed head injection. The core engine owns plugin loading, task orchestration, export, and full schema validation. Direct use of this package is not a substitute for core configuration validation. All settings share kirigami.yaml; its schema is kirigami.schema.json.
# yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/php-kirigami/kirigami@main/packages/kirigami/kirigami.schema.jsonkirigami:
# ── Required ──────────────────────────────────────────────────────────
project: My Website # Site name. Printed in the CLI banner, exposed as $project.
baseurl: https://example.com # Deployed root URL, no trailing slash. Used for sitemap.xml.
root: src # Source directory containing your _*.php pages.
# ── Optional ────────────────────────────────────────────────────────
banner: assets/banner.txt # Text file stamped as a license banner on exported files.
# ── Arbitrary project data ──────────────────────────────────────────
# Everything else under `kirigami:` is free-form. The whole block is
# extracted as PHP variables and made available in every page, in
# before.php/after.php, and anywhere PREPROS::$config->data is read.
author: Jane Doe
email: [email protected]
gtag: G-XXXXXXXXXX
description: A short description of the site, useful for <meta name="description">.
keywords:
- keyword one
- keyword two
seo: # Presence turns on the META tags and LD JSON-LD (`seo: {}` is enough).
type: Organization # JSON-LD main entity; `jsonld: false` turns the JSON-LD off.
logo: assets/logo.png
prepros:
before: _layouts/header.php # Included before every page body.
after: _layouts/footer.php # Included after every page body.
format: true # Pretty-print the HTML output (default: false).
network: true # Allow HTTP fetches in PHPDOC @tag annotations / CURL / SCRAPER.
mountext: # Extra file extensions to auto-mount into the wasm fs,
- .svg # in addition to the defaults (.php .json .yaml .yml .md .db .txt).
- .webp
includes: # PHP files auto-included once, before any page renders.
- _lib/functions.php
types: # Named page types — opt in per page with @type <name>.
article:
before: _layouts/types/article.header.php
after: _layouts/types/article.footer.php
image: # Image autogenerator — powers IMG::asset() / IMG::palette().
format: webp # webp | avif (default: webp)
source: assets/images # Source folder, relative to cwd() (default: assets/images)
dest: images # Output folder, relative to kirigami.root (default: images)
plugins:
- name: "@kirigami/plugin-highlight"
active: true
options:
theme: auto
esbuild:
# minify: false
sass:
style: expanded
export:
path: dist
ignore: ["*.psd", "notes/"]
scripts:
- name: convert-images
mount: ["assets/images/**/*.jpg"]
trigger: before-build # before-build | before-export | after-export
tasks:
- name: js-core
type: esbuild
entry: scripts/kirigami.core.js
- name: scss-core
type: sass
entry: styles/kirigami.core.scsskirigami block
Core project settings. Read by php-prepros. The entire block is extracted into PHP variables and made available in every page template, before.php, after.php, and prepros.includes files — $project, $author, $gtag, etc. are available with no further setup, and also as PREPROS::$config->data.
| Key | Required | Description |
|-----|----------|--------------|
| project | ✅ | Human-readable site name. Exposed as $project. |
| baseurl | ✅ | Root URL of the deployed site, no trailing slash. Used to build absolute <loc> entries in sitemap.xml; exposed as $baseurl. |
| root | ✅ | Path (relative to the project root) to the directory containing your _*.php source pages. Build fails immediately if missing or if the path doesn't exist. |
| banner | — | Path (relative to the project root) to a text file stamped as a license/copyright banner on exported .js/.css/.html files during kiri export. May contain the ###DATE### token, replaced with today's date. Falls back to an auto-generated banner. |
| anything else | — | Free-form key/value pairs (strings, numbers, booleans, lists, nested maps — anything valid YAML). Every key is extracted as a PHP variable ($author, $gtag, …). Use this for contact info, social links, analytics IDs, SEO keywords, or any project data you want available everywhere. When the top-level seo block is present, LD also reads some of these by convention: person, jobtitle, email, area, knowsabout, keywords, and social-network URL keys (facebook, instagram, …). |
seo block
Top-level, optional. The SEO surface: one set of keys feeds both the
META tags (the standard SEO / social <meta> and <link> tags) and
the LD schema.org JSON-LD graph, and its presence switches both on
for every page's <head>. An empty seo: {} is enough; everything is derived
from the kirigami block and each page's PHPDOC (@title, @description /
@abstract, @keywords, @image, @robots, @og_type, @canonical). A
tag or script the layout already hand-writes is left untouched.
auto: falsestops META's tags,jsonld: falsestops the JSON-LD; the config values stay available toMETA::tags()/LD::script().seo: falsekeeps nothing on; no block at all means nothing is injected (explicitMETA::tag()/LD::add()calls still emit).
Full key reference: seo config. Per-page tags:
@meta_* and @ld_*.
prepros block
Global before and after files are optional and default to null; an empty prepros: {} renders without a layout. When provided, these paths must refer to existing files. PHP warnings are logged to stderr and returned as diagnostics, without being inserted into generated HTML.
Options for the PHP → HTML compiler. Read by php-prepros. Declaring this block (even empty) also makes kiri prepend a forced prepros task on every build/export/watch.
| Key | Type | Default | Description |
|-----|------|---------|--------------|
| before | string | — | Path (relative to kirigami.root) to a PHP file included before every page's body. Typically your <head>/layout opening. |
| after | string | — | Path (relative to kirigami.root) to a PHP file included after every page's body. Typically your layout closing. |
| format | bool | false | Pretty-print the compiled HTML via HTML::format() before writing it to disk. |
| head | bool | true | Auto-wire each page's <head>: a theme/FOUC guard as the first child, a <link rel="stylesheet"> per sass task output, and a <script> (no defer, before </body>) per esbuild task output — each with a per-page relative path and a ?<timestamp> cache-bust. A file already referenced in the page is skipped. Set false to disable, or head: false on a single sass/esbuild task to skip just its tag. |
| network | bool | false | Enables outbound HTTP(S) inside the WASM PHP runtime. Required for PHPDOC @tag https://… annotations that fetch remote .yaml/.json/.md data (see Auto-loading data files), and for the CURL / SCRAPER classes. |
| mountext | string[] | [] | Extra file extensions to mount automatically into the virtual filesystem alongside the built-in .php, .json, .yaml, .yml, .md, .db, .txt. Use this for assets your PHP code reads directly (e.g. .svg, .webp). Files with extensions not in this set are skipped during mounting — mount them on demand with PREPROS::mount() instead. |
| includes | string[] | [] | PHP files (relative to kirigami.root) include_once'd once, right after config is loaded — before any page renders. The natural place to PREPROS::registerTag(), PREPROS::registerHook(), or MD::registerPlugin(). |
| types | object | {} | Named page types. A page opts in with @type <name> in its PHPDOC header; the matching entry's before/after (each string, relative to kirigami.root, both optional) wrap the page body one level inside the global before/after — render order is global before → type before → body → type after → global after. A page with no @type, or naming a type absent here, renders with just the global wrap. See @type. |
image block
Options for the image autogenerator. Read by php-prepros — these are what IMG::asset() / IMG::palette(), the <img asset> tag, and kirigami-core's img-asset() / colors() Sass functions all resolve against. Optional; the defaults below apply even when the block is absent.
| Key | Type | Default | Description |
|-----|------|---------|--------------|
| format | string | webp | Output format for generated images: webp or avif. |
| source | string | assets/images | Folder holding the source images, relative to cwd(). |
| dest | string | images | Destination folder for generated images, relative to kirigami.root. |
plugins block
List of Kirigami plugins. Consumed by the kiri CLI (see @kirigami/sdk), not by php-prepros directly.
| Key | Required | Description |
|-----|----------|--------------|
| name | ✅ | Plugin package name. Must match @kirigami/plugin-*, <scope>/kirigami-plugin-*, or kirigami-plugin-*. |
| active | ✅ | Whether the plugin is loaded. |
| options | — | Free-form object passed to the plugin; its shape depends on the plugin. |
esbuild / sass blocks
Free-form objects. Consumed by the kiri CLI. There is no fixed key set: whatever you put here is spread straight into the underlying library call for every matching task, after Kirigami's own defaults — so it can also override them (minify, target, style: "compressed", source maps, …). Refer to esbuild's BuildOptions and Dart Sass's Options for what's accepted. Writing the key with nothing under it parses to null in YAML, equivalent to omitting the block.
sass: additionally recognizes two keys that are not passed to Dart Sass:
| Key | Type | Description |
|-----|------|--------------|
| before | string / string[] | Extra .scss files compiled before the task entry (paths relative to cwd()). |
| after | string / string[] | Extra .scss files compiled after the task entry. |
export block
Options for kiri export. Consumed by the kiri CLI. Optional.
| Key | Type | Default | Description |
|-----|------|---------|--------------|
| path | string | dist | Output directory for kiri export, relative to the project root. |
| ignore | string[] | [] | Extra gitignore-style patterns excluded from the export copy, on top of Kirigami's built-in exclusions. |
scripts block
Named PHP scripts. Consumed by the kiri CLI, which runs each scripts/<name>.php through runenv() — so the full php-prepros class library is available and kirigami.yaml's kirigami block is exposed as PREPROS::$config->data.
| Key | Required | Description |
|-----|----------|--------------|
| name | ✅ | Must match an existing scripts/<name>.php file. Run with kiri run <name> [args...]; extra CLI arguments are forwarded as $argv entries. |
| mount | — | Glob patterns (relative to the project root) of extra local files to mount into the sandbox before the script runs. |
| trigger | — | Fire the script automatically: before-build (start of build and export), before-export (very start of export), or after-export (once export has finished). |
tasks block
Ordered list of build tasks, run in array order. Consumed by the kiri CLI, on top of the implicit prepros task (added when the prepros block is present) and the implicit dist task (added during kiri export).
| type | Purpose | Required fields | Optional |
|--------|---------|-----------------|----------|
| esbuild | Bundle/minify a JS/TS entry. Build + watch. Output: <entry>.min.js. | name, type, entry | force |
| sass | Compile a .scss/.sass entry, minified with csso on export. Build + watch. Output: <entry>.min.css. | name, type, entry | force |
| prepros | Render pages + sitemap.xml. Watch only (runs on build/export only when forced/implicit). | name, type | target, force |
| dist | Copy kirigami.root into an output dir, stamping the banner. Forced/implicit only. | name, type, path | ignore, force |
Writing pages
Source pages live in the directory pointed to by kirigami.root. The naming convention is straightforward: any file whose name starts with _ and ends in .php is treated as a page source. The leading underscore is stripped in the output filename.
src/
├── _layouts/
├── _lib/
├── _index.php → src/index.html
├── about/
│ └── _index.php → src/about/index.html
└── blog/
├── _index.php → src/blog/index.html
└── _articles.yaml (data file, not compiled)Directories whose name starts with _ (e.g. _layouts/, _lib/) are skipped entirely during directory-wide builds.
PHPDOC header
Every page starts with a PHP docblock that drives metadata and data loading:
<?php
/**
* @name about
* @title About us
* @abstract A short description of this page.
*/
?>
<section>
<h1><?php echo $title; ?></h1>
<p><?php echo $abstract; ?></p>
</section>All annotations are injected as PHP variables ($name, $title, $abstract, …). You can define any custom annotation you need.
Annotations are also available as variables in before and after PHP included files, so you can write proper metas in the HTML header.
Only lines whose first non-whitespace character (past the * gutter) is @
open an annotation — an @word written in the prose of the block is left alone.
A value can wrap onto the following indented continuation lines:
/**
* @title About us
* @description A longer blurb that does not fit comfortably
* on a single line and continues here.
*/Auto-loading data files
When an annotation value looks like a filename (with a .yaml, .yml, .json, or .md extension), it is automatically parsed and injected as a structured variable instead of a plain string.
<?php
/**
* @name medias
* @articles _articles.yaml
*/
?>
<?php foreach ($articles as $article): ?>
<a href="<?php echo $article->lien; ?>">
<?php echo $article->titre; ?>
</a>
<?php endforeach; ?>| Extension | Parsed as |
|-----------|-----------|
| .yaml / .yml | stdClass object (or array of objects for sequences) |
| .json | Result of json_decode() |
| .md | HTML string via MD::toHtml() |
When network: true is set in kirigami.yaml, annotation values that start with http:// or https:// are fetched from the network and parsed the same way:
/**
* @posts https://api.example.com/posts.json
*/@content, @indent, and @type
Three special annotation names change how a page's body is assembled:
@content— if acontentvariable already resolves to a non-empty value (typically because it's a.md/.yaml/.jsonannotation that auto-loaded into HTML/data, see above), it is used as-is as the page body, and the PHP file itself is not executed for its output. This is handy for pages that are pure data/markdown wrapped by a shared layout.@indent— when set to a number, every line of the rendered body is prefixed with that many spaces before being wrapped bybefore.php/after.php. Useful for keeping generated HTML readable when a page is nested inside indented layout markup.@type— names an entry underprepros.types. If it matches, that entry'sbefore/afterwrap the (already-indented) body one level insidebefore.php/after.php: global before → type before → body → type after → global after. No match (missing annotation, or a name absent fromprepros.types) leaves the page with just the global wrap — a page type is an extra layer, never a replacement for the site's real header/footer.
<?php
/**
* @name changelog
* @title Changelog
* @content _changelog.md
* @indent 4
* @type article
*/Built-in tags
Two tags are registered out of the box (prepros.plugins.php) and processed
after the PHP runs, on the assembled HTML — no include or plugin needed.
<markdown> … </markdown>
Converts its inner content from Markdown to HTML, stripping the common leading
indentation first (via STR::trimIndent()) so you can indent it naturally inside
your template. All registered MD plugins work inside it. See
Extending the <markdown> tag to override it.
<section>
<markdown>
## Who we are
We are a **student organization** from Québec.
</markdown>
</section>Add the prose attribute — <markdown prose> — to wrap the output in
<div class="prose">, so it picks up the long-form typography of
@kirigami/canva's styles/prose
with no extra markup. Any class / id on the tag lands on that wrapper
(<markdown prose class="lede" id="intro"> → <div class="prose lede" id="intro">).
A bare <markdown> emits just the converted HTML, as before.
<img asset="…">
The HTML-side entry point of the image autogenerator — the exact same feature as
the img-asset() Sass function
and IMG::asset(), with the same parameters. The tag calls IMG::asset()
under the hood, then swaps the asset attribute for the generated src.
<!-- in: resolves assets/images/hero.jpg through IMG::asset('hero.jpg', 800, 0, false) -->
<img asset="hero.jpg" width="800" alt="Our office" loading="lazy">
<!-- out: <img src="../images/hero-800w.webp" alt="Our office" loading="lazy"> -->| Attribute | Maps to IMG::asset() arg | Notes |
|-----------|---------------------------|-------|
| asset | $path | Required. Path relative to image.source. Missing/empty ⇒ the tag is left untouched. |
| width | $width | Optional, integer. Omitted ⇒ 0 (keep). |
| height | $height | Optional, integer. Omitted ⇒ 0 (keep). |
| cover | $cover | Boolean — presence means true (crop + fill). |
| (any other) | — | alt, class, id, loading, … are passed straight through onto the output <img>. |
asset / width / height / cover are consumed and removed; everything else
survives. The generated file lands in image.dest and is only (re)generated when
missing or older than the source — see IMG for the naming convention.
The Sass
img-asset()/colors()functions, the<img asset>tag andIMG::asset()all run on the same engine — theIMGclass (GD, with the Imagick fallback) in this package.@kirigami/kirigami'ssasstask routes its image work here throughprocessImages(), so there is no native image dependency in the toolchain.
JavaScript API
import { render, sitemap, runenv, mountPath, processImages, resetRuntime } from '@kirigami/php-prepros';PHP operations are queued in call order, including mounts and result extraction.
await resetRuntime() waits for preceding PHP work, disposes the owned runtime
and its network proxy, and clears cached configuration and mounts. The next
operation initializes a fresh runtime from kirigami.yaml. Core
Project.reload() calls this and also clears the plugin PHP include list.
It does not clear persistent cache/cookie files on disk. This remains a
single-project API whose working directory must be set before import.
render(file?, phpIncludes?)
phpIncludes defaults to []. The core collects it through prepros:php;
direct callers supply local PHP file paths (absolute or relative to the
working directory). Existing paths are mounted under /plugins/ and included
before rendering; missing paths are silently skipped. Each render replaces
the runtime include list, which remains on its configuration until another
render or reset. Direct calls do not load the core plugin registry for you.
Compile a single PHP page or a whole directory.
// Compile one page
const pageResult = await render('about/_index.php');
// Compile everything under src/
const treeResult = await render('.');
// Compile everything (uses kirigami.root from config)
const defaultResult = await render();Paths used by
render()are all relative to thekirigami.rootconfiguration.
Returns Promise<PreprosResult>:
interface PreprosResult {
success: boolean;
files?: string[]; // project-relative paths; may be absent on parsing failure
error?: string;
debug?: string; // captured PHP stdout
stderr?: string; // diagnostics attached to failures
warnings?: string; // nonfatal stderr on success
page?: string | null; // PHP-render failure context, when available
where?: string; // PHP source location, when available
}Setup and filesystem failures can reject before a result exists; PHP failures
usually return success: false. Check both channels. Files may already have
been copied to the host before a later error; operations are not transactional.
The runtime returns debug/stderr, not the older declared response field.
Directory rendering selects _*.php files only when every directory between
kirigami.root and the page has a name without a leading underscore. Sitemap
selection uses the same rule. Direct requests for private pages fail; rendering
a private directory produces no pages. The configured source root itself may
start with _ (for example _src). Previously generated private HTML is not
deleted by this selection rule; remove stale outputs when migrating a site.
sitemap()
Generate sitemap.xml and robots.txt at the source root, plus humans.txt
when author configuration provides content. It accepts no directory argument.
const result = await sitemap();
// With kirigami.root: src, files includes src/sitemap.xml and src/robots.txt.runenv(script, paths?, ...args)
Run an arbitrary PHP script — not a page template — inside the very same sandboxed WASM environment used for render(), with the full php-prepros class library autoloaded and kirigami.yaml's kirigami block available as PREPROS::$config->data. Useful for one-off maintenance scripts, data migrations, or CLI-style tooling that needs CACHE, SCRAPER, IMG, etc. without going through the page-rendering pipeline.
// Run a standalone PHP script
const purgeResult = await runenv('scripts/purge-cache.php');
// Also mount explicit extra files into the sandbox before running
const imageResult = await runenv('scripts/build-og-images.php', ['assets/photos/hero.jpg']);
// Extra arguments are appended and available as $argv[2], $argv[3], … in the script
const importResult = await runenv('scripts/import.php', [], '--force');script— path to a PHP file inside the project, executed withrequire_once.paths— optional array of explicit local file paths, not directories. Missing files are skipped; a directory can cause a filesystem rejection. UsemountPath()first for recursive directory mounting....args— extra string arguments appended to the script's$argv.
Script and extra-file paths resolve against the project captured at import.
Before initializing PHP or copying files, runenv() rejects paths outside
that project, including symbolic links whose real targets are outside it.
Directories are rejected; missing optional files are skipped. This check does
not make PHP scripts untrusted-code sandboxes or restrict explicit mountPath()
calls.
Returns Promise<PreprosResult>, following the same shape as render(). Inside the script, call PREPROS::exportFile() for any file you want listed in result.files.
runPluginScript(script, pluginRoot, paths?, ...args)
Same as runenv(), for a script shipped inside a plugin package. A plugin
installed with npm link or from a workspace lives outside the project, so
runenv() would reject it. Here the script's authored and real paths must stay
inside pluginRoot instead, and it is mounted under
/plugin-scripts/<package dir>/. Extra paths are still project files.
The caller vouches for pluginRoot: @kirigami/kirigami only passes the
resolved package directory of an active plugin.
mountPath(localPath, virtualDir?, php?)
The JavaScript-side counterpart to PREPROS::mount(). Mounts a local file or directory — recursively, preserving structure — into the WASM sandbox's virtual filesystem, ahead of (or between) calls to render(), sitemap(), or runenv(). Useful when a Node-side build step needs to make extra local files visible to PHP before rendering starts.
import { mountPath, render } from '@kirigami/php-prepros';
// Mount a single file at its natural virtual path (/project/<relative path>)
await mountPath('assets/data/team.yaml');
// Mount a whole directory, at a custom virtual path
await mountPath('vendor/fonts', '/project/fonts');
await render();localPath— path to a local file or directory. Relative paths are resolved against the project root.virtualDir— optional destination path inside the WASM filesystem. Defaults to/project/<localPath relative to the project root>when omitted.php— optional WASM PHP instance to mount into. Defaults to PHP-prepros's owned instance (the same one used internally byrender()/sitemap()/runenv()), creating it if needed. This is separate from PHP-WASM's shared getter instances and is replaced afterresetRuntime().
Mounting a directory only copies files whose extension is one of the defaults (.php, .json, .yaml, .yml, .md, .db, .txt) or listed in prepros.mountext, same as automatic root mounting. Mounting a single file directly copies it regardless of extension — this is the simplest way to make an arbitrary asset (an image, a font, a CSV, …) available to PHP without adding its extension to prepros.mountext project-wide.
Returns Promise<void>.
processImages(jobs)
Run a batch of image jobs — resize/encode, or palette extraction — through the
IMG class (GD, with the Imagick fallback). This is the engine
@kirigami/kirigami's sass task uses for its img-asset() and colors()
functions, so Sass, IMG::asset() and the <img asset> tag
all share one implementation, one image: config and one set of output
filenames — with no native image dependency.
import { processImages } from '@kirigami/php-prepros';
const { files, colors } = await processImages([
// resize/encode `hero.jpg` (resolved against image.source) to each dest —
// absolute virtual paths, already carrying the target extension
{ op: 'resize', src: 'hero.jpg', width: 1200, height: 0, cover: false, quality: 82,
dests: ['/project/src/images/hero-1200w.webp'] },
// extract a 5-colour palette (cached in .cache.db); returned, not written
{ op: 'palette', src: 'hero.jpg', count: 5 },
]);
// files includes 'src/images/hero-1200w.webp' and may include '.cache.db'.
// colors → { 'hero.jpg:5': ['#1e3a5f', '#c8a24b', …] }jobs— array ofresize/palettejobs (see the shape above). An empty array is a no-op and does not start the WASM runtime.- Omitted
jobsalso returns{ success: true, files: [], colors: {} }without starting PHP. Non-array input currently does the same; this is not strict input validation. - Staleness is the caller's responsibility: every
resizejob listed is executed. resize:width/heightdefault to0(preserve size when both are zero),covertofalse, and lossy encoder quality to82.destscontains absolute/project/...output paths.palettedefaultscountto5.- The PHP worker handles
paletteexplicitly and treats any otheropas a resize; pass only the two documented operations. Processing stops at the first exception. Always checksuccessbefore using files or colors.
Returns Promise<PreprosResult & { colors: Record<string, string[]> }>.
resetRuntime()
Returns Promise<void>. Queued after preceding operations, it disposes the
owned runtime, mounts, and cached configuration. It does not change the project
path captured at import, clear disk caches, or reset the SDK hook registry.
TypeScript declarations
The shipped index.d.ts includes render(file?, phpIncludes?), argument-free
sitemap(), explicit-file runenv() mounts, and the current diagnostic fields.
PreprosResult.files is optional because response parsing can fail before a
file list exists. ImageBatchResult.files is always normalized to an array.
The obsolete response field is replaced by debug and stderr.
PHP classes reference
All classes are autoloaded — no manual require needed inside your page files.
The autoloader itself, $argv/$config, procedural aliases, and the boot
hook are installed via php.ini's auto_prepend_file (pointed at
utils.inc.php), set once per WASM runtime instance — every entrypoint
(prepros.php, runenv.php, imagebatch.php) gets it automatically,
with no include of its own.
PREPROS
The core engine. Manages the rendering pipeline, tag processing, hooks, mounting, and file export.
// Available inside page templates and included files.
PREPROS::$config // stdClass — full resolved config; ->data is the kirigami: block,
// ->image the image: block, plus before/after/format/… from prepros:
PREPROS::registerTag(string $tag, callable $callback)
PREPROS::registerHook(string $hook, callable $callback)
PREPROS::runHook(string $hook, mixed $data = null) // fire a hook (built-in or your own), returns the piped $data
PREPROS::mount(string|array $patterns)
PREPROS::exportFile(string|array $absolutePath)
PREPROS::getExportedFiles(): string[]
PREPROS::fstat(string $path) // stat a file in the WASM FS (or false)
PREPROS::backtraceFile() // path of the page currently renderingPREPROS::render(string $file)
Internal method called once per source file. Orchestrates the full pipeline:
- Resolves PHPDOC metadata and auto-loads data files.
- Fires the
pre_renderhook with the raw source contents. - Includes
before.php(wrapped in thepre_before/post_beforehooks) and the page body (or@content, see above). - If the page declares
@type <name>andprepros.types.<name>exists, wraps the body with that type'sbefore/after(wrapped inpre_type_before/post_type_beforeandpre_type_after/post_type_after) — nested inside the global wrap. - Includes
after.php(wrapped inpre_after/post_after), assembling everything into a single string. - Processes all registered custom HTML tags.
- Fires the
post_renderhook on the assembled HTML. - Optionally pretty-prints via
HTML::format()(whenformat: true). - Writes the output
.htmlfile.
PREPROS::sitemap()
Scans the source tree for _index.php files and generates a standards-compliant sitemap.xml (Sitemaps 0.9), using kirigami.baseurl as the root URL.
PREPROS::mount(string|array $patterns)
Mounts additional local project files into the WASM virtual filesystem, on demand, from one or more glob patterns evaluated against the project root (via picomatch). Unlike the automatic mounting done for kirigami.root (limited to .php, .json, .yaml, .yml, .md, .db, .txt, and prepros.mountext), mount() copies any matching file, regardless of extension.
// Mount every .webp under assets/, wherever the page needs them
PREPROS::mount('assets/**/*.webp');
// Multiple patterns at once
PREPROS::mount(['data/**/*.csv', 'vendor/fonts/*.woff2']);Returns an array of the virtual paths (under /project/...) that were mounted, or false on failure.
PREPROS::exportFile(string $file)
Marks a file as a build output so it gets surfaced in PreprosResult.files. Called automatically by render(), sitemap(), CACHE::set(), and CURL. Call it manually if your custom code writes additional files.
MD
Markdown-to-HTML converter with a plugin system for custom shortcodes,
backed by PHP's native mdhtml extension (real cmark-gfm), statically
built into @kirigami/php-wasm — no userland parsing.
$html = MD::toHtml(string $markdown): string;Supports the full GitHub Flavored Markdown subset, plus a few extensions:
- ATX (
#…######) and Setext headings, with auto-generatedidattributes - Ordered and unordered lists, including nested
- GFM task lists (
- [ ]/- [x]) - GFM tables with column alignment
- GFM alerts (
> [!NOTE],> [!WARNING], etc.) - Blockquotes (recursive)
- Fenced code blocks with language class
- Inline code
- Bold, italic, bold+italic, strikethrough
- Links with automatic
target="_blank" rel="noopener noreferrer"for external URLs - Images with
loading="lazy" - Auto-linked bare URLs
- Horizontal rules
- Hard line breaks (trailing double space →
<br>) - Footnotes —
[^1]references and[^1]: …definitions (multi-paragraph) - Definition lists —
Term/: Definition - Emoji shortcodes —
:rocket:→ 🚀, from a built-in map (seeMD::registerEmoji()) - Sanitized inline HTML — raw tags are filtered against an allowlist of tags and attributes, not passed through verbatim
Plugin API
Extend Markdown with custom shortcode tags:
// Inline tag {% tagname arg1 "arg with spaces" %}
// Block tag {% tagname arg1
// body content
// %}
MD::registerPlugin(string $name, callable $callback): void
MD::unregisterPlugin(string $name): void
MD::getRegisteredPlugins(): string[]
MD::registerEmoji(string $shortcode, string $char): void // `:name:` → charThe callback always receives (array $args, string $body):
MD::registerPlugin('video', function (array $args, string $body): string {
$src = htmlspecialchars($args[0] ?? '', ENT_QUOTES, 'UTF-8');
return "<video src=\"{$src}\" controls></video>";
});Then in any Markdown content (including inside <markdown> tags):
{% video /videos/intro.mp4 %}HTML
Pretty-printer for the final HTML output. Used automatically when format: true is set in the config.
$formatted = HTML::format(string $html): string;Uses PHP 8.4's Dom\HTMLDocument (Lexbor engine) to parse the input and re-serialize it with consistent 4-space indentation. Inline elements, <script>, and <style> blocks are handled correctly — their content is indented but not reformatted. A <pre><code> block is shifted to its nesting depth too (relative indentation kept), and the leading run is stripped again before display; a bare <pre> and <textarea> stay byte-for-byte. Boolean HTML5 attributes (muted, autoplay, noopener, etc.) are written without a value.
YAML
PHP data files use the native yaml extension backed by LibYAML. Its YAML 1.1 implicit booleans include unquoted y, n, yes, no, on, off, true, and false, including mapping keys. Quote these words when you mean strings (for example, "NO": Norway). YAML::parse() / parseFile() / loadFile() preserve the wrapper’s array/object choice; native yaml_parse() / yaml_parse_file() have their own extension signatures. yaml_load_file() remains a wrapper alias. The project’s kirigami.yaml is parsed separately in Node through struct-walker and js-yaml.
A YAML parser backed by PHP's native yaml extension (libyaml), statically built into @kirigami/php-wasm — full YAML 1.1 support, no userland parsing.
$data = YAML::parse(string $yaml, bool $assoc = false): mixed;
$data = YAML::parseFile(string $path, bool $assoc = false): mixed;
$data = YAML::loadFile(string $path, bool $assoc = false): mixed;By default, YAML mappings are returned as stdClass objects. Pass true as the second argument to get associative arrays instead.
Following YAML 1.1 means the usual implicit-boolean gotcha applies to both values and mapping keys: y/Y/n/N, yes/no, true/false, on/off (any case) all resolve to a boolean when unquoted — so an unquoted no: key or a NO value becomes false. Quote a scalar ("y": 2) to keep it a string.
YAML::loadFile() behaves like YAML::parseFile(), then walks the result recursively: any string value ending in .yaml, .yml, or .json that resolves to an existing file (relative to its own file's directory) is replaced by that file's parsed content, and so on, recursively. Values that don't match an existing file are left untouched. Circular references (A → B → A) throw a RuntimeException.
# team.yaml
lead: people/jane.yaml # resolved and inlined automatically
members:
- people/jane.yaml
- people/john.yaml$team = YAML::loadFile('/project/data/team.yaml');
// $team->lead is now the fully parsed content of people/jane.yaml, not a stringSCHEMA
A JSON Schema validator with an Ajv-like API, backed by the native jsonk
extension built into @kirigami/php-wasm. Available to your own code and
plugins.
$validator = new SCHEMA(array $schema);
$validator->isValid(mixed $data): bool // true / false
$validator->validate(mixed $data): bool // alias of isValid()
$validator->getErrors(): string[] // "path: message" strings from the last runjsonk implements draft 2020-12 for a self-contained schema: every validation
keyword (type, enum, const, required, properties,
patternProperties, additionalProperties, propertyNames,
dependentRequired, dependentSchemas, items, prefixItems, contains,
uniqueItems, the min*/max* and exclusive* bounds, multipleOf,
pattern, format, if/then/else, allOf/anyOf/oneOf/not) and
$ref to #/$defs/… / #/definitions/…, absolute URLs, or URLs relative to
the schema's $id. See php-jsonk
for the details and limits.
Schemas are PHP arrays, so SCHEMA adapts them before handing them to jsonk:
an empty array in a schema position ('properties' => []) is treated as {},
draft-07 tuple items (a list of schemas) becomes prefixItems (and
additionalItems becomes items), and format: url is read as uri. Error
paths look like (root), name or tags[1].
The previous pure-PHP (Draft-7 style) validator is still available as
SCHEMA_LEGACY, with the same API.
$validator = new SCHEMA([
'type' => 'object',
'required' => ['name', 'age'],
'properties' => [
'name' => ['type' => 'string', 'minLength' => 1],
'age' => ['type' => 'integer', 'minimum' => 0],
],
'additionalProperties' => false,
]);
if (!$validator->isValid($data)) {
foreach ($validator->getErrors() as $err) echo $err, PHP_EOL;
}LD
A schema.org JSON-LD generator. LD accumulates structured-data nodes for
the page under render and emits them as one
<script type="application/ld+json"> block — with an @graph when there is more
than one node — in the <head>.
Automatic mode
On as soon as kirigami.yaml has a seo: block (seo: {} is
enough), alongside META's tags and from the same keys. A
post_render hook injects a graph built from seo:, the loose keys of the
kirigami block, and the current page's PHPDOC:
- an
Organizationnode (@id#organization) —name/url/descriptionfromproject/baseurl/description,sameAsgathered from every recognised social-network URL key (facebook,instagram,linkedin,github,youtube,mastodon, …), plusemail,telephone,areaServed(←area),knowsAbout(←knowsabout),address,logo, andfounder→ the Person node when there is one.@typecomes fromseo.type; - a
Personnode (#person) whenpersonis set —name+jobTitle(←jobtitle) +email+url, linked to the Organization viaworksFor; - a
WebSitenode (#website) —publisher→ Organization,inLanguage,keywords(←keywords), and aSearchActionwhenseo.searchis set; - a
WebPagenode for the page — see the per-page tags below; - a
BreadcrumbListfor every non-home page, derived from the_index.phpancestor trail (home → each parent section → this page). No@breadcrumbopt-in needed. Disable it for one page with@ld_breadcrumb false.
seo: { jsonld: false } stops the automatic pass; META's tags keep working.
A page whose rendered <head> already contains an application/ld+json
script is never touched, so hand-rolled markup keeps working.
Per-page PHPDOC tags — these feed the page node (and override the generic
@title / @description / @datePublished fallbacks):
| Tag | Effect |
|-----|--------|
| @ld false | Skip JSON-LD for this page entirely (@ld_ignore true also works). |
| @ld_type <Type> | @type of th
