@kirigami/php-prepros 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1887 +1,1988 @@
1
- <div align="center">
2
-
3
- <img src="https://zmotrin.github.io/assets/kirigami/kirigami-logo-universal.svg" alt="Kirigami" width="400" />
4
-
5
- ---
6
-
7
- # @kirigami/php-prepros
8
-
9
-
10
- PHP preprocessor for the **Kirigami** static site generator.
11
-
12
- [![npm version](https://img.shields.io/npm/v/@kirigami/php-prepros)](https://www.npmjs.com/package/@kirigami/php-prepros)
13
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
14
- [![Node.js >=24.0.0](https://img.shields.io/badge/node-%3E%3D24.0.0-brightgreen)](https://nodejs.org)
15
- [![Website](https://img.shields.io/badge/website-php--kirigami.github.io-1f6b4a)](https://php-kirigami.github.io)
16
-
17
- </div>
18
-
19
- ---
20
-
21
- ## Overview
22
-
23
- 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.
24
-
25
- 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.
26
-
27
- Part of the **Kirigami** project ecosystem.
28
-
29
-
30
- ---
31
-
32
-
33
- ## Table of contents
34
-
35
- - [@kirigami/php-prepros](#kirigamiphp-prepros)
36
- - [Overview](#overview)
37
- - [Table of contents](#table-of-contents)
38
- - [What's new in 2.0.0](#whats-new-in-200)
39
- - [What's new in 1.9.3](#whats-new-in-193)
40
- - [What's new in 1.9.2](#whats-new-in-192)
41
- - [What's new in 1.9.1](#whats-new-in-191)
42
- - [What's new in 1.9.0](#whats-new-in-190)
43
- - [What's new in 1.8.0](#whats-new-in-180)
44
- - [What's new in 1.7.2](#whats-new-in-172)
45
- - [What's new in 1.7.1](#whats-new-in-171)
46
- - [What's new in 1.7.0](#whats-new-in-170)
47
- - [What's new in 1.6.0](#whats-new-in-160)
48
- - [What's new in 1.4.0](#whats-new-in-140)
49
- - [What's new in 1.3.0](#whats-new-in-130)
50
- - [What's new in 1.2.1](#whats-new-in-121)
51
- - [What's new in 1.2.0](#whats-new-in-120)
52
- - [How it works](#how-it-works)
53
- - [Installation](#installation)
54
- - [Configuration — `kirigami.yaml`](#configuration--kirigamiyaml)
55
- - [`kirigami` block](#kirigami-block)
56
- - [`seo` block](#seo-block)
57
- - [`prepros` block](#prepros-block)
58
- - [`image` block](#image-block)
59
- - [`plugins` block](#plugins-block)
60
- - [`esbuild` / `sass` blocks](#esbuild--sass-blocks)
61
- - [`export` block](#export-block)
62
- - [`scripts` block](#scripts-block)
63
- - [`tasks` block](#tasks-block)
64
- - [Writing pages](#writing-pages)
65
- - [PHPDOC header](#phpdoc-header)
66
- - [Auto-loading data files](#auto-loading-data-files)
67
- - [`@content` and `@indent`](#content-and-indent)
68
- - [Built-in tags](#built-in-tags)
69
- - [JavaScript API](#javascript-api)
70
- - [`render(file?)`](#renderfile)
71
- - [`sitemap()`](#sitemap)
72
- - [`runenv(script, paths?, ...args)`](#runenvscript-paths-args)
73
- - [`mountPath(localPath, virtualDir?, php?)`](#mountpathlocalpath-virtualdir-php)
74
- - [`processImages(jobs)`](#processimagesjobs)
75
- - [PHP classes reference](#php-classes-reference)
76
- - [PREPROS](#prepros)
77
- - [`PREPROS::render(string $file)`](#preprosrenderstring-file)
78
- - [`PREPROS::sitemap()`](#preprossitemap)
79
- - [`PREPROS::mount(string|array $patterns)`](#preprosmountstringarray-patterns)
80
- - [`PREPROS::exportFile(string $file)`](#preprosexportfilestring-file)
81
- - [MD](#md)
82
- - [Plugin API](#plugin-api)
83
- - [HTML](#html)
84
- - [YAML](#yaml)
85
- - [SCHEMA](#schema)
86
- - [LD](#ld)
87
- - [Automatic mode](#automatic-mode)
88
- - [Explicit builders](#explicit-builders)
89
- - [`jsonld` config](#jsonld-config)
90
- - [META](#meta)
91
- - [`meta` config](#meta-config)
92
- - [CACHE](#cache)
93
- - [IMG](#img)
94
- - [FS](#fs)
95
- - [STR](#str)
96
- - [ARR](#arr)
97
- - [CURL](#curl)
98
- - [SCRAPER](#scraper)
99
- - [OBF](#obf)
100
- - [STD](#std)
101
- - [Bundled polyfills](#bundled-polyfills)
102
- - [Procedural shortcuts (aliases)](#procedural-shortcuts-aliases)
103
- - [Plugin system](#plugin-system)
104
- - [PREPROS tags](#prepros-tags)
105
- - [PREPROS hooks](#prepros-hooks)
106
- - [MD plugins](#md-plugins)
107
- - [Built-in plugins](#built-in-plugins)
108
- - [`{% callout type ["Title"] content %}`](#-callout-type-title-content-)
109
- - [`{% youtube id [width height] %}`](#-youtube-id-width-height-)
110
- - [`{% codepen id [user height] %}`](#-codepen-id-user-height-)
111
- - [`{% checklist ["Title"] items %}`](#-checklist-title-items-)
112
- - [Extending the `<markdown>` tag](#extending-the-markdown-tag)
113
- - [Requirements](#requirements)
114
- - [License](#license)
115
-
116
- ---
117
-
118
- ## What's new in 2.0.0
119
-
120
- - **Breaking: `meta:` and `jsonld:` merged into one top-level `seo:` block.**
121
- The two used to be independent siblings of `kirigami:` that happened to
122
- share fallback data; they're now one block, one mental model for a
123
- project's whole SEO/social surface — `META`'s own keys live directly under
124
- `seo:`, and `jsonld` is nested inside it as its own sub-block:
125
-
126
- ```yaml
127
- # before (1.x)
128
- meta:
129
- favicon: favicon.png
130
- jsonld: {}
131
-
132
- # after (2.0.0)
133
- seo:
134
- favicon: favicon.png
135
- jsonld: {}
136
- ```
137
-
138
- The two stay **independently toggled** exactly as before — a project can
139
- have META's tags without JSON-LD, or vice versa; `seo: { jsonld: false }`
140
- (or `{ auto: false }`) stops just the JSON-LD injection, `seo: false` (or
141
- `{ auto: false }` at the top level) stops just META's. Every fallback chain
142
- (a page's PHPDOC → the block → the loose `kirigami:` keys) is unchanged,
143
- including `jsonld`'s own keys still feeding META's defaults (`jsonld.image`,
144
- `jsonld.lang`, `jsonld.person`, …) — only *where the two blocks live* in
145
- `kirigami.yaml` changed, not how resolution works. **Migration**: rename
146
- `meta:` to `seo:` and move the `jsonld:` block's content under it as
147
- `seo.jsonld:`. See [`seo` block](#seo-block) / [`meta` config](#meta-config)
148
- / [`jsonld` config](#jsonld-config).
149
-
150
- ---
151
-
152
- ## What's new in 1.9.3
153
-
154
- - Dependency bump to `@kirigami/struct-walker` 1.0.5; `homepage` + README
155
- pointed at the site (metadata only).
156
-
157
- ---
158
-
159
- ## What's new in 1.9.2
160
-
161
- - **Fixed a real Markdown bug**: a list item's source line count was 1:1
162
- with `<li>` count, so an indented continuation line with no marker of its
163
- own (a soft-wrapped `- **foo** text\n more text`) fell outside the
164
- block-matching regex entirely — the list closed after the first line, the
165
- continuation resurfaced as a stray flat `<p>`, and a new list reopened for
166
- the next marker line. Fixed by widening the block regex to also accept a
167
- marker-less indented line and, in the per-line loop, appending it to the
168
- previous item instead of dropping it.
169
-
170
- ---
171
-
172
- ## What's new in 1.9.1
173
-
174
- - **`{% img-asset %}` no longer crashes the build on an unresolvable path.**
175
- `IMG::asset()`'s exception is now caught and turned into an HTML comment,
176
- matching `codepen`/`checklist`'s own missing-argument behavior instead of
177
- aborting the whole render. This also makes it safe to *document* the tag —
178
- a literal `` `{% img-asset path … %}` `` written as prose inside a code
179
- span still runs the plugin (code-span protection only swaps the *displayed*
180
- output back to the literal text; the callback itself always executes), so
181
- a placeholder path used to throw "Invalid image file." and fail the build.
182
-
183
- ---
184
-
185
- ## What's new in 1.9.0
186
-
187
- - **`{% youtube %}` removed** — moved to
188
- [`@kirigami/plugin-embed`](https://www.npmjs.com/package/@kirigami/plugin-embed),
189
- which replaces the old plain-iframe output with a real oEmbed-backed card
190
- (cover thumbnail, title, play button — no network call until the visitor
191
- actually clicks play). If a project used the built-in `{% youtube %}`,
192
- install the plugin; without it, the tag now falls through unresolved
193
- (`{% youtube ID %}` printed as-is) rather than rendering an iframe.
194
- `codepen`/`checklist`/`callout` are unaffected.
195
-
196
- ---
197
-
198
- ## What's new in 1.8.0
199
-
200
- - **`{% img-asset %}` — a new built-in Markdown plugin.** Same pipeline as the
201
- `<img asset>` HTML tag (`IMG::asset()`: resize, cache, publish under
202
- `image.dest`), usable straight from Markdown text:
203
-
204
- ```
205
- {% img-asset photo.jpg %}
206
- {% img-asset photo.jpg 800 %}
207
- {% img-asset photo.jpg 800 600 %}
208
- {% img-asset photo.jpg 800 600 cover %}
209
- ```
210
-
211
- Positional args: source path (relative to `image.source`), width, height,
212
- and the literal `cover` keyword. Registered in `md.plugins.php` alongside
213
- `codepen`/`youtube`/`checklist`/`callout` — available out of the box, drop
214
- it with `MD::unregisterPlugin('img-asset')` if you don't want it.
215
-
216
- ---
217
-
218
- ## What's new in 1.7.2
219
-
220
- - **Cleaner formatted output around highlighted code.** The de-indent script
221
- `PREPROS::injectHead()` adds when `prepros.format` is on now flattens the
222
- `<pre><code>` indentation `HTML::format()` writes for *every* block, including
223
- ones a build-time highlighter has wrapped in `<span>`s. It works on
224
- `innerHTML` line by line and removes only the shared leading run (relative
225
- indentation is kept). This lets [`@kirigami/plugin-highlight`](https://www.npmjs.com/package/@kirigami/plugin-highlight)
226
- 1.7.2+ re-indent its markup to line up with the rest of the document instead
227
- of leaving it flush-left — the served HTML stays consistently indented, the
228
- rendered code is still de-indented before the first paint.
229
-
230
- - **`<markdown prose>` wraps in `.prose`.** With the `prose` attribute the
231
- built-in tag emits `<div class="prose"> … </div>` so long-form Markdown picks
232
- up `@kirigami/canva`'s `styles/prose` typography with no extra markup. Opt-in
233
- (a bare `<markdown>` is unchanged); `class` / `id` on the tag land on the
234
- wrapper.
235
-
236
- ---
237
-
238
- ## What's new in 1.7.1
239
-
240
- - **No side effects on import.** `kirigami.yaml` is now loaded on first use
241
- (`render()` / `sitemap()` / `runenv()` / `processImages()`), not while the
242
- module is being imported. `import '@kirigami/php-prepros'` from a directory
243
- with no project no longer throws — which is what made `kiri build --help` /
244
- `kiri export --help` / `kiri run --help` crash instead of printing their help.
245
-
246
- ---
247
-
248
- ## What's new in 1.7.0
249
-
250
- - **`META`** class — a `<head>` SEO / social metadata generator, the companion
251
- to [`LD`](#ld). Builds the standard tags — `<title>`, `description`,
252
- `keywords`, `robots`, `language`, `generator`, `author`, Open Graph, Twitter
253
- Card, `<link rel="canonical">`, favicon / apple-touch-icon / humans — from
254
- each page's PHPDOC, the top-level `meta:` block, and the loose `kirigami:` /
255
- `jsonld:` keys `LD` already reads. It emits only what it can resolve, and
256
- leaves any tag the layout already hand-writes untouched.
257
-
258
- - **Opt-in**: a top-level `meta:` block (empty `meta: {}` is enough) switches
259
- on automatic injection into every page's `<head>`. `meta: false` (or
260
- `{ auto: false }`) keeps the config but stops the injection.
261
- - Per-page PHPDOC: `@meta false` (skip), `@meta_title`, `@meta_description`
262
- (falls back to `@description` / `@abstract` / `@excerpt`), `@meta_keywords`,
263
- `@meta_image`, `@meta_robots`, `@meta_type`, `@canonical`.
264
- - Manual builders — always emitted, still de-duplicated: `META::tag()`,
265
- `META::link()`, `META::raw()`, `META::tags()`. Procedural aliases:
266
- `meta_tag()`, `meta_link()`, `meta_raw()`, `meta_tags()`.
267
-
268
- Full key reference: [`META` → `meta` config](#meta-config).
269
-
270
- ---
271
-
272
- ## What's new in 1.6.0
273
-
274
- - **Managed `<head>` (`prepros.head`).** Every rendered page's `<head>` is now
275
- auto-wired: a tiny theme/FOUC guard as the first child (adds the `js` class,
276
- applies the stored `data-theme` before first paint), a
277
- `<link rel="stylesheet">` for every `sass` task output, and a `<script>` (no
278
- `defer`, just before `</body>`) for every `esbuild` task output — each with a
279
- per-page relative path and a `?<timestamp>` cache-bust. A file already
280
- referenced in the page is left alone, so you can still hand-place one. Turn it
281
- off with `prepros: { head: false }`, or `head: false` on a single sass/esbuild
282
- task. A template's `header.php` no longer wires assets at all.
283
-
284
- - **`HTML::format()` indents `<pre><code>`.** A fenced code block's lines are
285
- shifted to the block's nesting depth so the HTML source stays readable
286
- (relative indentation preserved). The exact leading run is stripped again
287
- before it's shown, by a small de-indent script `prepros.head` injects before
288
- `</body>` (only when `format` is on). Since 1.7.2 the script works on
289
- `innerHTML` line by line, so it also flattens blocks a build-time highlighter
290
- has wrapped in `<span>`s. A bare `<pre>` and `<textarea>` are still emitted
291
- byte-for-byte.
292
-
293
- ---
294
-
295
- ## What's new in 1.4.0
296
-
297
- A round of fixes to the rough edges that showed up building a full site from
298
- scratch — mostly developer-experience, all backward compatible.
299
-
300
- - **`HTML::format()` keeps `<pre>` / `<textarea>` verbatim.** Their line breaks,
301
- indentation and blank lines are no longer collapsed, so a fenced code block
302
- survives the formatter intact — `format: true` and Markdown code blocks now
303
- coexist. (1.6.0 refines this: a `<pre><code>` block is re-indented to its
304
- nesting depth and de-indented again before display.)
305
- - **The default Markdown plugins load out of the box.** `{% callout %}`,
306
- `{% youtube %}`, `{% codepen %}` and `{% checklist %}` are registered
307
- automatically (`md.plugins.php` is auto-included from `MD`), as the docs always
308
- said. Drop one with `MD::unregisterPlugin('name')` or shadow it with your own
309
- `MD::registerPlugin()`.
310
- - **Build errors you can actually read.** A fatal in a template (a bad call, a
311
- `null` argument, a `TypeError`…) comes back as a structured failure with the
312
- message, the offending page and the `file:line` — never a bare
313
- `Error: undefined`. The `try/catch` now covers `Throwable`, not just
314
- `Exception`. PHP warnings and notices no longer sink an otherwise-clean build:
315
- they surface as `warnings` on the result. `kiri` prints the message, the page,
316
- and the tail of the PHP stderr/debug output on failure.
317
- - **PHPDOC parsing.** A tag value may now wrap onto the following *indented*
318
- continuation lines instead of being silently truncated at the first line. And
319
- an `@word` written in the block's prose is ignored rather than overwriting a
320
- real tag — only lines that *start* with `@` open a tag.
321
- - **`FS::getBreadcrumb()` / `FS::getChildren()`** are anchored on the page being
322
- rendered (`PREPROS::$file`), so they return the right trail / child list when
323
- called from a layout include, a partial, or a helper function — not only
324
- straight from the template. Pass an explicit path to override.
325
- - **`{% tag %}` inside a code span or code block stays literal** (`` `{% badge %}` ``
326
- renders as text) instead of being expanded — or leaking an unrestored
327
- placeholder.
328
- - **`IMG` never upscales.** A requested size larger than the source is clamped
329
- down to the source instead of throwing an opaque encoder error (the AVIF
330
- encoder in particular).
331
- - **Build-time tokens expand at render time.** `###YEAR###`, `###TIMESTAMP###`
332
- and `###TODAY###` are substituted when each page is generated, so
333
- `kiri build` / `kiri watch` previews show real values, not the literal token
334
- (previously only `kiri export` replaced them).
335
- - **`page_info` hook robustness.** The first built-in callback accepts either the
336
- `[$file, $info]` pair the hook fires with or the bare `$info` object a later
337
- callback receives, so a custom `page_info` hook can't fatal on the argument
338
- shape. See [PREPROS hooks](#prepros-hooks).
339
-
340
- ---
341
-
342
- ## What's new in 1.3.0
343
-
344
- - **`LD`** class — a schema.org JSON-LD generator. Collects structured-data
345
- nodes during a render and emits them as a single
346
- `<script type="application/ld+json">` `@graph` in every page's `<head>`.
347
- - Automatic injection is **opt-in**: add a top-level `jsonld:` block to
348
- `kirigami.yaml` (even empty, `jsonld: {}`) and an `Organization` (+ `Person`, `WebSite`,
349
- `WebPage`, `BreadcrumbList`) graph is derived from that block plus the loose
350
- keys projects already carry (`person`, `jobtitle`, `email`, `area`,
351
- `knowsabout`, `keywords`, `facebook`, …). No `jsonld:` block → nothing is
352
- injected.
353
- - Turn it back off with `jsonld: false` / `jsonld: { auto: false }`, or per
354
- page with `@ld false`; per-page `@ld_type` / `@ld_title` / `@ld_image` / …
355
- tags feed the page node, and a `BreadcrumbList` is built from the
356
- `_index.php` ancestor trail with no opt-in.
357
- - Explicit builders for everything else: `LD::add()`, `LD::article()`,
358
- `LD::faqPage()`, `LD::breadcrumb()`, `LD::ref()`, and every schema.org type
359
- via `LD::typeName([...])`. Procedural aliases: `ld_add()`, `ld_organization()`,
360
- `ld_script()`, …
361
- - A page that already hand-writes an `application/ld+json` script is left
362
- untouched.
363
-
364
- ---
365
-
366
- ## What's new in 1.2.1
367
-
368
- - **`processImages()`** JS export — batch resize / palette-extraction through the
369
- `IMG` class (`src/imagebatch.php`). `@kirigami/kirigami`'s `sass` task now uses
370
- it for `img-asset()` / `colors()`, so the whole toolchain is free of a native
371
- image dependency (`sharp` is gone).
372
- - **`IMG::save()`** takes an optional `$quality` (0-100) for jpg / webp / avif;
373
- `null` keeps the per-format default (82).
374
-
375
- ---
376
-
377
- ## What's new in 1.2.0
378
-
379
- - **`SCHEMA`** class — a pure-PHP, dependency-free JSON Schema validator
380
- (Draft-7 style, Ajv-like API: `isValid()` / `validate()` / `getErrors()`).
381
- - **`IMG::asset()` / `IMG::palette()`** — static helpers powering kirigami-core's
382
- `img-asset()` and `colors()` Sass functions: on-demand resize/convert of a
383
- source image, and cached representative-colour extraction.
384
- - **`<img asset="…">` tag** — the HTML-side entry point of the image
385
- autogenerator, same parameters as `IMG::asset()` (see [Built-in tags](#built-in-tags)).
386
- - **`IMG` now handles vector and exotic formats** — SVG, EPS, AI, PDF (rasterized
387
- via Imagick), plus HEIC / TIFF / BMP, on top of GD's JPEG / PNG / GIF / WebP /
388
- AVIF.
389
- - **`MD` emoji shortcodes** — `:rocket:` → 🚀 from a large built-in map, extend­able
390
- with `MD::registerEmoji()`.
391
- - **`MD` footnotes and definition lists** — `[^1]` / `[^1]: …`, and `Term` / `: …`.
392
- - **`MD` inline HTML is now sanitized** against a tag/attribute allowlist rather
393
- than passed through verbatim.
394
- - **`STR::normalize()`** — Unicode NFD + combining-mark stripping;
395
- **`STR::slug($str, $sep = '')`** now takes a separator (pass `'-'` for a
396
- hyphenated slug).
397
- - **Bundled `Normalizer` polyfill** — `ext-intl` isn't in the WASM build, so a
398
- polyfill keeps `Normalizer::normalize()` (and `STR::normalize()` / `slug()`)
399
- working.
400
-
401
- Earlier, in 1.1.x: `PREPROS::mount()` + the `mountPath()` / `runenv()` JS
402
- exports, the `SCRAPER` and `CURL` and `ARR` classes, `YAML::loadFile()`, the
403
- `STR::is_url()` / `html_entities_decode()` / `shorthash()` / `slug()` helpers,
404
- the `{% youtube %}` / `{% codepen %}` / `{% checklist %}` MD plugins, and the
405
- `@content` / `@indent` PHPDOC annotations.
406
-
407
- ---
408
-
409
- ## How it works
410
-
411
- `@kirigami/php-prepros` runs your PHP source files inside a **WebAssembly PHP 8.x runtime** ([`@kirigami/php-wasm`](https://www.npmjs.com/package/@kirigami/php-wasm)), entirely in Node.js — no PHP installation required on the host machine.
412
-
413
- The lifecycle of a page build looks like this:
414
-
415
- ```
416
- _index.php ──▶ PHP (wasm) ──▶ processTags() ──▶ HTML::format() ──▶ index.html
417
- │
418
- ├── before.php (optional layout header)
419
- ├── after.php (optional layout footer)
420
- └── PHPDOC annotations resolved (yaml / json / md / url)
421
- ```
422
-
423
- 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()`](#preprosmountstringarray-patterns).
424
-
425
- ---
426
-
427
- ## Installation
428
-
429
- ```bash
430
- npm install @kirigami/php-prepros
431
- ```
432
-
433
- ---
434
-
435
- ## Configuration — `kirigami.yaml`
436
-
437
- Every project **must** have a `kirigami.yaml` at its root. The preprocessor reads it at startup and throws if it is absent or invalid.
438
-
439
- `@kirigami/php-prepros` itself only acts on four blocks — **`kirigami:`**, **`seo:`**, **`prepros:`**, and **`image:`**. The remaining blocks (**`plugins:`**, **`esbuild:`**, **`sass:`**, **`export:`**, **`scripts:`**, **`tasks:`**) are consumed by the [`kiri`](https://www.npmjs.com/package/@kirigami/kirigami) CLI that drives the build; they are documented here for completeness because everything lives in the one file. The full file is validated against [`kirigami.schema.json`](https://github.com/php-kirigami/kirigami/blob/main/packages/kirigami/kirigami.schema.json), also served for editor autocompletion:
440
-
441
- ```yaml
442
- # yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/php-kirigami/kirigami@main/packages/kirigami/kirigami.schema.json
443
- ```
444
-
445
- ```yaml
446
- kirigami:
447
- # ── Required ──────────────────────────────────────────────────────────
448
- project: My Website # Site name. Printed in the CLI banner, exposed as $project.
449
- baseurl: https://example.com # Deployed root URL, no trailing slash. Used for sitemap.xml.
450
- root: src # Source directory containing your _*.php pages.
451
-
452
- # ── Optional ────────────────────────────────────────────────────────
453
- banner: assets/banner.txt # Text file stamped as a license banner on exported files.
454
-
455
- # ── Arbitrary project data ──────────────────────────────────────────
456
- # Everything else under `kirigami:` is free-form. The whole block is
457
- # extracted as PHP variables and made available in every page, in
458
- # before.php/after.php, and anywhere PREPROS::$config->data is read.
459
- author: Jane Doe
460
- email: hello@example.com
461
- gtag: G-XXXXXXXXXX
462
- description: A short description of the site, useful for <meta name="description">.
463
- keywords:
464
- - keyword one
465
- - keyword two
466
-
467
- seo: # Presence turns on the META <head> tags generator.
468
- jsonld: # Nested, independent opt-in for the LD JSON-LD generator.
469
- type: Organization # `jsonld: {}` alone is enough; see the seo block below.
470
- logo: assets/logo.png
471
-
472
- prepros:
473
- before: _layouts/header.php # Included before every page body.
474
- after: _layouts/footer.php # Included after every page body.
475
- format: true # Pretty-print the HTML output (default: false).
476
- network: true # Allow HTTP fetches in PHPDOC @tag annotations / CURL / SCRAPER.
477
- mountext: # Extra file extensions to auto-mount into the wasm fs,
478
- - .svg # in addition to the defaults (.php .json .yaml .yml .md .db .txt).
479
- - .webp
480
- includes: # PHP files auto-included once, before any page renders.
481
- - _lib/functions.php
482
-
483
- image: # Image autogenerator — powers IMG::asset() / IMG::palette().
484
- format: webp # webp | avif (default: webp)
485
- source: assets/images # Source folder, relative to cwd() (default: assets/images)
486
- dest: images # Output folder, relative to kirigami.root (default: images)
487
-
488
- plugins:
489
- - name: "@kirigami/plugin-highlight"
490
- active: true
491
- options:
492
- style: canva
493
- color: black
494
-
495
- esbuild:
496
- # minify: false
497
-
498
- sass:
499
- style: expanded
500
-
501
- export:
502
- path: dist
503
- ignore: ["*.psd", "notes/"]
504
-
505
- scripts:
506
- - name: convert-images
507
- mount: ["assets/images/**/*.jpg"]
508
- trigger: before-build # before-build | before-export | after-export
509
-
510
- tasks:
511
- - name: js-core
512
- type: esbuild
513
- entry: scripts/kirigami.core.js
514
-
515
- - name: scss-core
516
- type: sass
517
- entry: styles/kirigami.core.scss
518
- ```
519
-
520
- ### `kirigami` block
521
-
522
- 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`.
523
-
524
- | Key | Required | Description |
525
- |-----|----------|--------------|
526
- | `project` | ✅ | Human-readable site name. Exposed as `$project`. |
527
- | `baseurl` | ✅ | Root URL of the deployed site, no trailing slash. Used to build absolute `<loc>` entries in `sitemap.xml`; exposed as `$baseurl`. |
528
- | `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. |
529
- | `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. |
530
- | *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`](#ld) also reads some of these by convention: `person`, `jobtitle`, `email`, `area`, `knowsabout`, `keywords`, and social-network URL keys (`facebook`, `instagram`, …). |
531
-
532
- ### `seo` block
533
-
534
- Top-level, optional. The unified SEO surface — replaces the old separate
535
- `meta:`/`jsonld:` blocks (**breaking in 2.0.0**, see
536
- [What's new in 2.0.0](#whats-new-in-200)). Its **presence** switches on the
537
- [`META`](#meta) generator — the standard SEO / social `<meta>` and `<link>`
538
- tags are then built for every page and injected into its `<head>`. An empty
539
- `seo: {}` is enough; everything is derived from the `kirigami` block and each
540
- page's PHPDOC (`@title`, `@description` / `@abstract`, `@keywords`, `@image`,
541
- `@robots`, `@og_type`, `@canonical`). A tag the layout already hand-writes is
542
- left untouched. `seo: false` (or `seo: { auto: false }`) keeps the config
543
- values but stops the injection; no block at all means nothing is injected
544
- (explicit `META::tag()` / `meta_tag()` calls still emit). Full key reference
545
- and per-page `@meta_*` tags: [`META` → `seo` config](#meta-config).
546
-
547
- Nested inside it, `seo.jsonld` is its own **independent** opt-in — a project
548
- can have META's tags without JSON-LD, or vice versa. Its presence switches on
549
- the [`LD`](#ld) schema.org JSON-LD generator — an `application/ld+json` graph
550
- is then injected into every page's `<head>`. An empty `seo: { jsonld: {} }` is
551
- enough; its keys refine what `LD` otherwise infers from the rest of `seo:`,
552
- the `kirigami` block, and each page's PHPDOC. `seo: { jsonld: false }` (or
553
- `{ auto: false }`) keeps the config values but stops the injection. Full key
554
- reference and per-page `@ld_*` tags: [`LD` → `jsonld` config](#jsonld-config).
555
-
556
- ### `prepros` block
557
-
558
- 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.
559
-
560
- | Key | Type | Default | Description |
561
- |-----|------|---------|--------------|
562
- | `before` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **before** every page's body. Typically your `<head>`/layout opening. |
563
- | `after` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **after** every page's body. Typically your layout closing. |
564
- | `format` | `bool` | `false` | Pretty-print the compiled HTML via [`HTML::format()`](#html) before writing it to disk. |
565
- | `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. |
566
- | `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](#auto-loading-data-files)), and for the `CURL` / `SCRAPER` classes. |
567
- | `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()`](#preprosmountstringarray-patterns) instead. |
568
- | `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()`. |
569
-
570
- ### `image` block
571
-
572
- Options for the image autogenerator. **Read by `php-prepros`** — these are what [`IMG::asset()` / `IMG::palette()`](#img), the [`<img asset>` tag](#img-asset), and kirigami-core's `img-asset()` / `colors()` Sass functions all resolve against. Optional; the defaults below apply even when the block is absent.
573
-
574
- | Key | Type | Default | Description |
575
- |-----|------|---------|--------------|
576
- | `format` | `string` | `webp` | Output format for generated images: `webp` or `avif`. |
577
- | `source` | `string` | `assets/images` | Folder holding the source images, relative to `cwd()`. |
578
- | `dest` | `string` | `images` | Destination folder for generated images, relative to `kirigami.root`. |
579
-
580
- ### `plugins` block
581
-
582
- List of Kirigami plugins. **Consumed by the `kiri` CLI** (see [`@kirigami/sdk`](https://www.npmjs.com/package/@kirigami/sdk)), not by `php-prepros` directly.
583
-
584
- | Key | Required | Description |
585
- |-----|----------|--------------|
586
- | `name` | ✅ | Plugin package name. Must match `@kirigami/plugin-*`, `<scope>/kirigami-plugin-*`, or `kirigami-plugin-*`. |
587
- | `active` | ✅ | Whether the plugin is loaded. |
588
- | `options` | — | Free-form object passed to the plugin; its shape depends on the plugin. |
589
-
590
- ### `esbuild` / `sass` blocks
591
-
592
- 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`](https://esbuild.github.io/api/#build-api) and Dart Sass's [`Options`](https://sass-lang.com/documentation/js-api/interfaces/options/) for what's accepted. Writing the key with nothing under it parses to `null` in YAML, equivalent to omitting the block.
593
-
594
- `sass:` additionally recognizes two keys that are **not** passed to Dart Sass:
595
-
596
- | Key | Type | Description |
597
- |-----|------|--------------|
598
- | `before` | `string` / `string[]` | Extra `.scss` files compiled **before** the task entry (paths relative to `cwd()`). |
599
- | `after` | `string` / `string[]` | Extra `.scss` files compiled **after** the task entry. |
600
-
601
- ### `export` block
602
-
603
- Options for `kiri export`. **Consumed by the `kiri` CLI.** Optional.
604
-
605
- | Key | Type | Default | Description |
606
- |-----|------|---------|--------------|
607
- | `path` | `string` | `dist` | Output directory for `kiri export`, relative to the project root. |
608
- | `ignore` | `string[]` | `[]` | Extra gitignore-style patterns excluded from the export copy, on top of Kirigami's built-in exclusions. |
609
-
610
- ### `scripts` block
611
-
612
- Named PHP scripts. **Consumed by the `kiri` CLI**, which runs each `scripts/<name>.php` through [`runenv()`](#runenvscript-paths-args) — so the full `php-prepros` class library is available and `kirigami.yaml`'s `kirigami` block is exposed as `PREPROS::$config->data`.
613
-
614
- | Key | Required | Description |
615
- |-----|----------|--------------|
616
- | `name` | ✅ | Must match an existing `scripts/<name>.php` file. Run with `kiri run <name> [args...]`; extra CLI arguments are forwarded as `$argv` entries. |
617
- | `mount` | — | Glob patterns (relative to the project root) of extra local files to mount into the sandbox before the script runs. |
618
- | `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). |
619
-
620
- ### `tasks` block
621
-
622
- 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`).
623
-
624
- | `type` | Purpose | Required fields | Optional |
625
- |--------|---------|-----------------|----------|
626
- | `esbuild` | Bundle/minify a JS/TS entry. Build + watch. Output: `<entry>.min.js`. | `name`, `type`, `entry` | `force` |
627
- | `sass` | Compile a `.scss`/`.sass` entry, minified with csso on export. Build + watch. Output: `<entry>.min.css`. | `name`, `type`, `entry` | `force` |
628
- | `prepros` | Render pages + `sitemap.xml`. Watch only (runs on build/export only when forced/implicit). | `name`, `type` | `target`, `force` |
629
- | `dist` | Copy `kirigami.root` into an output dir, stamping the banner. Forced/implicit only. | `name`, `type`, `path` | `ignore`, `force` |
630
-
631
- ---
632
-
633
- ## Writing pages
634
-
635
- 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.
636
-
637
- ```
638
- src/
639
- ├── _layouts/
640
- ├── _lib/
641
- ├── _index.php → src/index.html
642
- ├── about/
643
- │ └── _index.php → src/about/index.html
644
- └── blog/
645
- ├── _index.php → src/blog/index.html
646
- └── _articles.yaml (data file, not compiled)
647
- ```
648
-
649
- Directories whose name starts with `_` (e.g. `_layouts/`, `_lib/`) are skipped entirely during directory-wide builds.
650
-
651
- ### PHPDOC header
652
-
653
- Every page starts with a PHP docblock that drives metadata and data loading:
654
-
655
- ```php
656
- <?php
657
- /**
658
- * @name about
659
- * @title About us
660
- * @abstract A short description of this page.
661
- */
662
- ?>
663
- <section>
664
- <h1><?php echo $title; ?></h1>
665
- <p><?php echo $abstract; ?></p>
666
- </section>
667
- ```
668
-
669
- All annotations are injected as PHP variables (`$name`, `$title`, `$abstract`, …). You can define any custom annotation you need.
670
-
671
- Annotations are also available as variables in `before` and `after` PHP included files, so you can write proper metas in the HTML header.
672
-
673
- Only lines whose first non-whitespace character (past the `*` gutter) is `@`
674
- open an annotation — an `@word` written in the prose of the block is left alone.
675
- A value can wrap onto the following **indented** continuation lines:
676
-
677
- ```php
678
- /**
679
- * @title About us
680
- * @description A longer blurb that does not fit comfortably
681
- * on a single line and continues here.
682
- */
683
- ```
684
-
685
- ### Auto-loading data files
686
-
687
- 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.
688
-
689
- ```php
690
- <?php
691
- /**
692
- * @name medias
693
- * @articles _articles.yaml
694
- */
695
- ?>
696
- <?php foreach ($articles as $article): ?>
697
- <a href="<?php echo $article->lien; ?>">
698
- <?php echo $article->titre; ?>
699
- </a>
700
- <?php endforeach; ?>
701
- ```
702
-
703
- | Extension | Parsed as |
704
- |-----------|-----------|
705
- | `.yaml` / `.yml` | `stdClass` object (or array of objects for sequences) |
706
- | `.json` | Result of `json_decode()` |
707
- | `.md` | HTML string via `MD::toHtml()` |
708
-
709
- 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:
710
-
711
- ```php
712
- /**
713
- * @posts https://api.example.com/posts.json
714
- */
715
- ```
716
-
717
- ### `@content` and `@indent`
718
-
719
- Two special annotation names change how a page's body is assembled:
720
-
721
- - **`@content`** — if a `content` variable already resolves to a non-empty value (typically because it's a `.md`/`.yaml`/`.json` annotation 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.
722
- - **`@indent`** — when set to a number, every line of the rendered body is prefixed with that many spaces before being wrapped by `before.php`/`after.php`. Useful for keeping generated HTML readable when a page is nested inside indented layout markup.
723
-
724
- ```php
725
- <?php
726
- /**
727
- * @name changelog
728
- * @title Changelog
729
- * @content _changelog.md
730
- * @indent 4
731
- */
732
- ```
733
-
734
- ### Built-in tags
735
-
736
- Two tags are registered out of the box (`prepros.plugins.php`) and processed
737
- **after** the PHP runs, on the assembled HTML — no include or plugin needed.
738
-
739
- #### `<markdown> … </markdown>`
740
-
741
- Converts its inner content from Markdown to HTML, stripping the common leading
742
- indentation first (via `STR::trimIndent()`) so you can indent it naturally inside
743
- your template. All registered [MD plugins](#md-plugins) work inside it. See
744
- [Extending the `<markdown>` tag](#extending-the-markdown-tag) to override it.
745
-
746
- ```html
747
- <section>
748
- <markdown>
749
- ## Who we are
750
-
751
- We are a **student organization** from Québec.
752
- </markdown>
753
- </section>
754
- ```
755
-
756
- Add the `prose` attribute — `<markdown prose>` — to wrap the output in
757
- `<div class="prose">`, so it picks up the long-form typography of
758
- [`@kirigami/canva`'s `styles/prose`](https://www.npmjs.com/package/@kirigami/canva)
759
- with no extra markup. Any `class` / `id` on the tag lands on that wrapper
760
- (`<markdown prose class="lede" id="intro">` → `<div class="prose lede" id="intro">`).
761
- A bare `<markdown>` emits just the converted HTML, as before.
762
-
763
- #### `<img asset="…">`
764
-
765
- The HTML-side entry point of the image autogenerator — the exact same feature as
766
- the [`img-asset()` Sass function](https://www.npmjs.com/package/@kirigami/kirigami#sass-functions)
767
- and [`IMG::asset()`](#img), with the same parameters. The tag calls `IMG::asset()`
768
- under the hood, then swaps the `asset` attribute for the generated `src`.
769
-
770
- ```html
771
- <!-- in: resolves assets/images/hero.jpg through IMG::asset('hero.jpg', 800, 0, false) -->
772
- <img asset="hero.jpg" width="800" alt="Our office" loading="lazy">
773
- <!-- out: <img src="../images/hero-800w.webp" alt="Our office" loading="lazy"> -->
774
- ```
775
-
776
- | Attribute | Maps to `IMG::asset()` arg | Notes |
777
- |-----------|---------------------------|-------|
778
- | `asset` | `$path` | **Required.** Path relative to `image.source`. Missing/empty ⇒ the tag is left untouched. |
779
- | `width` | `$width` | Optional, integer. Omitted ⇒ `0` (keep). |
780
- | `height` | `$height` | Optional, integer. Omitted ⇒ `0` (keep). |
781
- | `cover` | `$cover` | Boolean — **presence means `true`** (crop + fill). |
782
- | *(any other)* | — | `alt`, `class`, `id`, `loading`, … are passed straight through onto the output `<img>`. |
783
-
784
- `asset` / `width` / `height` / `cover` are consumed and removed; everything else
785
- survives. The generated file lands in `image.dest` and is only (re)generated when
786
- missing or older than the source — see [`IMG`](#img) for the naming convention.
787
-
788
- > The Sass `img-asset()` / `colors()` functions, the `<img asset>` tag and
789
- > `IMG::asset()` all run on the **same engine** — the `IMG` class (GD, with the
790
- > Imagick fallback) in this package. `@kirigami/kirigami`'s `sass` task routes its
791
- > image work here through [`processImages()`](#processimagesjobs), so there is no
792
- > native image dependency in the toolchain.
793
-
794
- ---
795
-
796
- ## JavaScript API
797
-
798
- ```js
799
- import { render, sitemap, runenv, mountPath, processImages } from '@kirigami/php-prepros';
800
- ```
801
-
802
- ### `render(file?)`
803
-
804
- Compile a single PHP page or a whole directory.
805
-
806
- ```js
807
- // Compile one page
808
- const result = await render('about/_index.php');
809
-
810
- // Compile everything under src/
811
- const result = await render('.');
812
-
813
- // Compile everything (uses kirigami.root from config)
814
- const result = await render();
815
- ```
816
- > Paths used by `render()` are all relative to the `kirigami.root` configuration.
817
-
818
-
819
- **Returns** `Promise<PreprosResult>`:
820
-
821
- ```ts
822
- interface PreprosResult {
823
- success: boolean;
824
- files: string[]; // relative paths of every file written
825
- error?: string; // present only on failure
826
- }
827
- ```
828
-
829
- ### `sitemap()`
830
-
831
- Generate `sitemap.xml` at the source root.
832
-
833
- ```js
834
- const result = await sitemap();
835
- // result.files === ['src/sitemap.xml']
836
- ```
837
-
838
- ### `runenv(script, paths?, ...args)`
839
-
840
- 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.
841
-
842
- ```js
843
- // Run a standalone PHP script
844
- const result = await runenv('scripts/purge-cache.php');
845
-
846
- // Also mount extra local paths/files into the sandbox before running
847
- const result = await runenv('scripts/build-og-images.php', ['assets/photos']);
848
-
849
- // Extra arguments are appended and available as $argv[2], $argv[3], … in the script
850
- const result = await runenv('scripts/import.php', [], '--force');
851
- ```
852
-
853
- - `script` — path to a PHP file **inside the project**, executed with `require_once`.
854
- - `paths` — optional array of extra local paths (files or directories) to mount into the sandbox before the script runs.
855
- - `...args` — extra string arguments appended to the script's `$argv`.
856
-
857
- **Returns** `Promise<PreprosResult>`, following the same shape as `render()`. Inside the script, call `PREPROS::exportFile()` for any file you want listed in `result.files`.
858
-
859
- ### `mountPath(localPath, virtualDir?, php?)`
860
-
861
- The JavaScript-side counterpart to [`PREPROS::mount()`](#preprosmountstringarray-patterns). 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.
862
-
863
- ```js
864
- import { mountPath, render } from '@kirigami/php-prepros';
865
-
866
- // Mount a single file at its natural virtual path (/project/<relative path>)
867
- await mountPath('assets/data/team.yaml');
868
-
869
- // Mount a whole directory, at a custom virtual path
870
- await mountPath('vendor/fonts', '/project/fonts');
871
-
872
- await render();
873
- ```
874
-
875
- - `localPath` — path to a local file or directory. Relative paths are resolved against the project root.
876
- - `virtualDir` — optional destination path inside the WASM filesystem. Defaults to `/project/<localPath relative to the project root>` when omitted.
877
- - `php` — optional WASM PHP instance to mount into. Defaults to the shared singleton instance (the same one used internally by `render()`/`sitemap()`/`runenv()`), creating it if needed.
878
-
879
- 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.
880
-
881
- **Returns** `Promise<void>`.
882
-
883
- ### `processImages(jobs)`
884
-
885
- Run a batch of image jobs — resize/encode, or palette extraction — through the
886
- [`IMG`](#img) class (GD, with the Imagick fallback). This is the engine
887
- `@kirigami/kirigami`'s `sass` task uses for its `img-asset()` and `colors()`
888
- functions, so Sass, `IMG::asset()` and the [`<img asset>` tag](#built-in-tags)
889
- all share one implementation, one `image:` config and one set of output
890
- filenames — with no native image dependency.
891
-
892
- ```js
893
- import { processImages } from '@kirigami/php-prepros';
894
-
895
- const { files, colors } = await processImages([
896
- // resize/encode `hero.jpg` (resolved against image.source) to each dest —
897
- // absolute virtual paths, already carrying the target extension
898
- { op: 'resize', src: 'hero.jpg', width: 1200, height: 0, cover: false, quality: 82,
899
- dests: ['/project/src/images/hero-1200w.webp'] },
900
-
901
- // extract a 5-colour palette (cached in .cache.db); returned, not written
902
- { op: 'palette', src: 'hero.jpg', count: 5 },
903
- ]);
904
-
905
- // files → ['src/images/hero-1200w.webp'] (also copied back to the host)
906
- // colors → { 'hero.jpg:5': ['#1e3a5f', '#c8a24b', …] }
907
- ```
908
-
909
- - `jobs` — array of `resize` / `palette` jobs (see the shape above). An **empty
910
- array is a no-op** and does **not** start the WASM runtime.
911
- - Staleness is the caller's responsibility: every `resize` job listed is executed.
912
-
913
- **Returns** `Promise<PreprosResult & { colors: Record<string, string[]> }>`.
914
-
915
- ---
916
-
917
- ## PHP classes reference
918
-
919
- All classes are autoloaded — no manual `require` needed inside your page files.
920
-
921
- ---
922
-
923
- ### PREPROS
924
-
925
- The core engine. Manages the rendering pipeline, tag processing, hooks, mounting, and file export.
926
-
927
- ```php
928
- // Available inside page templates and included files.
929
- PREPROS::$config // stdClass — full resolved config; ->data is the kirigami: block,
930
- // ->image the image: block, plus before/after/format/… from prepros:
931
- PREPROS::registerTag(string $tag, callable $callback)
932
- PREPROS::registerHook(string $hook, callable $callback)
933
- PREPROS::runHook(string $hook, mixed $data = null) // fire a hook (built-in or your own), returns the piped $data
934
- PREPROS::mount(string|array $patterns)
935
- PREPROS::exportFile(string|array $absolutePath)
936
- PREPROS::getExportedFiles(): string[]
937
- PREPROS::fstat(string $path) // stat a file in the WASM FS (or false)
938
- PREPROS::backtraceFile() // path of the page currently rendering
939
- ```
940
-
941
- #### `PREPROS::render(string $file)`
942
-
943
- Internal method called once per source file. Orchestrates the full pipeline:
944
-
945
- 1. Resolves PHPDOC metadata and auto-loads data files.
946
- 2. Fires the `pre_render` hook with the raw source contents.
947
- 3. Includes `before.php` (wrapped in the `pre_before` / `post_before` hooks), the page body (or `@content`, see [above](#content-and-indent)), and `after.php` (wrapped in `pre_after` / `post_after`) into a single string.
948
- 4. Processes all registered custom HTML tags.
949
- 5. Fires the `post_render` hook on the assembled HTML.
950
- 6. Optionally pretty-prints via `HTML::format()` (when `format: true`).
951
- 7. Writes the output `.html` file.
952
-
953
- #### `PREPROS::sitemap()`
954
-
955
- 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.
956
-
957
- #### `PREPROS::mount(string|array $patterns)`
958
-
959
- 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.
960
-
961
- ```php
962
- // Mount every .webp under assets/, wherever the page needs them
963
- PREPROS::mount('assets/**/*.webp');
964
-
965
- // Multiple patterns at once
966
- PREPROS::mount(['data/**/*.csv', 'vendor/fonts/*.woff2']);
967
- ```
968
-
969
- Returns an array of the virtual paths (under `/project/...`) that were mounted, or `false` on failure.
970
-
971
- #### `PREPROS::exportFile(string $file)`
972
-
973
- 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.
974
-
975
- ---
976
-
977
- ### MD
978
-
979
- Markdown-to-HTML converter with a plugin system for custom shortcodes.
980
-
981
- ```php
982
- $html = MD::toHtml(string $markdown): string;
983
- ```
984
-
985
- Supports the full GitHub Flavored Markdown subset, plus a few extensions:
986
-
987
- - ATX (`#` … `######`) and Setext headings, with auto-generated `id` attributes
988
- - Ordered and unordered lists, including nested
989
- - GFM task lists (`- [ ]` / `- [x]`)
990
- - GFM tables with column alignment
991
- - GFM alerts (`> [!NOTE]`, `> [!WARNING]`, etc.)
992
- - Blockquotes (recursive)
993
- - Fenced code blocks with language class
994
- - Inline code
995
- - Bold, italic, bold+italic, strikethrough
996
- - Links with automatic `target="_blank" rel="noopener noreferrer"` for external URLs
997
- - Images with `loading="lazy"`
998
- - Auto-linked bare URLs
999
- - Horizontal rules
1000
- - Hard line breaks (trailing double space → `<br>`)
1001
- - **Footnotes** — `[^1]` references and `[^1]: …` definitions (multi-paragraph)
1002
- - **Definition lists** — `Term` / `: Definition`
1003
- - **Emoji shortcodes** — `:rocket:` → 🚀, from a built-in map (see `MD::registerEmoji()`)
1004
- - **Sanitized inline HTML** — raw tags are filtered against an allowlist of tags and attributes, not passed through verbatim
1005
-
1006
- #### Plugin API
1007
-
1008
- Extend Markdown with custom shortcode tags:
1009
-
1010
- ```php
1011
- // Inline tag {% tagname arg1 "arg with spaces" %}
1012
- // Block tag {% tagname arg1
1013
- // body content
1014
- // %}
1015
-
1016
- MD::registerPlugin(string $name, callable $callback): void
1017
- MD::unregisterPlugin(string $name): void
1018
- MD::getRegisteredPlugins(): string[]
1019
- MD::registerEmoji(string $shortcode, string $char): void // `:name:` → char
1020
- ```
1021
-
1022
- The callback always receives `(array $args, string $body)`:
1023
-
1024
- ```php
1025
- MD::registerPlugin('video', function (array $args, string $body): string {
1026
- $src = htmlspecialchars($args[0] ?? '', ENT_QUOTES, 'UTF-8');
1027
- return "<video src=\"{$src}\" controls></video>";
1028
- });
1029
- ```
1030
-
1031
- Then in any Markdown content (including inside `<markdown>` tags):
1032
-
1033
- ```
1034
- {% video /videos/intro.mp4 %}
1035
- ```
1036
-
1037
- ---
1038
-
1039
- ### HTML
1040
-
1041
- Pretty-printer for the final HTML output. Used automatically when `format: true` is set in the config.
1042
-
1043
- ```php
1044
- $formatted = HTML::format(string $html): string;
1045
- ```
1046
-
1047
- 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.
1048
-
1049
- ---
1050
-
1051
- ### YAML
1052
-
1053
- A lightweight, zero-dependency YAML parser. Covers the full subset used in static site projects.
1054
-
1055
- ```php
1056
- $data = YAML::parse(string $yaml, bool $assoc = false): mixed;
1057
- $data = YAML::parseFile(string $path, bool $assoc = false): mixed;
1058
- $data = YAML::loadFile(string $path, bool $assoc = false): mixed;
1059
- ```
1060
-
1061
- Supported features:
1062
-
1063
- - Scalars: strings (quoted and unquoted), integers, floats, booleans, null
1064
- - Single and double quoted strings with escape sequences
1065
- - Literal block scalars (`|`, `|-`, `|+`)
1066
- - Folded block scalars (`>`, `>-`, `>+`)
1067
- - Plain scalars spanning multiple lines
1068
- - Nested mappings and sequences
1069
- - Inline collections (`[a, b]` and `{k: v}`)
1070
- - Comments (`#`)
1071
- - Multiple documents separated by `---`
1072
-
1073
- By default, YAML mappings are returned as `stdClass` objects. Pass `true` as the second argument to get associative arrays instead.
1074
-
1075
- `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`.
1076
-
1077
- ```yaml
1078
- # team.yaml
1079
- lead: people/jane.yaml # resolved and inlined automatically
1080
- members:
1081
- - people/jane.yaml
1082
- - people/john.yaml
1083
- ```
1084
-
1085
- ```php
1086
- $team = YAML::loadFile('/project/data/team.yaml');
1087
- // $team->lead is now the fully parsed content of people/jane.yaml, not a string
1088
- ```
1089
-
1090
- ---
1091
-
1092
- ### SCHEMA
1093
-
1094
- A pure-PHP, dependency-free JSON Schema validator — Draft-7 style, with an
1095
- Ajv-like API. Used internally to validate structured data, but available to your
1096
- own code and plugins.
1097
-
1098
- ```php
1099
- $validator = new SCHEMA(array $schema);
1100
-
1101
- $validator->isValid(mixed $data): bool // true / false
1102
- $validator->validate(mixed $data): bool // alias of isValid()
1103
- $validator->getErrors(): string[] // "path: message" strings from the last run
1104
- ```
1105
-
1106
- Supported keywords: `type`, `required`, `properties`, `patternProperties`,
1107
- `additionalProperties`, `items`, `minItems`, `maxItems`, `uniqueItems`,
1108
- `minLength`, `maxLength`, `pattern`, `minimum`, `maximum`, `exclusiveMinimum`,
1109
- `exclusiveMaximum`, `minProperties`, `maxProperties`, `enum`, `const`,
1110
- `anyOf`, `allOf`, `oneOf`, `not`, `format`, and local `$ref` pointers.
1111
-
1112
- ```php
1113
- $validator = new SCHEMA([
1114
- 'type' => 'object',
1115
- 'required' => ['name', 'age'],
1116
- 'properties' => [
1117
- 'name' => ['type' => 'string', 'minLength' => 1],
1118
- 'age' => ['type' => 'integer', 'minimum' => 0],
1119
- ],
1120
- 'additionalProperties' => false,
1121
- ]);
1122
-
1123
- if (!$validator->isValid($data)) {
1124
- foreach ($validator->getErrors() as $err) echo $err, PHP_EOL;
1125
- }
1126
- ```
1127
-
1128
- ---
1129
-
1130
- ### LD
1131
-
1132
- A **schema.org JSON-LD generator**. `LD` accumulates structured-data nodes for
1133
- the page under render and emits them as one
1134
- `<script type="application/ld+json">` block — with an `@graph` when there is more
1135
- than one node — in the `<head>`.
1136
-
1137
- #### Automatic mode
1138
-
1139
- Opt in by adding a `jsonld` sub-block under `seo:` in `kirigami.yaml` (nested
1140
- inside the same block [`META`](#meta) reads, independent of the rest of it) —
1141
- an empty `seo: { jsonld: {} }` is enough. A `post_render` hook then injects a
1142
- graph built from that sub-block, the loose keys of the `kirigami` block, and
1143
- the current page's PHPDOC:
1144
-
1145
- - an `Organization` node (`@id` `#organization`) — `name`/`url`/`description`
1146
- from `project`/`baseurl`/`description`, `sameAs` gathered from every
1147
- recognised social-network URL key (`facebook`, `instagram`, `linkedin`,
1148
- `github`, `youtube`, `mastodon`, …), plus `email`, `telephone`, `areaServed`
1149
- (← `area`), `knowsAbout` (← `knowsabout`), `address`, `logo`, and `founder` →
1150
- the Person node when there is one. `@type` comes from `jsonld.type`;
1151
- - a `Person` node (`#person`) when `person` is set — `name` + `jobTitle`
1152
- (← `jobtitle`) + `email` + `url`, linked to the Organization via `worksFor`;
1153
- - a `WebSite` node (`#website`) — `publisher` → Organization, `inLanguage`,
1154
- `keywords` (← `keywords`), and a `SearchAction` when `jsonld.search` is set;
1155
- - a `WebPage` node for the page — see the per-page tags below;
1156
- - a `BreadcrumbList` for every non-home page, derived from the `_index.php`
1157
- ancestor trail (home → each parent section → this page). No `@breadcrumb`
1158
- opt-in needed — it is always attempted while the `jsonld` sub-block is on.
1159
- Disable it for one page with `@ld_breadcrumb false`.
1160
-
1161
- Remove the `jsonld` sub-block (or set `seo: { jsonld: false }` /
1162
- `{ jsonld: { auto: false } }`) to stop the automatic pass — the rest of `seo:`
1163
- (META's tags) keeps working either way. A page whose rendered `<head>` already
1164
- contains an `application/ld+json` script is never touched, so hand-rolled
1165
- markup keeps working.
1166
-
1167
- **Per-page PHPDOC tags** — these feed the page node (and override the generic
1168
- `@title` / `@description` / `@datePublished` fallbacks):
1169
-
1170
- | Tag | Effect |
1171
- |-----|--------|
1172
- | `@ld false` | Skip JSON-LD for this page entirely (`@ld_ignore true` also works). |
1173
- | `@ld_type <Type>` | `@type` of the page node — `AboutPage`, `ContactPage`, `CollectionPage`, `ProfilePage`, or a content type like `Article`, `Service`, `Recipe`, … (default `WebPage`). Types containing “Page” also get `primaryImageOfPage` + a `breadcrumb` link; others get a plain `image`. |
1174
- | `@ld_title <text>` | Page node `name` (default: `@title`). |
1175
- | `@ld_description <text>` | Page node `description` (default: `@description`). |
1176
- | `@ld_image <path>` | Page image, absolute or relative to `baseurl` (default: `@image` / `@ogimage`). |
1177
- | `@ld_published <date>` | `datePublished` (default: `@datePublished` / `@published` / `@date`). |
1178
- | `@ld_modified <date>` | `dateModified` (default: `@dateModified` / `@modified` / `@updated`). |
1179
- | `@ld_breadcrumb false` | No `BreadcrumbList` for this page. |
1180
-
1181
- ```php
1182
- /**
1183
- * @title À propos
1184
- * @ld_type AboutPage
1185
- * @ld_title À propos de Humain Humain
1186
- * @ld_description Notre approche ethnographique de la consultation.
1187
- */
1188
- ```
1189
-
1190
- #### Explicit builders
1191
-
1192
- Call these from a page template or from a `prepros.includes` file. Nodes added
1193
- this way are always emitted — with or without a `jsonld` sub-block — and share
1194
- the graph the automatic pass uses, so the two combine; a node with a stable
1195
- `@id` is merged on repeat calls.
1196
-
1197
- ```php
1198
- LD::add(string|array $type, array $props = [], ?string $id = null): array // build + register a node
1199
- LD::node(string|array $type, array $props = []): array // build only, no register
1200
- LD::push(array $node): array // register a ready-made node
1201
- LD::ref(string $id): array // ['@id' => …] ('#person' → the Person node)
1202
- LD::remove(string $id): void
1203
- LD::graph(): array
1204
- LD::reset(): void
1205
-
1206
- LD::organization(array $overrides = []): array // config-aware, @id #organization
1207
- LD::person(array $overrides = []): array // config-aware, @id #person
1208
- LD::website(array $overrides = []): array // config-aware, @id #website
1209
- LD::webPage(array $overrides = []): array // current-page-aware, @id …#webpage
1210
- LD::breadcrumb(?array $items = null, array $overrides = []): array // items: [['name'=>…,'url'=>…], …]
1211
- LD::faqPage(array $qa, array $overrides = []): array // qa: ['Question ?' => 'Answer.', …]
1212
-
1213
- LD::address(array|string $a): array
1214
- LD::image(string $url, ?string $id = null, ?int $w = null, ?int $h = null): array
1215
- LD::geo(float $lat, float $lng): array
1216
- LD::rating(int|float $value, ?int $count = null, $best = 5, $worst = 1): array
1217
- LD::offer(array $o): array
1218
- LD::contactPoint(array $c): array
1219
- LD::searchAction(string $urlTemplate): array
1220
-
1221
- LD::script(bool $pretty = true): string // <script>…</script>, and disables auto-injection
1222
- LD::json(bool $pretty = true): string // the document, no wrapper
1223
- ```
1224
-
1225
- Every other schema.org type is reachable through `__callStatic` — the method
1226
- name is upper-cased to form the `@type`:
1227
-
1228
- ```php
1229
- LD::recipe([ 'name' => 'Tarte aux pommes', 'recipeYield' => '6', 'prepTime' => 'PT30M' ]);
1230
- LD::event([ 'name' => 'Vernissage', 'startDate' => '2026-10-01T18:00' ]);
1231
- LD::softwareApplication([ 'name' => 'Kirigami', 'applicationCategory' => 'DeveloperApplication' ]);
1232
-
1233
- LD::article([
1234
- 'headline' => $title,
1235
- 'datePublished' => '2026-09-01',
1236
- 'author' => LD::ref('#person'),
1237
- 'image' => LD::image('images/cover.webp'),
1238
- 'publisher' => LD::ref('#organization'),
1239
- ]);
1240
- ```
1241
-
1242
- Same API from procedural code: `ld_add()`, `ld_node()`, `ld_ref()`,
1243
- `ld_organization()`, `ld_person()`, `ld_website()`, `ld_web_page()`,
1244
- `ld_breadcrumb()`, `ld_faq_page()`, `ld_script()`, `ld_json()`.
1245
-
1246
- #### `jsonld` config
1247
-
1248
- `jsonld` is a sub-block of the **top-level** `seo:` block of `kirigami.yaml`
1249
- (nested alongside [`META`](#meta)'s own keys), and its presence is what
1250
- **switches automatic injection on** — independently of the rest of `seo:`. An
1251
- empty `seo: { jsonld: {} }` is enough — everything is then derived from the
1252
- `kirigami` block's loose keys. Adding keys overrides those inferences; all are
1253
- optional.
1254
-
1255
- ```yaml
1256
- kirigami:
1257
- project: Humain Humain
1258
- baseurl: https://humainhumain.com
1259
- person: Méralie Murray-Hall
1260
- jobtitle: Anthropologue
1261
- facebook: https://www.facebook.com/humainhumainconsultation.ethnographie/
1262
-
1263
- seo:
1264
- jsonld: # nested; the sub-block being present
1265
- type: ProfessionalService # is the switch — `{}` also works
1266
- lang: fr-CA # inLanguage on WebSite / WebPage (default: en)
1267
- logo: assets/logo.png # absolute, or relative to baseurl
1268
- knowsAbout: [Ethnographie, Recherche qualitative]
1269
- address:
1270
- addressLocality: Québec
1271
- addressCountry: CA
1272
- search: https://humainhumain.com/?q={search_term_string}
1273
- ```
1274
-
1275
- | Key | Type | Description |
1276
- |-----|------|-------------|
1277
- | `auto` | `bool` | Inject the `<script>` automatically. Default `true` **once the `jsonld` sub-block exists**. Set `auto: false` (or `seo: { jsonld: false }`) to keep the sub-block for its config values but stop the automatic injection — `LD::script()` / `ld_script()` can still place it by hand. |
1278
- | `type` | `string` | `@type` for the main entity — `Organization`, `ProfessionalService`, `LocalBusiness`, … |
1279
- | `name` / `url` / `description` | `string` | Main-entity / WebSite fields. Default to `project` / `baseurl` / `description`. |
1280
- | `logo` / `image` | `string` | Absolute URL or path relative to `baseurl`. `image` defaults to `logo`. |
1281
- | `sameAs` | `string[]` | Profile URLs, merged with the social-network URL keys found loose in the block. |
1282
- | `email` / `telephone` | `string` | Default to the loose `email` / `telephone` keys. |
1283
- | `address` | `map` | `PostalAddress` properties. |
1284
- | `areaServed` | `string` | Defaults to the loose `area` key. |
1285
- | `knowsAbout` / `keywords` | `string[]` | Default to the loose `knowsabout` / `keywords` keys. |
1286
- | `person` | `string` \| `map` | The `#person` node. A string is the name; a map takes any `Person` property. Defaults to `person` + `jobtitle` + `email`. |
1287
- | `lang` | `string` | BCP-47 tag for `inLanguage`. Default `en`. |
1288
- | `search` | `string` | URL template for a sitelinks `SearchAction`; must contain `{search_term_string}`. |
1289
-
1290
- ---
1291
-
1292
- ### META
1293
-
1294
- A **`<head>` SEO / social metadata generator** — the companion to [`LD`](#ld).
1295
- Where `LD` emits a schema.org `application/ld+json` graph, `META` emits the plain
1296
- tags a browser and a link-preview crawler read: `<title>`, `<meta name="…">`,
1297
- `<meta property="og:…">`, `<meta name="twitter:…">`, and a handful of `<link>`s.
1298
-
1299
- It draws on the same sources, in this order of precedence: the page's PHPDOC, the
1300
- top-level `seo:` block, then the loose `kirigami:` keys and the `seo.jsonld`
1301
- sub-block. Every tag is emitted **only when it can be resolved** — no value, no
1302
- tag — and a tag the page's layout already writes by hand is detected and
1303
- skipped, so it drops in beside an existing `header.php` without duplicating
1304
- anything.
1305
-
1306
- **Automatic mode** is opt-in: the top-level `seo:` block (empty `seo: {}` is
1307
- enough) turns on injection into every page's `<head>`, right before `</head>`.
1308
-
1309
- ```yaml
1310
- kirigami:
1311
- project: Humain Humain
1312
- baseurl: https://humainhumain.com
1313
- tagline: Ethnographie au service des organisations
1314
- description: A social-science consultancy using ethnography for organisational change.
1315
- keywords: [ethnographie, consultation publique, sciences sociales]
1316
- author: Maxime Larrivée-Roy
1317
-
1318
- seo: # top-level; the block being present is the switch
1319
- twitter: "@humainhumain"
1320
- themeColor: "#0b7285"
1321
- jsonld: {} # independent opt-in — META reads its logo / image / lang / person too
1322
- ```
1323
-
1324
- Per-page, from the PHPDOC block — each falls back to the generic page tag:
1325
-
1326
- | Tag | Feeds | Default |
1327
- |-----|-------|---------|
1328
- | `@meta false` | skip metadata for this page entirely | — (`@meta_ignore true` also works) |
1329
- | `@meta_title` | `<title>`, `og:title`, `twitter:title` | `@title` |
1330
- | `@meta_description` | `description`, `og:description`, `twitter:description` | `@description` / `@abstract` / `@excerpt` / `@summary`, then the site `description` |
1331
- | `@meta_keywords` | `<meta name="keywords">` | `@keywords`, then `seo.keywords` |
1332
- | `@meta_image` | `og:image`, `twitter:image` | `@image` / `@ogimage`, then `seo.image` |
1333
- | `@meta_robots` | `<meta name="robots">` | `@robots`, then `seo.robots` |
1334
- | `@meta_type` | `og:type` | `@og_type`, then `seo.ogType` |
1335
- | `@canonical` | `<link rel="canonical">` | derived from the file path + `baseurl` |
1336
-
1337
- **Manual builders** — always emitted (with or without a `seo:` block), still
1338
- de-duplicated against the page:
1339
-
1340
- ```php
1341
- META::tag(string $name, ?string $content): void // name= , or property= for an og:* key
1342
- META::link(string $rel, string $href, array $attrs = []): void
1343
- META::raw(string $html): void // a verbatim, already-valid tag line
1344
- META::tags(string $html = ''): string // the whole block, \n-joined
1345
- META::reset(): void
1346
- ```
1347
-
1348
- ```php
1349
- META::tag('twitter:image', 'https://humainhumain.com/card.png');
1350
- META::tag('og:image:alt', 'The Humain Humain team at work');
1351
- META::link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
1352
- ```
1353
-
1354
- Same API from procedural code: `meta_tag()`, `meta_link()`, `meta_raw()`,
1355
- `meta_tags()`.
1356
-
1357
- #### `meta` config
1358
-
1359
- These keys live directly under the **top-level** `seo:` block of
1360
- `kirigami.yaml` (a sibling of `kirigami:`, `prepros:`, …) — `jsonld` is the one
1361
- sub-block among them, documented separately in [`LD` → `jsonld` config](#jsonld-config).
1362
- All keys are optional.
1363
-
1364
- | Key | Type | Description |
1365
- |-----|------|-------------|
1366
- | `auto` | `bool` | Inject the tags automatically. Default `true` **once the `seo:` block exists**. `auto: false` (or `seo: false`) keeps the block for its values but stops the injection — `META::tags()` / `meta_tags()` can place them by hand. Independent of `jsonld.auto`. |
1367
- | `titleFormat` | `string` | `<title>` template for a normal page. Tokens `{title}`, `{project}`, `{tagline}`. Dangling separators from an empty token are trimmed. Default `{title} — {project}`. |
1368
- | `titleFormatHome` | `string` | Title template when the page has no `@title` (home / section landings). Default `{project} — {tagline}`. |
1369
- | `description` | `string` | Default description for pages with no `@description` / `@abstract`. Defaults to `jsonld.description` / loose `description`. |
1370
- | `keywords` | `string[]` \| `string` | Default `keywords` content (list or comma string). Defaults to `jsonld.keywords` / loose `keywords`. |
1371
- | `robots` | `string` | Default robots directive. Default `index, follow`. `robots: false` omits the tag. |
1372
- | `language` | `string` | BCP-47 tag → `<meta name="language">` and, dash→underscore, `og:locale`. Defaults to `jsonld.lang` / loose `lang` / `language`, then `en`. |
1373
- | `generator` | `string` \| `false` | `<meta name="generator">`. Default `Kirigami`; `false` omits it. |
1374
- | `author` / `designer` | `string` | Default to the loose `author` / `designer` keys (author also falls back to `jsonld.person`'s name). `designer` is not emitted unless set. |
1375
- | `themeColor` | `string` | `<meta name="theme-color">`. Not emitted unless set. |
1376
- | `image` | `string` | Default `og:image` / `twitter:image` — absolute URL or path relative to `baseurl`. Defaults to `jsonld.image` → `jsonld.logo` → loose `image` / `ogimage`. |
1377
- | `ogType` | `string` | Default `og:type`. Default `website`. |
1378
- | `twitterCard` | `string` | `twitter:card` type. Default `summary_large_image`. |
1379
- | `twitter` | `string` \| `map` | Handle for `twitter:site` / `twitter:creator`. A bare string (with/without `@`, or a profile URL) fills both; a map takes `site` / `creator` separately. |
1380
- | `canonical` | `bool` | Emit `<link rel="canonical">`. Default `true`. |
1381
- | `favicon` / `appleTouchIcon` / `humans` | `string` \| `bool` | `<link rel="icon">` / `rel="apple-touch-icon"` / `rel="author"`. A path sets it (page-relative when a bare filename); `true` forces the default file (`favicon.ico` / `apple-touch-icon.png` / `humans.txt`); omitted, the default file is auto-detected on disk at the source root; `false` disables it. |
1382
- | `jsonld` | `object` \| `bool` | Sub-block for `LD`'s schema.org JSON-LD — its own independent opt-in. See [`LD` → `jsonld` config](#jsonld-config). |
1383
-
1384
- ---
1385
-
1386
- ### CACHE
1387
-
1388
- Persistent SQLite-backed key-value cache. Survives across incremental builds via `.cache.db` at the project root.
1389
-
1390
- ```php
1391
- CACHE::get(string $key): mixed
1392
- CACHE::set(string $key, mixed $val, int $ttl = 0): bool
1393
- CACHE::delete(string $key): bool
1394
- CACHE::purge(): bool // removes expired entries
1395
- ```
1396
-
1397
- The `$ttl` is in seconds. `0` means the entry never expires. Typical use case: caching the result of network fetches in custom hooks or plugins — it is what powers [`SCRAPER`](#scraper) and `CURL`'s cookie persistence internally.
1398
-
1399
- ```php
1400
- $data = CACHE::get('my-remote-data');
1401
- if ($data === null) {
1402
- $data = json_decode(file_get_contents('https://api.example.com/data.json'));
1403
- CACHE::set('my-remote-data', $data, 3600); // cache for 1 hour
1404
- }
1405
- ```
1406
-
1407
- ---
1408
-
1409
- ### IMG
1410
-
1411
- Image manipulation helper. GD handles JPEG, PNG, GIF, WebP and AVIF directly;
1412
- anything GD can't decode (HEIC, TIFF, BMP, and the vector formats SVG, EPS, AI,
1413
- PDF) falls back to Imagick, which rasterizes it to a GD image in memory. Vector
1414
- files with no intrinsic pixel size are rasterized at 2000&nbsp;px on the longest
1415
- side, preserving the aspect ratio.
1416
-
1417
- ```php
1418
- $img = new IMG(string $file);
1419
-
1420
- // Properties
1421
- $img->width // int
1422
- $img->height // int
1423
-
1424
- // Instance methods (resize/save are chainable)
1425
- $img->resize(int $width, int $height = 0, bool $cover = false): self
1426
- $img->save(string $dest, ?int $quality = null): self // quality 0-100 for jpg/webp/avif; null = per-format default (82)
1427
- $img->getRepresentativeColors(int $count = 5): string[] // ['#rrggbb', …]
1428
-
1429
- // Static helpers
1430
- IMG::asset(string $path, int $width = 0, int $height = 0, bool $cover = false): string // same feature as the <img asset> tag and the img-asset() Sass function
1431
- IMG::palette(string $path, int $colors = 5): string[]
1432
- ```
1433
-
1434
- `resize()` operates in *contain* mode by default (scales to fit within the target box while preserving aspect ratio). Pass `$cover = true` to crop and fill the exact target dimensions.
1435
-
1436
- `save()` infers the output format from the file extension (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`) and marks the file as a build output.
1437
-
1438
- ```php
1439
- // Build a 1200×630 cropped Open Graph image next to the original
1440
- (new IMG('/project/src/images/hero.jpg'))
1441
- ->resize(1200, 630, true)
1442
- ->save('/project/src/images/hero-og.jpg');
1443
- ```
1444
-
1445
- `IMG::asset()` and `IMG::palette()` are what back kirigami-core's `img-asset()`
1446
- and `colors()` Sass functions: they resolve `$path` against `image.source` from
1447
- `kirigami.yaml`, generate a resized/re-encoded file under `image.dest` (only
1448
- when missing or stale), or return a `CACHE`-backed list of representative
1449
- colours. Both are equally usable from your own PHP.
1450
-
1451
- `IMG::asset()` is the single implementation behind the [`<img asset>` tag](#built-in-tags)
1452
- too — the tag is just a thin wrapper. Generated files are named after the source
1453
- plus a dimension suffix: `-<W>w`, `-<H>h`, `-<W>x<H>`, or `-<W>x<H>-cover`, with
1454
- the `image.format` extension (e.g. `hero.jpg` + `width="800"` → `hero-800w.webp`).
1455
- `@kirigami/kirigami`'s Sass `img-asset()` / `colors()` functions produce the same
1456
- files from the same config through this same class, via
1457
- [`processImages()`](#processimagesjobs) — one engine, no native dependency.
1458
-
1459
- ---
1460
-
1461
- ### FS
1462
-
1463
- Filesystem utilities.
1464
-
1465
- ```php
1466
- FS::dig(string $glob): iterable // recursive glob, yields file paths
1467
- FS::getRelativePath(string $from, string $to): string
1468
- FS::phpFileInfo(string $file): object|false // parse PHPDOC annotations
1469
- FS::getChildren(string $backtrace = ''): object[] // child _index.php pages, ordered by @position
1470
- FS::getBreadcrumb(string $backtrace = ''): object[] // ancestor _index.php pages, top-most first (opt-in via @breadcrumb)
1471
- FS::rmdir(string $dir, bool $removeSelf = true): bool
1472
- FS::pathJoin(string ...$parts): string // URL-aware path join with .. resolution
1473
- ```
1474
-
1475
- `FS::dig()` is the workhorse of directory-wide builds — it recursively walks a glob pattern and yields every matching file path.
1476
-
1477
- `FS::phpFileInfo()` parses the first PHPDOC block of a PHP file and returns its `@tag value` pairs as a `stdClass`. This is used internally to resolve page metadata and data-file annotations.
1478
-
1479
- `FS::getChildren()` (procedural: `fs_get_children()`) — usable only during a render — scans the folders directly below the calling template, keeps the ones that contain an `_index.php`, and returns one `stdClass` per child: the parsed PHPDOC of that `_index.php` plus a `->file` key with its absolute path. Entries are ordered by `@position` ascending (a page with no `@position` sorts as `999999`), then by folder name (natural, case-insensitive). Handy for building a section index or a navigation menu:
1480
-
1481
- ```php
1482
- <?php foreach (fs_get_children() as $page): ?>
1483
- <li><a href="<?= FS::getRelativePath(__DIR__, dirname($page->file)) ?>/"><?= $page->title ?></a></li>
1484
- <?php endforeach ?>
1485
- ```
1486
-
1487
- `FS::getBreadcrumb()` (procedural: `fs_get_breadcrumb()`) — also render-only — is the upward counterpart: it returns the calling page's breadcrumb trail. It only produces output when the calling file opts in with `@breadcrumb true` (or `@breadcrumb 1`) in its first PHPDOC block, otherwise it returns `[]`. From the folder **above** the caller's own folder (a page is never part of its own trail), it walks the parent directories upward and collects the `_index.php` of each, stopping at the source root or at the first ancestor `_index.php` that carries no active `@breadcrumb` tag — that page acts as a separator and is left out. Directories without an `_index.php` are skipped without breaking the chain. Entries come back ordered from the top-most ancestor down to the nearest parent, each a `stdClass` (parsed PHPDOC of its `_index.php` plus a `->file` key with its absolute path):
1488
-
1489
- ```php
1490
- <nav aria-label="Breadcrumb">
1491
- <?php foreach (fs_get_breadcrumb() as $crumb): ?>
1492
- <a href="<?= FS::getRelativePath(__DIR__, dirname($crumb->file)) ?>/"><?= $crumb->title ?></a>
1493
- <?php endforeach ?>
1494
- </nav>
1495
- ```
1496
-
1497
- ---
1498
-
1499
- ### STR
1500
-
1501
- String utilities used internally by the tag-processing pipeline, and available for your own templates and plugins.
1502
-
1503
- ```php
1504
- STR::htmlesc(string $str): string
1505
- STR::replaceTags(string $tag, string $html, callable $callback): string
1506
- STR::parseHtmlAttributes(string $attrString): array
1507
- STR::trimIndent(string $str): string
1508
- STR::is_url(string $str): bool
1509
- STR::html_entities_decode(string $str): string
1510
- STR::shorthash(string $str): string
1511
- STR::normalize(string $str): string
1512
- STR::slug(string $str, string $sep = ''): string
1513
- ```
1514
-
1515
- `STR::replaceTags()` is the engine behind `PREPROS::registerTag()`. It finds all occurrences of `<tagname ...>...</tagname>` in an HTML string and replaces each with the return value of `$callback($fullMatch, $attrs, $body)`.
1516
-
1517
- `STR::trimIndent()` strips the common leading whitespace from a multi-line string — handy when pulling content out of indented `<markdown>` blocks.
1518
-
1519
- `STR::is_url()` checks whether a string parses as a URL with a recognized scheme (`http`, `https`, `ftp`, `ftps`, `ssh`, `ssl`, `sftp`, `itunes`).
1520
-
1521
- `STR::html_entities_decode()` trims a string and decodes its HTML entities — handy when normalizing text scraped from a third-party page.
1522
-
1523
- `STR::shorthash()` returns the first 12 characters of a string's SHA-256 hash — used internally as a stable, filename-safe cache key (see `SCRAPER`).
1524
-
1525
- `STR::normalize()` applies Unicode NFD decomposition and strips combining marks (`é` → `e`) — the accent-folding step used by `slug()`.
1526
-
1527
- `STR::slug()` normalizes, transliterates to ASCII, lowercases, and replaces every run of non-`[a-z0-9]` characters with `$sep` (empty by default → a compact identifier; pass `'-'` for a conventional hyphenated slug).
1528
-
1529
- ---
1530
-
1531
- ### ARR
1532
-
1533
- Recursive lookup helper for nested arrays and objects.
1534
-
1535
- ```php
1536
- ARR::find_key(mixed $data, string $key): mixed
1537
- ```
1538
-
1539
- Walks an array or object (including mixed nested `stdClass`/array structures, as produced by `YAML::parse()` or `json_decode()`) depth-first and returns the value of the **first** matching key found, at any depth, or `null` if none matches.
1540
-
1541
- ```php
1542
- $config = YAML::parseFile('team.yaml');
1543
- $email = ARR::find_key($config, 'email'); // finds `email` however deep it's nested
1544
- ```
1545
-
1546
- ---
1547
-
1548
- ### CURL
1549
-
1550
- Low-level HTTP client built on PHP's cURL extension, used internally by `SCRAPER`. Ships with a realistic browser `User-Agent`/header set and a cookie jar persisted at `.cookie.txt` (auto-registered via `PREPROS::exportFile()`).
1551
-
1552
- ```php
1553
- CURL::urlExists(string $url, ?string $mimereg = null): bool
1554
- CURL::getInfo(string $url): array|false // HEAD request, returns curl_getinfo()
1555
- CURL::getContents(string $file, ?string $dest = null, ?callable $clb = null): string|bool
1556
- ```
1557
-
1558
- `CURL::urlExists()` issues a `HEAD` request and returns `true` for any `2xx`/`3xx` response.
1559
-
1560
- `CURL::getContents()` downloads a URL. Without `$dest`, it returns the body as a string; with `$dest`, it streams the download to that file path and returns a boolean. Pass `$clb` to receive download progress as a float between `0` and `1`.
1561
-
1562
- ```php
1563
- CURL::getContents('https://example.com/report.pdf', '/project/src/downloads/report.pdf', function (float $progress) {
1564
- error_log(sprintf('%.0f%%', $progress * 100));
1565
- });
1566
- ```
1567
-
1568
- ---
1569
-
1570
- ### SCRAPER
1571
-
1572
- Fetches a URL and extracts page metadata (`title`, `description`, `image`, `label`) from its JSON-LD (`schema.org`), Open Graph, and standard `<meta>` tags — the kind of data you'd want for a rich link preview. Results are cached indefinitely via `CACHE`, keyed on the URL.
1573
-
1574
- ```php
1575
- $metas = SCRAPER::get(string $url): object|false;
1576
- ```
1577
-
1578
- ```php
1579
- $metas = SCRAPER::get('https://example.com/blog/some-article');
1580
- if ($metas) {
1581
- echo $metas->title; // string
1582
- echo $metas->description; // string
1583
- echo $metas->image; // string (absolute URL, may be empty)
1584
- echo $metas->label; // string — site/publisher name, may be empty
1585
- echo $metas->url; // string — the URL that was scraped
1586
- }
1587
- ```
1588
-
1589
- Returns `false` if the page can't be reached, can't be parsed, or has no discoverable title. Throws an `Exception` on invalid URLs. Uses `CURL::getContents()` under the hood, so it benefits from the same shared cookie jar and browser-like headers.
1590
-
1591
- ---
1592
-
1593
- ### OBF
1594
-
1595
- Simple reversible obfuscation for values you want to embed in HTML without making them trivially readable (e.g., contact data, API tokens in templates).
1596
-
1597
- ```php
1598
- $encoded = OBF::encode(mixed $obj): string;
1599
- $decoded = OBF::decode(string $str): mixed;
1600
- ```
1601
-
1602
- Applies JSON encoding → base64 → ROT-13 → gzip. Not cryptographically secure; intended for light obfuscation only.
1603
-
1604
- ---
1605
-
1606
- ### STD
1607
-
1608
- Output helpers used by the PHP runtime to communicate back to Node.js over stdout/stderr.
1609
-
1610
- ```php
1611
- STD::succeed(array|string $props = []): void // exits 0, writes JSON to stdout
1612
- STD::error(array|string $props = []): void // exits 1, writes JSON to stderr
1613
- ```
1614
-
1615
- These are internal to the build runner (`render()`, `sitemap()`, and `runenv()` all rely on them). You generally do not need to call them in page templates, but they are available if a script run via `runenv()` needs to terminate early with a custom result.
1616
-
1617
- ---
1618
-
1619
- ### Bundled polyfills
1620
-
1621
- The WASM PHP build ships without `ext-intl`, so `@kirigami/php-prepros` bundles a
1622
- `Normalizer` polyfill (autoloaded like every other class). It provides the
1623
- standard `Normalizer::normalize()` / `Normalizer::isNormalized()` API and the
1624
- `Normalizer::NFC` / `NFD` / `NFKC` / `NFKD` (and `FORM_*`) constants — enough for
1625
- `STR::normalize()` and `STR::slug()` to fold accents. Prefer the `STR` helpers in
1626
- your own code; the polyfill is there so third-party snippets that call
1627
- `Normalizer` directly keep working.
1628
-
1629
- ---
1630
-
1631
- ### Procedural shortcuts (aliases)
1632
-
1633
- Every static method of every class above is also exposed as a plain function by
1634
- `src/libraries/aliases.inc.php` (autoloaded — no `require` needed). Each alias is
1635
- named `<lowercase class>_<snake_case method>()` and does nothing but forward its
1636
- arguments, so the classes remain the canonical API. They exist to make page
1637
- templates and `kiri run` scripts read better:
1638
-
1639
- ```php
1640
- <?= md_to_html(file_get_contents('CHANGELOG.md')) ?>
1641
- <img src="<?= img_asset('hero.jpg', 1200, 630, true) ?>" alt="">
1642
- ```
1643
-
1644
- Every function has a complete PHPDoc block, so editor hover and autocomplete
1645
- surface the signature, parameters, and description.
1646
-
1647
- | Class | Aliases |
1648
- |---|---|
1649
- | `PREPROS` | `prepros_render` · `prepros_sitemap` · `prepros_mount` · `prepros_fstat` · `prepros_export_file` · `prepros_get_exported_files` · `prepros_backtrace_file` · `prepros_register_tag` · `prepros_register_hook` · `prepros_run_hook` |
1650
- | `MD` | `md_to_html` · `md_register_plugin` · `md_unregister_plugin` · `md_get_registered_plugins` · `md_register_emoji` |
1651
- | `HTML` | `html_format` |
1652
- | `YAML` | `yaml_parse` · `yaml_parse_file` · `yaml_load_file` |
1653
- | `SCHEMA` | `schema` (factory) · `schema_validate` |
1654
- | `LD` | `ld_add` · `ld_node` · `ld_ref` · `ld_organization` · `ld_person` · `ld_website` · `ld_web_page` · `ld_breadcrumb` · `ld_faq_page` · `ld_script` · `ld_json` |
1655
- | `META` | `meta_tag` · `meta_link` · `meta_raw` · `meta_tags` |
1656
- | `CACHE` | `cache_get` · `cache_set` · `cache_delete` · `cache_purge` |
1657
- | `IMG` | `img_asset` · `img_palette` |
1658
- | `FS` | `fs_dig` · `fs_get_relative_path` · `fs_php_file_info` · `fs_rmdir` · `fs_path_join` |
1659
- | `STR` | `str_htmlesc` · `str_replace_tags` · `str_parse_html_attributes` · `str_trim_indent` · `str_is_url` · `str_html_entities_decode` · `str_shorthash` · `str_normalize` · `str_slug` |
1660
- | `ARR` | `arr_find_key` |
1661
- | `CURL` | `curl_url_exists` · `curl_get_info` · `curl_get_contents` |
1662
- | `SCRAPER` | `scraper_get` |
1663
- | `OBF` | `obf_encode` · `obf_decode` |
1664
- | `STD` | `std_succeed` · `std_error` |
1665
-
1666
- Notes:
1667
-
1668
- - `register_tag()` / `register_hook()` are kept as unprefixed aliases of
1669
- `prepros_register_tag()` / `prepros_register_hook()`.
1670
- - `img_asset()` resolves the generated URL relative to the file that calls it,
1671
- exactly like `IMG::asset()`.
1672
- - `schema_validate(array $schema, mixed $data, ?array &$errors = null): bool`
1673
- fills `$errors` with the validation messages.
1674
- - `yaml_parse()` / `yaml_parse_file()` are only defined when the PECL `yaml`
1675
- extension isn't already providing them.
1676
-
1677
- ---
1678
-
1679
- ## Plugin system
1680
-
1681
- `@kirigami/php-prepros` has two complementary plugin layers: **PREPROS** (HTML-tag level, operates on the assembled page) and **MD** (shortcode level, operates inside Markdown content).
1682
-
1683
- ---
1684
-
1685
- ### PREPROS tags
1686
-
1687
- Register a custom HTML tag that is processed **after** PHP execution, on the fully assembled HTML string:
1688
-
1689
- ```php
1690
- // In a file listed under prepros.includes in kirigami.yaml, or in before.php:
1691
-
1692
- PREPROS::registerTag('gallery', function (string $fullTag, array $attrs, string $body): string {
1693
- $id = $attrs['id'] ?? '';
1694
- $imgs = glob("/project/src/images/gallery/{$id}/*.webp");
1695
- $html = '<div class="gallery">';
1696
- foreach ($imgs as $img) {
1697
- $src = str_replace('/project/src', '', $img);
1698
- $html .= "<img src=\"{$src}\" loading=\"lazy\">";
1699
- }
1700
- return $html . '</div>';
1701
- });
1702
- ```
1703
-
1704
- Then in any page template:
1705
-
1706
- ```html
1707
- <gallery id="summer-2025"></gallery>
1708
- ```
1709
-
1710
- The callback receives:
1711
-
1712
- | Parameter | Type | Description |
1713
- |-----------|------|-------------|
1714
- | `$fullTag` | `string` | The complete matched tag string |
1715
- | `$attrs` | `array` | Parsed HTML attributes as an associative array |
1716
- | `$body` | `string` | Inner content between opening and closing tags |
1717
-
1718
- The built-in [`<markdown>` and `<img asset>` tags](#built-in-tags) are registered
1719
- this way.
1720
-
1721
- ---
1722
-
1723
- ### PREPROS hooks
1724
-
1725
- Hooks let you intercept and transform data at key points in the rendering pipeline:
1726
-
1727
- ```php
1728
- PREPROS::registerHook(string $hookName, callable $callback): void
1729
- ```
1730
-
1731
- | Hook | When it fires | `$data` type | Expected return |
1732
- |------|---------------|--------------|-----------------|
1733
- | `boot` | Once per process, right after bootstrap (config loaded, `includes` pulled in), before any page renders. Fires for every entrypoint. | `stdClass $config` | ignored |
1734
- | `page_info` | After PHPDOC parsing, before rendering (auto-loads `.yaml`/`.json`/`.md` annotations) | `[$filePath, $pageObject]` — see note | `$pageObject` (modified) |
1735
- | `pre_render` | Before PHP execution | Raw file contents as `string` | `string` |
1736
- | `pre_before` | Just before the `before` include (inside its output buffer — `echo` to prepend to the header) | `before` config path as `string\|null` | ignored |
1737
- | `post_before` | Right after the `before` include, on the captured header | Header `string` | `string` |
1738
- | `pre_after` | Just before the `after` include (inside its output buffer — `echo` to prepend to the footer) | `after` config path as `string\|null` | ignored |
1739
- | `post_after` | Right after the `after` include, on the captured footer | Footer `string` | `string` |
1740
- | `post_render` | After tag processing, before `HTML::format()` | Assembled HTML `string` | `string` |
1741
-
1742
- Multiple callbacks can be registered for the same hook — they are executed in registration order, each receiving the return value of the previous one.
1743
-
1744
- > Built-in `page_info` + `post_render` callbacks power [`LD`](#ld) and
1745
- > [`META`](#meta): `page_info` captures the page under render, `post_render`
1746
- > injects the JSON-LD `<script>` and the `<meta>`/`<link>` block into its
1747
- > `<head>`. Your own callbacks run after them.
1748
-
1749
- > **`page_info` payload shape.** The hook *fires* with `[$filePath, $pageObject]`,
1750
- > but each callback is expected to return the `$pageObject` alone — so a callback
1751
- > registered after the built-ins receives the bare object, not the pair. Handle
1752
- > both: `$page = is_array($p) ? $p[1] : $p;` (the current page path is always
1753
- > available as `PREPROS::$file`).
1754
-
1755
- ```php
1756
- // Example: inject a last-modified date into every page
1757
- PREPROS::registerHook('post_render', function (string $html): string {
1758
- $date = date('Y-m-d');
1759
- return str_replace('{{build_date}}', $date, $html);
1760
- });
1761
- ```
1762
-
1763
- ---
1764
-
1765
- ### MD plugins
1766
-
1767
- MD plugins add custom shortcode tags inside Markdown content. They work inside `<markdown>` blocks, in `.md` data files, and anywhere `MD::toHtml()` is called.
1768
-
1769
- **Inline syntax** (all on one line):
1770
-
1771
- ```
1772
- {% tagname arg1 "argument with spaces" %}
1773
- ```
1774
-
1775
- **Block syntax** (body on subsequent lines):
1776
-
1777
- ```
1778
- {% tagname optional-arg
1779
- Line one of the body.
1780
- Line two of the body.
1781
- %}
1782
- ```
1783
-
1784
- ```php
1785
- MD::registerPlugin(string $name, callable $callback): void
1786
- ```
1787
-
1788
- The callback signature is always `(array $args, string $body): string`. `$args` contains arguments parsed from the opening line; `$body` is the trimmed multi-line body (empty string for inline tags).
1789
-
1790
- ---
1791
-
1792
- ### Built-in plugins
1793
-
1794
- The following MD plugins are registered out of the box in `md.plugins.php`:
1795
-
1796
- #### `{% callout type ["Title"] content %}`
1797
-
1798
- Renders a styled callout block. `type` is one of `info`, `success`, `warning`, `danger`.
1799
-
1800
- ```
1801
- {% callout warning "Heads up" This section is outdated. %}
1802
-
1803
- {% callout danger "Critical"
1804
- Line one of a longer warning.
1805
-
1806
- Line two after a blank line.
1807
- %}
1808
- ```
1809
-
1810
- #### `{% youtube id [width height] %}`
1811
-
1812
- Embeds a responsive YouTube player via `<iframe>`. `width`/`height` default to `560`/`315`.
1813
-
1814
- ```
1815
- {% youtube dQw4w9WgXcQ %}
1816
- {% youtube dQw4w9WgXcQ 800 450 %}
1817
- ```
1818
-
1819
- #### `{% codepen id [user height] %}`
1820
-
1821
- Embeds a CodePen result via `<iframe>`. `user` defaults to `anonymous`, `height` defaults to `400`.
1822
-
1823
- ```
1824
- {% codepen abcXYZ %}
1825
- {% codepen abcXYZ jsmith 500 %}
1826
- ```
1827
-
1828
- #### `{% checklist ["Title"] items %}`
1829
-
1830
- Renders a block-syntax list of checkbox items, one per line, with an optional title.
1831
-
1832
- ```
1833
- {% checklist "Today"
1834
- Do the dishes
1835
- Walk the dog
1836
- Read a book
1837
- %}
1838
- ```
1839
-
1840
- ---
1841
-
1842
- ## Extending the `<markdown>` tag
1843
-
1844
- The `<markdown>` tag is one of the [built-in tags](#built-in-tags) (alongside
1845
- `<img asset>`), registered as a PREPROS tag out of the box. It converts its inner
1846
- content from Markdown to HTML and strips common leading indentation so you can
1847
- write cleanly inside your PHP templates:
1848
-
1849
- ```html
1850
- <section class="about">
1851
- <div>
1852
- <markdown>
1853
- ## Who we are
1854
-
1855
- We are a **student organization** from Québec.
1856
-
1857
- {% youtube dQw4w9WgXcQ %}
1858
- </markdown>
1859
- </div>
1860
- </section>
1861
- ```
1862
-
1863
- All registered MD plugins are available inside `<markdown>` blocks. You can extend the tag's behaviour by registering additional MD plugins (see above) or by overriding the tag itself:
1864
-
1865
- ```php
1866
- PREPROS::registerTag('markdown', function (string $tag, array $attrs, string $body): string {
1867
- $body = STR::trimIndent($body);
1868
- $html = MD::toHtml($body);
1869
- // wrap in a container, add a class, etc.
1870
- $class = $attrs['class'] ?? 'prose';
1871
- return "<div class=\"{$class}\">{$html}</div>";
1872
- });
1873
- ```
1874
-
1875
- ---
1876
-
1877
- ## Requirements
1878
-
1879
- - Node.js `>= 24.0.0`
1880
- - npm `>= 10.2.3`
1881
- - ESM only (`"type": "module"`)
1882
-
1883
- ---
1884
-
1885
- ## License
1886
-
1887
- MIT © Maxime Larrivée-Roy, 2026
1
+ <div align="center">
2
+
3
+ <img src="https://zmotrin.github.io/assets/kirigami/kirigami-logo-universal.svg" alt="Kirigami" width="400" />
4
+
5
+ ---
6
+
7
+ # @kirigami/php-prepros
8
+
9
+
10
+ PHP preprocessor for the **Kirigami** static site generator.
11
+
12
+ [![npm version](https://img.shields.io/npm/v/@kirigami/php-prepros)](https://www.npmjs.com/package/@kirigami/php-prepros)
13
+ [![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](./LICENSE)
14
+ [![Node.js >=24.0.0](https://img.shields.io/badge/node-%3E%3D24.0.0-brightgreen)](https://nodejs.org)
15
+ [![Website](https://img.shields.io/badge/website-php--kirigami.github.io-1f6b4a)](https://php-kirigami.github.io)
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## Overview
22
+
23
+ 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.
24
+
25
+ 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.
26
+
27
+ Part of the **Kirigami** project ecosystem.
28
+
29
+
30
+ ---
31
+
32
+
33
+ ## Table of contents
34
+
35
+ - [@kirigami/php-prepros](#kirigamiphp-prepros)
36
+ - [Overview](#overview)
37
+ - [What's new in 3.0.0](#whats-new-in-300)
38
+ - [What's new in 2.0.0](#whats-new-in-200)
39
+ - [What's new in 1.9.3](#whats-new-in-193)
40
+ - [What's new in 1.9.2](#whats-new-in-192)
41
+ - [What's new in 1.9.1](#whats-new-in-191)
42
+ - [What's new in 1.9.0](#whats-new-in-190)
43
+ - [What's new in 1.8.0](#whats-new-in-180)
44
+ - [What's new in 1.7.2](#whats-new-in-172)
45
+ - [What's new in 1.7.1](#whats-new-in-171)
46
+ - [What's new in 1.7.0](#whats-new-in-170)
47
+ - [What's new in 1.6.0](#whats-new-in-160)
48
+ - [What's new in 1.4.0](#whats-new-in-140)
49
+ - [What's new in 1.3.0](#whats-new-in-130)
50
+ - [What's new in 1.2.1](#whats-new-in-121)
51
+ - [What's new in 1.2.0](#whats-new-in-120)
52
+ - [How it works](#how-it-works)
53
+ - [Installation](#installation)
54
+ - [Configuration — `kirigami.yaml`](#configuration--kirigamiyaml)
55
+ - [`kirigami` block](#kirigami-block)
56
+ - [`seo` block](#seo-block)
57
+ - [`prepros` block](#prepros-block)
58
+ - [`image` block](#image-block)
59
+ - [`plugins` block](#plugins-block)
60
+ - [`esbuild` / `sass` blocks](#esbuild--sass-blocks)
61
+ - [`export` block](#export-block)
62
+ - [`scripts` block](#scripts-block)
63
+ - [`tasks` block](#tasks-block)
64
+ - [Writing pages](#writing-pages)
65
+ - [PHPDOC header](#phpdoc-header)
66
+ - [Auto-loading data files](#auto-loading-data-files)
67
+ - [`@content`, `@indent`, and `@type`](#content-indent-and-type)
68
+ - [Built-in tags](#built-in-tags)
69
+ - [JavaScript API](#javascript-api)
70
+ - [`render(file?, phpIncludes?)`](#renderfile-phpincludes)
71
+ - [`sitemap()`](#sitemap)
72
+ - [`runenv(script, paths?, ...args)`](#runenvscript-paths-args)
73
+ - [`runPluginScript(script, pluginRoot, paths?, ...args)`](#runpluginscriptscript-pluginroot-paths-args)
74
+ - [`mountPath(localPath, virtualDir?, php?)`](#mountpathlocalpath-virtualdir-php)
75
+ - [`processImages(jobs)`](#processimagesjobs)
76
+ - [`resetRuntime()`](#resetruntime)
77
+ - [TypeScript declarations](#typescript-declarations)
78
+ - [PHP classes reference](#php-classes-reference)
79
+ - [PREPROS](#prepros)
80
+ - [MD](#md)
81
+ - [HTML](#html)
82
+ - [YAML](#yaml)
83
+ - [SCHEMA](#schema)
84
+ - [LD](#ld)
85
+ - [META](#meta)
86
+ - [CACHE](#cache)
87
+ - [IMG](#img)
88
+ - [FS](#fs)
89
+ - [STR](#str)
90
+ - [ARR](#arr)
91
+ - [CURL](#curl)
92
+ - [SCRAPER](#scraper)
93
+ - [OBF](#obf)
94
+ - [STD](#std)
95
+ - [Unicode normalization](#unicode-normalization)
96
+ - [Procedural shortcuts (aliases)](#procedural-shortcuts-aliases)
97
+ - [Plugin system](#plugin-system)
98
+ - [PREPROS tags](#prepros-tags)
99
+ - [PREPROS hooks](#prepros-hooks)
100
+ - [MD plugins](#md-plugins)
101
+ - [Built-in plugins](#built-in-plugins)
102
+ - [Extending the `<markdown>` tag](#extending-the-markdown-tag)
103
+ - [Requirements](#requirements)
104
+ - [License](#license)
105
+
106
+ ---
107
+
108
+ ## What's new in 3.0.0
109
+
110
+ 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.
111
+
112
+ 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`.
113
+
114
+ `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.
115
+
116
+ `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`.
117
+
118
+ `Normalizer` now comes from the native `norm` extension (utf8proc) instead of the bundled pure-PHP polyfill, which stays available as `NORMALIZER_LEGACY`.
119
+
120
+ **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.
121
+
122
+ ```yaml
123
+ # before # after
124
+ seo: seo:
125
+ language: fr-CA lang: fr-CA
126
+ jsonld: type: ProfessionalService
127
+ type: ProfessionalService logo: images/logo.png
128
+ logo: images/logo.png
129
+ ```
130
+
131
+ ---
132
+
133
+ ## What's new in 2.0.0
134
+
135
+ - **Breaking: `meta:` and `jsonld:` merged into one top-level `seo:` block.**
136
+ The two used to be independent siblings of `kirigami:` that happened to
137
+ share fallback data; they're now one block, one mental model for a
138
+ project's whole SEO/social surface — `META`'s own keys live directly under
139
+ `seo:`, and `jsonld` is nested inside it as its own sub-block:
140
+
141
+ ```yaml
142
+ # before (1.x)
143
+ meta:
144
+ favicon: favicon.png
145
+ jsonld: {}
146
+
147
+ # after (2.0.0)
148
+ seo:
149
+ favicon: favicon.png
150
+ jsonld: {}
151
+ ```
152
+
153
+ The two stay **independently toggled** exactly as before — a project can
154
+ have META's tags without JSON-LD, or vice versa; `seo: { jsonld: false }`
155
+ (or `{ auto: false }`) stops just the JSON-LD injection, `seo: false` (or
156
+ `{ auto: false }` at the top level) stops just META's. Every fallback chain
157
+ (a page's PHPDOC → the block → the loose `kirigami:` keys) is unchanged,
158
+ including `jsonld`'s own keys still feeding META's defaults (`jsonld.image`,
159
+ `jsonld.lang`, `jsonld.person`, …) — only *where the two blocks live* in
160
+ `kirigami.yaml` changed, not how resolution works. **Migration**: rename
161
+ `meta:` to `seo:` and move the `jsonld:` block's content under it as
162
+ `seo.jsonld:`. See [`seo` block](#seo-block) / [`meta` config](#meta-config)
163
+ / [`jsonld` config](#jsonld-config).
164
+
165
+ ---
166
+
167
+ ## What's new in 1.9.3
168
+
169
+ - Dependency bump to `@kirigami/struct-walker` 1.0.5; `homepage` + README
170
+ pointed at the site (metadata only).
171
+
172
+ ---
173
+
174
+ ## What's new in 1.9.2
175
+
176
+ - **Fixed a real Markdown bug**: a list item's source line count was 1:1
177
+ with `<li>` count, so an indented continuation line with no marker of its
178
+ own (a soft-wrapped `- **foo** text\n more text`) fell outside the
179
+ block-matching regex entirely — the list closed after the first line, the
180
+ continuation resurfaced as a stray flat `<p>`, and a new list reopened for
181
+ the next marker line. Fixed by widening the block regex to also accept a
182
+ marker-less indented line and, in the per-line loop, appending it to the
183
+ previous item instead of dropping it.
184
+
185
+ ---
186
+
187
+ ## What's new in 1.9.1
188
+
189
+ - **`{% img-asset %}` no longer crashes the build on an unresolvable path.**
190
+ `IMG::asset()`'s exception is now caught and turned into an HTML comment,
191
+ matching `codepen`/`checklist`'s own missing-argument behavior instead of
192
+ aborting the whole render. This also makes it safe to *document* the tag —
193
+ a literal `` `{% img-asset path … %}` `` written as prose inside a code
194
+ span still runs the plugin (code-span protection only swaps the *displayed*
195
+ output back to the literal text; the callback itself always executes), so
196
+ a placeholder path used to throw "Invalid image file." and fail the build.
197
+
198
+ ---
199
+
200
+ ## What's new in 1.9.0
201
+
202
+ - **`{% youtube %}` removed** — moved to
203
+ [`@kirigami/plugin-embed`](https://www.npmjs.com/package/@kirigami/plugin-embed),
204
+ which replaces the old plain-iframe output with a real oEmbed-backed card
205
+ (cover thumbnail, title, play button — no network call until the visitor
206
+ actually clicks play). If a project used the built-in `{% youtube %}`,
207
+ install the plugin; without it, the tag now falls through unresolved
208
+ (`{% youtube ID %}` printed as-is) rather than rendering an iframe.
209
+ `codepen`/`checklist`/`callout` are unaffected.
210
+
211
+ ---
212
+
213
+ ## What's new in 1.8.0
214
+
215
+ - **`{% img-asset %}` — a new built-in Markdown plugin.** Same pipeline as the
216
+ `<img asset>` HTML tag (`IMG::asset()`: resize, cache, publish under
217
+ `image.dest`), usable straight from Markdown text:
218
+
219
+ ```
220
+ {% img-asset photo.jpg %}
221
+ {% img-asset photo.jpg 800 %}
222
+ {% img-asset photo.jpg 800 600 %}
223
+ {% img-asset photo.jpg 800 600 cover %}
224
+ ```
225
+
226
+ Positional args: source path (relative to `image.source`), width, height,
227
+ and the literal `cover` keyword. Registered in `md.plugins.php` alongside
228
+ `codepen`/`youtube`/`checklist`/`callout` — available out of the box, drop
229
+ it with `MD::unregisterPlugin('img-asset')` if you don't want it.
230
+
231
+ ---
232
+
233
+ ## What's new in 1.7.2
234
+
235
+ - **Cleaner formatted output around highlighted code.** The de-indent script
236
+ `PREPROS::injectHead()` adds when `prepros.format` is on now flattens the
237
+ `<pre><code>` indentation `HTML::format()` writes for *every* block, including
238
+ ones a build-time highlighter has wrapped in `<span>`s. It works on
239
+ `innerHTML` line by line and removes only the shared leading run (relative
240
+ indentation is kept). This lets [`@kirigami/plugin-highlight`](https://www.npmjs.com/package/@kirigami/plugin-highlight)
241
+ 1.7.2+ re-indent its markup to line up with the rest of the document instead
242
+ of leaving it flush-left — the served HTML stays consistently indented, the
243
+ rendered code is still de-indented before the first paint.
244
+
245
+ - **`<markdown prose>` wraps in `.prose`.** With the `prose` attribute the
246
+ built-in tag emits `<div class="prose"> … </div>` so long-form Markdown picks
247
+ up `@kirigami/canva`'s `styles/prose` typography with no extra markup. Opt-in
248
+ (a bare `<markdown>` is unchanged); `class` / `id` on the tag land on the
249
+ wrapper.
250
+
251
+ ---
252
+
253
+ ## What's new in 1.7.1
254
+
255
+ - **No side effects on import.** `kirigami.yaml` is now loaded on first use
256
+ (`render()` / `sitemap()` / `runenv()` / `processImages()`), not while the
257
+ module is being imported. `import '@kirigami/php-prepros'` from a directory
258
+ with no project no longer throws — which is what made `kiri build --help` /
259
+ `kiri export --help` / `kiri run --help` crash instead of printing their help.
260
+
261
+ ---
262
+
263
+ ## What's new in 1.7.0
264
+
265
+ - **`META`** class — a `<head>` SEO / social metadata generator, the companion
266
+ to [`LD`](#ld). Builds the standard tags — `<title>`, `description`,
267
+ `keywords`, `robots`, `language`, `generator`, `author`, Open Graph, Twitter
268
+ Card, `<link rel="canonical">`, favicon / apple-touch-icon / humans — from
269
+ each page's PHPDOC, the top-level `meta:` block, and the loose `kirigami:` /
270
+ `jsonld:` keys `LD` already reads. It emits only what it can resolve, and
271
+ leaves any tag the layout already hand-writes untouched.
272
+
273
+ - **Opt-in**: a top-level `meta:` block (empty `meta: {}` is enough) switches
274
+ on automatic injection into every page's `<head>`. `meta: false` (or
275
+ `{ auto: false }`) keeps the config but stops the injection.
276
+ - Per-page PHPDOC: `@meta false` (skip), `@meta_title`, `@meta_description`
277
+ (falls back to `@description` / `@abstract` / `@excerpt`), `@meta_keywords`,
278
+ `@meta_image`, `@meta_robots`, `@meta_type`, `@canonical`.
279
+ - Manual builders — always emitted, still de-duplicated: `META::tag()`,
280
+ `META::link()`, `META::raw()`, `META::tags()`. Procedural aliases:
281
+ `meta_tag()`, `meta_link()`, `meta_raw()`, `meta_tags()`.
282
+
283
+ Full key reference: [`META` → `meta` config](#meta-config).
284
+
285
+ ---
286
+
287
+ ## What's new in 1.6.0
288
+
289
+ - **Managed `<head>` (`prepros.head`).** Every rendered page's `<head>` is now
290
+ auto-wired: a tiny theme/FOUC guard as the first child (adds the `js` class,
291
+ applies the stored `data-theme` before first paint), a
292
+ `<link rel="stylesheet">` for every `sass` task output, and a `<script>` (no
293
+ `defer`, just before `</body>`) for every `esbuild` task output — each with a
294
+ per-page relative path and a `?<timestamp>` cache-bust. A file already
295
+ referenced in the page is left alone, so you can still hand-place one. Turn it
296
+ off with `prepros: { head: false }`, or `head: false` on a single sass/esbuild
297
+ task. A template's `header.php` no longer wires assets at all.
298
+
299
+ - **`HTML::format()` indents `<pre><code>`.** A fenced code block's lines are
300
+ shifted to the block's nesting depth so the HTML source stays readable
301
+ (relative indentation preserved). The exact leading run is stripped again
302
+ before it's shown, by a small de-indent script `prepros.head` injects before
303
+ `</body>` (only when `format` is on). Since 1.7.2 the script works on
304
+ `innerHTML` line by line, so it also flattens blocks a build-time highlighter
305
+ has wrapped in `<span>`s. A bare `<pre>` and `<textarea>` are still emitted
306
+ byte-for-byte.
307
+
308
+ ---
309
+
310
+ ## What's new in 1.4.0
311
+
312
+ A round of fixes to the rough edges that showed up building a full site from
313
+ scratch — mostly developer-experience, all backward compatible.
314
+
315
+ - **`HTML::format()` keeps `<pre>` / `<textarea>` verbatim.** Their line breaks,
316
+ indentation and blank lines are no longer collapsed, so a fenced code block
317
+ survives the formatter intact — `format: true` and Markdown code blocks now
318
+ coexist. (1.6.0 refines this: a `<pre><code>` block is re-indented to its
319
+ nesting depth and de-indented again before display.)
320
+ - **The default Markdown plugins load out of the box.** `{% callout %}`,
321
+ `{% youtube %}`, `{% codepen %}` and `{% checklist %}` are registered
322
+ automatically (`md.plugins.php` is auto-included from `MD`), as the docs always
323
+ said. Drop one with `MD::unregisterPlugin('name')` or shadow it with your own
324
+ `MD::registerPlugin()`.
325
+ - **Build errors you can actually read.** A fatal in a template (a bad call, a
326
+ `null` argument, a `TypeError`…) comes back as a structured failure with the
327
+ message, the offending page and the `file:line` — never a bare
328
+ `Error: undefined`. The `try/catch` now covers `Throwable`, not just
329
+ `Exception`. PHP warnings and notices no longer sink an otherwise-clean build:
330
+ they surface as `warnings` on the result. `kiri` prints the message, the page,
331
+ and the tail of the PHP stderr/debug output on failure.
332
+ - **PHPDOC parsing.** A tag value may now wrap onto the following *indented*
333
+ continuation lines instead of being silently truncated at the first line. And
334
+ an `@word` written in the block's prose is ignored rather than overwriting a
335
+ real tag — only lines that *start* with `@` open a tag.
336
+ - **`FS::getBreadcrumb()` / `FS::getChildren()`** are anchored on the page being
337
+ rendered (`PREPROS::$file`), so they return the right trail / child list when
338
+ called from a layout include, a partial, or a helper function — not only
339
+ straight from the template. Pass an explicit path to override.
340
+ - **`{% tag %}` inside a code span or code block stays literal** (`` `{% badge %}` ``
341
+ renders as text) instead of being expanded — or leaking an unrestored
342
+ placeholder.
343
+ - **`IMG` never upscales.** A requested size larger than the source is clamped
344
+ down to the source instead of throwing an opaque encoder error (the AVIF
345
+ encoder in particular).
346
+ - **Build-time tokens expand at render time.** `###YEAR###`, `###TIMESTAMP###`
347
+ and `###TODAY###` are substituted when each page is generated, so
348
+ `kiri build` / `kiri watch` previews show real values, not the literal token
349
+ (previously only `kiri export` replaced them).
350
+ - **`page_info` hook robustness.** The first built-in callback accepts either the
351
+ `[$file, $info]` pair the hook fires with or the bare `$info` object a later
352
+ callback receives, so a custom `page_info` hook can't fatal on the argument
353
+ shape. See [PREPROS hooks](#prepros-hooks).
354
+
355
+ ---
356
+
357
+ ## What's new in 1.3.0
358
+
359
+ - **`LD`** class — a schema.org JSON-LD generator. Collects structured-data
360
+ nodes during a render and emits them as a single
361
+ `<script type="application/ld+json">` `@graph` in every page's `<head>`.
362
+ - Automatic injection is **opt-in**: add a top-level `jsonld:` block to
363
+ `kirigami.yaml` (even empty, `jsonld: {}`) and an `Organization` (+ `Person`, `WebSite`,
364
+ `WebPage`, `BreadcrumbList`) graph is derived from that block plus the loose
365
+ keys projects already carry (`person`, `jobtitle`, `email`, `area`,
366
+ `knowsabout`, `keywords`, `facebook`, …). No `jsonld:` block → nothing is
367
+ injected.
368
+ - Turn it back off with `jsonld: false` / `jsonld: { auto: false }`, or per
369
+ page with `@ld false`; per-page `@ld_type` / `@ld_title` / `@ld_image` / …
370
+ tags feed the page node, and a `BreadcrumbList` is built from the
371
+ `_index.php` ancestor trail with no opt-in.
372
+ - Explicit builders for everything else: `LD::add()`, `LD::article()`,
373
+ `LD::faqPage()`, `LD::breadcrumb()`, `LD::ref()`, and every schema.org type
374
+ via `LD::typeName([...])`. Procedural aliases: `ld_add()`, `ld_organization()`,
375
+ `ld_script()`, …
376
+ - A page that already hand-writes an `application/ld+json` script is left
377
+ untouched.
378
+
379
+ ---
380
+
381
+ ## What's new in 1.2.1
382
+
383
+ - **`processImages()`** JS export — batch resize / palette-extraction through the
384
+ `IMG` class (`src/imagebatch.php`). `@kirigami/kirigami`'s `sass` task now uses
385
+ it for `img-asset()` / `colors()`, so the whole toolchain is free of a native
386
+ image dependency (`sharp` is gone).
387
+ - **`IMG::save()`** takes an optional `$quality` (0-100) for jpg / webp / avif;
388
+ `null` keeps the per-format default (82).
389
+
390
+ ---
391
+
392
+ ## What's new in 1.2.0
393
+
394
+ - **`SCHEMA`** class — a pure-PHP, dependency-free JSON Schema validator
395
+ (Draft-7 style, Ajv-like API: `isValid()` / `validate()` / `getErrors()`).
396
+ - **`IMG::asset()` / `IMG::palette()`** — static helpers powering kirigami-core's
397
+ `img-asset()` and `colors()` Sass functions: on-demand resize/convert of a
398
+ source image, and cached representative-colour extraction.
399
+ - **`<img asset="…">` tag** — the HTML-side entry point of the image
400
+ autogenerator, same parameters as `IMG::asset()` (see [Built-in tags](#built-in-tags)).
401
+ - **`IMG` now handles vector and exotic formats** — SVG, EPS, AI, PDF (rasterized
402
+ via Imagick), plus HEIC / TIFF / BMP, on top of GD's JPEG / PNG / GIF / WebP /
403
+ AVIF.
404
+ - **`MD` emoji shortcodes** — `:rocket:` → 🚀 from a large built-in map, extend­able
405
+ with `MD::registerEmoji()`.
406
+ - **`MD` footnotes and definition lists** — `[^1]` / `[^1]: …`, and `Term` / `: …`.
407
+ - **`MD` inline HTML is now sanitized** against a tag/attribute allowlist rather
408
+ than passed through verbatim.
409
+ - **`STR::normalize()`** — Unicode NFD + combining-mark stripping;
410
+ **`STR::slug($str, $sep = '')`** now takes a separator (pass `'-'` for a
411
+ hyphenated slug).
412
+ - **Bundled `Normalizer` polyfill** — `ext-intl` isn't in the WASM build, so a
413
+ polyfill keeps `Normalizer::normalize()` (and `STR::normalize()` / `slug()`)
414
+ working.
415
+
416
+ Earlier, in 1.1.x: `PREPROS::mount()` + the `mountPath()` / `runenv()` JS
417
+ exports, the `SCRAPER` and `CURL` and `ARR` classes, `YAML::loadFile()`, the
418
+ `STR::is_url()` / `html_entities_decode()` / `shorthash()` / `slug()` helpers,
419
+ the `{% youtube %}` / `{% codepen %}` / `{% checklist %}` MD plugins, and the
420
+ `@content` / `@indent` PHPDOC annotations.
421
+
422
+ ---
423
+
424
+ ## How it works
425
+
426
+ `@kirigami/php-prepros` runs your PHP source files inside a **WebAssembly PHP 8.x runtime** ([`@kirigami/php-wasm`](https://www.npmjs.com/package/@kirigami/php-wasm)), entirely in Node.js — no PHP installation required on the host machine.
427
+
428
+ The lifecycle of a page build looks like this:
429
+
430
+ ```
431
+ _index.php ──▶ PHP (wasm) ──▶ processTags() ──▶ HTML::format() ──▶ index.html
432
+ │
433
+ ├── before.php (optional layout header)
434
+ ├── after.php (optional layout footer)
435
+ └── PHPDOC annotations resolved (yaml / json / md / url)
436
+ ```
437
+
438
+ 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()`](#preprosmountstringarray-patterns).
439
+
440
+ ---
441
+
442
+ ## Installation
443
+
444
+ ```bash
445
+ npm install @kirigami/php-prepros
446
+ ```
447
+
448
+ ---
449
+
450
+ ## Configuration — `kirigami.yaml`
451
+
452
+ Every project **must** have a `kirigami.yaml` at its root. The preprocessor reads it at startup and throws if it is absent or invalid.
453
+
454
+ `@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`](../kirigami/kirigami.schema.json).
455
+
456
+ ```yaml
457
+ # yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/php-kirigami/kirigami@main/packages/kirigami/kirigami.schema.json
458
+ ```
459
+
460
+ ```yaml
461
+ kirigami:
462
+ # ── Required ──────────────────────────────────────────────────────────
463
+ project: My Website # Site name. Printed in the CLI banner, exposed as $project.
464
+ baseurl: https://example.com # Deployed root URL, no trailing slash. Used for sitemap.xml.
465
+ root: src # Source directory containing your _*.php pages.
466
+
467
+ # ── Optional ────────────────────────────────────────────────────────
468
+ banner: assets/banner.txt # Text file stamped as a license banner on exported files.
469
+
470
+ # ── Arbitrary project data ──────────────────────────────────────────
471
+ # Everything else under `kirigami:` is free-form. The whole block is
472
+ # extracted as PHP variables and made available in every page, in
473
+ # before.php/after.php, and anywhere PREPROS::$config->data is read.
474
+ author: Jane Doe
475
+ email: hello@example.com
476
+ gtag: G-XXXXXXXXXX
477
+ description: A short description of the site, useful for <meta name="description">.
478
+ keywords:
479
+ - keyword one
480
+ - keyword two
481
+
482
+ seo: # Presence turns on the META tags and LD JSON-LD (`seo: {}` is enough).
483
+ type: Organization # JSON-LD main entity; `jsonld: false` turns the JSON-LD off.
484
+ logo: assets/logo.png
485
+
486
+ prepros:
487
+ before: _layouts/header.php # Included before every page body.
488
+ after: _layouts/footer.php # Included after every page body.
489
+ format: true # Pretty-print the HTML output (default: false).
490
+ network: true # Allow HTTP fetches in PHPDOC @tag annotations / CURL / SCRAPER.
491
+ mountext: # Extra file extensions to auto-mount into the wasm fs,
492
+ - .svg # in addition to the defaults (.php .json .yaml .yml .md .db .txt).
493
+ - .webp
494
+ includes: # PHP files auto-included once, before any page renders.
495
+ - _lib/functions.php
496
+ types: # Named page types — opt in per page with @type <name>.
497
+ article:
498
+ before: _layouts/types/article.header.php
499
+ after: _layouts/types/article.footer.php
500
+
501
+ image: # Image autogenerator — powers IMG::asset() / IMG::palette().
502
+ format: webp # webp | avif (default: webp)
503
+ source: assets/images # Source folder, relative to cwd() (default: assets/images)
504
+ dest: images # Output folder, relative to kirigami.root (default: images)
505
+
506
+ plugins:
507
+ - name: "@kirigami/plugin-highlight"
508
+ active: true
509
+ options:
510
+ theme: auto
511
+
512
+ esbuild:
513
+ # minify: false
514
+
515
+ sass:
516
+ style: expanded
517
+
518
+ export:
519
+ path: dist
520
+ ignore: ["*.psd", "notes/"]
521
+
522
+ scripts:
523
+ - name: convert-images
524
+ mount: ["assets/images/**/*.jpg"]
525
+ trigger: before-build # before-build | before-export | after-export
526
+
527
+ tasks:
528
+ - name: js-core
529
+ type: esbuild
530
+ entry: scripts/kirigami.core.js
531
+
532
+ - name: scss-core
533
+ type: sass
534
+ entry: styles/kirigami.core.scss
535
+ ```
536
+
537
+ ### `kirigami` block
538
+
539
+ 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`.
540
+
541
+ | Key | Required | Description |
542
+ |-----|----------|--------------|
543
+ | `project` | ✅ | Human-readable site name. Exposed as `$project`. |
544
+ | `baseurl` | ✅ | Root URL of the deployed site, no trailing slash. Used to build absolute `<loc>` entries in `sitemap.xml`; exposed as `$baseurl`. |
545
+ | `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. |
546
+ | `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. |
547
+ | *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`](#ld) also reads some of these by convention: `person`, `jobtitle`, `email`, `area`, `knowsabout`, `keywords`, and social-network URL keys (`facebook`, `instagram`, …). |
548
+
549
+ ### `seo` block
550
+
551
+ Top-level, optional. The SEO surface: one set of keys feeds both the
552
+ [`META`](#meta) tags (the standard SEO / social `<meta>` and `<link>` tags) and
553
+ the [`LD`](#ld) schema.org JSON-LD graph, and its **presence** switches both on
554
+ for every page's `<head>`. An empty `seo: {}` is enough; everything is derived
555
+ from the `kirigami` block and each page's PHPDOC (`@title`, `@description` /
556
+ `@abstract`, `@keywords`, `@image`, `@robots`, `@og_type`, `@canonical`). A
557
+ tag or script the layout already hand-writes is left untouched.
558
+
559
+ - `auto: false` stops META's tags, `jsonld: false` stops the JSON-LD; the
560
+ config values stay available to `META::tags()` / `LD::script()`.
561
+ - `seo: false` keeps nothing on; no block at all means nothing is injected
562
+ (explicit `META::tag()` / `LD::add()` calls still emit).
563
+
564
+ Full key reference: [`seo` config](#seo-config). Per-page tags:
565
+ [`@meta_*`](#meta) and [`@ld_*`](#ld).
566
+
567
+ ### `prepros` block
568
+
569
+ 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.
570
+
571
+ 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.
572
+
573
+ | Key | Type | Default | Description |
574
+ |-----|------|---------|--------------|
575
+ | `before` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **before** every page's body. Typically your `<head>`/layout opening. |
576
+ | `after` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **after** every page's body. Typically your layout closing. |
577
+ | `format` | `bool` | `false` | Pretty-print the compiled HTML via [`HTML::format()`](#html) before writing it to disk. |
578
+ | `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. |
579
+ | `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](#auto-loading-data-files)), and for the `CURL` / `SCRAPER` classes. |
580
+ | `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()`](#preprosmountstringarray-patterns) instead. |
581
+ | `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()`. |
582
+ | `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`](#content-indent-and-type). |
583
+
584
+ ### `image` block
585
+
586
+ Options for the image autogenerator. **Read by `php-prepros`** — these are what [`IMG::asset()` / `IMG::palette()`](#img), the [`<img asset>` tag](#img-asset), and kirigami-core's `img-asset()` / `colors()` Sass functions all resolve against. Optional; the defaults below apply even when the block is absent.
587
+
588
+ | Key | Type | Default | Description |
589
+ |-----|------|---------|--------------|
590
+ | `format` | `string` | `webp` | Output format for generated images: `webp` or `avif`. |
591
+ | `source` | `string` | `assets/images` | Folder holding the source images, relative to `cwd()`. |
592
+ | `dest` | `string` | `images` | Destination folder for generated images, relative to `kirigami.root`. |
593
+
594
+ ### `plugins` block
595
+
596
+ List of Kirigami plugins. **Consumed by the `kiri` CLI** (see [`@kirigami/sdk`](https://www.npmjs.com/package/@kirigami/sdk)), not by `php-prepros` directly.
597
+
598
+ | Key | Required | Description |
599
+ |-----|----------|--------------|
600
+ | `name` | ✅ | Plugin package name. Must match `@kirigami/plugin-*`, `<scope>/kirigami-plugin-*`, or `kirigami-plugin-*`. |
601
+ | `active` | ✅ | Whether the plugin is loaded. |
602
+ | `options` | — | Free-form object passed to the plugin; its shape depends on the plugin. |
603
+
604
+ ### `esbuild` / `sass` blocks
605
+
606
+ 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`](https://esbuild.github.io/api/#build-api) and Dart Sass's [`Options`](https://sass-lang.com/documentation/js-api/interfaces/options/) for what's accepted. Writing the key with nothing under it parses to `null` in YAML, equivalent to omitting the block.
607
+
608
+ `sass:` additionally recognizes two keys that are **not** passed to Dart Sass:
609
+
610
+ | Key | Type | Description |
611
+ |-----|------|--------------|
612
+ | `before` | `string` / `string[]` | Extra `.scss` files compiled **before** the task entry (paths relative to `cwd()`). |
613
+ | `after` | `string` / `string[]` | Extra `.scss` files compiled **after** the task entry. |
614
+
615
+ ### `export` block
616
+
617
+ Options for `kiri export`. **Consumed by the `kiri` CLI.** Optional.
618
+
619
+ | Key | Type | Default | Description |
620
+ |-----|------|---------|--------------|
621
+ | `path` | `string` | `dist` | Output directory for `kiri export`, relative to the project root. |
622
+ | `ignore` | `string[]` | `[]` | Extra gitignore-style patterns excluded from the export copy, on top of Kirigami's built-in exclusions. |
623
+
624
+ ### `scripts` block
625
+
626
+ Named PHP scripts. **Consumed by the `kiri` CLI**, which runs each `scripts/<name>.php` through [`runenv()`](#runenvscript-paths-args) — so the full `php-prepros` class library is available and `kirigami.yaml`'s `kirigami` block is exposed as `PREPROS::$config->data`.
627
+
628
+ | Key | Required | Description |
629
+ |-----|----------|--------------|
630
+ | `name` | ✅ | Must match an existing `scripts/<name>.php` file. Run with `kiri run <name> [args...]`; extra CLI arguments are forwarded as `$argv` entries. |
631
+ | `mount` | — | Glob patterns (relative to the project root) of extra local files to mount into the sandbox before the script runs. |
632
+ | `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). |
633
+
634
+ ### `tasks` block
635
+
636
+ 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`).
637
+
638
+ | `type` | Purpose | Required fields | Optional |
639
+ |--------|---------|-----------------|----------|
640
+ | `esbuild` | Bundle/minify a JS/TS entry. Build + watch. Output: `<entry>.min.js`. | `name`, `type`, `entry` | `force` |
641
+ | `sass` | Compile a `.scss`/`.sass` entry, minified with csso on export. Build + watch. Output: `<entry>.min.css`. | `name`, `type`, `entry` | `force` |
642
+ | `prepros` | Render pages + `sitemap.xml`. Watch only (runs on build/export only when forced/implicit). | `name`, `type` | `target`, `force` |
643
+ | `dist` | Copy `kirigami.root` into an output dir, stamping the banner. Forced/implicit only. | `name`, `type`, `path` | `ignore`, `force` |
644
+
645
+ ---
646
+
647
+ ## Writing pages
648
+
649
+ 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.
650
+
651
+ ```
652
+ src/
653
+ ├── _layouts/
654
+ ├── _lib/
655
+ ├── _index.php → src/index.html
656
+ ├── about/
657
+ │ └── _index.php → src/about/index.html
658
+ └── blog/
659
+ ├── _index.php → src/blog/index.html
660
+ └── _articles.yaml (data file, not compiled)
661
+ ```
662
+
663
+ Directories whose name starts with `_` (e.g. `_layouts/`, `_lib/`) are skipped entirely during directory-wide builds.
664
+
665
+ ### PHPDOC header
666
+
667
+ Every page starts with a PHP docblock that drives metadata and data loading:
668
+
669
+ ```php
670
+ <?php
671
+ /**
672
+ * @name about
673
+ * @title About us
674
+ * @abstract A short description of this page.
675
+ */
676
+ ?>
677
+ <section>
678
+ <h1><?php echo $title; ?></h1>
679
+ <p><?php echo $abstract; ?></p>
680
+ </section>
681
+ ```
682
+
683
+ All annotations are injected as PHP variables (`$name`, `$title`, `$abstract`, …). You can define any custom annotation you need.
684
+
685
+ Annotations are also available as variables in `before` and `after` PHP included files, so you can write proper metas in the HTML header.
686
+
687
+ Only lines whose first non-whitespace character (past the `*` gutter) is `@`
688
+ open an annotation — an `@word` written in the prose of the block is left alone.
689
+ A value can wrap onto the following **indented** continuation lines:
690
+
691
+ ```php
692
+ /**
693
+ * @title About us
694
+ * @description A longer blurb that does not fit comfortably
695
+ * on a single line and continues here.
696
+ */
697
+ ```
698
+
699
+ ### Auto-loading data files
700
+
701
+ 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.
702
+
703
+ ```php
704
+ <?php
705
+ /**
706
+ * @name medias
707
+ * @articles _articles.yaml
708
+ */
709
+ ?>
710
+ <?php foreach ($articles as $article): ?>
711
+ <a href="<?php echo $article->lien; ?>">
712
+ <?php echo $article->titre; ?>
713
+ </a>
714
+ <?php endforeach; ?>
715
+ ```
716
+
717
+ | Extension | Parsed as |
718
+ |-----------|-----------|
719
+ | `.yaml` / `.yml` | `stdClass` object (or array of objects for sequences) |
720
+ | `.json` | Result of `json_decode()` |
721
+ | `.md` | HTML string via `MD::toHtml()` |
722
+
723
+ 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:
724
+
725
+ ```php
726
+ /**
727
+ * @posts https://api.example.com/posts.json
728
+ */
729
+ ```
730
+
731
+ ### `@content`, `@indent`, and `@type`
732
+
733
+ Three special annotation names change how a page's body is assembled:
734
+
735
+ - **`@content`** — if a `content` variable already resolves to a non-empty value (typically because it's a `.md`/`.yaml`/`.json` annotation 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.
736
+ - **`@indent`** — when set to a number, every line of the rendered body is prefixed with that many spaces before being wrapped by `before.php`/`after.php`. Useful for keeping generated HTML readable when a page is nested inside indented layout markup.
737
+ - **`@type`** — names an entry under [`prepros.types`](#prepros-block). If it matches, that entry's `before`/`after` wrap the (already-indented) body **one level inside** `before.php`/`after.php`: global before → type before → body → type after → global after. No match (missing annotation, or a name absent from `prepros.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.
738
+
739
+ ```php
740
+ <?php
741
+ /**
742
+ * @name changelog
743
+ * @title Changelog
744
+ * @content _changelog.md
745
+ * @indent 4
746
+ * @type article
747
+ */
748
+ ```
749
+
750
+ ### Built-in tags
751
+
752
+ Two tags are registered out of the box (`prepros.plugins.php`) and processed
753
+ **after** the PHP runs, on the assembled HTML — no include or plugin needed.
754
+
755
+ #### `<markdown> … </markdown>`
756
+
757
+ Converts its inner content from Markdown to HTML, stripping the common leading
758
+ indentation first (via `STR::trimIndent()`) so you can indent it naturally inside
759
+ your template. All registered [MD plugins](#md-plugins) work inside it. See
760
+ [Extending the `<markdown>` tag](#extending-the-markdown-tag) to override it.
761
+
762
+ ```html
763
+ <section>
764
+ <markdown>
765
+ ## Who we are
766
+
767
+ We are a **student organization** from Québec.
768
+ </markdown>
769
+ </section>
770
+ ```
771
+
772
+ Add the `prose` attribute — `<markdown prose>` — to wrap the output in
773
+ `<div class="prose">`, so it picks up the long-form typography of
774
+ [`@kirigami/canva`'s `styles/prose`](https://www.npmjs.com/package/@kirigami/canva)
775
+ with no extra markup. Any `class` / `id` on the tag lands on that wrapper
776
+ (`<markdown prose class="lede" id="intro">` → `<div class="prose lede" id="intro">`).
777
+ A bare `<markdown>` emits just the converted HTML, as before.
778
+
779
+ #### `<img asset="…">`
780
+
781
+ The HTML-side entry point of the image autogenerator — the exact same feature as
782
+ the [`img-asset()` Sass function](https://www.npmjs.com/package/@kirigami/kirigami#sass-functions)
783
+ and [`IMG::asset()`](#img), with the same parameters. The tag calls `IMG::asset()`
784
+ under the hood, then swaps the `asset` attribute for the generated `src`.
785
+
786
+ ```html
787
+ <!-- in: resolves assets/images/hero.jpg through IMG::asset('hero.jpg', 800, 0, false) -->
788
+ <img asset="hero.jpg" width="800" alt="Our office" loading="lazy">
789
+ <!-- out: <img src="../images/hero-800w.webp" alt="Our office" loading="lazy"> -->
790
+ ```
791
+
792
+ | Attribute | Maps to `IMG::asset()` arg | Notes |
793
+ |-----------|---------------------------|-------|
794
+ | `asset` | `$path` | **Required.** Path relative to `image.source`. Missing/empty ⇒ the tag is left untouched. |
795
+ | `width` | `$width` | Optional, integer. Omitted ⇒ `0` (keep). |
796
+ | `height` | `$height` | Optional, integer. Omitted ⇒ `0` (keep). |
797
+ | `cover` | `$cover` | Boolean — **presence means `true`** (crop + fill). |
798
+ | *(any other)* | — | `alt`, `class`, `id`, `loading`, … are passed straight through onto the output `<img>`. |
799
+
800
+ `asset` / `width` / `height` / `cover` are consumed and removed; everything else
801
+ survives. The generated file lands in `image.dest` and is only (re)generated when
802
+ missing or older than the source — see [`IMG`](#img) for the naming convention.
803
+
804
+ > The Sass `img-asset()` / `colors()` functions, the `<img asset>` tag and
805
+ > `IMG::asset()` all run on the **same engine** — the `IMG` class (GD, with the
806
+ > Imagick fallback) in this package. `@kirigami/kirigami`'s `sass` task routes its
807
+ > image work here through [`processImages()`](#processimagesjobs), so there is no
808
+ > native image dependency in the toolchain.
809
+
810
+ ---
811
+
812
+ ## JavaScript API
813
+
814
+ ```js
815
+ import { render, sitemap, runenv, mountPath, processImages, resetRuntime } from '@kirigami/php-prepros';
816
+ ```
817
+
818
+ PHP operations are queued in call order, including mounts and result extraction.
819
+ `await resetRuntime()` waits for preceding PHP work, disposes the owned runtime
820
+ and its network proxy, and clears cached configuration and mounts. The next
821
+ operation initializes a fresh runtime from `kirigami.yaml`. Core
822
+ `Project.reload()` calls this and also clears the plugin PHP include list.
823
+ It does not clear persistent cache/cookie files on disk. This remains a
824
+ single-project API whose working directory must be set before import.
825
+
826
+ ### `render(file?, phpIncludes?)`
827
+
828
+ `phpIncludes` defaults to `[]`. The core collects it through `prepros:php`;
829
+ direct callers supply local PHP file paths (absolute or relative to the
830
+ working directory). Existing paths are mounted under `/plugins/` and included
831
+ before rendering; missing paths are silently skipped. Each render replaces
832
+ the runtime include list, which remains on its configuration until another
833
+ render or reset. Direct calls do not load the core plugin registry for you.
834
+
835
+ Compile a single PHP page or a whole directory.
836
+
837
+ ```js
838
+ // Compile one page
839
+ const pageResult = await render('about/_index.php');
840
+
841
+ // Compile everything under src/
842
+ const treeResult = await render('.');
843
+
844
+ // Compile everything (uses kirigami.root from config)
845
+ const defaultResult = await render();
846
+ ```
847
+ > Paths used by `render()` are all relative to the `kirigami.root` configuration.
848
+
849
+
850
+ **Returns** `Promise<PreprosResult>`:
851
+
852
+ ```ts
853
+ interface PreprosResult {
854
+ success: boolean;
855
+ files?: string[]; // project-relative paths; may be absent on parsing failure
856
+ error?: string;
857
+ debug?: string; // captured PHP stdout
858
+ stderr?: string; // diagnostics attached to failures
859
+ warnings?: string; // nonfatal stderr on success
860
+ page?: string | null; // PHP-render failure context, when available
861
+ where?: string; // PHP source location, when available
862
+ }
863
+ ```
864
+
865
+ Setup and filesystem failures can reject before a result exists; PHP failures
866
+ usually return `success: false`. Check both channels. Files may already have
867
+ been copied to the host before a later error; operations are not transactional.
868
+ The runtime returns `debug`/`stderr`, not the older declared `response` field.
869
+
870
+ Directory rendering selects `_*.php` files only when every directory between
871
+ `kirigami.root` and the page has a name without a leading underscore. Sitemap
872
+ selection uses the same rule. Direct requests for private pages fail; rendering
873
+ a private directory produces no pages. The configured source root itself may
874
+ start with `_` (for example `_src`). Previously generated private HTML is not
875
+ deleted by this selection rule; remove stale outputs when migrating a site.
876
+
877
+ ### `sitemap()`
878
+
879
+ Generate `sitemap.xml` and `robots.txt` at the source root, plus `humans.txt`
880
+ when author configuration provides content. It accepts no directory argument.
881
+
882
+ ```js
883
+ const result = await sitemap();
884
+ // With kirigami.root: src, files includes src/sitemap.xml and src/robots.txt.
885
+ ```
886
+
887
+ ### `runenv(script, paths?, ...args)`
888
+
889
+ 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.
890
+
891
+ ```js
892
+ // Run a standalone PHP script
893
+ const purgeResult = await runenv('scripts/purge-cache.php');
894
+
895
+ // Also mount explicit extra files into the sandbox before running
896
+ const imageResult = await runenv('scripts/build-og-images.php', ['assets/photos/hero.jpg']);
897
+
898
+ // Extra arguments are appended and available as $argv[2], $argv[3], … in the script
899
+ const importResult = await runenv('scripts/import.php', [], '--force');
900
+ ```
901
+
902
+ - `script` — path to a PHP file **inside the project**, executed with `require_once`.
903
+ - `paths` — optional array of explicit local file paths, not directories. Missing
904
+ files are skipped; a directory can cause a filesystem rejection. Use
905
+ `mountPath()` first for recursive directory mounting.
906
+ - `...args` — extra string arguments appended to the script's `$argv`.
907
+
908
+ Script and extra-file paths resolve against the project captured at import.
909
+ Before initializing PHP or copying files, `runenv()` rejects paths outside
910
+ that project, including symbolic links whose real targets are outside it.
911
+ Directories are rejected; missing optional files are skipped. This check does
912
+ not make PHP scripts untrusted-code sandboxes or restrict explicit `mountPath()`
913
+ calls.
914
+
915
+ **Returns** `Promise<PreprosResult>`, following the same shape as `render()`. Inside the script, call `PREPROS::exportFile()` for any file you want listed in `result.files`.
916
+
917
+ ### `runPluginScript(script, pluginRoot, paths?, ...args)`
918
+
919
+ Same as `runenv()`, for a script shipped inside a plugin package. A plugin
920
+ installed with `npm link` or from a workspace lives outside the project, so
921
+ `runenv()` would reject it. Here the script's authored and real paths must stay
922
+ inside `pluginRoot` instead, and it is mounted under
923
+ `/plugin-scripts/<package dir>/`. Extra `paths` are still project files.
924
+ The caller vouches for `pluginRoot`: `@kirigami/kirigami` only passes the
925
+ resolved package directory of an active plugin.
926
+
927
+ ### `mountPath(localPath, virtualDir?, php?)`
928
+
929
+ The JavaScript-side counterpart to [`PREPROS::mount()`](#preprosmountstringarray-patterns). 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.
930
+
931
+ ```js
932
+ import { mountPath, render } from '@kirigami/php-prepros';
933
+
934
+ // Mount a single file at its natural virtual path (/project/<relative path>)
935
+ await mountPath('assets/data/team.yaml');
936
+
937
+ // Mount a whole directory, at a custom virtual path
938
+ await mountPath('vendor/fonts', '/project/fonts');
939
+
940
+ await render();
941
+ ```
942
+
943
+ - `localPath` — path to a local file or directory. Relative paths are resolved against the project root.
944
+ - `virtualDir` — optional destination path inside the WASM filesystem. Defaults to `/project/<localPath relative to the project root>` when omitted.
945
+ - `php` — optional WASM PHP instance to mount into. Defaults to PHP-prepros's owned instance (the same one used internally by `render()`/`sitemap()`/`runenv()`), creating it if needed. This is separate from PHP-WASM's shared getter instances and is replaced after `resetRuntime()`.
946
+
947
+ 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.
948
+
949
+ **Returns** `Promise<void>`.
950
+
951
+ ### `processImages(jobs)`
952
+
953
+ Run a batch of image jobs — resize/encode, or palette extraction — through the
954
+ [`IMG`](#img) class (GD, with the Imagick fallback). This is the engine
955
+ `@kirigami/kirigami`'s `sass` task uses for its `img-asset()` and `colors()`
956
+ functions, so Sass, `IMG::asset()` and the [`<img asset>` tag](#built-in-tags)
957
+ all share one implementation, one `image:` config and one set of output
958
+ filenames — with no native image dependency.
959
+
960
+ ```js
961
+ import { processImages } from '@kirigami/php-prepros';
962
+
963
+ const { files, colors } = await processImages([
964
+ // resize/encode `hero.jpg` (resolved against image.source) to each dest —
965
+ // absolute virtual paths, already carrying the target extension
966
+ { op: 'resize', src: 'hero.jpg', width: 1200, height: 0, cover: false, quality: 82,
967
+ dests: ['/project/src/images/hero-1200w.webp'] },
968
+
969
+ // extract a 5-colour palette (cached in .cache.db); returned, not written
970
+ { op: 'palette', src: 'hero.jpg', count: 5 },
971
+ ]);
972
+
973
+ // files includes 'src/images/hero-1200w.webp' and may include '.cache.db'.
974
+ // colors → { 'hero.jpg:5': ['#1e3a5f', '#c8a24b', …] }
975
+ ```
976
+
977
+ - `jobs` — array of `resize` / `palette` jobs (see the shape above). An **empty
978
+ array is a no-op** and does **not** start the WASM runtime.
979
+ - Omitted `jobs` also returns `{ success: true, files: [], colors: {} }`
980
+ without starting PHP. Non-array input currently does the same; this is not
981
+ strict input validation.
982
+ - Staleness is the caller's responsibility: every `resize` job listed is executed.
983
+ - `resize`: `width`/`height` default to `0` (preserve size when both are zero),
984
+ `cover` to `false`, and lossy encoder quality to `82`. `dests` contains
985
+ absolute `/project/...` output paths. `palette` defaults `count` to `5`.
986
+ - The PHP worker handles `palette` explicitly and treats any other `op` as a
987
+ resize; pass only the two documented operations. Processing stops at the
988
+ first exception. Always check `success` before using files or colors.
989
+
990
+ **Returns** `Promise<PreprosResult & { colors: Record<string, string[]> }>`.
991
+
992
+ ### `resetRuntime()`
993
+
994
+ Returns `Promise<void>`. Queued after preceding operations, it disposes the
995
+ owned runtime, mounts, and cached configuration. It does not change the project
996
+ path captured at import, clear disk caches, or reset the SDK hook registry.
997
+
998
+ ### TypeScript declarations
999
+
1000
+ The shipped `index.d.ts` includes `render(file?, phpIncludes?)`, argument-free
1001
+ `sitemap()`, explicit-file `runenv()` mounts, and the current diagnostic fields.
1002
+ `PreprosResult.files` is optional because response parsing can fail before a
1003
+ file list exists. `ImageBatchResult.files` is always normalized to an array.
1004
+ The obsolete `response` field is replaced by `debug` and `stderr`.
1005
+
1006
+ ---
1007
+
1008
+ ## PHP classes reference
1009
+
1010
+ All classes are autoloaded — no manual `require` needed inside your page files.
1011
+ The autoloader itself, `$argv`/`$config`, procedural aliases, and the `boot`
1012
+ hook are installed via php.ini's `auto_prepend_file` (pointed at
1013
+ `utils.inc.php`), set once per WASM runtime instance — every entrypoint
1014
+ (`prepros.php`, `runenv.php`, `imagebatch.php`) gets it automatically,
1015
+ with no `include` of its own.
1016
+
1017
+ ---
1018
+
1019
+ ### PREPROS
1020
+
1021
+ The core engine. Manages the rendering pipeline, tag processing, hooks, mounting, and file export.
1022
+
1023
+ ```php
1024
+ // Available inside page templates and included files.
1025
+ PREPROS::$config // stdClass — full resolved config; ->data is the kirigami: block,
1026
+ // ->image the image: block, plus before/after/format/… from prepros:
1027
+ PREPROS::registerTag(string $tag, callable $callback)
1028
+ PREPROS::registerHook(string $hook, callable $callback)
1029
+ PREPROS::runHook(string $hook, mixed $data = null) // fire a hook (built-in or your own), returns the piped $data
1030
+ PREPROS::mount(string|array $patterns)
1031
+ PREPROS::exportFile(string|array $absolutePath)
1032
+ PREPROS::getExportedFiles(): string[]
1033
+ PREPROS::fstat(string $path) // stat a file in the WASM FS (or false)
1034
+ PREPROS::backtraceFile() // path of the page currently rendering
1035
+ ```
1036
+
1037
+ #### `PREPROS::render(string $file)`
1038
+
1039
+ Internal method called once per source file. Orchestrates the full pipeline:
1040
+
1041
+ 1. Resolves PHPDOC metadata and auto-loads data files.
1042
+ 2. Fires the `pre_render` hook with the raw source contents.
1043
+ 3. Includes `before.php` (wrapped in the `pre_before` / `post_before` hooks) and the page body (or `@content`, see [above](#content-indent-and-type)).
1044
+ 4. If the page declares `@type <name>` and `prepros.types.<name>` exists, wraps the body with that type's `before`/`after` (wrapped in `pre_type_before` / `post_type_before` and `pre_type_after` / `post_type_after`) — nested inside the global wrap.
1045
+ 5. Includes `after.php` (wrapped in `pre_after` / `post_after`), assembling everything into a single string.
1046
+ 6. Processes all registered custom HTML tags.
1047
+ 7. Fires the `post_render` hook on the assembled HTML.
1048
+ 8. Optionally pretty-prints via `HTML::format()` (when `format: true`).
1049
+ 9. Writes the output `.html` file.
1050
+
1051
+ #### `PREPROS::sitemap()`
1052
+
1053
+ 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.
1054
+
1055
+ #### `PREPROS::mount(string|array $patterns)`
1056
+
1057
+ 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.
1058
+
1059
+ ```php
1060
+ // Mount every .webp under assets/, wherever the page needs them
1061
+ PREPROS::mount('assets/**/*.webp');
1062
+
1063
+ // Multiple patterns at once
1064
+ PREPROS::mount(['data/**/*.csv', 'vendor/fonts/*.woff2']);
1065
+ ```
1066
+
1067
+ Returns an array of the virtual paths (under `/project/...`) that were mounted, or `false` on failure.
1068
+
1069
+ #### `PREPROS::exportFile(string $file)`
1070
+
1071
+ 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.
1072
+
1073
+ ---
1074
+
1075
+ ### MD
1076
+
1077
+ Markdown-to-HTML converter with a plugin system for custom shortcodes,
1078
+ backed by PHP's native `mdhtml` extension (real `cmark-gfm`), statically
1079
+ built into `@kirigami/php-wasm` — no userland parsing.
1080
+
1081
+ ```php
1082
+ $html = MD::toHtml(string $markdown): string;
1083
+ ```
1084
+
1085
+ Supports the full GitHub Flavored Markdown subset, plus a few extensions:
1086
+
1087
+ - ATX (`#` … `######`) and Setext headings, with auto-generated `id` attributes
1088
+ - Ordered and unordered lists, including nested
1089
+ - GFM task lists (`- [ ]` / `- [x]`)
1090
+ - GFM tables with column alignment
1091
+ - GFM alerts (`> [!NOTE]`, `> [!WARNING]`, etc.)
1092
+ - Blockquotes (recursive)
1093
+ - Fenced code blocks with language class
1094
+ - Inline code
1095
+ - Bold, italic, bold+italic, strikethrough
1096
+ - Links with automatic `target="_blank" rel="noopener noreferrer"` for external URLs
1097
+ - Images with `loading="lazy"`
1098
+ - Auto-linked bare URLs
1099
+ - Horizontal rules
1100
+ - Hard line breaks (trailing double space → `<br>`)
1101
+ - **Footnotes** — `[^1]` references and `[^1]: …` definitions (multi-paragraph)
1102
+ - **Definition lists** — `Term` / `: Definition`
1103
+ - **Emoji shortcodes** — `:rocket:` → 🚀, from a built-in map (see `MD::registerEmoji()`)
1104
+ - **Sanitized inline HTML** — raw tags are filtered against an allowlist of tags and attributes, not passed through verbatim
1105
+
1106
+ #### Plugin API
1107
+
1108
+ Extend Markdown with custom shortcode tags:
1109
+
1110
+ ```php
1111
+ // Inline tag {% tagname arg1 "arg with spaces" %}
1112
+ // Block tag {% tagname arg1
1113
+ // body content
1114
+ // %}
1115
+
1116
+ MD::registerPlugin(string $name, callable $callback): void
1117
+ MD::unregisterPlugin(string $name): void
1118
+ MD::getRegisteredPlugins(): string[]
1119
+ MD::registerEmoji(string $shortcode, string $char): void // `:name:` → char
1120
+ ```
1121
+
1122
+ The callback always receives `(array $args, string $body)`:
1123
+
1124
+ ```php
1125
+ MD::registerPlugin('video', function (array $args, string $body): string {
1126
+ $src = htmlspecialchars($args[0] ?? '', ENT_QUOTES, 'UTF-8');
1127
+ return "<video src=\"{$src}\" controls></video>";
1128
+ });
1129
+ ```
1130
+
1131
+ Then in any Markdown content (including inside `<markdown>` tags):
1132
+
1133
+ ```
1134
+ {% video /videos/intro.mp4 %}
1135
+ ```
1136
+
1137
+ ---
1138
+
1139
+ ### HTML
1140
+
1141
+ Pretty-printer for the final HTML output. Used automatically when `format: true` is set in the config.
1142
+
1143
+ ```php
1144
+ $formatted = HTML::format(string $html): string;
1145
+ ```
1146
+
1147
+ 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.
1148
+
1149
+ ---
1150
+
1151
+ ### YAML
1152
+
1153
+ 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`.
1154
+
1155
+ 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.
1156
+
1157
+ ```php
1158
+ $data = YAML::parse(string $yaml, bool $assoc = false): mixed;
1159
+ $data = YAML::parseFile(string $path, bool $assoc = false): mixed;
1160
+ $data = YAML::loadFile(string $path, bool $assoc = false): mixed;
1161
+ ```
1162
+
1163
+ By default, YAML mappings are returned as `stdClass` objects. Pass `true` as the second argument to get associative arrays instead.
1164
+
1165
+ 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.
1166
+
1167
+ `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`.
1168
+
1169
+ ```yaml
1170
+ # team.yaml
1171
+ lead: people/jane.yaml # resolved and inlined automatically
1172
+ members:
1173
+ - people/jane.yaml
1174
+ - people/john.yaml
1175
+ ```
1176
+
1177
+ ```php
1178
+ $team = YAML::loadFile('/project/data/team.yaml');
1179
+ // $team->lead is now the fully parsed content of people/jane.yaml, not a string
1180
+ ```
1181
+
1182
+ ---
1183
+
1184
+ ### SCHEMA
1185
+
1186
+ A JSON Schema validator with an Ajv-like API, backed by the native `jsonk`
1187
+ extension built into `@kirigami/php-wasm`. Available to your own code and
1188
+ plugins.
1189
+
1190
+ ```php
1191
+ $validator = new SCHEMA(array $schema);
1192
+
1193
+ $validator->isValid(mixed $data): bool // true / false
1194
+ $validator->validate(mixed $data): bool // alias of isValid()
1195
+ $validator->getErrors(): string[] // "path: message" strings from the last run
1196
+ ```
1197
+
1198
+ jsonk implements draft 2020-12 for a self-contained schema: every validation
1199
+ keyword (`type`, `enum`, `const`, `required`, `properties`,
1200
+ `patternProperties`, `additionalProperties`, `propertyNames`,
1201
+ `dependentRequired`, `dependentSchemas`, `items`, `prefixItems`, `contains`,
1202
+ `uniqueItems`, the `min*`/`max*` and `exclusive*` bounds, `multipleOf`,
1203
+ `pattern`, `format`, `if`/`then`/`else`, `allOf`/`anyOf`/`oneOf`/`not`) and
1204
+ `$ref` to `#/$defs/…` / `#/definitions/…`, absolute URLs, or URLs relative to
1205
+ the schema's `$id`. See [php-jsonk](https://github.com/php-kirigami/php-jsonk)
1206
+ for the details and limits.
1207
+
1208
+ Schemas are PHP arrays, so `SCHEMA` adapts them before handing them to jsonk:
1209
+ an empty array in a schema position (`'properties' => []`) is treated as `{}`,
1210
+ draft-07 tuple `items` (a list of schemas) becomes `prefixItems` (and
1211
+ `additionalItems` becomes `items`), and `format: url` is read as `uri`. Error
1212
+ paths look like `(root)`, `name` or `tags[1]`.
1213
+
1214
+ The previous pure-PHP (Draft-7 style) validator is still available as
1215
+ `SCHEMA_LEGACY`, with the same API.
1216
+
1217
+ ```php
1218
+ $validator = new SCHEMA([
1219
+ 'type' => 'object',
1220
+ 'required' => ['name', 'age'],
1221
+ 'properties' => [
1222
+ 'name' => ['type' => 'string', 'minLength' => 1],
1223
+ 'age' => ['type' => 'integer', 'minimum' => 0],
1224
+ ],
1225
+ 'additionalProperties' => false,
1226
+ ]);
1227
+
1228
+ if (!$validator->isValid($data)) {
1229
+ foreach ($validator->getErrors() as $err) echo $err, PHP_EOL;
1230
+ }
1231
+ ```
1232
+
1233
+ ---
1234
+
1235
+ ### LD
1236
+
1237
+ A **schema.org JSON-LD generator**. `LD` accumulates structured-data nodes for
1238
+ the page under render and emits them as one
1239
+ `<script type="application/ld+json">` block — with an `@graph` when there is more
1240
+ than one node — in the `<head>`.
1241
+
1242
+ #### Automatic mode
1243
+
1244
+ On as soon as `kirigami.yaml` has a [`seo:` block](#seo-block) (`seo: {}` is
1245
+ enough), alongside [`META`](#meta)'s tags and from the same keys. A
1246
+ `post_render` hook injects a graph built from `seo:`, the loose keys of the
1247
+ `kirigami` block, and the current page's PHPDOC:
1248
+
1249
+ - an `Organization` node (`@id` `#organization`) — `name`/`url`/`description`
1250
+ from `project`/`baseurl`/`description`, `sameAs` gathered from every
1251
+ recognised social-network URL key (`facebook`, `instagram`, `linkedin`,
1252
+ `github`, `youtube`, `mastodon`, …), plus `email`, `telephone`, `areaServed`
1253
+ (← `area`), `knowsAbout` (← `knowsabout`), `address`, `logo`, and `founder` →
1254
+ the Person node when there is one. `@type` comes from `seo.type`;
1255
+ - a `Person` node (`#person`) when `person` is set — `name` + `jobTitle`
1256
+ (← `jobtitle`) + `email` + `url`, linked to the Organization via `worksFor`;
1257
+ - a `WebSite` node (`#website`) — `publisher` → Organization, `inLanguage`,
1258
+ `keywords` (← `keywords`), and a `SearchAction` when `seo.search` is set;
1259
+ - a `WebPage` node for the page — see the per-page tags below;
1260
+ - a `BreadcrumbList` for every non-home page, derived from the `_index.php`
1261
+ ancestor trail (home → each parent section → this page). No `@breadcrumb`
1262
+ opt-in needed. Disable it for one page with `@ld_breadcrumb false`.
1263
+
1264
+ `seo: { jsonld: false }` stops the automatic pass; META's tags keep working.
1265
+ A page whose rendered `<head>` already contains an `application/ld+json`
1266
+ script is never touched, so hand-rolled markup keeps working.
1267
+
1268
+ **Per-page PHPDOC tags** — these feed the page node (and override the generic
1269
+ `@title` / `@description` / `@datePublished` fallbacks):
1270
+
1271
+ | Tag | Effect |
1272
+ |-----|--------|
1273
+ | `@ld false` | Skip JSON-LD for this page entirely (`@ld_ignore true` also works). |
1274
+ | `@ld_type <Type>` | `@type` of the page node — `AboutPage`, `ContactPage`, `CollectionPage`, `ProfilePage`, or a content type like `Article`, `Service`, `Recipe`, … (default `WebPage`). Types containing “Page” also get `primaryImageOfPage` + a `breadcrumb` link; others get a plain `image`. |
1275
+ | `@ld_title <text>` | Page node `name` (default: `@title`). |
1276
+ | `@ld_description <text>` | Page node `description` (default: `@description`). |
1277
+ | `@ld_image <path>` | Page image, absolute or relative to `baseurl` (default: `@image` / `@ogimage`). |
1278
+ | `@ld_published <date>` | `datePublished` (default: `@datePublished` / `@published` / `@date`). |
1279
+ | `@ld_modified <date>` | `dateModified` (default: `@dateModified` / `@modified` / `@updated`). |
1280
+ | `@ld_breadcrumb false` | No `BreadcrumbList` for this page. |
1281
+
1282
+ ```php
1283
+ /**
1284
+ * @title À propos
1285
+ * @ld_type AboutPage
1286
+ * @ld_title À propos de Humain Humain
1287
+ * @ld_description Notre approche ethnographique de la consultation.
1288
+ */
1289
+ ```
1290
+
1291
+ #### Explicit builders
1292
+
1293
+ Call these from a page template or from a `prepros.includes` file. Nodes added
1294
+ this way are always emitted — automatic pass on or not — and share
1295
+ the graph the automatic pass uses, so the two combine; a node with a stable
1296
+ `@id` is merged on repeat calls.
1297
+
1298
+ ```php
1299
+ LD::add(string|array $type, array $props = [], ?string $id = null): array // build + register a node
1300
+ LD::node(string|array $type, array $props = []): array // build only, no register
1301
+ LD::push(array $node): array // register a ready-made node
1302
+ LD::ref(string $id): array // ['@id' => …] ('#person' → the Person node)
1303
+ LD::remove(string $id): void
1304
+ LD::graph(): array
1305
+ LD::reset(): void
1306
+
1307
+ LD::organization(array $overrides = []): array // config-aware, @id #organization
1308
+ LD::person(array $overrides = []): array // config-aware, @id #person
1309
+ LD::website(array $overrides = []): array // config-aware, @id #website
1310
+ LD::webPage(array $overrides = []): array // current-page-aware, @id …#webpage
1311
+ LD::breadcrumb(?array $items = null, array $overrides = []): array // items: [['name'=>…,'url'=>…], …]
1312
+ LD::faqPage(array $qa, array $overrides = []): array // qa: ['Question ?' => 'Answer.', …]
1313
+
1314
+ LD::address(array|string $a): array
1315
+ LD::image(string $url, ?string $id = null, ?int $w = null, ?int $h = null): array
1316
+ LD::geo(float $lat, float $lng): array
1317
+ LD::rating(int|float $value, ?int $count = null, $best = 5, $worst = 1): array
1318
+ LD::offer(array $o): array
1319
+ LD::contactPoint(array $c): array
1320
+ LD::searchAction(string $urlTemplate): array
1321
+
1322
+ LD::script(bool $pretty = true): string // <script>…</script>, and disables auto-injection
1323
+ LD::json(bool $pretty = true): string // the document, no wrapper
1324
+ ```
1325
+
1326
+ Every other schema.org type is reachable through `__callStatic` — the method
1327
+ name is upper-cased to form the `@type`:
1328
+
1329
+ ```php
1330
+ LD::recipe([ 'name' => 'Tarte aux pommes', 'recipeYield' => '6', 'prepTime' => 'PT30M' ]);
1331
+ LD::event([ 'name' => 'Vernissage', 'startDate' => '2026-10-01T18:00' ]);
1332
+ LD::softwareApplication([ 'name' => 'Kirigami', 'applicationCategory' => 'DeveloperApplication' ]);
1333
+
1334
+ LD::article([
1335
+ 'headline' => $title,
1336
+ 'datePublished' => '2026-09-01',
1337
+ 'author' => LD::ref('#person'),
1338
+ 'image' => LD::image('images/cover.webp'),
1339
+ 'publisher' => LD::ref('#organization'),
1340
+ ]);
1341
+ ```
1342
+
1343
+ Same API from procedural code: `ld_add()`, `ld_node()`, `ld_ref()`,
1344
+ `ld_organization()`, `ld_person()`, `ld_website()`, `ld_web_page()`,
1345
+ `ld_breadcrumb()`, `ld_faq_page()`, `ld_script()`, `ld_json()`.
1346
+
1347
+ LD's keys (`type`, `logo`, `person`, `address`, `search`, …) live in the
1348
+ [`seo` config](#seo-config) with META's.
1349
+
1350
+ ---
1351
+
1352
+ ### META
1353
+
1354
+ A **`<head>` SEO / social metadata generator** — the companion to [`LD`](#ld).
1355
+ Where `LD` emits a schema.org `application/ld+json` graph, `META` emits the plain
1356
+ tags a browser and a link-preview crawler read: `<title>`, `<meta name="…">`,
1357
+ `<meta property="og:…">`, `<meta name="twitter:…">`, and a handful of `<link>`s.
1358
+
1359
+ It draws on the same sources, in this order of precedence: the page's PHPDOC, the
1360
+ top-level `seo:` block, then the loose `kirigami:` keys. Every tag is emitted **only when it can be resolved** — no value, no
1361
+ tag — and a tag the page's layout already writes by hand is detected and
1362
+ skipped, so it drops in beside an existing `header.php` without duplicating
1363
+ anything.
1364
+
1365
+ **Automatic mode** is opt-in: the top-level `seo:` block (empty `seo: {}` is
1366
+ enough) turns on injection into every page's `<head>`, right before `</head>`.
1367
+
1368
+ ```yaml
1369
+ kirigami:
1370
+ project: Humain Humain
1371
+ baseurl: https://humainhumain.com
1372
+ tagline: Ethnographie au service des organisations
1373
+ description: A social-science consultancy using ethnography for organisational change.
1374
+ keywords: [ethnographie, consultation publique, sciences sociales]
1375
+ author: Maxime Larrivée-Roy
1376
+
1377
+ seo: # top-level; the block being present is the switch
1378
+ twitter: "@humainhumain"
1379
+ themeColor: "#0b7285"
1380
+ lang: fr-CA # <meta name="language">, og:locale, JSON-LD inLanguage
1381
+ logo: assets/logo.png # JSON-LD logo, and og:image when there is no `image`
1382
+ ```
1383
+
1384
+ Per-page, from the PHPDOC block — each falls back to the generic page tag:
1385
+
1386
+ | Tag | Feeds | Default |
1387
+ |-----|-------|---------|
1388
+ | `@meta false` | skip metadata for this page entirely | — (`@meta_ignore true` also works) |
1389
+ | `@meta_title` | `<title>`, `og:title`, `twitter:title` | `@title` |
1390
+ | `@meta_description` | `description`, `og:description`, `twitter:description` | `@description` / `@abstract` / `@excerpt` / `@summary`, then the site `description` |
1391
+ | `@meta_keywords` | `<meta name="keywords">` | `@keywords`, then `seo.keywords` |
1392
+ | `@meta_image` | `og:image`, `twitter:image` | `@image` / `@ogimage`, then `seo.image` |
1393
+ | `@meta_robots` | `<meta name="robots">` | `@robots`, then `seo.robots` |
1394
+ | `@meta_type` | `og:type` | `@og_type`, then `seo.ogType` |
1395
+ | `@canonical` | `<link rel="canonical">` | derived from the file path + `baseurl` |
1396
+
1397
+ **Manual builders** — always emitted (with or without a `seo:` block), still
1398
+ de-duplicated against the page:
1399
+
1400
+ ```php
1401
+ META::tag(string $name, ?string $content): void // name= , or property= for an og:* key
1402
+ META::link(string $rel, string $href, array $attrs = []): void
1403
+ META::raw(string $html): void // a verbatim, already-valid tag line
1404
+ META::tags(string $html = ''): string // the whole block, \n-joined
1405
+ META::reset(): void
1406
+ ```
1407
+
1408
+ ```php
1409
+ META::tag('twitter:image', 'https://humainhumain.com/card.png');
1410
+ META::tag('og:image:alt', 'The Humain Humain team at work');
1411
+ META::link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
1412
+ ```
1413
+
1414
+ Same API from procedural code: `meta_tag()`, `meta_link()`, `meta_raw()`,
1415
+ `meta_tags()`.
1416
+
1417
+ <a id="meta-config"></a><a id="jsonld-config"></a>
1418
+
1419
+ #### `seo` config
1420
+
1421
+ These keys live directly under the **top-level** `seo:` block of
1422
+ `kirigami.yaml` (a sibling of `kirigami:`, `prepros:`, …) and feed both META's
1423
+ tags and LD's JSON-LD. All are optional.
1424
+
1425
+ | Key | Type | Description |
1426
+ |-----|------|-------------|
1427
+ | `auto` | `bool` | Inject META's tags automatically. Default `true` **once the `seo:` block exists**. `auto: false` keeps the block for its values but stops the tags — `META::tags()` / `meta_tags()` can place them by hand. |
1428
+ | `jsonld` | `bool` | Inject LD's JSON-LD automatically. Default `true` **once the `seo:` block exists**. `jsonld: false` stops it — `LD::script()` / `ld_script()` can place it by hand. |
1429
+ | `titleFormat` | `string` | `<title>` template for a normal page. Tokens `{title}`, `{project}`, `{tagline}`. Dangling separators from an empty token are trimmed. Default `{title} — {project}`. |
1430
+ | `titleFormatHome` | `string` | Title template when the page has no `@title` (home / section landings). Default `{project} — {tagline}`. |
1431
+ | `description` | `string` | Default description for pages with no `@description` / `@abstract`, and the JSON-LD main entity / WebSite description. Defaults to the loose `description`. |
1432
+ | `keywords` | `string[]` \| `string` | Default `keywords` content (list or comma string) and the WebSite `keywords`. Defaults to the loose `keywords`. |
1433
+ | `robots` | `string` | Default robots directive. Default `index, follow`. `robots: false` omits the tag. |
1434
+ | `lang` | `string` | BCP-47 tag → `<meta name="language">`, `og:locale` (dash→underscore) and JSON-LD `inLanguage`. Defaults to the loose `lang` / `language`, then `en`. |
1435
+ | `generator` | `string` \| `false` | `<meta name="generator">`. Default `Kirigami`; `false` omits it. |
1436
+ | `author` / `designer` | `string` | Default to the loose `author` / `designer` keys (author also falls back to `person`'s name). `designer` is not emitted unless set. |
1437
+ | `themeColor` | `string` | `<meta name="theme-color">`. Not emitted unless set. |
1438
+ | `image` | `string` | Default `og:image` / `twitter:image` and JSON-LD image — absolute URL or path relative to `baseurl`. Defaults to `logo`, then the loose `image` / `ogimage`. |
1439
+ | `logo` | `string` | Organization logo (JSON-LD `ImageObject`), absolute URL or path relative to `baseurl`. |
1440
+ | `ogType` | `string` | Default `og:type`. Default `website`. |
1441
+ | `twitterCard` | `string` | `twitter:card` type. Default `summary_large_image`. |
1442
+ | `twitter` | `string` \| `map` | Handle for `twitter:site` / `twitter:creator`. A bare string (with/without `@`, or a profile URL) fills both; a map takes `site` / `creator` separately. |
1443
+ | `canonical` | `bool` | Emit `<link rel="canonical">`. Default `true`. |
1444
+ | `favicon` / `appleTouchIcon` / `humans` | `string` \| `bool` | `<link rel="icon">` / `rel="apple-touch-icon"` / `rel="author"`. A path sets it (page-relative when a bare filename); `true` forces the default file (`favicon.ico` / `apple-touch-icon.png` / `humans.txt`); omitted, the default file is auto-detected on disk at the source root; `false` disables it. |
1445
+ | `type` | `string` | JSON-LD `@type` of the main entity — `Organization` (default), `ProfessionalService`, `LocalBusiness`, … |
1446
+ | `name` / `url` | `string` | JSON-LD main entity / WebSite name and URL. Default to `project` / `baseurl`. |
1447
+ | `sameAs` | `string[]` | Profile URLs, merged with the social-network URL keys found loose in the `kirigami` block. |
1448
+ | `email` / `telephone` | `string` | Default to the loose `email` / `telephone` keys. |
1449
+ | `address` | `map` | `PostalAddress` properties. |
1450
+ | `areaServed` | `string` | Defaults to the loose `area` key. |
1451
+ | `knowsAbout` | `string[]` | Defaults to the loose `knowsabout` key. |
1452
+ | `person` | `string` \| `map` | The `#person` node (and the default `author`). A string is the name; a map takes any `Person` property. Defaults to `person` + `jobtitle` + `email`. |
1453
+ | `search` | `string` | URL template for a sitelinks `SearchAction`; must contain `{search_term_string}`. |
1454
+
1455
+ ```yaml
1456
+ kirigami:
1457
+ project: Humain Humain
1458
+ baseurl: https://humainhumain.com
1459
+ person: Méralie Murray-Hall
1460
+ jobtitle: Anthropologue
1461
+ facebook: https://www.facebook.com/humainhumainconsultation.ethnographie/
1462
+
1463
+ seo:
1464
+ type: ProfessionalService
1465
+ lang: fr-CA
1466
+ logo: assets/logo.png
1467
+ knowsAbout: [Ethnographie, Recherche qualitative]
1468
+ address:
1469
+ addressLocality: Québec
1470
+ addressCountry: CA
1471
+ search: https://humainhumain.com/?q={search_term_string}
1472
+ ```
1473
+
1474
+ ---
1475
+
1476
+ ### CACHE
1477
+
1478
+ Persistent SQLite-backed key-value cache. Survives across incremental builds via `.cache.db` at the project root.
1479
+
1480
+ ```php
1481
+ CACHE::get(string $key): mixed
1482
+ CACHE::set(string $key, mixed $val, int $ttl = 0): bool
1483
+ CACHE::delete(string $key): bool
1484
+ CACHE::purge(): bool // removes expired entries
1485
+ ```
1486
+
1487
+ The `$ttl` is in seconds. `0` means the entry never expires. Typical use case: caching the result of network fetches in custom hooks or plugins — it is what powers [`SCRAPER`](#scraper) internally. `CURL` persists its cookie jar separately in `.cookie.txt`.
1488
+
1489
+ ```php
1490
+ $data = CACHE::get('my-remote-data');
1491
+ if ($data === null) {
1492
+ $data = json_decode(file_get_contents('https://api.example.com/data.json'));
1493
+ CACHE::set('my-remote-data', $data, 3600); // cache for 1 hour
1494
+ }
1495
+ ```
1496
+
1497
+ ---
1498
+
1499
+ ### IMG
1500
+
1501
+ Image manipulation helper. GD handles JPEG, PNG, GIF, WebP and AVIF directly;
1502
+ anything GD can't decode (HEIC, TIFF, BMP, and the vector formats SVG, EPS, AI,
1503
+ PDF) falls back to Imagick, which rasterizes it to a GD image in memory. Vector
1504
+ files with no intrinsic pixel size are rasterized at 2000&nbsp;px on the longest
1505
+ side, preserving the aspect ratio.
1506
+
1507
+ ```php
1508
+ $img = new IMG(string $file);
1509
+
1510
+ // Properties
1511
+ $img->width // int
1512
+ $img->height // int
1513
+
1514
+ // Instance methods (resize/save are chainable)
1515
+ $img->resize(int $width, int $height = 0, bool $cover = false): self
1516
+ $img->save(string $dest, ?int $quality = null): self // quality 0-100 for jpg/webp/avif; null = per-format default (82)
1517
+ $img->getRepresentativeColors(int $count = 5): string[] // ['#rrggbb', …]
1518
+
1519
+ // Static helpers
1520
+ IMG::asset(string $path, int $width = 0, int $height = 0, bool $cover = false): string // same feature as the <img asset> tag and the img-asset() Sass function
1521
+ IMG::palette(string $path, int $colors = 5): string[]
1522
+ ```
1523
+
1524
+ `resize()` operates in *contain* mode by default (scales to fit within the target box while preserving aspect ratio). Pass `$cover = true` to crop and fill the exact target dimensions.
1525
+
1526
+ `save()` infers the output format from the file extension (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`) and marks the file as a build output.
1527
+
1528
+ ```php
1529
+ // Build a 1200×630 cropped Open Graph image next to the original
1530
+ (new IMG('/project/src/images/hero.jpg'))
1531
+ ->resize(1200, 630, true)
1532
+ ->save('/project/src/images/hero-og.jpg');
1533
+ ```
1534
+
1535
+ `IMG::asset()` and `IMG::palette()` are what back kirigami-core's `img-asset()`
1536
+ and `colors()` Sass functions: they resolve `$path` against `image.source` from
1537
+ `kirigami.yaml`, generate a resized/re-encoded file under `image.dest` (only
1538
+ when missing or stale), or return a `CACHE`-backed list of representative
1539
+ colours. Both are equally usable from your own PHP.
1540
+
1541
+ `IMG::asset()` is the single implementation behind the [`<img asset>` tag](#built-in-tags)
1542
+ too — the tag is just a thin wrapper. Generated files are named after the source
1543
+ plus a dimension suffix: `-<W>w`, `-<H>h`, `-<W>x<H>`, or `-<W>x<H>-cover`, with
1544
+ the `image.format` extension (e.g. `hero.jpg` + `width="800"` → `hero-800w.webp`).
1545
+ `@kirigami/kirigami`'s Sass `img-asset()` / `colors()` functions produce the same
1546
+ files from the same config through this same class, via
1547
+ [`processImages()`](#processimagesjobs) — one engine, no native dependency.
1548
+
1549
+ ---
1550
+
1551
+ ### FS
1552
+
1553
+ Filesystem utilities.
1554
+
1555
+ ```php
1556
+ FS::dig(string $glob): iterable // recursive glob, yields file paths
1557
+ FS::getRelativePath(string $from, string $to): string
1558
+ FS::phpFileInfo(string $file): object|false // parse PHPDOC annotations
1559
+ FS::getChildren(string $backtrace = ''): object[] // child _index.php pages, ordered by @position
1560
+ FS::getBreadcrumb(string $backtrace = ''): object[] // ancestor _index.php pages, top-most first (opt-in via @breadcrumb)
1561
+ FS::rmdir(string $dir, bool $removeSelf = true): bool
1562
+ FS::pathJoin(string ...$parts): string // URL-aware path join with .. resolution
1563
+ ```
1564
+
1565
+ `FS::dig()` is the workhorse of directory-wide builds — it recursively walks a glob pattern and yields every matching file path.
1566
+
1567
+ `FS::phpFileInfo()` parses the first PHPDOC block of a PHP file and returns its `@tag value` pairs as a `stdClass`. This is used internally to resolve page metadata and data-file annotations.
1568
+
1569
+ `FS::getChildren()` (procedural: `fs_get_children()`) — usable only during a render — scans the folders directly below the calling template, keeps the ones that contain an `_index.php`, and returns one `stdClass` per child: the parsed PHPDOC of that `_index.php` plus a `->file` key with its absolute path. Entries are ordered by `@position` ascending (a page with no `@position` sorts as `999999`), then by folder name (natural, case-insensitive). Handy for building a section index or a navigation menu:
1570
+
1571
+ ```php
1572
+ <?php foreach (fs_get_children() as $page): ?>
1573
+ <li><a href="<?= FS::getRelativePath(__DIR__, dirname($page->file)) ?>/"><?= $page->title ?></a></li>
1574
+ <?php endforeach ?>
1575
+ ```
1576
+
1577
+ `FS::getBreadcrumb()` (procedural: `fs_get_breadcrumb()`) — also render-only — is the upward counterpart: it returns the calling page's breadcrumb trail. It only produces output when the calling file opts in with `@breadcrumb true` (or `@breadcrumb 1`) in its first PHPDOC block, otherwise it returns `[]`. From the folder **above** the caller's own folder (a page is never part of its own trail), it walks the parent directories upward and collects the `_index.php` of each, stopping at the source root or at the first ancestor `_index.php` that carries no active `@breadcrumb` tag — that page acts as a separator and is left out. Directories without an `_index.php` are skipped without breaking the chain. Entries come back ordered from the top-most ancestor down to the nearest parent, each a `stdClass` (parsed PHPDOC of its `_index.php` plus a `->file` key with its absolute path):
1578
+
1579
+ ```php
1580
+ <nav aria-label="Breadcrumb">
1581
+ <?php foreach (fs_get_breadcrumb() as $crumb): ?>
1582
+ <a href="<?= FS::getRelativePath(__DIR__, dirname($crumb->file)) ?>/"><?= $crumb->title ?></a>
1583
+ <?php endforeach ?>
1584
+ </nav>
1585
+ ```
1586
+
1587
+ ---
1588
+
1589
+ ### STR
1590
+
1591
+ String utilities used internally by the tag-processing pipeline, and available for your own templates and plugins.
1592
+
1593
+ ```php
1594
+ STR::htmlesc(string $str): string
1595
+ STR::replaceTags(string $tag, string $html, callable $callback): string
1596
+ STR::parseHtmlAttributes(string $attrString): array
1597
+ STR::trimIndent(string $str): string
1598
+ STR::is_url(string $str): bool
1599
+ STR::html_entities_decode(string $str): string
1600
+ STR::shorthash(string $str): string
1601
+ STR::normalize(string $str): string
1602
+ STR::slug(string $str, string $sep = ''): string
1603
+ ```
1604
+
1605
+ `STR::replaceTags()` is the engine behind `PREPROS::registerTag()`. It finds all occurrences of `<tagname ...>...</tagname>` in an HTML string and replaces each with the return value of `$callback($fullMatch, $attrs, $body)`.
1606
+
1607
+ `STR::trimIndent()` strips the common leading whitespace from a multi-line string — handy when pulling content out of indented `<markdown>` blocks.
1608
+
1609
+ `STR::is_url()` checks whether a string parses as a URL with a recognized scheme (`http`, `https`, `ftp`, `ftps`, `ssh`, `ssl`, `sftp`, `itunes`).
1610
+
1611
+ `STR::html_entities_decode()` trims a string and decodes its HTML entities — handy when normalizing text scraped from a third-party page.
1612
+
1613
+ `STR::shorthash()` returns the first 12 characters of a string's SHA-256 hash — used internally as a stable, filename-safe cache key (see `SCRAPER`).
1614
+
1615
+ `STR::normalize()` applies Unicode NFD decomposition and strips combining marks (`é` → `e`) — the accent-folding step used by `slug()`.
1616
+
1617
+ `STR::slug()` normalizes, transliterates to ASCII, lowercases, and replaces every run of non-`[a-z0-9]` characters with `$sep` (empty by default → a compact identifier; pass `'-'` for a conventional hyphenated slug).
1618
+
1619
+ ---
1620
+
1621
+ ### ARR
1622
+
1623
+ Recursive lookup helper for nested arrays and objects.
1624
+
1625
+ ```php
1626
+ ARR::find_key(mixed $data, string $key): mixed
1627
+ ```
1628
+
1629
+ Walks an array or object (including mixed nested `stdClass`/array structures, as produced by `YAML::parse()` or `json_decode()`) depth-first and returns the value of the **first** matching key found, at any depth, or `null` if none matches.
1630
+
1631
+ ```php
1632
+ $config = YAML::parseFile('team.yaml');
1633
+ $email = ARR::find_key($config, 'email'); // finds `email` however deep it's nested
1634
+ ```
1635
+
1636
+ ---
1637
+
1638
+ ### CURL
1639
+
1640
+ CURL verifies HTTPS certificate chains and hostnames, including redirects. The network-enabled WASM runtime supplies Node’s root certificates through `curl.cainfo` and `openssl.cafile`. Untrusted certificates and hostname mismatches are rejected; there is no insecure fallback. `getInfo()` returns `false` when cURL fails, and `getContents()` preserves its existing failure return values.
1641
+
1642
+ Low-level HTTP client built on PHP's cURL extension, used internally by `SCRAPER`. Ships with a realistic browser `User-Agent`/header set and a cookie jar persisted at `.cookie.txt` (auto-registered via `PREPROS::exportFile()`).
1643
+
1644
+ ```php
1645
+ CURL::urlExists(string $url, ?string $mimereg = null): bool
1646
+ CURL::getInfo(string $url): array|false // HEAD request, returns curl_getinfo()
1647
+ CURL::getContents(string $file, ?string $dest = null, ?callable $clb = null): string|bool
1648
+ ```
1649
+
1650
+ `CURL::urlExists()` issues a `HEAD` request and returns `true` for any `2xx`/`3xx` response.
1651
+
1652
+ `CURL::getContents()` downloads a URL. Without `$dest`, it returns the body as a string; with `$dest`, it streams the download to that file path and returns a boolean. Pass `$clb` to receive download progress as a float between `0` and `1`.
1653
+
1654
+ ```php
1655
+ CURL::getContents('https://example.com/report.pdf', '/project/src/downloads/report.pdf', function (float $progress) {
1656
+ error_log(sprintf('%.0f%%', $progress * 100));
1657
+ });
1658
+ ```
1659
+
1660
+ ---
1661
+
1662
+ ### SCRAPER
1663
+
1664
+ Fetches a URL and extracts page metadata (`title`, `description`, `image`, `label`) from its JSON-LD (`schema.org`), Open Graph, and standard `<meta>` tags — the kind of data you'd want for a rich link preview. Results are cached indefinitely via `CACHE`, keyed on the URL.
1665
+
1666
+ ```php
1667
+ $metas = SCRAPER::get(string $url): object|false;
1668
+ ```
1669
+
1670
+ ```php
1671
+ $metas = SCRAPER::get('https://example.com/blog/some-article');
1672
+ if ($metas) {
1673
+ echo $metas->title; // string
1674
+ echo $metas->description; // string
1675
+ echo $metas->image; // string (absolute URL, may be empty)
1676
+ echo $metas->label; // string — site/publisher name, may be empty
1677
+ echo $metas->url; // string — the URL that was scraped
1678
+ }
1679
+ ```
1680
+
1681
+ Returns `false` if the page can't be reached, can't be parsed, or has no discoverable title. Throws an `Exception` on invalid URLs. Uses `CURL::getContents()` under the hood, so it benefits from the same shared cookie jar and browser-like headers.
1682
+
1683
+ ---
1684
+
1685
+ ### OBF
1686
+
1687
+ Simple reversible obfuscation for non-secret values embedded in HTML, such as display labels or contact data. It does not protect API tokens or other credentials.
1688
+
1689
+ ```php
1690
+ $encoded = OBF::encode(mixed $obj): string;
1691
+ $decoded = OBF::decode(string $str): mixed;
1692
+ ```
1693
+
1694
+ Applies JSON encoding → base64 → ROT-13 → gzip. Not cryptographically secure; intended for light obfuscation only.
1695
+
1696
+ ---
1697
+
1698
+ ### STD
1699
+
1700
+ Result helpers that write JSON to `/internal/prepros_result.json` in the virtual filesystem. Node reads that result file; ordinary PHP stdout/stderr are separate diagnostic channels.
1701
+
1702
+ ```php
1703
+ STD::succeed(array|string $props = []): void // exits 0, writes JSON to /internal/prepros_result.json
1704
+ STD::error(array|string $props = []): void // exits 1, writes JSON to /internal/prepros_result.json
1705
+ ```
1706
+
1707
+ These are internal to the build runner (`render()`, `sitemap()`, and `runenv()` all rely on them). You generally do not need to call them in page templates, but they are available if a script run via `runenv()` needs to terminate early with a custom result.
1708
+
1709
+ ---
1710
+
1711
+ ### Unicode normalization
1712
+
1713
+ The WASM PHP build ships without `ext-intl`; the native `norm` extension
1714
+ ([php-norm](https://github.com/php-kirigami/php-norm), utf8proc) provides the
1715
+ standard `Normalizer` class instead: `Normalizer::normalize()` /
1716
+ `Normalizer::isNormalized()`, the `NFC` / `NFD` / `NFKC` / `NFKD` (and
1717
+ `FORM_*`) constants, and the `normalizer_normalize()` /
1718
+ `normalizer_is_normalized()` functions. `STR::normalize()` and `STR::slug()`
1719
+ use it to fold accents. Prefer the `STR` helpers in your own code.
1720
+
1721
+ The pure-PHP polyfill that used to fill this gap is still autoloadable as
1722
+ `NORMALIZER_LEGACY`, with the same API.
1723
+
1724
+ ---
1725
+
1726
+ ### Procedural shortcuts (aliases)
1727
+
1728
+ Every static method of every class above is also exposed as a plain function by
1729
+ `src/libraries/aliases.inc.php` (autoloaded — no `require` needed). Each alias is
1730
+ named `<lowercase class>_<snake_case method>()` and does nothing but forward its
1731
+ arguments, so the classes remain the canonical API. They exist to make page
1732
+ templates and `kiri run` scripts read better:
1733
+
1734
+ ```php
1735
+ <?= md_to_html(file_get_contents('CHANGELOG.md')) ?>
1736
+ <img src="<?= img_asset('hero.jpg', 1200, 630, true) ?>" alt="">
1737
+ ```
1738
+
1739
+ Every function has a complete PHPDoc block, so editor hover and autocomplete
1740
+ surface the signature, parameters, and description.
1741
+
1742
+ | Class | Aliases |
1743
+ |---|---|
1744
+ | `PREPROS` | `prepros_render` · `prepros_sitemap` · `prepros_mount` · `prepros_fstat` · `prepros_export_file` · `prepros_get_exported_files` · `prepros_backtrace_file` · `prepros_register_tag` · `prepros_register_hook` · `prepros_run_hook` |
1745
+ | `MD` | `md_to_html` · `md_register_plugin` · `md_unregister_plugin` · `md_get_registered_plugins` · `md_register_emoji` |
1746
+ | `HTML` | `html_format` |
1747
+ | `YAML` | `yaml_parse` · `yaml_parse_file` · `yaml_load_file` |
1748
+ | `SCHEMA` | `schema` (factory) · `schema_validate` |
1749
+ | `LD` | `ld_add` · `ld_node` · `ld_ref` · `ld_organization` · `ld_person` · `ld_website` · `ld_web_page` · `ld_breadcrumb` · `ld_faq_page` · `ld_script` · `ld_json` |
1750
+ | `META` | `meta_tag` · `meta_link` · `meta_raw` · `meta_tags` |
1751
+ | `CACHE` | `cache_get` · `cache_set` · `cache_delete` · `cache_purge` |
1752
+ | `IMG` | `img_asset` · `img_palette` |
1753
+ | `FS` | `fs_dig` · `fs_get_relative_path` · `fs_php_file_info` · `fs_rmdir` · `fs_path_join` |
1754
+ | `STR` | `str_htmlesc` · `str_replace_tags` · `str_parse_html_attributes` · `str_trim_indent` · `str_is_url` · `str_html_entities_decode` · `str_shorthash` · `str_normalize` · `str_slug` |
1755
+ | `ARR` | `arr_find_key` |
1756
+ | `CURL` | `curl_url_exists` · `curl_get_info` · `curl_get_contents` |
1757
+ | `SCRAPER` | `scraper_get` |
1758
+ | `OBF` | `obf_encode` · `obf_decode` |
1759
+ | `STD` | `std_succeed` · `std_error` |
1760
+
1761
+ Notes:
1762
+
1763
+ - `register_tag()` / `register_hook()` are kept as unprefixed aliases of
1764
+ `prepros_register_tag()` / `prepros_register_hook()`.
1765
+ - `img_asset()` resolves the generated URL relative to the file that calls it,
1766
+ exactly like `IMG::asset()`.
1767
+ - `schema_validate(array $schema, mixed $data, ?array &$errors = null): bool`
1768
+ fills `$errors` with the validation messages.
1769
+ - `yaml_parse()` / `yaml_parse_file()` are only defined when the PECL `yaml`
1770
+ extension isn't already providing them.
1771
+
1772
+ ---
1773
+
1774
+ ## Plugin system
1775
+
1776
+ `@kirigami/php-prepros` has two complementary plugin layers: **PREPROS** (HTML-tag level, operates on the assembled page) and **MD** (shortcode level, operates inside Markdown content).
1777
+
1778
+ ---
1779
+
1780
+ ### PREPROS tags
1781
+
1782
+ Register a custom HTML tag that is processed **after** PHP execution, on the fully assembled HTML string:
1783
+
1784
+ ```php
1785
+ // In a file listed under prepros.includes in kirigami.yaml, or in before.php:
1786
+
1787
+ PREPROS::registerTag('gallery', function (string $fullTag, array $attrs, string $body): string {
1788
+ $id = $attrs['id'] ?? '';
1789
+ $imgs = glob("/project/src/images/gallery/{$id}/*.webp");
1790
+ $html = '<div class="gallery">';
1791
+ foreach ($imgs as $img) {
1792
+ $src = str_replace('/project/src', '', $img);
1793
+ $html .= "<img src=\"{$src}\" loading=\"lazy\">";
1794
+ }
1795
+ return $html . '</div>';
1796
+ });
1797
+ ```
1798
+
1799
+ Then in any page template:
1800
+
1801
+ ```html
1802
+ <gallery id="summer-2025"></gallery>
1803
+ ```
1804
+
1805
+ The callback receives:
1806
+
1807
+ | Parameter | Type | Description |
1808
+ |-----------|------|-------------|
1809
+ | `$fullTag` | `string` | The complete matched tag string |
1810
+ | `$attrs` | `array` | Parsed HTML attributes as an associative array |
1811
+ | `$body` | `string` | Inner content between opening and closing tags |
1812
+
1813
+ The built-in [`<markdown>` and `<img asset>` tags](#built-in-tags) are registered
1814
+ this way.
1815
+
1816
+ ---
1817
+
1818
+ ### PREPROS hooks
1819
+
1820
+ Hooks let you intercept and transform data at key points in the rendering pipeline:
1821
+
1822
+ ```php
1823
+ PREPROS::registerHook(string $hookName, callable $callback): void
1824
+ ```
1825
+
1826
+ | Hook | When it fires | `$data` type | Expected return |
1827
+ |------|---------------|--------------|-----------------|
1828
+ | `boot` | Once per process, right after bootstrap (config loaded, `includes` pulled in), before any page renders. Fires for every entrypoint. | `stdClass $config` | ignored |
1829
+ | `shutdown` | Via `register_shutdown_function()`, at the very end of the request — fires even after `STD::succeed()`/`STD::error()`'s `exit()`, unlike `auto_append_file` (which PHP skips whenever the script exits). The place for cleanup that must always run. | `null` | ignored |
1830
+ | `page_info` | After PHPDOC parsing, before rendering (auto-loads `.yaml`/`.json`/`.md` annotations) | `[$filePath, $pageObject]` — see note | `$pageObject` (modified) |
1831
+ | `pre_render` | Before PHP execution | Raw file contents as `string` | Ignored by the current render call |
1832
+ | `pre_before` | Just before the `before` include (inside its output buffer — `echo` to prepend to the header) | `before` config path as `string\|null` | ignored |
1833
+ | `post_before` | Right after the `before` include, on the captured header | Header `string` | `string` |
1834
+ | `pre_type_before` | Just before the page's `@type` `before` include, if any (inside its output buffer) | Type's `before` config path as `string\|null` | ignored |
1835
+ | `post_type_before` | Right after the `@type` `before` include, on the captured type header | Type header `string` | `string` |
1836
+ | `pre_type_after` | Just before the page's `@type` `after` include, if any (inside its output buffer) | Type's `after` config path as `string\|null` | ignored |
1837
+ | `post_type_after` | Right after the `@type` `after` include, on the captured type footer | Type footer `string` | `string` |
1838
+ | `pre_after` | Just before the `after` include (inside its output buffer — `echo` to prepend to the footer) | `after` config path as `string\|null` | ignored |
1839
+ | `post_after` | Right after the `after` include, on the captured footer | Footer `string` | `string` |
1840
+ | `post_render` | After tag processing, before `HTML::format()` | Assembled HTML `string` | `string` |
1841
+
1842
+ Multiple callbacks can be registered for the same hook — they are executed in registration order, each receiving the return value of the previous one.
1843
+
1844
+ > Built-in `page_info` + `post_render` callbacks power [`LD`](#ld) and
1845
+ > [`META`](#meta): `page_info` captures the page under render, `post_render`
1846
+ > injects the JSON-LD `<script>` and the `<meta>`/`<link>` block into its
1847
+ > `<head>`. Your own callbacks run after them.
1848
+
1849
+ > **`page_info` payload shape.** The hook *fires* with `[$filePath, $pageObject]`,
1850
+ > but each callback is expected to return the `$pageObject` alone — so a callback
1851
+ > registered after the built-ins receives the bare object, not the pair. Handle
1852
+ > both: `$page = is_array($p) ? $p[1] : $p;` (the current page path is always
1853
+ > available as `PREPROS::$file`).
1854
+
1855
+ ```php
1856
+ // Example: inject a last-modified date into every page
1857
+ PREPROS::registerHook('post_render', function (string $html): string {
1858
+ $date = date('Y-m-d');
1859
+ return str_replace('{{build_date}}', $date, $html);
1860
+ });
1861
+ ```
1862
+
1863
+ ---
1864
+
1865
+ ### MD plugins
1866
+
1867
+ MD plugins add custom shortcode tags inside Markdown content. They work inside `<markdown>` blocks, in `.md` data files, and anywhere `MD::toHtml()` is called.
1868
+
1869
+ **Inline syntax** (all on one line):
1870
+
1871
+ ```
1872
+ {% tagname arg1 "argument with spaces" %}
1873
+ ```
1874
+
1875
+ **Block syntax** (body on subsequent lines):
1876
+
1877
+ ```
1878
+ {% tagname optional-arg
1879
+ Line one of the body.
1880
+ Line two of the body.
1881
+ %}
1882
+ ```
1883
+
1884
+ ```php
1885
+ MD::registerPlugin(string $name, callable $callback): void
1886
+ ```
1887
+
1888
+ The callback signature is always `(array $args, string $body): string`. `$args` contains arguments parsed from the opening line; `$body` is the trimmed multi-line body (empty string for inline tags).
1889
+
1890
+ ---
1891
+
1892
+ ### Built-in plugins
1893
+
1894
+ The following MD plugins are registered out of the box in `md.plugins.php`:
1895
+
1896
+ #### `{% callout type ["Title"] content %}`
1897
+
1898
+ Renders a styled callout block. `type` is one of `info`, `success`, `warning`, `danger`.
1899
+
1900
+ ```
1901
+ {% callout warning "Heads up" This section is outdated. %}
1902
+
1903
+ {% callout danger "Critical"
1904
+ Line one of a longer warning.
1905
+
1906
+ Line two after a blank line.
1907
+ %}
1908
+ ```
1909
+
1910
+ #### `{% img-asset path [width height [cover]] %}`
1911
+
1912
+ Generate an image with `IMG::asset()` and emit an `<img>` tag. Width and height default to zero; the optional final `cover` selects cropping.
1913
+
1914
+ ```markdown
1915
+ {% img-asset photo.jpg 800 600 cover %}
1916
+ ```
1917
+
1918
+ YouTube and Vimeo shortcuts require `@kirigami/plugin-embed`; YouTube is no longer a built-in plugin.
1919
+
1920
+ #### `{% codepen id [user height] %}`
1921
+
1922
+ Embeds a CodePen result via `<iframe>`. `user` defaults to `anonymous`, `height` defaults to `400`.
1923
+
1924
+ ```
1925
+ {% codepen abcXYZ %}
1926
+ {% codepen abcXYZ jsmith 500 %}
1927
+ ```
1928
+
1929
+ #### `{% checklist ["Title"] items %}`
1930
+
1931
+ Renders a block-syntax list of checkbox items, one per line, with an optional title.
1932
+
1933
+ ```
1934
+ {% checklist "Today"
1935
+ Do the dishes
1936
+ Walk the dog
1937
+ Read a book
1938
+ %}
1939
+ ```
1940
+
1941
+ ---
1942
+
1943
+ ## Extending the `<markdown>` tag
1944
+
1945
+ The `<markdown>` tag is one of the [built-in tags](#built-in-tags) (alongside
1946
+ `<img asset>`), registered as a PREPROS tag out of the box. It converts its inner
1947
+ content from Markdown to HTML and strips common leading indentation so you can
1948
+ write cleanly inside your PHP templates:
1949
+
1950
+ ```html
1951
+ <section class="about">
1952
+ <div>
1953
+ <markdown>
1954
+ ## Who we are
1955
+
1956
+ We are a **student organization** from Québec.
1957
+
1958
+ {% codepen abc123 author 400 %}
1959
+ </markdown>
1960
+ </div>
1961
+ </section>
1962
+ ```
1963
+
1964
+ All registered MD plugins are available inside `<markdown>` blocks. You can extend the tag's behaviour by registering additional MD plugins (see above) or by overriding the tag itself:
1965
+
1966
+ ```php
1967
+ PREPROS::registerTag('markdown', function (string $tag, array $attrs, string $body): string {
1968
+ $body = STR::trimIndent($body);
1969
+ $html = MD::toHtml($body);
1970
+ // wrap in a container, add a class, etc.
1971
+ $class = $attrs['class'] ?? 'prose';
1972
+ return "<div class=\"{$class}\">{$html}</div>";
1973
+ });
1974
+ ```
1975
+
1976
+ ---
1977
+
1978
+ ## Requirements
1979
+
1980
+ - Node.js `>= 24.0.0`
1981
+ - npm `>= 10.2.3`
1982
+ - ESM only (`"type": "module"`)
1983
+
1984
+ ---
1985
+
1986
+ ## License
1987
+
1988
+ GPL-3.0-or-later © Maxime Larrivée-Roy, 2026