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