@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.
- package/README.md +1593 -869
- package/index.d.ts +145 -2
- package/index.js +1 -1
- package/package.json +4 -4
- package/src/imagebatch.php +67 -0
- package/src/libraries/aliases.inc.php +836 -0
- package/src/libraries/curl.class.php +20 -3
- package/src/libraries/fs.class.php +161 -6
- package/src/libraries/html.class.php +92 -22
- package/src/libraries/img.class.php +372 -17
- package/src/libraries/ld.class.php +804 -0
- package/src/libraries/md.class.php +502 -173
- package/src/libraries/md.plugins.php +25 -19
- package/src/libraries/normalizer.class.php +310 -0
- package/src/libraries/prepros.class.php +149 -10
- package/src/libraries/prepros.plugins.php +56 -6
- package/src/libraries/schema.class.php +550 -0
- package/src/libraries/scraper.class.php +64 -59
- package/src/libraries/std.class.php +3 -0
- package/src/libraries/str.class.php +50 -16
- package/src/libraries/yaml.class.php +66 -66
- package/src/phpjs/fstat.js +9 -0
- package/src/prepros.js +126 -47
- package/src/prepros.php +9 -2
- package/src/runenv.php +5 -2
- package/src/utils/getfilestats.js +30 -0
- package/src/utils/isbinary.js +14 -14
- package/src/utils.inc.php +20 -9
|
@@ -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/
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 `/** … */` 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 —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// (
|
|
109
|
-
//
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
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
|
-
//
|
|
171
|
-
//
|
|
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
|
-
//
|
|
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);
|