Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.51% covered (success)
94.51%
86 / 91
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Normalizer
94.51% covered (success)
94.51%
86 / 91
66.67% covered (warning)
66.67%
6 / 9
50.41
0.00% covered (danger)
0.00%
0 / 1
 content
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 normalize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 children
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 node
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 text
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 marks
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
7.08
 attrs
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 mergeRuns
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 isEmpty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Richtext;
6
7/**
8 * Canonical form for richtext documents: fixed key order (type,
9 * attrs, text, marks, content), sorted attribute keys, marks sorted
10 * by type, attributes equal to their spec default omitted, empty
11 * text runs dropped, adjacent text runs with identical marks merged.
12 * Byte-stable storage keeps history diffs small and normalization
13 * idempotent.
14 */
15final class Normalizer
16{
17    /**
18     * Canonicalize every envelope-marked richtext value inside a node
19     * content array (fields and blocks alike); empty documents become
20     * null locale entries.
21     *
22     * @param array<mixed> $content
23     * @return array<mixed>
24     */
25    public function content(array $content): array
26    {
27        foreach ($content as $key => $value) {
28            if (!is_array($value)) {
29                continue;
30            }
31
32            if (($value['format'] ?? null) === Envelope::FORMAT && is_array($value['value'] ?? null)) {
33                foreach ($value['value'] as $locale => $doc) {
34                    $value['value'][$locale] = $this->normalize($doc);
35                }
36
37                $content[$key] = $value;
38
39                continue;
40            }
41
42            $content[$key] = $this->content($value);
43        }
44
45        return $content;
46    }
47
48    /**
49     * Canonical form of a document; null when it is empty.
50     *
51     * @return null|array<string, mixed>
52     */
53    public function normalize(mixed $doc): ?array
54    {
55        if (!is_array($doc) || ($doc['type'] ?? null) !== 'doc') {
56            return null;
57        }
58
59        $content = $this->children($doc['content'] ?? []);
60
61        if ($this->isEmpty($content)) {
62            return null;
63        }
64
65        return ['type' => 'doc', 'content' => $content];
66    }
67
68    /** @return list<array<string, mixed>> */
69    private function children(mixed $content): array
70    {
71        if (!is_array($content)) {
72            return [];
73        }
74
75        $result = [];
76
77        foreach ($content as $child) {
78            if (!is_array($child) || !is_string($child['type'] ?? null)) {
79                continue;
80            }
81
82            $node = $this->node($child);
83
84            if ($node !== null) {
85                $result[] = $node;
86            }
87        }
88
89        return $this->mergeRuns($result);
90    }
91
92    /**
93     * @param array<string, mixed> $node
94     * @return null|array<string, mixed>
95     */
96    private function node(array $node): ?array
97    {
98        $type = $node['type'];
99
100        if ($type === 'text') {
101            return $this->text($node);
102        }
103
104        $result = ['type' => $type];
105        $attrs = $this->attrs(
106            is_array($node['attrs'] ?? null) ? $node['attrs'] : [],
107            Spec::nodeDefaults($type),
108        );
109
110        if ($attrs !== []) {
111            $result['attrs'] = $attrs;
112        }
113
114        if (!Spec::isLeaf($type)) {
115            $content = $this->children($node['content'] ?? []);
116
117            if ($content !== []) {
118                $result['content'] = $content;
119            }
120        }
121
122        return $result;
123    }
124
125    /**
126     * @param array<string, mixed> $node
127     * @return null|array<string, mixed>
128     */
129    private function text(array $node): ?array
130    {
131        $text = $node['text'] ?? null;
132
133        if (!is_string($text) || $text === '') {
134            return null;
135        }
136
137        $result = ['type' => 'text', 'text' => $text];
138        $marks = $this->marks($node['marks'] ?? []);
139
140        if ($marks !== []) {
141            $result['marks'] = $marks;
142        }
143
144        return $result;
145    }
146
147    /** @return list<array<string, mixed>> */
148    private function marks(mixed $marks): array
149    {
150        if (!is_array($marks)) {
151            return [];
152        }
153
154        $result = [];
155
156        foreach ($marks as $mark) {
157            if (!is_array($mark) || !is_string($mark['type'] ?? null)) {
158                continue;
159            }
160
161            $type = $mark['type'];
162            $entry = ['type' => $type];
163            $attrs = $this->attrs(
164                is_array($mark['attrs'] ?? null) ? $mark['attrs'] : [],
165                Spec::markDefaults($type),
166            );
167
168            if ($attrs !== []) {
169                $entry['attrs'] = $attrs;
170            }
171
172            $result[$type] = $entry;
173        }
174
175        ksort($result);
176
177        return array_values($result);
178    }
179
180    /**
181     * @param array<mixed> $attrs
182     * @param array<string, mixed> $defaults
183     * @return array<string, mixed>
184     */
185    private function attrs(array $attrs, array $defaults): array
186    {
187        $result = [];
188
189        foreach ($attrs as $key => $value) {
190            $hasDefault = array_key_exists($key, $defaults);
191
192            if ($hasDefault && $value === $defaults[$key]) {
193                continue;
194            }
195
196            if (!$hasDefault && $value === null) {
197                continue;
198            }
199
200            $result[$key] = $value;
201        }
202
203        ksort($result);
204
205        return $result;
206    }
207
208    /**
209     * Merge adjacent text runs carrying identical mark sets.
210     *
211     * @param list<array<string, mixed>> $nodes
212     * @return list<array<string, mixed>>
213     */
214    private function mergeRuns(array $nodes): array
215    {
216        $result = [];
217
218        foreach ($nodes as $node) {
219            $last = $result === [] ? null : $result[count($result) - 1];
220
221            if (
222                $last !== null
223                && $node['type'] === 'text'
224                && $last['type'] === 'text'
225                && ($node['marks'] ?? []) === ($last['marks'] ?? [])
226            ) {
227                $result[count($result) - 1]['text'] = $last['text'] . $node['text'];
228
229                continue;
230            }
231
232            $result[] = $node;
233        }
234
235        return $result;
236    }
237
238    /** @param list<array<string, mixed>> $content */
239    private function isEmpty(array $content): bool
240    {
241        foreach ($content as $node) {
242            if ($node['type'] !== 'paragraph' || ($node['content'] ?? []) !== []) {
243                return false;
244            }
245        }
246
247        return true;
248    }
249}