Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.89% covered (warning)
86.89%
159 / 183
52.63% covered (warning)
52.63%
10 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Validator
86.89% covered (warning)
86.89%
159 / 183
52.63% covered (warning)
52.63%
10 / 19
157.13
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
 validate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 node
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
10.10
 text
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 marks
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
14.57
 link
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
11.97
 style
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 attrs
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
9.24
 paragraphAttrs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 headingAttrs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 orderedListAttrs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 horizontalRuleAttrs
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 imageAttrs
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
10.89
 classAttr
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 alignAttr
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 content
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
19.92
 allowedIn
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 keys
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 attrKeys
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Richtext;
6
7/**
8 * Writer-strict validation of richtext documents: saves and
9 * migrations must produce only spec vocabulary. Readers stay
10 * tolerant — this class is never used on the render path.
11 */
12final class Validator
13{
14    /** @var list<string> */
15    private array $errors = [];
16
17    /**
18     * @param array<string, string> $classes Declared paragraph classes (`richtext.classes`)
19     * @param array<string, string> $styles Declared text styles (`richtext.styles`)
20     */
21    public function __construct(
22        private readonly array $classes = [],
23        private readonly array $styles = [],
24    ) {}
25
26    /** @return list<string> Errors; empty when the document is valid. */
27    public function validate(mixed $doc): array
28    {
29        $this->errors = [];
30
31        if (!is_array($doc)) {
32            return ['doc: not a node object'];
33        }
34
35        if (($doc['type'] ?? null) !== 'doc') {
36            return ["doc: root node must have type 'doc'"];
37        }
38
39        $this->keys($doc, ['type', 'content'], 'doc');
40        $this->content($doc, 'doc', 'doc');
41
42        return $this->errors;
43    }
44
45    private function node(mixed $node, string $path): void
46    {
47        if (!is_array($node)) {
48            $this->errors[] = "{$path}: not a node object";
49
50            return;
51        }
52
53        $type = $node['type'] ?? null;
54
55        if (!is_string($type) || !Spec::isNode($type) || $type === 'doc') {
56            $printable = is_string($type) ? $type : gettype($type);
57            $this->errors[] = "{$path}: unknown node type '{$printable}'";
58
59            return;
60        }
61
62        if ($type === 'text') {
63            $this->text($node, $path);
64
65            return;
66        }
67
68        $allowed = ['type'];
69
70        if (Spec::nodeDefaults($type) !== []) {
71            $allowed[] = 'attrs';
72        }
73
74        if (!Spec::isLeaf($type)) {
75            $allowed[] = 'content';
76        }
77
78        $this->keys($node, $allowed, $path);
79        $this->attrs($node, $type, $path);
80
81        if (!Spec::isLeaf($type)) {
82            $this->content($node, $type, $path);
83        }
84    }
85
86    /** @param array<mixed> $node */
87    private function text(array $node, string $path): void
88    {
89        $this->keys($node, ['type', 'text', 'marks'], $path);
90
91        if (!is_string($node['text'] ?? null) || $node['text'] === '') {
92            $this->errors[] = "{$path}: text node needs a non-empty string 'text'";
93        }
94
95        if (!array_key_exists('marks', $node)) {
96            return;
97        }
98
99        $this->marks($node['marks'], $path);
100    }
101
102    private function marks(mixed $marks, string $path): void
103    {
104        if (!is_array($marks) || !array_is_list($marks)) {
105            $this->errors[] = "{$path}: marks must be a list";
106
107            return;
108        }
109
110        $seen = [];
111
112        foreach ($marks as $i => $mark) {
113            $markPath = "{$path}.marks.{$i}";
114
115            if (!is_array($mark)) {
116                $this->errors[] = "{$markPath}: not a mark object";
117
118                continue;
119            }
120
121            $type = $mark['type'] ?? null;
122
123            if (!is_string($type) || !Spec::isMark($type)) {
124                $printable = is_string($type) ? $type : gettype($type);
125                $this->errors[] = "{$markPath}: unknown mark type '{$printable}'";
126
127                continue;
128            }
129
130            if (isset($seen[$type])) {
131                $this->errors[] = "{$markPath}: duplicate mark '{$type}'";
132            }
133
134            $seen[$type] = true;
135            $this->keys(
136                $mark,
137                $type === 'link' || $type === 'style' ? ['type', 'attrs'] : ['type'],
138                $markPath,
139            );
140
141            if ($type === 'link') {
142                $this->link($mark['attrs'] ?? null, $markPath);
143            } elseif ($type === 'style') {
144                $this->style($mark['attrs'] ?? null, $markPath);
145            }
146        }
147
148        if (isset($seen['subscript'], $seen['superscript'])) {
149            $this->errors[] = "{$path}: subscript and superscript exclude each other";
150        }
151    }
152
153    private function link(mixed $attrs, string $path): void
154    {
155        if (!is_array($attrs)) {
156            $this->errors[] = "{$path}: link needs attrs";
157
158            return;
159        }
160
161        $this->attrKeys($attrs, ['href', 'node', 'asset', 'target', 'class'], $path);
162        $targets = array_filter(
163            [
164                $attrs['href'] ?? null,
165                $attrs['node'] ?? null,
166                $attrs['asset'] ?? null,
167            ],
168            static fn(mixed $value) => $value !== null,
169        );
170
171        if (count($targets) !== 1) {
172            $this->errors[] = "{$path}: link needs exactly one of href/node/asset";
173        }
174
175        foreach (['href', 'node', 'asset'] as $key) {
176            if (array_key_exists($key, $attrs) && (!is_string($attrs[$key]) || $attrs[$key] === '')) {
177                $this->errors[] = "{$path}: link {$key} must be a non-empty string";
178            }
179        }
180
181        foreach (['target', 'class'] as $key) {
182            if (array_key_exists($key, $attrs) && $attrs[$key] !== null && !is_string($attrs[$key])) {
183                $this->errors[] = "{$path}: link {$key} must be a string or null";
184            }
185        }
186    }
187
188    private function style(mixed $attrs, string $path): void
189    {
190        $class = is_array($attrs) ? $attrs['class'] ?? null : null;
191
192        if (!is_string($class) || $class === '') {
193            $this->errors[] = "{$path}: style needs a class";
194
195            return;
196        }
197
198        $this->attrKeys($attrs, ['class'], $path);
199
200        if (!isset($this->styles[$class])) {
201            $this->errors[] = "{$path}: undeclared text style '{$class}'";
202        }
203    }
204
205    /** @param array<mixed> $node */
206    private function attrs(array $node, string $type, string $path): void
207    {
208        $attrs = $node['attrs'] ?? [];
209
210        if (!is_array($attrs)) {
211            $this->errors[] = "{$path}: attrs must be an object";
212
213            return;
214        }
215
216        match ($type) {
217            'paragraph' => $this->paragraphAttrs($attrs, $path),
218            'heading' => $this->headingAttrs($attrs, $path),
219            'orderedList' => $this->orderedListAttrs($attrs, $path),
220            'horizontalRule' => $this->horizontalRuleAttrs($attrs, $path),
221            'image' => $this->imageAttrs($attrs, $path),
222            default => $attrs === []
223                ? null
224                : ($this->errors[] = "{$path}: node '{$type}' allows no attrs"),
225        };
226    }
227
228    /** @param array<mixed> $attrs */
229    private function paragraphAttrs(array $attrs, string $path): void
230    {
231        $this->attrKeys($attrs, ['class', 'align'], $path);
232        $this->classAttr($attrs, $path);
233        $this->alignAttr($attrs, $path);
234    }
235
236    /** @param array<mixed> $attrs */
237    private function headingAttrs(array $attrs, string $path): void
238    {
239        $this->attrKeys($attrs, ['level', 'align'], $path);
240        $level = $attrs['level'] ?? null;
241
242        if (!is_int($level) || $level < 1 || $level > 6) {
243            $this->errors[] = "{$path}: heading level must be an int between 1 and 6";
244        }
245
246        $this->alignAttr($attrs, $path);
247    }
248
249    /** @param array<mixed> $attrs */
250    private function orderedListAttrs(array $attrs, string $path): void
251    {
252        $this->attrKeys($attrs, ['start'], $path);
253
254        if (array_key_exists('start', $attrs) && (!is_int($attrs['start']) || $attrs['start'] < 1)) {
255            $this->errors[] = "{$path}: orderedList start must be an int >= 1";
256        }
257    }
258
259    /** @param array<mixed> $attrs */
260    private function horizontalRuleAttrs(array $attrs, string $path): void
261    {
262        $this->attrKeys($attrs, ['class'], $path);
263        $class = $attrs['class'] ?? null;
264
265        if ($class !== null && (!is_string($class) || $class === '')) {
266            $this->errors[] = "{$path}: horizontalRule class must be a non-empty string or null";
267        }
268    }
269
270    /** @param array<mixed> $attrs */
271    private function imageAttrs(array $attrs, string $path): void
272    {
273        $this->attrKeys($attrs, ['uid', 'meta'], $path);
274
275        if (!is_string($attrs['uid'] ?? null) || $attrs['uid'] === '') {
276            $this->errors[] = "{$path}: image needs a non-empty uid";
277        }
278
279        $meta = $attrs['meta'] ?? null;
280
281        if ($meta === null) {
282            return;
283        }
284
285        if (!is_array($meta)) {
286            $this->errors[] = "{$path}: image meta must be an object or null";
287
288            return;
289        }
290
291        foreach ($meta as $key => $value) {
292            if (!in_array($key, ['alt', 'title'], true)) {
293                $this->errors[] = "{$path}: unknown image meta key '{$key}'";
294            } elseif ($value !== null && !is_string($value)) {
295                $this->errors[] = "{$path}: image meta {$key} must be a string or null";
296            }
297        }
298    }
299
300    /** @param array<mixed> $attrs */
301    private function classAttr(array $attrs, string $path): void
302    {
303        if (!array_key_exists('class', $attrs)) {
304            return;
305        }
306
307        $class = $attrs['class'];
308
309        if (!is_string($class) || $class === '') {
310            $this->errors[] = "{$path}: class must be a non-empty string";
311
312            return;
313        }
314
315        if ($class !== 'default' && !isset($this->classes[$class])) {
316            $this->errors[] = "{$path}: undeclared paragraph class '{$class}'";
317        }
318    }
319
320    /** @param array<mixed> $attrs */
321    private function alignAttr(array $attrs, string $path): void
322    {
323        if (!array_key_exists('align', $attrs)) {
324            return;
325        }
326
327        $align = $attrs['align'];
328
329        if ($align !== null && !in_array($align, Spec::ALIGNMENTS, true)) {
330            $printable = is_string($align) ? $align : gettype($align);
331            $this->errors[] = "{$path}: invalid align '{$printable}'";
332        }
333    }
334
335    /** @param array<mixed> $node */
336    private function content(array $node, string $type, string $path): void
337    {
338        $model = Spec::CONTENT[$type];
339        $content = $node['content'] ?? [];
340
341        if (!is_array($content) || !array_is_list($content)) {
342            $this->errors[] = "{$path}: content must be a list";
343
344            return;
345        }
346
347        if (str_ends_with($model, '+') && $content === []) {
348            $this->errors[] = "{$path}: '{$type}' must not be empty";
349        }
350
351        if ($model === 'paragraph block*' && $content !== []) {
352            $first = $content[0];
353
354            if (!is_array($first) || ($first['type'] ?? null) !== 'paragraph') {
355                $this->errors[] = "{$path}: listItem must start with a paragraph";
356            }
357        }
358
359        if ($model === 'paragraph block*' && $content === []) {
360            $this->errors[] = "{$path}: listItem must not be empty";
361        }
362
363        foreach ($content as $i => $child) {
364            $childPath = "{$path}.content.{$i}";
365            $childType = is_array($child) && is_string($child['type'] ?? null) ? $child['type'] : null;
366
367            if ($childType !== null && !$this->allowedIn($model, $childType, $i)) {
368                $this->errors[] = "{$childPath}: '{$childType}' not allowed in '{$type}'";
369            }
370
371            if ($type === 'codeBlock' && is_array($child) && array_key_exists('marks', $child)) {
372                $this->errors[] = "{$childPath}: codeBlock content allows no marks";
373                $child = array_diff_key($child, ['marks' => true]);
374            }
375
376            $this->node($child, $childPath);
377        }
378    }
379
380    private function allowedIn(string $model, string $childType, int $index): bool
381    {
382        return match ($model) {
383            'block+' => Spec::isBlock($childType),
384            'inline*' => Spec::isInline($childType),
385            'listItem+' => $childType === 'listItem',
386            'paragraph block*' => $index === 0 ? $childType === 'paragraph' : Spec::isBlock($childType),
387            'text*' => $childType === 'text',
388            default => false,
389        };
390    }
391
392    /**
393     * @param array<mixed> $data
394     * @param list<string> $allowed
395     */
396    private function keys(array $data, array $allowed, string $path): void
397    {
398        foreach (array_keys($data) as $key) {
399            if (!in_array($key, $allowed, true)) {
400                $this->errors[] = "{$path}: unknown key '{$key}'";
401            }
402        }
403    }
404
405    /**
406     * @param array<mixed> $attrs
407     * @param list<string> $allowed
408     */
409    private function attrKeys(array $attrs, array $allowed, string $path): void
410    {
411        foreach (array_keys($attrs) as $key) {
412            if (!in_array($key, $allowed, true)) {
413                $this->errors[] = "{$path}: unknown attr '{$key}'";
414            }
415        }
416    }
417}