@kirigami/php-prepros 1.0.0 → 1.0.3

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
@@ -4,6 +4,8 @@
4
4
 
5
5
  Build full static websites in PHP — with zero server, zero runtime dependency, zero compromise on expressiveness. Write your pages as regular PHP files, annotate them with a PHPDOC header, and let `php-prepros` compile everything to clean, deployable HTML.
6
6
 
7
+ It is the perfect solution for **GitHub Pages**. Since it runs entirely in Node.js, it is fully compatible with **GitHub Actions**, allowing you to automate your deployment pipeline effortlessly.
8
+
7
9
  Part of the **Kirigami** project ecosystem. Other packages are coming soon.
8
10
 
9
11
  [![npm version](https://img.shields.io/npm/v/@kirigami/php-prepros)](https://www.npmjs.com/package/@kirigami/php-wasm)
@@ -51,6 +53,7 @@ Part of the **Kirigami** project ecosystem. Other packages are coming soon.
51
53
 
52
54
  ---
53
55
 
56
+
54
57
  ## How it works
55
58
 
56
59
  `@kirigami/php-prepros` runs your PHP source files inside a **WebAssembly PHP 8.x runtime** ([`@kirigami/php-wasm`](https://github.com/kirigami/php-wasm)), entirely in Node.js — no PHP installation required on the host machine.
@@ -112,6 +115,9 @@ Source pages live in the directory pointed to by `kirigami.root`. The naming con
112
115
 
113
116
  ```
114
117
  src/
118
+ ├── _layout/
119
+ ├── _lib/
120
+ ├── about/
115
121
  ├── _index.php → src/index.html
116
122
  ├── about/
117
123
  │ └── _index.php → src/about/index.html
@@ -142,7 +148,7 @@ Every page starts with a PHP docblock that drives metadata and data loading:
142
148
 
143
149
  All annotations are injected as PHP variables (`$name`, `$title`, `$abstract`, …). You can define any custom annotation you need.
144
150
 
145
- Anotations are also avaiables as variables in `before` and `header` php included files so you can write proper metas in the HTML header.
151
+ Anotations are also avaiables as variables in `before` and `after` php included files so you can write proper metas in the HTML header.
146
152
 
147
153
  ### Auto-loading data files
148
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirigami/php-prepros",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
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",
@@ -44,14 +44,14 @@
44
44
  "test": "echo \"Error: no test specified\" && exit 1"
45
45
  },
46
46
  "dependencies": {
47
- "@kirigami/php-wasm": "8.5.7-1",
48
- "js-yaml": "^4.2.0"
47
+ "@kirigami/php-wasm": "8.5.9",
48
+ "@kirigami/struct-walker": "1.0.2"
49
49
  },
50
50
  "repository": {
51
51
  "type": "git",
52
- "url": "https://github.com/php-kirigami/kirigami"
52
+ "url": "git+https://github.com/php-kirigami/kirigami.git"
53
53
  },
54
- "homepage": "https://github.com/php-kirigami/kirigami/#readme",
54
+ "homepage": "https://github.com/php-kirigami/kirigami/tree/main/packages/php-prepros#readme",
55
55
  "bugs": {
56
56
  "url": "https://github.com/php-kirigami/kirigami/issues"
57
57
  }
@@ -101,9 +101,13 @@ class HTML
101
101
  return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
102
102
  }
103
103
 
104
- // Si tous les enfants sont inline, on utilise innerHTML tel quel — pas de reconstruction
105
- if (static::hasOnlyInlineChildren($node)) {
106
- $inner = trim($node->innerHTML);
104
+ // Si tous les enfants sont inline ET que ça ressemble à un flux de texte
105
+ // (du vrai texte, ou un seul élément enfant), on re-sérialise en une seule ligne.
106
+ // Sans looksLikeTextFlow, un <section> qui contient plusieurs gros <a> côte à côte
107
+ // (ex: une grille de logos) se retrouverait aussi collé sur une seule ligne,
108
+ // puisque <a> est dans INLINE — ce n'est pas ce qu'on veut.
109
+ if (static::hasOnlyInlineChildren($node) && static::looksLikeTextFlow($node)) {
110
+ $inner = trim(static::renderInline($node));
107
111
  return "{$pad}<{$tag}{$attrs}>{$inner}</{$tag}>\n";
108
112
  }
109
113
 
@@ -115,6 +119,53 @@ class HTML
115
119
  return "{$pad}<{$tag}{$attrs}>\n{$inner}{$pad}</{$tag}>\n";
116
120
  }
117
121
 
122
+ // Sérialise le contenu inline d'un nœud sur une seule ligne, en réduisant
123
+ // tout groupe d'espaces/tabs/retours à la ligne du texte source à une seule espace
124
+ // (équivalent au comportement de collapse des espaces en HTML).
125
+ private static function renderInline(Dom\Node $node): string
126
+ {
127
+ $out = '';
128
+ foreach ($node->childNodes as $child) {
129
+ if ($child->nodeType === XML_TEXT_NODE) {
130
+ $text = preg_replace('/\s+/', ' ', $child->nodeValue);
131
+ $out .= htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
132
+ continue;
133
+ }
134
+ if ($child->nodeType === XML_COMMENT_NODE) {
135
+ $out .= '<!--' . $child->nodeValue . '-->';
136
+ continue;
137
+ }
138
+ if ($child->nodeType !== XML_ELEMENT_NODE) {
139
+ continue;
140
+ }
141
+ $tag = strtolower($child->nodeName);
142
+ $attrs = static::renderAttrs($child);
143
+ if (in_array($tag, self::VOID, true)) {
144
+ $out .= "<{$tag}{$attrs}>";
145
+ } else {
146
+ $out .= "<{$tag}{$attrs}>" . static::renderInline($child) . "</{$tag}>";
147
+ }
148
+ }
149
+ return $out;
150
+ }
151
+
152
+ // Distingue "du texte qui contient un peu d'inline" (Cliquez <a>ici</a>.) d'un
153
+ // conteneur qui aligne simplement plusieurs blocs inline côte à côte (grille de <a><img></a>).
154
+ // Vrai si : il y a du texte significatif parmi les enfants, OU un seul enfant élément.
155
+ private static function looksLikeTextFlow(Dom\Node $node): bool
156
+ {
157
+ $elementCount = 0;
158
+ foreach ($node->childNodes as $child) {
159
+ if ($child->nodeType === XML_TEXT_NODE && trim($child->nodeValue) !== '') {
160
+ return true;
161
+ }
162
+ if ($child->nodeType === XML_ELEMENT_NODE) {
163
+ $elementCount++;
164
+ }
165
+ }
166
+ return $elementCount <= 1;
167
+ }
168
+
118
169
  // Vérifie que tous les descendants directs sont inline (texte, void inline, éléments inline)
119
170
  // Les éléments inline eux-mêmes ne doivent pas contenir d'éléments block
120
171
  private static function hasOnlyInlineChildren(Dom\Node $node): bool
@@ -165,10 +216,11 @@ class HTML
165
216
  return $out;
166
217
  }
167
218
  foreach ($node->attributes as $attr) {
168
- if (in_array($attr->name, self::BOOLEAN_ATTRS, true)) {
169
- $out .= ' ' . $attr->name;
219
+ $name = strtolower(trim((string) $attr->name));
220
+ if (in_array($name, self::BOOLEAN_ATTRS, true)) {
221
+ $out .= ' ' . $name;
170
222
  } else {
171
- $out .= ' ' . $attr->name . '="' . htmlspecialchars($attr->value, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
223
+ $out .= ' ' . $name . '="' . htmlspecialchars((string) $attr->value, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
172
224
  }
173
225
  }
174
226
  return $out;
@@ -64,6 +64,49 @@ class MD {
64
64
  return array_keys(self::$plugins);
65
65
  }
66
66
 
67
+ // ========================================================================
68
+ // Génère un id de type "slug" pour les ancres de titres (ATX et Setext).
69
+ // ========================================================================
70
+ private static function slugify(string $text): string {
71
+ $id = strtolower(preg_replace('/[^\w\- ]/u', '', $text));
72
+ return preg_replace('/\s+/', '-', trim($id));
73
+ }
74
+
75
+ // ========================================================================
76
+ // Convertit une largeur d'indentation (espaces/tabs) en nombre de colonnes,
77
+ // une tabulation comptant pour 4 espaces.
78
+ // ========================================================================
79
+ private static function indentWidth(string $whitespace): int {
80
+ return strlen(str_replace("\t", ' ', $whitespace));
81
+ }
82
+
83
+ /**
84
+ * Construit récursivement une liste (imbriquée) <ol>/<ul> à partir d'un
85
+ * tableau plat d'items { indent, type, text }. $i est avancé au fur et à
86
+ * mesure de la consommation des items.
87
+ *
88
+ * @param array<int, array{indent:int, type:string, text:string}> $items
89
+ */
90
+ private static function buildListTree(array $items, int &$i, int $count): string {
91
+ $type = $items[$i]['type'];
92
+ $baseIndent = $items[$i]['indent'];
93
+ $out = "<{$type}>\n";
94
+
95
+ while ($i < $count && $items[$i]['indent'] === $baseIndent && $items[$i]['type'] === $type) {
96
+ $text = $items[$i]['text'];
97
+ $i++;
98
+
99
+ $nested = '';
100
+ if ($i < $count && $items[$i]['indent'] > $baseIndent) {
101
+ $nested = "\n" . self::buildListTree($items, $i, $count);
102
+ }
103
+
104
+ $out .= " <li>{$text}{$nested}</li>\n";
105
+ }
106
+
107
+ return $out . "</{$type}>";
108
+ }
109
+
67
110
  // ========================================================================
68
111
 
69
112
  public static function toHtml(string $markdown): string {
@@ -137,6 +180,27 @@ class MD {
137
180
  );
138
181
 
139
182
 
183
+ // ====================================================================
184
+ // ÉTAPE 2b : DÉFINITIONS DE LIENS PAR RÉFÉRENCE
185
+ // [label]: https://example.com "Titre optionnel"
186
+ // [label]: <https://example.com> 'Titre optionnel'
187
+ // [label]: https://example.com (Titre optionnel)
188
+ // Extraites (et retirées du texte) avant tout le reste ; utilisées
189
+ // plus loin par les liens [texte][label] / [texte][].
190
+ // ====================================================================
191
+ $refDefs = [];
192
+ $html = preg_replace_callback(
193
+ '/^[ \t]{0,3}\[([^\]]+)\]:[ \t]*<?([^\s>]+)>?(?:[ \t]+(?:"([^"]*)"|\'([^\']*)\'|\(([^)]*)\)))?[ \t]*$/m',
194
+ function ($m) use (&$refDefs): string {
195
+ $label = strtolower(trim($m[1]));
196
+ $title = $m[3] !== '' ? $m[3] : ($m[4] !== '' ? $m[4] : ($m[5] ?? ''));
197
+ $refDefs[$label] = ['url' => $m[2], 'title' => $title];
198
+ return '';
199
+ },
200
+ $html
201
+ );
202
+
203
+
140
204
  // ====================================================================
141
205
  // ÉTAPE 3 : BLOCS DE CODE (```lang ... ```)
142
206
  // ====================================================================
@@ -149,8 +213,44 @@ class MD {
149
213
  return $placeholder;
150
214
  }, $html);
151
215
 
152
- // Code inline (`...`)
216
+ // ====================================================================
217
+ // ÉTAPE 3a : BLOCS DE CODE INDENTÉS (4 espaces ou 1 tabulation)
218
+ // Reconnu seulement quand précédé d'une ligne vide (ou du début du
219
+ // document) et suivi d'une ligne vide (ou de la fin du document), afin
220
+ // d'éviter les conflits avec l'indentation des listes imbriquées.
221
+ // ====================================================================
222
+ $html = preg_replace_callback(
223
+ '/(?<=\n\n|^)((?:[ ]{4}|\t)[^\n]*(?:\n(?:[ ]{4}|\t)[^\n]*)*)(?=\n\n|\n*$)/',
224
+ function ($matches) use (&$codeBlocks) {
225
+ $lines = explode("\n", $matches[1]);
226
+ $stripped = array_map(static function (string $l): string {
227
+ return preg_replace('/^(?:[ ]{4}|\t)/', '', $l);
228
+ }, $lines);
229
+ $code = htmlspecialchars(implode("\n", $stripped), ENT_QUOTES, 'UTF-8');
230
+ $placeholder = "\x02CB" . count($codeBlocks) . "\x03";
231
+ $codeBlocks[$placeholder] = "<pre><code>{$code}</code></pre>";
232
+ return $placeholder;
233
+ },
234
+ $html
235
+ );
236
+
237
+ // Code inline avec double backticks (permet d'inclure un backtick littéral)
153
238
  $inlineCodes = [];
239
+ $html = preg_replace_callback('/``(.+?)``/s', function ($matches) use (&$inlineCodes) {
240
+ $content = $matches[1];
241
+ // Convention standard : si le contenu commence et finit par un
242
+ // espace (et n'est pas uniquement des espaces), on retire un
243
+ // espace de chaque côté — utile pour englober un ` en bordure.
244
+ if (preg_match('/^ (.*[^ ]) $/s', $content, $trim)) {
245
+ $content = $trim[1];
246
+ }
247
+ $code = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
248
+ $placeholder = "\x02IC" . count($inlineCodes) . "\x03";
249
+ $inlineCodes[$placeholder] = "<code>{$code}</code>";
250
+ return $placeholder;
251
+ }, $html);
252
+
253
+ // Code inline (`...`)
154
254
  $html = preg_replace_callback('/`([^`\n]+)`/', function ($matches) use (&$inlineCodes) {
155
255
  $code = htmlspecialchars($matches[1], ENT_QUOTES, 'UTF-8');
156
256
  $placeholder = "\x02IC" . count($inlineCodes) . "\x03";
@@ -160,7 +260,45 @@ class MD {
160
260
 
161
261
 
162
262
  // ====================================================================
163
- // ÉTAPE 3b : ALERTES GFM ET BLOCKQUOTES
263
+ // ÉTAPE 3b : ÉCHAPPEMENT DES CARACTÈRES (\* \_ \# etc.)
264
+ // Traité après l'extraction du code (le code reste littéral) et avant
265
+ // tout le reste, pour que \* n'ouvre pas une emphase, \# ne crée pas
266
+ // un titre, \- ne crée pas de liste, etc.
267
+ // ====================================================================
268
+ $escapes = [];
269
+ $html = preg_replace_callback(
270
+ '/\\\\([\\\\`*_{}\[\]<>()#+\-.!|])/',
271
+ function ($m) use (&$escapes): string {
272
+ $placeholder = "\x02ESC" . count($escapes) . "\x03";
273
+ $escapes[$placeholder] = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
274
+ return $placeholder;
275
+ },
276
+ $html
277
+ );
278
+
279
+
280
+ // ====================================================================
281
+ // ÉTAPE 3d : LIENS AUTOMATIQUES <https://...> et <email@example.com>
282
+ // Traités avant l'encodage XSS car les caractères < > seraient encodés
283
+ // en &lt; &gt; et la regex ne matcherait plus.
284
+ // ====================================================================
285
+ $autolinks = [];
286
+ $html = preg_replace_callback('/<(https?:\/\/[^\s<>]+)>/', function ($m) use (&$autolinks): string {
287
+ $url = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
288
+ $placeholder = "\x02AL" . count($autolinks) . "\x03";
289
+ $autolinks[$placeholder] = "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener noreferrer\">{$url}</a>";
290
+ return $placeholder;
291
+ }, $html);
292
+ $html = preg_replace_callback('/<([^\s<>]+@[^\s<>]+\.[^\s<>]+)>/', function ($m) use (&$autolinks): string {
293
+ $email = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
294
+ $placeholder = "\x02AL" . count($autolinks) . "\x03";
295
+ $autolinks[$placeholder] = "<a href=\"mailto:{$email}\">{$email}</a>";
296
+ return $placeholder;
297
+ }, $html);
298
+
299
+
300
+ // ====================================================================
301
+ // ÉTAPE 3e : ALERTES GFM ET BLOCKQUOTES
164
302
  // Traités avant l'encodage XSS car le caractère > serait encodé en &gt;
165
303
  // et les regex ne matcheraient plus.
166
304
  // ====================================================================
@@ -179,19 +317,26 @@ class MD {
179
317
  $blockquotes[$placeholder] = "<div class=\"markdown-alert markdown-alert-{$type}\">"
180
318
  . "<p class=\"markdown-alert-title\">{$label}</p>"
181
319
  . "<p>{$content}</p></div>";
182
- return $placeholder;
320
+ // Le \n final consommé par la regex est réinjecté après le
321
+ // placeholder pour ne pas fusionner la ligne vide suivante
322
+ // avec celle du placeholder (ce qui fausserait par exemple
323
+ // la détection d'un titre Setext juste après).
324
+ return $placeholder . (str_ends_with($matches[1], "\n") ? "\n" : '');
183
325
  },
184
326
  $html
185
327
  );
186
328
 
187
- // Blockquotes standards
329
+ // Blockquotes standards (imbrication gérée par récursion sur toHtml,
330
+ // qui ré-applique cette même règle sur le contenu déjà dé-préfixé
331
+ // d'un niveau de ">")
188
332
  $html = preg_replace_callback('/^((?:>[ \t]?[^\n]*\n?)+)/m', function ($matches) use (&$blockquotes): string {
189
333
  $content = preg_replace('/^>[ \t]?/m', '', $matches[1]);
190
334
  // Les deux espaces trailing sont laissés tels quels : toHtml() les gère lui-même
191
335
  $inner = self::toHtml(trim($content));
192
336
  $placeholder = "\x02BQ" . count($blockquotes) . "\x03";
193
337
  $blockquotes[$placeholder] = "<blockquote>{$inner}</blockquote>";
194
- return $placeholder;
338
+ // Voir commentaire ci-dessus : on préserve le \n final consommé.
339
+ return $placeholder . (str_ends_with($matches[1], "\n") ? "\n" : '');
195
340
  }, $html);
196
341
 
197
342
 
@@ -251,7 +396,7 @@ class MD {
251
396
 
252
397
 
253
398
  // ====================================================================
254
- // ÉTAPE 6 : (Alertes GFM et blockquotes traités à l'étape 3b)
399
+ // ÉTAPE 6 : (Alertes GFM et blockquotes traités à l'étape 3e)
255
400
  // ====================================================================
256
401
 
257
402
 
@@ -262,45 +407,76 @@ class MD {
262
407
  $html = preg_replace('/^[ \t]*[-*+] \[[xX]\] (.+)$/m', '<li class="task-item"><input type="checkbox" checked disabled /> $1</li>', $html);
263
408
 
264
409
 
410
+ // ====================================================================
411
+ // ÉTAPE 7b : TITRES SETEXT (syntaxe alternative == / --)
412
+ // Titre
413
+ // ===== → <h1>
414
+ //
415
+ // Titre
416
+ // ----- → <h2>
417
+ // Traité avant les titres ATX et avant les lignes séparatrices (une
418
+ // ligne de tirets juste après une ligne de texte est un titre, pas un <hr>).
419
+ // ====================================================================
420
+ $html = preg_replace_callback(
421
+ '/^(?![ \t]*(?:#{1,6}[ \t]|>|```|\||[-*+][ \t]|\d+\.[ \t]))[ \t]*(\S.*?)[ \t]*\n[ \t]*=+[ \t]*$/m',
422
+ function ($matches) {
423
+ $text = trim($matches[1]);
424
+ $id = self::slugify($text);
425
+ return "<h1 id=\"{$id}\">{$text}</h1>";
426
+ },
427
+ $html
428
+ );
429
+ $html = preg_replace_callback(
430
+ '/^(?![ \t]*(?:#{1,6}[ \t]|>|```|\||[-*+][ \t]|\d+\.[ \t]))[ \t]*(\S.*?)[ \t]*\n[ \t]*-+[ \t]*$/m',
431
+ function ($matches) {
432
+ $text = trim($matches[1]);
433
+ $id = self::slugify($text);
434
+ return "<h2 id=\"{$id}\">{$text}</h2>";
435
+ },
436
+ $html
437
+ );
438
+
439
+
265
440
  // ====================================================================
266
441
  // ÉTAPE 8 : TITRES (ATX : # à ######)
267
442
  // ====================================================================
268
443
  $html = preg_replace_callback('/^(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?$/m', function ($matches) {
269
444
  $level = strlen($matches[1]);
270
445
  $text = trim($matches[2]);
271
- $id = strtolower(preg_replace('/[^\w\- ]/u', '', $text));
272
- $id = preg_replace('/\s+/', '-', trim($id));
446
+ $id = self::slugify($text);
273
447
  return "<h{$level} id=\"{$id}\">{$text}</h{$level}>";
274
448
  }, $html);
275
449
 
276
450
 
277
451
  // ====================================================================
278
- // ÉTAPE 9 : LISTES (puces et ordonnées)
452
+ // ÉTAPE 9 : LISTES (puces et ordonnées, avec imbrication)
453
+ // Une seule passe détecte un bloc contigu de lignes qui sont soit une
454
+ // puce (-,*,+) soit un item numéroté, quel que soit leur niveau
455
+ // d'indentation ; le bloc est ensuite reconstruit récursivement en
456
+ // <ol>/<ul> imbriqués selon la profondeur d'indentation relative.
457
+ // Les items de tâches (déjà convertis en <li class="task-item">) ne
458
+ // matchent plus ce motif et ne sont donc pas ré-englobés ici.
279
459
  // ====================================================================
280
460
  $html = preg_replace_callback(
281
- '/^([ \t]*\d+\. .+(?:\n[ \t]*\d+\. .+)*)/m',
282
- function ($matches) {
283
- $items = preg_split('/\n/', trim($matches[1]));
284
- $out = "<ol>\n";
285
- foreach ($items as $item) {
286
- $text = preg_replace('/^[ \t]*\d+\. /', '', $item);
287
- $out .= " <li>{$text}</li>\n";
288
- }
289
- return $out . "</ol>";
290
- },
291
- $html
292
- );
293
-
294
- $html = preg_replace_callback(
295
- '/^([ \t]*[-*+] (?!\[[ xX]\] ).+(?:\n[ \t]*[-*+] (?!\[[ xX]\] ).+)*)/m',
461
+ '/^([ \t]*(?:\d+\.|[-*+])[ \t]+.+(?:\n[ \t]*(?:\d+\.|[-*+])[ \t]+.+)*)/m',
296
462
  function ($matches) {
297
- $items = preg_split('/\n/', trim($matches[1]));
298
- $out = "<ul>\n";
299
- foreach ($items as $item) {
300
- $text = preg_replace('/^[ \t]*[-*+] /', '', $item);
301
- $out .= " <li>{$text}</li>\n";
463
+ $lines = explode("\n", $matches[1]);
464
+ $items = [];
465
+ foreach ($lines as $line) {
466
+ if (preg_match('/^([ \t]*)(\d+)\.[ \t]+(.*)$/', $line, $m)) {
467
+ $items[] = ['indent' => self::indentWidth($m[1]), 'type' => 'ol', 'text' => $m[3]];
468
+ } elseif (preg_match('/^([ \t]*)[-*+][ \t]+(.*)$/', $line, $m)) {
469
+ $items[] = ['indent' => self::indentWidth($m[1]), 'type' => 'ul', 'text' => $m[2]];
470
+ }
302
471
  }
303
- return $out . "</ul>";
472
+ if (empty($items)) return $matches[1];
473
+ // Normalise le niveau d'indentation le plus bas à 0
474
+ $minIndent = min(array_column($items, 'indent'));
475
+ foreach ($items as &$it) $it['indent'] -= $minIndent;
476
+ unset($it);
477
+
478
+ $i = 0;
479
+ return self::buildListTree($items, $i, count($items));
304
480
  },
305
481
  $html
306
482
  );
@@ -339,32 +515,33 @@ class MD {
339
515
  $html
340
516
  );
341
517
 
342
- // Liens markdown [texte](url "titre optionnel")
518
+ $buildLink = static function (string $text, string $href, string $title): string {
519
+ $titleAttr = $title !== '' ? ' title="' . $title . '"' : '';
520
+ $extern = preg_match('/^https?:\/\//i', $href)
521
+ ? ' target="_blank" rel="noopener noreferrer"'
522
+ : '';
523
+ return "<a href=\"{$href}\"{$titleAttr}{$extern}>{$text}</a>";
524
+ };
525
+
526
+ // Liens par référence [texte][label] et [texte][] (raccourci = label = texte)
343
527
  $html = preg_replace_callback(
344
- '/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/',
345
- function ($m): string {
346
- $text = $m[1];
347
- $href = $m[2];
348
- $title = isset($m[3]) && $m[3] !== '' ? ' title="' . $m[3] . '"' : '';
349
- $extern = preg_match('/^https?:\/\//i', $href)
350
- ? ' target="_blank" rel="noopener noreferrer"'
351
- : '';
352
- return "<a href=\"{$href}\"{$title}{$extern}>{$text}</a>";
528
+ '/\[([^\]]+)\]\[([^\]]*)\]/',
529
+ function ($m) use (&$refDefs, $buildLink): string {
530
+ $text = $m[1];
531
+ $label = strtolower(trim($m[2] !== '' ? $m[2] : $m[1]));
532
+ if (!isset($refDefs[$label])) return $m[0];
533
+ $def = $refDefs[$label];
534
+ return $buildLink($text, $def['url'], $def['title']);
353
535
  },
354
536
  $html
355
537
  );
356
538
 
357
- // Liens automatiques <https://...>
358
- $html = preg_replace(
359
- '/<(https?:\/\/[^>]+)>/',
360
- '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
361
- $html
362
- );
363
-
364
- // URL nues https://...
365
- $html = preg_replace(
366
- '/(?<!["\'=>])\b(https?:\/\/[^\s<>"\')\]]+)/',
367
- '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
539
+ // Liens markdown [texte](url "titre optionnel")
540
+ $html = preg_replace_callback(
541
+ '/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/',
542
+ function ($m) use ($buildLink): string {
543
+ return $buildLink($m[1], $m[2], $m[3] ?? '');
544
+ },
368
545
  $html
369
546
  );
370
547
 
@@ -384,12 +561,15 @@ class MD {
384
561
  // ====================================================================
385
562
  $blockStartTags = ['<h', '<pre', '<ul', '<ol', '<li', '<table', '<thead', '<tbody',
386
563
  '<tr', '<td', '<th', '<blockquote', '<div', '<hr', '<img',
387
- '</ul>', '</ol>', '</table>', '</blockquote>', '</div>',
388
- "\x02CB", "\x02IC", "\x02PLG", "\x02BQ"];
564
+ "\x02CB", "\x02PLG", "\x02BQ"];
389
565
 
390
566
  $isBlockLine = static function (string $line) use ($blockStartTags): bool {
391
567
  $t = ltrim($line);
392
568
  if ($t === '') return false;
569
+ // Toute balise fermante (</...>) est toujours considérée comme une
570
+ // ligne "bloc" : ça évite qu'une fermeture de <table>, <thead>,
571
+ // <tr>, etc. finisse absorbée dans un <p> environnant.
572
+ if (str_starts_with($t, '</')) return true;
393
573
  foreach ($blockStartTags as $tag) {
394
574
  if (str_starts_with($t, $tag)) return true;
395
575
  }
@@ -437,8 +617,11 @@ class MD {
437
617
  $html = strtr($html, $blockquotes);
438
618
  $html = strtr($html, $codeBlocks);
439
619
  $html = strtr($html, $inlineCodes);
620
+ $html = strtr($html, $autolinks);
621
+ // Les échappements sont réinjectés en tout dernier, une fois que plus
622
+ // aucune regex Markdown ne peut les interpréter.
623
+ $html = strtr($html, $escapes);
440
624
 
441
625
  return $html;
442
626
  }
443
- }
444
-
627
+ }
@@ -54,6 +54,137 @@ class YAML
54
54
  return self::parse(file_get_contents($path), $assoc);
55
55
  }
56
56
 
57
+ /**
58
+ * Charge un fichier YAML ou JSON, puis parcourt récursivement le résultat
59
+ * et remplace toute valeur string qui correspond à un chemin relatif vers
60
+ * un fichier YAML/JSON existant par le contenu désérialisé de ce fichier.
61
+ *
62
+ * Chaque fichier inclus est lui-même résolu relativement à son propre
63
+ * répertoire, et ainsi de suite (récursif).
64
+ *
65
+ * Si la chaîne ne se termine pas par .yml/.yaml/.json, ou si le fichier
66
+ * résolu n'existe pas, la valeur est conservée telle quelle.
67
+ *
68
+ * Les références circulaires (ex. A → B → A) lèvent une RuntimeException.
69
+ *
70
+ * Utilisation :
71
+ * $data = YAML::loadFile('/chemin/vers/config.yaml');
72
+ * $data = YAML::loadFile('/chemin/vers/config.yaml', true); // arrays assoc
73
+ *
74
+ * @param string $path Chemin vers le fichier racine (YAML ou JSON).
75
+ * @param bool $assoc true → mappings en array, false → stdClass.
76
+ * @return mixed
77
+ */
78
+ public static function loadFile(string $path, bool $assoc = false): mixed
79
+ {
80
+ $absolute = realpath($path);
81
+ if ($absolute === false || !is_readable($absolute)) {
82
+ throw new \RuntimeException("Impossible de lire le fichier : $path");
83
+ }
84
+
85
+ return self::loadFileRecursive($absolute, $assoc, []);
86
+ }
87
+
88
+ // -------------------------------------------------------------------------
89
+ // Méthodes privées pour loadFile
90
+ // -------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Charge et résout un fichier, en propageant la liste des ancêtres pour
94
+ * détecter les cycles.
95
+ *
96
+ * @param string $absolute Chemin absolu canonique du fichier à charger.
97
+ * @param bool $assoc
98
+ * @param string[] $ancestors Chemins absolus des fichiers en cours de traitement.
99
+ * @return mixed
100
+ */
101
+ private static function loadFileRecursive(string $absolute, bool $assoc, array $ancestors): mixed
102
+ {
103
+ if (in_array($absolute, $ancestors, true)) {
104
+ throw new \RuntimeException(
105
+ "Référence circulaire détectée : " . implode(' → ', $ancestors) . " → $absolute"
106
+ );
107
+ }
108
+
109
+ $ext = strtolower(pathinfo($absolute, PATHINFO_EXTENSION));
110
+ $raw = file_get_contents($absolute);
111
+ $dir = dirname($absolute);
112
+
113
+ if ($ext === 'json') {
114
+ $data = json_decode($raw, $assoc, 512, JSON_THROW_ON_ERROR);
115
+ } else {
116
+ // .yml, .yaml ou autre extension traitée comme YAML
117
+ $data = self::parse($raw, $assoc);
118
+ }
119
+
120
+ return self::resolveNode($data, $dir, $assoc, [...$ancestors, $absolute]);
121
+ }
122
+
123
+ /**
124
+ * Parcourt récursivement une valeur PHP (objet stdClass, array, string,
125
+ * scalaire) et résout les références vers des fichiers externes.
126
+ *
127
+ * @param mixed $node
128
+ * @param string $dir Répertoire du fichier qui contient ce nœud.
129
+ * @param bool $assoc
130
+ * @param string[] $ancestors
131
+ * @return mixed
132
+ */
133
+ private static function resolveNode(mixed $node, string $dir, bool $assoc, array $ancestors): mixed
134
+ {
135
+ if (is_string($node)) {
136
+ return self::resolveString($node, $dir, $assoc, $ancestors);
137
+ }
138
+
139
+ if (is_array($node)) {
140
+ foreach ($node as $key => $value) {
141
+ $node[$key] = self::resolveNode($value, $dir, $assoc, $ancestors);
142
+ }
143
+ return $node;
144
+ }
145
+
146
+ if ($node instanceof \stdClass) {
147
+ foreach ($node as $key => $value) {
148
+ $node->$key = self::resolveNode($value, $dir, $assoc, $ancestors);
149
+ }
150
+ return $node;
151
+ }
152
+
153
+ // int, float, bool, null → retourné tel quel
154
+ return $node;
155
+ }
156
+
157
+ /**
158
+ * Si la chaîne pointe vers un fichier YAML/JSON existant (chemin relatif
159
+ * au répertoire $dir), charge ce fichier récursivement. Sinon retourne la
160
+ * chaîne d'origine.
161
+ *
162
+ * @param string $str
163
+ * @param string $dir
164
+ * @param bool $assoc
165
+ * @param string[] $ancestors
166
+ * @return mixed
167
+ */
168
+ private static function resolveString(string $str, string $dir, bool $assoc, array $ancestors): mixed
169
+ {
170
+ $trimmed = trim($str);
171
+
172
+ // Filtre rapide sur l'extension
173
+ if (!preg_match('/\.(ya?ml|json)$/i', $trimmed)) {
174
+ return $str;
175
+ }
176
+
177
+ // Résolution du chemin relatif au répertoire du fichier parent
178
+ $candidate = $dir . DIRECTORY_SEPARATOR . $trimmed;
179
+ $absolute = realpath($candidate);
180
+
181
+ if ($absolute === false || !is_readable($absolute)) {
182
+ return $str; // Fichier introuvable → string ordinaire
183
+ }
184
+
185
+ return self::loadFileRecursive($absolute, $assoc, $ancestors);
186
+ }
187
+
57
188
  // -------------------------------------------------------------------------
58
189
  // Parsing récursif
59
190
  // -------------------------------------------------------------------------
package/src/prepros.js CHANGED
@@ -1,16 +1,48 @@
1
1
  import fs from 'fs';
2
2
  import path, { dirname } from "path";
3
+ import isBinary from './utils/isbinary.js';
4
+ import joinWith from './utils/joinwith.js'
3
5
  import { spawn } from 'child_process';
4
6
  import { fileURLToPath } from 'url';
7
+ import { walkFile } from '@kirigami/struct-walker';
5
8
  import { getPHPRuntime, getPHPRuntimeWithNetwork } from "@kirigami/php-wasm";
6
- import isBinary from './utils/isbinary.js';
7
- import joinWith from './utils/joinwith.js'
8
- import yaml from "js-yaml";
9
9
 
10
10
 
11
11
  const __project = process.cwd();
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
  const __configpath = path.join(__project, 'kirigami.yaml');
14
+ let __root = null;
15
+ let __php = null;
16
+
17
+
18
+ if (!fs.existsSync(__configpath)) throw `Config file not found: ${__configpath}`;
19
+ const config = await walkFile(__configpath);
20
+ if(!config) throw `Invalid config file: ${__configpath}`;
21
+
22
+
23
+ const getPHPInstance = async () => {
24
+ if(!__php) {
25
+ if(config?.kirigami?.root === undefined) throw `Missing prepros:root property in config file: ${__configpath}`;
26
+ __root = path.join(__project, config.kirigami.root);
27
+ if (!fs.existsSync(__root)) throw `Invalid prepros:root path: ${__root}`;
28
+
29
+ const preprosConfig = config.prepros;
30
+ preprosConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
31
+ preprosConfig.root = joinWith('/project/', config?.kirigami?.root);
32
+ preprosConfig.data = config.kirigami || {};
33
+
34
+ __php = await (preprosConfig.network ? getPHPRuntimeWithNetwork() : getPHPRuntime());
35
+ __php.setSpawnHandler((command, args, options) => spawn(command, args, options));
36
+ __php.preprosConfig = preprosConfig;
37
+
38
+ const mountPaths = [];
39
+ const __cache = path.join(__project, '.cache.db');
40
+ mountPath(__php, __dirname, '/prepros');
41
+ mountPath(__php, joinWith(__project, config?.kirigami?.root), joinWith('/project', config?.kirigami?.root));
42
+ if (fs.existsSync(__cache)) mountPath(__php, __cache, '/project/.cache.db');
43
+ }
44
+ return __php;
45
+ }
14
46
 
15
47
 
16
48
  const mountPath = (php, localPath, virtualDir) => {
@@ -32,14 +64,16 @@ const mountPath = (php, localPath, virtualDir) => {
32
64
 
33
65
 
34
66
  const run = async (args) => {
35
- php.setSpawnHandler((command, args, options) => spawn(command, args, options));
67
+ const php = await getPHPInstance();
36
68
  const output = await php.runStream({
37
69
  scriptPath: '/prepros/prepros.php',
38
- env: { PREPROS_ARGS: JSON.stringify(args), PREPROS_CONFIG: JSON.stringify(preprosConfig) }
70
+ env: {
71
+ PREPROS_ARGS: JSON.stringify(args),
72
+ PREPROS_CONFIG: JSON.stringify(php.preprosConfig)
73
+ }
39
74
  });
40
75
  const stdout = await output.stdoutText;
41
76
  const stderr = await output.stderrText;
42
- console.log(stdout);
43
77
  let retobj;
44
78
  try {
45
79
  retobj = JSON.parse(stdout);
@@ -64,44 +98,29 @@ const run = async (args) => {
64
98
 
65
99
 
66
100
  const render = async (file = '.') => {
101
+ const php = await getPHPInstance();
67
102
  const target = path.resolve(__project, config?.kirigami?.root, file);
68
103
  const fsvm = path.join('/project', config?.kirigami?.root, file).replace(/\\/g, '/');
69
104
  mountPath(php, target, fsvm);
105
+ if(config?.prepros?.before) {
106
+ const include = path.resolve(__project, config?.kirigami?.root, config?.prepros?.before);
107
+ const dest = path.join('/project', config?.kirigami?.root, config?.prepros?.before).replace(/\\/g, '/');
108
+ mountPath(php, include, dest);
109
+ }
110
+ if(config?.prepros?.after) {
111
+ const include = path.resolve(__project, config?.kirigami?.root, config?.prepros?.after);
112
+ const dest = path.join('/project', config?.kirigami?.root, config?.prepros?.after).replace(/\\/g, '/');
113
+ mountPath(php, include, dest);
114
+ }
70
115
  return run([fsvm]);
71
116
  }
72
117
 
73
118
 
74
119
  const sitemap = async (dir) => {
120
+ const php = await getPHPInstance();
75
121
  mountPath(php, __root, '/project/' + config?.kirigami?.root);
76
122
  return run(['sitemap']);
77
123
  }
78
124
 
79
125
 
80
- if (!fs.existsSync(__configpath)) throw `Config file not found: ${__configpath}`;
81
- const configContents = fs.readFileSync(__configpath, 'utf8');
82
- const config = yaml.load(configContents);
83
- if(!config) throw `Invalid config file: ${__configpath}`;
84
-
85
-
86
- if(config?.kirigami?.root === undefined) throw `Missing prepros:root property in config file: ${__configpath}`;
87
- const __root = path.join(__project, config.kirigami.root);
88
- if (!fs.existsSync(__root)) throw `Invalid prepros:root path: ${__root}`;
89
-
90
-
91
- const preprosConfig = config.prepros;
92
- preprosConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
93
- preprosConfig.root = joinWith('/project/', config?.kirigami?.root);
94
- preprosConfig.data = config.kirigami || {};
95
-
96
-
97
- const php = await (preprosConfig.network ? getPHPRuntimeWithNetwork() : getPHPRuntime());
98
-
99
-
100
- const mountPaths = [];
101
- const __cache = path.join(__project, '.cache.db');
102
- mountPath(php, __dirname, '/prepros');
103
- mountPath(php, joinWith(__project, config?.kirigami?.root), joinWith('/project', config?.kirigami?.root));
104
- if (fs.existsSync(__cache)) mountPath(php, __cache, '/project/.cache.db');
105
-
106
-
107
126
  export { render, sitemap };
package/src/prepros.php CHANGED
@@ -1,10 +1,10 @@
1
1
  <?php
2
2
 
3
- ini_set('display_errors', 1);
4
3
  ini_set('log_errors', 1);
5
- ini_set('error_log', 'php://stderr');
6
4
  ini_set('html_errors', 0);
5
+ ini_set('display_errors', 1);
7
6
  ini_set('error_reporting', 32767);
7
+ ini_set('error_log', 'php://stderr');
8
8
  error_reporting(E_ALL);
9
9
 
10
10