@kirigami/php-prepros 1.9.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,506 +1,496 @@
1
- <?php
2
-
3
- declare(strict_types=1);
4
-
5
- /**
6
- * META — `<head>` metadata generator.
7
- *
8
- * Builds the standard SEO / social `<meta>` and `<link>` tags for a page and
9
- * injects them into its `<head>`, from three sources, in this order of
10
- * precedence:
11
- *
12
- * 1. the page's own PHPDOC block (`@title`, `@description`, `@image`, …),
13
- * 2. the top-level `meta:` block of `kirigami.yaml` (config + overrides),
14
- * 3. the loose keys of the `kirigami:` block and the top-level `jsonld:`
15
- * block (`description`, `keywords`, `author`, `person`, `lang`, `logo`,
16
- * `image`, …) — the same values `LD` already reads.
17
- *
18
- * It only ever emits what it can resolve: a tag with no value is skipped, and a
19
- * tag the page's layout already hand-writes (`<title>`, `<meta name="description">`,
20
- * `<link rel="canonical">`, …) is left untouched — so it slots in next to an
21
- * existing `header.php` without doubling anything up.
22
- *
23
- * Automatic injection is **opt-in**: it needs a top-level `meta:` block (an
24
- * empty map, `meta: {}`, is enough). `meta: false` (or `meta: { auto: false }`)
25
- * keeps the config values but stops the injection; no block at all means
26
- * nothing is injected — an explicit `META::tag()` call from a template still
27
- * emits.
28
- *
29
- * Per-page PHPDOC tags, each falling back to the generic page tag:
30
- *
31
- * @meta false skip metadata for this page (or @meta_ignore true)
32
- * @meta_title <text> <title> / og:title / twitter:title (default @title)
33
- * @meta_description … description / og / twitter (default @description / @abstract / @excerpt)
34
- * @meta_keywords a, b <meta name="keywords"> (default @keywords)
35
- * @meta_image <path> og:image / twitter:image (default @image / @ogimage)
36
- * @meta_robots <rule> <meta name="robots"> (default @robots)
37
- * @meta_type <type> og:type (default @og_type)
38
- * @canonical <url> <link rel="canonical"> (default: derived from the file path + baseurl)
39
- *
40
- * Manual use from a template or an `includes` file — always emitted, `meta:`
41
- * block or not, and still de-duplicated against the page:
42
- *
43
- * META::tag('twitter:image', 'https://…/card.png'); // name= or property= picked from the key
44
- * META::link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
45
- * META::raw('<meta name="rating" content="general">');
46
- */
47
- final class META
48
- {
49
- /** Resolved config, once per process. */
50
- private static ?object $config = null;
51
-
52
- /** @var array{file:?string,info:object}|null Page context from the `page_info` hook. */
53
- private static ?array $page = null;
54
-
55
- /** True once inject()/tags() has run, so the injector does not emit twice. */
56
- private static bool $emitted = false;
57
-
58
- /** @var string[] Ready-made tag lines added by hand from a template. */
59
- private static array $extra = [];
60
-
61
-
62
- // -----------------------------------------------------------------------
63
- // Configuration
64
- // -----------------------------------------------------------------------
65
-
66
- /**
67
- * Resolved metadata configuration, merging the `meta:` block with the loose
68
- * keys of the `kirigami:` block and the `jsonld:` block.
69
- */
70
- public static function config(): object
71
- {
72
- if (self::$config !== null) return self::$config;
73
-
74
- $data = self::data();
75
- $raw = self::metaRaw();
76
- $m = is_object($raw) ? $raw : new stdClass;
77
- $ld = self::jsonld();
78
-
79
- // Opt-in: needs a top-level `meta:` block (an empty map counts).
80
- $enabled = is_object($raw) || $raw === true;
81
- if ($enabled && isset($m->auto) && !self::truthy($m->auto)) $enabled = false;
82
-
83
- $lang = $m->language ?? $m->lang
84
- ?? ($ld->lang ?? null)
85
- ?? $data->lang ?? $data->language ?? 'en';
86
-
87
- $person = $ld->person ?? $data->person ?? null;
88
- $personName = is_object($person) ? ($person->name ?? null) : (is_string($person) ? $person : null);
89
-
90
- $twitter = $m->twitter ?? $data->twitter ?? null;
91
- $twSite = $m->twitterSite ?? (is_object($twitter) ? ($twitter->site ?? null) : (is_string($twitter) ? $twitter : null));
92
- $twCreator = $m->twitterCreator ?? (is_object($twitter) ? ($twitter->creator ?? null) : (is_string($twitter) ? $twitter : null));
93
-
94
- $generator = $m->generator ?? 'Kirigami';
95
- if (self::falsy($generator)) $generator = null;
96
-
97
- self::$config = (object) [
98
- 'enabled' => $enabled,
99
- 'project' => $data->project ?? $m->siteName ?? null,
100
- 'tagline' => $m->tagline ?? $data->tagline ?? null,
101
- 'titleFormat' => (string) ($m->titleFormat ?? '{title} — {project}'),
102
- 'titleFormatHome' => (string) ($m->titleFormatHome ?? '{project} — {tagline}'),
103
- 'description' => $m->description ?? $ld->description ?? $data->description ?? null,
104
- 'keywords' => self::arr($m->keywords ?? $ld->keywords ?? $data->keywords ?? null),
105
- 'robots' => $m->robots ?? 'index, follow',
106
- 'language' => $lang,
107
- 'generator' => $generator,
108
- 'author' => $m->author ?? $data->author ?? $personName ?? null,
109
- 'designer' => $m->designer ?? $data->designer ?? null,
110
- 'themeColor' => $m->themeColor ?? $m->themecolor ?? $data->themecolor ?? null,
111
- 'image' => self::absUrl($m->image ?? $ld->image ?? $ld->logo ?? $data->image ?? $data->ogimage ?? null),
112
- 'ogType' => $m->ogType ?? $m->ogtype ?? 'website',
113
- 'twitterCard' => $m->twitterCard ?? $m->twittercard ?? 'summary_large_image',
114
- 'twitterSite' => self::handle($twSite),
115
- 'twitterCreator' => self::handle($twCreator),
116
- 'canonical' => !isset($m->canonical) || self::truthy($m->canonical),
117
- 'favicon' => $m->favicon ?? null,
118
- 'appleTouchIcon' => $m->appleTouchIcon ?? $m->appletouchicon ?? null,
119
- 'humans' => $m->humans ?? null,
120
- ];
121
-
122
- return self::$config;
123
- }
124
-
125
-
126
- // -----------------------------------------------------------------------
127
- // Manual API
128
- // -----------------------------------------------------------------------
129
-
130
- /**
131
- * Adds a `<meta>` tag. The key picks the attribute: `og:*` → `property=`,
132
- * everything else → `name=`. An empty `$content` is a no-op.
133
- */
134
- public static function tag(string $name, ?string $content): void
135
- {
136
- $name = trim($name);
137
- $content = $content === null ? '' : trim($content);
138
- if ($name === '' || $content === '') return;
139
-
140
- $attr = str_starts_with(strtolower($name), 'og:') ? 'property' : 'name';
141
- self::$extra[] = '<meta ' . $attr . '="' . STR::htmlesc($name) . '" content="' . STR::htmlesc($content) . '">';
142
- }
143
-
144
- /** Adds a `<link>` tag. `$attrs` are extra attributes (`type`, `sizes`, …). */
145
- public static function link(string $rel, string $href, array $attrs = []): void
146
- {
147
- if (trim($rel) === '' || trim($href) === '') return;
148
- $out = '<link rel="' . STR::htmlesc($rel) . '"';
149
- foreach ($attrs as $k => $v) {
150
- if ($v === null || $v === '' || $v === false) continue;
151
- $out .= ' ' . $k . '="' . STR::htmlesc((string) $v) . '"';
152
- }
153
- $out .= ' href="' . STR::htmlesc($href) . '">';
154
- self::$extra[] = $out;
155
- }
156
-
157
- /** Adds a verbatim tag line (already valid HTML). */
158
- public static function raw(string $html): void
159
- {
160
- $html = trim($html);
161
- if ($html !== '') self::$extra[] = $html;
162
- }
163
-
164
- /** Clears the per-render state. */
165
- public static function reset(): void
166
- {
167
- self::$page = null;
168
- self::$emitted = false;
169
- self::$extra = [];
170
- }
171
-
172
-
173
- // -----------------------------------------------------------------------
174
- // Output
175
- // -----------------------------------------------------------------------
176
-
177
- /**
178
- * The full block of tag lines (`\n`-joined), each dropped when `$html`
179
- * already carries an equivalent tag. `''` when there is nothing to emit.
180
- */
181
- public static function tags(string $html = ''): string
182
- {
183
- self::$emitted = true;
184
-
185
- $c = self::config();
186
- $info = self::pageInfo();
187
-
188
- /** @var array<int,array{0:string,1:string}> [probe regex, tag html] */
189
- $lines = [];
190
-
191
- // The auto block only builds when the `meta:` block opted in; an
192
- // explicit META::tag() call still lands through self::$extra below.
193
- if ($c->enabled) {
194
- $relroot = self::relroot();
195
-
196
- $title = self::pageTitle();
197
- $desc = self::pageTag('meta_description', 'metadescription')
198
- ?? ($info->description ?? $info->abstract ?? $info->excerpt ?? $info->summary ?? null);
199
- $desc = is_string($desc) && trim($desc) !== '' ? trim($desc) : ($c->description ?: null);
200
- $keywords = self::arr(self::pageTag('meta_keywords', 'metakeywords')) ?: $c->keywords;
201
- $image = self::absUrl(self::pageTag('meta_image', 'metaimage') ?? $info->image ?? $info->ogimage ?? null) ?? $c->image;
202
- $robots = self::pageTag('meta_robots', 'metarobots', 'robots') ?? $c->robots;
203
- $ogType = self::pageTag('meta_type', 'metatype', 'og_type', 'ogtype') ?? $c->ogType;
204
- $url = self::pageTag('canonical') ?? self::pageUrl();
205
-
206
- if ($title) $lines[] = ['#<title[\s>]#i', '<title>' . STR::htmlesc($title) . '</title>'];
207
-
208
- self::name($lines, 'description', $desc);
209
- self::name($lines, 'keywords', $keywords ? implode(', ', $keywords) : null);
210
- self::name($lines, 'robots', $robots && !self::falsy($robots) ? (string) $robots : null);
211
- self::name($lines, 'language', $c->language);
212
- self::name($lines, 'generator', $c->generator);
213
- self::name($lines, 'author', $c->author);
214
- self::name($lines, 'designer', $c->designer);
215
- self::name($lines, 'theme-color', $c->themeColor);
216
-
217
- self::name($lines, 'twitter:card', $c->twitterCard);
218
- self::name($lines, 'twitter:title', $title);
219
- self::name($lines, 'twitter:description', $desc);
220
- self::name($lines, 'twitter:image', $image);
221
- self::name($lines, 'twitter:site', $c->twitterSite);
222
- self::name($lines, 'twitter:creator', $c->twitterCreator);
223
-
224
- self::prop($lines, 'og:site_name', $c->project);
225
- self::prop($lines, 'og:locale', $c->language ? str_replace('-', '_', $c->language) : null);
226
- self::prop($lines, 'og:type', $ogType);
227
- self::prop($lines, 'og:title', $title);
228
- self::prop($lines, 'og:description', $desc);
229
- self::prop($lines, 'og:url', $url);
230
- self::prop($lines, 'og:image', $image);
231
-
232
- if ($c->canonical && $url) {
233
- $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])canonical\1#i',
234
- '<link rel="canonical" href="' . STR::htmlesc($url) . '">'];
235
- }
236
-
237
- if ($h = self::asset($c->humans, 'humans', 'humans.txt', $relroot)) {
238
- $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])author\1#i',
239
- '<link rel="author" type="text/plain" href="' . STR::htmlesc($h) . '">'];
240
- }
241
- if ($f = self::asset($c->favicon, 'favicon', 'favicon.ico', $relroot)) {
242
- $type = str_ends_with($f, '.svg') ? 'image/svg+xml' : (str_ends_with($f, '.png') ? 'image/png' : 'image/x-icon');
243
- $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])icon\1#i',
244
- '<link rel="icon" type="' . $type . '" href="' . STR::htmlesc($f) . '">'];
245
- }
246
- if ($a = self::asset($c->appleTouchIcon, 'appleTouchIcon', 'apple-touch-icon.png', $relroot)) {
247
- $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])apple-touch-icon\1#i',
248
- '<link rel="apple-touch-icon" href="' . STR::htmlesc($a) . '">'];
249
- }
250
- }
251
-
252
- $out = [];
253
- $seen = [];
254
- foreach ($lines as [$probe, $tag]) {
255
- if (isset($seen[$tag])) continue;
256
- if ($html !== '' && preg_match($probe, $html)) continue;
257
- $seen[$tag] = true;
258
- $out[] = $tag;
259
- }
260
- foreach (self::$extra as $tag) {
261
- if (isset($seen[$tag])) continue;
262
- $seen[$tag] = true;
263
- $out[] = $tag;
264
- }
265
-
266
- return implode("\n", $out);
267
- }
268
-
269
-
270
- // -----------------------------------------------------------------------
271
- // Render hooks (wired in prepros.plugins.php)
272
- // -----------------------------------------------------------------------
273
-
274
- /** Captures the page under render. Called from the `page_info` hook. */
275
- public static function capture(mixed $info): void
276
- {
277
- self::reset();
278
- self::$page = ['file' => PREPROS::$file ?: null, 'info' => is_object($info) ? $info : new stdClass];
279
- }
280
-
281
- /**
282
- * Injects the tag block right before `</head>` (after any `<meta charset>`
283
- * / `<title>` the layout wrote). Called from the `post_render` hook.
284
- */
285
- public static function inject(string $html): string
286
- {
287
- try {
288
- if (self::$emitted) return $html;
289
- if (self::pageOptedOut()) return $html;
290
- if (!self::config()->enabled && self::$extra === []) return $html;
291
-
292
- $block = self::tags($html);
293
- if ($block === '') return $html;
294
-
295
- $block = ' ' . str_replace("\n", "\n ", $block) . "\n";
296
- return preg_match('#</head>#i', $html)
297
- ? preg_replace('#</head>#i', $block . '</head>', $html, 1)
298
- : $block . $html;
299
- } finally {
300
- self::reset();
301
- }
302
- }
303
-
304
-
305
- // -----------------------------------------------------------------------
306
- // Internals
307
- // -----------------------------------------------------------------------
308
-
309
- private static function name(array &$lines, string $name, ?string $content): void
310
- {
311
- self::meta($lines, 'name', $name, $content);
312
- }
313
-
314
- private static function prop(array &$lines, string $prop, ?string $content): void
315
- {
316
- self::meta($lines, 'property', $prop, $content);
317
- }
318
-
319
- /**
320
- * Queues a `<meta $attr="$key" content="$content">`, with a probe that
321
- * matches the same key under *either* `name=` or `property=` — a layout that
322
- * wrote `og:title` as `name=` (or `twitter:image` as `property=`) still
323
- * counts as already present.
324
- */
325
- private static function meta(array &$lines, string $attr, string $key, ?string $content): void
326
- {
327
- $content = $content === null ? '' : trim($content);
328
- if ($content === '') return;
329
- $lines[] = [
330
- '#<meta\s+[^>]*(?:name|property)\s*=\s*(["\'])' . preg_quote($key, '#') . '\1#i',
331
- '<meta ' . $attr . '="' . $key . '" content="' . STR::htmlesc($content) . '">',
332
- ];
333
- }
334
-
335
- /** The composed page title, per `titleFormat` / `titleFormatHome`. */
336
- private static function pageTitle(): ?string
337
- {
338
- $c = self::config();
339
- $info = self::pageInfo();
340
- $page = self::pageTag('meta_title', 'metatitle') ?? $info->title ?? $info->name ?? null;
341
-
342
- $tokens = [
343
- '{title}' => is_string($page) ? trim($page) : '',
344
- '{project}' => (string) ($c->project ?? ''),
345
- '{tagline}' => (string) ($c->tagline ?? ''),
346
- ];
347
-
348
- $fmt = (!$tokens['{title}'] || $tokens['{title}'] === $tokens['{project}'])
349
- ? $c->titleFormatHome
350
- : $c->titleFormat;
351
-
352
- $out = strtr($fmt, $tokens);
353
- // Collapse separators left dangling by an empty token.
354
- $out = preg_replace('/\s*[|\x{2013}\x{2014}\-\/·:]\s*(?=$|[|\x{2013}\x{2014}\-\/·:])/u', '', $out);
355
- $out = trim(preg_replace('/^\s*[|\x{2013}\x{2014}\-\/·:]\s*|\s*[|\x{2013}\x{2014}\-\/·:]\s*$/u', '', $out));
356
-
357
- return $out !== '' ? $out : ($tokens['{project}'] ?: null);
358
- }
359
-
360
- /** The `kirigami:` block. */
361
- private static function data(): object
362
- {
363
- if (isset(PREPROS::$config) && is_object(PREPROS::$config) && isset(PREPROS::$config->data) && is_object(PREPROS::$config->data)) {
364
- return PREPROS::$config->data;
365
- }
366
- return new stdClass;
367
- }
368
-
369
- /** The raw top-level `meta:` block: object, `false`, `true`, or `null`. */
370
- private static function metaRaw(): mixed
371
- {
372
- return (isset(PREPROS::$config) && is_object(PREPROS::$config)) ? (PREPROS::$config->meta ?? null) : null;
373
- }
374
-
375
- /** The top-level `jsonld:` block as an object (empty when absent / disabled). */
376
- private static function jsonld(): object
377
- {
378
- $raw = (isset(PREPROS::$config) && is_object(PREPROS::$config)) ? (PREPROS::$config->jsonld ?? null) : null;
379
- return is_object($raw) ? $raw : new stdClass;
380
- }
381
-
382
- private static function pageInfo(): object
383
- {
384
- return self::$page['info'] ?? new stdClass;
385
- }
386
-
387
- /** First non-empty, non-boolean value among the given PHPDOC tag names. */
388
- private static function pageTag(string ...$names): ?string
389
- {
390
- $info = self::pageInfo();
391
- foreach ($names as $n) {
392
- $v = $info->$n ?? null;
393
- if (is_string($v) && trim($v) !== '' && !self::truthy($v) && !self::falsy($v)) return trim($v);
394
- }
395
- return null;
396
- }
397
-
398
- /** `@meta false` / `@meta_ignore true` → skip this page. */
399
- private static function pageOptedOut(): bool
400
- {
401
- $info = self::pageInfo();
402
- $v = $info->meta ?? null;
403
- if (is_string($v) && self::falsy($v)) return true;
404
- return isset($info->meta_ignore) && self::truthy($info->meta_ignore);
405
- }
406
-
407
- /** Absolute URL of the page currently under render (mirrors PREPROS::render()). */
408
- private static function pageUrl(): ?string
409
- {
410
- $data = self::data();
411
- $file = self::$page['file'] ?? (PREPROS::$file ?: null);
412
- if (!$file || empty($data->baseurl)) return null;
413
-
414
- $root = @realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? '');
415
- $abs = @realpath($file) ?: $file;
416
- $rel = str_replace('\\', '/', pathinfo(str_replace($root, '', $abs), PATHINFO_DIRNAME));
417
-
418
- $basepath = rtrim((string) parse_url($data->baseurl, PHP_URL_PATH), '/');
419
- $path = preg_replace('#/+#', '/', $basepath . '/' . trim($rel, '/') . '/');
420
- $origin = preg_replace('#^(https?://[^/]+).*#', '$1', (string) $data->baseurl);
421
-
422
- return $origin . $path;
423
- }
424
-
425
- /** Path from the page's directory back to the source root, e.g. `../` or `./`. */
426
- private static function relroot(): string
427
- {
428
- $file = self::$page['file'] ?? (PREPROS::$file ?: '');
429
- if ($file === '') return './';
430
- $dir = str_replace('\\', '/', dirname((string) (@realpath($file) ?: $file))) . '/';
431
- $root = str_replace('\\', '/', (string) (@realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? ''))) . '/';
432
- return FS::getRelativePath($dir, $root);
433
- }
434
-
435
- /**
436
- * Resolves an asset link: an explicit string is used as-is (page-relative
437
- * when it is a bare filename); `true` forces the default file; `null`
438
- * auto-detects the default file at the source root (presence reported by
439
- * `prepros.js` via `metaFiles`, since these extensions are not mounted);
440
- * `false` disables it.
441
- */
442
- private static function asset(mixed $value, string $key, string $default, string $relroot): ?string
443
- {
444
- if (self::falsy($value)) return null;
445
-
446
- if (is_string($value) && trim($value) !== '') {
447
- $v = trim($value);
448
- if (preg_match('#^(https?:)?//#', $v) || str_starts_with($v, '/') || str_starts_with($v, '.')) return $v;
449
- return rtrim($relroot, '/') . '/' . ltrim($v, '/');
450
- }
451
-
452
- if ($value === true) return rtrim($relroot, '/') . '/' . $default;
453
-
454
- $files = (isset(PREPROS::$config) && is_object(PREPROS::$config)) ? (PREPROS::$config->metaFiles ?? null) : null;
455
- $found = is_object($files) ? ($files->$key ?? false) : false;
456
- if (!$found) {
457
- // Fall back to a direct check, for files that do live in the sandbox.
458
- $root = (string) (@realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? ''));
459
- $found = $root !== '' && is_file($root . '/' . $default);
460
- }
461
- return $found ? rtrim($relroot, '/') . '/' . $default : null;
462
- }
463
-
464
- /** Turns a relative path into an absolute URL against `baseurl`. */
465
- private static function absUrl(?string $url): ?string
466
- {
467
- if ($url === null || trim($url) === '') return null;
468
- $url = trim($url);
469
- if (preg_match('#^(https?:)?//#', $url) || str_starts_with($url, 'data:')) return $url;
470
-
471
- $origin = preg_replace('#^(https?://[^/]+).*#', '$1', (string) (self::data()->baseurl ?? ''));
472
- return $origin === '' ? $url : $origin . '/' . ltrim($url, '/');
473
- }
474
-
475
- /** `@user` / `user` / `https://twitter.com/user` → `@user`. */
476
- private static function handle(mixed $v): ?string
477
- {
478
- if (!is_string($v) || trim($v) === '') return null;
479
- $v = trim($v);
480
- if (preg_match('#(?:twitter|x)\.com/@?([A-Za-z0-9_]{1,15})#i', $v, $m)) return '@' . $m[1];
481
- return '@' . ltrim($v, '@');
482
- }
483
-
484
- /** @return array<int,string> */
485
- private static function arr(mixed $v): array
486
- {
487
- if ($v === null || $v === '') return [];
488
- if (is_array($v)) return array_values(array_filter(array_map(fn($x) => trim((string) $x), $v), fn($x) => $x !== ''));
489
- if (is_object($v)) return self::arr((array) $v);
490
- // A scalar string: split a comma-separated list, else keep as one item.
491
- $s = trim((string) $v);
492
- return str_contains($s, ',') ? self::arr(array_map('trim', explode(',', $s))) : ($s === '' ? [] : [$s]);
493
- }
494
-
495
- private static function truthy(mixed $v): bool
496
- {
497
- if (is_bool($v)) return $v;
498
- return in_array(strtolower(trim((string) $v)), ['1', 'true', 'yes', 'on'], true);
499
- }
500
-
501
- private static function falsy(mixed $v): bool
502
- {
503
- if (is_bool($v)) return !$v;
504
- return in_array(strtolower(trim((string) $v)), ['0', 'false', 'no', 'off'], true);
505
- }
506
- }
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * META — `<head>` metadata generator.
7
+ *
8
+ * Builds the standard SEO / social `<meta>` and `<link>` tags for a page and
9
+ * injects them into its `<head>`, from three sources, in this order of
10
+ * precedence:
11
+ *
12
+ * 1. the page's own PHPDOC block (`@title`, `@description`, `@image`, …),
13
+ * 2. the top-level `seo:` block of `kirigami.yaml` (config + overrides),
14
+ * 3. the loose keys of the `kirigami:` block (`description`, `keywords`,
15
+ * `author`, `person`, `lang`, `image`, …) — the same values `LD` reads.
16
+ *
17
+ * It only ever emits what it can resolve: a tag with no value is skipped, and a
18
+ * tag the page's layout already hand-writes (`<title>`, `<meta name="description">`,
19
+ * `<link rel="canonical">`, …) is left untouched — so it slots in next to an
20
+ * existing `header.php` without doubling anything up.
21
+ *
22
+ * Automatic injection is **opt-in**: it needs a top-level `seo:` block (an
23
+ * empty map, `seo: {}`, is enough). `seo: false` (or `seo: { auto: false }`)
24
+ * keeps the config values but stops the injection; no block at all means
25
+ * nothing is injected — an explicit `META::tag()` call from a template still
26
+ * emits. `LD`'s schema.org JSON-LD is injected alongside, from the same `seo:`
27
+ * block, unless `jsonld: false` (see `LD`'s own docblock).
28
+ *
29
+ * Per-page PHPDOC tags, each falling back to the generic page tag:
30
+ *
31
+ * @meta false skip metadata for this page (or @meta_ignore true)
32
+ * @meta_title <text> <title> / og:title / twitter:title (default @title)
33
+ * @meta_description … description / og / twitter (default @description / @abstract / @excerpt)
34
+ * @meta_keywords a, b <meta name="keywords"> (default @keywords)
35
+ * @meta_image <path> og:image / twitter:image (default @image / @ogimage)
36
+ * @meta_robots <rule> <meta name="robots"> (default @robots)
37
+ * @meta_type <type> og:type (default @og_type)
38
+ * @canonical <url> <link rel="canonical"> (default: derived from the file path + baseurl)
39
+ *
40
+ * Manual use from a template or an `includes` file — always emitted, `seo:`
41
+ * block or not, and still de-duplicated against the page:
42
+ *
43
+ * META::tag('twitter:image', 'https://…/card.png'); // name= or property= picked from the key
44
+ * META::link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
45
+ * META::raw('<meta name="rating" content="general">');
46
+ */
47
+ final class META
48
+ {
49
+ /** Resolved config, once per process. */
50
+ private static ?object $config = null;
51
+
52
+ /** @var array{file:?string,info:object}|null Page context from the `page_info` hook. */
53
+ private static ?array $page = null;
54
+
55
+ /** True once inject()/tags() has run, so the injector does not emit twice. */
56
+ private static bool $emitted = false;
57
+
58
+ /** @var string[] Ready-made tag lines added by hand from a template. */
59
+ private static array $extra = [];
60
+
61
+
62
+ // -----------------------------------------------------------------------
63
+ // Configuration
64
+ // -----------------------------------------------------------------------
65
+
66
+ /**
67
+ * Resolved metadata configuration, merging the `seo:` block with the loose
68
+ * keys of the `kirigami:` block.
69
+ */
70
+ public static function config(): object
71
+ {
72
+ if (self::$config !== null) return self::$config;
73
+
74
+ $data = self::data();
75
+ $raw = self::metaRaw();
76
+ $m = is_object($raw) ? $raw : new stdClass;
77
+
78
+ // Opt-in: needs a top-level `seo:` block (an empty map counts).
79
+ $enabled = is_object($raw) || $raw === true;
80
+ if ($enabled && isset($m->auto) && !self::truthy($m->auto)) $enabled = false;
81
+
82
+ $lang = $m->lang ?? $data->lang ?? $data->language ?? 'en';
83
+
84
+ $person = $m->person ?? $data->person ?? null;
85
+ $personName = is_object($person) ? ($person->name ?? null) : (is_string($person) ? $person : null);
86
+
87
+ $twitter = $m->twitter ?? $data->twitter ?? null;
88
+ $twSite = $m->twitterSite ?? (is_object($twitter) ? ($twitter->site ?? null) : (is_string($twitter) ? $twitter : null));
89
+ $twCreator = $m->twitterCreator ?? (is_object($twitter) ? ($twitter->creator ?? null) : (is_string($twitter) ? $twitter : null));
90
+
91
+ $generator = $m->generator ?? 'Kirigami';
92
+ if (self::falsy($generator)) $generator = null;
93
+
94
+ self::$config = (object) [
95
+ 'enabled' => $enabled,
96
+ 'project' => $data->project ?? $m->siteName ?? null,
97
+ 'tagline' => $m->tagline ?? $data->tagline ?? null,
98
+ 'titleFormat' => (string) ($m->titleFormat ?? '{title} — {project}'),
99
+ 'titleFormatHome' => (string) ($m->titleFormatHome ?? '{project} — {tagline}'),
100
+ 'description' => $m->description ?? $data->description ?? null,
101
+ 'keywords' => self::arr($m->keywords ?? $data->keywords ?? null),
102
+ 'robots' => $m->robots ?? 'index, follow',
103
+ 'language' => $lang,
104
+ 'generator' => $generator,
105
+ 'author' => $m->author ?? $data->author ?? $personName ?? null,
106
+ 'designer' => $m->designer ?? $data->designer ?? null,
107
+ 'themeColor' => $m->themeColor ?? $m->themecolor ?? $data->themecolor ?? null,
108
+ 'image' => self::absUrl($m->image ?? $m->logo ?? $data->image ?? $data->ogimage ?? null),
109
+ 'ogType' => $m->ogType ?? $m->ogtype ?? 'website',
110
+ 'twitterCard' => $m->twitterCard ?? $m->twittercard ?? 'summary_large_image',
111
+ 'twitterSite' => self::handle($twSite),
112
+ 'twitterCreator' => self::handle($twCreator),
113
+ 'canonical' => !isset($m->canonical) || self::truthy($m->canonical),
114
+ 'favicon' => $m->favicon ?? null,
115
+ 'appleTouchIcon' => $m->appleTouchIcon ?? $m->appletouchicon ?? null,
116
+ 'humans' => $m->humans ?? null,
117
+ ];
118
+
119
+ return self::$config;
120
+ }
121
+
122
+
123
+ // -----------------------------------------------------------------------
124
+ // Manual API
125
+ // -----------------------------------------------------------------------
126
+
127
+ /**
128
+ * Adds a `<meta>` tag. The key picks the attribute: `og:*` → `property=`,
129
+ * everything else → `name=`. An empty `$content` is a no-op.
130
+ */
131
+ public static function tag(string $name, ?string $content): void
132
+ {
133
+ $name = trim($name);
134
+ $content = $content === null ? '' : trim($content);
135
+ if ($name === '' || $content === '') return;
136
+
137
+ $attr = str_starts_with(strtolower($name), 'og:') ? 'property' : 'name';
138
+ self::$extra[] = '<meta ' . $attr . '="' . STR::htmlesc($name) . '" content="' . STR::htmlesc($content) . '">';
139
+ }
140
+
141
+ /** Adds a `<link>` tag. `$attrs` are extra attributes (`type`, `sizes`, …). */
142
+ public static function link(string $rel, string $href, array $attrs = []): void
143
+ {
144
+ if (trim($rel) === '' || trim($href) === '') return;
145
+ $out = '<link rel="' . STR::htmlesc($rel) . '"';
146
+ foreach ($attrs as $k => $v) {
147
+ if ($v === null || $v === '' || $v === false) continue;
148
+ $out .= ' ' . $k . '="' . STR::htmlesc((string) $v) . '"';
149
+ }
150
+ $out .= ' href="' . STR::htmlesc($href) . '">';
151
+ self::$extra[] = $out;
152
+ }
153
+
154
+ /** Adds a verbatim tag line (already valid HTML). */
155
+ public static function raw(string $html): void
156
+ {
157
+ $html = trim($html);
158
+ if ($html !== '') self::$extra[] = $html;
159
+ }
160
+
161
+ /** Clears the per-render state. */
162
+ public static function reset(): void
163
+ {
164
+ self::$page = null;
165
+ self::$emitted = false;
166
+ self::$extra = [];
167
+ }
168
+
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Output
172
+ // -----------------------------------------------------------------------
173
+
174
+ /**
175
+ * The full block of tag lines (`\n`-joined), each dropped when `$html`
176
+ * already carries an equivalent tag. `''` when there is nothing to emit.
177
+ */
178
+ public static function tags(string $html = ''): string
179
+ {
180
+ self::$emitted = true;
181
+
182
+ $c = self::config();
183
+ $info = self::pageInfo();
184
+
185
+ /** @var array<int,array{0:string,1:string}> [probe regex, tag html] */
186
+ $lines = [];
187
+
188
+ // The auto block only builds when the `seo:` block opted in; an
189
+ // explicit META::tag() call still lands through self::$extra below.
190
+ if ($c->enabled) {
191
+ $relroot = self::relroot();
192
+
193
+ $title = self::pageTitle();
194
+ $desc = self::pageTag('meta_description', 'metadescription')
195
+ ?? ($info->description ?? $info->abstract ?? $info->excerpt ?? $info->summary ?? null);
196
+ $desc = is_string($desc) && trim($desc) !== '' ? trim($desc) : ($c->description ?: null);
197
+ $keywords = self::arr(self::pageTag('meta_keywords', 'metakeywords')) ?: $c->keywords;
198
+ $image = self::absUrl(self::pageTag('meta_image', 'metaimage') ?? $info->image ?? $info->ogimage ?? null) ?? $c->image;
199
+ $robots = self::pageTag('meta_robots', 'metarobots', 'robots') ?? $c->robots;
200
+ $ogType = self::pageTag('meta_type', 'metatype', 'og_type', 'ogtype') ?? $c->ogType;
201
+ $url = self::pageTag('canonical') ?? self::pageUrl();
202
+
203
+ if ($title) $lines[] = ['#<title[\s>]#i', '<title>' . STR::htmlesc($title) . '</title>'];
204
+
205
+ self::name($lines, 'description', $desc);
206
+ self::name($lines, 'keywords', $keywords ? implode(', ', $keywords) : null);
207
+ self::name($lines, 'robots', $robots && !self::falsy($robots) ? (string) $robots : null);
208
+ self::name($lines, 'language', $c->language);
209
+ self::name($lines, 'generator', $c->generator);
210
+ self::name($lines, 'author', $c->author);
211
+ self::name($lines, 'designer', $c->designer);
212
+ self::name($lines, 'theme-color', $c->themeColor);
213
+
214
+ self::name($lines, 'twitter:card', $c->twitterCard);
215
+ self::name($lines, 'twitter:title', $title);
216
+ self::name($lines, 'twitter:description', $desc);
217
+ self::name($lines, 'twitter:image', $image);
218
+ self::name($lines, 'twitter:site', $c->twitterSite);
219
+ self::name($lines, 'twitter:creator', $c->twitterCreator);
220
+
221
+ self::prop($lines, 'og:site_name', $c->project);
222
+ self::prop($lines, 'og:locale', $c->language ? str_replace('-', '_', $c->language) : null);
223
+ self::prop($lines, 'og:type', $ogType);
224
+ self::prop($lines, 'og:title', $title);
225
+ self::prop($lines, 'og:description', $desc);
226
+ self::prop($lines, 'og:url', $url);
227
+ self::prop($lines, 'og:image', $image);
228
+
229
+ if ($c->canonical && $url) {
230
+ $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])canonical\1#i',
231
+ '<link rel="canonical" href="' . STR::htmlesc($url) . '">'];
232
+ }
233
+
234
+ if ($h = self::asset($c->humans, 'humans', 'humans.txt', $relroot)) {
235
+ $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])author\1#i',
236
+ '<link rel="author" type="text/plain" href="' . STR::htmlesc($h) . '">'];
237
+ }
238
+ if ($f = self::asset($c->favicon, 'favicon', 'favicon.ico', $relroot)) {
239
+ $type = str_ends_with($f, '.svg') ? 'image/svg+xml' : (str_ends_with($f, '.png') ? 'image/png' : 'image/x-icon');
240
+ $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])icon\1#i',
241
+ '<link rel="icon" type="' . $type . '" href="' . STR::htmlesc($f) . '">'];
242
+ }
243
+ if ($a = self::asset($c->appleTouchIcon, 'appleTouchIcon', 'apple-touch-icon.png', $relroot)) {
244
+ $lines[] = ['#<link\s+[^>]*rel\s*=\s*(["\'])apple-touch-icon\1#i',
245
+ '<link rel="apple-touch-icon" href="' . STR::htmlesc($a) . '">'];
246
+ }
247
+ }
248
+
249
+ $out = [];
250
+ $seen = [];
251
+ foreach ($lines as [$probe, $tag]) {
252
+ if (isset($seen[$tag])) continue;
253
+ if ($html !== '' && preg_match($probe, $html)) continue;
254
+ $seen[$tag] = true;
255
+ $out[] = $tag;
256
+ }
257
+ foreach (self::$extra as $tag) {
258
+ if (isset($seen[$tag])) continue;
259
+ $seen[$tag] = true;
260
+ $out[] = $tag;
261
+ }
262
+
263
+ return implode("\n", $out);
264
+ }
265
+
266
+
267
+ // -----------------------------------------------------------------------
268
+ // Render hooks (wired in prepros.plugins.php)
269
+ // -----------------------------------------------------------------------
270
+
271
+ /** Captures the page under render. Called from the `page_info` hook. */
272
+ public static function capture(mixed $info): void
273
+ {
274
+ self::reset();
275
+ self::$page = ['file' => PREPROS::$file ?: null, 'info' => is_object($info) ? $info : new stdClass];
276
+ }
277
+
278
+ /**
279
+ * Injects the tag block right before `</head>` (after any `<meta charset>`
280
+ * / `<title>` the layout wrote). Called from the `post_render` hook.
281
+ */
282
+ public static function inject(string $html): string
283
+ {
284
+ try {
285
+ if (self::$emitted) return $html;
286
+ if (self::pageOptedOut()) return $html;
287
+ if (!self::config()->enabled && self::$extra === []) return $html;
288
+
289
+ $block = self::tags($html);
290
+ if ($block === '') return $html;
291
+
292
+ $block = ' ' . str_replace("\n", "\n ", $block) . "\n";
293
+ return preg_match('#</head>#i', $html)
294
+ ? preg_replace('#</head>#i', $block . '</head>', $html, 1)
295
+ : $block . $html;
296
+ } finally {
297
+ self::reset();
298
+ }
299
+ }
300
+
301
+
302
+ // -----------------------------------------------------------------------
303
+ // Internals
304
+ // -----------------------------------------------------------------------
305
+
306
+ private static function name(array &$lines, string $name, ?string $content): void
307
+ {
308
+ self::meta($lines, 'name', $name, $content);
309
+ }
310
+
311
+ private static function prop(array &$lines, string $prop, ?string $content): void
312
+ {
313
+ self::meta($lines, 'property', $prop, $content);
314
+ }
315
+
316
+ /**
317
+ * Queues a `<meta $attr="$key" content="$content">`, with a probe that
318
+ * matches the same key under *either* `name=` or `property=` — a layout that
319
+ * wrote `og:title` as `name=` (or `twitter:image` as `property=`) still
320
+ * counts as already present.
321
+ */
322
+ private static function meta(array &$lines, string $attr, string $key, ?string $content): void
323
+ {
324
+ $content = $content === null ? '' : trim($content);
325
+ if ($content === '') return;
326
+ $lines[] = [
327
+ '#<meta\s+[^>]*(?:name|property)\s*=\s*(["\'])' . preg_quote($key, '#') . '\1#i',
328
+ '<meta ' . $attr . '="' . $key . '" content="' . STR::htmlesc($content) . '">',
329
+ ];
330
+ }
331
+
332
+ /** The composed page title, per `titleFormat` / `titleFormatHome`. */
333
+ private static function pageTitle(): ?string
334
+ {
335
+ $c = self::config();
336
+ $info = self::pageInfo();
337
+ $page = self::pageTag('meta_title', 'metatitle') ?? $info->title ?? $info->name ?? null;
338
+
339
+ $tokens = [
340
+ '{title}' => is_string($page) ? trim($page) : '',
341
+ '{project}' => (string) ($c->project ?? ''),
342
+ '{tagline}' => (string) ($c->tagline ?? ''),
343
+ ];
344
+
345
+ $fmt = (!$tokens['{title}'] || $tokens['{title}'] === $tokens['{project}'])
346
+ ? $c->titleFormatHome
347
+ : $c->titleFormat;
348
+
349
+ $out = strtr($fmt, $tokens);
350
+ // Collapse separators left dangling by an empty token.
351
+ $out = preg_replace('/\s*[|\x{2013}\x{2014}\-\/·:]\s*(?=$|[|\x{2013}\x{2014}\-\/·:])/u', '', $out);
352
+ $out = trim(preg_replace('/^\s*[|\x{2013}\x{2014}\-\/·:]\s*|\s*[|\x{2013}\x{2014}\-\/·:]\s*$/u', '', $out));
353
+
354
+ return $out !== '' ? $out : ($tokens['{project}'] ?: null);
355
+ }
356
+
357
+ /** The `kirigami:` block. */
358
+ private static function data(): object
359
+ {
360
+ if (isset(PREPROS::$config) && is_object(PREPROS::$config) && isset(PREPROS::$config->data) && is_object(PREPROS::$config->data)) {
361
+ return PREPROS::$config->data;
362
+ }
363
+ return new stdClass;
364
+ }
365
+
366
+ /** The raw top-level `seo:` block: object, `false`, `true`, or `null`. */
367
+ private static function metaRaw(): mixed
368
+ {
369
+ return (isset(PREPROS::$config) && is_object(PREPROS::$config)) ? (PREPROS::$config->seo ?? null) : null;
370
+ }
371
+
372
+ private static function pageInfo(): object
373
+ {
374
+ return self::$page['info'] ?? new stdClass;
375
+ }
376
+
377
+ /** First non-empty, non-boolean value among the given PHPDOC tag names. */
378
+ private static function pageTag(string ...$names): ?string
379
+ {
380
+ $info = self::pageInfo();
381
+ foreach ($names as $n) {
382
+ $v = $info->$n ?? null;
383
+ if (is_string($v) && trim($v) !== '' && !self::truthy($v) && !self::falsy($v)) return trim($v);
384
+ }
385
+ return null;
386
+ }
387
+
388
+ /** `@meta false` / `@meta_ignore true` → skip this page. */
389
+ private static function pageOptedOut(): bool
390
+ {
391
+ $info = self::pageInfo();
392
+ $v = $info->meta ?? null;
393
+ if (is_string($v) && self::falsy($v)) return true;
394
+ return isset($info->meta_ignore) && self::truthy($info->meta_ignore);
395
+ }
396
+
397
+ /** Absolute URL of the page currently under render (mirrors PREPROS::render()). */
398
+ private static function pageUrl(): ?string
399
+ {
400
+ $data = self::data();
401
+ $file = self::$page['file'] ?? (PREPROS::$file ?: null);
402
+ if (!$file || empty($data->baseurl)) return null;
403
+
404
+ $root = @realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? '');
405
+ $abs = @realpath($file) ?: $file;
406
+ $rel = str_replace('\\', '/', pathinfo(str_replace($root, '', $abs), PATHINFO_DIRNAME));
407
+
408
+ $basepath = rtrim((string) parse_url($data->baseurl, PHP_URL_PATH), '/');
409
+ $path = preg_replace('#/+#', '/', $basepath . '/' . trim($rel, '/') . '/');
410
+ $origin = preg_replace('#^(https?://[^/]+).*#', '$1', (string) $data->baseurl);
411
+
412
+ return $origin . $path;
413
+ }
414
+
415
+ /** Path from the page's directory back to the source root, e.g. `../` or `./`. */
416
+ private static function relroot(): string
417
+ {
418
+ $file = self::$page['file'] ?? (PREPROS::$file ?: '');
419
+ if ($file === '') return './';
420
+ $dir = str_replace('\\', '/', dirname((string) (@realpath($file) ?: $file))) . '/';
421
+ $root = str_replace('\\', '/', (string) (@realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? ''))) . '/';
422
+ return FS::getRelativePath($dir, $root);
423
+ }
424
+
425
+ /**
426
+ * Resolves an asset link: an explicit string is used as-is (page-relative
427
+ * when it is a bare filename); `true` forces the default file; `null`
428
+ * auto-detects the default file at the source root (presence reported by
429
+ * `prepros.js` via `metaFiles`, since these extensions are not mounted);
430
+ * `false` disables it.
431
+ */
432
+ private static function asset(mixed $value, string $key, string $default, string $relroot): ?string
433
+ {
434
+ if (self::falsy($value)) return null;
435
+
436
+ if (is_string($value) && trim($value) !== '') {
437
+ $v = trim($value);
438
+ if (preg_match('#^(https?:)?//#', $v) || str_starts_with($v, '/') || str_starts_with($v, '.')) return $v;
439
+ return rtrim($relroot, '/') . '/' . ltrim($v, '/');
440
+ }
441
+
442
+ if ($value === true) return rtrim($relroot, '/') . '/' . $default;
443
+
444
+ $files = (isset(PREPROS::$config) && is_object(PREPROS::$config)) ? (PREPROS::$config->metaFiles ?? null) : null;
445
+ $found = is_object($files) ? ($files->$key ?? false) : false;
446
+ if (!$found) {
447
+ // Fall back to a direct check, for files that do live in the sandbox.
448
+ $root = (string) (@realpath(PREPROS::$config->root ?? '') ?: (PREPROS::$config->root ?? ''));
449
+ $found = $root !== '' && is_file($root . '/' . $default);
450
+ }
451
+ return $found ? rtrim($relroot, '/') . '/' . $default : null;
452
+ }
453
+
454
+ /** Turns a relative path into an absolute URL against `baseurl`. */
455
+ private static function absUrl(?string $url): ?string
456
+ {
457
+ if ($url === null || trim($url) === '') return null;
458
+ $url = trim($url);
459
+ if (preg_match('#^(https?:)?//#', $url) || str_starts_with($url, 'data:')) return $url;
460
+
461
+ $origin = preg_replace('#^(https?://[^/]+).*#', '$1', (string) (self::data()->baseurl ?? ''));
462
+ return $origin === '' ? $url : $origin . '/' . ltrim($url, '/');
463
+ }
464
+
465
+ /** `@user` / `user` / `https://twitter.com/user` → `@user`. */
466
+ private static function handle(mixed $v): ?string
467
+ {
468
+ if (!is_string($v) || trim($v) === '') return null;
469
+ $v = trim($v);
470
+ if (preg_match('#(?:twitter|x)\.com/@?([A-Za-z0-9_]{1,15})#i', $v, $m)) return '@' . $m[1];
471
+ return '@' . ltrim($v, '@');
472
+ }
473
+
474
+ /** @return array<int,string> */
475
+ private static function arr(mixed $v): array
476
+ {
477
+ if ($v === null || $v === '') return [];
478
+ if (is_array($v)) return array_values(array_filter(array_map(fn($x) => trim((string) $x), $v), fn($x) => $x !== ''));
479
+ if (is_object($v)) return self::arr((array) $v);
480
+ // A scalar string: split a comma-separated list, else keep as one item.
481
+ $s = trim((string) $v);
482
+ return str_contains($s, ',') ? self::arr(array_map('trim', explode(',', $s))) : ($s === '' ? [] : [$s]);
483
+ }
484
+
485
+ private static function truthy(mixed $v): bool
486
+ {
487
+ if (is_bool($v)) return $v;
488
+ return in_array(strtolower(trim((string) $v)), ['1', 'true', 'yes', 'on'], true);
489
+ }
490
+
491
+ private static function falsy(mixed $v): bool
492
+ {
493
+ if (is_bool($v)) return !$v;
494
+ return in_array(strtolower(trim((string) $v)), ['0', 'false', 'no', 'off'], true);
495
+ }
496
+ }