@kirigami/php-prepros 3.1.1 → 3.2.1

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