@kirigami/php-prepros 1.0.1 → 1.0.4

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
@@ -53,6 +53,7 @@ Part of the **Kirigami** project ecosystem. Other packages are coming soon.
53
53
 
54
54
  ---
55
55
 
56
+
56
57
  ## How it works
57
58
 
58
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.
@@ -114,6 +115,9 @@ Source pages live in the directory pointed to by `kirigami.root`. The naming con
114
115
 
115
116
  ```
116
117
  src/
118
+ ├── _layout/
119
+ ├── _lib/
120
+ ├── about/
117
121
  ├── _index.php → src/index.html
118
122
  ├── about/
119
123
  │ └── _index.php → src/about/index.html
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirigami/php-prepros",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
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-1",
48
+ "@kirigami/struct-walker": "1.0.3"
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,217 @@ 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
+
110
+ // ========================================================================
111
+ // ÉMOJIS (syntaxe étendue) : :shortcode: → caractère unicode.
112
+ // Table non exhaustive mais couvrant les raccourcis les plus courants ;
113
+ // extensible via registerEmoji().
114
+ // ========================================================================
115
+ /** @var array<string, string> */
116
+ private static array $extraEmoji = [];
117
+
118
+ private static array $emojiMap = [
119
+ 'smile' => '😄', 'smiley' => '😃', 'grin' => '😁', 'joy' => '😂', 'rofl' => '🤣',
120
+ 'blush' => '😊', 'wink' => '😉', 'relaxed' => '☺️', 'slight_smile' => '🙂',
121
+ 'upside_down_face' => '🙃', 'innocent' => '😇', 'heart_eyes' => '😍', 'kissing_heart' => '😘',
122
+ 'thinking' => '🤔', 'neutral_face' => '😐', 'expressionless' => '😑', 'no_mouth' => '😶',
123
+ 'roll_eyes' => '🙄', 'smirk' => '😏', 'unamused' => '😒', 'grimacing' => '😬',
124
+ 'lying_face' => '🤥', 'relieved' => '😌', 'pensive' => '😔', 'sleepy' => '😪',
125
+ 'drooling_face' => '🤤', 'sleeping' => '😴', 'mask' => '😷', 'sunglasses' => '😎',
126
+ 'star_struck' => '🤩', 'partying_face' => '🥳', 'worried' => '😟', 'frowning' => '☹️',
127
+ 'confused' => '😕', 'slightly_frowning_face' => '🙁', 'cry' => '😢', 'sob' => '😭',
128
+ 'scream' => '😱', 'confounded' => '😖', 'persevere' => '😣', 'disappointed' => '😞',
129
+ 'sweat' => '😓', 'weary' => '😩', 'tired_face' => '😫', 'yawning_face' => '🥱',
130
+ 'triumph' => '😤', 'rage' => '😡', 'angry' => '😠', 'cursing_face' => '🤬',
131
+ 'exploding_head' => '🤯', 'flushed' => '😳', 'hot_face' => '🥵', 'cold_face' => '🥶',
132
+ 'scream_cat' => '🙀', 'nerd_face' => '🤓', 'monocle_face' => '🧐', 'zany_face' => '🤪',
133
+ 'raised_eyebrow' => '🤨', 'shushing_face' => '🤫', 'zipper_mouth_face' => '🤐',
134
+ 'heart' => '❤️', 'orange_heart' => '🧡', 'yellow_heart' => '💛', 'green_heart' => '💚',
135
+ 'blue_heart' => '💙', 'purple_heart' => '💜', 'black_heart' => '🖤', 'white_heart' => '🤍',
136
+ 'broken_heart' => '💔', 'two_hearts' => '💕', 'sparkling_heart' => '💖', 'heartbeat' => '💓',
137
+ 'thumbsup' => '👍', '+1' => '👍', 'thumbsdown' => '👎', '-1' => '👎',
138
+ 'clap' => '👏', 'raised_hands' => '🙌', 'pray' => '🙏', 'wave' => '👋',
139
+ 'ok_hand' => '👌', 'v' => '✌️', 'crossed_fingers' => '🤞', 'muscle' => '💪',
140
+ 'point_up' => '☝️', 'point_down' => '👇', 'point_left' => '👈', 'point_right' => '👉',
141
+ 'handshake' => '🤝', 'writing_hand' => '✍️', 'fire' => '🔥', 'star' => '⭐',
142
+ 'star2' => '🌟', 'sparkles' => '✨', 'zap' => '⚡', 'boom' => '💥', 'collision' => '💥',
143
+ 'rocket' => '🚀', 'tada' => '🎉', 'confetti_ball' => '🎊', 'gift' => '🎁',
144
+ 'balloon' => '🎈', 'trophy' => '🏆', 'medal' => '🏅', 'crown' => '👑',
145
+ 'gem' => '💎', 'moneybag' => '💰', 'dollar' => '💵', '100' => '💯',
146
+ 'warning' => '⚠️', 'no_entry' => '⛔', 'stop_sign' => '🛑', 'checkered_flag' => '🏁',
147
+ 'white_check_mark' => '✅', 'heavy_check_mark' => '✔️', 'x' => '❌', 'negative_squared_cross_mark' => '❎',
148
+ 'question' => '❓', 'grey_question' => '❔', 'exclamation' => '❗', 'bangbang' => '‼️',
149
+ 'interrobang' => '⁉️', 'bulb' => '💡', 'bell' => '🔔', 'no_bell' => '🔕',
150
+ 'lock' => '🔒', 'unlock' => '🔓', 'key' => '🔑', 'mag' => '🔍', 'link' => '🔗',
151
+ 'pushpin' => '📌', 'paperclip' => '📎', 'calendar' => '📅', 'clock' => '🕐',
152
+ 'hourglass' => '⌛', 'alarm_clock' => '⏰', 'memo' => '📝', 'pencil2' => '✏️',
153
+ 'book' => '📖', 'books' => '📚', 'newspaper' => '📰', 'email' => '📧',
154
+ 'envelope' => '✉️', 'inbox_tray' => '📥', 'outbox_tray' => '📤', 'package' => '📦',
155
+ 'file_folder' => '📁', 'open_file_folder' => '📂', 'clipboard' => '📋',
156
+ 'chart_with_upwards_trend' => '📈', 'chart_with_downwards_trend' => '📉', 'bar_chart' => '📊',
157
+ 'computer' => '💻', 'desktop_computer' => '🖥️', 'keyboard' => '⌨️', 'printer' => '🖨️',
158
+ 'phone' => '📱', 'iphone' => '📱', 'camera' => '📷', 'video_camera' => '📹',
159
+ 'tv' => '📺', 'radio' => '📻', 'battery' => '🔋', 'electric_plug' => '🔌',
160
+ 'bug' => '🐛', 'beetle' => '🪲', 'gear' => '⚙️', 'wrench' => '🔧', 'hammer' => '🔨',
161
+ 'nut_and_bolt' => '🔩', 'toolbox' => '🧰', 'test_tube' => '🧪', 'microscope' => '🔬',
162
+ 'satellite' => '🛰️', 'globe_with_meridians' => '🌐', 'earth_americas' => '🌎',
163
+ 'sun' => '☀️', 'sunny' => '☀️', 'partly_sunny' => '⛅', 'cloud' => '☁️',
164
+ 'rainbow' => '🌈', 'umbrella' => '☂️', 'snowflake' => '❄️', 'droplet' => '💧',
165
+ 'ocean' => '🌊', 'tent' => '⛺', 'camping' => '🏕️', 'mountain' => '⛰️',
166
+ 'evergreen_tree' => '🌲', 'deciduous_tree' => '🌳', 'palm_tree' => '🌴',
167
+ 'cactus' => '🌵', 'seedling' => '🌱', 'four_leaf_clover' => '🍀', 'maple_leaf' => '🍁',
168
+ 'dog' => '🐶', 'cat' => '🐱', 'mouse' => '🐭', 'rabbit' => '🐰', 'fox_face' => '🦊',
169
+ 'bear' => '🐻', 'panda_face' => '🐼', 'koala' => '🐨', 'tiger' => '🐯', 'lion' => '🦁',
170
+ 'cow' => '🐮', 'pig' => '🐷', 'frog' => '🐸', 'monkey_face' => '🐵', 'chicken' => '🐔',
171
+ 'penguin' => '🐧', 'bird' => '🐦', 'baby_chick' => '🐤', 'owl' => '🦉',
172
+ 'horse' => '🐴', 'unicorn' => '🦄', 'bee' => '🐝', 'butterfly' => '🦋', 'snail' => '🐌',
173
+ 'octopus' => '🐙', 'fish' => '🐟', 'dolphin' => '🐬', 'whale' => '🐳',
174
+ 'pizza' => '🍕', 'hamburger' => '🍔', 'fries' => '🍟', 'hotdog' => '🌭',
175
+ 'taco' => '🌮', 'sushi' => '🍣', 'ramen' => '🍜', 'spaghetti' => '🍝',
176
+ 'bread' => '🍞', 'cheese' => '🧀', 'egg' => '🥚', 'popcorn' => '🍿',
177
+ 'cookie' => '🍪', 'doughnut' => '🍩', 'cake' => '🍰', 'birthday' => '🎂',
178
+ 'candy' => '🍬', 'chocolate_bar' => '🍫', 'icecream' => '🍦', 'apple' => '🍎',
179
+ 'banana' => '🍌', 'grapes' => '🍇', 'watermelon' => '🍉', 'strawberry' => '🍓',
180
+ 'lemon' => '🍋', 'peach' => '🍑', 'coffee' => '☕', 'tea' => '🍵', 'beer' => '🍺',
181
+ 'beers' => '🍻', 'wine_glass' => '🍷', 'cocktail' => '🍸', 'tropical_drink' => '🍹',
182
+ 'champagne' => '🍾', 'soccer' => '⚽', 'basketball' => '🏀', 'football' => '🏈',
183
+ 'baseball' => '⚾', 'tennis' => '🎾', 'volleyball' => '🏐', 'rugby_football' => '🏉',
184
+ '8ball' => '🎱', 'golf' => '⛳', 'dart' => '🎯', 'video_game' => '🎮',
185
+ 'game_die' => '🎲', 'jigsaw' => '🧩', 'car' => '🚗', 'taxi' => '🚕', 'bus' => '🚌',
186
+ 'ambulance' => '🚑', 'fire_engine' => '🚒', 'police_car' => '🚓', 'bike' => '🚲',
187
+ 'airplane' => '✈️', 'helicopter' => '🚁', 'train' => '🚆', 'ship' => '🚢',
188
+ 'house' => '🏠', 'office' => '🏢', 'hospital' => '🏥', 'school' => '🏫',
189
+ 'church' => '⛪', 'castle' => '🏰', 'world_map' => '🗺️', 'flag_white' => '🏳️',
190
+ 'flag_black' => '🏴', 'checkered_flag2' => '🏁', 'eyes' => '👀', 'eye' => '👁️',
191
+ 'speech_balloon' => '💬', 'thought_balloon' => '💭', 'zzz' => '💤', 'boom2' => '💥',
192
+ 'sos' => '🆘', 'new' => '🆕', 'ok' => '🆗', 'up' => '🆙', 'cool' => '🆒',
193
+ 'free' => '🆓', 'id' => '🆔', 'ng' => '🆖',
194
+ ];
195
+
196
+ /**
197
+ * Enregistre (ou remplace) un raccourci emoji personnalisé.
198
+ */
199
+ public static function registerEmoji(string $shortcode, string $char): void {
200
+ self::$extraEmoji[strtolower(trim($shortcode, ':'))] = $char;
201
+ }
202
+
203
+ private static function emojiFor(string $shortcode): ?string {
204
+ $key = strtolower($shortcode);
205
+ return self::$extraEmoji[$key] ?? self::$emojiMap[$key] ?? null;
206
+ }
207
+
208
+ // ========================================================================
209
+ // LISTES DE DÉFINITION (syntaxe étendue)
210
+ // Terme
211
+ // : Définition
212
+ // Analyse procédurale ligne par ligne (plus sûre qu'une seule grosse
213
+ // regex pour regrouper plusieurs paires terme/définitions dans un même
214
+ // <dl>, séparées ou non par une ligne vide).
215
+ // ========================================================================
216
+ private static function isDefinitionColonLine(string $line): bool {
217
+ return (bool) preg_match('/^[ \t]*:[ \t]+.+$/', $line);
218
+ }
219
+
220
+ private static function looksLikeOtherBlock(string $line): bool {
221
+ $t = ltrim($line);
222
+ if ($t === '') return true;
223
+ if (str_starts_with($t, '<')) return true;
224
+ return (bool) preg_match('/^(?:#{1,6}[ \t]|>|```|\||[-*+][ \t]|\d+\.[ \t])/', $t);
225
+ }
226
+
227
+ private static function extractDefinitionLists(string $html): string {
228
+ $lines = explode("\n", $html);
229
+ $n = count($lines);
230
+ $out = [];
231
+ $i = 0;
232
+
233
+ while ($i < $n) {
234
+ $isTermStart = $i + 1 < $n
235
+ && !self::looksLikeOtherBlock($lines[$i])
236
+ && !self::isDefinitionColonLine($lines[$i])
237
+ && self::isDefinitionColonLine($lines[$i + 1]);
238
+
239
+ if (!$isTermStart) {
240
+ $out[] = $lines[$i];
241
+ $i++;
242
+ continue;
243
+ }
244
+
245
+ $dl = "<dl>\n";
246
+ while (true) {
247
+ $term = trim($lines[$i]);
248
+ $dl .= " <dt>{$term}</dt>\n";
249
+ $i++;
250
+ while ($i < $n && preg_match('/^[ \t]*:[ \t]+(.*)$/', $lines[$i], $m)) {
251
+ $dl .= " <dd>{$m[1]}</dd>\n";
252
+ $i++;
253
+ }
254
+
255
+ // Une seule ligne vide entre deux groupes reste dans le même <dl>
256
+ // si le groupe suivant est bien un nouveau terme.
257
+ if ($i < $n && trim($lines[$i]) === '') {
258
+ $j = $i;
259
+ while ($j < $n && trim($lines[$j]) === '') $j++;
260
+ if ($j + 1 < $n
261
+ && !self::looksLikeOtherBlock($lines[$j])
262
+ && !self::isDefinitionColonLine($lines[$j])
263
+ && self::isDefinitionColonLine($lines[$j + 1])
264
+ ) {
265
+ $i = $j;
266
+ continue;
267
+ }
268
+ }
269
+ break;
270
+ }
271
+ $dl .= "</dl>";
272
+ $out[] = $dl;
273
+ }
274
+
275
+ return implode("\n", $out);
276
+ }
277
+
67
278
  // ========================================================================
68
279
 
69
280
  public static function toHtml(string $markdown): string {
@@ -137,6 +348,59 @@ class MD {
137
348
  );
138
349
 
139
350
 
351
+ // ====================================================================
352
+ // ÉTAPE 2a : DÉFINITIONS DE NOTES DE BAS DE PAGE (footnotes)
353
+ // [^1]: Texte de la note.
354
+ // [^bignote]: Première ligne.
355
+ //
356
+ // Paragraphe suivant, indenté de 4 espaces ou 1 tabulation.
357
+ //
358
+ // `{ du code }`
359
+ // Extraites (et retirées du texte) AVANT les définitions de liens par
360
+ // référence, car [^label]: matcherait aussi leur regex sinon.
361
+ // Le contenu de chaque note est rendu via un appel récursif à
362
+ // toHtml() pour supporter plusieurs paragraphes, du code, etc.
363
+ // ====================================================================
364
+ $footnoteDefs = [];
365
+ $html = preg_replace_callback(
366
+ '/^\[\^([^\]\s]+)\]:[ \t]?([^\n]*)((?:\n(?:[ \t]{4}[^\n]*|[ \t]*))*)/m',
367
+ function ($m) use (&$footnoteDefs): string {
368
+ $label = strtolower(trim($m[1]));
369
+ $first = $m[2];
370
+ $rest = $m[3] ?? '';
371
+ $restLines = $rest !== '' ? explode("\n", $rest) : [];
372
+ $restLines = array_map(static function (string $l): string {
373
+ return preg_replace('/^(?:[ ]{4}|\t)/', '', $l);
374
+ }, $restLines);
375
+ $content = trim($first . "\n" . implode("\n", $restLines));
376
+ $footnoteDefs[$label] = self::toHtml($content);
377
+ return '';
378
+ },
379
+ $html
380
+ );
381
+
382
+
383
+ // ====================================================================
384
+ // ÉTAPE 2b : DÉFINITIONS DE LIENS PAR RÉFÉRENCE
385
+ // [label]: https://example.com "Titre optionnel"
386
+ // [label]: <https://example.com> 'Titre optionnel'
387
+ // [label]: https://example.com (Titre optionnel)
388
+ // Extraites (et retirées du texte) avant tout le reste ; utilisées
389
+ // plus loin par les liens [texte][label] / [texte][].
390
+ // ====================================================================
391
+ $refDefs = [];
392
+ $html = preg_replace_callback(
393
+ '/^[ \t]{0,3}\[([^\]]+)\]:[ \t]*<?([^\s>]+)>?(?:[ \t]+(?:"([^"]*)"|\'([^\']*)\'|\(([^)]*)\)))?[ \t]*$/m',
394
+ function ($m) use (&$refDefs): string {
395
+ $label = strtolower(trim($m[1]));
396
+ $title = $m[3] !== '' ? $m[3] : ($m[4] !== '' ? $m[4] : ($m[5] ?? ''));
397
+ $refDefs[$label] = ['url' => $m[2], 'title' => $title];
398
+ return '';
399
+ },
400
+ $html
401
+ );
402
+
403
+
140
404
  // ====================================================================
141
405
  // ÉTAPE 3 : BLOCS DE CODE (```lang ... ```)
142
406
  // ====================================================================
@@ -149,8 +413,44 @@ class MD {
149
413
  return $placeholder;
150
414
  }, $html);
151
415
 
152
- // Code inline (`...`)
416
+ // ====================================================================
417
+ // ÉTAPE 3a : BLOCS DE CODE INDENTÉS (4 espaces ou 1 tabulation)
418
+ // Reconnu seulement quand précédé d'une ligne vide (ou du début du
419
+ // document) et suivi d'une ligne vide (ou de la fin du document), afin
420
+ // d'éviter les conflits avec l'indentation des listes imbriquées.
421
+ // ====================================================================
422
+ $html = preg_replace_callback(
423
+ '/(?<=\n\n|^)((?:[ ]{4}|\t)[^\n]*(?:\n(?:[ ]{4}|\t)[^\n]*)*)(?=\n\n|\n*$)/',
424
+ function ($matches) use (&$codeBlocks) {
425
+ $lines = explode("\n", $matches[1]);
426
+ $stripped = array_map(static function (string $l): string {
427
+ return preg_replace('/^(?:[ ]{4}|\t)/', '', $l);
428
+ }, $lines);
429
+ $code = htmlspecialchars(implode("\n", $stripped), ENT_QUOTES, 'UTF-8');
430
+ $placeholder = "\x02CB" . count($codeBlocks) . "\x03";
431
+ $codeBlocks[$placeholder] = "<pre><code>{$code}</code></pre>";
432
+ return $placeholder;
433
+ },
434
+ $html
435
+ );
436
+
437
+ // Code inline avec double backticks (permet d'inclure un backtick littéral)
153
438
  $inlineCodes = [];
439
+ $html = preg_replace_callback('/``(.+?)``/s', function ($matches) use (&$inlineCodes) {
440
+ $content = $matches[1];
441
+ // Convention standard : si le contenu commence et finit par un
442
+ // espace (et n'est pas uniquement des espaces), on retire un
443
+ // espace de chaque côté — utile pour englober un ` en bordure.
444
+ if (preg_match('/^ (.*[^ ]) $/s', $content, $trim)) {
445
+ $content = $trim[1];
446
+ }
447
+ $code = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
448
+ $placeholder = "\x02IC" . count($inlineCodes) . "\x03";
449
+ $inlineCodes[$placeholder] = "<code>{$code}</code>";
450
+ return $placeholder;
451
+ }, $html);
452
+
453
+ // Code inline (`...`)
154
454
  $html = preg_replace_callback('/`([^`\n]+)`/', function ($matches) use (&$inlineCodes) {
155
455
  $code = htmlspecialchars($matches[1], ENT_QUOTES, 'UTF-8');
156
456
  $placeholder = "\x02IC" . count($inlineCodes) . "\x03";
@@ -160,7 +460,57 @@ class MD {
160
460
 
161
461
 
162
462
  // ====================================================================
163
- // ÉTAPE 3b : ALERTES GFM ET BLOCKQUOTES
463
+ // ÉTAPE 3b : ÉCHAPPEMENT DES CARACTÈRES (\* \_ \# etc.)
464
+ // Traité après l'extraction du code (le code reste littéral) et avant
465
+ // tout le reste, pour que \* n'ouvre pas une emphase, \# ne crée pas
466
+ // un titre, \- ne crée pas de liste, etc.
467
+ // ====================================================================
468
+ $escapes = [];
469
+ $html = preg_replace_callback(
470
+ '/\\\\([\\\\`*_{}\[\]<>()#+\-.!|])/',
471
+ function ($m) use (&$escapes): string {
472
+ $placeholder = "\x02ESC" . count($escapes) . "\x03";
473
+ $escapes[$placeholder] = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
474
+ return $placeholder;
475
+ },
476
+ $html
477
+ );
478
+ // &#124; est la convention documentée (Markdown Extra / PHP Markdown)
479
+ // pour afficher un pipe littéral dans une cellule de tableau sans
480
+ // qu'il soit interprété comme séparateur de colonnes.
481
+ $html = preg_replace_callback(
482
+ '/&#124;/i',
483
+ function () use (&$escapes): string {
484
+ $placeholder = "\x02ESC" . count($escapes) . "\x03";
485
+ $escapes[$placeholder] = '|';
486
+ return $placeholder;
487
+ },
488
+ $html
489
+ );
490
+
491
+
492
+ // ====================================================================
493
+ // ÉTAPE 3d : LIENS AUTOMATIQUES <https://...> et <email@example.com>
494
+ // Traités avant l'encodage XSS car les caractères < > seraient encodés
495
+ // en &lt; &gt; et la regex ne matcherait plus.
496
+ // ====================================================================
497
+ $autolinks = [];
498
+ $html = preg_replace_callback('/<(https?:\/\/[^\s<>]+)>/', function ($m) use (&$autolinks): string {
499
+ $url = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
500
+ $placeholder = "\x02AL" . count($autolinks) . "\x03";
501
+ $autolinks[$placeholder] = "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener noreferrer\">{$url}</a>";
502
+ return $placeholder;
503
+ }, $html);
504
+ $html = preg_replace_callback('/<([^\s<>]+@[^\s<>]+\.[^\s<>]+)>/', function ($m) use (&$autolinks): string {
505
+ $email = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
506
+ $placeholder = "\x02AL" . count($autolinks) . "\x03";
507
+ $autolinks[$placeholder] = "<a href=\"mailto:{$email}\">{$email}</a>";
508
+ return $placeholder;
509
+ }, $html);
510
+
511
+
512
+ // ====================================================================
513
+ // ÉTAPE 3e : ALERTES GFM ET BLOCKQUOTES
164
514
  // Traités avant l'encodage XSS car le caractère > serait encodé en &gt;
165
515
  // et les regex ne matcheraient plus.
166
516
  // ====================================================================
@@ -179,19 +529,26 @@ class MD {
179
529
  $blockquotes[$placeholder] = "<div class=\"markdown-alert markdown-alert-{$type}\">"
180
530
  . "<p class=\"markdown-alert-title\">{$label}</p>"
181
531
  . "<p>{$content}</p></div>";
182
- return $placeholder;
532
+ // Le \n final consommé par la regex est réinjecté après le
533
+ // placeholder pour ne pas fusionner la ligne vide suivante
534
+ // avec celle du placeholder (ce qui fausserait par exemple
535
+ // la détection d'un titre Setext juste après).
536
+ return $placeholder . (str_ends_with($matches[1], "\n") ? "\n" : '');
183
537
  },
184
538
  $html
185
539
  );
186
540
 
187
- // Blockquotes standards
541
+ // Blockquotes standards (imbrication gérée par récursion sur toHtml,
542
+ // qui ré-applique cette même règle sur le contenu déjà dé-préfixé
543
+ // d'un niveau de ">")
188
544
  $html = preg_replace_callback('/^((?:>[ \t]?[^\n]*\n?)+)/m', function ($matches) use (&$blockquotes): string {
189
545
  $content = preg_replace('/^>[ \t]?/m', '', $matches[1]);
190
546
  // Les deux espaces trailing sont laissés tels quels : toHtml() les gère lui-même
191
547
  $inner = self::toHtml(trim($content));
192
548
  $placeholder = "\x02BQ" . count($blockquotes) . "\x03";
193
549
  $blockquotes[$placeholder] = "<blockquote>{$inner}</blockquote>";
194
- return $placeholder;
550
+ // Voir commentaire ci-dessus : on préserve le \n final consommé.
551
+ return $placeholder . (str_ends_with($matches[1], "\n") ? "\n" : '');
195
552
  }, $html);
196
553
 
197
554
 
@@ -251,7 +608,7 @@ class MD {
251
608
 
252
609
 
253
610
  // ====================================================================
254
- // ÉTAPE 6 : (Alertes GFM et blockquotes traités à l'étape 3b)
611
+ // ÉTAPE 6 : (Alertes GFM et blockquotes traités à l'étape 3e)
255
612
  // ====================================================================
256
613
 
257
614
 
@@ -263,44 +620,79 @@ class MD {
263
620
 
264
621
 
265
622
  // ====================================================================
266
- // ÉTAPE 8 : TITRES (ATX : # à ######)
623
+ // ÉTAPE 7b : TITRES SETEXT (syntaxe alternative == / --)
624
+ // Titre
625
+ // ===== → <h1>
626
+ //
627
+ // Titre
628
+ // ----- → <h2>
629
+ // Traité avant les titres ATX et avant les lignes séparatrices (une
630
+ // ligne de tirets juste après une ligne de texte est un titre, pas un <hr>).
267
631
  // ====================================================================
268
- $html = preg_replace_callback('/^(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?$/m', function ($matches) {
269
- $level = strlen($matches[1]);
270
- $text = trim($matches[2]);
271
- $id = strtolower(preg_replace('/[^\w\- ]/u', '', $text));
272
- $id = preg_replace('/\s+/', '-', trim($id));
273
- return "<h{$level} id=\"{$id}\">{$text}</h{$level}>";
274
- }, $html);
632
+ $html = preg_replace_callback(
633
+ '/^(?![ \t]*(?:#{1,6}[ \t]|>|```|\||[-*+][ \t]|\d+\.[ \t]))[ \t]*(\S.*?)[ \t]*(?:\{#([a-zA-Z0-9_\-:.]+)\}[ \t]*)?\n[ \t]*=+[ \t]*$/m',
634
+ function ($matches) {
635
+ $text = trim($matches[1]);
636
+ $id = !empty($matches[2]) ? $matches[2] : self::slugify($text);
637
+ return "<h1 id=\"{$id}\">{$text}</h1>";
638
+ },
639
+ $html
640
+ );
641
+ $html = preg_replace_callback(
642
+ '/^(?![ \t]*(?:#{1,6}[ \t]|>|```|\||[-*+][ \t]|\d+\.[ \t]))[ \t]*(\S.*?)[ \t]*(?:\{#([a-zA-Z0-9_\-:.]+)\}[ \t]*)?\n[ \t]*-+[ \t]*$/m',
643
+ function ($matches) {
644
+ $text = trim($matches[1]);
645
+ $id = !empty($matches[2]) ? $matches[2] : self::slugify($text);
646
+ return "<h2 id=\"{$id}\">{$text}</h2>";
647
+ },
648
+ $html
649
+ );
275
650
 
276
651
 
277
652
  // ====================================================================
278
- // ÉTAPE 9 : LISTES (puces et ordonnées)
653
+ // ÉTAPE 8 : TITRES (ATX : # à ######)
279
654
  // ====================================================================
280
655
  $html = preg_replace_callback(
281
- '/^([ \t]*\d+\. .+(?:\n[ \t]*\d+\. .+)*)/m',
656
+ '/^(#{1,6})[ \t]+(.+?)[ \t]*(?:\{#([a-zA-Z0-9_\-:.]+)\}[ \t]*)?(?:[ \t]+#+)?$/m',
282
657
  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>";
658
+ $level = strlen($matches[1]);
659
+ $text = trim($matches[2]);
660
+ $id = !empty($matches[3]) ? $matches[3] : self::slugify($text);
661
+ return "<h{$level} id=\"{$id}\">{$text}</h{$level}>";
290
662
  },
291
663
  $html
292
664
  );
293
665
 
666
+
667
+ // ====================================================================
668
+ // ÉTAPE 9 : LISTES (puces et ordonnées, avec imbrication)
669
+ // Une seule passe détecte un bloc contigu de lignes qui sont soit une
670
+ // puce (-,*,+) soit un item numéroté, quel que soit leur niveau
671
+ // d'indentation ; le bloc est ensuite reconstruit récursivement en
672
+ // <ol>/<ul> imbriqués selon la profondeur d'indentation relative.
673
+ // Les items de tâches (déjà convertis en <li class="task-item">) ne
674
+ // matchent plus ce motif et ne sont donc pas ré-englobés ici.
675
+ // ====================================================================
294
676
  $html = preg_replace_callback(
295
- '/^([ \t]*[-*+] (?!\[[ xX]\] ).+(?:\n[ \t]*[-*+] (?!\[[ xX]\] ).+)*)/m',
677
+ '/^([ \t]*(?:\d+\.|[-*+])[ \t]+.+(?:\n[ \t]*(?:\d+\.|[-*+])[ \t]+.+)*)/m',
296
678
  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";
679
+ $lines = explode("\n", $matches[1]);
680
+ $items = [];
681
+ foreach ($lines as $line) {
682
+ if (preg_match('/^([ \t]*)(\d+)\.[ \t]+(.*)$/', $line, $m)) {
683
+ $items[] = ['indent' => self::indentWidth($m[1]), 'type' => 'ol', 'text' => $m[3]];
684
+ } elseif (preg_match('/^([ \t]*)[-*+][ \t]+(.*)$/', $line, $m)) {
685
+ $items[] = ['indent' => self::indentWidth($m[1]), 'type' => 'ul', 'text' => $m[2]];
686
+ }
302
687
  }
303
- return $out . "</ul>";
688
+ if (empty($items)) return $matches[1];
689
+ // Normalise le niveau d'indentation le plus bas à 0
690
+ $minIndent = min(array_column($items, 'indent'));
691
+ foreach ($items as &$it) $it['indent'] -= $minIndent;
692
+ unset($it);
693
+
694
+ $i = 0;
695
+ return self::buildListTree($items, $i, count($items));
304
696
  },
305
697
  $html
306
698
  );
@@ -315,7 +707,39 @@ class MD {
315
707
 
316
708
 
317
709
  // ====================================================================
318
- // ÉTAPE 10 : TEXTE EN LIGNE (Gras, Italique, Barré)
710
+ // ÉTAPE 9b : LISTES DE DÉFINITION (syntaxe étendue)
711
+ // Terme
712
+ // : Définition
713
+ // ====================================================================
714
+ $html = self::extractDefinitionLists($html);
715
+
716
+
717
+ // ====================================================================
718
+ // ÉTAPE 9c : RÉFÉRENCES DE NOTES DE BAS DE PAGE [^label]
719
+ // Converties AVANT l'emphase pour ne pas entrer en collision avec le
720
+ // nouvel exposant ^texte^ (un [^1] suivi plus loin d'un [^2] sur la
721
+ // même ligne pourrait sinon être interprété comme ^1] ... [^2^).
722
+ // La numérotation est séquentielle, dans l'ordre de première
723
+ // apparition dans le texte (comme documenté).
724
+ // ====================================================================
725
+ $footnoteOrder = [];
726
+ $html = preg_replace_callback('/\[\^([^\]\s]+)\]/', function ($m) use (&$footnoteOrder, &$footnoteDefs): string {
727
+ $label = strtolower(trim($m[1]));
728
+ if (!isset($footnoteDefs[$label])) {
729
+ // Référence vers une note non définie : laissée telle quelle.
730
+ return $m[0];
731
+ }
732
+ if (!isset($footnoteOrder[$label])) {
733
+ $footnoteOrder[$label] = count($footnoteOrder) + 1;
734
+ }
735
+ $num = $footnoteOrder[$label];
736
+ return "<sup id=\"fnref:{$label}\"><a href=\"#fn:{$label}\">{$num}</a></sup>";
737
+ }, $html);
738
+
739
+
740
+ // ====================================================================
741
+ // ÉTAPE 10 : TEXTE EN LIGNE (Gras, Italique, Barré, Surlignage,
742
+ // Indice/Exposant, Emoji)
319
743
  // ====================================================================
320
744
  $html = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $html);
321
745
  $html = preg_replace('/___(.+?)___/s', '<strong><em>$1</em></strong>', $html);
@@ -325,7 +749,25 @@ class MD {
325
749
  // Le _ italique ne doit matcher qu'aux frontières de mots pour ne pas
326
750
  // capturer les snake_case, noms de packages (@php-wasm/node), etc.
327
751
  $html = preg_replace('/(?<!\w)_([^_\n]+)_(?!\w)/', '<em>$1</em>', $html);
752
+ // Surlignage ==texte== (syntaxe étendue)
753
+ $html = preg_replace('/==(.+?)==/s', '<mark>$1</mark>', $html);
754
+ // Barré ~~texte~~ — traité AVANT le sous-script (simple ~) pour que
755
+ // celui-ci ne matche pas la moitié d'une paire de tildes doubles.
328
756
  $html = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $html);
757
+ // Exposant ^texte^ (syntaxe étendue) — placé avant l'échappement des
758
+ // références de notes ([^label]) n'est pas un souci : celles-ci sont
759
+ // encadrées de crochets et ne forment donc pas de paire ^...^ isolée.
760
+ $html = preg_replace('/\^([^\^\n]+)\^/', '<sup>$1</sup>', $html);
761
+ // Sous-script ~texte~ (un seul tilde ; les ~~ ont déjà été consommés
762
+ // juste au-dessus par le barré).
763
+ $html = preg_replace('/~([^~\n]+)~/', '<sub>$1</sub>', $html);
764
+
765
+ // Émojis :shortcode: (syntaxe étendue) — les raccourcis inconnus sont
766
+ // laissés tels quels plutôt que silencieusement supprimés.
767
+ $html = preg_replace_callback('/:([a-zA-Z0-9_+\-]+):/', function ($m): string {
768
+ $emoji = self::emojiFor($m[1]);
769
+ return $emoji ?? $m[0];
770
+ }, $html);
329
771
 
330
772
 
331
773
  // ====================================================================
@@ -339,29 +781,39 @@ class MD {
339
781
  $html
340
782
  );
341
783
 
342
- // Liens markdown [texte](url "titre optionnel")
784
+ $buildLink = static function (string $text, string $href, string $title): string {
785
+ $titleAttr = $title !== '' ? ' title="' . $title . '"' : '';
786
+ $extern = preg_match('/^https?:\/\//i', $href)
787
+ ? ' target="_blank" rel="noopener noreferrer"'
788
+ : '';
789
+ return "<a href=\"{$href}\"{$titleAttr}{$extern}>{$text}</a>";
790
+ };
791
+
792
+ // Liens par référence [texte][label] et [texte][] (raccourci = label = texte)
343
793
  $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>";
794
+ '/\[([^\]]+)\]\[([^\]]*)\]/',
795
+ function ($m) use (&$refDefs, $buildLink): string {
796
+ $text = $m[1];
797
+ $label = strtolower(trim($m[2] !== '' ? $m[2] : $m[1]));
798
+ if (!isset($refDefs[$label])) return $m[0];
799
+ $def = $refDefs[$label];
800
+ return $buildLink($text, $def['url'], $def['title']);
353
801
  },
354
802
  $html
355
803
  );
356
804
 
357
- // Liens automatiques <https://...>
358
- $html = preg_replace(
359
- '/<(https?:\/\/[^>]+)>/',
360
- '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
805
+ // Liens markdown [texte](url "titre optionnel")
806
+ $html = preg_replace_callback(
807
+ '/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/',
808
+ function ($m) use ($buildLink): string {
809
+ return $buildLink($m[1], $m[2], $m[3] ?? '');
810
+ },
361
811
  $html
362
812
  );
363
813
 
364
- // URL nues https://...
814
+ // URL nues https://... (syntaxe étendue : auto-link sans crochets).
815
+ // Exclut celles déjà entre guillemets/attributs (href="...") ou déjà
816
+ // transformées en lien pour ne pas les doubler.
365
817
  $html = preg_replace(
366
818
  '/(?<!["\'=>])\b(https?:\/\/[^\s<>"\')\]]+)/',
367
819
  '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
@@ -384,12 +836,16 @@ class MD {
384
836
  // ====================================================================
385
837
  $blockStartTags = ['<h', '<pre', '<ul', '<ol', '<li', '<table', '<thead', '<tbody',
386
838
  '<tr', '<td', '<th', '<blockquote', '<div', '<hr', '<img',
387
- '</ul>', '</ol>', '</table>', '</blockquote>', '</div>',
388
- "\x02CB", "\x02IC", "\x02PLG", "\x02BQ"];
839
+ '<dl', '<dt', '<dd',
840
+ "\x02CB", "\x02PLG", "\x02BQ"];
389
841
 
390
842
  $isBlockLine = static function (string $line) use ($blockStartTags): bool {
391
843
  $t = ltrim($line);
392
844
  if ($t === '') return false;
845
+ // Toute balise fermante (</...>) est toujours considérée comme une
846
+ // ligne "bloc" : ça évite qu'une fermeture de <table>, <thead>,
847
+ // <tr>, etc. finisse absorbée dans un <p> environnant.
848
+ if (str_starts_with($t, '</')) return true;
393
849
  foreach ($blockStartTags as $tag) {
394
850
  if (str_starts_with($t, $tag)) return true;
395
851
  }
@@ -437,8 +893,27 @@ class MD {
437
893
  $html = strtr($html, $blockquotes);
438
894
  $html = strtr($html, $codeBlocks);
439
895
  $html = strtr($html, $inlineCodes);
896
+ $html = strtr($html, $autolinks);
897
+ // Les échappements sont réinjectés en tout dernier, une fois que plus
898
+ // aucune regex Markdown ne peut les interpréter.
899
+ $html = strtr($html, $escapes);
900
+
901
+
902
+ // ====================================================================
903
+ // ÉTAPE 15 : BLOC DES NOTES DE BAS DE PAGE
904
+ // Ajouté en fin de document, uniquement si au moins une note a été
905
+ // référencée (les notes définies mais jamais référencées sont
906
+ // silencieusement ignorées).
907
+ // ====================================================================
908
+ if (!empty($footnoteOrder)) {
909
+ $html .= "\n<div class=\"footnotes\">\n<ol>\n";
910
+ foreach ($footnoteOrder as $label => $num) {
911
+ $content = $footnoteDefs[$label];
912
+ $html .= " <li id=\"fn:{$label}\">{$content} <a href=\"#fnref:{$label}\" class=\"footnote-backref\">↩</a></li>\n";
913
+ }
914
+ $html .= "</ol>\n</div>";
915
+ }
440
916
 
441
917
  return $html;
442
918
  }
443
- }
444
-
919
+ }
@@ -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