@kirigami/php-prepros 1.1.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,869 +1,1593 @@
1
- # @kirigami/php-prepros
2
-
3
- > PHP preprocessor for the **Kirigami** static site generator.
4
-
5
- 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.
6
-
7
- 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.
8
-
9
- Part of the **Kirigami** project ecosystem. Other packages are coming soon.
10
-
11
- [![npm version](https://img.shields.io/npm/v/@kirigami/php-prepros)](https://www.npmjs.com/package/@kirigami/php-wasm)
12
- [![License: MIT](https://img.shields.io/badge/MIT-blue)](./LICENSE)
13
- [![Node.js >=20.10.0](https://img.shields.io/badge/node-%3E%3D20.10.0-brightgreen)](https://nodejs.org)
14
-
15
- ---
16
-
17
- ## Table of contents
18
-
19
- - [@kirigami/php-prepros](#kirigamiphp-prepros)
20
- - [Table of contents](#table-of-contents)
21
- - [What's new in 1.1.0](#whats-new-in-110)
22
- - [How it works](#how-it-works)
23
- - [Installation](#installation)
24
- - [Configuration — `kirigami.yaml`](#configuration--kirigamiyaml)
25
- - [`kirigami` block](#kirigami-block)
26
- - [`prepros` block](#prepros-block)
27
- - [Writing pages](#writing-pages)
28
- - [PHPDOC header](#phpdoc-header)
29
- - [Auto-loading data files](#auto-loading-data-files)
30
- - [`@content` and `@indent`](#content-and-indent)
31
- - [JavaScript API](#javascript-api)
32
- - [`render(file?)`](#renderfile)
33
- - [`sitemap()`](#sitemap)
34
- - [`runenv(script, paths?, ...args)`](#runenvscript-paths-args)
35
- - [PHP classes reference](#php-classes-reference)
36
- - [PREPROS](#prepros)
37
- - [`PREPROS::render(string $file)`](#preprosrenderstring-file)
38
- - [`PREPROS::sitemap()`](#preprossitemap)
39
- - [`PREPROS::mount(string|array $patterns)`](#preprosmountstringarray-patterns)
40
- - [`PREPROS::exportFile(string $file)`](#preprosexportfilestring-file)
41
- - [MD](#md)
42
- - [Plugin API](#plugin-api)
43
- - [HTML](#html)
44
- - [YAML](#yaml)
45
- - [CACHE](#cache)
46
- - [IMG](#img)
47
- - [FS](#fs)
48
- - [STR](#str)
49
- - [ARR](#arr)
50
- - [CURL](#curl)
51
- - [SCRAPER](#scraper)
52
- - [OBF](#obf)
53
- - [STD](#std)
54
- - [Plugin system](#plugin-system)
55
- - [PREPROS tags](#prepros-tags)
56
- - [PREPROS hooks](#prepros-hooks)
57
- - [MD plugins](#md-plugins)
58
- - [Built-in plugins](#built-in-plugins)
59
- - [`{% callout type ["Title"] content %}`](#-callout-type-title-content-)
60
- - [`{% youtube id [width height] %}`](#-youtube-id-width-height-)
61
- - [`{% codepen id [user height] %}`](#-codepen-id-user-height-)
62
- - [`{% checklist ["Title"] items %}`](#-checklist-title-items-)
63
- - [Extending the `<markdown>` tag](#extending-the-markdown-tag)
64
- - [License](#license)
65
-
66
- ---
67
-
68
- ## What's new in 1.1.0
69
-
70
- - **`PREPROS::mount()`** — mount extra files into the WASM filesystem on demand, from a glob pattern, at any point during rendering.
71
- - **`runenv()`** JavaScript export — run an arbitrary PHP script (not a page template) inside the same sandboxed environment, with full access to every `php-prepros` class.
72
- - **`SCRAPER`** class — fetch a URL and extract `title` / `description` / `image` / `label` from its Open Graph, `<meta>`, and JSON-LD data, with automatic caching.
73
- - **`CURL`** class — low-level cURL helper used internally by `SCRAPER`, also usable directly (`urlExists()`, `getInfo()`, `getContents()`), with a shared, persisted cookie jar.
74
- - **`ARR`** class — recursive associative array/object key lookup.
75
- - **`YAML::loadFile()`** — like `YAML::parseFile()`, but recursively resolves any string value that points to another existing `.yaml`/`.yml`/`.json` file into its parsed content.
76
- - New `STR` helpers: `STR::is_url()`, `STR::html_entities_decode()`, `STR::shorthash()`, `STR::slug()`.
77
- - New built-in MD plugins: `{% youtube %}`, `{% codepen %}`, and `{% checklist %}`, alongside the existing `{% callout %}`.
78
- - `@content` and `@indent` PHPDOC annotations, letting a page skip its own PHP body in favour of pre-rendered content, and control its indentation when nested inside a layout.
79
-
80
- ---
81
-
82
- ## How it works
83
-
84
- `@kirigami/php-prepros` runs your PHP source files inside a **WebAssembly PHP 8.x runtime** ([`@kirigami/php-wasm`](https://github.com/kirigami/php-wasm)), entirely in Node.js — no PHP installation required on the host machine.
85
-
86
- The lifecycle of a page build looks like this:
87
-
88
- ```
89
- _index.php ──▶ PHP (wasm) ──▶ processTags() ──▶ HTML::format() ──▶ index.html
90
- │
91
- ├── before.php (optional layout header)
92
- ├── after.php (optional layout footer)
93
- └── PHPDOC annotations resolved (yaml / json / md / url)
94
- ```
95
-
96
- 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).
97
-
98
- ---
99
-
100
- ## Installation
101
-
102
- ```bash
103
- npm install @kirigami/php-prepros
104
- ```
105
-
106
- > **Node.js ≥ 20** is required (ESM-only package).
107
-
108
- ---
109
-
110
- ## Configuration — `kirigami.yaml`
111
-
112
- Every project **must** have a `kirigami.yaml` at its root. The preprocessor reads it at startup and throws if it is absent or invalid.
113
-
114
- ```yaml
115
- kirigami:
116
- # ── Required ──────────────────────────────────────────────────────────
117
- root: src # Source directory containing your _*.php pages.
118
-
119
- # ── Used internally ──────────────────────────────────────────────────
120
- baseurl: https://example.com # Used as the base URL when generating sitemap.xml.
121
-
122
- # ── Arbitrary project data ──────────────────────────────────────────
123
- # Everything else under `kirigami:` is free-form. The whole block is
124
- # extracted as PHP variables and made available in every page, in
125
- # before.php/after.php, and anywhere PREPROS::$config->data is read.
126
- project: My Website
127
- author: Jane Doe
128
- person: John Smith
129
- jobtitle: Founder
130
- email: hello@example.com
131
- facebook: https://www.facebook.com/example
132
- area: Somewhere, Country
133
- gtag: G-XXXXXXXXXX
134
- banner: assets/banner.txt
135
- description: A short description of the site, useful for <meta name="description">.
136
- knowsabout:
137
- - Topic one
138
- - Topic two
139
- keywords:
140
- - keyword one
141
- - keyword two
142
-
143
- prepros:
144
- before: _layout/header.php # Included before every page body.
145
- after: _layout/footer.php # Included after every page body.
146
- format: true # Pretty-print the HTML output (default: false).
147
- network: false # Allow HTTP fetches in PHPDOC @tag annotations.
148
- mountext: # Extra file extensions to auto-mount into the wasm fs,
149
- - .svg # in addition to the defaults (.php .json .yaml .yml .md .db .txt).
150
- - .webp
151
- includes: # PHP files auto-included once, before any page renders.
152
- - _lib/helpers.php
153
- ```
154
-
155
- ### `kirigami` block
156
-
157
- | Key | Required | Description |
158
- |-----|----------|--------------|
159
- | `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. |
160
- | `baseurl` | for `sitemap()` | Root URL used to build absolute `<loc>` entries when generating `sitemap.xml`. |
161
- | *anything else* | — | Free-form key/value pairs (strings, numbers, booleans, lists, nested maps — anything valid YAML). Every key is extracted as a PHP variable (`$project`, `$author`, `$gtag`, …) and available in page templates, `before.php`, `after.php`, and PHP files listed under `prepros.includes`. Use this to hold your site name, contact info, social links, analytics IDs, SEO keywords, banners, or any project-specific data you want available everywhere. |
162
-
163
- ### `prepros` block
164
-
165
- | Key | Type | Default | Description |
166
- |-----|------|---------|--------------|
167
- | `before` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **before** every page's body. Typically your `<head>`/layout opening. |
168
- | `after` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **after** every page's body. Typically your layout closing. |
169
- | `format` | `bool` | `false` | Pretty-print the compiled HTML via [`HTML::format()`](#html) before writing it to disk. |
170
- | `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)). |
171
- | `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`, `.txt`, `.webp`). Files with extensions not in this set are simply skipped during mounting — mount them on demand with [`PREPROS::mount()`](#preprosmountstringarray-patterns) instead. |
172
- | `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()`. |
173
-
174
- The entire `kirigami` block is extracted into PHP variables and made available in every page template, `before.php`, and `after.php`. `$project`, `$author`, `$gtag`, etc. are available without any further setup.
175
-
176
- ---
177
-
178
- ## Writing pages
179
-
180
- 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.
181
-
182
- ```
183
- src/
184
- ├── _layout/
185
- ├── _lib/
186
- ├── about/
187
- ├── _index.php → src/index.html
188
- ├── about/
189
- │ └── _index.php → src/about/index.html
190
- └── blog/
191
- ├── _index.php → src/blog/index.html
192
- └── _articles.yaml (data file, not compiled)
193
- ```
194
-
195
- Directories whose name starts with `_` (e.g. `_layout/`, `_lib/`) are skipped entirely during directory-wide builds.
196
-
197
- ### PHPDOC header
198
-
199
- Every page starts with a PHP docblock that drives metadata and data loading:
200
-
201
- ```php
202
- <?php
203
- /**
204
- * @name about
205
- * @title About us
206
- * @abstract A short description of this page.
207
- */
208
- ?>
209
- <section>
210
- <h1><?php echo $title; ?></h1>
211
- <p><?php echo $abstract; ?></p>
212
- </section>
213
- ```
214
-
215
- All annotations are injected as PHP variables (`$name`, `$title`, `$abstract`, …). You can define any custom annotation you need.
216
-
217
- Annotations are also available as variables in `before` and `after` PHP included files, so you can write proper metas in the HTML header.
218
-
219
- ### Auto-loading data files
220
-
221
- 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.
222
-
223
- ```php
224
- <?php
225
- /**
226
- * @name medias
227
- * @articles _articles.yaml
228
- */
229
- ?>
230
- <?php foreach ($articles as $article): ?>
231
- <a href="<?php echo $article->lien; ?>">
232
- <?php echo $article->titre; ?>
233
- </a>
234
- <?php endforeach; ?>
235
- ```
236
-
237
- | Extension | Parsed as |
238
- |-----------|-----------|
239
- | `.yaml` / `.yml` | `stdClass` object (or array of objects for sequences) |
240
- | `.json` | Result of `json_decode()` |
241
- | `.md` | HTML string via `MD::toHtml()` |
242
-
243
- 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:
244
-
245
- ```php
246
- /**
247
- * @posts https://api.example.com/posts.json
248
- */
249
- ```
250
-
251
- ### `@content` and `@indent`
252
-
253
- Two special annotation names change how a page's body is assembled:
254
-
255
- - **`@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.
256
- - **`@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.
257
-
258
- ```php
259
- <?php
260
- /**
261
- * @name changelog
262
- * @title Changelog
263
- * @content _changelog.md
264
- * @indent 4
265
- */
266
- ```
267
-
268
- ---
269
-
270
- ## JavaScript API
271
-
272
- ```js
273
- import { render, sitemap, runenv } from '@kirigami/php-prepros';
274
- ```
275
-
276
- ### `render(file?)`
277
-
278
- Compile a single PHP page or a whole directory.
279
-
280
- ```js
281
- // Compile one page
282
- const result = await render('about/_index.php');
283
-
284
- // Compile everything under src/
285
- const result = await render('.');
286
-
287
- // Compile everything (uses kirigami.root from config)
288
- const result = await render();
289
- ```
290
- > Paths used by `render()` are all relative to the `kirigami.root` configuration.
291
-
292
-
293
- **Returns** `Promise<PreprosResult>`:
294
-
295
- ```ts
296
- interface PreprosResult {
297
- success: boolean;
298
- files: string[]; // relative paths of every file written
299
- error?: string; // present only on failure
300
- }
301
- ```
302
-
303
- ### `sitemap()`
304
-
305
- Generate `sitemap.xml` at the source root.
306
-
307
- ```js
308
- const result = await sitemap();
309
- // result.files === ['src/sitemap.xml']
310
- ```
311
-
312
- ### `runenv(script, paths?, ...args)`
313
-
314
- 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.
315
-
316
- ```js
317
- // Run a standalone PHP script
318
- const result = await runenv('scripts/purge-cache.php');
319
-
320
- // Also mount extra local paths/files into the sandbox before running
321
- const result = await runenv('scripts/build-og-images.php', ['assets/photos']);
322
-
323
- // Extra arguments are appended and available as $argv[2], $argv[3], … in the script
324
- const result = await runenv('scripts/import.php', [], '--force');
325
- ```
326
-
327
- - `script` — path to a PHP file **inside the project**, executed with `require_once`.
328
- - `paths` — optional array of extra local paths (files or directories) to mount into the sandbox before the script runs.
329
- - `...args` — extra string arguments appended to the script's `$argv`.
330
-
331
- **Returns** `Promise<PreprosResult>`, following the same shape as `render()`. Inside the script, call `PREPROS::exportFile()` for any file you want listed in `result.files`.
332
-
333
- ---
334
-
335
- ## PHP classes reference
336
-
337
- All classes are autoloaded — no manual `require` needed inside your page files.
338
-
339
- ---
340
-
341
- ### PREPROS
342
-
343
- The core engine. Manages the rendering pipeline, tag processing, hooks, mounting, and file export.
344
-
345
- ```php
346
- // Available inside page templates and included files.
347
- PREPROS::$config // stdClass — full resolved config (prepros section of kirigami.yaml)
348
- PREPROS::registerTag(string $tag, callable $callback)
349
- PREPROS::registerHook(string $hook, callable $callback)
350
- PREPROS::mount(string|array $patterns)
351
- PREPROS::exportFile(string $absolutePath)
352
- PREPROS::getExportedFiles(): string[]
353
- ```
354
-
355
- #### `PREPROS::render(string $file)`
356
-
357
- Internal method called once per source file. Orchestrates the full pipeline:
358
-
359
- 1. Resolves PHPDOC metadata and auto-loads data files.
360
- 2. Fires the `pre_render` hook with the raw source contents.
361
- 3. Includes `before.php`, the page body (or `@content`, see [above](#content-and-indent)), and `after.php` into a single string.
362
- 4. Processes all registered custom HTML tags.
363
- 5. Fires the `post_render` hook on the assembled HTML.
364
- 6. Optionally pretty-prints via `HTML::format()` (when `format: true`).
365
- 7. Writes the output `.html` file.
366
-
367
- #### `PREPROS::sitemap()`
368
-
369
- 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.
370
-
371
- #### `PREPROS::mount(string|array $patterns)`
372
-
373
- 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.
374
-
375
- ```php
376
- // Mount every .webp under assets/, wherever the page needs them
377
- PREPROS::mount('assets/**/*.webp');
378
-
379
- // Multiple patterns at once
380
- PREPROS::mount(['data/**/*.csv', 'vendor/fonts/*.woff2']);
381
- ```
382
-
383
- Returns an array of the virtual paths (under `/project/...`) that were mounted, or `false` on failure.
384
-
385
- #### `PREPROS::exportFile(string $file)`
386
-
387
- 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.
388
-
389
- ---
390
-
391
- ### MD
392
-
393
- Markdown-to-HTML converter with a plugin system for custom shortcodes.
394
-
395
- ```php
396
- $html = MD::toHtml(string $markdown): string;
397
- ```
398
-
399
- Supports the full GitHub Flavored Markdown subset:
400
-
401
- - ATX headings (`#` through `######`) with auto-generated `id` attributes
402
- - Ordered and unordered lists, including nested
403
- - GFM task lists (`- [ ]` / `- [x]`)
404
- - GFM tables with column alignment
405
- - GFM alerts (`> [!NOTE]`, `> [!WARNING]`, etc.)
406
- - Blockquotes (recursive)
407
- - Fenced code blocks with language class
408
- - Inline code
409
- - Bold, italic, bold+italic, strikethrough
410
- - Links with automatic `target="_blank" rel="noopener noreferrer"` for external URLs
411
- - Images with `loading="lazy"`
412
- - Auto-linked bare URLs
413
- - Horizontal rules
414
- - Hard line breaks (trailing double space → `<br>`)
415
-
416
- #### Plugin API
417
-
418
- Extend Markdown with custom shortcode tags:
419
-
420
- ```php
421
- // Inline tag {% tagname arg1 "arg with spaces" %}
422
- // Block tag {% tagname arg1
423
- // body content
424
- // %}
425
-
426
- MD::registerPlugin(string $name, callable $callback): void
427
- MD::unregisterPlugin(string $name): void
428
- MD::getRegisteredPlugins(): string[]
429
- ```
430
-
431
- The callback always receives `(array $args, string $body)`:
432
-
433
- ```php
434
- MD::registerPlugin('video', function (array $args, string $body): string {
435
- $src = htmlspecialchars($args[0] ?? '', ENT_QUOTES, 'UTF-8');
436
- return "<video src=\"{$src}\" controls></video>";
437
- });
438
- ```
439
-
440
- Then in any Markdown content (including inside `<markdown>` tags):
441
-
442
- ```
443
- {% video /videos/intro.mp4 %}
444
- ```
445
-
446
- ---
447
-
448
- ### HTML
449
-
450
- Pretty-printer for the final HTML output. Used automatically when `format: true` is set in the config.
451
-
452
- ```php
453
- $formatted = HTML::format(string $html): string;
454
- ```
455
-
456
- 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. Boolean HTML5 attributes (`muted`, `autoplay`, `noopener`, etc.) are written without a value.
457
-
458
- ---
459
-
460
- ### YAML
461
-
462
- A lightweight, zero-dependency YAML parser. Covers the full subset used in static site projects.
463
-
464
- ```php
465
- $data = YAML::parse(string $yaml, bool $assoc = false): mixed;
466
- $data = YAML::parseFile(string $path, bool $assoc = false): mixed;
467
- $data = YAML::loadFile(string $path, bool $assoc = false): mixed;
468
- ```
469
-
470
- Supported features:
471
-
472
- - Scalars: strings (quoted and unquoted), integers, floats, booleans, null
473
- - Single and double quoted strings with escape sequences
474
- - Literal block scalars (`|`, `|-`, `|+`)
475
- - Folded block scalars (`>`, `>-`, `>+`)
476
- - Plain scalars spanning multiple lines
477
- - Nested mappings and sequences
478
- - Inline collections (`[a, b]` and `{k: v}`)
479
- - Comments (`#`)
480
- - Multiple documents separated by `---`
481
-
482
- By default, YAML mappings are returned as `stdClass` objects. Pass `true` as the second argument to get associative arrays instead.
483
-
484
- `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`.
485
-
486
- ```yaml
487
- # team.yaml
488
- lead: people/jane.yaml # resolved and inlined automatically
489
- members:
490
- - people/jane.yaml
491
- - people/john.yaml
492
- ```
493
-
494
- ```php
495
- $team = YAML::loadFile('/project/data/team.yaml');
496
- // $team->lead is now the fully parsed content of people/jane.yaml, not a string
497
- ```
498
-
499
- ---
500
-
501
- ### CACHE
502
-
503
- Persistent SQLite-backed key-value cache. Survives across incremental builds via `.cache.db` at the project root.
504
-
505
- ```php
506
- CACHE::get(string $key): mixed
507
- CACHE::set(string $key, mixed $val, int $ttl = 0): bool
508
- CACHE::delete(string $key): bool
509
- CACHE::purge(): bool // removes expired entries
510
- ```
511
-
512
- The `$ttl` is in seconds. `0` means the entry never expires. Typical use case: caching the result of network fetches in custom hooks or plugins — it is what powers [`SCRAPER`](#scraper) and `CURL`'s cookie persistence internally.
513
-
514
- ```php
515
- $data = CACHE::get('my-remote-data');
516
- if ($data === null) {
517
- $data = json_decode(file_get_contents('https://api.example.com/data.json'));
518
- CACHE::set('my-remote-data', $data, 3600); // cache for 1 hour
519
- }
520
- ```
521
-
522
- ---
523
-
524
- ### IMG
525
-
526
- Image manipulation helper built on PHP GD. Supports JPEG, PNG, GIF, and WebP.
527
-
528
- ```php
529
- $img = new IMG(string $file);
530
-
531
- // Properties
532
- $img->width // int
533
- $img->height // int
534
-
535
- // Methods (chainable)
536
- $img->resize(int $width, int $height = 0, bool $cover = false): self
537
- $img->save(string $dest): self
538
- ```
539
-
540
- `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.
541
-
542
- `save()` infers the output format from the file extension (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`).
543
-
544
- ```php
545
- (new IMG('/project/src/images/hero.jpg'))
546
- ->resize(1200, 630, true)
547
- ->save('/project/src/images/hero-og.jpg');
548
- ```
549
-
550
- ---
551
-
552
- ### FS
553
-
554
- Filesystem utilities.
555
-
556
- ```php
557
- FS::dig(string $glob): iterable // recursive glob, yields file paths
558
- FS::getRelativePath(string $from, string $to): string
559
- FS::phpFileInfo(string $file): object|false // parse PHPDOC annotations
560
- FS::rmdir(string $dir, bool $removeSelf = true): bool
561
- FS::pathJoin(string ...$parts): string // URL-aware path join with .. resolution
562
- ```
563
-
564
- `FS::dig()` is the workhorse of directory-wide builds — it recursively walks a glob pattern and yields every matching file path.
565
-
566
- `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.
567
-
568
- ---
569
-
570
- ### STR
571
-
572
- String utilities used internally by the tag-processing pipeline, and available for your own templates and plugins.
573
-
574
- ```php
575
- STR::htmlesc(string $str): string
576
- STR::replaceTags(string $tag, string $html, callable $callback): string
577
- STR::parseHtmlAttributes(string $attrString): array
578
- STR::trimIndent(string $str): string
579
- STR::is_url(string $str): bool
580
- STR::html_entities_decode(string $str): string
581
- STR::shorthash(string $str): string
582
- STR::slug(string $str): string
583
- ```
584
-
585
- `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)`.
586
-
587
- `STR::trimIndent()` strips the common leading whitespace from a multi-line string — handy when pulling content out of indented `<markdown>` blocks.
588
-
589
- `STR::is_url()` checks whether a string parses as a URL with a recognized scheme (`http`, `https`, `ftp`, `ftps`, `ssh`, `ssl`, `sftp`, `itunes`).
590
-
591
- `STR::html_entities_decode()` trims a string and decodes its HTML entities — handy when normalizing text scraped from a third-party page.
592
-
593
- `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`).
594
-
595
- `STR::slug()` transliterates a string to ASCII, lowercases it, and strips anything that isn't `[a-z0-9]` — a compact identifier rather than a hyphenated slug.
596
-
597
- ---
598
-
599
- ### ARR
600
-
601
- Recursive lookup helper for nested arrays and objects.
602
-
603
- ```php
604
- ARR::find_key(mixed $data, string $key): mixed
605
- ```
606
-
607
- 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.
608
-
609
- ```php
610
- $config = YAML::parseFile('team.yaml');
611
- $email = ARR::find_key($config, 'email'); // finds `email` however deep it's nested
612
- ```
613
-
614
- ---
615
-
616
- ### CURL
617
-
618
- 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()`).
619
-
620
- ```php
621
- CURL::urlExists(string $url, ?string $mimereg = null): bool
622
- CURL::getInfo(string $url): array|false // HEAD request, returns curl_getinfo()
623
- CURL::getContents(string $file, ?string $dest = null, ?callable $clb = null): string|bool
624
- ```
625
-
626
- `CURL::urlExists()` issues a `HEAD` request and returns `true` for any `2xx`/`3xx` response.
627
-
628
- `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`.
629
-
630
- ```php
631
- CURL::getContents('https://example.com/report.pdf', '/project/src/downloads/report.pdf', function (float $progress) {
632
- error_log(sprintf('%.0f%%', $progress * 100));
633
- });
634
- ```
635
-
636
- ---
637
-
638
- ### SCRAPER
639
-
640
- 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.
641
-
642
- ```php
643
- $metas = SCRAPER::get(string $url): object|false;
644
- ```
645
-
646
- ```php
647
- $metas = SCRAPER::get('https://example.com/blog/some-article');
648
- if ($metas) {
649
- echo $metas->title; // string
650
- echo $metas->description; // string
651
- echo $metas->image; // string (absolute URL, may be empty)
652
- echo $metas->label; // string — site/publisher name, may be empty
653
- echo $metas->url; // string — the URL that was scraped
654
- }
655
- ```
656
-
657
- 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.
658
-
659
- A free `url_exists(string $url, ?string $mimereg = null): bool` function is also available globally (not namespaced under a class), cached the same way as `SCRAPER::get()`.
660
-
661
- ---
662
-
663
- ### OBF
664
-
665
- Simple reversible obfuscation for values you want to embed in HTML without making them trivially readable (e.g., contact data, API tokens in templates).
666
-
667
- ```php
668
- $encoded = OBF::encode(mixed $obj): string;
669
- $decoded = OBF::decode(string $str): mixed;
670
- ```
671
-
672
- Applies JSON encoding → base64 → ROT-13 → gzip. Not cryptographically secure; intended for light obfuscation only.
673
-
674
- ---
675
-
676
- ### STD
677
-
678
- Output helpers used by the PHP runtime to communicate back to Node.js over stdout/stderr.
679
-
680
- ```php
681
- STD::succeed(array|string $props = []): void // exits 0, writes JSON to stdout
682
- STD::error(array|string $props = []): void // exits 1, writes JSON to stderr
683
- ```
684
-
685
- 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.
686
-
687
- ---
688
-
689
- ## Plugin system
690
-
691
- `@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).
692
-
693
- ---
694
-
695
- ### PREPROS tags
696
-
697
- Register a custom HTML tag that is processed **after** PHP execution, on the fully assembled HTML string:
698
-
699
- ```php
700
- // In a file listed under prepros.includes in kirigami.yaml, or in before.php:
701
-
702
- PREPROS::registerTag('gallery', function (string $fullTag, array $attrs, string $body): string {
703
- $id = $attrs['id'] ?? '';
704
- $imgs = glob("/project/src/images/gallery/{$id}/*.webp");
705
- $html = '<div class="gallery">';
706
- foreach ($imgs as $img) {
707
- $src = str_replace('/project/src', '', $img);
708
- $html .= "<img src=\"{$src}\" loading=\"lazy\">";
709
- }
710
- return $html . '</div>';
711
- });
712
- ```
713
-
714
- Then in any page template:
715
-
716
- ```html
717
- <gallery id="summer-2025"></gallery>
718
- ```
719
-
720
- The callback receives:
721
-
722
- | Parameter | Type | Description |
723
- |-----------|------|-------------|
724
- | `$fullTag` | `string` | The complete matched tag string |
725
- | `$attrs` | `array` | Parsed HTML attributes as an associative array |
726
- | `$body` | `string` | Inner content between opening and closing tags |
727
-
728
- The built-in `<markdown>` tag is registered this way (see below).
729
-
730
- ---
731
-
732
- ### PREPROS hooks
733
-
734
- Hooks let you intercept and transform data at key points in the rendering pipeline:
735
-
736
- ```php
737
- PREPROS::registerHook(string $hookName, callable $callback): void
738
- ```
739
-
740
- | Hook | When it fires | `$data` type | Expected return |
741
- |------|---------------|--------------|-----------------|
742
- | `page_info` | After PHPDOC parsing, before rendering (auto-loads `.yaml`/`.json`/`.md` annotations) | `[$filePath, $pageObject]` | `$pageObject` (modified) |
743
- | `pre_render` | Before PHP execution | Raw file contents as `string` | `string` |
744
- | `post_render` | After tag processing, before `HTML::format()` | Assembled HTML `string` | `string` |
745
-
746
- Multiple callbacks can be registered for the same hook — they are executed in registration order, each receiving the return value of the previous one.
747
-
748
- ```php
749
- // Example: inject a last-modified date into every page
750
- PREPROS::registerHook('post_render', function (string $html): string {
751
- $date = date('Y-m-d');
752
- return str_replace('{{build_date}}', $date, $html);
753
- });
754
- ```
755
-
756
- ---
757
-
758
- ### MD plugins
759
-
760
- MD plugins add custom shortcode tags inside Markdown content. They work inside `<markdown>` blocks, in `.md` data files, and anywhere `MD::toHtml()` is called.
761
-
762
- **Inline syntax** (all on one line):
763
-
764
- ```
765
- {% tagname arg1 "argument with spaces" %}
766
- ```
767
-
768
- **Block syntax** (body on subsequent lines):
769
-
770
- ```
771
- {% tagname optional-arg
772
- Line one of the body.
773
- Line two of the body.
774
- %}
775
- ```
776
-
777
- ```php
778
- MD::registerPlugin(string $name, callable $callback): void
779
- ```
780
-
781
- 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).
782
-
783
- ---
784
-
785
- ### Built-in plugins
786
-
787
- The following MD plugins are registered out of the box in `md.plugins.php`:
788
-
789
- #### `{% callout type ["Title"] content %}`
790
-
791
- Renders a styled callout block. `type` is one of `info`, `success`, `warning`, `danger`.
792
-
793
- ```
794
- {% callout warning "Heads up" This section is outdated. %}
795
-
796
- {% callout danger "Critical"
797
- Line one of a longer warning.
798
-
799
- Line two after a blank line.
800
- %}
801
- ```
802
-
803
- #### `{% youtube id [width height] %}`
804
-
805
- Embeds a responsive YouTube player via `<iframe>`. `width`/`height` default to `560`/`315`.
806
-
807
- ```
808
- {% youtube dQw4w9WgXcQ %}
809
- {% youtube dQw4w9WgXcQ 800 450 %}
810
- ```
811
-
812
- #### `{% codepen id [user height] %}`
813
-
814
- Embeds a CodePen result via `<iframe>`. `user` defaults to `anonymous`, `height` defaults to `400`.
815
-
816
- ```
817
- {% codepen abcXYZ %}
818
- {% codepen abcXYZ jsmith 500 %}
819
- ```
820
-
821
- #### `{% checklist ["Title"] items %}`
822
-
823
- Renders a block-syntax list of checkbox items, one per line, with an optional title.
824
-
825
- ```
826
- {% checklist "Today"
827
- Do the dishes
828
- Walk the dog
829
- Read a book
830
- %}
831
- ```
832
-
833
- ---
834
-
835
- ## Extending the `<markdown>` tag
836
-
837
- The `<markdown>` tag is registered as a PREPROS tag out of the box. It converts its inner content from Markdown to HTML and strips common leading indentation so you can write cleanly inside your PHP templates:
838
-
839
- ```html
840
- <section class="about">
841
- <div>
842
- <markdown>
843
- ## Who we are
844
-
845
- We are a **student organization** from Québec.
846
-
847
- {% youtube dQw4w9WgXcQ %}
848
- </markdown>
849
- </div>
850
- </section>
851
- ```
852
-
853
- 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:
854
-
855
- ```php
856
- PREPROS::registerTag('markdown', function (string $tag, array $attrs, string $body): string {
857
- $body = STR::trimIndent($body);
858
- $html = MD::toHtml($body);
859
- // wrap in a container, add a class, etc.
860
- $class = $attrs['class'] ?? 'prose';
861
- return "<div class=\"{$class}\">{$html}</div>";
862
- });
863
- ```
864
-
865
- ---
866
-
867
- ## License
868
-
869
- MIT © Maxime Larrivée-Roy, 2026
1
+ <div align="center">
2
+
3
+ <img src="https://zmotrin.github.io/assets/kirigami/kirigami-logo-universal.svg" alt="Kirigami" width="400" />
4
+
5
+ ---
6
+
7
+ # @kirigami/php-prepros
8
+
9
+
10
+ PHP preprocessor for the **Kirigami** static site generator.
11
+
12
+ [![npm version](https://img.shields.io/npm/v/@kirigami/php-prepros)](https://www.npmjs.com/package/@kirigami/php-prepros)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
14
+ [![Node.js >=24.0.0](https://img.shields.io/badge/node-%3E%3D24.0.0-brightgreen)](https://nodejs.org)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## Overview
21
+
22
+ 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.
23
+
24
+ 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.
25
+
26
+ Part of the **Kirigami** project ecosystem.
27
+
28
+
29
+ ---
30
+
31
+
32
+ ## Table of contents
33
+
34
+ - [@kirigami/php-prepros](#kirigamiphp-prepros)
35
+ - [Overview](#overview)
36
+ - [Table of contents](#table-of-contents)
37
+ - [What's new in 1.6.0](#whats-new-in-160)
38
+ - [What's new in 1.4.0](#whats-new-in-140)
39
+ - [What's new in 1.3.0](#whats-new-in-130)
40
+ - [What's new in 1.2.1](#whats-new-in-121)
41
+ - [What's new in 1.2.0](#whats-new-in-120)
42
+ - [How it works](#how-it-works)
43
+ - [Installation](#installation)
44
+ - [Configuration — `kirigami.yaml`](#configuration--kirigamiyaml)
45
+ - [`kirigami` block](#kirigami-block)
46
+ - [`jsonld` block](#jsonld-block)
47
+ - [`prepros` block](#prepros-block)
48
+ - [`image` block](#image-block)
49
+ - [`plugins` block](#plugins-block)
50
+ - [`esbuild` / `sass` blocks](#esbuild--sass-blocks)
51
+ - [`export` block](#export-block)
52
+ - [`scripts` block](#scripts-block)
53
+ - [`tasks` block](#tasks-block)
54
+ - [Writing pages](#writing-pages)
55
+ - [PHPDOC header](#phpdoc-header)
56
+ - [Auto-loading data files](#auto-loading-data-files)
57
+ - [`@content` and `@indent`](#content-and-indent)
58
+ - [Built-in tags](#built-in-tags)
59
+ - [JavaScript API](#javascript-api)
60
+ - [`render(file?)`](#renderfile)
61
+ - [`sitemap()`](#sitemap)
62
+ - [`runenv(script, paths?, ...args)`](#runenvscript-paths-args)
63
+ - [`mountPath(localPath, virtualDir?, php?)`](#mountpathlocalpath-virtualdir-php)
64
+ - [`processImages(jobs)`](#processimagesjobs)
65
+ - [PHP classes reference](#php-classes-reference)
66
+ - [PREPROS](#prepros)
67
+ - [`PREPROS::render(string $file)`](#preprosrenderstring-file)
68
+ - [`PREPROS::sitemap()`](#preprossitemap)
69
+ - [`PREPROS::mount(string|array $patterns)`](#preprosmountstringarray-patterns)
70
+ - [`PREPROS::exportFile(string $file)`](#preprosexportfilestring-file)
71
+ - [MD](#md)
72
+ - [Plugin API](#plugin-api)
73
+ - [HTML](#html)
74
+ - [YAML](#yaml)
75
+ - [SCHEMA](#schema)
76
+ - [LD](#ld)
77
+ - [Automatic mode](#automatic-mode)
78
+ - [Explicit builders](#explicit-builders)
79
+ - [`jsonld` config](#jsonld-config)
80
+ - [CACHE](#cache)
81
+ - [IMG](#img)
82
+ - [FS](#fs)
83
+ - [STR](#str)
84
+ - [ARR](#arr)
85
+ - [CURL](#curl)
86
+ - [SCRAPER](#scraper)
87
+ - [OBF](#obf)
88
+ - [STD](#std)
89
+ - [Bundled polyfills](#bundled-polyfills)
90
+ - [Procedural shortcuts (aliases)](#procedural-shortcuts-aliases)
91
+ - [Plugin system](#plugin-system)
92
+ - [PREPROS tags](#prepros-tags)
93
+ - [PREPROS hooks](#prepros-hooks)
94
+ - [MD plugins](#md-plugins)
95
+ - [Built-in plugins](#built-in-plugins)
96
+ - [`{% callout type ["Title"] content %}`](#-callout-type-title-content-)
97
+ - [`{% youtube id [width height] %}`](#-youtube-id-width-height-)
98
+ - [`{% codepen id [user height] %}`](#-codepen-id-user-height-)
99
+ - [`{% checklist ["Title"] items %}`](#-checklist-title-items-)
100
+ - [Extending the `<markdown>` tag](#extending-the-markdown-tag)
101
+ - [Requirements](#requirements)
102
+ - [License](#license)
103
+
104
+ ---
105
+
106
+ ## What's new in 1.6.0
107
+
108
+ - **Managed `<head>` (`prepros.head`).** Every rendered page's `<head>` is now
109
+ auto-wired: a tiny theme/FOUC guard as the first child (adds the `js` class,
110
+ applies the stored `data-theme` before first paint), a
111
+ `<link rel="stylesheet">` for every `sass` task output, and a `<script>` (no
112
+ `defer`, just before `</body>`) for every `esbuild` task output — each with a
113
+ per-page relative path and a `?<timestamp>` cache-bust. A file already
114
+ referenced in the page is left alone, so you can still hand-place one. Turn it
115
+ off with `prepros: { head: false }`, or `head: false` on a single sass/esbuild
116
+ task. A template's `header.php` no longer wires assets at all.
117
+
118
+ - **`HTML::format()` indents `<pre><code>`.** A fenced code block's lines are
119
+ shifted to the block's nesting depth so the HTML source stays readable
120
+ (relative indentation preserved). The exact leading run is stripped again
121
+ before it's shown — at build time by `@kirigami/plugin-highlight`, otherwise
122
+ by a ~250-byte de-indent script `prepros.head` injects before `</body>` (only
123
+ when `format` is on; it skips blocks a highlighter already flattened). A bare
124
+ `<pre>` and `<textarea>` are still emitted byte-for-byte.
125
+
126
+ ---
127
+
128
+ ## What's new in 1.4.0
129
+
130
+ A round of fixes to the rough edges that showed up building a full site from
131
+ scratch — mostly developer-experience, all backward compatible.
132
+
133
+ - **`HTML::format()` keeps `<pre>` / `<textarea>` verbatim.** Their line breaks,
134
+ indentation and blank lines are no longer collapsed, so a fenced code block
135
+ survives the formatter intact — `format: true` and Markdown code blocks now
136
+ coexist. (1.6.0 refines this: a `<pre><code>` block is re-indented to its
137
+ nesting depth and de-indented again before display.)
138
+ - **The default Markdown plugins load out of the box.** `{% callout %}`,
139
+ `{% youtube %}`, `{% codepen %}` and `{% checklist %}` are registered
140
+ automatically (`md.plugins.php` is auto-included from `MD`), as the docs always
141
+ said. Drop one with `MD::unregisterPlugin('name')` or shadow it with your own
142
+ `MD::registerPlugin()`.
143
+ - **Build errors you can actually read.** A fatal in a template (a bad call, a
144
+ `null` argument, a `TypeError`…) comes back as a structured failure with the
145
+ message, the offending page and the `file:line` — never a bare
146
+ `Error: undefined`. The `try/catch` now covers `Throwable`, not just
147
+ `Exception`. PHP warnings and notices no longer sink an otherwise-clean build:
148
+ they surface as `warnings` on the result. `kiri` prints the message, the page,
149
+ and the tail of the PHP stderr/debug output on failure.
150
+ - **PHPDOC parsing.** A tag value may now wrap onto the following *indented*
151
+ continuation lines instead of being silently truncated at the first line. And
152
+ an `@word` written in the block's prose is ignored rather than overwriting a
153
+ real tag — only lines that *start* with `@` open a tag.
154
+ - **`FS::getBreadcrumb()` / `FS::getChildren()`** are anchored on the page being
155
+ rendered (`PREPROS::$file`), so they return the right trail / child list when
156
+ called from a layout include, a partial, or a helper function — not only
157
+ straight from the template. Pass an explicit path to override.
158
+ - **`{% tag %}` inside a code span or code block stays literal** (`` `{% badge %}` ``
159
+ renders as text) instead of being expanded — or leaking an unrestored
160
+ placeholder.
161
+ - **`IMG` never upscales.** A requested size larger than the source is clamped
162
+ down to the source instead of throwing an opaque encoder error (the AVIF
163
+ encoder in particular).
164
+ - **Build-time tokens expand at render time.** `###YEAR###`, `###TIMESTAMP###`
165
+ and `###TODAY###` are substituted when each page is generated, so
166
+ `kiri build` / `kiri watch` previews show real values, not the literal token
167
+ (previously only `kiri export` replaced them).
168
+ - **`page_info` hook robustness.** The first built-in callback accepts either the
169
+ `[$file, $info]` pair the hook fires with or the bare `$info` object a later
170
+ callback receives, so a custom `page_info` hook can't fatal on the argument
171
+ shape. See [PREPROS hooks](#prepros-hooks).
172
+
173
+ ---
174
+
175
+ ## What's new in 1.3.0
176
+
177
+ - **`LD`** class — a schema.org JSON-LD generator. Collects structured-data
178
+ nodes during a render and emits them as a single
179
+ `<script type="application/ld+json">` `@graph` in every page's `<head>`.
180
+ - Automatic injection is **opt-in**: add a top-level `jsonld:` block to
181
+ `kirigami.yaml` (even empty, `jsonld: {}`) and an `Organization` (+ `Person`, `WebSite`,
182
+ `WebPage`, `BreadcrumbList`) graph is derived from that block plus the loose
183
+ keys projects already carry (`person`, `jobtitle`, `email`, `area`,
184
+ `knowsabout`, `keywords`, `facebook`, …). No `jsonld:` block → nothing is
185
+ injected.
186
+ - Turn it back off with `jsonld: false` / `jsonld: { auto: false }`, or per
187
+ page with `@ld false`; per-page `@ld_type` / `@ld_title` / `@ld_image` / …
188
+ tags feed the page node, and a `BreadcrumbList` is built from the
189
+ `_index.php` ancestor trail with no opt-in.
190
+ - Explicit builders for everything else: `LD::add()`, `LD::article()`,
191
+ `LD::faqPage()`, `LD::breadcrumb()`, `LD::ref()`, and every schema.org type
192
+ via `LD::typeName([...])`. Procedural aliases: `ld_add()`, `ld_organization()`,
193
+ `ld_script()`, …
194
+ - A page that already hand-writes an `application/ld+json` script is left
195
+ untouched.
196
+
197
+ ---
198
+
199
+ ## What's new in 1.2.1
200
+
201
+ - **`processImages()`** JS export — batch resize / palette-extraction through the
202
+ `IMG` class (`src/imagebatch.php`). `@kirigami/kirigami`'s `sass` task now uses
203
+ it for `img-asset()` / `colors()`, so the whole toolchain is free of a native
204
+ image dependency (`sharp` is gone).
205
+ - **`IMG::save()`** takes an optional `$quality` (0-100) for jpg / webp / avif;
206
+ `null` keeps the per-format default (82).
207
+
208
+ ---
209
+
210
+ ## What's new in 1.2.0
211
+
212
+ - **`SCHEMA`** class — a pure-PHP, dependency-free JSON Schema validator
213
+ (Draft-7 style, Ajv-like API: `isValid()` / `validate()` / `getErrors()`).
214
+ - **`IMG::asset()` / `IMG::palette()`** — static helpers powering kirigami-core's
215
+ `img-asset()` and `colors()` Sass functions: on-demand resize/convert of a
216
+ source image, and cached representative-colour extraction.
217
+ - **`<img asset="…">` tag** — the HTML-side entry point of the image
218
+ autogenerator, same parameters as `IMG::asset()` (see [Built-in tags](#built-in-tags)).
219
+ - **`IMG` now handles vector and exotic formats** — SVG, EPS, AI, PDF (rasterized
220
+ via Imagick), plus HEIC / TIFF / BMP, on top of GD's JPEG / PNG / GIF / WebP /
221
+ AVIF.
222
+ - **`MD` emoji shortcodes** — `:rocket:` → 🚀 from a large built-in map, extend­able
223
+ with `MD::registerEmoji()`.
224
+ - **`MD` footnotes and definition lists** — `[^1]` / `[^1]: …`, and `Term` / `: …`.
225
+ - **`MD` inline HTML is now sanitized** against a tag/attribute allowlist rather
226
+ than passed through verbatim.
227
+ - **`STR::normalize()`** — Unicode NFD + combining-mark stripping;
228
+ **`STR::slug($str, $sep = '')`** now takes a separator (pass `'-'` for a
229
+ hyphenated slug).
230
+ - **Bundled `Normalizer` polyfill** — `ext-intl` isn't in the WASM build, so a
231
+ polyfill keeps `Normalizer::normalize()` (and `STR::normalize()` / `slug()`)
232
+ working.
233
+
234
+ Earlier, in 1.1.x: `PREPROS::mount()` + the `mountPath()` / `runenv()` JS
235
+ exports, the `SCRAPER` and `CURL` and `ARR` classes, `YAML::loadFile()`, the
236
+ `STR::is_url()` / `html_entities_decode()` / `shorthash()` / `slug()` helpers,
237
+ the `{% youtube %}` / `{% codepen %}` / `{% checklist %}` MD plugins, and the
238
+ `@content` / `@indent` PHPDOC annotations.
239
+
240
+ ---
241
+
242
+ ## How it works
243
+
244
+ `@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.
245
+
246
+ The lifecycle of a page build looks like this:
247
+
248
+ ```
249
+ _index.php ──▶ PHP (wasm) ──▶ processTags() ──▶ HTML::format() ──▶ index.html
250
+ │
251
+ ├── before.php (optional layout header)
252
+ ├── after.php (optional layout footer)
253
+ └── PHPDOC annotations resolved (yaml / json / md / url)
254
+ ```
255
+
256
+ 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).
257
+
258
+ ---
259
+
260
+ ## Installation
261
+
262
+ ```bash
263
+ npm install @kirigami/php-prepros
264
+ ```
265
+
266
+ ---
267
+
268
+ ## Configuration — `kirigami.yaml`
269
+
270
+ Every project **must** have a `kirigami.yaml` at its root. The preprocessor reads it at startup and throws if it is absent or invalid.
271
+
272
+ `@kirigami/php-prepros` itself only acts on four blocks — **`kirigami:`**, **`jsonld:`**, **`prepros:`**, and **`image:`**. The remaining blocks (**`plugins:`**, **`esbuild:`**, **`sass:`**, **`export:`**, **`scripts:`**, **`tasks:`**) are consumed by the [`kiri`](https://www.npmjs.com/package/@kirigami/kirigami) CLI that drives the build; they are documented here for completeness because everything lives in the one file. The full file is validated against [`kirigami.schema.json`](https://github.com/php-kirigami/kirigami/blob/main/packages/kirigami/kirigami.schema.json), also served for editor autocompletion:
273
+
274
+ ```yaml
275
+ # yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/php-kirigami/kirigami@main/packages/kirigami/kirigami.schema.json
276
+ ```
277
+
278
+ ```yaml
279
+ kirigami:
280
+ # ── Required ──────────────────────────────────────────────────────────
281
+ project: My Website # Site name. Printed in the CLI banner, exposed as $project.
282
+ baseurl: https://example.com # Deployed root URL, no trailing slash. Used for sitemap.xml.
283
+ root: src # Source directory containing your _*.php pages.
284
+
285
+ # ── Optional ────────────────────────────────────────────────────────
286
+ banner: assets/banner.txt # Text file stamped as a license banner on exported files.
287
+
288
+ # ── Arbitrary project data ──────────────────────────────────────────
289
+ # Everything else under `kirigami:` is free-form. The whole block is
290
+ # extracted as PHP variables and made available in every page, in
291
+ # before.php/after.php, and anywhere PREPROS::$config->data is read.
292
+ author: Jane Doe
293
+ email: hello@example.com
294
+ gtag: G-XXXXXXXXXX
295
+ description: A short description of the site, useful for <meta name="description">.
296
+ keywords:
297
+ - keyword one
298
+ - keyword two
299
+
300
+ jsonld: # Presence turns on the LD schema.org JSON-LD generator.
301
+ type: Organization # `jsonld: {}` alone is enough; see the jsonld block below.
302
+ logo: assets/logo.png
303
+
304
+ prepros:
305
+ before: _layouts/header.php # Included before every page body.
306
+ after: _layouts/footer.php # Included after every page body.
307
+ format: true # Pretty-print the HTML output (default: false).
308
+ network: true # Allow HTTP fetches in PHPDOC @tag annotations / CURL / SCRAPER.
309
+ mountext: # Extra file extensions to auto-mount into the wasm fs,
310
+ - .svg # in addition to the defaults (.php .json .yaml .yml .md .db .txt).
311
+ - .webp
312
+ includes: # PHP files auto-included once, before any page renders.
313
+ - _lib/functions.php
314
+
315
+ image: # Image autogenerator — powers IMG::asset() / IMG::palette().
316
+ format: webp # webp | avif (default: webp)
317
+ source: assets/images # Source folder, relative to cwd() (default: assets/images)
318
+ dest: images # Output folder, relative to kirigami.root (default: images)
319
+
320
+ plugins:
321
+ - name: "@kirigami/plugin-highlight"
322
+ active: true
323
+ options:
324
+ style: canva
325
+ color: black
326
+
327
+ esbuild:
328
+ # minify: false
329
+
330
+ sass:
331
+ style: expanded
332
+
333
+ export:
334
+ path: dist
335
+ ignore: ["*.psd", "notes/"]
336
+
337
+ scripts:
338
+ - name: convert-images
339
+ mount: ["assets/images/**/*.jpg"]
340
+ trigger: before-build # before-build | before-export | after-export
341
+
342
+ tasks:
343
+ - name: js-core
344
+ type: esbuild
345
+ entry: scripts/kirigami.core.js
346
+
347
+ - name: scss-core
348
+ type: sass
349
+ entry: styles/kirigami.core.scss
350
+ ```
351
+
352
+ ### `kirigami` block
353
+
354
+ 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`.
355
+
356
+ | Key | Required | Description |
357
+ |-----|----------|--------------|
358
+ | `project` | ✅ | Human-readable site name. Exposed as `$project`. |
359
+ | `baseurl` | ✅ | Root URL of the deployed site, no trailing slash. Used to build absolute `<loc>` entries in `sitemap.xml`; exposed as `$baseurl`. |
360
+ | `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. |
361
+ | `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. |
362
+ | *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 `jsonld` 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`, …). |
363
+
364
+ ### `jsonld` block
365
+
366
+ Top-level, optional. Its **presence** switches on the [`LD`](#ld) schema.org
367
+ JSON-LD generator — an `application/ld+json` graph is then injected into every
368
+ page's `<head>`. An empty `jsonld: {}` is enough; its keys refine what `LD`
369
+ otherwise infers from the `kirigami` block and each page's PHPDOC. `jsonld: false`
370
+ (or `jsonld: { auto: false }`) keeps the config values but stops the injection;
371
+ no block at all means nothing is injected. Full key reference and per-page
372
+ `@ld_*` tags: [`LD` → `jsonld` config](#jsonld-config).
373
+
374
+ ### `prepros` block
375
+
376
+ 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.
377
+
378
+ | Key | Type | Default | Description |
379
+ |-----|------|---------|--------------|
380
+ | `before` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **before** every page's body. Typically your `<head>`/layout opening. |
381
+ | `after` | `string` | — | Path (relative to `kirigami.root`) to a PHP file included **after** every page's body. Typically your layout closing. |
382
+ | `format` | `bool` | `false` | Pretty-print the compiled HTML via [`HTML::format()`](#html) before writing it to disk. |
383
+ | `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. |
384
+ | `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. |
385
+ | `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. |
386
+ | `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()`. |
387
+
388
+ ### `image` block
389
+
390
+ 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.
391
+
392
+ | Key | Type | Default | Description |
393
+ |-----|------|---------|--------------|
394
+ | `format` | `string` | `webp` | Output format for generated images: `webp` or `avif`. |
395
+ | `source` | `string` | `assets/images` | Folder holding the source images, relative to `cwd()`. |
396
+ | `dest` | `string` | `images` | Destination folder for generated images, relative to `kirigami.root`. |
397
+
398
+ ### `plugins` block
399
+
400
+ List of Kirigami plugins. **Consumed by the `kiri` CLI** (see [`@kirigami/sdk`](https://www.npmjs.com/package/@kirigami/sdk)), not by `php-prepros` directly.
401
+
402
+ | Key | Required | Description |
403
+ |-----|----------|--------------|
404
+ | `name` | ✅ | Plugin package name. Must match `@kirigami/plugin-*`, `<scope>/kirigami-plugin-*`, or `kirigami-plugin-*`. |
405
+ | `active` | ✅ | Whether the plugin is loaded. |
406
+ | `options` | — | Free-form object passed to the plugin; its shape depends on the plugin. |
407
+
408
+ ### `esbuild` / `sass` blocks
409
+
410
+ 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.
411
+
412
+ `sass:` additionally recognizes two keys that are **not** passed to Dart Sass:
413
+
414
+ | Key | Type | Description |
415
+ |-----|------|--------------|
416
+ | `before` | `string` / `string[]` | Extra `.scss` files compiled **before** the task entry (paths relative to `cwd()`). |
417
+ | `after` | `string` / `string[]` | Extra `.scss` files compiled **after** the task entry. |
418
+
419
+ ### `export` block
420
+
421
+ Options for `kiri export`. **Consumed by the `kiri` CLI.** Optional.
422
+
423
+ | Key | Type | Default | Description |
424
+ |-----|------|---------|--------------|
425
+ | `path` | `string` | `dist` | Output directory for `kiri export`, relative to the project root. |
426
+ | `ignore` | `string[]` | `[]` | Extra gitignore-style patterns excluded from the export copy, on top of Kirigami's built-in exclusions. |
427
+
428
+ ### `scripts` block
429
+
430
+ 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`.
431
+
432
+ | Key | Required | Description |
433
+ |-----|----------|--------------|
434
+ | `name` | ✅ | Must match an existing `scripts/<name>.php` file. Run with `kiri run <name> [args...]`; extra CLI arguments are forwarded as `$argv` entries. |
435
+ | `mount` | — | Glob patterns (relative to the project root) of extra local files to mount into the sandbox before the script runs. |
436
+ | `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). |
437
+
438
+ ### `tasks` block
439
+
440
+ 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`).
441
+
442
+ | `type` | Purpose | Required fields | Optional |
443
+ |--------|---------|-----------------|----------|
444
+ | `esbuild` | Bundle/minify a JS/TS entry. Build + watch. Output: `<entry>.min.js`. | `name`, `type`, `entry` | `force` |
445
+ | `sass` | Compile a `.scss`/`.sass` entry, minified with csso on export. Build + watch. Output: `<entry>.min.css`. | `name`, `type`, `entry` | `force` |
446
+ | `prepros` | Render pages + `sitemap.xml`. Watch only (runs on build/export only when forced/implicit). | `name`, `type` | `target`, `force` |
447
+ | `dist` | Copy `kirigami.root` into an output dir, stamping the banner. Forced/implicit only. | `name`, `type`, `path` | `ignore`, `force` |
448
+
449
+ ---
450
+
451
+ ## Writing pages
452
+
453
+ 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.
454
+
455
+ ```
456
+ src/
457
+ ├── _layouts/
458
+ ├── _lib/
459
+ ├── _index.php → src/index.html
460
+ ├── about/
461
+ │ └── _index.php → src/about/index.html
462
+ └── blog/
463
+ ├── _index.php → src/blog/index.html
464
+ └── _articles.yaml (data file, not compiled)
465
+ ```
466
+
467
+ Directories whose name starts with `_` (e.g. `_layouts/`, `_lib/`) are skipped entirely during directory-wide builds.
468
+
469
+ ### PHPDOC header
470
+
471
+ Every page starts with a PHP docblock that drives metadata and data loading:
472
+
473
+ ```php
474
+ <?php
475
+ /**
476
+ * @name about
477
+ * @title About us
478
+ * @abstract A short description of this page.
479
+ */
480
+ ?>
481
+ <section>
482
+ <h1><?php echo $title; ?></h1>
483
+ <p><?php echo $abstract; ?></p>
484
+ </section>
485
+ ```
486
+
487
+ All annotations are injected as PHP variables (`$name`, `$title`, `$abstract`, …). You can define any custom annotation you need.
488
+
489
+ Annotations are also available as variables in `before` and `after` PHP included files, so you can write proper metas in the HTML header.
490
+
491
+ Only lines whose first non-whitespace character (past the `*` gutter) is `@`
492
+ open an annotation — an `@word` written in the prose of the block is left alone.
493
+ A value can wrap onto the following **indented** continuation lines:
494
+
495
+ ```php
496
+ /**
497
+ * @title About us
498
+ * @description A longer blurb that does not fit comfortably
499
+ * on a single line and continues here.
500
+ */
501
+ ```
502
+
503
+ ### Auto-loading data files
504
+
505
+ 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.
506
+
507
+ ```php
508
+ <?php
509
+ /**
510
+ * @name medias
511
+ * @articles _articles.yaml
512
+ */
513
+ ?>
514
+ <?php foreach ($articles as $article): ?>
515
+ <a href="<?php echo $article->lien; ?>">
516
+ <?php echo $article->titre; ?>
517
+ </a>
518
+ <?php endforeach; ?>
519
+ ```
520
+
521
+ | Extension | Parsed as |
522
+ |-----------|-----------|
523
+ | `.yaml` / `.yml` | `stdClass` object (or array of objects for sequences) |
524
+ | `.json` | Result of `json_decode()` |
525
+ | `.md` | HTML string via `MD::toHtml()` |
526
+
527
+ 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:
528
+
529
+ ```php
530
+ /**
531
+ * @posts https://api.example.com/posts.json
532
+ */
533
+ ```
534
+
535
+ ### `@content` and `@indent`
536
+
537
+ Two special annotation names change how a page's body is assembled:
538
+
539
+ - **`@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.
540
+ - **`@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.
541
+
542
+ ```php
543
+ <?php
544
+ /**
545
+ * @name changelog
546
+ * @title Changelog
547
+ * @content _changelog.md
548
+ * @indent 4
549
+ */
550
+ ```
551
+
552
+ ### Built-in tags
553
+
554
+ Two tags are registered out of the box (`prepros.plugins.php`) and processed
555
+ **after** the PHP runs, on the assembled HTML — no include or plugin needed.
556
+
557
+ #### `<markdown> … </markdown>`
558
+
559
+ Converts its inner content from Markdown to HTML, stripping the common leading
560
+ indentation first (via `STR::trimIndent()`) so you can indent it naturally inside
561
+ your template. All registered [MD plugins](#md-plugins) work inside it. See
562
+ [Extending the `<markdown>` tag](#extending-the-markdown-tag) to override it.
563
+
564
+ ```html
565
+ <section>
566
+ <markdown>
567
+ ## Who we are
568
+
569
+ We are a **student organization** from Québec.
570
+ </markdown>
571
+ </section>
572
+ ```
573
+
574
+ #### `<img asset="…">`
575
+
576
+ The HTML-side entry point of the image autogenerator — the exact same feature as
577
+ the [`img-asset()` Sass function](https://www.npmjs.com/package/@kirigami/kirigami#sass-functions)
578
+ and [`IMG::asset()`](#img), with the same parameters. The tag calls `IMG::asset()`
579
+ under the hood, then swaps the `asset` attribute for the generated `src`.
580
+
581
+ ```html
582
+ <!-- in: resolves assets/images/hero.jpg through IMG::asset('hero.jpg', 800, 0, false) -->
583
+ <img asset="hero.jpg" width="800" alt="Our office" loading="lazy">
584
+ <!-- out: <img src="../images/hero-800w.webp" alt="Our office" loading="lazy"> -->
585
+ ```
586
+
587
+ | Attribute | Maps to `IMG::asset()` arg | Notes |
588
+ |-----------|---------------------------|-------|
589
+ | `asset` | `$path` | **Required.** Path relative to `image.source`. Missing/empty ⇒ the tag is left untouched. |
590
+ | `width` | `$width` | Optional, integer. Omitted ⇒ `0` (keep). |
591
+ | `height` | `$height` | Optional, integer. Omitted ⇒ `0` (keep). |
592
+ | `cover` | `$cover` | Boolean — **presence means `true`** (crop + fill). |
593
+ | *(any other)* | — | `alt`, `class`, `id`, `loading`, … are passed straight through onto the output `<img>`. |
594
+
595
+ `asset` / `width` / `height` / `cover` are consumed and removed; everything else
596
+ survives. The generated file lands in `image.dest` and is only (re)generated when
597
+ missing or older than the source — see [`IMG`](#img) for the naming convention.
598
+
599
+ > The Sass `img-asset()` / `colors()` functions, the `<img asset>` tag and
600
+ > `IMG::asset()` all run on the **same engine** — the `IMG` class (GD, with the
601
+ > Imagick fallback) in this package. `@kirigami/kirigami`'s `sass` task routes its
602
+ > image work here through [`processImages()`](#processimagesjobs), so there is no
603
+ > native image dependency in the toolchain.
604
+
605
+ ---
606
+
607
+ ## JavaScript API
608
+
609
+ ```js
610
+ import { render, sitemap, runenv, mountPath, processImages } from '@kirigami/php-prepros';
611
+ ```
612
+
613
+ ### `render(file?)`
614
+
615
+ Compile a single PHP page or a whole directory.
616
+
617
+ ```js
618
+ // Compile one page
619
+ const result = await render('about/_index.php');
620
+
621
+ // Compile everything under src/
622
+ const result = await render('.');
623
+
624
+ // Compile everything (uses kirigami.root from config)
625
+ const result = await render();
626
+ ```
627
+ > Paths used by `render()` are all relative to the `kirigami.root` configuration.
628
+
629
+
630
+ **Returns** `Promise<PreprosResult>`:
631
+
632
+ ```ts
633
+ interface PreprosResult {
634
+ success: boolean;
635
+ files: string[]; // relative paths of every file written
636
+ error?: string; // present only on failure
637
+ }
638
+ ```
639
+
640
+ ### `sitemap()`
641
+
642
+ Generate `sitemap.xml` at the source root.
643
+
644
+ ```js
645
+ const result = await sitemap();
646
+ // result.files === ['src/sitemap.xml']
647
+ ```
648
+
649
+ ### `runenv(script, paths?, ...args)`
650
+
651
+ 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.
652
+
653
+ ```js
654
+ // Run a standalone PHP script
655
+ const result = await runenv('scripts/purge-cache.php');
656
+
657
+ // Also mount extra local paths/files into the sandbox before running
658
+ const result = await runenv('scripts/build-og-images.php', ['assets/photos']);
659
+
660
+ // Extra arguments are appended and available as $argv[2], $argv[3], … in the script
661
+ const result = await runenv('scripts/import.php', [], '--force');
662
+ ```
663
+
664
+ - `script` — path to a PHP file **inside the project**, executed with `require_once`.
665
+ - `paths` — optional array of extra local paths (files or directories) to mount into the sandbox before the script runs.
666
+ - `...args` — extra string arguments appended to the script's `$argv`.
667
+
668
+ **Returns** `Promise<PreprosResult>`, following the same shape as `render()`. Inside the script, call `PREPROS::exportFile()` for any file you want listed in `result.files`.
669
+
670
+ ### `mountPath(localPath, virtualDir?, php?)`
671
+
672
+ 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.
673
+
674
+ ```js
675
+ import { mountPath, render } from '@kirigami/php-prepros';
676
+
677
+ // Mount a single file at its natural virtual path (/project/<relative path>)
678
+ await mountPath('assets/data/team.yaml');
679
+
680
+ // Mount a whole directory, at a custom virtual path
681
+ await mountPath('vendor/fonts', '/project/fonts');
682
+
683
+ await render();
684
+ ```
685
+
686
+ - `localPath` — path to a local file or directory. Relative paths are resolved against the project root.
687
+ - `virtualDir` — optional destination path inside the WASM filesystem. Defaults to `/project/<localPath relative to the project root>` when omitted.
688
+ - `php` — optional WASM PHP instance to mount into. Defaults to the shared singleton instance (the same one used internally by `render()`/`sitemap()`/`runenv()`), creating it if needed.
689
+
690
+ 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.
691
+
692
+ **Returns** `Promise<void>`.
693
+
694
+ ### `processImages(jobs)`
695
+
696
+ Run a batch of image jobs — resize/encode, or palette extraction — through the
697
+ [`IMG`](#img) class (GD, with the Imagick fallback). This is the engine
698
+ `@kirigami/kirigami`'s `sass` task uses for its `img-asset()` and `colors()`
699
+ functions, so Sass, `IMG::asset()` and the [`<img asset>` tag](#built-in-tags)
700
+ all share one implementation, one `image:` config and one set of output
701
+ filenames — with no native image dependency.
702
+
703
+ ```js
704
+ import { processImages } from '@kirigami/php-prepros';
705
+
706
+ const { files, colors } = await processImages([
707
+ // resize/encode `hero.jpg` (resolved against image.source) to each dest —
708
+ // absolute virtual paths, already carrying the target extension
709
+ { op: 'resize', src: 'hero.jpg', width: 1200, height: 0, cover: false, quality: 82,
710
+ dests: ['/project/src/images/hero-1200w.webp'] },
711
+
712
+ // extract a 5-colour palette (cached in .cache.db); returned, not written
713
+ { op: 'palette', src: 'hero.jpg', count: 5 },
714
+ ]);
715
+
716
+ // files → ['src/images/hero-1200w.webp'] (also copied back to the host)
717
+ // colors → { 'hero.jpg:5': ['#1e3a5f', '#c8a24b', …] }
718
+ ```
719
+
720
+ - `jobs` — array of `resize` / `palette` jobs (see the shape above). An **empty
721
+ array is a no-op** and does **not** start the WASM runtime.
722
+ - Staleness is the caller's responsibility: every `resize` job listed is executed.
723
+
724
+ **Returns** `Promise<PreprosResult & { colors: Record<string, string[]> }>`.
725
+
726
+ ---
727
+
728
+ ## PHP classes reference
729
+
730
+ All classes are autoloaded — no manual `require` needed inside your page files.
731
+
732
+ ---
733
+
734
+ ### PREPROS
735
+
736
+ The core engine. Manages the rendering pipeline, tag processing, hooks, mounting, and file export.
737
+
738
+ ```php
739
+ // Available inside page templates and included files.
740
+ PREPROS::$config // stdClass — full resolved config; ->data is the kirigami: block,
741
+ // ->image the image: block, plus before/after/format/… from prepros:
742
+ PREPROS::registerTag(string $tag, callable $callback)
743
+ PREPROS::registerHook(string $hook, callable $callback)
744
+ PREPROS::runHook(string $hook, mixed $data = null) // fire a hook (built-in or your own), returns the piped $data
745
+ PREPROS::mount(string|array $patterns)
746
+ PREPROS::exportFile(string|array $absolutePath)
747
+ PREPROS::getExportedFiles(): string[]
748
+ PREPROS::fstat(string $path) // stat a file in the WASM FS (or false)
749
+ PREPROS::backtraceFile() // path of the page currently rendering
750
+ ```
751
+
752
+ #### `PREPROS::render(string $file)`
753
+
754
+ Internal method called once per source file. Orchestrates the full pipeline:
755
+
756
+ 1. Resolves PHPDOC metadata and auto-loads data files.
757
+ 2. Fires the `pre_render` hook with the raw source contents.
758
+ 3. Includes `before.php` (wrapped in the `pre_before` / `post_before` hooks), the page body (or `@content`, see [above](#content-and-indent)), and `after.php` (wrapped in `pre_after` / `post_after`) into a single string.
759
+ 4. Processes all registered custom HTML tags.
760
+ 5. Fires the `post_render` hook on the assembled HTML.
761
+ 6. Optionally pretty-prints via `HTML::format()` (when `format: true`).
762
+ 7. Writes the output `.html` file.
763
+
764
+ #### `PREPROS::sitemap()`
765
+
766
+ 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.
767
+
768
+ #### `PREPROS::mount(string|array $patterns)`
769
+
770
+ 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.
771
+
772
+ ```php
773
+ // Mount every .webp under assets/, wherever the page needs them
774
+ PREPROS::mount('assets/**/*.webp');
775
+
776
+ // Multiple patterns at once
777
+ PREPROS::mount(['data/**/*.csv', 'vendor/fonts/*.woff2']);
778
+ ```
779
+
780
+ Returns an array of the virtual paths (under `/project/...`) that were mounted, or `false` on failure.
781
+
782
+ #### `PREPROS::exportFile(string $file)`
783
+
784
+ 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.
785
+
786
+ ---
787
+
788
+ ### MD
789
+
790
+ Markdown-to-HTML converter with a plugin system for custom shortcodes.
791
+
792
+ ```php
793
+ $html = MD::toHtml(string $markdown): string;
794
+ ```
795
+
796
+ Supports the full GitHub Flavored Markdown subset, plus a few extensions:
797
+
798
+ - ATX (`#` … `######`) and Setext headings, with auto-generated `id` attributes
799
+ - Ordered and unordered lists, including nested
800
+ - GFM task lists (`- [ ]` / `- [x]`)
801
+ - GFM tables with column alignment
802
+ - GFM alerts (`> [!NOTE]`, `> [!WARNING]`, etc.)
803
+ - Blockquotes (recursive)
804
+ - Fenced code blocks with language class
805
+ - Inline code
806
+ - Bold, italic, bold+italic, strikethrough
807
+ - Links with automatic `target="_blank" rel="noopener noreferrer"` for external URLs
808
+ - Images with `loading="lazy"`
809
+ - Auto-linked bare URLs
810
+ - Horizontal rules
811
+ - Hard line breaks (trailing double space → `<br>`)
812
+ - **Footnotes** — `[^1]` references and `[^1]: …` definitions (multi-paragraph)
813
+ - **Definition lists** — `Term` / `: Definition`
814
+ - **Emoji shortcodes** — `:rocket:` → 🚀, from a built-in map (see `MD::registerEmoji()`)
815
+ - **Sanitized inline HTML** — raw tags are filtered against an allowlist of tags and attributes, not passed through verbatim
816
+
817
+ #### Plugin API
818
+
819
+ Extend Markdown with custom shortcode tags:
820
+
821
+ ```php
822
+ // Inline tag {% tagname arg1 "arg with spaces" %}
823
+ // Block tag {% tagname arg1
824
+ // body content
825
+ // %}
826
+
827
+ MD::registerPlugin(string $name, callable $callback): void
828
+ MD::unregisterPlugin(string $name): void
829
+ MD::getRegisteredPlugins(): string[]
830
+ MD::registerEmoji(string $shortcode, string $char): void // `:name:` → char
831
+ ```
832
+
833
+ The callback always receives `(array $args, string $body)`:
834
+
835
+ ```php
836
+ MD::registerPlugin('video', function (array $args, string $body): string {
837
+ $src = htmlspecialchars($args[0] ?? '', ENT_QUOTES, 'UTF-8');
838
+ return "<video src=\"{$src}\" controls></video>";
839
+ });
840
+ ```
841
+
842
+ Then in any Markdown content (including inside `<markdown>` tags):
843
+
844
+ ```
845
+ {% video /videos/intro.mp4 %}
846
+ ```
847
+
848
+ ---
849
+
850
+ ### HTML
851
+
852
+ Pretty-printer for the final HTML output. Used automatically when `format: true` is set in the config.
853
+
854
+ ```php
855
+ $formatted = HTML::format(string $html): string;
856
+ ```
857
+
858
+ 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.
859
+
860
+ ---
861
+
862
+ ### YAML
863
+
864
+ A lightweight, zero-dependency YAML parser. Covers the full subset used in static site projects.
865
+
866
+ ```php
867
+ $data = YAML::parse(string $yaml, bool $assoc = false): mixed;
868
+ $data = YAML::parseFile(string $path, bool $assoc = false): mixed;
869
+ $data = YAML::loadFile(string $path, bool $assoc = false): mixed;
870
+ ```
871
+
872
+ Supported features:
873
+
874
+ - Scalars: strings (quoted and unquoted), integers, floats, booleans, null
875
+ - Single and double quoted strings with escape sequences
876
+ - Literal block scalars (`|`, `|-`, `|+`)
877
+ - Folded block scalars (`>`, `>-`, `>+`)
878
+ - Plain scalars spanning multiple lines
879
+ - Nested mappings and sequences
880
+ - Inline collections (`[a, b]` and `{k: v}`)
881
+ - Comments (`#`)
882
+ - Multiple documents separated by `---`
883
+
884
+ By default, YAML mappings are returned as `stdClass` objects. Pass `true` as the second argument to get associative arrays instead.
885
+
886
+ `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`.
887
+
888
+ ```yaml
889
+ # team.yaml
890
+ lead: people/jane.yaml # resolved and inlined automatically
891
+ members:
892
+ - people/jane.yaml
893
+ - people/john.yaml
894
+ ```
895
+
896
+ ```php
897
+ $team = YAML::loadFile('/project/data/team.yaml');
898
+ // $team->lead is now the fully parsed content of people/jane.yaml, not a string
899
+ ```
900
+
901
+ ---
902
+
903
+ ### SCHEMA
904
+
905
+ A pure-PHP, dependency-free JSON Schema validator — Draft-7 style, with an
906
+ Ajv-like API. Used internally to validate structured data, but available to your
907
+ own code and plugins.
908
+
909
+ ```php
910
+ $validator = new SCHEMA(array $schema);
911
+
912
+ $validator->isValid(mixed $data): bool // true / false
913
+ $validator->validate(mixed $data): bool // alias of isValid()
914
+ $validator->getErrors(): string[] // "path: message" strings from the last run
915
+ ```
916
+
917
+ Supported keywords: `type`, `required`, `properties`, `patternProperties`,
918
+ `additionalProperties`, `items`, `minItems`, `maxItems`, `uniqueItems`,
919
+ `minLength`, `maxLength`, `pattern`, `minimum`, `maximum`, `exclusiveMinimum`,
920
+ `exclusiveMaximum`, `minProperties`, `maxProperties`, `enum`, `const`,
921
+ `anyOf`, `allOf`, `oneOf`, `not`, `format`, and local `$ref` pointers.
922
+
923
+ ```php
924
+ $validator = new SCHEMA([
925
+ 'type' => 'object',
926
+ 'required' => ['name', 'age'],
927
+ 'properties' => [
928
+ 'name' => ['type' => 'string', 'minLength' => 1],
929
+ 'age' => ['type' => 'integer', 'minimum' => 0],
930
+ ],
931
+ 'additionalProperties' => false,
932
+ ]);
933
+
934
+ if (!$validator->isValid($data)) {
935
+ foreach ($validator->getErrors() as $err) echo $err, PHP_EOL;
936
+ }
937
+ ```
938
+
939
+ ---
940
+
941
+ ### LD
942
+
943
+ A **schema.org JSON-LD generator**. `LD` accumulates structured-data nodes for
944
+ the page under render and emits them as one
945
+ `<script type="application/ld+json">` block — with an `@graph` when there is more
946
+ than one node — in the `<head>`.
947
+
948
+ #### Automatic mode
949
+
950
+ Opt in by adding a top-level `jsonld:` block to `kirigami.yaml` (a sibling of
951
+ `kirigami:`, not nested under it) — an empty `jsonld: {}` is enough. A
952
+ `post_render` hook then injects a graph built from that block, the loose keys of
953
+ the `kirigami` block, and the current page's PHPDOC:
954
+
955
+ - an `Organization` node (`@id` `#organization`) — `name`/`url`/`description`
956
+ from `project`/`baseurl`/`description`, `sameAs` gathered from every
957
+ recognised social-network URL key (`facebook`, `instagram`, `linkedin`,
958
+ `github`, `youtube`, `mastodon`, …), plus `email`, `telephone`, `areaServed`
959
+ (← `area`), `knowsAbout` (← `knowsabout`), `address`, `logo`, and `founder` →
960
+ the Person node when there is one. `@type` comes from `jsonld.type`;
961
+ - a `Person` node (`#person`) when `person` is set — `name` + `jobTitle`
962
+ (← `jobtitle`) + `email` + `url`, linked to the Organization via `worksFor`;
963
+ - a `WebSite` node (`#website`) — `publisher` → Organization, `inLanguage`,
964
+ `keywords` (← `keywords`), and a `SearchAction` when `jsonld.search` is set;
965
+ - a `WebPage` node for the page — see the per-page tags below;
966
+ - a `BreadcrumbList` for every non-home page, derived from the `_index.php`
967
+ ancestor trail (home → each parent section → this page). No `@breadcrumb`
968
+ opt-in needed — it is always attempted while the `jsonld:` block is on.
969
+ Disable it for one page with `@ld_breadcrumb false`.
970
+
971
+ Remove the `jsonld:` block (or set `jsonld: false` / `jsonld: { auto: false }`)
972
+ to stop the automatic pass. A page whose rendered `<head>` already contains an
973
+ `application/ld+json` script is never touched, so hand-rolled markup keeps
974
+ working.
975
+
976
+ **Per-page PHPDOC tags** — these feed the page node (and override the generic
977
+ `@title` / `@description` / `@datePublished` fallbacks):
978
+
979
+ | Tag | Effect |
980
+ |-----|--------|
981
+ | `@ld false` | Skip JSON-LD for this page entirely (`@ld_ignore true` also works). |
982
+ | `@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`. |
983
+ | `@ld_title <text>` | Page node `name` (default: `@title`). |
984
+ | `@ld_description <text>` | Page node `description` (default: `@description`). |
985
+ | `@ld_image <path>` | Page image, absolute or relative to `baseurl` (default: `@image` / `@ogimage`). |
986
+ | `@ld_published <date>` | `datePublished` (default: `@datePublished` / `@published` / `@date`). |
987
+ | `@ld_modified <date>` | `dateModified` (default: `@dateModified` / `@modified` / `@updated`). |
988
+ | `@ld_breadcrumb false` | No `BreadcrumbList` for this page. |
989
+
990
+ ```php
991
+ /**
992
+ * @title À propos
993
+ * @ld_type AboutPage
994
+ * @ld_title À propos de Humain Humain
995
+ * @ld_description Notre approche ethnographique de la consultation.
996
+ */
997
+ ```
998
+
999
+ #### Explicit builders
1000
+
1001
+ Call these from a page template or from a `prepros.includes` file. Nodes added
1002
+ this way are always emitted — with or without a `jsonld:` block — and share the
1003
+ graph the automatic pass uses, so the two combine; a node with a stable `@id` is
1004
+ merged on repeat calls.
1005
+
1006
+ ```php
1007
+ LD::add(string|array $type, array $props = [], ?string $id = null): array // build + register a node
1008
+ LD::node(string|array $type, array $props = []): array // build only, no register
1009
+ LD::push(array $node): array // register a ready-made node
1010
+ LD::ref(string $id): array // ['@id' => …] ('#person' → the Person node)
1011
+ LD::remove(string $id): void
1012
+ LD::graph(): array
1013
+ LD::reset(): void
1014
+
1015
+ LD::organization(array $overrides = []): array // config-aware, @id #organization
1016
+ LD::person(array $overrides = []): array // config-aware, @id #person
1017
+ LD::website(array $overrides = []): array // config-aware, @id #website
1018
+ LD::webPage(array $overrides = []): array // current-page-aware, @id …#webpage
1019
+ LD::breadcrumb(?array $items = null, array $overrides = []): array // items: [['name'=>…,'url'=>…], …]
1020
+ LD::faqPage(array $qa, array $overrides = []): array // qa: ['Question ?' => 'Answer.', …]
1021
+
1022
+ LD::address(array|string $a): array
1023
+ LD::image(string $url, ?string $id = null, ?int $w = null, ?int $h = null): array
1024
+ LD::geo(float $lat, float $lng): array
1025
+ LD::rating(int|float $value, ?int $count = null, $best = 5, $worst = 1): array
1026
+ LD::offer(array $o): array
1027
+ LD::contactPoint(array $c): array
1028
+ LD::searchAction(string $urlTemplate): array
1029
+
1030
+ LD::script(bool $pretty = true): string // <script>…</script>, and disables auto-injection
1031
+ LD::json(bool $pretty = true): string // the document, no wrapper
1032
+ ```
1033
+
1034
+ Every other schema.org type is reachable through `__callStatic` — the method
1035
+ name is upper-cased to form the `@type`:
1036
+
1037
+ ```php
1038
+ LD::recipe([ 'name' => 'Tarte aux pommes', 'recipeYield' => '6', 'prepTime' => 'PT30M' ]);
1039
+ LD::event([ 'name' => 'Vernissage', 'startDate' => '2026-10-01T18:00' ]);
1040
+ LD::softwareApplication([ 'name' => 'Kirigami', 'applicationCategory' => 'DeveloperApplication' ]);
1041
+
1042
+ LD::article([
1043
+ 'headline' => $title,
1044
+ 'datePublished' => '2026-09-01',
1045
+ 'author' => LD::ref('#person'),
1046
+ 'image' => LD::image('images/cover.webp'),
1047
+ 'publisher' => LD::ref('#organization'),
1048
+ ]);
1049
+ ```
1050
+
1051
+ Same API from procedural code: `ld_add()`, `ld_node()`, `ld_ref()`,
1052
+ `ld_organization()`, `ld_person()`, `ld_website()`, `ld_web_page()`,
1053
+ `ld_breadcrumb()`, `ld_faq_page()`, `ld_script()`, `ld_json()`.
1054
+
1055
+ #### `jsonld` config
1056
+
1057
+ `jsonld:` is a **top-level** block of `kirigami.yaml` (a sibling of `kirigami:`,
1058
+ `prepros:`, …), and its presence is what **switches automatic injection on**. An
1059
+ empty `jsonld: {}` is enough — everything is then derived from the `kirigami`
1060
+ block's loose keys. Adding keys overrides those inferences; all are optional.
1061
+
1062
+ ```yaml
1063
+ kirigami:
1064
+ project: Humain Humain
1065
+ baseurl: https://humainhumain.com
1066
+ person: Méralie Murray-Hall
1067
+ jobtitle: Anthropologue
1068
+ facebook: https://www.facebook.com/humainhumainconsultation.ethnographie/
1069
+
1070
+ jsonld: # top-level; the block being present is
1071
+ type: ProfessionalService # the switch — `jsonld: {}` also works
1072
+ lang: fr-CA # inLanguage on WebSite / WebPage (default: en)
1073
+ logo: assets/logo.png # absolute, or relative to baseurl
1074
+ knowsAbout: [Ethnographie, Recherche qualitative]
1075
+ address:
1076
+ addressLocality: Québec
1077
+ addressCountry: CA
1078
+ search: https://humainhumain.com/?q={search_term_string}
1079
+ ```
1080
+
1081
+ | Key | Type | Description |
1082
+ |-----|------|-------------|
1083
+ | `auto` | `bool` | Inject the `<script>` automatically. Default `true` **once the `jsonld:` block exists**. Set `auto: false` (or `jsonld: false`) to keep the block for its config values but stop the automatic injection — `LD::script()` / `ld_script()` can still place it by hand. |
1084
+ | `type` | `string` | `@type` for the main entity — `Organization`, `ProfessionalService`, `LocalBusiness`, … |
1085
+ | `name` / `url` / `description` | `string` | Main-entity / WebSite fields. Default to `project` / `baseurl` / `description`. |
1086
+ | `logo` / `image` | `string` | Absolute URL or path relative to `baseurl`. `image` defaults to `logo`. |
1087
+ | `sameAs` | `string[]` | Profile URLs, merged with the social-network URL keys found loose in the block. |
1088
+ | `email` / `telephone` | `string` | Default to the loose `email` / `telephone` keys. |
1089
+ | `address` | `map` | `PostalAddress` properties. |
1090
+ | `areaServed` | `string` | Defaults to the loose `area` key. |
1091
+ | `knowsAbout` / `keywords` | `string[]` | Default to the loose `knowsabout` / `keywords` keys. |
1092
+ | `person` | `string` \| `map` | The `#person` node. A string is the name; a map takes any `Person` property. Defaults to `person` + `jobtitle` + `email`. |
1093
+ | `lang` | `string` | BCP-47 tag for `inLanguage`. Default `en`. |
1094
+ | `search` | `string` | URL template for a sitelinks `SearchAction`; must contain `{search_term_string}`. |
1095
+
1096
+ ---
1097
+
1098
+ ### CACHE
1099
+
1100
+ Persistent SQLite-backed key-value cache. Survives across incremental builds via `.cache.db` at the project root.
1101
+
1102
+ ```php
1103
+ CACHE::get(string $key): mixed
1104
+ CACHE::set(string $key, mixed $val, int $ttl = 0): bool
1105
+ CACHE::delete(string $key): bool
1106
+ CACHE::purge(): bool // removes expired entries
1107
+ ```
1108
+
1109
+ The `$ttl` is in seconds. `0` means the entry never expires. Typical use case: caching the result of network fetches in custom hooks or plugins — it is what powers [`SCRAPER`](#scraper) and `CURL`'s cookie persistence internally.
1110
+
1111
+ ```php
1112
+ $data = CACHE::get('my-remote-data');
1113
+ if ($data === null) {
1114
+ $data = json_decode(file_get_contents('https://api.example.com/data.json'));
1115
+ CACHE::set('my-remote-data', $data, 3600); // cache for 1 hour
1116
+ }
1117
+ ```
1118
+
1119
+ ---
1120
+
1121
+ ### IMG
1122
+
1123
+ Image manipulation helper. GD handles JPEG, PNG, GIF, WebP and AVIF directly;
1124
+ anything GD can't decode (HEIC, TIFF, BMP, and the vector formats SVG, EPS, AI,
1125
+ PDF) falls back to Imagick, which rasterizes it to a GD image in memory. Vector
1126
+ files with no intrinsic pixel size are rasterized at 2000&nbsp;px on the longest
1127
+ side, preserving the aspect ratio.
1128
+
1129
+ ```php
1130
+ $img = new IMG(string $file);
1131
+
1132
+ // Properties
1133
+ $img->width // int
1134
+ $img->height // int
1135
+
1136
+ // Instance methods (resize/save are chainable)
1137
+ $img->resize(int $width, int $height = 0, bool $cover = false): self
1138
+ $img->save(string $dest, ?int $quality = null): self // quality 0-100 for jpg/webp/avif; null = per-format default (82)
1139
+ $img->getRepresentativeColors(int $count = 5): string[] // ['#rrggbb', …]
1140
+
1141
+ // Static helpers
1142
+ 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
1143
+ IMG::palette(string $path, int $colors = 5): string[]
1144
+ ```
1145
+
1146
+ `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.
1147
+
1148
+ `save()` infers the output format from the file extension (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`) and marks the file as a build output.
1149
+
1150
+ ```php
1151
+ // Build a 1200×630 cropped Open Graph image next to the original
1152
+ (new IMG('/project/src/images/hero.jpg'))
1153
+ ->resize(1200, 630, true)
1154
+ ->save('/project/src/images/hero-og.jpg');
1155
+ ```
1156
+
1157
+ `IMG::asset()` and `IMG::palette()` are what back kirigami-core's `img-asset()`
1158
+ and `colors()` Sass functions: they resolve `$path` against `image.source` from
1159
+ `kirigami.yaml`, generate a resized/re-encoded file under `image.dest` (only
1160
+ when missing or stale), or return a `CACHE`-backed list of representative
1161
+ colours. Both are equally usable from your own PHP.
1162
+
1163
+ `IMG::asset()` is the single implementation behind the [`<img asset>` tag](#built-in-tags)
1164
+ too — the tag is just a thin wrapper. Generated files are named after the source
1165
+ plus a dimension suffix: `-<W>w`, `-<H>h`, `-<W>x<H>`, or `-<W>x<H>-cover`, with
1166
+ the `image.format` extension (e.g. `hero.jpg` + `width="800"` → `hero-800w.webp`).
1167
+ `@kirigami/kirigami`'s Sass `img-asset()` / `colors()` functions produce the same
1168
+ files from the same config through this same class, via
1169
+ [`processImages()`](#processimagesjobs) — one engine, no native dependency.
1170
+
1171
+ ---
1172
+
1173
+ ### FS
1174
+
1175
+ Filesystem utilities.
1176
+
1177
+ ```php
1178
+ FS::dig(string $glob): iterable // recursive glob, yields file paths
1179
+ FS::getRelativePath(string $from, string $to): string
1180
+ FS::phpFileInfo(string $file): object|false // parse PHPDOC annotations
1181
+ FS::getChildren(string $backtrace = ''): object[] // child _index.php pages, ordered by @position
1182
+ FS::getBreadcrumb(string $backtrace = ''): object[] // ancestor _index.php pages, top-most first (opt-in via @breadcrumb)
1183
+ FS::rmdir(string $dir, bool $removeSelf = true): bool
1184
+ FS::pathJoin(string ...$parts): string // URL-aware path join with .. resolution
1185
+ ```
1186
+
1187
+ `FS::dig()` is the workhorse of directory-wide builds — it recursively walks a glob pattern and yields every matching file path.
1188
+
1189
+ `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.
1190
+
1191
+ `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:
1192
+
1193
+ ```php
1194
+ <?php foreach (fs_get_children() as $page): ?>
1195
+ <li><a href="<?= FS::getRelativePath(__DIR__, dirname($page->file)) ?>/"><?= $page->title ?></a></li>
1196
+ <?php endforeach ?>
1197
+ ```
1198
+
1199
+ `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):
1200
+
1201
+ ```php
1202
+ <nav aria-label="Breadcrumb">
1203
+ <?php foreach (fs_get_breadcrumb() as $crumb): ?>
1204
+ <a href="<?= FS::getRelativePath(__DIR__, dirname($crumb->file)) ?>/"><?= $crumb->title ?></a>
1205
+ <?php endforeach ?>
1206
+ </nav>
1207
+ ```
1208
+
1209
+ ---
1210
+
1211
+ ### STR
1212
+
1213
+ String utilities used internally by the tag-processing pipeline, and available for your own templates and plugins.
1214
+
1215
+ ```php
1216
+ STR::htmlesc(string $str): string
1217
+ STR::replaceTags(string $tag, string $html, callable $callback): string
1218
+ STR::parseHtmlAttributes(string $attrString): array
1219
+ STR::trimIndent(string $str): string
1220
+ STR::is_url(string $str): bool
1221
+ STR::html_entities_decode(string $str): string
1222
+ STR::shorthash(string $str): string
1223
+ STR::normalize(string $str): string
1224
+ STR::slug(string $str, string $sep = ''): string
1225
+ ```
1226
+
1227
+ `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)`.
1228
+
1229
+ `STR::trimIndent()` strips the common leading whitespace from a multi-line string — handy when pulling content out of indented `<markdown>` blocks.
1230
+
1231
+ `STR::is_url()` checks whether a string parses as a URL with a recognized scheme (`http`, `https`, `ftp`, `ftps`, `ssh`, `ssl`, `sftp`, `itunes`).
1232
+
1233
+ `STR::html_entities_decode()` trims a string and decodes its HTML entities — handy when normalizing text scraped from a third-party page.
1234
+
1235
+ `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`).
1236
+
1237
+ `STR::normalize()` applies Unicode NFD decomposition and strips combining marks (`é` → `e`) — the accent-folding step used by `slug()`.
1238
+
1239
+ `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).
1240
+
1241
+ ---
1242
+
1243
+ ### ARR
1244
+
1245
+ Recursive lookup helper for nested arrays and objects.
1246
+
1247
+ ```php
1248
+ ARR::find_key(mixed $data, string $key): mixed
1249
+ ```
1250
+
1251
+ 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.
1252
+
1253
+ ```php
1254
+ $config = YAML::parseFile('team.yaml');
1255
+ $email = ARR::find_key($config, 'email'); // finds `email` however deep it's nested
1256
+ ```
1257
+
1258
+ ---
1259
+
1260
+ ### CURL
1261
+
1262
+ 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()`).
1263
+
1264
+ ```php
1265
+ CURL::urlExists(string $url, ?string $mimereg = null): bool
1266
+ CURL::getInfo(string $url): array|false // HEAD request, returns curl_getinfo()
1267
+ CURL::getContents(string $file, ?string $dest = null, ?callable $clb = null): string|bool
1268
+ ```
1269
+
1270
+ `CURL::urlExists()` issues a `HEAD` request and returns `true` for any `2xx`/`3xx` response.
1271
+
1272
+ `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`.
1273
+
1274
+ ```php
1275
+ CURL::getContents('https://example.com/report.pdf', '/project/src/downloads/report.pdf', function (float $progress) {
1276
+ error_log(sprintf('%.0f%%', $progress * 100));
1277
+ });
1278
+ ```
1279
+
1280
+ ---
1281
+
1282
+ ### SCRAPER
1283
+
1284
+ 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.
1285
+
1286
+ ```php
1287
+ $metas = SCRAPER::get(string $url): object|false;
1288
+ ```
1289
+
1290
+ ```php
1291
+ $metas = SCRAPER::get('https://example.com/blog/some-article');
1292
+ if ($metas) {
1293
+ echo $metas->title; // string
1294
+ echo $metas->description; // string
1295
+ echo $metas->image; // string (absolute URL, may be empty)
1296
+ echo $metas->label; // string — site/publisher name, may be empty
1297
+ echo $metas->url; // string — the URL that was scraped
1298
+ }
1299
+ ```
1300
+
1301
+ 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.
1302
+
1303
+ ---
1304
+
1305
+ ### OBF
1306
+
1307
+ Simple reversible obfuscation for values you want to embed in HTML without making them trivially readable (e.g., contact data, API tokens in templates).
1308
+
1309
+ ```php
1310
+ $encoded = OBF::encode(mixed $obj): string;
1311
+ $decoded = OBF::decode(string $str): mixed;
1312
+ ```
1313
+
1314
+ Applies JSON encoding → base64 → ROT-13 → gzip. Not cryptographically secure; intended for light obfuscation only.
1315
+
1316
+ ---
1317
+
1318
+ ### STD
1319
+
1320
+ Output helpers used by the PHP runtime to communicate back to Node.js over stdout/stderr.
1321
+
1322
+ ```php
1323
+ STD::succeed(array|string $props = []): void // exits 0, writes JSON to stdout
1324
+ STD::error(array|string $props = []): void // exits 1, writes JSON to stderr
1325
+ ```
1326
+
1327
+ 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.
1328
+
1329
+ ---
1330
+
1331
+ ### Bundled polyfills
1332
+
1333
+ The WASM PHP build ships without `ext-intl`, so `@kirigami/php-prepros` bundles a
1334
+ `Normalizer` polyfill (autoloaded like every other class). It provides the
1335
+ standard `Normalizer::normalize()` / `Normalizer::isNormalized()` API and the
1336
+ `Normalizer::NFC` / `NFD` / `NFKC` / `NFKD` (and `FORM_*`) constants — enough for
1337
+ `STR::normalize()` and `STR::slug()` to fold accents. Prefer the `STR` helpers in
1338
+ your own code; the polyfill is there so third-party snippets that call
1339
+ `Normalizer` directly keep working.
1340
+
1341
+ ---
1342
+
1343
+ ### Procedural shortcuts (aliases)
1344
+
1345
+ Every static method of every class above is also exposed as a plain function by
1346
+ `src/libraries/aliases.inc.php` (autoloaded — no `require` needed). Each alias is
1347
+ named `<lowercase class>_<snake_case method>()` and does nothing but forward its
1348
+ arguments, so the classes remain the canonical API. They exist to make page
1349
+ templates and `kiri run` scripts read better:
1350
+
1351
+ ```php
1352
+ <?= md_to_html(file_get_contents('CHANGELOG.md')) ?>
1353
+ <img src="<?= img_asset('hero.jpg', 1200, 630, true) ?>" alt="">
1354
+ ```
1355
+
1356
+ Every function has a complete PHPDoc block, so editor hover and autocomplete
1357
+ surface the signature, parameters, and description.
1358
+
1359
+ | Class | Aliases |
1360
+ |---|---|
1361
+ | `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` |
1362
+ | `MD` | `md_to_html` · `md_register_plugin` · `md_unregister_plugin` · `md_get_registered_plugins` · `md_register_emoji` |
1363
+ | `HTML` | `html_format` |
1364
+ | `YAML` | `yaml_parse` · `yaml_parse_file` · `yaml_load_file` |
1365
+ | `SCHEMA` | `schema` (factory) · `schema_validate` |
1366
+ | `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` |
1367
+ | `CACHE` | `cache_get` · `cache_set` · `cache_delete` · `cache_purge` |
1368
+ | `IMG` | `img_asset` · `img_palette` |
1369
+ | `FS` | `fs_dig` · `fs_get_relative_path` · `fs_php_file_info` · `fs_rmdir` · `fs_path_join` |
1370
+ | `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` |
1371
+ | `ARR` | `arr_find_key` |
1372
+ | `CURL` | `curl_url_exists` · `curl_get_info` · `curl_get_contents` |
1373
+ | `SCRAPER` | `scraper_get` |
1374
+ | `OBF` | `obf_encode` · `obf_decode` |
1375
+ | `STD` | `std_succeed` · `std_error` |
1376
+
1377
+ Notes:
1378
+
1379
+ - `register_tag()` / `register_hook()` are kept as unprefixed aliases of
1380
+ `prepros_register_tag()` / `prepros_register_hook()`.
1381
+ - `img_asset()` resolves the generated URL relative to the file that calls it,
1382
+ exactly like `IMG::asset()`.
1383
+ - `schema_validate(array $schema, mixed $data, ?array &$errors = null): bool`
1384
+ fills `$errors` with the validation messages.
1385
+ - `yaml_parse()` / `yaml_parse_file()` are only defined when the PECL `yaml`
1386
+ extension isn't already providing them.
1387
+
1388
+ ---
1389
+
1390
+ ## Plugin system
1391
+
1392
+ `@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).
1393
+
1394
+ ---
1395
+
1396
+ ### PREPROS tags
1397
+
1398
+ Register a custom HTML tag that is processed **after** PHP execution, on the fully assembled HTML string:
1399
+
1400
+ ```php
1401
+ // In a file listed under prepros.includes in kirigami.yaml, or in before.php:
1402
+
1403
+ PREPROS::registerTag('gallery', function (string $fullTag, array $attrs, string $body): string {
1404
+ $id = $attrs['id'] ?? '';
1405
+ $imgs = glob("/project/src/images/gallery/{$id}/*.webp");
1406
+ $html = '<div class="gallery">';
1407
+ foreach ($imgs as $img) {
1408
+ $src = str_replace('/project/src', '', $img);
1409
+ $html .= "<img src=\"{$src}\" loading=\"lazy\">";
1410
+ }
1411
+ return $html . '</div>';
1412
+ });
1413
+ ```
1414
+
1415
+ Then in any page template:
1416
+
1417
+ ```html
1418
+ <gallery id="summer-2025"></gallery>
1419
+ ```
1420
+
1421
+ The callback receives:
1422
+
1423
+ | Parameter | Type | Description |
1424
+ |-----------|------|-------------|
1425
+ | `$fullTag` | `string` | The complete matched tag string |
1426
+ | `$attrs` | `array` | Parsed HTML attributes as an associative array |
1427
+ | `$body` | `string` | Inner content between opening and closing tags |
1428
+
1429
+ The built-in [`<markdown>` and `<img asset>` tags](#built-in-tags) are registered
1430
+ this way.
1431
+
1432
+ ---
1433
+
1434
+ ### PREPROS hooks
1435
+
1436
+ Hooks let you intercept and transform data at key points in the rendering pipeline:
1437
+
1438
+ ```php
1439
+ PREPROS::registerHook(string $hookName, callable $callback): void
1440
+ ```
1441
+
1442
+ | Hook | When it fires | `$data` type | Expected return |
1443
+ |------|---------------|--------------|-----------------|
1444
+ | `boot` | Once per process, right after bootstrap (config loaded, `includes` pulled in), before any page renders. Fires for every entrypoint. | `stdClass $config` | ignored |
1445
+ | `page_info` | After PHPDOC parsing, before rendering (auto-loads `.yaml`/`.json`/`.md` annotations) | `[$filePath, $pageObject]` — see note | `$pageObject` (modified) |
1446
+ | `pre_render` | Before PHP execution | Raw file contents as `string` | `string` |
1447
+ | `pre_before` | Just before the `before` include (inside its output buffer — `echo` to prepend to the header) | `before` config path as `string\|null` | ignored |
1448
+ | `post_before` | Right after the `before` include, on the captured header | Header `string` | `string` |
1449
+ | `pre_after` | Just before the `after` include (inside its output buffer — `echo` to prepend to the footer) | `after` config path as `string\|null` | ignored |
1450
+ | `post_after` | Right after the `after` include, on the captured footer | Footer `string` | `string` |
1451
+ | `post_render` | After tag processing, before `HTML::format()` | Assembled HTML `string` | `string` |
1452
+
1453
+ Multiple callbacks can be registered for the same hook — they are executed in registration order, each receiving the return value of the previous one.
1454
+
1455
+ > **`page_info` payload shape.** The hook *fires* with `[$filePath, $pageObject]`,
1456
+ > but each callback is expected to return the `$pageObject` alone — so a callback
1457
+ > registered after the built-ins receives the bare object, not the pair. Handle
1458
+ > both: `$page = is_array($p) ? $p[1] : $p;` (the current page path is always
1459
+ > available as `PREPROS::$file`).
1460
+
1461
+ ```php
1462
+ // Example: inject a last-modified date into every page
1463
+ PREPROS::registerHook('post_render', function (string $html): string {
1464
+ $date = date('Y-m-d');
1465
+ return str_replace('{{build_date}}', $date, $html);
1466
+ });
1467
+ ```
1468
+
1469
+ ---
1470
+
1471
+ ### MD plugins
1472
+
1473
+ MD plugins add custom shortcode tags inside Markdown content. They work inside `<markdown>` blocks, in `.md` data files, and anywhere `MD::toHtml()` is called.
1474
+
1475
+ **Inline syntax** (all on one line):
1476
+
1477
+ ```
1478
+ {% tagname arg1 "argument with spaces" %}
1479
+ ```
1480
+
1481
+ **Block syntax** (body on subsequent lines):
1482
+
1483
+ ```
1484
+ {% tagname optional-arg
1485
+ Line one of the body.
1486
+ Line two of the body.
1487
+ %}
1488
+ ```
1489
+
1490
+ ```php
1491
+ MD::registerPlugin(string $name, callable $callback): void
1492
+ ```
1493
+
1494
+ 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).
1495
+
1496
+ ---
1497
+
1498
+ ### Built-in plugins
1499
+
1500
+ The following MD plugins are registered out of the box in `md.plugins.php`:
1501
+
1502
+ #### `{% callout type ["Title"] content %}`
1503
+
1504
+ Renders a styled callout block. `type` is one of `info`, `success`, `warning`, `danger`.
1505
+
1506
+ ```
1507
+ {% callout warning "Heads up" This section is outdated. %}
1508
+
1509
+ {% callout danger "Critical"
1510
+ Line one of a longer warning.
1511
+
1512
+ Line two after a blank line.
1513
+ %}
1514
+ ```
1515
+
1516
+ #### `{% youtube id [width height] %}`
1517
+
1518
+ Embeds a responsive YouTube player via `<iframe>`. `width`/`height` default to `560`/`315`.
1519
+
1520
+ ```
1521
+ {% youtube dQw4w9WgXcQ %}
1522
+ {% youtube dQw4w9WgXcQ 800 450 %}
1523
+ ```
1524
+
1525
+ #### `{% codepen id [user height] %}`
1526
+
1527
+ Embeds a CodePen result via `<iframe>`. `user` defaults to `anonymous`, `height` defaults to `400`.
1528
+
1529
+ ```
1530
+ {% codepen abcXYZ %}
1531
+ {% codepen abcXYZ jsmith 500 %}
1532
+ ```
1533
+
1534
+ #### `{% checklist ["Title"] items %}`
1535
+
1536
+ Renders a block-syntax list of checkbox items, one per line, with an optional title.
1537
+
1538
+ ```
1539
+ {% checklist "Today"
1540
+ Do the dishes
1541
+ Walk the dog
1542
+ Read a book
1543
+ %}
1544
+ ```
1545
+
1546
+ ---
1547
+
1548
+ ## Extending the `<markdown>` tag
1549
+
1550
+ The `<markdown>` tag is one of the [built-in tags](#built-in-tags) (alongside
1551
+ `<img asset>`), registered as a PREPROS tag out of the box. It converts its inner
1552
+ content from Markdown to HTML and strips common leading indentation so you can
1553
+ write cleanly inside your PHP templates:
1554
+
1555
+ ```html
1556
+ <section class="about">
1557
+ <div>
1558
+ <markdown>
1559
+ ## Who we are
1560
+
1561
+ We are a **student organization** from Québec.
1562
+
1563
+ {% youtube dQw4w9WgXcQ %}
1564
+ </markdown>
1565
+ </div>
1566
+ </section>
1567
+ ```
1568
+
1569
+ 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:
1570
+
1571
+ ```php
1572
+ PREPROS::registerTag('markdown', function (string $tag, array $attrs, string $body): string {
1573
+ $body = STR::trimIndent($body);
1574
+ $html = MD::toHtml($body);
1575
+ // wrap in a container, add a class, etc.
1576
+ $class = $attrs['class'] ?? 'prose';
1577
+ return "<div class=\"{$class}\">{$html}</div>";
1578
+ });
1579
+ ```
1580
+
1581
+ ---
1582
+
1583
+ ## Requirements
1584
+
1585
+ - Node.js `>= 24.0.0`
1586
+ - npm `>= 10.2.3`
1587
+ - ESM only (`"type": "module"`)
1588
+
1589
+ ---
1590
+
1591
+ ## License
1592
+
1593
+ MIT © Maxime Larrivée-Roy, 2026