Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
10.67% |
16 / 150 |
|
13.33% |
2 / 15 |
CRAP | |
0.00% |
0 / 1 |
JSON | |
10.67% |
16 / 150 |
|
13.33% |
2 / 15 |
2373.28 | |
0.00% |
0 / 1 |
getConfig | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
rootValueTable | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
objectTable | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
objectRow | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
arrayTable | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
valueCell | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
primitiveValue | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
56 | |||
toDOM | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
rootValueTableFrom | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
objectTableFrom | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
56 | |||
objectRowFrom | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
arrayTableFrom | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
56 | |||
valueCellFrom | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
primitiveValueFrom | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
fromDOM | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This implements the "json" content model as an extension, to allow editing |
4 | * JSON data structures using Visual Editor. It represents the JSON |
5 | * structure as a nested table. |
6 | */ |
7 | |
8 | declare( strict_types = 1 ); |
9 | |
10 | namespace Wikimedia\Parsoid\Ext\JSON; |
11 | |
12 | use Wikimedia\Assert\Assert; |
13 | use Wikimedia\Parsoid\Core\ContentModelHandler; |
14 | use Wikimedia\Parsoid\Core\SelserData; |
15 | use Wikimedia\Parsoid\DOM\Document; |
16 | use Wikimedia\Parsoid\DOM\Element; |
17 | use Wikimedia\Parsoid\Ext\DOMUtils; |
18 | use Wikimedia\Parsoid\Ext\ExtensionModule; |
19 | use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; |
20 | use Wikimedia\Parsoid\Ext\PHPUtils; |
21 | use Wikimedia\Parsoid\Utils\DOMCompat; |
22 | |
23 | /** |
24 | * Native Parsoid implementation of the "json" contentmodel. |
25 | */ |
26 | class JSON extends ContentModelHandler implements ExtensionModule { |
27 | private const PARSE_ERROR_HTML = "<table typeof=\"mw:Error\" data-mw='{\"errors\":[{\"key\":\"bad-json\"}]}'>"; |
28 | |
29 | /** @inheritDoc */ |
30 | public function getConfig(): array { |
31 | return [ |
32 | 'name' => 'JSON content', |
33 | 'contentModels' => [ |
34 | 'json' => self::class, |
35 | ], |
36 | ]; |
37 | } |
38 | |
39 | /** |
40 | * @param Element $parent |
41 | * @param array|object|string $val |
42 | */ |
43 | private function rootValueTable( Element $parent, $val ): void { |
44 | if ( is_array( $val ) ) { |
45 | // Wrap arrays in another array so they're visually boxed in a |
46 | // container. Otherwise they are visually indistinguishable from |
47 | // a single value. |
48 | self::arrayTable( $parent, [ $val ] ); |
49 | return; |
50 | } |
51 | |
52 | if ( $val && is_object( $val ) ) { |
53 | self::objectTable( $parent, (array)$val ); |
54 | return; |
55 | } |
56 | |
57 | DOMCompat::setInnerHTML( $parent, |
58 | '<table class="mw-json mw-json-single-value"><tbody><tr><td>' ); |
59 | self::primitiveValue( DOMCompat::querySelector( $parent, 'td' ), $val ); |
60 | } |
61 | |
62 | private function objectTable( Element $parent, array $val ): void { |
63 | DOMCompat::setInnerHTML( $parent, |
64 | '<table class="mw-json mw-json-object"><tbody>' ); |
65 | $tbody = $parent->firstChild->firstChild; |
66 | DOMUtils::assertElt( $tbody ); |
67 | $keys = array_keys( $val ); |
68 | if ( count( $keys ) ) { |
69 | foreach ( $val as $k => $v ) { |
70 | self::objectRow( $tbody, (string)$k, $v ); |
71 | } |
72 | } else { |
73 | DOMCompat::setInnerHTML( $tbody, |
74 | '<tr><td class="mw-json-empty">' ); |
75 | } |
76 | } |
77 | |
78 | /** |
79 | * @param Element $parent |
80 | * @param ?string $key |
81 | * @param mixed $val |
82 | */ |
83 | private function objectRow( Element $parent, ?string $key, $val ): void { |
84 | $tr = $parent->ownerDocument->createElement( 'tr' ); |
85 | if ( $key !== null ) { |
86 | $th = $parent->ownerDocument->createElement( 'th' ); |
87 | $th->textContent = $key; |
88 | $tr->appendChild( $th ); |
89 | } |
90 | self::valueCell( $tr, $val ); |
91 | $parent->appendChild( $tr ); |
92 | } |
93 | |
94 | private function arrayTable( Element $parent, array $val ): void { |
95 | DOMCompat::setInnerHTML( $parent, |
96 | '<table class="mw-json mw-json-array"><tbody>' ); |
97 | $tbody = $parent->firstChild->firstChild; |
98 | DOMUtils::assertElt( $tbody ); |
99 | if ( count( $val ) ) { |
100 | foreach ( $val as $v ) { |
101 | self::objectRow( $tbody, null, $v ); |
102 | } |
103 | } else { |
104 | DOMCompat::setInnerHTML( $tbody, |
105 | '<tr><td class="mw-json-empty">' ); |
106 | } |
107 | } |
108 | |
109 | /** |
110 | * @param Element $parent |
111 | * @param mixed $val |
112 | */ |
113 | private function valueCell( Element $parent, $val ): void { |
114 | $td = $parent->ownerDocument->createElement( 'td' ); |
115 | if ( is_array( $val ) ) { |
116 | self::arrayTable( $td, $val ); |
117 | } elseif ( $val && is_object( $val ) ) { |
118 | self::objectTable( $td, (array)$val ); |
119 | } else { |
120 | DOMCompat::getClassList( $td )->add( 'value' ); |
121 | self::primitiveValue( $td, $val ); |
122 | } |
123 | $parent->appendChild( $td ); |
124 | } |
125 | |
126 | /** |
127 | * @param Element $parent |
128 | * @param string|int|bool|null $val |
129 | */ |
130 | private function primitiveValue( Element $parent, $val ): void { |
131 | if ( $val === null ) { |
132 | DOMCompat::getClassList( $parent )->add( 'mw-json-null' ); |
133 | $parent->textContent = 'null'; |
134 | return; |
135 | } elseif ( is_bool( $val ) ) { |
136 | DOMCompat::getClassList( $parent )->add( 'mw-json-boolean' ); |
137 | $parent->textContent = $val ? 'true' : 'false'; |
138 | return; |
139 | } elseif ( is_int( $val ) || is_float( $val ) ) { |
140 | DOMCompat::getClassList( $parent )->add( 'mw-json-number' ); |
141 | } elseif ( is_string( $val ) ) { |
142 | DOMCompat::getClassList( $parent )->add( 'mw-json-string' ); |
143 | } |
144 | $parent->textContent = (string)$val; |
145 | } |
146 | |
147 | /** |
148 | * JSON to HTML. |
149 | * Implementation matches that from includes/content/JsonContent.php in |
150 | * mediawiki core, except that we distinguish value types. |
151 | * @param ParsoidExtensionAPI $extApi |
152 | * @return Document |
153 | */ |
154 | public function toDOM( ParsoidExtensionAPI $extApi ): Document { |
155 | // @phan-suppress-next-line PhanDeprecatedFunction not ready for this yet |
156 | $jsonText = $extApi->getPageConfig()->getPageMainContent(); |
157 | $document = $extApi->getTopLevelDoc(); |
158 | $body = DOMCompat::getBody( $document ); |
159 | |
160 | // PORT-FIXME: When production moves to PHP 7.3, re-enable this try |
161 | // catch code |
162 | |
163 | // try { |
164 | // $src = json_decode( $jsonText, false, 6, JSON_THROW_ON_ERROR ); |
165 | // self::rootValueTable( $body, $src ); |
166 | // } catch ( JsonException $e ) { |
167 | // DOMCompat::setInnerHTML( $body, self::PARSE_ERROR_HTML ); |
168 | // } |
169 | |
170 | $src = json_decode( $jsonText, false, 6 ); |
171 | if ( $src === null && json_last_error() !== JSON_ERROR_NONE ) { |
172 | DOMCompat::setInnerHTML( $body, self::PARSE_ERROR_HTML ); |
173 | } else { |
174 | self::rootValueTable( $body, $src ); |
175 | } |
176 | |
177 | // end of PHP 7.2 compatible error handling code, remove whem enabling |
178 | // 7.3+ try catch code |
179 | |
180 | // We're responsible for running the standard DOMPostProcessor on our |
181 | // resulting document. |
182 | $extApi->postProcessDOM( $document ); |
183 | |
184 | return $document; |
185 | } |
186 | |
187 | /** |
188 | * RootValueTableFrom |
189 | * @param Element $el |
190 | * @return array|false|int|string|null |
191 | */ |
192 | private function rootValueTableFrom( Element $el ) { |
193 | if ( DOMUtils::hasClass( $el, 'mw-json-single-value' ) ) { |
194 | return self::primitiveValueFrom( DOMCompat::querySelector( $el, 'tr > td' ) ); |
195 | } elseif ( DOMUtils::hasClass( $el, 'mw-json-array' ) ) { |
196 | return self::arrayTableFrom( $el )[0]; |
197 | } else { |
198 | return self::objectTableFrom( $el ); |
199 | } |
200 | } |
201 | |
202 | /** |
203 | * @param Element $el |
204 | * @return array |
205 | */ |
206 | private function objectTableFrom( Element $el ) { |
207 | Assert::invariant( DOMUtils::hasClass( $el, 'mw-json-object' ), |
208 | 'Expected mw-json-object' ); |
209 | $tbody = $el; |
210 | if ( $tbody->firstChild ) { |
211 | $child = $tbody->firstChild; |
212 | DOMUtils::assertElt( $child ); |
213 | if ( DOMCompat::nodeName( $child ) === 'tbody' ) { |
214 | $tbody = $child; |
215 | } |
216 | } |
217 | $rows = $tbody->childNodes; |
218 | $obj = []; |
219 | $empty = count( $rows ) === 0; |
220 | if ( !$empty ) { |
221 | $child = $rows->item( 0 )->firstChild; |
222 | DOMUtils::assertElt( $child ); |
223 | if ( DOMUtils::hasClass( $child, 'mw-json-empty' ) ) { |
224 | $empty = true; |
225 | } |
226 | } |
227 | if ( !$empty ) { |
228 | for ( $i = 0; $i < count( $rows ); $i++ ) { |
229 | $item = $rows->item( $i ); |
230 | DOMUtils::assertElt( $item ); |
231 | self::objectRowFrom( $item, $obj, null ); |
232 | } |
233 | } |
234 | return $obj; |
235 | } |
236 | |
237 | private function objectRowFrom( Element $tr, array &$obj, ?int $key ): void { |
238 | $td = $tr->firstChild; |
239 | if ( $key === null ) { |
240 | $key = $td->textContent; |
241 | $td = $td->nextSibling; |
242 | } |
243 | DOMUtils::assertElt( $td ); |
244 | $obj[$key] = self::valueCellFrom( $td ); |
245 | } |
246 | |
247 | private function arrayTableFrom( Element $el ): array { |
248 | Assert::invariant( DOMUtils::hasClass( $el, 'mw-json-array' ), |
249 | 'Expected ms-json-array' ); |
250 | $tbody = $el; |
251 | if ( $tbody->firstChild ) { |
252 | $child = $tbody->firstChild; |
253 | DOMUtils::assertElt( $child ); |
254 | if ( DOMCompat::nodeName( $child ) === 'tbody' ) { |
255 | $tbody = $child; |
256 | } |
257 | } |
258 | $rows = $tbody->childNodes; |
259 | $arr = []; |
260 | $empty = count( $rows ) === 0; |
261 | if ( !$empty ) { |
262 | $child = $rows->item( 0 )->firstChild; |
263 | DOMUtils::assertElt( $child ); |
264 | if ( DOMUtils::hasClass( $child, 'mw-json-empty' ) ) { |
265 | $empty = true; |
266 | } |
267 | } |
268 | if ( !$empty ) { |
269 | for ( $i = 0; $i < count( $rows ); $i++ ) { |
270 | $item = $rows->item( $i ); |
271 | DOMUtils::assertElt( $item ); |
272 | self::objectRowFrom( $item, $arr, $i ); |
273 | } |
274 | } |
275 | return $arr; |
276 | } |
277 | |
278 | /** |
279 | * @param Element $el |
280 | * @return array|object|false|float|int|string|null |
281 | */ |
282 | private function valueCellFrom( Element $el ) { |
283 | Assert::invariant( DOMCompat::nodeName( $el ) === 'td', 'Expected tagName = td' ); |
284 | $table = $el->firstChild; |
285 | if ( $table instanceof Element ) { |
286 | if ( DOMUtils::hasClass( $table, 'mw-json-array' ) ) { |
287 | return self::arrayTableFrom( $table ); |
288 | } elseif ( DOMUtils::hasClass( $table, 'mw-json-object' ) ) { |
289 | return self::objectTableFrom( $table ); |
290 | } |
291 | } else { |
292 | return self::primitiveValueFrom( $el ); |
293 | } |
294 | } |
295 | |
296 | /** |
297 | * @param Element $el |
298 | * @return false|float|int|string|null |
299 | */ |
300 | private function primitiveValueFrom( Element $el ) { |
301 | if ( DOMUtils::hasClass( $el, 'mw-json-null' ) ) { |
302 | return null; |
303 | } elseif ( DOMUtils::hasClass( $el, 'mw-json-boolean' ) ) { |
304 | return str_contains( $el->textContent, 'true' ); |
305 | } elseif ( DOMUtils::hasClass( $el, 'mw-json-number' ) ) { |
306 | return floatval( $el->textContent ); |
307 | } elseif ( DOMUtils::hasClass( $el, 'mw-json-string' ) ) { |
308 | return (string)$el->textContent; |
309 | } else { |
310 | return null; // shouldn't happen. |
311 | } |
312 | } |
313 | |
314 | /** |
315 | * DOM to JSON. |
316 | * @param ParsoidExtensionAPI $extApi |
317 | * @param ?SelserData $selserData |
318 | * @return string |
319 | */ |
320 | public function fromDOM( |
321 | ParsoidExtensionAPI $extApi, ?SelserData $selserData = null |
322 | ): string { |
323 | $body = DOMCompat::getBody( $extApi->getTopLevelDoc() ); |
324 | $t = $body->firstChild; |
325 | DOMUtils::assertElt( $t ); |
326 | Assert::invariant( $t && DOMCompat::nodeName( $t ) === 'table', |
327 | 'Expected tagName = table' ); |
328 | self::rootValueTableFrom( $t ); |
329 | return PHPUtils::jsonEncode( self::rootValueTableFrom( $t ) ); |
330 | } |
331 | |
332 | } |