@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,804 +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
|
|
11
|
-
* `kirigami:` block (`person`, `jobtitle`, `area`,
|
|
12
|
-
* `facebook`, … that Kirigami projects already use).
|
|
13
|
-
*
|
|
14
|
-
* Two ways to use it, and they combine:
|
|
15
|
-
*
|
|
16
|
-
* 1. Automatic —
|
|
17
|
-
* `
|
|
18
|
-
* injects an `Organization` (+ `Person`, `WebSite`, `WebPage`, and a
|
|
19
|
-
* `BreadcrumbList` built from the `_index.php` ancestor trail) `@graph`
|
|
20
|
-
* derived from
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* @
|
|
28
|
-
* @
|
|
29
|
-
* @
|
|
30
|
-
* @
|
|
31
|
-
* @
|
|
32
|
-
* @
|
|
33
|
-
* @
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* LD::
|
|
40
|
-
* LD::
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
'
|
|
56
|
-
'
|
|
57
|
-
'
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
$
|
|
87
|
-
$
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
$enabled = is_object($raw) || $raw === true;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
$sameAs =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
$person
|
|
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
|
-
* @param
|
|
153
|
-
* @
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
$node =
|
|
159
|
-
if ($
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* @param
|
|
172
|
-
* @
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
$
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
$
|
|
195
|
-
$key
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
self::$
|
|
226
|
-
self::$
|
|
227
|
-
|
|
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
|
-
$node['
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if ($c->
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
$
|
|
283
|
-
|
|
284
|
-
'
|
|
285
|
-
'
|
|
286
|
-
'
|
|
287
|
-
'
|
|
288
|
-
'
|
|
289
|
-
'
|
|
290
|
-
|
|
291
|
-
];
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
'
|
|
304
|
-
'
|
|
305
|
-
'
|
|
306
|
-
'
|
|
307
|
-
'
|
|
308
|
-
|
|
309
|
-
];
|
|
310
|
-
if ($c->
|
|
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
|
-
'
|
|
353
|
-
'
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
$img
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
$info
|
|
383
|
-
|
|
384
|
-
$items
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
'@
|
|
445
|
-
'
|
|
446
|
-
'
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
'@type'
|
|
488
|
-
'
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
$json
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
self
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
*
|
|
537
|
-
* `
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (self
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
$script
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
self::
|
|
571
|
-
|
|
572
|
-
self::
|
|
573
|
-
self::
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
$
|
|
601
|
-
|
|
602
|
-
$v
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
$v
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
$base
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
$file
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
$data
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
$
|
|
640
|
-
$
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
$
|
|
644
|
-
$
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
$
|
|
663
|
-
$pageUrl
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
$
|
|
680
|
-
|
|
681
|
-
'
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
$
|
|
703
|
-
$
|
|
704
|
-
$
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
$
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
$index
|
|
714
|
-
|
|
715
|
-
$
|
|
716
|
-
$
|
|
717
|
-
|
|
718
|
-
'
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
$base
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if ($url
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
$origin
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
if ($v
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
$
|
|
779
|
-
$
|
|
780
|
-
|
|
781
|
-
$item
|
|
782
|
-
if ($
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
}
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* LD — schema.org JSON-LD generator.
|
|
7
|
+
*
|
|
8
|
+
* Collects schema.org nodes during a render and emits them as a single
|
|
9
|
+
* `<script type="application/ld+json">` block in the page `<head>`, built from
|
|
10
|
+
* the `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
|
+
}
|