Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.53% covered (danger)
14.53%
52 / 358
4.00% covered (danger)
4.00%
2 / 50
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMDataUtils
14.53% covered (danger)
14.53%
52 / 358
4.00% covered (danger)
4.00%
2 / 50
11686.29
0.00% covered (danger)
0.00%
0 / 1
 getBag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCodec
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isPrepared
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prepareDoc
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 prepareChildDoc
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 stashObjectInDoc
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 noAttrs
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getNodeData
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 setNodeData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataParsoid
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setDataParsoid
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataMwI18n
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDataMwI18nDefault
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDataNodeI18n
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setDataNodeI18n
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataAttrI18n
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setDataAttrI18n
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDataAttrI18nNames
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getDataParsoidDiff
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDataParsoidDiffDefault
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDataParsoidDiff
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDataMw
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 setDataMw
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 validDataMw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getJSONAttribute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 setJSONAttribute
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setShadowInfo
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setShadowInfoIfModified
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 addNormalizedAttribute
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 storeInPageBundle
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
6.05
 getCodecHints
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 injectPageBundle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 extractPageBundle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 visitAndLoadDataAttribs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadDataAttribs
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 usedIdIndex
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 visitAndStoreDataAttribs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 storeDataAttribs
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
182
 cloneNode
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 cloneDocumentFragment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 fixClonedData
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isHtmlAttributeWithSpecialSemantics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAttributeObject
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
4.02
 getAttributeObjectDefault
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
6.60
 setAttributeObject
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 removeAttributeObject
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 nodeHasDataMw
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 removeFromExpandedAttrs
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 loadRichAttributes
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
110
 storeRichAttributes
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Composer\Semver\Semver;
7use InvalidArgumentException;
8use stdClass;
9use Wikimedia\Assert\Assert;
10use Wikimedia\Assert\UnreachableException;
11use Wikimedia\JsonCodec\Hint;
12use Wikimedia\JsonCodec\JsonCodec;
13use Wikimedia\Parsoid\Core\DomPageBundle;
14use Wikimedia\Parsoid\Core\PageBundle;
15use Wikimedia\Parsoid\DOM\Document;
16use Wikimedia\Parsoid\DOM\DocumentFragment;
17use Wikimedia\Parsoid\DOM\Element;
18use Wikimedia\Parsoid\DOM\Node;
19use Wikimedia\Parsoid\NodeData\DataBag;
20use Wikimedia\Parsoid\NodeData\DataMw;
21use Wikimedia\Parsoid\NodeData\DataMwAttrib;
22use Wikimedia\Parsoid\NodeData\DataMwI18n;
23use Wikimedia\Parsoid\NodeData\DataParsoid;
24use Wikimedia\Parsoid\NodeData\DataParsoidDiff;
25use Wikimedia\Parsoid\NodeData\I18nInfo;
26use Wikimedia\Parsoid\NodeData\NodeData;
27use Wikimedia\Parsoid\NodeData\TempData;
28
29/**
30 * These helpers pertain to HTML and data attributes of a node.
31 */
32class DOMDataUtils {
33    public const DATA_OBJECT_ATTR_NAME = 'data-object-id';
34
35    /** The internal property prefix used for rich attribute data. */
36    private const RICH_ATTR_DATA_PREFIX = 'rich-data-';
37
38    /** The internal property prefix used for rich attribute type hints. */
39    private const RICH_ATTR_HINT_PREFIX = 'rich-hint-';
40
41    /**
42     * Return the dynamic "bag" property of a Document.
43     * @param Document $doc
44     * @return DataBag
45     */
46    public static function getBag( Document $doc ): DataBag {
47        // This is a dynamic property; it is not declared.
48        // All references go through here so we can suppress phan's complaint.
49        // @phan-suppress-next-line PhanUndeclaredProperty
50        return $doc->bag;
51    }
52
53    /**
54     * Return the JsonCodec used for rich attributes in a Document.
55     * @param Document $doc
56     * @return JsonCodec
57     */
58    public static function getCodec( Document $doc ): JsonCodec {
59        // This is a dynamic property; it is not declared.
60        // All references go through here so we can suppress phan's complaint.
61        // @phan-suppress-next-line PhanUndeclaredProperty
62        return $doc->codec;
63    }
64
65    public static function isPrepared( Document $doc ): bool {
66        // `bag` is a deliberate dynamic property; see DOMDataUtils::getBag()
67        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
68        return isset( $doc->bag );
69    }
70
71    public static function prepareDoc( Document $doc ): void {
72        // `bag` is a deliberate dynamic property; see DOMDataUtils::getBag()
73        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
74        $doc->bag = new DataBag();
75        // `codec` is a deliberate dynamic property; see DOMDataUtils::getCodec()
76        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
77        $doc->codec = new JsonCodec();
78
79        // Cache the head and body.
80        DOMCompat::getHead( $doc );
81        DOMCompat::getBody( $doc );
82    }
83
84    /**
85     * @param Document $topLevelDoc
86     * @param Document $childDoc
87     */
88    public static function prepareChildDoc( Document $topLevelDoc, Document $childDoc ) {
89        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
90        Assert::invariant( $topLevelDoc->bag instanceof DataBag, 'doc bag not set' );
91        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
92        $childDoc->bag = $topLevelDoc->bag;
93        // @phan-suppress-next-line PhanUndeclaredProperty dynamic property
94        $childDoc->codec = $topLevelDoc->codec;
95    }
96
97    /**
98     * Stash $obj in $doc and return an id for later retrieval
99     * @param Document $doc
100     * @param NodeData $obj
101     * @return int
102     */
103    public static function stashObjectInDoc( Document $doc, NodeData $obj ): int {
104        return self::getBag( $doc )->stashObject( $obj );
105    }
106
107    /**
108     * Does this node have any attributes?
109     * @param Element $node
110     * @return bool
111     */
112    public static function noAttrs( Element $node ): bool {
113        // The 'xmlns' attribute is "invisible" T235295
114        if ( $node->hasAttribute( 'xmlns' ) ) {
115            return false;
116        }
117        $numAttrs = count( $node->attributes );
118        return $numAttrs === 0 ||
119            ( $numAttrs === 1 && $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) );
120    }
121
122    /**
123     * Get data object from a node.
124     *
125     * @param Element $node node
126     * @return NodeData
127     */
128    public static function getNodeData( Element $node ): NodeData {
129        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
130            // Initialized on first request
131            $dataObject = new NodeData;
132            self::setNodeData( $node, $dataObject );
133            return $dataObject;
134        }
135
136        $nodeId = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
137        if ( $nodeId !== null ) {
138            $dataObject = self::getBag( $node->ownerDocument )->getObject( (int)$nodeId );
139        } else {
140            $dataObject = null; // Make phan happy
141        }
142        Assert::invariant( isset( $dataObject ), 'Bogus nodeId given!' );
143        if ( isset( $dataObject->storedId ) ) {
144            throw new UnreachableException(
145                'Trying to fetch node data without loading!' .
146                // If this node's data-object id is different from storedId,
147                // it will indicate that the data-parsoid object was shared
148                // between nodes without getting cloned. Useful for debugging.
149                'Node id: ' . $nodeId . ' ' .
150                'Stored data: ' . PHPUtils::jsonEncode( $dataObject )
151            );
152        }
153        return $dataObject;
154    }
155
156    /**
157     * Set node data.
158     *
159     * @param Element $node node
160     * @param NodeData $data data
161     */
162    public static function setNodeData( Element $node, NodeData $data ): void {
163        $nodeId = self::stashObjectInDoc( $node->ownerDocument, $data );
164        $node->setAttribute( self::DATA_OBJECT_ATTR_NAME, (string)$nodeId );
165    }
166
167    /**
168     * Get data parsoid info from a node.
169     *
170     * @param Element $node node
171     * @return DataParsoid
172     */
173    public static function getDataParsoid( Element $node ): DataParsoid {
174        $data = self::getNodeData( $node );
175        $data->parsoid ??= new DataParsoid;
176        return $data->parsoid;
177    }
178
179    /**
180     * Set data parsoid info on a node.
181     *
182     * @param Element $node node
183     * @param DataParsoid $dp data-parsoid
184     */
185    public static function setDataParsoid( Element $node, DataParsoid $dp ): void {
186        $data = self::getNodeData( $node );
187        $data->parsoid = $dp;
188    }
189
190    /**
191     * Returns the i18n information of a node. This is in private access because it shouldn't
192     * typically be used directly; instead getDataNodeI18n and getDataAttrI18n should be used.
193     * @param Element $node
194     * @return DataMwI18n|null
195     */
196    private static function getDataMwI18n( Element $node ): ?DataMwI18n {
197        // No default value; returns null if not present.
198        return self::getAttributeObject( $node, 'data-mw-i18n', DataMwI18n::hint() );
199    }
200
201    /**
202     * Returns the i18n information of a node, setting it to a default
203     * value if it is missing.  This should not typically be used
204     * directly; instead setDataNodeI18n and setDataAttrI18n should be
205     * used.
206     *
207     * @param Element $node
208     * @return DataMwI18n $i18n
209     */
210    private static function getDataMwI18nDefault( Element $node ): DataMwI18n {
211        return self::getAttributeObjectDefault( $node, 'data-mw-i18n', DataMwI18n::hint() );
212    }
213
214    /**
215     * Retrieves internationalization (i18n) information of a node (typically for localization)
216     * @param Element $node
217     * @return ?I18nInfo
218     */
219    public static function getDataNodeI18n( Element $node ): ?I18nInfo {
220        $i18n = self::getDataMwI18n( $node );
221        if ( $i18n === null ) {
222            return null;
223        }
224        return $i18n->getSpanInfo();
225    }
226
227    /**
228     * Sets internationalization (i18n) information of a node, used for later localization
229     * @param Element $node
230     * @param I18nInfo $info
231     * @return void
232     */
233    public static function setDataNodeI18n( Element $node, I18nInfo $info ) {
234        $i18n = self::getDataMwI18nDefault( $node );
235        $i18n->setSpanInfo( $info );
236    }
237
238    /**
239     * Retrieves internationalization (i18n) information of an attribute value (typically for
240     * localization)
241     * @param Element $node
242     * @param string $name
243     * @return ?I18nInfo
244     */
245    public static function getDataAttrI18n( Element $node, string $name ): ?I18nInfo {
246        $i18n = self::getDataMwI18n( $node );
247        if ( $i18n === null ) {
248            return null;
249        }
250        return $i18n->getAttributeInfo( $name );
251    }
252
253    /**
254     * Sets internationalization (i18n) information of a attribute value, used for later
255     * localization
256     * @param Element $node
257     * @param string $name
258     * @param I18nInfo $info
259     * @return void
260     */
261    public static function setDataAttrI18n( Element $node, string $name, I18nInfo $info ) {
262        $i18n = self::getDataMwI18nDefault( $node );
263        $i18n->setAttributeInfo( $name, $info );
264    }
265
266    /**
267     * @param Element $node
268     * @return array
269     */
270    public static function getDataAttrI18nNames( Element $node ): array {
271        $i18n = self::getDataMwI18n( $node );
272        if ( $i18n === null ) {
273            // We won't set a default value for this property
274            return [];
275        }
276        return $i18n->getAttributeNames();
277    }
278
279    /**
280     * Get data diff info from a node.
281     *
282     * @param Element $node node
283     * @return ?DataParsoidDiff
284     */
285    public static function getDataParsoidDiff( Element $node ): ?DataParsoidDiff {
286        // No default value; returns null if not present.
287        return self::getAttributeObject( $node, 'data-parsoid-diff', DataParsoidDiff::hint() );
288    }
289
290    /**
291     * Get data diff info from a node, setting a default value if not present.
292     *
293     * @param Element $node node
294     * @return DataParsoidDiff
295     */
296    public static function getDataParsoidDiffDefault( Element $node ): DataParsoidDiff {
297        return self::getAttributeObjectDefault( $node, 'data-parsoid-diff', DataParsoidDiff::hint() );
298    }
299
300    /**
301     * Set data diff info on a node.
302     *
303     * @param Element $node node
304     * @param ?DataParsoidDiff $diffObj data-parsoid-diff object
305     */
306    public static function setDataParsoidDiff( Element $node, ?DataParsoidDiff $diffObj ): void {
307        if ( $diffObj !== null ) {
308            self::setAttributeObject( $node, 'data-parsoid-diff', $diffObj, DataParsoidDiff::hint() );
309        } else {
310            self::removeAttributeObject( $node, 'data-parsoid-diff' );
311        }
312    }
313
314    /**
315     * Get data meta wiki info from a node.
316     *
317     * @param Element $node node
318     * @return DataMw
319     */
320    public static function getDataMw( Element $node ): DataMw {
321        $data = self::getNodeData( $node );
322        $data->mw ??= new DataMw;
323        return $data->mw;
324    }
325
326    /**
327     * Set data meta wiki info from a node.
328     *
329     * @param Element $node node
330     * @param ?DataMw $dmw data-mw
331     */
332    public static function setDataMw( Element $node, ?DataMw $dmw ): void {
333        $data = self::getNodeData( $node );
334        $data->mw = $dmw;
335    }
336
337    /**
338     * Check if there is meta wiki info in a node.
339     *
340     * @param Element $node node
341     * @return bool
342     */
343    public static function validDataMw( Element $node ): bool {
344        return (array)self::getDataMw( $node ) !== [];
345    }
346
347    /**
348     * Get an object from a JSON-encoded XML attribute on a node.
349     *
350     * @param Element $node node
351     * @param string $name name
352     * @param mixed $defaultVal
353     * @return mixed
354     */
355    public static function getJSONAttribute( Element $node, string $name, $defaultVal ) {
356        $attVal = DOMCompat::getAttribute( $node, $name );
357        if ( $attVal === null ) {
358            return $defaultVal;
359        }
360        $decoded = PHPUtils::jsonDecode( $attVal, false );
361        if ( $decoded !== null ) {
362            return $decoded;
363        } else {
364            error_log( 'ERROR: Could not decode attribute-val ' . $attVal .
365                ' for ' . $name . ' on node ' . DOMCompat::nodeName( $node ) );
366            return $defaultVal;
367        }
368    }
369
370    /**
371     * Set a attribute on a node with a JSON-encoded object.
372     *
373     * @param Element $node node
374     * @param string $name Name of the attribute.
375     * @param mixed $obj value of the attribute to
376     */
377    public static function setJSONAttribute( Element $node, string $name, $obj ): void {
378        $val = $obj === [] ? '{}' : PHPUtils::jsonEncode( $obj );
379        $node->setAttribute( $name, $val );
380    }
381
382    // Shadow attributes should probably be unified with rich attributes
383    // at some point. [CSA 2024-10-15]
384
385    /**
386     * Set shadow info on a node; similar to the method on tokens.
387     * Records a key = value pair in data-parsoid['a'] property.
388     *
389     * This is effectively a call of 'setShadowInfoIfModified' except
390     * there is no original value, so by definition, $val is modified.
391     *
392     * @param Element $node node
393     * @param string $name Name of the attribute.
394     * @param mixed $val val
395     */
396    public static function setShadowInfo( Element $node, string $name, $val ): void {
397        $dp = self::getDataParsoid( $node );
398        $dp->a ??= [];
399        $dp->sa ??= [];
400        $dp->a[$name] = $val;
401    }
402
403    /**
404     * Set shadow info on a node; similar to the method on tokens.
405     *
406     * If the new value ($val) for the key ($name) is different from the
407     * original value ($origVal):
408     * - the new value is recorded in data-parsoid->a and
409     * - the original value is recorded in data-parsoid->sa
410     *
411     * @param Element $node node
412     * @param string $name Name of the attribute.
413     * @param mixed $val val
414     * @param mixed $origVal original value (null is a valid value)
415     * @param bool $skipOrig
416     */
417    public static function setShadowInfoIfModified(
418        Element $node, string $name, $val, $origVal, bool $skipOrig = false
419    ): void {
420        if ( !$skipOrig && ( $val === $origVal || $origVal === null ) ) {
421            return;
422        }
423        $dp = self::getDataParsoid( $node );
424        $dp->a ??= [];
425        $dp->sa ??= [];
426        // FIXME: This is a hack to not overwrite already shadowed info.
427        // We should either fix the call site that depends on this
428        // behaviour to do an explicit check, or double down on this
429        // by porting it to the token method as well.
430        if ( !$skipOrig && !array_key_exists( $name, $dp->a ) ) {
431            $dp->sa[$name] = $origVal;
432        }
433        $dp->a[$name] = $val;
434    }
435
436    /**
437     * Set an attribute and shadow info to a node.
438     * Similar to the method on tokens
439     *
440     * @param Element $node node
441     * @param string $name Name of the attribute.
442     * @param mixed $val value
443     * @param mixed $origVal original value
444     * @param bool $skipOrig
445     */
446    public static function addNormalizedAttribute(
447        Element $node, string $name, $val, $origVal, bool $skipOrig = false
448    ): void {
449        if ( $name === 'id' ) {
450            DOMCompat::setIdAttribute( $node, $val );
451        } else {
452            $node->setAttribute( $name, $val );
453        }
454        self::setShadowInfoIfModified( $node, $name, $val, $origVal, $skipOrig );
455    }
456
457    /**
458     * Removes the `data-*` attribute from a node, and migrates the data to the
459     * given DomPageBundle. Generates a unique id with the following format:
460     * ```
461     * mw<base64-encoded counter>
462     * ```
463     * but attempts to keep user defined ids.
464     *
465     * TODO: Note that $data is effective a partial PageBundle containing
466     * only the 'parsoid' and 'mw' properties.
467     *
468     * @param DomPageBundle $pb
469     * @param Element $node node
470     * @param stdClass $data data
471     * @param array $idIndex Index of used id attributes in the DOM
472     */
473    public static function storeInPageBundle(
474        DomPageBundle $pb, Element $node, stdClass $data, array $idIndex
475    ): void {
476        $hints = self::getCodecHints();
477        $uid = DOMCompat::getAttribute( $node, 'id' );
478        $document = $node->ownerDocument;
479        $codec = self::getCodec( $document );
480        $docDp = &$pb->parsoid;
481        $origId = $uid;
482        if ( $uid !== null && array_key_exists( $uid, $docDp['ids'] ) ) {
483            $uid = null;
484        }
485        if ( $uid === '' ) {
486            $uid = null;
487        }
488        if ( $uid === null ) {
489            do {
490                $docDp['counter'] += 1;
491                // PORT-FIXME: NOTE that we aren't updating the idIndex here because
492                // we are generating unique ids that will not conflict. In any case,
493                // the idIndex is a workaround for the PHP DOM's issues and we might
494                // switch out of this in the future anyway.
495                $uid = 'mw' . PHPUtils::counterToBase64( $docDp['counter'] );
496            } while ( isset( $idIndex[$uid] ) );
497            self::addNormalizedAttribute( $node, 'id', $uid, $origId );
498        }
499        // Convert from DataParsoid/DataMw objects to associative array
500        $docDp['ids'][$uid] = $codec->toJsonArray( $data->parsoid, $hints['data-parsoid'] );
501        if ( isset( $data->mw ) ) {
502            $pb->mw['ids'][$uid] = $codec->toJsonArray( $data->mw, $hints['data-mw'] );
503        }
504    }
505
506    /**
507     * Helper function to create static Hint objects for JsonCodec.
508     * @return array<Hint>
509     */
510    public static function getCodecHints(): array {
511        static $hints = null;
512        if ( $hints === null ) {
513            $hints = [
514                'data-parsoid' => Hint::build( DataParsoid::class, Hint::ALLOW_OBJECT ),
515                'data-mw' => Hint::build( DataMw::class, Hint::ALLOW_OBJECT ),
516            ];
517        }
518        return $hints;
519    }
520
521    /**
522     * @param Document $doc doc
523     * @param PageBundle $pb object
524     */
525    public static function injectPageBundle( Document $doc, PageBundle $pb ): void {
526        $script = DOMUtils::appendToHead( $doc, 'script', [
527            'id' => 'mw-pagebundle',
528            'type' => 'application/x-mw-pagebundle',
529        ] );
530        $script->appendChild( $doc->createTextNode( $pb->encodeForHeadElement() ) );
531    }
532
533    /**
534     * @param Document $doc doc
535     * @return ?PageBundle
536     */
537    public static function extractPageBundle( Document $doc ): ?PageBundle {
538        $pb = null;
539        $dpScriptElt = DOMCompat::getElementById( $doc, 'mw-pagebundle' );
540        if ( $dpScriptElt ) {
541            $dpScriptElt->parentNode->removeChild( $dpScriptElt );
542            $pb = PageBundle::decodeFromHeadElement( $dpScriptElt->textContent );
543        }
544        return $pb;
545    }
546
547    /**
548     * Walk DOM from node downward calling loadDataAttribs
549     *
550     * @param Node $node node
551     * @param array $options options
552     */
553    public static function visitAndLoadDataAttribs( Node $node, array $options = [] ): void {
554        DOMUtils::visitDOM( $node, [ self::class, 'loadDataAttribs' ], $options );
555    }
556
557    /**
558     * These are intended be used on a document after post-processing, so that
559     * the underlying .dataobject is transparently applied (in the store case)
560     * and reloaded (in the load case), rather than worrying about keeping
561     * the attributes up-to-date throughout that phase.  For the most part,
562     * using this.ppTo* should be sufficient and using these directly should be
563     * avoided.
564     *
565     * @param Node $node node
566     * @param array $options options
567     */
568    public static function loadDataAttribs( Node $node, array $options ): void {
569        if ( !( $node instanceof Element ) ) {
570            return;
571        }
572        // Reset the node data object's stored state, since we're reloading it
573        self::setNodeData( $node, new NodeData );
574        $codec = self::getCodec( $node->ownerDocument );
575        $dataParsoidAttr = DOMCompat::getAttribute( $node, 'data-parsoid' );
576        $dp = $codec->newFromJsonString(
577            $dataParsoidAttr ?? '{}', self::getCodecHints()['data-parsoid']
578        );
579        if ( !empty( $options['markNew'] ) ) {
580            $dp->setTempFlag( TempData::IS_NEW, $dataParsoidAttr === null );
581        }
582        self::setDataParsoid( $node, $dp );
583        $node->removeAttribute( 'data-parsoid' );
584
585        $dataMwAttr = DOMCompat::getAttribute( $node, 'data-mw' );
586        $dmw = $dataMwAttr === null ? null :
587            $codec->newFromJsonString( $dataMwAttr, self::getCodecHints()['data-mw'] );
588        self::setDataMw( $node, $dmw );
589        $node->removeAttribute( 'data-mw' );
590
591        // We don't load rich attributes here: that will be done lazily as
592        // getAttributeObject()/etc methods are called because we don't
593        // know the true types of the rich values yet.  In the future
594        // we might have a schema or self-labelling of values which would
595        // allow us to load rich attributes here as well.
596    }
597
598    /**
599     * Builds an index of id attributes seen in the DOM
600     * @param Node $node
601     * @return array
602     */
603    public static function usedIdIndex( Node $node ): array {
604        $index = [];
605        DOMUtils::visitDOM( DOMCompat::getBody( $node->ownerDocument ),
606            static function ( Node $n, ?array $options = null ) use ( &$index ) {
607                if ( $n instanceof Element ) {
608                    $id = DOMCompat::getAttribute( $n, 'id' );
609                    if ( $id !== null ) {
610                        $index[$id] = true;
611                    }
612                }
613            },
614            []
615        );
616        return $index;
617    }
618
619    /**
620     * Walk DOM from node downward calling storeDataAttribs
621     *
622     * @param Node $node node
623     * @param array $options options
624     */
625    public static function visitAndStoreDataAttribs( Node $node, array $options = [] ): void {
626        // PORT-FIXME: storeDataAttribs calls storeInPageBundle which calls getElementById.
627        // PHP's `getElementById` implementation is broken, and we work around that by
628        // using Zest which uses XPath. So, getElementById call can be O(n) and calling it
629        // on on every element of the DOM via vistDOM here makes it O(n^2) instead of O(n).
630        // So, we work around that by building an index and avoiding getElementById entirely
631        // in storeInPageBundle.
632        if ( !empty( $options['storeInPageBundle'] ) ) {
633            $options['idIndex'] = self::usedIdIndex( $node );
634        }
635        DOMUtils::visitDOM( $node, [ self::class, 'storeDataAttribs' ], $options );
636    }
637
638    /**
639     * Copy data attributes from the bag to either JSON-encoded attributes on
640     * each node, or the page bundle, erasing the data-object-id attributes.
641     *
642     * @param Node $node node
643     * @param ?array $options options
644     *   - discardDataParsoid: Discard DataParsoid objects instead of storing them
645     *   - keepTmp: Preserve DataParsoid::$tmp
646     *   - storeInPageBundle: If set to a DomPageBundle, data will be stored
647     *     in the given page bundle instead of data-parsoid and data-mw.
648     *   - outputContentVersion: Version of output we're storing.  The page bundle
649     *     didn't have data-mw before 999.x
650     *   - idIndex: Array of used ID attributes
651     */
652    public static function storeDataAttribs( Node $node, ?array $options = null ): void {
653        $hints = self::getCodecHints();
654        $options ??= [];
655        if ( !( $node instanceof Element ) ) {
656            return;
657        }
658
659        // Store rich attributes.  Note that, at present, rich attributes may
660        // be serialized into the data-mw attributes which are serialized in
661        // the pagebundle; thus we need to serialize all the "attributes
662        // with special html semantics" (which will get added to data-mw)
663        // *before* we handle the other attributes and the page bundle.
664        self::storeRichAttributes( $node, [ 'onlySpecial' => true ] + $options );
665
666        Assert::invariant( empty( $options['discardDataParsoid'] ) || empty( $options['keepTmp'] ),
667            'Conflicting options: discardDataParsoid and keepTmp are both enabled.' );
668        $codec = self::getCodec( $node->ownerDocument );
669        $dp = self::getDataParsoid( $node );
670        $discardDataParsoid = !empty( $options['discardDataParsoid'] );
671        if ( $dp->getTempFlag( TempData::IS_NEW ) && !$dp->isModified() ) {
672            // This hack ensures that a loadDataAttribs + storeDataAttribs pair
673            // don't dirty the node by introducing an empty data-parsoid attribute
674            // where one didn't exist before.
675            //
676            // Ideally, we'll find a better solution for this edge case later.
677            $discardDataParsoid = true;
678        }
679        $data = null;
680        if ( !$discardDataParsoid ) {
681            if ( empty( $options['keepTmp'] ) ) {
682                // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
683                unset( $dp->tmp );
684            }
685
686            if ( !empty( $options['storeInPageBundle'] ) ) {
687                $data ??= new stdClass;
688                $data->parsoid = $dp;
689            } else {
690                $node->setAttribute(
691                    'data-parsoid',
692                    PHPUtils::jsonEncode(
693                        $codec->toJsonArray( $dp, $hints['data-parsoid'] )
694                    )
695                );
696            }
697        }
698
699        // Special handling for data-mw.  This should eventually go away
700        // and be replaced with the standard "rich attribute" handling:
701        // (a) now that DataMw is a class type, we should never actually
702        // have "invalid" data mw objects in practice;
703        // (b) eventually we can remove support for output content version
704        // older than 999.x.
705
706        // Strip invalid data-mw attributes
707        if ( self::validDataMw( $node ) ) {
708            if (
709                !empty( $options['storeInPageBundle'] ) &&
710                // The pagebundle didn't have data-mw before 999.x
711                Semver::satisfies( $options['outputContentVersion'] ?? '0.0.0', '^999.0.0' )
712            ) {
713                $data ??= new stdClass;
714                $data->mw = self::getDataMw( $node );
715            } else {
716                $node->setAttribute(
717                    'data-mw',
718                    PHPUtils::jsonEncode(
719                        $codec->toJsonArray( self::getDataMw( $node ), $hints['data-mw'] )
720                    )
721                );
722            }
723        }
724
725        // Serialize the rest of the rich attributes
726        // (This will eventually include data-mw.)
727        self::storeRichAttributes( $node, $options );
728
729        // Store pagebundle
730        if ( $data !== null ) {
731            self::storeInPageBundle( $options['storeInPageBundle'], $node, $data, $options['idIndex'] );
732        }
733
734        // Indicate that this node's data has been stored so that if we try
735        // to access it after the fact we're aware and remove the attribute
736        // since it's no longer needed.
737        $nd = self::getNodeData( $node );
738        $id = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
739        $nd->storedId = $id !== null ? intval( $id ) : null;
740        $node->removeAttribute( self::DATA_OBJECT_ATTR_NAME );
741    }
742
743    /**
744     * Clones a node and its data bag
745     * @param Element $elt
746     * @param bool $deep
747     * @return Element
748     */
749    public static function cloneNode( Element $elt, bool $deep ): Element {
750        $clone = $elt->cloneNode( $deep );
751        '@phan-var Element $clone'; // @var Element $clone
752        // We do not need to worry about $deep because a shallow clone does not have child nodes,
753        // so it's always cloning data on the cloned tree (which may be empty).
754        self::fixClonedData( $clone );
755        return $clone;
756    }
757
758    /**
759     * Clones a DocumentFragment and its associated data bags
760     */
761    public static function cloneDocumentFragment( DocumentFragment $df ): DocumentFragment {
762        $clone = $df->cloneNode( true );
763        '@phan-var DocumentFragment $clone'; // @var DocumentFragment $clone
764        foreach ( $clone->childNodes as $child ) {
765            if ( $child instanceof Element ) {
766                self::fixClonedData( $child );
767            }
768        }
769        return $clone;
770    }
771
772    /**
773     * Recursively fixes cloned data from $elt: to avoid conflicts of element IDs, we clone the
774     * data and set it in the node with a new element ID (which setNodeData does).
775     * @param Element $elt
776     */
777    private static function fixClonedData( Element $elt ): void {
778        if ( $elt->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
779            self::setNodeData( $elt, clone self::getNodeData( $elt ) );
780        }
781        foreach ( $elt->childNodes as $child ) {
782            if ( $child instanceof Element ) {
783                self::fixClonedData( $child );
784            }
785        }
786    }
787
788    // This is a generic (and somewhat optimistic) interface for
789    // complex-valued attributes in a DOM tree.  The object and DOM
790    // values are "live"; that is, they are passed by-reference and
791    // mutations to the object and DOM persist in the document.
792    // These values are only "frozen" into a standards-compliant
793    // HTML5 attribute representation when the document is serialized.
794    // (A corresponding 'parse' stage needs to occur on a new document
795    // to "thaw out" the HTML5 attribute representations.)
796
797    // Note that although we are expanding the possible attribute *values*
798    // we are still deliberately keeping attribute *names* restricted.
799    // This is a deliberate design choice.  Dynamically-generated
800    // attribute names are best handled by the "key value pair"
801    // fragment datatype, which is one of the fragment types from which
802    // the output document can be composed -- but that composition
803    // mechanism and the way the fragment composition is reflected in
804    // the DOM is out-of-scope for this API.  This just provides a
805    // richer way to embed complex information of that sort into a
806    // DOM document.
807
808    // An important design decision here was not to embed type information
809    // for attributes into the representation, which is done to avoid
810    // HTML bloat.  This leads directly to a "lazy load" implementation,
811    // as we can't actually load an attribute value until we know what
812    // its class type is, and that's only provided when the call to
813    // ::getAttributeObject() is made.  In order to implement an "eager
814    // load" implementation, we would need a schema for the document
815    // which maps every named attribute to an appropriate type.  This
816    // is possible if eager loading is desired in the future, or because
817    // you like the added structural documentation provided by a schema.
818
819    // Certain attributes have semantics given by HTML.  For example,
820    // the `class` and `alt` attributes shouldn't be serialized as a
821    // JSON blob, even if you want to store a rich value.  For these
822    // "HTML attributes with special semantics" (everything not
823    // starting with data-* at the moment) we tolerate a bit of bloat
824    // and store a flattened string representation of the rich value
825    // in the direct HTML attribute, and store the serialized rich
826    // value elsewhere. This value is used to provide the appropriate
827    // HTML semantics (ie, the browser will apply CSS styling to the
828    // flattened `class`, use the flattened `href` to navigate) but
829    // should not be used by clients /of the MediaWiki DOM spec/
830    // (including Parsoid), which should ignore the flattened value
831    // and consistently use the rich value in order to avoid
832    // losing/overwriting data.
833
834    // The JSON representation of a rich valued attribute can be
835    // customized using the mechanisms provided by the wikimedia/json-codec
836    // library; in particular you will want to use the "implicit typing"
837    // mechanism provided by the library to avoid bloating the output
838    // with explicit references to the PHP implementation classes.
839
840    // See
841    // https://www.mediawiki.org/wiki/Parsoid/MediaWiki_DOM_spec/Rich_Attributes
842    // for a more detailed discussion of this design.  The present
843    // implementation corresponds to "proposal 1a", the first step in
844    // the full proposal.
845
846    /**
847     * Determine whether the given attribute name has "special" HTML
848     * semantics.  For these attributes, a "stringified" flattened
849     * version of the attribute is stored in the attribute, for
850     * semantic compatibility with browsers etc, and the "rich" form
851     * of the attribute is stored in a separate attribute.
852     *
853     * Although in theory we could minimize this by looking at the
854     * names of attributes explicitly reserved for each tag name in
855     * the HTML spec, at this time we're going to be conservative and
856     * assume every attribute has "special" semantics that we should
857     * preserve except for those attributes whose names begin with
858     * `data-*`.
859     *
860     * In the future we might tweak the set of attributes with special
861     * semantics in order to reduce unnecessary bloat (ie storing
862     * flattened versions of attributes where the flattened value will
863     * never be used) and/or to include flattened values for certain
864     * data-* attributes (for example, if a gadget were to rely on a
865     * flattened value in `data-time`).
866     *
867     * @param string $tagName The tag name of the Element containing the
868     *   attribute
869     * @param string $attrName The name of the attribute
870     * @return bool True if the named attribute has special HTML semantics
871     */
872    private static function isHtmlAttributeWithSpecialSemantics( string $tagName, string $attrName ): bool {
873        return !(bool)preg_match( '/^data-/i', $attrName );
874    }
875
876    /**
877     * Return the value of a rich attribute as a live (by-reference) object.
878     * This also serves as an assertion that there are not conflicting types.
879     *
880     * @phan-template T
881     * @param Element $node The node on which the attribute is to be found.
882     * @param string $name The name of the attribute.
883     * @param class-string<T>|Hint<T> $classHint
884     * @return ?T The attribute value, or null if not present.
885     */
886    public static function getAttributeObject(
887        Element $node, string $name, $classHint
888    ): ?object {
889        self::loadRichAttributes( $node, $name ); // lazy load
890        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
891            // Don't create an empty node data object if we don't need to.
892            return null;
893        }
894        $nodeData = self::getNodeData( $node );
895        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
896        $value = $nodeData->$propName ?? null;
897        // We lazily decode rich values, because we need to know the $classHint
898        // before we decode.  Undecoded values are wrapped with an array so
899        // we can tell whether the value has been decoded already or not.
900        if ( is_array( $value ) ) {
901            // This value should be decoded
902            $codec = self::getCodec( $node->ownerDocument );
903            $value = $codec->newFromJsonArray( $value[0], $classHint );
904            if ( is_array( $value ) ) {
905                // JsonCodec allows class hints to indicate that the value
906                // is an array of some object type, but for our purposes
907                // the result must always be an object so that it is live.
908                $value = (object)$value;
909            }
910            // To signal that it's been decoded already we need $value
911            // not to be an array
912            Assert::invariant(
913                !is_array( $value ), "rich attribute can't be array"
914            );
915            $nodeData->$propName = $value;
916            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
917            $nodeData->$hintName = $classHint;
918        }
919        return $value;
920    }
921
922    /**
923     * Return the value of a rich attribute as a live (by-reference)
924     * object.  This also serves as an assertion that there are not
925     * conflicting types.  If the value is not present and the class
926     * hint is a RichCodecable, a default value will be created using
927     * `$className::defaultValue()` and stored as the value of the
928     * attribute.
929     *
930     * @note The $className should have be JsonCodecable (either directly
931     *  or via a custom JsonClassCodec).
932     *
933     * @phan-template T
934     * @param Element $node The node on which the attribute is to be found.
935     * @param string $name The name of the attribute.
936     * @param class-string<T>|Hint<T> $classHint
937     * @return ?T The attribute value, or null if not present.
938     */
939    public static function getAttributeObjectDefault(
940        Element $node, string $name, $classHint
941    ): ?object {
942        $value = self::getAttributeObject( $node, $name, $classHint );
943        if ( $value === null ) {
944            $className = $classHint;
945            while ( $className instanceof Hint ) {
946                Assert::invariant(
947                    $className->modifier !== Hint::LIST &&
948                    $className->modifier !== Hint::STDCLASS,
949                    "Can't create default value for list or object"
950                );
951                $className = $className->parent;
952            }
953            '@phan-var string $className';
954            if ( is_a( $className, RichCodecable::class, true ) ) {
955                $value = $className::defaultValue();
956            }
957            $value ??= new $className;
958            self::setAttributeObject( $node, $name, $value, $classHint );
959        }
960        return $value;
961    }
962
963    /**
964     * Set the value of a rich attribute, overwriting any previous
965     * value.  Generally mutating the result returned by the
966     * `::getAttribute*Default()` methods should be done instead of
967     * using this method, since the objects returned are live.
968     *
969     * @note For attribute names where
970     *  `::isHtmlAttributeWithSpecialSemantics()` returns `true` you
971     *  can customize the "flattened" representation used for HTML
972     *  semantics by having the value implement `RichCodecable::flatten()`.
973     *
974     * @phan-template T
975     * @param Element $node The node on which the attribute is to be found.
976     * @param string $name The name of the attribute.
977     * @phan-suppress-next-line PhanTypeMismatchDeclaredParam
978     * @param T $value The new (object) value for the attribute
979     * @param class-string<T>|Hint<T>|null $classHint Optional serialization hint
980     * @phpcs:ignore MediaWiki.Commenting.FunctionAnnotations.UnrecognizedAnnotation
981     * @phan-suppress-next-next-line PhanTemplateTypeNotUsedInFunctionReturn
982     */
983    public static function setAttributeObject(
984        Element $node, string $name, object $value, $classHint = null
985    ): void {
986        // Remove attribute from DOM; will be rewritten from node data during
987        // serialization.
988        self::removeAttributeObject( $node, $name );
989        $nodeData = self::getNodeData( $node );
990        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
991        $nodeData->$propName = $value;
992        if ( $classHint === null && is_a( $value, RichCodecable::class ) ) {
993            $className = get_class( $value );
994            $classHint = $className::hint();
995        }
996        $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
997        $nodeData->$hintName = $classHint;
998    }
999
1000    /**
1001     * Remove a rich attribute.
1002     *
1003     * @param Element $node The node on which the attribute is to be found.
1004     * @param string $name The name of the attribute.
1005     */
1006    public static function removeAttributeObject(
1007        Element $node, string $name
1008    ): void {
1009        $node->removeAttribute( $name );
1010        self::removeFromExpandedAttrs( $node, $name );
1011        if ( $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1012            $nodeData = self::getNodeData( $node );
1013            $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1014            unset( $nodeData->$propName );
1015            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1016            unset( $nodeData->$hintName );
1017        }
1018    }
1019
1020    /**
1021     * Helper function for code clarity: test whether there is
1022     * an existing data-mw value on a node which has already had
1023     * loadDataAttribs called on it.
1024     */
1025    private static function nodeHasDataMw( Element $node ): bool {
1026        // If data-mw were present, loadDataAttribs would have created
1027        // the DATA_OBJECT_ATTR_NAME attribute for associated NodeData
1028        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1029            return false;
1030        }
1031        $data = self::getNodeData( $node );
1032        return $data->mw !== null;
1033    }
1034
1035    /**
1036     * Helper function to remove any entries from data-mw.attribs which match
1037     * this attribute name.  They will be rewritten during rich attribute
1038     * serialization if necessary.
1039     * @param Element $node
1040     * @param string $name
1041     */
1042    private static function removeFromExpandedAttrs(
1043        Element $node, string $name
1044    ): void {
1045        // Don't create a new data-mw yet if we don't need one.
1046        if ( !self::nodehasDataMw( $node ) ) {
1047            return;
1048        }
1049        if ( !self::isHtmlAttributeWithSpecialSemantics( $node->tagName, $name ) ) {
1050            return;
1051        }
1052        // If there was a data-mw.attribs for this attribute, remove it
1053        // (it will be rewritten during serialization later)
1054        $dataMw = self::getDataMw( $node );
1055        $dataMw->attribs = array_values( array_filter(
1056            $dataMw->attribs ?? [],
1057            static function ( $a ) use ( $name ) {
1058                if ( !( $a instanceof DataMwAttrib ) ) {
1059                    return true;
1060                }
1061                $key = $a->key;
1062                if ( $key === $name ) {
1063                    return false; // Remove this entry
1064                }
1065                if ( is_array( $key ) && ( $key['txt'] ?? null ) == $name ) {
1066                    return false; // Remove this entry
1067                }
1068                return true;
1069            }
1070        ) );
1071        if ( count( $dataMw->attribs ) === 0 ) {
1072            unset( $dataMw->attribs );
1073            DOMUtils::removeTypeOf( $node, 'mw:ExpandedAttrs' );
1074        }
1075    }
1076
1077    // Serialization/deserialization support for rich attributes.
1078
1079    // There are many possible serializations which could be used.
1080    // For the moment we've chosen the simplest possible one, which
1081    // embeds big JSON blobs in attribute values.  For "attributes
1082    // with special HTML semantics" the JSON blobs are stored in
1083    // data-mw.attribs and the straight HTML attribute value is a
1084    // flattened form of the true value.
1085
1086    /**
1087     * Internal function to lazy-load rich attribute data from the HTML
1088     * DOM representation.
1089     * @param Element $node The node possibly containing the rich attribute
1090     * @param string $name The attribute name we are going to load values for
1091     */
1092    private static function loadRichAttributes(
1093        Element $node, string $name
1094    ): void {
1095        // Because we don't have a complete schema for the document which
1096        // identifies which attributes are 'rich' and which are not, we
1097        // lazily-load attributes one-by-one once we know their names and types
1098        // instead of trying to preload them in bulk.
1099
1100        // *However* in order to avoid O(N^2) manipulation of the
1101        // data-mw.attribs list, we do move all the values from data-mw.attribs
1102        // into NodeData, even those not matching our given name.  We can't
1103        // decode those yet: they will be decoded once getAttributeObject()
1104        // is called on them to provide the proper type hint (or else they
1105        // will eventually be reserialized in their undecoded form).
1106
1107        $flatValue = DOMCompat::getAttribute( $node, $name );
1108        if ( $flatValue === null ) {
1109            // Use the presence of the attribute in the DOM to indicate
1110            // whether this attribute has been loaded; this avoids (for
1111            // example) traversing AttributeExpander entries in
1112            // data-mw.attribs multiple times looking for the name of a
1113            // rich attribute.  If the attribute is not in the DOM either
1114            // there is no attribute of this name or it has already been
1115            // loaded.
1116            return;
1117        }
1118
1119        if ( self::isHtmlAttributeWithSpecialSemantics( $node->tagName, $name ) ) {
1120            // Look aside at data-mw for attributes with special semantics
1121            if ( !self::nodeHasDataMw( $node ) ) {
1122                // No data-mw, so no rich value for this attribute
1123                return;
1124            }
1125            $dataMw = self::getDataMw( $node );
1126            // Load all attribute values from $dataMw->attribs to avoid O(N^2)
1127            // loading of list
1128            if ( $dataMw->attribs ?? false ) {
1129                $unused = [];
1130                foreach ( $dataMw->attribs as $a ) {
1131                    if ( $a instanceof DataMwAttrib ) {
1132                        $key = $a->key;
1133                        $value = $a->value;
1134                        // Attribute expander may use array values for
1135                        // key, since it supports rich key values.
1136                        // Ignore any entries created this way, since
1137                        // we can't preserve their values: they will be
1138                        // added to $unused and replaced.
1139                        if ( is_string( $key ) || is_numeric( $key ) ) {
1140                            $propName = self::RICH_ATTR_DATA_PREFIX . $key;
1141                            $nodeData = self::getNodeData( $node );
1142                            // wrap $value with an array to indicate that
1143                            // is it not yet decoded. Preserve the flattened
1144                            // value as well in case we round-trip without
1145                            // modifying this value.
1146                            $nodeData->$propName = [ $value, $flatValue ];
1147                            // Signal that the value has been moved to NodeData
1148                            // (this will also short cut this iteration over
1149                            // data-mw.attribs in future calls)
1150                            $node->removeAttribute( $key );
1151                            continue;
1152                        }
1153                    }
1154                    $unused[] = $a;
1155                }
1156                if ( count( $unused ) === 0 ) {
1157                    unset( $dataMw->attribs );
1158                } else {
1159                    $dataMw->attribs = $unused;
1160                }
1161            }
1162            return;
1163        }
1164        // The attribute does not have "special HTML semantics"
1165        $decoded = json_decode( $flatValue, false );
1166        // $decoded is the 'non-string' form of the value; we can't finish
1167        // deserializing it into an object until we know the appropriate type
1168        // hint.
1169        self::removeAttributeObject( $node, $name );
1170        $nodeData = self::getNodeData( $node );
1171        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1172        // Mark this as undecoded by wrapping it as an array,
1173        // since decoded values will always be objects.
1174        // (Attribute values without "special HTML semantics" do not
1175        // have flattened versions, so 2nd element to this array isn't
1176        // needed.)
1177        $nodeData->$propName = [ $decoded ];
1178    }
1179
1180    /**
1181     * Internal function to encode rich attribute data into an HTML
1182     * DOM representation.
1183     * @param Element $node The node possibly containing the rich attribute
1184     * @param array $options The options provided to ::storeDataAttribs()
1185     */
1186    private static function storeRichAttributes( Element $node, array $options ): void {
1187        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1188            return; // No rich attributes here
1189        }
1190        $tagName = $node->tagName;
1191        $nodeData = self::getNodeData( $node );
1192        $codec = self::getCodec( $node->ownerDocument );
1193        foreach ( get_object_vars( $nodeData ) as $k => $v ) {
1194            // Look for dynamic properties with names w/ the proper prefix
1195            if ( str_starts_with( $k, self::RICH_ATTR_DATA_PREFIX ) ) {
1196                $attrName = substr( $k, strlen( self::RICH_ATTR_DATA_PREFIX ) );
1197                if (
1198                    ( $options['onlySpecial'] ?? false ) &&
1199                    !self::isHtmlAttributeWithSpecialSemantics( $tagName, $attrName )
1200                ) {
1201                    continue; // skip this for now
1202                }
1203                $flat = null;
1204                if ( is_array( $v ) ) {
1205                    // If $v is an array, it was never decoded.
1206                    $json = $v[0];
1207                    $flat = $v[1] ?? null;
1208                } else {
1209                    $hintName = self::RICH_ATTR_HINT_PREFIX . $attrName;
1210                    $classHint = $nodeData->$hintName ?? null;
1211                    if ( is_a( $v, RichCodecable::class ) ) {
1212                        $classHint ??= $v::hint();
1213                        $flat = $v->flatten();
1214                    }
1215                    $classHint ??= get_class( $v );
1216                    try {
1217                        $json = $codec->toJsonArray( $v, $classHint );
1218                    } catch ( InvalidArgumentException $e ) {
1219                        // For better debuggability, include the attribute name
1220                        throw new InvalidArgumentException( "$attrName" . $e->getMessage() );
1221                    }
1222                }
1223                if ( !self::isHtmlAttributeWithSpecialSemantics( $tagName, $attrName ) ) {
1224                    $encoded = PHPUtils::jsonEncode( $json );
1225                    $node->setAttribute( $attrName, $encoded );
1226                } else {
1227                    // For compatibility, store the rich value in data-mw.attrs
1228                    // and store a flattened version in the $attrName.
1229                    if ( $flat !== null ) {
1230                        $node->setAttribute( $attrName, $flat );
1231                    } else {
1232                        $node->removeAttribute( $attrName );
1233                    }
1234                    $dataMw = self::getDataMw( $node );
1235                    $dataMw->attribs[] = new DataMwAttrib( $attrName, $json );
1236                    DOMUtils::addTypeOf( $node, 'mw:ExpandedAttrs' );
1237                }
1238                unset( $nodeData->$k );
1239            }
1240        }
1241    }
1242}