@kirigami/php-prepros 1.6.0 → 1.7.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/README.md CHANGED
@@ -34,6 +34,7 @@ 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.0](#whats-new-in-170)
37
38
  - [What's new in 1.6.0](#whats-new-in-160)
38
39
  - [What's new in 1.4.0](#whats-new-in-140)
39
40
  - [What's new in 1.3.0](#whats-new-in-130)
@@ -44,6 +45,7 @@ Part of the **Kirigami** project ecosystem.
44
45
  - [Configuration — `kirigami.yaml`](#configuration--kirigamiyaml)
45
46
  - [`kirigami` block](#kirigami-block)
46
47
  - [`jsonld` block](#jsonld-block)
48
+ - [`meta` block](#meta-block)
47
49
  - [`prepros` block](#prepros-block)
48
50
  - [`image` block](#image-block)
49
51
  - [`plugins` block](#plugins-block)
@@ -77,6 +79,8 @@ Part of the **Kirigami** project ecosystem.
77
79
  - [Automatic mode](#automatic-mode)
78
80
  - [Explicit builders](#explicit-builders)
79
81
  - [`jsonld` config](#jsonld-config)
82
+ - [META](#meta)
83
+ - [`meta` config](#meta-config)
80
84
  - [CACHE](#cache)
81
85
  - [IMG](#img)
82
86
  - [FS](#fs)
@@ -103,6 +107,30 @@ Part of the **Kirigami** project ecosystem.
103
107
 
104
108
  ---
105
109
 
110
+ ## What's new in 1.7.0
111
+
112
+ - **`META`** class — a `<head>` SEO / social metadata generator, the companion
113
+ to [`LD`](#ld). Builds the standard tags — `<title>`, `description`,
114
+ `keywords`, `robots`, `language`, `generator`, `author`, Open Graph, Twitter
115
+ Card, `<link rel="canonical">`, favicon / apple-touch-icon / humans — from
116
+ each page's PHPDOC, the top-level `meta:` block, and the loose `kirigami:` /
117
+ `jsonld:` keys `LD` already reads. It emits only what it can resolve, and
118
+ leaves any tag the layout already hand-writes untouched.
119
+
120
+ - **Opt-in**: a top-level `meta:` block (empty `meta: {}` is enough) switches
121
+ on automatic injection into every page's `<head>`. `meta: false` (or
122
+ `{ auto: false }`) keeps the config but stops the injection.
123
+ - Per-page PHPDOC: `@meta false` (skip), `@meta_title`, `@meta_description`
124
+ (falls back to `@description` / `@abstract` / `@excerpt`), `@meta_keywords`,
125
+ `@meta_image`, `@meta_robots`, `@meta_type`, `@canonical`.
126
+ - Manual builders — always emitted, still de-duplicated: `META::tag()`,
127
+ `META::link()`, `META::raw()`, `META::tags()`. Procedural aliases:
128
+ `meta_tag()`, `meta_link()`, `meta_raw()`, `meta_tags()`.
129
+
130
+ Full key reference: [`META` → `meta` config](#meta-config).
131
+
132
+ ---
133
+
106
134
  ## What's new in 1.6.0
107
135
 
108
136
  - **Managed `<head>` (`prepros.head`).** Every rendered page's `<head>` is now
@@ -371,6 +399,19 @@ otherwise infers from the `kirigami` block and each page's PHPDOC. `jsonld: fals
371
399
  no block at all means nothing is injected. Full key reference and per-page
372
400
  `@ld_*` tags: [`LD` → `jsonld` config](#jsonld-config).
373
401
 
402
+ ### `meta` block
403
+
404
+ Top-level, optional. Its **presence** switches on the [`META`](#meta) generator —
405
+ the standard SEO / social `<meta>` and `<link>` tags are then built for every
406
+ page and injected into its `<head>`. An empty `meta: {}` is enough; everything is
407
+ derived from the `kirigami` block, the `jsonld` block, and each page's PHPDOC
408
+ (`@title`, `@description` / `@abstract`, `@keywords`, `@image`, `@robots`,
409
+ `@og_type`, `@canonical`). Keys refine those inferences. A tag the layout already
410
+ hand-writes is left untouched. `meta: false` (or `meta: { auto: false }`) keeps
411
+ the config values but stops the injection; no block at all means nothing is
412
+ injected (explicit `META::tag()` / `meta_tag()` calls still emit). Full key
413
+ reference and per-page `@meta_*` tags: [`META` → `meta` config](#meta-config).
414
+
374
415
  ### `prepros` block
375
416
 
376
417
  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 +1136,96 @@ jsonld: # top-level; the block being present i
1095
1136
 
1096
1137
  ---
1097
1138
 
1139
+ ### META
1140
+
1141
+ A **`<head>` SEO / social metadata generator** — the companion to [`LD`](#ld).
1142
+ Where `LD` emits a schema.org `application/ld+json` graph, `META` emits the plain
1143
+ tags a browser and a link-preview crawler read: `<title>`, `<meta name="…">`,
1144
+ `<meta property="og:…">`, `<meta name="twitter:…">`, and a handful of `<link>`s.
1145
+
1146
+ It draws on the same sources, in this order of precedence: the page's PHPDOC, the
1147
+ top-level `meta:` block, then the loose `kirigami:` keys and the `jsonld:` block.
1148
+ Every tag is emitted **only when it can be resolved** — no value, no tag — and a
1149
+ tag the page's layout already writes by hand is detected and skipped, so it drops
1150
+ in beside an existing `header.php` without duplicating anything.
1151
+
1152
+ **Automatic mode** is opt-in: the top-level `meta:` block (empty `meta: {}` is
1153
+ enough) turns on injection into every page's `<head>`, right before `</head>`.
1154
+
1155
+ ```yaml
1156
+ kirigami:
1157
+ project: Humain Humain
1158
+ baseurl: https://humainhumain.com
1159
+ tagline: Ethnographie au service des organisations
1160
+ description: A social-science consultancy using ethnography for organisational change.
1161
+ keywords: [ethnographie, consultation publique, sciences sociales]
1162
+ author: Maxime Larrivée-Roy
1163
+
1164
+ jsonld: {} # META reads its logo / image / lang / person too
1165
+ meta: # top-level; the block being present is the switch
1166
+ twitter: "@humainhumain"
1167
+ themeColor: "#0b7285"
1168
+ ```
1169
+
1170
+ Per-page, from the PHPDOC block — each falls back to the generic page tag:
1171
+
1172
+ | Tag | Feeds | Default |
1173
+ |-----|-------|---------|
1174
+ | `@meta false` | skip metadata for this page entirely | — (`@meta_ignore true` also works) |
1175
+ | `@meta_title` | `<title>`, `og:title`, `twitter:title` | `@title` |
1176
+ | `@meta_description` | `description`, `og:description`, `twitter:description` | `@description` / `@abstract` / `@excerpt` / `@summary`, then the site `description` |
1177
+ | `@meta_keywords` | `<meta name="keywords">` | `@keywords`, then `meta.keywords` |
1178
+ | `@meta_image` | `og:image`, `twitter:image` | `@image` / `@ogimage`, then `meta.image` |
1179
+ | `@meta_robots` | `<meta name="robots">` | `@robots`, then `meta.robots` |
1180
+ | `@meta_type` | `og:type` | `@og_type`, then `meta.ogType` |
1181
+ | `@canonical` | `<link rel="canonical">` | derived from the file path + `baseurl` |
1182
+
1183
+ **Manual builders** — always emitted (with or without a `meta:` block), still
1184
+ de-duplicated against the page:
1185
+
1186
+ ```php
1187
+ META::tag(string $name, ?string $content): void // name= , or property= for an og:* key
1188
+ META::link(string $rel, string $href, array $attrs = []): void
1189
+ META::raw(string $html): void // a verbatim, already-valid tag line
1190
+ META::tags(string $html = ''): string // the whole block, \n-joined
1191
+ META::reset(): void
1192
+ ```
1193
+
1194
+ ```php
1195
+ META::tag('twitter:image', 'https://humainhumain.com/card.png');
1196
+ META::tag('og:image:alt', 'The Humain Humain team at work');
1197
+ META::link('icon', './favicon.svg', ['type' => 'image/svg+xml']);
1198
+ ```
1199
+
1200
+ Same API from procedural code: `meta_tag()`, `meta_link()`, `meta_raw()`,
1201
+ `meta_tags()`.
1202
+
1203
+ #### `meta` config
1204
+
1205
+ `meta:` is a **top-level** block of `kirigami.yaml` (a sibling of `kirigami:`,
1206
+ `jsonld:`, `prepros:`, …). All keys are optional.
1207
+
1208
+ | Key | Type | Description |
1209
+ |-----|------|-------------|
1210
+ | `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. |
1211
+ | `titleFormat` | `string` | `<title>` template for a normal page. Tokens `{title}`, `{project}`, `{tagline}`. Dangling separators from an empty token are trimmed. Default `{title} — {project}`. |
1212
+ | `titleFormatHome` | `string` | Title template when the page has no `@title` (home / section landings). Default `{project} — {tagline}`. |
1213
+ | `description` | `string` | Default description for pages with no `@description` / `@abstract`. Defaults to the `jsonld` / loose `description`. |
1214
+ | `keywords` | `string[]` \| `string` | Default `keywords` content (list or comma string). Defaults to the `jsonld` / loose `keywords`. |
1215
+ | `robots` | `string` | Default robots directive. Default `index, follow`. `robots: false` omits the tag. |
1216
+ | `language` | `string` | BCP-47 tag → `<meta name="language">` and, dash→underscore, `og:locale`. Defaults to `jsonld.lang` / loose `lang` / `language`, then `en`. |
1217
+ | `generator` | `string` \| `false` | `<meta name="generator">`. Default `Kirigami`; `false` omits it. |
1218
+ | `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. |
1219
+ | `themeColor` | `string` | `<meta name="theme-color">`. Not emitted unless set. |
1220
+ | `image` | `string` | Default `og:image` / `twitter:image` — absolute URL or path relative to `baseurl`. Defaults to `jsonld.image` → `jsonld.logo` → loose `image` / `ogimage`. |
1221
+ | `ogType` | `string` | Default `og:type`. Default `website`. |
1222
+ | `twitterCard` | `string` | `twitter:card` type. Default `summary_large_image`. |
1223
+ | `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. |
1224
+ | `canonical` | `bool` | Emit `<link rel="canonical">`. Default `true`. |
1225
+ | `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. |
1226
+
1227
+ ---
1228
+
1098
1229
  ### CACHE
1099
1230
 
1100
1231
  Persistent SQLite-backed key-value cache. Survives across incremental builds via `.cache.db` at the project root.
@@ -1364,6 +1495,7 @@ surface the signature, parameters, and description.
1364
1495
  | `YAML` | `yaml_parse` · `yaml_parse_file` · `yaml_load_file` |
1365
1496
  | `SCHEMA` | `schema` (factory) · `schema_validate` |
1366
1497
  | `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` |
1498
+ | `META` | `meta_tag` · `meta_link` · `meta_raw` · `meta_tags` |
1367
1499
  | `CACHE` | `cache_get` · `cache_set` · `cache_delete` · `cache_purge` |
1368
1500
  | `IMG` | `img_asset` · `img_palette` |
1369
1501
  | `FS` | `fs_dig` · `fs_get_relative_path` · `fs_php_file_info` · `fs_rmdir` · `fs_path_join` |
@@ -1452,6 +1584,11 @@ PREPROS::registerHook(string $hookName, callable $callback): void
1452
1584
 
1453
1585
  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
1586
 
1587
+ > Built-in `page_info` + `post_render` callbacks power [`LD`](#ld) and
1588
+ > [`META`](#meta): `page_info` captures the page under render, `post_render`
1589
+ > injects the JSON-LD `<script>` and the `<meta>`/`<link>` block into its
1590
+ > `<head>`. Your own callbacks run after them.
1591
+
1455
1592
  > **`page_info` payload shape.** The hook *fires* with `[$filePath, $pageObject]`,
1456
1593
  > but each callback is expected to return the `$pageObject` alone — so a callback
1457
1594
  > 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.6.0",
3
+ "version": "1.7.0",
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
- * `###TIMESTAMP###` is expanded by replaceTokens() right after.
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###`), and again — harmlessly — on export.
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###' => date('Y'),
217
- '###TIMESTAMP###' => (string) time(),
218
- '###TODAY###' => date('Y-m-d'),
224
+ '###YEAR###' => date('Y'),
225
+ '###TODAY###' => date('Y-m-d'),
219
226
  ]);
220
227
  }
221
228
 
@@ -232,7 +239,11 @@ final class PREPROS
232
239
 
233
240
  public static function getExportedFiles(): array
234
241
  {
235
- $files = array_unique(self::$files);
242
+ // array_unique() keeps the original keys, so any duplicate leaves a gap
243
+ // in the sequence — json_encode() would then emit a JSON object instead
244
+ // of an array and the JS side chokes ("retobj.files.map is not a
245
+ // function"). array_values() reindexes so it always serialises as a list.
246
+ $files = array_values(array_unique(self::$files));
236
247
  // sort($files);
237
248
  return $files;
238
249
  }
@@ -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>` into its `<head>` once the HTML is assembled.
70
- // Registered after the data-loading hook above so `$page` arrives resolved.
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
@@ -38,6 +38,15 @@ const getPHPInstance = async () => {
38
38
  preprosConfig.root = joinWith('/project/', config?.kirigami?.root);
39
39
  preprosConfig.data = config.kirigami || {};
40
40
  preprosConfig.jsonld = config.jsonld ?? null;
41
+ preprosConfig.meta = config.meta ?? null;
42
+ // META auto-detects favicon / apple-touch-icon / humans.txt at the
43
+ // source root; those extensions aren't mounted into the sandbox, so the
44
+ // presence check is done here on the real filesystem instead.
45
+ preprosConfig.metaFiles = {
46
+ favicon: fs.existsSync(path.join(__root, 'favicon.ico')),
47
+ appleTouchIcon: fs.existsSync(path.join(__root, 'apple-touch-icon.png')),
48
+ humans: fs.existsSync(path.join(__root, 'humans.txt')),
49
+ };
41
50
  // The build task list — read by PREPROS::injectHead() to auto-wire each
42
51
  // page's <head> with a <link>/<script> per sass/esbuild task output.
43
52
  preprosConfig.tasks = config.tasks || [];
@@ -151,6 +160,9 @@ const run = async (args = [], script = null, mountfiles = []) => {
151
160
  const buffer = php.readFileAsBuffer(resultPath);
152
161
  retobj = JSON.parse(Buffer.from(buffer).toString('utf8'));
153
162
  retobj.debug = stdout;
163
+ // json_encode() turns a PHP array with gaps in its integer keys
164
+ // into an object — normalise back to a list before we map over it.
165
+ if (retobj.files && !Array.isArray(retobj.files)) retobj.files = Object.values(retobj.files);
154
166
  if(retobj.files) await Promise.all(retobj.files.map(async (file, i) => {
155
167
  const fbuffer = php.readFileAsBuffer(file);
156
168
  const dest = file.replace(/^\/project\//i, '');
package/src/utils.inc.php CHANGED
@@ -25,6 +25,7 @@ spl_autoload_register(function ($class) {
25
25
  'IMG' => 'img.class.php',
26
26
  'LD' => 'ld.class.php',
27
27
  'MD' => 'md.class.php',
28
+ 'META' => 'meta.class.php',
28
29
  'NORM' => 'norm.class.php',
29
30
  'OBF' => 'obf.class.php',
30
31
  'PREPROS' => 'prepros.class.php',