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    private function text(array $node, string $path): void
87    {
88        $this->keys($node, ['type', 'text', 'marks'], $path);
89
90        if (!is_string($node['text'] ?? null) || $node['text'] === '') {
91            $this->errors[] = "{$path}: text node needs a non-empty string 'text'";
92        }
93
94        if (!array_key_exists('marks', $node)) {
95            return;
96        }
97
98        $this->marks($node['marks'], $path);
99    }
100
101    private function marks(mixed $marks, string $path): void
102    {
103        if (!is_array($marks) || !array_is_list($marks)) {
104            $this->errors[] = "{$path}: marks must be a list";
105
106            return;
107        }
108
109        $seen = [];
110
111        foreach ($marks as $i => $mark) {
112            $markPath = "{$path}.marks.{$i}";
113
114            if (!is_array($mark)) {
115                $this->errors[] = "{$markPath}: not a mark object";
116
117                continue;
118            }
119
120            $type = $mark['type'] ?? null;
121
122            if (!is_string($type) || !Spec::isMark($type)) {
123                $printable = is_string($type) ? $type : gettype($type);
124                $this->errors[] = "{$markPath}: unknown mark type '{$printable}'";
125
126                continue;
127            }
128
129            if (isset($seen[$type])) {
130                $this->errors[] = "{$markPath}: duplicate mark '{$type}'";
131            }
132
133            $seen[$type] = true;
134            $this->keys(
135                $mark,
136                $type === 'link' || $type === 'style' ? ['type', 'attrs'] : ['type'],
137                $markPath,
138            );
139
140            if ($type === 'link') {
141                $this->link($mark['attrs'] ?? null, $markPath);
142            } elseif ($type === 'style') {
143                $this->style($mark['attrs'] ?? null, $markPath);
144            }
145        }
146
147        if (isset($seen['subscript'], $seen['superscript'])) {
148            $this->errors[] = "{$path}: subscript and superscript exclude each other";
149        }
150    }
151
152    private function link(mixed $attrs, string $path): void
153    {
154        if (!is_array($attrs)) {
155            $this->errors[] = "{$path}: link needs attrs";
156
157            return;
158        }
159
160        $this->attrKeys($attrs, ['href', 'node', 'asset', 'target', 'class'], $path);
161        $targets = array_filter(
162            [
163                $attrs['href'] ?? null,
164                $attrs['node'] ?? null,
165                $attrs['asset'] ?? null,
166            ],
167            static fn(mixed $value) => $value !== null,
168        );
169
170        if (count($targets) !== 1) {
171            $this->errors[] = "{$path}: link needs exactly one of href/node/asset";
172        }
173
174        foreach (['href', 'node', 'asset'] as $key) {
175            if (array_key_exists($key, $attrs) && (!is_string($attrs[$key]) || $attrs[$key] === '')) {
176                $this->errors[] = "{$path}: link {$key} must be a non-empty string";
177            }
178        }
179
180        foreach (['target', 'class'] as $key) {
181            if (array_key_exists($key, $attrs) && $attrs[$key] !== null && !is_string($attrs[$key])) {
182                $this->errors[] = "{$path}: link {$key} must be a string or null";
183            }
184        }
185    }
186
187    private function style(mixed $attrs, string $path): void
188    {
189        $class = is_array($attrs) ? $attrs['class'] ?? null : null;
190
191        if (!is_string($class) || $class === '') {
192            $this->errors[] = "{$path}: style needs a class";
193
194            return;
195        }
196
197        $this->attrKeys($attrs, ['class'], $path);
198
199        if (!isset($this->styles[$class])) {
200            $this->errors[] = "{$path}: undeclared text style '{$class}'";
201        }
202    }
203
204    private function attrs(array $node, string $type, string $path): void
205    {
206        $attrs = $node['attrs'] ?? [];
207
208        if (!is_array($attrs)) {
209            $this->errors[] = "{$path}: attrs must be an object";
210
211            return;
212        }
213
214        match ($type) {
215            'paragraph' => $this->paragraphAttrs($attrs, $path),
216            'heading' => $this->headingAttrs($attrs, $path),
217            'orderedList' => $this->orderedListAttrs($attrs, $path),
218            'horizontalRule' => $this->horizontalRuleAttrs($attrs, $path),
219            'image' => $this->imageAttrs($attrs, $path),
220            default => $attrs === []
221                ? null
222                : ($this->errors[] = "{$path}: node '{$type}' allows no attrs"),
223        };
224    }
225
226    private function paragraphAttrs(array $attrs, string $path): void
227    {
228        $this->attrKeys($attrs, ['class', 'align'], $path);
229        $this->classAttr($attrs, $path);
230        $this->alignAttr($attrs, $path);
231    }
232
233    private function headingAttrs(array $attrs, string $path): void
234    {
235        $this->attrKeys($attrs, ['level', 'align'], $path);
236        $level = $attrs['level'] ?? null;
237
238        if (!is_int($level) || $level < 1 || $level > 6) {
239            $this->errors[] = "{$path}: heading level must be an int between 1 and 6";
240        }
241
242        $this->alignAttr($attrs, $path);
243    }
244
245    private function orderedListAttrs(array $attrs, string $path): void
246    {
247        $this->attrKeys($attrs, ['start'], $path);
248
249        if (array_key_exists('start', $attrs) && (!is_int($attrs['start']) || $attrs['start'] < 1)) {
250            $this->errors[] = "{$path}: orderedList start must be an int >= 1";
251        }
252    }
253
254    private function horizontalRuleAttrs(array $attrs, string $path): void
255    {
256        $this->attrKeys($attrs, ['class'], $path);
257        $class = $attrs['class'] ?? null;
258
259        if ($class !== null && (!is_string($class) || $class === '')) {
260            $this->errors[] = "{$path}: horizontalRule class must be a non-empty string or null";
261        }
262    }
263
264    private function imageAttrs(array $attrs, string $path): void
265    {
266        $this->attrKeys($attrs, ['uid', 'meta'], $path);
267
268        if (!is_string($attrs['uid'] ?? null) || $attrs['uid'] === '') {
269            $this->errors[] = "{$path}: image needs a non-empty uid";
270        }
271
272        $meta = $attrs['meta'] ?? null;
273
274        if ($meta === null) {
275            return;
276        }
277
278        if (!is_array($meta)) {
279            $this->errors[] = "{$path}: image meta must be an object or null";
280
281            return;
282        }
283
284        foreach ($meta as $key => $value) {
285            if (!in_array($key, ['alt', 'title'], true)) {
286                $this->errors[] = "{$path}: unknown image meta key '{$key}'";
287            } elseif ($value !== null && !is_string($value)) {
288                $this->errors[] = "{$path}: image meta {$key} must be a string or null";
289            }
290        }
291    }
292
293    private function classAttr(array $attrs, string $path): void
294    {
295        if (!array_key_exists('class', $attrs)) {
296            return;
297        }
298
299        $class = $attrs['class'];
300
301        if (!is_string($class) || $class === '') {
302            $this->errors[] = "{$path}: class must be a non-empty string";
303
304            return;
305        }
306
307        if ($class !== 'default' && !isset($this->classes[$class])) {
308            $this->errors[] = "{$path}: undeclared paragraph class '{$class}'";
309        }
310    }
311
312    private function alignAttr(array $attrs, string $path): void
313    {
314        if (!array_key_exists('align', $attrs)) {
315            return;
316        }
317
318        $align = $attrs['align'];
319
320        if ($align !== null && !in_array($align, Spec::ALIGNMENTS, true)) {
321            $printable = is_string($align) ? $align : gettype($align);
322            $this->errors[] = "{$path}: invalid align '{$printable}'";
323        }
324    }
325
326    private function content(array $node, string $type, string $path): void
327    {
328        $model = Spec::CONTENT[$type];
329        $content = $node['content'] ?? [];
330
331        if (!is_array($content) || !array_is_list($content)) {
332            $this->errors[] = "{$path}: content must be a list";
333
334            return;
335        }
336
337        if (str_ends_with($model, '+') && $content === []) {
338            $this->errors[] = "{$path}: '{$type}' must not be empty";
339        }
340
341        if ($model === 'paragraph block*' && $content !== []) {
342            $first = $content[0];
343
344            if (!is_array($first) || ($first['type'] ?? null) !== 'paragraph') {
345                $this->errors[] = "{$path}: listItem must start with a paragraph";
346            }
347        }
348
349        if ($model === 'paragraph block*' && $content === []) {
350            $this->errors[] = "{$path}: listItem must not be empty";
351        }
352
353        foreach ($content as $i => $child) {
354            $childPath = "{$path}.content.{$i}";
355            $childType = is_array($child) && is_string($child['type'] ?? null) ? $child['type'] : null;
356
357            if ($childType !== null && !$this->allowedIn($model, $childType, $i)) {
358                $this->errors[] = "{$childPath}: '{$childType}' not allowed in '{$type}'";
359            }
360
361            if ($type === 'codeBlock' && is_array($child) && array_key_exists('marks', $child)) {
362                $this->errors[] = "{$childPath}: codeBlock content allows no marks";
363                $child = array_diff_key($child, ['marks' => true]);
364            }
365
366            $this->node($child, $childPath);
367        }
368    }
369
370    private function allowedIn(string $model, string $childType, int $index): bool
371    {
372        return match ($model) {
373            'block+' => Spec::isBlock($childType),
374            'inline*' => Spec::isInline($childType),
375            'listItem+' => $childType === 'listItem',
376            'paragraph block*' => $index === 0 ? $childType === 'paragraph' : Spec::isBlock($childType),
377            'text*' => $childType === 'text',
378            default => false,
379        };
380    }
381
382    private function keys(array $data, array $allowed, string $path): void
383    {
384        foreach (array_keys($data) as $key) {
385            if (!in_array($key, $allowed, true)) {
386                $this->errors[] = "{$path}: unknown key '{$key}'";
387            }
388        }
389    }
390
391    private function attrKeys(array $attrs, array $allowed, string $path): void
392    {
393        foreach (array_keys($attrs) as $key) {
394            if (!in_array($key, $allowed, true)) {
395                $this->errors[] = "{$path}: unknown attr '{$key}'";
396            }
397        }
398    }
399}