@kirigami/php-prepros 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/package.json +1 -1
- package/src/libraries/aliases.inc.php +43 -0
- package/src/libraries/meta.class.php +506 -0
- package/src/libraries/prepros.class.php +12 -5
- package/src/libraries/prepros.plugins.php +6 -3
- package/src/prepros.js +28 -7
- package/src/utils.inc.php +1 -0
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ Part of the **Kirigami** project ecosystem.
|
|
|
34
34
|
- [@kirigami/php-prepros](#kirigamiphp-prepros)
|
|
35
35
|
- [Overview](#overview)
|
|
36
36
|
- [Table of contents](#table-of-contents)
|
|
37
|
+
- [What's new in 1.7.1](#whats-new-in-171)
|
|
38
|
+
- [What's new in 1.7.0](#whats-new-in-170)
|
|
37
39
|
- [What's new in 1.6.0](#whats-new-in-160)
|
|
38
40
|
- [What's new in 1.4.0](#whats-new-in-140)
|
|
39
41
|
- [What's new in 1.3.0](#whats-new-in-130)
|
|
@@ -44,6 +46,7 @@ Part of the **Kirigami** project ecosystem.
|
|
|
44
46
|
- [Configuration — `kirigami.yaml`](#configuration--kirigamiyaml)
|
|
45
47
|
- [`kirigami` block](#kirigami-block)
|
|
46
48
|
- [`jsonld` block](#jsonld-block)
|
|
49
|
+
- [`meta` block](#meta-block)
|
|
47
50
|
- [`prepros` block](#prepros-block)
|
|
48
51
|
- [`image` block](#image-block)
|
|
49
52
|
- [`plugins` block](#plugins-block)
|
|
@@ -77,6 +80,8 @@ Part of the **Kirigami** project ecosystem.
|
|
|
77
80
|
- [Automatic mode](#automatic-mode)
|
|
78
81
|
- [Explicit builders](#explicit-builders)
|
|
79
82
|
- [`jsonld` config](#jsonld-config)
|
|
83
|
+
- [META](#meta)
|
|
84
|
+
- [`meta` config](#meta-config)
|
|
80
85
|
- [CACHE](#cache)
|
|
81
86
|
- [IMG](#img)
|
|
82
87
|
- [FS](#fs)
|
|
@@ -103,6 +108,40 @@ Part of the **Kirigami** project ecosystem.
|
|
|
103
108
|
|
|
104
109
|
---
|
|
105
110
|
|
|
111
|
+
## What's new in 1.7.1
|
|
112
|
+
|
|
113
|
+
- **No side effects on import.** `kirigami.yaml` is now loaded on first use
|
|
114
|
+
(`render()` / `sitemap()` / `runenv()` / `processImages()`), not while the
|
|
115
|
+
module is being imported. `import '@kirigami/php-prepros'` from a directory
|
|
116
|
+
with no project no longer throws — which is what made `kiri build --help` /
|
|
117
|
+
`kiri export --help` / `kiri run --help` crash instead of printing their help.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## What's new in 1.7.0
|
|
122
|
+
|
|
123
|
+
- **`META`** class — a `<head>` SEO / social metadata generator, the companion
|
|
124
|
+
to [`LD`](#ld). Builds the standard tags — `<title>`, `description`,
|
|
125
|
+
`keywords`, `robots`, `language`, `generator`, `author`, Open Graph, Twitter
|
|
126
|
+
Card, `<link rel="canonical">`, favicon / apple-touch-icon / humans — from
|
|
127
|
+
each page's PHPDOC, the top-level `meta:` block, and the loose `kirigami:` /
|
|
128
|
+
`jsonld:` keys `LD` already reads. It emits only what it can resolve, and
|
|
129
|
+
leaves any tag the layout already hand-writes untouched.
|
|
130
|
+
|
|
131
|
+
- **Opt-in**: a top-level `meta:` block (empty `meta: {}` is enough) switches
|
|
132
|
+
on automatic injection into every page's `<head>`. `meta: false` (or
|
|
133
|
+
`{ auto: false }`) keeps the config but stops the injection.
|
|
134
|
+
- Per-page PHPDOC: `@meta false` (skip), `@meta_title`, `@meta_description`
|
|
135
|
+
(falls back to `@description` / `@abstract` / `@excerpt`), `@meta_keywords`,
|
|
136
|
+
`@meta_image`, `@meta_robots`, `@meta_type`, `@canonical`.
|
|
137
|
+
- Manual builders — always emitted, still de-duplicated: `META::tag()`,
|
|
138
|
+
`META::link()`, `META::raw()`, `META::tags()`. Procedural aliases:
|
|
139
|
+
`meta_tag()`, `meta_link()`, `meta_raw()`, `meta_tags()`.
|
|
140
|
+
|
|
141
|
+
Full key reference: [`META` → `meta` config](#meta-config).
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
106
145
|
## What's new in 1.6.0
|
|
107
146
|
|
|
108
147
|
- **Managed `<head>` (`prepros.head`).** Every rendered page's `<head>` is now
|
|
@@ -371,6 +410,19 @@ otherwise infers from the `kirigami` block and each page's PHPDOC. `jsonld: fals
|
|
|
371
410
|
no block at all means nothing is injected. Full key reference and per-page
|
|
372
411
|
`@ld_*` tags: [`LD` → `jsonld` config](#jsonld-config).
|
|
373
412
|
|
|
413
|
+
### `meta` block
|
|
414
|
+
|
|
415
|
+
Top-level, optional. Its **presence** switches on the [`META`](#meta) generator —
|
|
416
|
+
the standard SEO / social `<meta>` and `<link>` tags are then built for every
|
|
417
|
+
page and injected into its `<head>`. An empty `meta: {}` is enough; everything is
|
|
418
|
+
derived from the `kirigami` block, the `jsonld` block, and each page's PHPDOC
|
|
419
|
+
(`@title`, `@description` / `@abstract`, `@keywords`, `@image`, `@robots`,
|
|
420
|
+
`@og_type`, `@canonical`). Keys refine those inferences. A tag the layout already
|
|
421
|
+
hand-writes is left untouched. `meta: false` (or `meta: { auto: false }`) keeps
|
|
422
|
+
the config values but stops the injection; no block at all means nothing is
|
|
423
|
+
injected (explicit `META::tag()` / `meta_tag()` calls still emit). Full key
|
|
424
|
+
reference and per-page `@meta_*` tags: [`META` → `meta` config](#meta-config).
|
|
425
|
+
|
|
374
426
|
### `prepros` block
|
|
375
427
|
|
|
376
428
|
Options for the PHP → HTML compiler. **Read by `php-prepros`.** Declaring this block (even empty) also makes `kiri` prepend a forced `prepros` task on every build/export/watch.
|
|
@@ -1095,6 +1147,96 @@ jsonld: # top-level; the block being present i
|
|
|
1095
1147
|
|
|
1096
1148
|
---
|
|
1097
1149
|
|
|
1150
|
+
### META
|
|
1151
|
+
|
|
1152
|
+
A **`<head>` SEO / social metadata generator** — the companion to [`LD`](#ld).
|
|
1153
|
+
Where `LD` emits a schema.org `application/ld+json` graph, `META` emits the plain
|
|
1154
|
+
tags a browser and a link-preview crawler read: `<title>`, `<meta name="…">`,
|
|
1155
|
+
`<meta property="og:…">`, `<meta name="twitter:…">`, and a handful of `<link>`s.
|
|
1156
|
+
|
|
1157
|
+
It draws on the same sources, in this order of precedence: the page's PHPDOC, the
|
|
1158
|
+
top-level `meta:` block, then the loose `kirigami:` keys and the `jsonld:` block.
|
|
1159
|
+
Every tag is emitted **only when it can be resolved** — no value, no tag — and a
|
|
1160
|
+
tag the page's layout already writes by hand is detected and skipped, so it drops
|
|
1161
|
+
in beside an existing `header.php` without duplicating anything.
|
|
1162
|
+
|
|
1163
|
+
**Automatic mode** is opt-in: the top-level `meta:` block (empty `meta: {}` is
|
|
1164
|
+
enough) turns on injection into every page's `<head>`, right before `</head>`.
|
|
1165
|
+
|
|
1166
|
+
```yaml
|
|
1167
|
+
kirigami:
|
|
1168
|
+
project: Humain Humain
|
|
1169
|
+
baseurl: https://humainhumain.com
|
|
1170
|
+
tagline: Ethnographie au service des organisations
|
|
1171
|
+
description: A social-science consultancy using ethnography for organisational change.
|
|
1172
|
+
keywords: [ethnographie, consultation publique, sciences sociales]
|
|
1173
|
+
author: Maxime Larrivée-Roy
|
|
1174
|
+
|
|
1175
|
+
jsonld: {} # META reads its logo / image / lang / person too
|
|
1176
|
+
meta: # top-level; the block being present is the switch
|
|
1177
|
+
twitter: "@humainhumain"
|
|
1178
|
+
themeColor: "#0b7285"
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
Per-page, from the PHPDOC block — each falls back to the generic page tag:
|
|
1182
|
+
|
|
1183
|
+
| Tag | Feeds | Default |
|
|
1184
|
+
|-----|-------|---------|
|
|
1185
|
+
| `@meta false` | skip metadata for this page entirely | — (`@meta_ignore true` also works) |
|
|
1186
|
+
| `@meta_title` | `<title>`, `og:title`, `twitter:title` | `@title` |
|
|
1187
|
+
| `@meta_description` | `description`, `og:description`, `twitter:description` | `@description` / `@abstract` / `@excerpt` / `@summary`, then the site `description` |
|
|
1188
|
+
| `@meta_keywords` | `<meta name="keywords">` | `@keywords`, then `meta.keywords` |
|
|
1189
|
+
| `@meta_image` | `og:image`, `twitter:image` | `@image` / `@ogimage`, then `meta.image` |
|
|
1190
|
+
| `@meta_robots` | `<meta name="robots">` | `@robots`, then `meta.robots` |
|
|
1191
|
+
| `@meta_type` | `og:type` | `@og_type`, then `meta.ogType` |
|
|
1192
|
+
| `@canonical` | `<link rel="canonical">` | derived from the file path + `baseurl` |
|
|
1193
|
+
|
|
1194
|
+
**Manual builders** — always emitted (with or without a `meta:` block), still
|
|
1195
|
+
de-duplicated against the page:
|
|
1196
|
+
|
|
1197
|
+
```php
|
|
1198
|
+
META::tag(string $name, ?string $content): void // name= , or property= for an og:* key
|
|
1199
|
+
META::link(string $rel, string $href, array $attrs = []): void
|
|
1200
|
+
META::raw(string $html): void // a verbatim, already-valid tag line
|
|
1201
|
+
META::tags(string $html = ''): string // the whole block, \n-joined
|
|
1202
|
+
META::reset(): void
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
```php
|
|
1206
|
+
META::tag('twitter:image', 'https://humainhumain.com/card.png');
|
|
1207
|
+
META::tag('og:image:alt', 'The Humain Humain team at work');
|
|
1208
|
+
META::link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
Same API from procedural code: `meta_tag()`, `meta_link()`, `meta_raw()`,
|
|
1212
|
+
`meta_tags()`.
|
|
1213
|
+
|
|
1214
|
+
#### `meta` config
|
|
1215
|
+
|
|
1216
|
+
`meta:` is a **top-level** block of `kirigami.yaml` (a sibling of `kirigami:`,
|
|
1217
|
+
`jsonld:`, `prepros:`, …). All keys are optional.
|
|
1218
|
+
|
|
1219
|
+
| Key | Type | Description |
|
|
1220
|
+
|-----|------|-------------|
|
|
1221
|
+
| `auto` | `bool` | Inject the tags automatically. Default `true` **once the `meta:` block exists**. `auto: false` (or `meta: false`) keeps the block for its values but stops the injection — `META::tags()` / `meta_tags()` can place them by hand. |
|
|
1222
|
+
| `titleFormat` | `string` | `<title>` template for a normal page. Tokens `{title}`, `{project}`, `{tagline}`. Dangling separators from an empty token are trimmed. Default `{title} — {project}`. |
|
|
1223
|
+
| `titleFormatHome` | `string` | Title template when the page has no `@title` (home / section landings). Default `{project} — {tagline}`. |
|
|
1224
|
+
| `description` | `string` | Default description for pages with no `@description` / `@abstract`. Defaults to the `jsonld` / loose `description`. |
|
|
1225
|
+
| `keywords` | `string[]` \| `string` | Default `keywords` content (list or comma string). Defaults to the `jsonld` / loose `keywords`. |
|
|
1226
|
+
| `robots` | `string` | Default robots directive. Default `index, follow`. `robots: false` omits the tag. |
|
|
1227
|
+
| `language` | `string` | BCP-47 tag → `<meta name="language">` and, dash→underscore, `og:locale`. Defaults to `jsonld.lang` / loose `lang` / `language`, then `en`. |
|
|
1228
|
+
| `generator` | `string` \| `false` | `<meta name="generator">`. Default `Kirigami`; `false` omits it. |
|
|
1229
|
+
| `author` / `designer` | `string` | Default to the loose `author` / `designer` keys (author also falls back to the `jsonld` person's name). `designer` is not emitted unless set. |
|
|
1230
|
+
| `themeColor` | `string` | `<meta name="theme-color">`. Not emitted unless set. |
|
|
1231
|
+
| `image` | `string` | Default `og:image` / `twitter:image` — absolute URL or path relative to `baseurl`. Defaults to `jsonld.image` → `jsonld.logo` → loose `image` / `ogimage`. |
|
|
1232
|
+
| `ogType` | `string` | Default `og:type`. Default `website`. |
|
|
1233
|
+
| `twitterCard` | `string` | `twitter:card` type. Default `summary_large_image`. |
|
|
1234
|
+
| `twitter` | `string` \| `map` | Handle for `twitter:site` / `twitter:creator`. A bare string (with/without `@`, or a profile URL) fills both; a map takes `site` / `creator` separately. |
|
|
1235
|
+
| `canonical` | `bool` | Emit `<link rel="canonical">`. Default `true`. |
|
|
1236
|
+
| `favicon` / `appleTouchIcon` / `humans` | `string` \| `bool` | `<link rel="icon">` / `rel="apple-touch-icon"` / `rel="author"`. A path sets it (page-relative when a bare filename); `true` forces the default file (`favicon.ico` / `apple-touch-icon.png` / `humans.txt`); omitted, the default file is auto-detected on disk at the source root; `false` disables it. |
|
|
1237
|
+
|
|
1238
|
+
---
|
|
1239
|
+
|
|
1098
1240
|
### CACHE
|
|
1099
1241
|
|
|
1100
1242
|
Persistent SQLite-backed key-value cache. Survives across incremental builds via `.cache.db` at the project root.
|
|
@@ -1364,6 +1506,7 @@ surface the signature, parameters, and description.
|
|
|
1364
1506
|
| `YAML` | `yaml_parse` · `yaml_parse_file` · `yaml_load_file` |
|
|
1365
1507
|
| `SCHEMA` | `schema` (factory) · `schema_validate` |
|
|
1366
1508
|
| `LD` | `ld_add` · `ld_node` · `ld_ref` · `ld_organization` · `ld_person` · `ld_website` · `ld_web_page` · `ld_breadcrumb` · `ld_faq_page` · `ld_script` · `ld_json` |
|
|
1509
|
+
| `META` | `meta_tag` · `meta_link` · `meta_raw` · `meta_tags` |
|
|
1367
1510
|
| `CACHE` | `cache_get` · `cache_set` · `cache_delete` · `cache_purge` |
|
|
1368
1511
|
| `IMG` | `img_asset` · `img_palette` |
|
|
1369
1512
|
| `FS` | `fs_dig` · `fs_get_relative_path` · `fs_php_file_info` · `fs_rmdir` · `fs_path_join` |
|
|
@@ -1452,6 +1595,11 @@ PREPROS::registerHook(string $hookName, callable $callback): void
|
|
|
1452
1595
|
|
|
1453
1596
|
Multiple callbacks can be registered for the same hook — they are executed in registration order, each receiving the return value of the previous one.
|
|
1454
1597
|
|
|
1598
|
+
> Built-in `page_info` + `post_render` callbacks power [`LD`](#ld) and
|
|
1599
|
+
> [`META`](#meta): `page_info` captures the page under render, `post_render`
|
|
1600
|
+
> injects the JSON-LD `<script>` and the `<meta>`/`<link>` block into its
|
|
1601
|
+
> `<head>`. Your own callbacks run after them.
|
|
1602
|
+
|
|
1455
1603
|
> **`page_info` payload shape.** The hook *fires* with `[$filePath, $pageObject]`,
|
|
1456
1604
|
> but each callback is expected to return the `$pageObject` alone — so a callback
|
|
1457
1605
|
> registered after the built-ins receives the bare object, not the pair. Handle
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirigami/php-prepros",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kirigami",
|
|
@@ -425,6 +425,49 @@ function ld_script(bool $pretty = true): string { return LD::script($pretty); }
|
|
|
425
425
|
function ld_json(bool $pretty = true): string { return LD::json($pretty); }
|
|
426
426
|
|
|
427
427
|
|
|
428
|
+
// ===========================================================================
|
|
429
|
+
// META — <head> SEO / social metadata generator
|
|
430
|
+
// ===========================================================================
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Adds a `<meta>` tag to the page's `<head>`. The key picks the attribute:
|
|
434
|
+
* `og:*` → `property=`, everything else → `name=`. An empty value is a no-op.
|
|
435
|
+
* Always emitted (and de-duplicated against the page), `meta:` block or not.
|
|
436
|
+
*
|
|
437
|
+
* meta_tag('twitter:image', 'https://…/card.png');
|
|
438
|
+
*
|
|
439
|
+
* @see META::tag()
|
|
440
|
+
*/
|
|
441
|
+
function meta_tag(string $name, ?string $content): void { META::tag($name, $content); }
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Adds a `<link>` tag to the page's `<head>`. `$attrs` are extra attributes
|
|
445
|
+
* (`type`, `sizes`, `hreflang`, …).
|
|
446
|
+
*
|
|
447
|
+
* meta_link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
|
|
448
|
+
*
|
|
449
|
+
* @param array<string,string|int|bool|null> $attrs
|
|
450
|
+
* @see META::link()
|
|
451
|
+
*/
|
|
452
|
+
function meta_link(string $rel, string $href, array $attrs = []): void { META::link($rel, $href, $attrs); }
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Adds a verbatim tag line (already valid HTML) to the page's `<head>`.
|
|
456
|
+
*
|
|
457
|
+
* @see META::raw()
|
|
458
|
+
*/
|
|
459
|
+
function meta_raw(string $html): void { META::raw($html); }
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The full block of generated `<meta>`/`<link>` lines for the current page,
|
|
463
|
+
* `\n`-joined, or `''`. Calling this places the block by hand.
|
|
464
|
+
*
|
|
465
|
+
* @param string $html Page HTML, used only to skip tags it already carries.
|
|
466
|
+
* @see META::tags()
|
|
467
|
+
*/
|
|
468
|
+
function meta_tags(string $html = ''): string { return META::tags($html); }
|
|
469
|
+
|
|
470
|
+
|
|
428
471
|
// ===========================================================================
|
|
429
472
|
// CACHE — persistent key/value (SQLite, `.cache.db` at the project root)
|
|
430
473
|
// ===========================================================================
|
|
@@ -0,0 +1,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 `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
|
+
}
|
|
@@ -142,7 +142,9 @@ final class PREPROS
|
|
|
142
142
|
*
|
|
143
143
|
* Runs unless `prepros.head` is `false`. A single task opts out with
|
|
144
144
|
* `head: false`. A file already referenced in the page is left alone.
|
|
145
|
-
*
|
|
145
|
+
* The `?###TIMESTAMP###` cache-buster is left literal at render time and
|
|
146
|
+
* only expanded on export (see replaceTokens()), so rebuilding a preview
|
|
147
|
+
* never rewrites the committed page.
|
|
146
148
|
*/
|
|
147
149
|
private static function injectHead(string $contents, string $relroot): string
|
|
148
150
|
{
|
|
@@ -208,14 +210,19 @@ final class PREPROS
|
|
|
208
210
|
/**
|
|
209
211
|
* Expands the build-time text tokens in a generated file. Runs at render
|
|
210
212
|
* time (so `kiri build` / `kiri watch` previews show real values, not the
|
|
211
|
-
* literal `###YEAR###`)
|
|
213
|
+
* literal `###YEAR###`).
|
|
214
|
+
*
|
|
215
|
+
* `###TIMESTAMP###` is deliberately NOT expanded here: it is only ever a
|
|
216
|
+
* cache-buster on the managed-`<head>` asset refs, has no preview value,
|
|
217
|
+
* and expanding it per render would rewrite every committed `src/**` page
|
|
218
|
+
* with a fresh number on each build. The export copy (bin/tasks/dist.js)
|
|
219
|
+
* substitutes it — and `###YEAR###` / `###TODAY###` — when writing `dist/`.
|
|
212
220
|
*/
|
|
213
221
|
private static function replaceTokens(string $contents): string
|
|
214
222
|
{
|
|
215
223
|
return strtr($contents, [
|
|
216
|
-
'###YEAR###'
|
|
217
|
-
'###
|
|
218
|
-
'###TODAY###' => date('Y-m-d'),
|
|
224
|
+
'###YEAR###' => date('Y'),
|
|
225
|
+
'###TODAY###' => date('Y-m-d'),
|
|
219
226
|
]);
|
|
220
227
|
}
|
|
221
228
|
|
|
@@ -65,14 +65,17 @@ PREPROS::registerHook('page_info', function($info) {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
// LD — capture the page under render, then inject the automatic
|
|
69
|
-
// schema.org JSON-LD `<script>`
|
|
70
|
-
//
|
|
68
|
+
// LD / META — capture the page under render, then inject the automatic
|
|
69
|
+
// schema.org JSON-LD `<script>` and the SEO/social `<meta>`/`<link>` block into
|
|
70
|
+
// its `<head>` once the HTML is assembled. Registered after the data-loading
|
|
71
|
+
// hook above so `$page` arrives resolved.
|
|
71
72
|
PREPROS::registerHook('page_info', function($page) {
|
|
72
73
|
LD::capture($page);
|
|
74
|
+
META::capture($page);
|
|
73
75
|
return $page;
|
|
74
76
|
});
|
|
75
77
|
|
|
76
78
|
PREPROS::registerHook('post_render', function($html) {
|
|
79
|
+
$html = META::inject($html);
|
|
77
80
|
return LD::inject($html);
|
|
78
81
|
});
|
package/src/prepros.js
CHANGED
|
@@ -12,16 +12,25 @@ const __modules = new Map;
|
|
|
12
12
|
const __project = process.cwd();
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const __configpath = path.join(__project, 'kirigami.yaml');
|
|
15
|
-
let __root
|
|
16
|
-
let __php
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
let __root = null;
|
|
16
|
+
let __php = null;
|
|
17
|
+
let __config = null;
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
// Load and cache kirigami.yaml on first use. Deferred (not run at import) so
|
|
21
|
+
// `import '@kirigami/php-prepros'` has no side effects — e.g. `kiri build -h`
|
|
22
|
+
// can pull the module in with no project on disk.
|
|
23
|
+
const loadConfig = async () => {
|
|
24
|
+
if (__config) return __config;
|
|
25
|
+
if (!fs.existsSync(__configpath)) throw `Config file not found: ${__configpath}`;
|
|
26
|
+
__config = await walkFile(__configpath);
|
|
27
|
+
if (!__config) throw `Invalid config file: ${__configpath}`;
|
|
28
|
+
return __config;
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
const getPHPInstance = async () => {
|
|
33
|
+
const config = await loadConfig();
|
|
25
34
|
if(!__php) {
|
|
26
35
|
if(config?.kirigami?.root === undefined) throw `Missing prepros:root property in config file: ${__configpath}`;
|
|
27
36
|
__root = path.join(__project, config.kirigami.root);
|
|
@@ -38,6 +47,15 @@ const getPHPInstance = async () => {
|
|
|
38
47
|
preprosConfig.root = joinWith('/project/', config?.kirigami?.root);
|
|
39
48
|
preprosConfig.data = config.kirigami || {};
|
|
40
49
|
preprosConfig.jsonld = config.jsonld ?? null;
|
|
50
|
+
preprosConfig.meta = config.meta ?? null;
|
|
51
|
+
// META auto-detects favicon / apple-touch-icon / humans.txt at the
|
|
52
|
+
// source root; those extensions aren't mounted into the sandbox, so the
|
|
53
|
+
// presence check is done here on the real filesystem instead.
|
|
54
|
+
preprosConfig.metaFiles = {
|
|
55
|
+
favicon: fs.existsSync(path.join(__root, 'favicon.ico')),
|
|
56
|
+
appleTouchIcon: fs.existsSync(path.join(__root, 'apple-touch-icon.png')),
|
|
57
|
+
humans: fs.existsSync(path.join(__root, 'humans.txt')),
|
|
58
|
+
};
|
|
41
59
|
// The build task list — read by PREPROS::injectHead() to auto-wire each
|
|
42
60
|
// page's <head> with a <link>/<script> per sass/esbuild task output.
|
|
43
61
|
preprosConfig.tasks = config.tasks || [];
|
|
@@ -91,6 +109,7 @@ const getPHPInstance = async () => {
|
|
|
91
109
|
|
|
92
110
|
|
|
93
111
|
const mountPath = async (localPath, virtualDir, php) => {
|
|
112
|
+
const config = await loadConfig();
|
|
94
113
|
php = php || await getPHPInstance();
|
|
95
114
|
if(!path.isAbsolute(localPath)) localPath = path.join(__project, localPath);
|
|
96
115
|
virtualDir = virtualDir || path.posix.join('/project', localPath.replace(__project + path.sep, ''));
|
|
@@ -242,6 +261,7 @@ const processImages = async (jobs = []) => {
|
|
|
242
261
|
|
|
243
262
|
|
|
244
263
|
const render = async (file = '.', phpIncludes = []) => {
|
|
264
|
+
const config = await loadConfig();
|
|
245
265
|
const target = path.resolve(config?.kirigami?.root, file);
|
|
246
266
|
const fsvm = path.join('/project', config?.kirigami?.root, file).replace(/\\/g, '/');
|
|
247
267
|
await mountPath(target);
|
|
@@ -269,6 +289,7 @@ const render = async (file = '.', phpIncludes = []) => {
|
|
|
269
289
|
|
|
270
290
|
|
|
271
291
|
const sitemap = async () => {
|
|
292
|
+
const config = await loadConfig();
|
|
272
293
|
await mountPath(config?.kirigami?.root);
|
|
273
294
|
return run(['sitemap']);
|
|
274
295
|
}
|
package/src/utils.inc.php
CHANGED