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