Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.09% covered (success)
92.09%
128 / 139
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Renderer
92.09% covered (success)
92.09%
128 / 139
61.11% covered (warning)
61.11%
11 / 18
99.47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 notices
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 children
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 node
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
14.17
 heading
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 orderedList
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 horizontalRule
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 blockAttrs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 alignAttrs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 text
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 mark
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
12.89
 styleMark
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 link
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
15.13
 image
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 tag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 attrString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 skip
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Richtext;
6
7use function Cosray\escape;
8
9/**
10 * Renders a richtext document to HTML. Reader-tolerant: unknown node
11 * or mark types and dangling references degrade instead of crashing;
12 * `notices()` reports what was skipped.
13 */
14final class Renderer
15{
16    /** @var list<string> */
17    private array $notices = [];
18
19    public function __construct(
20        private readonly Resolver $resolver,
21        private readonly string $imageSize = 'block',
22        private readonly string $linkRel = 'noopener noreferrer nofollow',
23    ) {}
24
25    public function render(mixed $doc): string
26    {
27        $this->notices = [];
28
29        if (!is_array($doc) || ($doc['type'] ?? null) !== 'doc') {
30            return '';
31        }
32
33        return $this->children($doc['content'] ?? null);
34    }
35
36    /** @return list<string> What the last render skipped or could not resolve. */
37    public function notices(): array
38    {
39        return $this->notices;
40    }
41
42    private function children(mixed $content): string
43    {
44        if (!is_array($content)) {
45            return '';
46        }
47
48        $html = '';
49
50        foreach ($content as $child) {
51            if (is_array($child)) {
52                $html .= $this->node($child);
53            }
54        }
55
56        return $html;
57    }
58
59    private function node(array $node): string
60    {
61        $type = $node['type'] ?? null;
62
63        if (!is_string($type)) {
64            return '';
65        }
66
67        return match ($type) {
68            'paragraph' => $this->tag(
69                'p',
70                $this->blockAttrs($node),
71                $this->children($node['content'] ?? null),
72            ),
73            'heading' => $this->heading($node),
74            'bulletList' => $this->tag('ul', [], $this->children($node['content'] ?? null)),
75            'orderedList' => $this->orderedList($node),
76            'listItem' => $this->tag('li', [], $this->children($node['content'] ?? null)),
77            'blockquote' => $this->tag('blockquote', [], $this->children($node['content'] ?? null)),
78            'codeBlock' => '<pre><code>' . $this->children($node['content'] ?? null) . '</code></pre>',
79            'horizontalRule' => $this->horizontalRule($node),
80            'hardBreak' => '<br>',
81            'text' => $this->text($node),
82            'image' => $this->image($node),
83            default => $this->skip("unknown node type '{$type}'"),
84        };
85    }
86
87    private function heading(array $node): string
88    {
89        $level = $node['attrs']['level'] ?? 1;
90        $level = is_int($level) && $level >= 1 && $level <= 6 ? $level : 1;
91
92        return $this->tag(
93            "h{$level}",
94            $this->alignAttrs($node),
95            $this->children($node['content'] ?? null),
96        );
97    }
98
99    private function orderedList(array $node): string
100    {
101        $start = $node['attrs']['start'] ?? 1;
102        $attrs = is_int($start) && $start !== 1 ? ['start' => (string) $start] : [];
103
104        return $this->tag('ol', $attrs, $this->children($node['content'] ?? null));
105    }
106
107    private function horizontalRule(array $node): string
108    {
109        $class = $node['attrs']['class'] ?? null;
110
111        return is_string($class) && $class !== ''
112            ? '<hr' . $this->attrString(['class' => $class]) . '>'
113            : '<hr>';
114    }
115
116    /** @return array<string, string> */
117    private function blockAttrs(array $node): array
118    {
119        $attrs = [];
120        $class = $node['attrs']['class'] ?? null;
121
122        if (is_string($class) && $class !== '' && $class !== 'default') {
123            $attrs['class'] = $class;
124        }
125
126        return array_merge($attrs, $this->alignAttrs($node));
127    }
128
129    /** @return array<string, string> */
130    private function alignAttrs(array $node): array
131    {
132        $align = $node['attrs']['align'] ?? null;
133
134        if (is_string($align) && in_array($align, Spec::ALIGNMENTS, true)) {
135            return ['style' => "text-align: {$align}"];
136        }
137
138        return [];
139    }
140
141    private function text(array $node): string
142    {
143        $text = $node['text'] ?? null;
144
145        if (!is_string($text) || $text === '') {
146            return '';
147        }
148
149        $html = escape($text);
150        $marks = [];
151
152        foreach (is_array($node['marks'] ?? null) ? $node['marks'] : [] as $mark) {
153            if (is_array($mark) && is_string($mark['type'] ?? null)) {
154                $marks[$mark['type']] = $mark;
155            }
156        }
157
158        foreach (array_reverse(Spec::MARK_ORDER) as $type) {
159            if (isset($marks[$type])) {
160                $html = $this->mark($marks[$type], $html);
161                unset($marks[$type]);
162            }
163        }
164
165        foreach (array_keys($marks) as $unknown) {
166            $this->skip("unknown mark type '{$unknown}'");
167        }
168
169        return $html;
170    }
171
172    private function mark(array $mark, string $inner): string
173    {
174        return match ($mark['type']) {
175            'bold' => "<strong>{$inner}</strong>",
176            'italic' => "<em>{$inner}</em>",
177            'underline' => "<u>{$inner}</u>",
178            'strike' => "<s>{$inner}</s>",
179            'code' => "<code>{$inner}</code>",
180            'subscript' => "<sub>{$inner}</sub>",
181            'superscript' => "<sup>{$inner}</sup>",
182            'style' => $this->styleMark($mark, $inner),
183            'link' => $this->link($mark, $inner),
184            default => $inner,
185        };
186    }
187
188    private function styleMark(array $mark, string $inner): string
189    {
190        $class = $mark['attrs']['class'] ?? null;
191
192        if (!is_string($class) || $class === '') {
193            return $inner;
194        }
195
196        return '<span' . $this->attrString(['class' => $class]) . ">{$inner}</span>";
197    }
198
199    private function link(array $mark, string $inner): string
200    {
201        $attrs = is_array($mark['attrs'] ?? null) ? $mark['attrs'] : [];
202        $href = null;
203        $rel = null;
204
205        if (is_string($attrs['href'] ?? null) && $attrs['href'] !== '') {
206            $href = $attrs['href'];
207            $rel = $this->linkRel;
208        } elseif (is_string($attrs['node'] ?? null) && $attrs['node'] !== '') {
209            $href = $this->resolver->nodePath($attrs['node']);
210
211            if ($href === null) {
212                return $this->skip("unresolvable node link '{$attrs['node']}'") . $inner;
213            }
214        } elseif (is_string($attrs['asset'] ?? null) && $attrs['asset'] !== '') {
215            $asset = $this->resolver->asset($attrs['asset']);
216
217            if ($asset === null) {
218                return $this->skip("unresolvable asset link '{$attrs['asset']}'") . $inner;
219            }
220
221            $href = $asset->path();
222        } else {
223            return $inner;
224        }
225
226        $html = ['href' => $href];
227
228        if (is_string($attrs['target'] ?? null) && $attrs['target'] !== '') {
229            $html['target'] = $attrs['target'];
230        }
231
232        if (is_string($attrs['class'] ?? null) && $attrs['class'] !== '') {
233            $html['class'] = $attrs['class'];
234        }
235
236        if ($rel !== null) {
237            $html['rel'] = $rel;
238        }
239
240        return '<a' . $this->attrString($html) . ">{$inner}</a>";
241    }
242
243    private function image(array $node): string
244    {
245        $uid = $node['attrs']['uid'] ?? null;
246
247        if (!is_string($uid) || $uid === '') {
248            return '';
249        }
250
251        $asset = $this->resolver->asset($uid);
252
253        if ($asset === null) {
254            return $this->skip("unresolvable image '{$uid}'");
255        }
256
257        $src = $asset->resizable() ? $asset->sizePath($this->imageSize) : $asset->path();
258        $meta = is_array($node['attrs']['meta'] ?? null) ? $node['attrs']['meta'] : [];
259        $alt = $meta['alt'] ?? null;
260        $title = $meta['title'] ?? null;
261
262        if (!is_string($alt) || $alt === '') {
263            $catalog = $asset->metaMap('alt');
264            $alt = $catalog === null ? null : $this->resolver->localize($catalog);
265        }
266
267        $attrs = [
268            'src' => $src,
269            'alt' => is_string($alt) ? $alt : '',
270        ];
271
272        if (is_string($title) && $title !== '') {
273            $attrs['title'] = $title;
274        }
275
276        return '<img' . $this->attrString($attrs) . '>';
277    }
278
279    /** @param array<string, string> $attrs */
280    private function tag(string $name, array $attrs, string $inner): string
281    {
282        return "<{$name}" . $this->attrString($attrs) . ">{$inner}</{$name}>";
283    }
284
285    /** @param array<string, string> $attrs */
286    private function attrString(array $attrs): string
287    {
288        $html = '';
289
290        foreach ($attrs as $key => $value) {
291            $html .= ' ' . $key . '="' . escape($value) . '"';
292        }
293
294        return $html;
295    }
296
297    private function skip(string $notice): string
298    {
299        $this->notices[] = $notice;
300
301        return '';
302    }
303}