@kirigami/php-prepros 1.2.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,18 +7,30 @@ if(!boolval(PREPROS::$config->network ?? false)) STD::error("CURL Error: Network
7
7
 
8
8
  class CURL {
9
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.
10
15
  const HEADERS = [
11
- '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',
12
- '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',
13
18
  'Accept-Language: fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7',
14
- '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"',
15
22
  'Sec-Ch-Ua-Mobile: ?0',
16
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"',
17
28
  'Sec-Fetch-Dest: document',
18
29
  'Sec-Fetch-Mode: navigate',
19
30
  'Sec-Fetch-Site: none',
20
31
  'Sec-Fetch-User: ?1',
21
32
  'Upgrade-Insecure-Requests: 1',
33
+ 'Priority: u=0, i',
22
34
  ];
23
35
 
24
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;
@@ -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");
@@ -190,6 +242,23 @@ class HTML
190
242
  return true;
191
243
  }
192
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
+
193
262
  private static function renderText(Dom\Node $node, int $depth): string
194
263
  {
195
264
  $text = trim($node->nodeValue);
@@ -147,6 +147,13 @@ class IMG
147
147
 
148
148
  public function resize(int $width, int $height = 0, bool $cover = false)
149
149
  {
150
+ // Never upscale. The AVIF encoder (and, less visibly, the others) can
151
+ // choke on an enlarged raster, and blowing pixels up gains nothing —
152
+ // so a target larger than the source is clamped down to it rather than
153
+ // throwing an opaque encode error. Ask for sizes <= the source.
154
+ if ($width > $this->width) $width = $this->width;
155
+ if ($height > $this->height) $height = $this->height;
156
+
150
157
  $srcRatio = $this->width / $this->height;
151
158
 
152
159
  if (!$height) {
@@ -397,17 +404,25 @@ class IMG
397
404
  }
398
405
 
399
406
 
400
- public function save(string $dest): self
407
+ /**
408
+ * Encodes the current image to $dest, the format taken from the file
409
+ * extension (jpg/jpeg, png, gif, webp, avif).
410
+ *
411
+ * $quality (0-100) applies to the lossy formats (jpg, webp, avif); it is
412
+ * ignored for png (whose second arg is a 0-9 compression level) and gif.
413
+ * When null, each format keeps its own default (82 for jpg/webp/avif).
414
+ */
415
+ public function save(string $dest, ?int $quality = null): self
401
416
  {
402
417
  $ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
403
418
  $dir = pathinfo($dest, PATHINFO_DIRNAME);
404
419
  if (!is_dir($dir) && !@mkdir($dir, 0777, true)) throw new Exception("Invalid destination.");
405
420
  $ok = match ($ext) {
406
- 'jpg', 'jpeg' => imagejpeg($this->im, $dest, 82),
421
+ 'jpg', 'jpeg' => imagejpeg($this->im, $dest, $quality ?? 82),
407
422
  'png' => imagepng($this->im, $dest, 6),
408
423
  'gif' => imagegif($this->im, $dest),
409
- 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 82) : false),
410
- 'avif' => (function_exists('imageavif') ? $this->encodeAvif($this->im, $dest, 82) : false),
424
+ 'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, $quality ?? 82) : false),
425
+ 'avif' => (function_exists('imageavif') ? $this->encodeAvif($this->im, $dest, $quality ?? 82) : false),
411
426
  default => throw new Exception("Invalid output file type.")
412
427
  };
413
428
  if (!$ok) throw new Exception("Failed to encode image as '{$ext}'.");