@kirigami/php-prepros 1.2.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,804 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * LD — schema.org JSON-LD generator.
7
+ *
8
+ * Collects schema.org nodes during a render and emits them as a single
9
+ * `<script type="application/ld+json">` block in the page `<head>`, built from
10
+ * the top-level `jsonld:` block of `kirigami.yaml` and the loose keys of the
11
+ * `kirigami:` block (`person`, `jobtitle`, `area`, `knowsabout`, `keywords`,
12
+ * `facebook`, … that Kirigami projects already use).
13
+ *
14
+ * Two ways to use it, and they combine:
15
+ *
16
+ * 1. Automatic — opt-in. As soon as `kirigami.yaml` carries a top-level
17
+ * `jsonld:` block (even an empty one, `jsonld: {}`), a `post_render` hook
18
+ * injects an `Organization` (+ `Person`, `WebSite`, `WebPage`, and a
19
+ * `BreadcrumbList` built from the `_index.php` ancestor trail) `@graph`
20
+ * derived from the block, the loose keys and the current page's PHPDOC.
21
+ * Without a `jsonld:` block nothing is injected. Turn it back off with
22
+ * `jsonld: false` (or `jsonld: { auto: false }`), or per page with
23
+ * `@ld false` in the template's PHPDOC. A page that already hand-writes an
24
+ * `application/ld+json` script is left untouched.
25
+ *
26
+ * Per-page PHPDOC tags feed the page node:
27
+ * @ld false skip JSON-LD for this page (or @ld_ignore true)
28
+ * @ld_type <Type> page node @type (AboutPage, ContactPage, Article, …)
29
+ * @ld_title <text> page node name (default @title)
30
+ * @ld_description … page node description (default @description)
31
+ * @ld_image <path> page image (default @image / @ogimage)
32
+ * @ld_published <d> datePublished (default @datePublished)
33
+ * @ld_modified <d> dateModified
34
+ * @ld_breadcrumb false no BreadcrumbList for this page
35
+ *
36
+ * 2. Explicit. Call the builders from a template or from an `includes` file.
37
+ * Nodes added this way are always emitted, `jsonld:` block or not:
38
+ *
39
+ * LD::add('Recipe', [ 'name' => 'Tarte', 'recipeYield' => '6' ]);
40
+ * LD::article([ 'headline' => $title, 'author' => LD::ref('#person') ]);
41
+ * LD::faqPage([ 'Question ?' => 'Réponse.' ]);
42
+ *
43
+ * Every schema.org type is reachable as `LD::typeName([...])` through
44
+ * `__callStatic` (`LD::musicAlbum([...])` → `{"@type":"MusicAlbum",…}`).
45
+ * Named builders (`organization`, `person`, `website`, `webPage`,
46
+ * `breadcrumb`, `faqPage`) additionally pre-fill config/context defaults.
47
+ *
48
+ * Nodes carrying a stable `@id` (`#organization`, `#website`, …) are merged on
49
+ * repeat calls, so a template can refine what the automatic pass produced.
50
+ */
51
+ final class LD
52
+ {
53
+ /** Known loose social keys collected into Organization.sameAs. */
54
+ private const SOCIAL = [
55
+ 'facebook', 'instagram', 'twitter', 'x', 'linkedin', 'github',
56
+ 'youtube', 'mastodon', 'threads', 'tiktok', 'bluesky', 'pinterest',
57
+ 'snapchat', 'soundcloud', 'bandcamp', 'vimeo', 'medium', 'behance',
58
+ 'dribbble', 'twitch',
59
+ ];
60
+
61
+ /** @var array<string|int,array<string,mixed>> Graph nodes for the current render, keyed by @id (or int). */
62
+ private static array $graph = [];
63
+
64
+ /** @var array{file:?string,info:object}|null Page context captured from the `page_info` hook. */
65
+ private static ?array $page = null;
66
+
67
+ /** True once script()/json() has run, so the injector does not emit twice. */
68
+ private static bool $emitted = false;
69
+
70
+ /** Normalized config, resolved once per process. */
71
+ private static ?object $config = null;
72
+
73
+
74
+ // -----------------------------------------------------------------------
75
+ // Configuration
76
+ // -----------------------------------------------------------------------
77
+
78
+ /**
79
+ * Resolved JSON-LD configuration, merging the `jsonld:` block with the
80
+ * loose top-level keys of the `kirigami:` block.
81
+ */
82
+ public static function config(): object
83
+ {
84
+ if (self::$config !== null) return self::$config;
85
+
86
+ $data = self::data();
87
+ $raw = self::jsonldRaw();
88
+ $j = is_object($raw) ? $raw : new stdClass;
89
+
90
+ // Automatic injection is opt-in: it needs a top-level `jsonld:` block (an
91
+ // empty map counts). `jsonld: false` / `jsonld: { auto: false }` turn it off.
92
+ $enabled = is_object($raw) || $raw === true;
93
+ if ($enabled && isset($j->auto) && !self::truthy($j->auto)) $enabled = false;
94
+
95
+ $sameAs = [];
96
+ if (isset($j->sameAs) && is_array($j->sameAs)) $sameAs = array_map('strval', $j->sameAs);
97
+ foreach ((array) $data as $k => $v) {
98
+ if (is_string($v) && STR::is_url($v) && in_array(strtolower((string) $k), self::SOCIAL, true)) {
99
+ $sameAs[] = $v;
100
+ }
101
+ }
102
+ $sameAs = array_values(array_unique($sameAs));
103
+
104
+ $person = null;
105
+ if (isset($j->person)) {
106
+ $person = is_object($j->person) ? (array) $j->person : ['name' => (string) $j->person];
107
+ } elseif (!empty($data->person)) {
108
+ $person = [
109
+ 'name' => (string) $data->person,
110
+ 'jobTitle' => $data->jobtitle ?? $data->jobTitle ?? null,
111
+ 'email' => $data->email ?? null,
112
+ ];
113
+ }
114
+ if (is_array($person)) {
115
+ if (isset($person['jobtitle']) && !isset($person['jobTitle'])) $person['jobTitle'] = $person['jobtitle'];
116
+ unset($person['jobtitle']);
117
+ $person = array_filter($person, fn($v) => $v !== null && $v !== '');
118
+ }
119
+
120
+ self::$config = (object) [
121
+ 'enabled' => $enabled,
122
+ 'type' => $j->type ?? 'Organization',
123
+ 'name' => $j->name ?? $data->project ?? null,
124
+ 'url' => rtrim((string) ($j->url ?? $data->baseurl ?? ''), '/') . '/',
125
+ 'description' => $j->description ?? $data->description ?? null,
126
+ 'logo' => self::absUrl($j->logo ?? null),
127
+ 'image' => self::absUrl($j->image ?? $j->logo ?? null),
128
+ 'sameAs' => $sameAs,
129
+ 'email' => $j->email ?? $data->email ?? null,
130
+ 'telephone' => $j->telephone ?? $data->telephone ?? $data->phone ?? null,
131
+ 'address' => isset($j->address) ? (is_object($j->address) ? (array) $j->address : $j->address) : null,
132
+ 'areaServed' => $j->areaServed ?? $data->area ?? null,
133
+ 'knowsAbout' => self::arr($j->knowsAbout ?? $data->knowsabout ?? $data->knowsAbout ?? null),
134
+ 'keywords' => self::arr($j->keywords ?? $data->keywords ?? null),
135
+ 'person' => $person ?: null,
136
+ 'lang' => $j->lang ?? $data->lang ?? $data->language ?? 'en',
137
+ 'search' => $j->search ?? null,
138
+ ];
139
+
140
+ return self::$config;
141
+ }
142
+
143
+
144
+ // -----------------------------------------------------------------------
145
+ // Graph primitives
146
+ // -----------------------------------------------------------------------
147
+
148
+ /**
149
+ * Builds a bare node — `['@type' => $type]` plus `$props`, recursively
150
+ * pruned of null / '' / [] values. Does not touch the graph.
151
+ *
152
+ * @param string|array<string> $type
153
+ * @param array<string,mixed> $props
154
+ * @return array<string,mixed>
155
+ */
156
+ public static function node(string|array $type, array $props = []): array
157
+ {
158
+ $node = [];
159
+ if ($type !== '' && $type !== []) $node['@type'] = $type;
160
+ if (isset($props['@id'])) { $node['@id'] = $props['@id']; unset($props['@id']); }
161
+ foreach ($props as $k => $v) $node[$k] = $v;
162
+
163
+ return self::clean($node);
164
+ }
165
+
166
+ /**
167
+ * Builds a node and registers it in the `@graph`. A node with an `@id`
168
+ * (passed as `$id` or inside `$props`) is merged into any existing node
169
+ * with the same id; otherwise it is appended.
170
+ *
171
+ * @param string|array<string> $type
172
+ * @param array<string,mixed> $props
173
+ * @return array<string,mixed> The stored node.
174
+ */
175
+ public static function add(string|array $type, array $props = [], ?string $id = null): array
176
+ {
177
+ $node = self::node($type, $props);
178
+ $key = $id ?? ($node['@id'] ?? null);
179
+
180
+ if ($key !== null) {
181
+ self::$graph[$key] = isset(self::$graph[$key])
182
+ ? self::merge(self::$graph[$key], $node)
183
+ : $node;
184
+ return self::$graph[$key];
185
+ }
186
+
187
+ self::$graph[] = $node;
188
+ return $node;
189
+ }
190
+
191
+ /** Appends an already-built node to the graph. */
192
+ public static function push(array $node): array
193
+ {
194
+ $node = self::clean($node);
195
+ $key = $node['@id'] ?? null;
196
+ if ($key !== null) {
197
+ self::$graph[$key] = isset(self::$graph[$key]) ? self::merge(self::$graph[$key], $node) : $node;
198
+ return self::$graph[$key];
199
+ }
200
+ self::$graph[] = $node;
201
+ return $node;
202
+ }
203
+
204
+ /** `['@id' => …]` reference. `#organization` → the site's Organization node. */
205
+ public static function ref(string $id): array
206
+ {
207
+ return ['@id' => self::id($id)];
208
+ }
209
+
210
+ /** Drops a node from the graph by its `@id` (fragment form accepted). */
211
+ public static function remove(string $id): void
212
+ {
213
+ unset(self::$graph[self::id($id)], self::$graph[$id]);
214
+ }
215
+
216
+ /** The graph as a plain list, in insertion order. */
217
+ public static function graph(): array
218
+ {
219
+ return array_values(self::$graph);
220
+ }
221
+
222
+ /** Clears the per-render state (graph, page context, emitted flag). */
223
+ public static function reset(): void
224
+ {
225
+ self::$graph = [];
226
+ self::$page = null;
227
+ self::$emitted = false;
228
+ }
229
+
230
+ /**
231
+ * Every other schema.org type: `LD::recipe([...])`, `LD::jobPosting([...])`,
232
+ * `LD::softwareApplication([...])`. The method name is upper-cased on its
233
+ * first letter to form the `@type`.
234
+ *
235
+ * @param array{0?:array<string,mixed>,1?:string} $args
236
+ * @return array<string,mixed>
237
+ */
238
+ public static function __callStatic(string $name, array $args): array
239
+ {
240
+ $props = (isset($args[0]) && is_array($args[0])) ? $args[0] : [];
241
+ $id = (isset($args[1]) && is_string($args[1])) ? $args[1] : null;
242
+ return self::add(ucfirst($name), $props, $id);
243
+ }
244
+
245
+
246
+ // -----------------------------------------------------------------------
247
+ // Config-aware builders
248
+ // -----------------------------------------------------------------------
249
+
250
+ /** The site's main entity (`Organization` by default; `jsonld.type` overrides). */
251
+ public static function organization(array $overrides = []): array
252
+ {
253
+ $c = self::config();
254
+
255
+ $node = [
256
+ '@id' => self::id('#organization'),
257
+ 'name' => $c->name,
258
+ 'url' => self::home(),
259
+ 'description' => $c->description,
260
+ 'email' => $c->email,
261
+ 'telephone' => $c->telephone,
262
+ 'sameAs' => $c->sameAs ?: null,
263
+ 'knowsAbout' => $c->knowsAbout ?: null,
264
+ 'areaServed' => $c->areaServed,
265
+ ];
266
+ if ($c->logo) {
267
+ $node['logo'] = self::image($c->logo, '#logo');
268
+ $node['image'] = self::ref('#logo');
269
+ }
270
+ if ($c->address) $node['address'] = self::address($c->address);
271
+ if ($c->person) $node['founder'] = self::ref('#person');
272
+
273
+ return self::add($overrides['@type'] ?? $c->type ?? 'Organization', array_merge($node, $overrides));
274
+ }
275
+
276
+ /** The key `Person` behind the site, linked to the Organization. */
277
+ public static function person(array $overrides = []): array
278
+ {
279
+ $c = self::config();
280
+ if (!$c->person) return [];
281
+
282
+ $p = $c->person;
283
+ $node = [
284
+ '@id' => self::id('#person'),
285
+ 'name' => $p['name'] ?? $c->name,
286
+ 'jobTitle' => $p['jobTitle'] ?? null,
287
+ 'email' => $p['email'] ?? null,
288
+ 'url' => $p['url'] ?? self::home(),
289
+ 'sameAs' => $p['sameAs'] ?? null,
290
+ 'worksFor' => self::ref('#organization'),
291
+ ];
292
+ unset($p['name'], $p['jobTitle'], $p['email'], $p['url'], $p['sameAs']);
293
+
294
+ return self::add('Person', array_merge($node, $p, $overrides));
295
+ }
296
+
297
+ /** The `WebSite` node (publisher → Organization, optional search action). */
298
+ public static function website(array $overrides = []): array
299
+ {
300
+ $c = self::config();
301
+
302
+ $node = [
303
+ '@id' => self::id('#website'),
304
+ 'url' => self::home(),
305
+ 'name' => $c->name,
306
+ 'description' => $c->description,
307
+ 'inLanguage' => $c->lang,
308
+ 'publisher' => self::ref('#organization'),
309
+ ];
310
+ if ($c->keywords) $node['keywords'] = implode(', ', $c->keywords);
311
+ if ($c->search) $node['potentialAction'] = self::searchAction($c->search);
312
+
313
+ return self::add('WebSite', array_merge($node, $overrides));
314
+ }
315
+
316
+ /**
317
+ * The current page's node, built from its PHPDOC. Reads, in order of
318
+ * precedence, the `@ld_*` tags then the generic page tags:
319
+ *
320
+ * @ld_type <Type> the node's `@type` (default `WebPage`; also `@ldtype`, `@jsonld`)
321
+ * @ld_title <text> the node's `name` (default `@title`)
322
+ * @ld_description … the node's `description` (default `@description`)
323
+ * @ld_image <path> the page image (default `@image` / `@ogimage`)
324
+ * @ld_published <d> `datePublished` (default `@datePublished` / `@published` / `@date`)
325
+ * @ld_modified <d> `dateModified` (default `@dateModified` / `@modified` / `@updated`)
326
+ *
327
+ * `LD::webPage(['@type' => …, …])` overrides everything.
328
+ */
329
+ public static function webPage(array $overrides = []): array
330
+ {
331
+ $c = self::config();
332
+ $info = self::pageInfo();
333
+ $url = self::pageUrl() ?? self::home();
334
+
335
+ $type = $overrides['@type']
336
+ ?? self::pageTag('ld_type', 'ldtype', 'jsonld')
337
+ ?? 'WebPage';
338
+ if (self::truthy($type) || self::falsy($type)) $type = 'WebPage'; // `@jsonld true`
339
+ unset($overrides['@type']);
340
+
341
+ $isPageType = stripos((string) $type, 'page') !== false;
342
+
343
+ $node = [
344
+ '@id' => $url . '#webpage',
345
+ 'url' => $url,
346
+ 'name' => self::pageTag('ld_title', 'ldtitle') ?? $info->title ?? $c->name,
347
+ // Page description: the page's own only — no fall-back to the site
348
+ // description, which would clone it onto every page.
349
+ 'description' => self::pageTag('ld_description', 'lddescription')
350
+ ?? $info->description ?? $info->desclong ?? $info->descshort ?? null,
351
+ 'isPartOf' => self::ref('#website'),
352
+ 'about' => self::ref('#organization'),
353
+ 'inLanguage' => $c->lang,
354
+ 'datePublished' => self::pageTag('ld_published', 'ldpublished')
355
+ ?? $info->datePublished ?? $info->published ?? $info->date ?? null,
356
+ 'dateModified' => self::pageTag('ld_modified', 'ldmodified')
357
+ ?? $info->dateModified ?? $info->modified ?? $info->updated ?? null,
358
+ ];
359
+
360
+ $img = self::pageTag('ld_image', 'ldimage') ?? $info->image ?? $info->ogimage ?? null;
361
+ if ($img) {
362
+ $node[$isPageType ? 'primaryImageOfPage' : 'image'] = self::image(self::absUrl((string) $img));
363
+ }
364
+
365
+ if ($isPageType && isset(self::$graph[$url . '#breadcrumb'])) {
366
+ $node['breadcrumb'] = self::ref($url . '#breadcrumb');
367
+ }
368
+
369
+ return self::add($type, array_merge($node, $overrides));
370
+ }
371
+
372
+ /**
373
+ * A `BreadcrumbList`. With no argument it is derived from the page's
374
+ * ancestor `_index.php` trail (always attempted while the `jsonld:` block
375
+ * is on — no `@breadcrumb` opt-in needed; disable per page with
376
+ * `@ld_breadcrumb false`). Pass `$items` as `[['name' => …, 'url' => …], …]`
377
+ * to build it by hand.
378
+ */
379
+ public static function breadcrumb(?array $items = null, array $overrides = []): array
380
+ {
381
+ if ($items === null) {
382
+ $info = self::pageInfo();
383
+ if (isset($info->ld_breadcrumb) && self::falsy($info->ld_breadcrumb)) return [];
384
+ $items = self::autoBreadcrumb();
385
+ if (count($items) < 2) return [];
386
+ }
387
+
388
+ $elements = [];
389
+ $pos = 1;
390
+ foreach ($items as $it) {
391
+ $entry = ['@type' => 'ListItem', 'position' => $pos++, 'name' => $it['name'] ?? null];
392
+ if (!empty($it['url'])) $entry['item'] = $it['url'];
393
+ $elements[] = self::clean($entry);
394
+ }
395
+
396
+ $url = self::pageUrl() ?? self::home();
397
+ return self::add('BreadcrumbList', array_merge([
398
+ '@id' => $url . '#breadcrumb',
399
+ 'itemListElement' => $elements,
400
+ ], $overrides));
401
+ }
402
+
403
+ /**
404
+ * An `FAQPage`. `$qa` maps a question to its answer string, or to an
405
+ * array of extra `Question` properties.
406
+ *
407
+ * @param array<string,string|array<string,mixed>> $qa
408
+ */
409
+ public static function faqPage(array $qa, array $overrides = []): array
410
+ {
411
+ $entities = [];
412
+ foreach ($qa as $q => $a) {
413
+ if (is_array($a)) {
414
+ $entities[] = self::clean(array_merge(['@type' => 'Question', 'name' => (string) $q], $a));
415
+ continue;
416
+ }
417
+ $entities[] = [
418
+ '@type' => 'Question',
419
+ 'name' => (string) $q,
420
+ 'acceptedAnswer' => ['@type' => 'Answer', 'text' => (string) $a],
421
+ ];
422
+ }
423
+
424
+ return self::add('FAQPage', array_merge(['mainEntity' => $entities], $overrides));
425
+ }
426
+
427
+
428
+ // -----------------------------------------------------------------------
429
+ // Value-object helpers (return a node, do not touch the graph)
430
+ // -----------------------------------------------------------------------
431
+
432
+ /** `PostalAddress` from a string or an associative array. */
433
+ public static function address(array|string $a): array
434
+ {
435
+ if (is_string($a)) return ['@type' => 'PostalAddress', 'streetAddress' => $a];
436
+ return self::clean(array_merge(['@type' => 'PostalAddress'], $a));
437
+ }
438
+
439
+ /** `ImageObject`; a relative `$url` is resolved against `baseurl`. */
440
+ public static function image(string $url, ?string $id = null, ?int $width = null, ?int $height = null): array
441
+ {
442
+ $url = self::absUrl($url);
443
+ return self::clean([
444
+ '@type' => 'ImageObject',
445
+ '@id' => $id ? self::id($id) : null,
446
+ 'url' => $url,
447
+ 'contentUrl' => $url,
448
+ 'width' => $width,
449
+ 'height' => $height,
450
+ ]);
451
+ }
452
+
453
+ /** `GeoCoordinates`. */
454
+ public static function geo(float $lat, float $lng): array
455
+ {
456
+ return ['@type' => 'GeoCoordinates', 'latitude' => $lat, 'longitude' => $lng];
457
+ }
458
+
459
+ /** `AggregateRating`. */
460
+ public static function rating(int|float $value, ?int $count = null, int|float $best = 5, int|float $worst = 1): array
461
+ {
462
+ return self::clean([
463
+ '@type' => 'AggregateRating',
464
+ 'ratingValue' => $value,
465
+ 'ratingCount' => $count,
466
+ 'bestRating' => $best,
467
+ 'worstRating' => $worst,
468
+ ]);
469
+ }
470
+
471
+ /** `Offer`. */
472
+ public static function offer(array $o): array
473
+ {
474
+ return self::clean(array_merge(['@type' => 'Offer'], $o));
475
+ }
476
+
477
+ /** `ContactPoint`. */
478
+ public static function contactPoint(array $c): array
479
+ {
480
+ return self::clean(array_merge(['@type' => 'ContactPoint'], $c));
481
+ }
482
+
483
+ /** `SearchAction` for a `WebSite` sitelinks search box. */
484
+ public static function searchAction(string $urlTemplate): array
485
+ {
486
+ return [
487
+ '@type' => 'SearchAction',
488
+ 'target' => ['@type' => 'EntryPoint', 'urlTemplate' => $urlTemplate],
489
+ 'query-input' => 'required name=search_term_string',
490
+ ];
491
+ }
492
+
493
+
494
+ // -----------------------------------------------------------------------
495
+ // Output
496
+ // -----------------------------------------------------------------------
497
+
498
+ /** The `<script type="application/ld+json">…</script>` block, or `''`. */
499
+ public static function script(bool $pretty = true): string
500
+ {
501
+ $json = self::json($pretty);
502
+ if ($json === '') return '';
503
+ return '<script type="application/ld+json">' . "\n" . $json . "\n" . '</script>';
504
+ }
505
+
506
+ /** The JSON-LD document as a string (`@graph` when there is more than one node). */
507
+ public static function json(bool $pretty = true): string
508
+ {
509
+ if (self::$graph === [] && self::config()->enabled && !self::pageOptedOut()) self::autofill();
510
+
511
+ $nodes = array_values(array_filter(self::$graph, fn($n) => count($n) > 1));
512
+ if ($nodes === []) return '';
513
+ self::$emitted = true;
514
+
515
+ $doc = count($nodes) === 1
516
+ ? array_merge(['@context' => 'https://schema.org'], $nodes[0])
517
+ : ['@context' => 'https://schema.org', '@graph' => $nodes];
518
+
519
+ $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | ($pretty ? JSON_PRETTY_PRINT : 0);
520
+ return json_encode($doc, $flags);
521
+ }
522
+
523
+
524
+ // -----------------------------------------------------------------------
525
+ // Render hooks (wired in prepros.plugins.php)
526
+ // -----------------------------------------------------------------------
527
+
528
+ /** Captures the page under render. Called from the `page_info` hook. */
529
+ public static function capture(mixed $info): void
530
+ {
531
+ self::reset();
532
+ self::$page = ['file' => PREPROS::$file ?: null, 'info' => is_object($info) ? $info : new stdClass];
533
+ }
534
+
535
+ /**
536
+ * Injects the automatic `<script>` right before `</head>`. Called from the
537
+ * `post_render` hook. Skips pages that already carry an
538
+ * `application/ld+json` script, or that opted out.
539
+ */
540
+ public static function inject(string $html): string
541
+ {
542
+ try {
543
+ if (self::$emitted) return $html;
544
+ if (self::pageOptedOut()) return $html;
545
+ if (stripos($html, 'application/ld+json') !== false) return $html;
546
+
547
+ // script() autofills the defaults only when the `jsonld:` block
548
+ // opted in; otherwise it emits nothing unless a template added
549
+ // nodes by hand, in which case those are still injected.
550
+ $script = self::script();
551
+ if ($script === '') return $html;
552
+
553
+ $block = ' ' . str_replace("\n", "\n ", $script) . "\n";
554
+ return preg_match('#</head>#i', $html)
555
+ ? preg_replace('#</head>#i', $block . '</head>', $html, 1)
556
+ : $block . $html;
557
+ } finally {
558
+ self::reset();
559
+ }
560
+ }
561
+
562
+
563
+ // -----------------------------------------------------------------------
564
+ // Internals
565
+ // -----------------------------------------------------------------------
566
+
567
+ private static function autofill(): void
568
+ {
569
+ $c = self::config();
570
+ self::organization();
571
+ if ($c->person) self::person();
572
+ self::website();
573
+ self::breadcrumb();
574
+ self::webPage();
575
+ }
576
+
577
+ /** The `kirigami:` block (`PREPROS::$config->data`), or an empty object outside a render. */
578
+ private static function data(): object
579
+ {
580
+ if (isset(PREPROS::$config) && is_object(PREPROS::$config) && isset(PREPROS::$config->data) && is_object(PREPROS::$config->data)) {
581
+ return PREPROS::$config->data;
582
+ }
583
+ return new stdClass;
584
+ }
585
+
586
+ /** The raw top-level `jsonld:` block: an object, `false`, `true`, or `null` when absent. */
587
+ private static function jsonldRaw(): mixed
588
+ {
589
+ return (isset(PREPROS::$config) && is_object(PREPROS::$config)) ? (PREPROS::$config->jsonld ?? null) : null;
590
+ }
591
+
592
+ private static function pageInfo(): object
593
+ {
594
+ return self::$page['info'] ?? new stdClass;
595
+ }
596
+
597
+ /** First non-empty value among the given PHPDOC tag names of the current page. */
598
+ private static function pageTag(string ...$names): ?string
599
+ {
600
+ $info = self::pageInfo();
601
+ foreach ($names as $n) {
602
+ $v = $info->$n ?? null;
603
+ if (is_string($v) && trim($v) !== '' && !self::truthy($v) && !self::falsy($v)) return trim($v);
604
+ }
605
+ return null;
606
+ }
607
+
608
+ /** `@ld false` / `@jsonld false` / `@ld_ignore` → skip this page entirely. */
609
+ private static function pageOptedOut(): bool
610
+ {
611
+ $info = self::pageInfo();
612
+ foreach (['ld', 'jsonld'] as $t) {
613
+ $v = $info->$t ?? null;
614
+ if (is_string($v) && self::falsy($v)) return true;
615
+ }
616
+ return isset($info->ld_ignore) && self::truthy($info->ld_ignore);
617
+ }
618
+
619
+ /** Site origin + base path + trailing slash, e.g. `https://example.com/`. */
620
+ private static function home(): string
621
+ {
622
+ $base = (string) (self::data()->baseurl ?? self::config()->url);
623
+ return $base === '' ? '/' : rtrim($base, '/') . '/';
624
+ }
625
+
626
+ /** Absolute URL of the page currently under render. */
627
+ private static function pageUrl(): ?string
628
+ {
629
+ $file = self::$page['file'] ?? (PREPROS::$file ?: null);
630
+ return $file ? self::urlForFile($file) : null;
631
+ }
632
+
633
+ /** Absolute URL of the directory holding a source file (mirrors PREPROS::render()). */
634
+ private static function urlForFile(string $file): ?string
635
+ {
636
+ $data = self::data();
637
+ if (empty($data->baseurl)) return null;
638
+
639
+ $root = @realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? '');
640
+ $abs = @realpath($file) ?: $file;
641
+ $rel = str_replace('\\', '/', pathinfo(str_replace($root, '', $abs), PATHINFO_DIRNAME));
642
+
643
+ $basepath = rtrim((string) parse_url($data->baseurl, PHP_URL_PATH), '/');
644
+ $path = preg_replace('#/+#', '/', $basepath . '/' . trim($rel, '/') . '/');
645
+ $origin = preg_replace('#^(https?://[^/]+).*#', '$1', (string) $data->baseurl);
646
+
647
+ return $origin . $path;
648
+ }
649
+
650
+ /**
651
+ * Breadcrumb items for the current page: a leading home crumb, the ancestor
652
+ * `_index.php` trail, then the page itself. `[]` for the site home page.
653
+ *
654
+ * @return array<int,array{name:?string,url:?string}>
655
+ */
656
+ private static function autoBreadcrumb(): array
657
+ {
658
+ $file = self::$page['file'] ?? (PREPROS::$file ?: null);
659
+ if (!$file) return [];
660
+
661
+ // The site's home URL, base path included (e.g. .../template-demo/).
662
+ $siteRoot = self::config()->url;
663
+ $pageUrl = self::pageUrl();
664
+ if ($pageUrl !== null && rtrim($pageUrl, '/') === rtrim($siteRoot, '/')) return [];
665
+
666
+ $trail = [];
667
+ try {
668
+ $trail = self::ancestorTrail($file);
669
+ } catch (\Throwable) {
670
+ $trail = [];
671
+ }
672
+
673
+ // Guarantee a leading home crumb (the ancestor walk already yields it
674
+ // when a root `_index.php` exists).
675
+ if (!$trail || rtrim((string) ($trail[0]['url'] ?? ''), '/') !== rtrim($siteRoot, '/')) {
676
+ array_unshift($trail, ['name' => self::config()->name, 'url' => $siteRoot]);
677
+ }
678
+
679
+ $info = self::pageInfo();
680
+ $trail[] = [
681
+ 'name' => $info->ld_title ?? $info->title ?? $info->name ?? null,
682
+ 'url' => $pageUrl,
683
+ ];
684
+
685
+ return $trail;
686
+ }
687
+
688
+ /**
689
+ * Walks the source tree upward from a page file, collecting one crumb per
690
+ * ancestor directory that holds an `_index.php` — the section index pages.
691
+ * For a non-index page (`_post.php`), its own folder's `_index.php` counts
692
+ * as the nearest ancestor. Ordered top-most first. No `@breadcrumb` opt-in.
693
+ *
694
+ * @return array<int,array{name:?string,url:?string}>
695
+ */
696
+ private static function ancestorTrail(string $file): array
697
+ {
698
+ $root = @realpath(PREPROS::$config->root ?? '');
699
+ if (!$root) return [];
700
+ $root = rtrim(str_replace('\\', '/', $root), '/');
701
+
702
+ $self = @realpath($file) ?: $file;
703
+ $selfNorm = str_replace('\\', '/', $self);
704
+ $startDir = str_replace('\\', '/', dirname($self));
705
+ $isIndex = strtolower(pathinfo($self, PATHINFO_FILENAME)) === '_index';
706
+
707
+ $dir = rtrim($isIndex ? dirname($startDir) : $startDir, '/');
708
+ $trail = [];
709
+
710
+ for ($guard = 0; $guard < 50; $guard++) {
711
+ if (strncmp($dir . '/', $root . '/', strlen($root) + 1) !== 0) break;
712
+
713
+ $index = $dir . '/_index.php';
714
+ if (is_file($index) && (str_replace('\\', '/', @realpath($index) ?: $index)) !== $selfNorm) {
715
+ $info = FS::phpFileInfo($index) ?: new stdClass;
716
+ $fallback = $dir === $root ? self::config()->name : self::humanize(basename($dir));
717
+ $trail[] = [
718
+ 'name' => $info->ld_title ?? $info->title ?? $info->name ?? $fallback,
719
+ 'url' => self::urlForFile($index),
720
+ ];
721
+ }
722
+
723
+ if ($dir === $root) break;
724
+ $dir = rtrim(str_replace('\\', '/', dirname($dir)), '/');
725
+ }
726
+
727
+ return array_reverse($trail);
728
+ }
729
+
730
+ /** `mon-dossier` → `Mon dossier`. */
731
+ private static function humanize(string $s): string
732
+ {
733
+ return ucfirst(trim(str_replace(['-', '_'], ' ', $s)));
734
+ }
735
+
736
+ /** Resolves a fragment (`#organization`) or bare path against `config()->url`. */
737
+ private static function id(string $frag): string
738
+ {
739
+ if (preg_match('#^https?://#', $frag)) return $frag;
740
+ $base = rtrim(self::config()->url, '/');
741
+ return str_starts_with($frag, '#') ? $base . '/' . $frag : $base . '/' . ltrim($frag, '/');
742
+ }
743
+
744
+ /** Turns a relative path into an absolute URL against `baseurl`. */
745
+ private static function absUrl(?string $url): ?string
746
+ {
747
+ if ($url === null || $url === '') return null;
748
+ if (preg_match('#^(https?:)?//#', $url) || str_starts_with($url, 'data:')) return $url;
749
+
750
+ $origin = preg_replace('#^(https?://[^/]+).*#', '$1', (string) (self::data()->baseurl ?? ''));
751
+ return $origin . '/' . ltrim($url, '/');
752
+ }
753
+
754
+ /** @return array<int,string> */
755
+ private static function arr(mixed $v): array
756
+ {
757
+ if ($v === null || $v === '') return [];
758
+ if (is_array($v)) return array_values(array_map('strval', $v));
759
+ return [(string) $v];
760
+ }
761
+
762
+ private static function truthy(mixed $v): bool
763
+ {
764
+ if (is_bool($v)) return $v;
765
+ return in_array(strtolower(trim((string) $v)), ['1', 'true', 'yes', 'on'], true);
766
+ }
767
+
768
+ private static function falsy(mixed $v): bool
769
+ {
770
+ if (is_bool($v)) return !$v;
771
+ return in_array(strtolower(trim((string) $v)), ['0', 'false', 'no', 'off'], true);
772
+ }
773
+
774
+ /** Recursively drops null / '' / [] values; trims strings. */
775
+ private static function clean(mixed $v): mixed
776
+ {
777
+ if (is_array($v)) {
778
+ $list = array_is_list($v);
779
+ $out = [];
780
+ foreach ($v as $k => $item) {
781
+ $item = self::clean($item);
782
+ if ($item === null || $item === '' || $item === []) continue;
783
+ if ($list) $out[] = $item;
784
+ else $out[$k] = $item;
785
+ }
786
+ return $out;
787
+ }
788
+ if (is_string($v)) return trim($v);
789
+ return $v;
790
+ }
791
+
792
+ /** Deep-merges $b into $a: scalars and lists overwrite, maps merge. */
793
+ private static function merge(array $a, array $b): array
794
+ {
795
+ foreach ($b as $k => $v) {
796
+ if (is_array($v) && !array_is_list($v) && isset($a[$k]) && is_array($a[$k]) && !array_is_list($a[$k])) {
797
+ $a[$k] = self::merge($a[$k], $v);
798
+ } else {
799
+ $a[$k] = $v;
800
+ }
801
+ }
802
+ return $a;
803
+ }
804
+ }