@kirigami/php-prepros 1.1.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,36 @@
1
1
  <?php
2
2
 
3
+
4
+
5
+ if(!boolval(PREPROS::$config->network ?? false)) STD::error("CURL Error: Network option is not activated.");
6
+
7
+
3
8
  class CURL {
4
9
 
10
+ // Mimics a Google Chrome (stable) browser on Windows 11, desktop, requesting
11
+ // a top-level document. Windows 11 still reports "Windows NT 10.0" in the UA
12
+ // string; the real version is only exposed through the Sec-CH-UA-Platform-Version
13
+ // client hint ("15.0.0"). Accept advertises every image format modern Chrome
14
+ // supports, AVIF included.
5
15
  const HEADERS = [
6
- 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36',
7
- 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
16
+ 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
17
+ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
8
18
  'Accept-Language: fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7',
9
- 'Sec-Ch-Ua: "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"',
19
+ // Accept-Encoding is intentionally left to CURLOPT_ENCODING so curl only
20
+ // advertises what it can actually transparently decode.
21
+ 'Sec-Ch-Ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
10
22
  'Sec-Ch-Ua-Mobile: ?0',
11
23
  'Sec-Ch-Ua-Platform: "Windows"',
24
+ 'Sec-Ch-Ua-Platform-Version: "15.0.0"',
25
+ 'Sec-Ch-Ua-Arch: "x86"',
26
+ 'Sec-Ch-Ua-Bitness: "64"',
27
+ 'Sec-Ch-Ua-Full-Version-List: "Google Chrome";v="131.0.6778.86", "Chromium";v="131.0.6778.86", "Not_A Brand";v="24.0.0.0"',
12
28
  'Sec-Fetch-Dest: document',
13
29
  'Sec-Fetch-Mode: navigate',
14
30
  'Sec-Fetch-Site: none',
15
31
  'Sec-Fetch-User: ?1',
16
32
  'Upgrade-Insecure-Requests: 1',
33
+ 'Priority: u=0, i',
17
34
  ];
18
35
 
19
36
 
@@ -48,6 +48,126 @@ class FS
48
48
  }
49
49
 
50
50
 
51
+ /**
52
+ * Lists the immediate child pages of the calling template.
53
+ *
54
+ * Scans the directories directly below the folder of the file that
55
+ * called this function, keeps the ones that hold an `_index.php`, parses
56
+ * that file's first PHPDOC block via {@see FS::phpFileInfo()} and returns
57
+ * the pages ordered by `@position` ascending (a missing `@position`
58
+ * counts as 999999), then by folder name.
59
+ *
60
+ * Only usable while a render is in progress (`PREPROS::$file` set).
61
+ *
62
+ * @param string $anchor Path whose folder is scanned; defaults to the page
63
+ * currently being rendered (`PREPROS::$file`), so it
64
+ * works the same called straight from a template, from
65
+ * a layout partial, or from a helper function.
66
+ * @return object[] One `stdClass` per child: the parsed PHPDOC
67
+ * of its `_index.php`, plus a `->file` key
68
+ * holding that file's absolute path.
69
+ * @throws \Exception When called outside of a render.
70
+ */
71
+ public static function getChildren(string $anchor = ''): array
72
+ {
73
+ if (PREPROS::$file === '') throw new Exception('FS::getChildren() can only be called during a render.');
74
+ if (!$anchor) $anchor = PREPROS::$file;
75
+ if (!$anchor || !$dir = realpath(pathinfo($anchor, PATHINFO_DIRNAME))) return [];
76
+
77
+ $children = [];
78
+ foreach (glob($dir . '/*', GLOB_ONLYDIR) as $subdir) {
79
+ $index = $subdir . '/_index.php';
80
+ if (!is_file($index)) continue;
81
+ $info = FS::phpFileInfo($index) ?: new stdClass;
82
+ $info = clone $info;
83
+ $info->file = realpath($index);
84
+ $position = (isset($info->position) && is_numeric($info->position)) ? (int) $info->position : 999999;
85
+ $children[] = ['position' => $position, 'name' => pathinfo($subdir, PATHINFO_BASENAME), 'info' => $info];
86
+ }
87
+
88
+ usort($children, fn($a, $b) => ($a['position'] <=> $b['position']) ?: strnatcasecmp($a['name'], $b['name']));
89
+
90
+ return array_column($children, 'info');
91
+ }
92
+
93
+
94
+ /**
95
+ * Builds the breadcrumb trail of the calling page.
96
+ *
97
+ * Only runs when the calling file opts in with `@breadcrumb true` (or
98
+ * `@breadcrumb 1`) in its first PHPDOC block; otherwise returns `[]`.
99
+ *
100
+ * Starting from the folder *above* the caller's own folder (the current
101
+ * page is never part of its own trail), it walks the parent directories
102
+ * upward, collecting the `_index.php` of each one via
103
+ * {@see FS::phpFileInfo()}. The walk stops at the source root, or at the
104
+ * first ancestor `_index.php` that does not carry an active `@breadcrumb`
105
+ * tag — that page is a pure separator and is left out of the result.
106
+ * Directories with no `_index.php` are skipped without breaking the chain.
107
+ *
108
+ * Only usable while a render is in progress (`PREPROS::$file` set).
109
+ *
110
+ * @param string $anchor Path the trail is walked from; defaults to the page
111
+ * currently being rendered (`PREPROS::$file`), so it
112
+ * works the same called straight from a template, from
113
+ * a layout partial, or from a helper function.
114
+ * @return object[] One `stdClass` per ancestor page, ordered from
115
+ * the top-most ancestor down to the nearest
116
+ * parent: the parsed PHPDOC of its `_index.php`,
117
+ * plus a `->file` key holding that file's
118
+ * absolute path.
119
+ * @throws \Exception When called outside of a render.
120
+ */
121
+ public static function getBreadcrumb(string $anchor = ''): array
122
+ {
123
+ if (PREPROS::$file === '') throw new Exception('FS::getBreadcrumb() can only be called during a render.');
124
+ if (!$anchor) $anchor = PREPROS::$file;
125
+ if (!$anchor || !$dir = realpath(pathinfo($anchor, PATHINFO_DIRNAME))) return [];
126
+
127
+ $self = FS::phpFileInfo($anchor) ?: new stdClass;
128
+ if (!self::truthy($self->breadcrumb ?? null)) return [];
129
+
130
+ if (!$root = realpath(PREPROS::$config->root)) return [];
131
+ $root = rtrim(str_replace('\\', '/', $root), '/');
132
+ $dir = rtrim(str_replace('\\', '/', $dir), '/');
133
+
134
+ $trail = [];
135
+ while (true) {
136
+ $parent = str_replace('\\', '/', dirname($dir));
137
+ if ($parent === $dir) break; // filesystem root
138
+ $dir = $parent;
139
+ if (strncmp($dir . '/', $root . '/', strlen($root) + 1) !== 0) break; // above the source root
140
+
141
+ $index = $dir . '/_index.php';
142
+ if (is_file($index)) {
143
+ $info = FS::phpFileInfo($index) ?: new stdClass;
144
+ if (!self::truthy($info->breadcrumb ?? null)) break; // separator page: stop, exclude it
145
+ $info = clone $info;
146
+ $info->file = realpath($index);
147
+ $trail[] = $info;
148
+ }
149
+
150
+ if ($dir === $root) break;
151
+ }
152
+
153
+ return array_reverse($trail);
154
+ }
155
+
156
+
157
+ /**
158
+ * Loose truthiness test for a PHPDOC tag value (`true` / `1` / `yes` /
159
+ * `on`, case-insensitive). PHPDOC values always come through as strings,
160
+ * but bool/int are handled too for callers that pass a raw value.
161
+ */
162
+ private static function truthy(mixed $v): bool
163
+ {
164
+ if (is_bool($v)) return $v;
165
+ if (is_int($v)) return $v === 1;
166
+ if (!is_string($v)) return false;
167
+ return in_array(strtolower(trim($v)), ['1', 'true', 'yes', 'on'], true);
168
+ }
169
+
170
+
51
171
  public static function phpFileInfo(string $file): object|bool
52
172
  {
53
173
  static $files = [];
@@ -62,17 +182,52 @@ class FS
62
182
  }
63
183
  }
64
184
  if (empty($block)) return new stdClass;
65
- if (!preg_match_all('#@([a-z0-9]+)[\s\t]+([^\n]+)#msi', $block, $m)) $files[$file] = new stdClass;
66
- else {
67
- $info = [];
68
- foreach ($m[1] as $k => $v) $info[trim($v)] = trim($m[2][$k]);
69
- $files[$file] = (object)$info;
70
- }
185
+ $files[$file] = (object) self::parseDocBlock($block);
71
186
  }
72
187
  return $files[$file];
73
188
  }
74
189
 
75
190
 
191
+ /**
192
+ * Parses the `@tag value` lines of a PHPDOC block into a `tag => value` map.
193
+ *
194
+ * Only lines whose first non-whitespace character (past an optional `*`
195
+ * gutter) is an `@` start a tag — so an `@word` dropped mid-sentence in the
196
+ * block's prose is ignored and can't clobber a real tag. A tag's value may
197
+ * wrap onto the following *indented* (hanging-indent) continuation lines,
198
+ * up to the next `@tag`, a blank line, a flush-left prose line, or the end
199
+ * of the block; continuation lines are joined with a single space.
200
+ *
201
+ * @param string $block Raw `/** … *&#47;` doc-comment text.
202
+ * @return array<string,string>
203
+ */
204
+ private static function parseDocBlock(string $block): array
205
+ {
206
+ $info = [];
207
+ $current = null;
208
+
209
+ foreach (preg_split('/\r\n|\r|\n/', $block) as $line) {
210
+ // Drop the opening `/**`, a ` * ` gutter, and the closing ` */`.
211
+ $line = preg_replace('#^\s*/\*\*+#', '', $line);
212
+ $line = preg_replace('#\s*\*/\s*$#', '', $line);
213
+ $line = preg_replace('#^[ \t]*\*[ \t]?#', '', $line, 1);
214
+
215
+ if (preg_match('/^[ \t]*@([A-Za-z0-9_]+)[ \t]*(.*)$/', $line, $m)) {
216
+ $current = trim($m[1]);
217
+ $info[$current] = trim($m[2]);
218
+ } elseif (trim($line) === '') {
219
+ $current = null; // blank line ends a value
220
+ } elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
221
+ $info[$current] = trim($info[$current] . ' ' . trim($line)); // hanging indent → continuation
222
+ } else {
223
+ $current = null; // flush-left prose ends a value
224
+ }
225
+ }
226
+
227
+ return $info;
228
+ }
229
+
230
+
76
231
  public static function rmdir(string $dir, bool $removeSelf = true): bool
77
232
  {
78
233
  if (!file_exists($dir)) return true;
@@ -1,16 +1,16 @@
1
1
  <?php
2
2
 
3
3
  /**
4
- * HtmlFormatter — formateur HTML pour Kirigami
5
- * Utilise Dom\HTMLDocument (PHP 8.4, moteur Lexbor) pour parser,
6
- * puis re-sérialise avec indentation.
7
- * Les blocs <script> et <style> sont indentés au bon niveau mais non reformatés.
4
+ * HtmlFormatter — HTML formatter for Kirigami
5
+ * Uses Dom\HTMLDocument (PHP 8.4, Lexbor engine) to parse, then re-serializes
6
+ * with indentation.
7
+ * <script> and <style> blocks are indented at the right level but not reformatted.
8
8
  */
9
9
  class HTML
10
10
  {
11
11
  private const INDENT = 4;
12
12
 
13
- // Attributs booléens HTML5 — écrits sans valeur
13
+ // HTML5 boolean attributes — written without a value
14
14
  private const BOOLEAN_ATTRS = [
15
15
  'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked',
16
16
  'controls', 'default', 'defer', 'disabled', 'formnovalidate',
@@ -19,7 +19,7 @@ class HTML
19
19
  'readonly', 'required', 'reversed', 'selected', 'webkit-playsinline',
20
20
  ];
21
21
 
22
- // Éléments inline — leur présence dans un parent n'empêche pas le rendu inline
22
+ // Inline elements — their presence in a parent doesn't prevent inline rendering
23
23
  private const INLINE = [
24
24
  'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite',
25
25
  'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map',
@@ -28,21 +28,35 @@ class HTML
28
28
  ];
29
29
 
30
30
  private const RAW = ['script', 'style'];
31
+ // Whitespace-significant elements: their text content is emitted byte for
32
+ // byte, never re-indented or collapsed (a code block must keep its line
33
+ // breaks and leading spaces; a <textarea> its exact value).
34
+ private const VERBATIM = ['pre', 'textarea'];
31
35
  private const VOID = [
32
36
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
33
37
  'link', 'meta', 'source', 'track', 'wbr',
34
38
  ];
35
39
 
40
+ /** Verbatim blocks stashed during a format() run, keyed by placeholder. */
41
+ private static array $verbatim = [];
42
+
36
43
  public static function format(string $html): string
37
44
  {
38
45
  $dom = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
39
46
 
47
+ self::$verbatim = [];
40
48
  $output = '';
41
49
  foreach ($dom->childNodes as $node) {
42
50
  $output .= static::renderNode($node, 0);
43
51
  }
44
52
 
53
+ // Collapse runs of blank lines — but not inside <pre>/<textarea>, whose
54
+ // content was swapped out for a placeholder above.
45
55
  $output = preg_replace('/\n{3,}/', "\n\n", $output);
56
+ if (self::$verbatim) {
57
+ $output = strtr($output, self::$verbatim);
58
+ self::$verbatim = [];
59
+ }
46
60
 
47
61
  return rtrim($output) . "\n";
48
62
  }
@@ -69,6 +83,44 @@ class HTML
69
83
  return "{$pad}<{$tag}{$attrs}>\n";
70
84
  }
71
85
 
86
+ // `<pre><code>` (a fenced code block): re-indent the code to this
87
+ // element's depth so the HTML source stays readable. The exact leading
88
+ // run added here is stripped again before display — at build time by
89
+ // @kirigami/plugin-highlight, otherwise by the small de-indent script
90
+ // Kirigami injects (prepros.head). Relative indentation is preserved.
91
+ if ($tag === 'pre') {
92
+ $code = self::soleCodeChild($node);
93
+ if ($code !== null) {
94
+ $childPad = str_repeat(' ', ($depth + 1) * self::INDENT);
95
+ $lines = explode("\n", $code->innerHTML);
96
+
97
+ $min = PHP_INT_MAX;
98
+ foreach ($lines as $line) {
99
+ if (trim($line) === '') continue;
100
+ $min = min($min, strlen($line) - strlen(ltrim($line, ' ')));
101
+ }
102
+ $min = $min === PHP_INT_MAX ? 0 : $min;
103
+
104
+ $body = implode("\n", array_map(
105
+ static fn(string $line) => trim($line) === '' ? '' : $childPad . substr($line, $min),
106
+ $lines
107
+ ));
108
+ $token = "\x01VERB" . count(self::$verbatim) . "\x01";
109
+ self::$verbatim[$token] = "\n" . rtrim($body, "\n") . "\n{$pad}";
110
+ return "{$pad}<pre><code" . static::renderAttrs($code) . ">{$token}</code></pre>\n";
111
+ }
112
+ }
113
+
114
+ // Whitespace-significant: emit the inner HTML exactly as parsed, so a
115
+ // bare <pre>/<textarea> keeps its line breaks and indentation. Only the
116
+ // opening tag is padded to the current depth.
117
+ if (in_array($tag, self::VERBATIM, true)) {
118
+ /** @var DOMElement $node */
119
+ $token = "\x01VERB" . count(self::$verbatim) . "\x01";
120
+ self::$verbatim[$token] = $node->innerHTML;
121
+ return "{$pad}<{$tag}{$attrs}>" . $token . "</{$tag}>\n";
122
+ }
123
+
72
124
  if ($isRaw) {
73
125
  /** @var DOMElement $node */
74
126
  $inner = trim($node->innerHTML, "\n\r");
@@ -78,7 +130,7 @@ class HTML
78
130
  $childPad = str_repeat(' ', ($depth + 1) * self::INDENT);
79
131
  $lines = explode("\n", $inner);
80
132
 
81
- // Calcule le niveau d'indentation minimal existant (ignore les lignes vides)
133
+ // Compute the existing minimum indentation level (ignoring blank lines)
82
134
  $minIndent = PHP_INT_MAX;
83
135
  foreach ($lines as $line) {
84
136
  if (trim($line) === '') continue;
@@ -86,7 +138,7 @@ class HTML
86
138
  }
87
139
  $minIndent = $minIndent === PHP_INT_MAX ? 0 : $minIndent;
88
140
 
89
- // Dédente puis ré-indente au bon niveau
141
+ // Dedent then re-indent at the right level
90
142
  $indented = implode("\n", array_map(
91
143
  static fn(string $line) => trim($line) !== ''
92
144
  ? $childPad . substr($line, $minIndent)
@@ -102,11 +154,11 @@ class HTML
102
154
  return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
103
155
  }
104
156
 
105
- // Si tous les enfants sont inline ET que ça ressemble à un flux de texte
106
- // (du vrai texte, ou un seul élément enfant), on re-sérialise en une seule ligne.
107
- // Sans looksLikeTextFlow, un <section> qui contient plusieurs gros <a> côte à côte
108
- // (ex: une grille de logos) se retrouverait aussi collé sur une seule ligne,
109
- // puisque <a> est dans INLINE — ce n'est pas ce qu'on veut.
157
+ // If all children are inline AND it looks like a text flow (real text,
158
+ // or a single child element), re-serialize on a single line.
159
+ // Without looksLikeTextFlow, a <section> holding several large <a> side
160
+ // by side (e.g. a logo grid) would also end up crammed onto one line,
161
+ // since <a> is in INLINE — which is not what we want.
110
162
  if (static::hasOnlyInlineChildren($node) && static::looksLikeTextFlow($node)) {
111
163
  $inner = trim(static::renderInline($node));
112
164
  return "{$pad}<{$tag}{$attrs}>{$inner}</{$tag}>\n";
@@ -120,9 +172,9 @@ class HTML
120
172
  return "{$pad}<{$tag}{$attrs}>\n{$inner}{$pad}</{$tag}>\n";
121
173
  }
122
174
 
123
- // Sérialise le contenu inline d'un nœud sur une seule ligne, en réduisant
124
- // tout groupe d'espaces/tabs/retours à la ligne du texte source à une seule espace
125
- // (équivalent au comportement de collapse des espaces en HTML).
175
+ // Serializes a node's inline content on a single line, collapsing any run
176
+ // of spaces/tabs/newlines in the source text to a single space (equivalent
177
+ // to HTML's whitespace-collapsing behavior).
126
178
  private static function renderInline(Dom\Node $node): string
127
179
  {
128
180
  $out = '';
@@ -150,9 +202,10 @@ class HTML
150
202
  return $out;
151
203
  }
152
204
 
153
- // Distingue "du texte qui contient un peu d'inline" (Cliquez <a>ici</a>.) d'un
154
- // conteneur qui aligne simplement plusieurs blocs inline côte à côte (grille de <a><img></a>).
155
- // Vrai si : il y a du texte significatif parmi les enfants, OU un seul enfant élément.
205
+ // Tells "text that contains a bit of inline" (Click <a>here</a>.) apart from
206
+ // a container that just lines up several inline blocks side by side (a grid
207
+ // of <a><img></a>).
208
+ // True if: there is meaningful text among the children, OR a single child element.
156
209
  private static function looksLikeTextFlow(Dom\Node $node): bool
157
210
  {
158
211
  $elementCount = 0;
@@ -167,8 +220,8 @@ class HTML
167
220
  return $elementCount <= 1;
168
221
  }
169
222
 
170
- // Vérifie que tous les descendants directs sont inline (texte, void inline, éléments inline)
171
- // Les éléments inline eux-mêmes ne doivent pas contenir d'éléments block
223
+ // Checks that every direct descendant is inline (text, inline void, inline elements)
224
+ // The inline elements themselves must not contain block elements
172
225
  private static function hasOnlyInlineChildren(Dom\Node $node): bool
173
226
  {
174
227
  foreach ($node->childNodes as $child) {
@@ -181,7 +234,7 @@ class HTML
181
234
  if (!in_array(strtolower($child->nodeName), self::INLINE, true)) {
182
235
  return false;
183
236
  }
184
- // Récursif : l'élément inline ne doit pas contenir d'éléments block
237
+ // Recursive: the inline element must not contain block elements
185
238
  if (!static::hasOnlyInlineChildren($child)) {
186
239
  return false;
187
240
  }
@@ -189,6 +242,23 @@ class HTML
189
242
  return true;
190
243
  }
191
244
 
245
+ // The lone <code> child of a <pre> (a fenced code block), or null when the
246
+ // <pre> holds anything else — raw text, several elements, markup to keep.
247
+ private static function soleCodeChild(Dom\Node $node): ?Dom\Node
248
+ {
249
+ $code = null;
250
+ foreach ($node->childNodes as $child) {
251
+ if ($child->nodeType === XML_TEXT_NODE) {
252
+ if (trim($child->nodeValue) !== '') return null;
253
+ continue;
254
+ }
255
+ if ($code !== null || $child->nodeType !== XML_ELEMENT_NODE) return null;
256
+ if (strtolower($child->nodeName) !== 'code') return null;
257
+ $code = $child;
258
+ }
259
+ return $code;
260
+ }
261
+
192
262
  private static function renderText(Dom\Node $node, int $depth): string
193
263
  {
194
264
  $text = trim($node->nodeValue);