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    /** @param array<string, mixed> $node */
60    private function node(array $node): string
61    {
62        $type = $node['type'] ?? null;
63
64        if (!is_string($type)) {
65            return '';
66        }
67
68        return match ($type) {
69            'paragraph' => $this->tag(
70                'p',
71                $this->blockAttrs($node),
72                $this->children($node['content'] ?? null),
73            ),
74            'heading' => $this->heading($node),
75            'bulletList' => $this->tag('ul', [], $this->children($node['content'] ?? null)),
76            'orderedList' => $this->orderedList($node),
77            'listItem' => $this->tag('li', [], $this->children($node['content'] ?? null)),
78            'blockquote' => $this->tag('blockquote', [], $this->children($node['content'] ?? null)),
79            'codeBlock' => '<pre><code>' . $this->children($node['content'] ?? null) . '</code></pre>',
80            'horizontalRule' => $this->horizontalRule($node),
81            'hardBreak' => '<br>',
82            'text' => $this->text($node),
83            'image' => $this->image($node),
84            default => $this->skip("unknown node type '{$type}'"),
85        };
86    }
87
88    /** @param array<string, mixed> $node */
89    private function heading(array $node): string
90    {
91        $level = $node['attrs']['level'] ?? 1;
92        $level = is_int($level) && $level >= 1 && $level <= 6 ? $level : 1;
93
94        return $this->tag(
95            "h{$level}",
96            $this->alignAttrs($node),
97            $this->children($node['content'] ?? null),
98        );
99    }
100
101    /** @param array<string, mixed> $node */
102    private function orderedList(array $node): string
103    {
104        $start = $node['attrs']['start'] ?? 1;
105        $attrs = is_int($start) && $start !== 1 ? ['start' => (string) $start] : [];
106
107        return $this->tag('ol', $attrs, $this->children($node['content'] ?? null));
108    }
109
110    /** @param array<string, mixed> $node */
111    private function horizontalRule(array $node): string
112    {
113        $class = $node['attrs']['class'] ?? null;
114
115        return is_string($class) && $class !== ''
116            ? '<hr' . $this->attrString(['class' => $class]) . '>'
117            : '<hr>';
118    }
119
120    /**
121     * @param array<string, mixed> $node
122     * @return array<string, string>
123     */
124    private function blockAttrs(array $node): array
125    {
126        $attrs = [];
127        $class = $node['attrs']['class'] ?? null;
128
129        if (is_string($class) && $class !== '' && $class !== 'default') {
130            $attrs['class'] = $class;
131        }
132
133        return array_merge($attrs, $this->alignAttrs($node));
134    }
135
136    /**
137     * @param array<string, mixed> $node
138     * @return array<string, string>
139     */
140    private function alignAttrs(array $node): array
141    {
142        $align = $node['attrs']['align'] ?? null;
143
144        if (is_string($align) && in_array($align, Spec::ALIGNMENTS, true)) {
145            return ['style' => "text-align: {$align}"];
146        }
147
148        return [];
149    }
150
151    /** @param array<string, mixed> $node */
152    private function text(array $node): string
153    {
154        $text = $node['text'] ?? null;
155
156        if (!is_string($text) || $text === '') {
157            return '';
158        }
159
160        $html = escape($text);
161        $marks = [];
162
163        foreach (is_array($node['marks'] ?? null) ? $node['marks'] : [] as $mark) {
164            if (is_array($mark) && is_string($mark['type'] ?? null)) {
165                $marks[$mark['type']] = $mark;
166            }
167        }
168
169        foreach (array_reverse(Spec::MARK_ORDER) as $type) {
170            if (isset($marks[$type])) {
171                $html = $this->mark($marks[$type], $html);
172                unset($marks[$type]);
173            }
174        }
175
176        foreach (array_keys($marks) as $unknown) {
177            $this->skip("unknown mark type '{$unknown}'");
178        }
179
180        return $html;
181    }
182
183    /** @param array<string, mixed> $mark */
184    private function mark(array $mark, string $inner): string
185    {
186        return match ($mark['type']) {
187            'bold' => "<strong>{$inner}</strong>",
188            'italic' => "<em>{$inner}</em>",
189            'underline' => "<u>{$inner}</u>",
190            'strike' => "<s>{$inner}</s>",
191            'code' => "<code>{$inner}</code>",
192            'subscript' => "<sub>{$inner}</sub>",
193            'superscript' => "<sup>{$inner}</sup>",
194            'style' => $this->styleMark($mark, $inner),
195            'link' => $this->link($mark, $inner),
196            default => $inner,
197        };
198    }
199
200    /** @param array<string, mixed> $mark */
201    private function styleMark(array $mark, string $inner): string
202    {
203        $class = $mark['attrs']['class'] ?? null;
204
205        if (!is_string($class) || $class === '') {
206            return $inner;
207        }
208
209        return '<span' . $this->attrString(['class' => $class]) . ">{$inner}</span>";
210    }
211
212    /** @param array<string, mixed> $mark */
213    private function link(array $mark, string $inner): string
214    {
215        $attrs = is_array($mark['attrs'] ?? null) ? $mark['attrs'] : [];
216        $href = null;
217        $rel = null;
218
219        if (is_string($attrs['href'] ?? null) && $attrs['href'] !== '') {
220            $href = $attrs['href'];
221            $rel = $this->linkRel;
222        } elseif (is_string($attrs['node'] ?? null) && $attrs['node'] !== '') {
223            $href = $this->resolver->nodePath($attrs['node']);
224
225            if ($href === null) {
226                return $this->skip("unresolvable node link '{$attrs['node']}'") . $inner;
227            }
228        } elseif (is_string($attrs['asset'] ?? null) && $attrs['asset'] !== '') {
229            $asset = $this->resolver->asset($attrs['asset']);
230
231            if ($asset === null) {
232                return $this->skip("unresolvable asset link '{$attrs['asset']}'") . $inner;
233            }
234
235            $href = $asset->path();
236        } else {
237            return $inner;
238        }
239
240        $html = ['href' => $href];
241
242        if (is_string($attrs['target'] ?? null) && $attrs['target'] !== '') {
243            $html['target'] = $attrs['target'];
244        }
245
246        if (is_string($attrs['class'] ?? null) && $attrs['class'] !== '') {
247            $html['class'] = $attrs['class'];
248        }
249
250        if ($rel !== null) {
251            $html['rel'] = $rel;
252        }
253
254        return '<a' . $this->attrString($html) . ">{$inner}</a>";
255    }
256
257    /** @param array<string, mixed> $node */
258    private function image(array $node): string
259    {
260        $uid = $node['attrs']['uid'] ?? null;
261
262        if (!is_string($uid) || $uid === '') {
263            return '';
264        }
265
266        $asset = $this->resolver->asset($uid);
267
268        if ($asset === null) {
269            return $this->skip("unresolvable image '{$uid}'");
270        }
271
272        $src = $asset->resizable() ? $asset->sizePath($this->imageSize) : $asset->path();
273        $meta = is_array($node['attrs']['meta'] ?? null) ? $node['attrs']['meta'] : [];
274        $alt = $meta['alt'] ?? null;
275        $title = $meta['title'] ?? null;
276
277        if (!is_string($alt) || $alt === '') {
278            $catalog = $asset->metaMap('alt');
279            $alt = $catalog === null ? null : $this->resolver->localize($catalog);
280        }
281
282        $attrs = [
283            'src' => $src,
284            'alt' => is_string($alt) ? $alt : '',
285        ];
286
287        if (is_string($title) && $title !== '') {
288            $attrs['title'] = $title;
289        }
290
291        return '<img' . $this->attrString($attrs) . '>';
292    }
293
294    /** @param array<string, string> $attrs */
295    private function tag(string $name, array $attrs, string $inner): string
296    {
297        return "<{$name}" . $this->attrString($attrs) . ">{$inner}</{$name}>";
298    }
299
300    /** @param array<string, string> $attrs */
301    private function attrString(array $attrs): string
302    {
303        $html = '';
304
305        foreach ($attrs as $key => $value) {
306            $html .= ' ' . $key . '="' . escape($value) . '"';
307        }
308
309        return $html;
310    }
311
312    private function skip(string $notice): string
313    {
314        $this->notices[] = $notice;
315
316        return '';
317    }
318}