-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathMarkdownEncoder.php
More file actions
322 lines (289 loc) · 10.8 KB
/
Copy pathMarkdownEncoder.php
File metadata and controls
322 lines (289 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
<?php
/*
* part of the katmore/micro-encode project
*
* Copyright (c) 2012-2026 Doug Bird. All Rights Reserved.
*/
declare(strict_types=1);
namespace MicroEncode;
/**
* Generates readable Markdown from arbitrary data
*
* @author D. Bird <retran@gmail.com>
*/
class MarkdownEncoder implements EncoderInterface
{
private const EMPTY_LIST_LABEL = '_(empty list)_';
private const EMPTY_MAP_LABEL = '_(empty)_';
/**
* @var string
*/
private readonly string $encodedValue;
/**
* @return string Markdown serialized data
*/
public function __toString(): string
{
return $this->encodedValue;
}
/**
* @return string Markdown serialized data
*/
public function getEncodedValue(): string
{
return $this->encodedValue;
}
/**
* @param mixed $data data to serialize to Markdown
* @param MarkdownEncoderOptions $options encoding options
*/
public function __construct(mixed $data, MarkdownEncoderOptions $options = new MarkdownEncoderOptions())
{
$this->encodedValue = static::dataToMarkdown($data, $options->orderedLists);
}
protected static function dataToMarkdown(mixed $data, bool $orderedLists): string
{
$out = '';
static::renderData($out, $data, $orderedLists, 0);
return $out;
}
/**
* Renders $data by appending to $out, which is passed by reference all the
* way down.
*
* Every recursion level appends its own output exactly once to the single
* shared $out, instead of returning a finished string for its caller to
* re-concatenate (which copied a subtree's bytes once per ancestor level,
* i.e. O(depth^2) total). Indentation is carried down as the running
* $indent column rather than applied afterwards to already-rendered text.
*
* Contract: this is always called at the start of a fresh output line, and
* is responsible for writing the indentation of every line it emits -
* including its first - except for lines that are empty, which stay empty
* (matching what the previous post-hoc line-indenting did).
*/
protected static function renderData(string &$out, mixed $data, bool $orderedLists, int $indent): void
{
if (is_array($data)) {
if ($data === []) {
static::appendBlock($out, self::EMPTY_LIST_LABEL, $indent);
return;
}
if (array_is_list($data)) {
static::renderList($out, $data, $orderedLists, $indent);
} else {
static::renderMap($out, $data, $orderedLists, $indent);
}
return;
}
if (is_object($data)) {
$pairs = static::objectToPairs($data);
if ($pairs === []) {
static::appendBlock($out, self::EMPTY_MAP_LABEL, $indent);
return;
}
static::renderMap($out, $pairs, $orderedLists, $indent);
return;
}
if (is_string($data) && (str_contains($data, "\n") || str_contains($data, "\r"))) {
static::appendBlock($out, static::fencedCodeBlock($data), $indent);
return;
}
static::appendBlock($out, static::renderScalar($data), $indent);
}
/**
* Appends an already-rendered leaf block, indenting each of its non-empty
* lines to $indent. Leaf blocks are terminal (a fenced code block or a
* single scalar line), so this scans each one exactly once, at the level
* where it occurs.
*/
protected static function appendBlock(string &$out, string $block, int $indent): void
{
if ($indent < 1) {
$out .= $block;
return;
}
$prefix = str_repeat(' ', $indent);
if (!str_contains($block, "\n")) {
if ($block !== '') {
$out .= $prefix.$block;
}
return;
}
$first = true;
foreach (explode("\n", $block) as $line) {
if (!$first) {
$out .= "\n";
}
$first = false;
if ($line !== '') {
$out .= $prefix.$line;
}
}
}
/**
* @return array<int|string, mixed>
*/
protected static function objectToPairs(object $data): array
{
$pairs = [];
foreach ($data as $key => $value) {
$pairs[$key] = $value;
}
return $pairs;
}
/**
* @param list<mixed> $items
*/
protected static function renderList(string &$out, array $items, bool $orderedLists, int $indent): void
{
// A "-" marker only ever works when it's immediately followed by inline
// content. The moment an item needs block layout (a nested array,
// object, or multiline string), its marker line has to be left bare,
// and CommonMark cannot reliably tell a bare "-" item's own nested
// content apart from a new sibling item using that same character - it
// can even fold a preceding label into a heading. Ordered markers don't
// have this problem: "1.", "2." are self-disambiguating. So a list
// containing any non-scalar element always renders as ordered,
// regardless of $orderedLists, as a structural necessity rather than
// a style choice. Mixing marker characters within one list isn't an
// option either way - CommonMark would read that as two separate lists.
$ordered = $orderedLists || static::containsBlockElement($items);
$index = 1;
foreach ($items as $value) {
if ($index > 1) {
$out .= "\n";
}
$marker = $ordered ? $index.'.' : '-';
static::renderItem($out, $marker, null, $value, $orderedLists, $indent);
$index++;
}
}
/**
* @param list<mixed> $items
*/
protected static function containsBlockElement(array $items): bool
{
foreach ($items as $value) {
if (static::isBlock($value)) {
return true;
}
}
return false;
}
/**
* @param array<int|string, mixed> $pairs
*/
protected static function renderMap(string &$out, array $pairs, bool $orderedLists, int $indent): void
{
$first = true;
foreach ($pairs as $key => $value) {
if (!$first) {
$out .= "\n";
}
$first = false;
$label = '**'.static::escapeMarkdown((string) $key).':**';
static::renderItem($out, '-', $label, $value, $orderedLists, $indent);
}
}
protected static function renderItem(
string &$out,
string $marker,
?string $label,
mixed $value,
bool $orderedLists,
int $indent
): void {
if ($indent > 0) {
$out .= str_repeat(' ', $indent);
}
$out .= $label === null ? $marker : "$marker $label";
if (static::isBlock($value)) {
// A label is rendered as an open paragraph. If the nested block's own
// first line is itself a bare, content-less list marker (a list of
// containers), CommonMark cannot let it interrupt that paragraph and
// the marker gets swallowed as literal text instead of starting a
// list. A blank line forces it to be recognized as a new block.
// Bare markers never need this: they don't open a paragraph, so a
// nested bare marker beneath them is already recognized correctly
// - and inserting a blank line there would instead disconnect it.
//
// The separator has to be appended before the block it precedes, so
// this is now decided before the block is rendered rather than after
// (it inspects only $value's own first element, never the rendered
// text, so the decision itself is unchanged).
$out .= ($label !== null && static::startsWithBareMarker($value)) ? "\n\n" : "\n";
static::renderData($out, $value, $orderedLists, $indent + strlen($marker) + 1);
return;
}
$out .= ' '.static::renderInlineValue($value);
}
protected static function isBlock(mixed $value): bool
{
if (is_array($value)) {
return $value !== [];
}
if (is_object($value)) {
return static::objectToPairs($value) !== [];
}
return is_string($value) && (str_contains($value, "\n") || str_contains($value, "\r"));
}
protected static function startsWithBareMarker(mixed $value): bool
{
if (!is_array($value) || $value === [] || !array_is_list($value)) {
return false;
}
return static::isBlock($value[array_key_first($value)]);
}
protected static function renderInlineValue(mixed $value): string
{
if (is_array($value)) {
return self::EMPTY_LIST_LABEL;
}
if (is_object($value)) {
return self::EMPTY_MAP_LABEL;
}
return static::renderScalar($value);
}
protected static function renderScalar(mixed $value): string
{
return match (true) {
$value === null => 'null',
is_bool($value) => $value ? 'true' : 'false',
is_int($value), is_float($value) => (string) $value,
default => static::escapeMarkdown((string) $value),
};
}
protected static function fencedCodeBlock(string $value): string
{
$fenceLength = 3;
if (preg_match_all('/`+/', $value, $matches)) {
foreach ($matches[0] as $run) {
$fenceLength = max($fenceLength, strlen($run) + 1);
}
}
$fence = str_repeat('`', $fenceLength);
return "$fence\n$value\n$fence";
}
protected static function escapeMarkdown(string $value): string
{
if ($value === '') {
return '""';
}
$escaped = str_replace('\\', '\\\\', $value);
// *_`[] are escaped because they're CommonMark inline-markup syntax.
// <, >, and & are escaped for a different reason: CommonMark permits
// raw inline HTML by default, and many renderers pass it straight
// through unless explicitly configured not to (e.g. Parsedown, or
// marked without a sanitizer) - so an unescaped value containing
// something like "<script>...</script>" would render as live HTML in
// whatever eventually consumes this output. Backslash-escaping these
// is valid per the CommonMark spec (any ASCII punctuation character
// may be backslash-escaped) and renders as literal text instead.
$escaped = preg_replace('/([*_`\[\]<>&])/', '\\\\$1', $escaped);
if ($escaped[0] === '-' || $escaped[0] === '#') {
$escaped = '\\'.$escaped;
}
return $escaped;
}
}