@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.
- package/LICENSE +502 -21
- package/README.md +1988 -1817
- package/index.d.ts +62 -26
- package/index.js +1 -1
- package/package.json +59 -59
- package/src/imagebatch.php +68 -67
- package/src/libraries/aliases.inc.php +879 -879
- package/src/libraries/curl.class.php +7 -7
- package/src/libraries/ld.class.php +803 -804
- package/src/libraries/md-legacy.class.php +1257 -0
- package/src/libraries/md.class.php +51 -1249
- package/src/libraries/meta.class.php +496 -506
- package/src/libraries/{normalizer.class.php → normalizer-legacy.class.php} +11 -1
- package/src/libraries/prepros.class.php +85 -8
- package/src/libraries/schema-legacy.class.php +549 -0
- package/src/libraries/schema.class.php +99 -472
- package/src/libraries/yaml-legacy.class.php +598 -0
- package/src/libraries/yaml.class.php +41 -406
- package/src/prepros.js +152 -48
- package/src/prepros.php +4 -5
- package/src/runenv.php +2 -2
- package/src/utils.inc.php +12 -2
|
@@ -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 `
|
|
14
|
-
* 3. the loose keys of the `kirigami:` block
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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, `
|
|
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 `
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
$enabled
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
$
|
|
88
|
-
$
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
$
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
'
|
|
99
|
-
'
|
|
100
|
-
'
|
|
101
|
-
'
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'
|
|
105
|
-
'
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'
|
|
110
|
-
'
|
|
111
|
-
'
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
$
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
$
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
$
|
|
195
|
-
|
|
196
|
-
$
|
|
197
|
-
$
|
|
198
|
-
|
|
199
|
-
$
|
|
200
|
-
$
|
|
201
|
-
$
|
|
202
|
-
|
|
203
|
-
$
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
self::name($lines, '
|
|
209
|
-
self::name($lines, '
|
|
210
|
-
self::name($lines, '
|
|
211
|
-
self::name($lines, '
|
|
212
|
-
self::name($lines, '
|
|
213
|
-
|
|
214
|
-
self::name($lines, '
|
|
215
|
-
self::name($lines, '
|
|
216
|
-
|
|
217
|
-
self::name($lines, 'twitter:
|
|
218
|
-
self::name($lines, 'twitter:
|
|
219
|
-
self::name($lines, 'twitter:
|
|
220
|
-
|
|
221
|
-
self::
|
|
222
|
-
self::
|
|
223
|
-
|
|
224
|
-
self::prop($lines, 'og:
|
|
225
|
-
self::prop($lines, 'og:
|
|
226
|
-
self::prop($lines, 'og:
|
|
227
|
-
self::prop($lines, 'og:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
$
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
$block =
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
*
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
$
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
$out =
|
|
353
|
-
|
|
354
|
-
$out
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
$
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
$
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
$
|
|
419
|
-
|
|
420
|
-
$
|
|
421
|
-
|
|
422
|
-
return $
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
$
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (
|
|
479
|
-
$v
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
private static function
|
|
486
|
-
{
|
|
487
|
-
if ($v
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
+
}
|