Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
10.87% covered (danger)
10.87%
15 / 138
13.33% covered (danger)
13.33%
2 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
JSON
10.87% covered (danger)
10.87%
15 / 138
13.33% covered (danger)
13.33%
2 / 15
2276.52
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 / 9
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 / 8
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 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 objectRowFrom
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 arrayTableFrom
0.00% covered (danger)
0.00%
0 / 18
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%
6 / 6
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\DOMCompat;
16use Wikimedia\Parsoid\Core\SelectiveUpdateData;
17use Wikimedia\Parsoid\DOM\Document;
18use Wikimedia\Parsoid\DOM\Element;
19use Wikimedia\Parsoid\Ext\DOMUtils;
20use Wikimedia\Parsoid\Ext\ExtensionModule;
21use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
22use Wikimedia\Parsoid\Ext\PHPUtils;
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        '@phan-var Element $tbody'; // @var Element $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        '@phan-var Element $tbody'; // @var Element $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 ) {
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            '@phan-var Element $child'; // @var Element $child
204            if ( DOMUtils::nodeName( $child ) === 'tbody' ) {
205                $tbody = $child;
206            }
207        }
208        $rows = DOMUtils::childNodes( $tbody );
209        $obj = [];
210        $empty = count( $rows ) === 0;
211        if ( !$empty ) {
212            $child = $rows[0]->firstChild;
213            '@phan-var Element $child'; // @var Element $child
214            if ( DOMUtils::hasClass( $child, 'mw-json-empty' ) ) {
215                $empty = true;
216            }
217        }
218        if ( !$empty ) {
219            foreach ( $rows as $item ) {
220                '@phan-var Element $item'; // @var Element $item
221                self::objectRowFrom( $item, $obj, null );
222            }
223        }
224        return $obj;
225    }
226
227    private function objectRowFrom( Element $tr, array &$obj, ?int $key ): void {
228        $td = $tr->firstChild;
229        if ( $key === null ) {
230            $key = $td->textContent;
231            $td = $td->nextSibling;
232        }
233        '@phan-var Element $td'; // @var Element $td
234        $obj[$key] = self::valueCellFrom( $td );
235    }
236
237    private function arrayTableFrom( Element $el ): array {
238        Assert::invariant( DOMUtils::hasClass( $el, 'mw-json-array' ),
239            'Expected ms-json-array' );
240        $tbody = $el;
241        if ( $tbody->firstChild ) {
242            $child = $tbody->firstChild;
243            '@phan-var Element $child'; // @var Element $child
244            if ( DOMUtils::nodeName( $child ) === 'tbody' ) {
245                $tbody = $child;
246            }
247        }
248        $rows = DOMUtils::childNodes( $tbody );
249        $arr = [];
250        $empty = count( $rows ) === 0;
251        if ( !$empty ) {
252            $child = $rows[0]->firstChild;
253            '@phan-var Element $child'; // @var Element $child
254            if ( DOMUtils::hasClass( $child, 'mw-json-empty' ) ) {
255                $empty = true;
256            }
257        }
258        if ( !$empty ) {
259            foreach ( $rows as $i => $item ) {
260                '@phan-var Element $item'; // @var Element $item
261                self::objectRowFrom( $item, $arr, $i );
262            }
263        }
264        return $arr;
265    }
266
267    /**
268     * @param Element $el
269     * @return array|object|false|float|int|string|null
270     */
271    private function valueCellFrom( Element $el ) {
272        Assert::invariant( DOMUtils::nodeName( $el ) === 'td', 'Expected tagName = td' );
273        $table = $el->firstChild;
274        if ( $table instanceof Element ) {
275            if ( DOMUtils::hasClass( $table, 'mw-json-array' ) ) {
276                return self::arrayTableFrom( $table );
277            } elseif ( DOMUtils::hasClass( $table, 'mw-json-object' ) ) {
278                return self::objectTableFrom( $table );
279            }
280        } else {
281            return self::primitiveValueFrom( $el );
282        }
283    }
284
285    /**
286     * @param Element $el
287     * @return false|float|int|string|null
288     */
289    private function primitiveValueFrom( Element $el ) {
290        if ( DOMUtils::hasClass( $el, 'mw-json-null' ) ) {
291            return null;
292        } elseif ( DOMUtils::hasClass( $el, 'mw-json-boolean' ) ) {
293            return str_contains( $el->textContent, 'true' );
294        } elseif ( DOMUtils::hasClass( $el, 'mw-json-number' ) ) {
295            return floatval( $el->textContent );
296        } elseif ( DOMUtils::hasClass( $el, 'mw-json-string' ) ) {
297            return (string)$el->textContent;
298        } else {
299            return null; // shouldn't happen.
300        }
301    }
302
303    /**
304     * DOM to JSON.
305     * @param ParsoidExtensionAPI $extApi
306     * @param ?SelectiveUpdateData $selectiveUpdateData
307     * @return string
308     */
309    public function fromDOM(
310        ParsoidExtensionAPI $extApi, ?SelectiveUpdateData $selectiveUpdateData = null
311    ): string {
312        $body = DOMCompat::getBody( $extApi->getTopLevelDoc() );
313        $t = $body->firstChild;
314        '@phan-var Element $t'; // @var Element $t
315        Assert::invariant( $t && DOMUtils::nodeName( $t ) === 'table',
316            'Expected tagName = table' );
317        self::rootValueTableFrom( $t );
318        return PHPUtils::jsonEncode( self::rootValueTableFrom( $t ) );
319    }
320
321}