@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 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.6.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
- * `###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
 
@@ -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
@@ -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 = null;
16
- let __php = null;
17
-
18
-
19
- if (!fs.existsSync(__configpath)) throw `Config file not found: ${__configpath}`;
20
- const config = await walkFile(__configpath);
21
- if(!config) throw `Invalid config file: ${__configpath}`;
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
@@ -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',