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
11516.06
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
156
 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 ) ) {
672            // Only necessary to support the cite extension's getById,
673            // that's already been loaded once.
674            //
675            // This is basically a hack to ensure that DOMUtils.isNewElt
676            // continues to work since we effectively rely on the absence
677            // of data-parsoid to identify new elements. But, loadDataAttribs
678            // creates an empty {} if one doesn't exist. So, this hack
679            // ensures that a loadDataAttribs + storeDataAttribs pair don't
680            // dirty the node by introducing an empty data-parsoid attribute
681            // where one didn't exist before.
682            //
683            // Ideally, we'll find a better solution for this edge case later.
684            $discardDataParsoid = true;
685        }
686        $data = null;
687        if ( !$discardDataParsoid ) {
688            if ( empty( $options['keepTmp'] ) ) {
689                // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
690                unset( $dp->tmp );
691            }
692
693            if ( !empty( $options['storeInPageBundle'] ) ) {
694                $data ??= new stdClass;
695                $data->parsoid = $dp;
696            } else {
697                $node->setAttribute(
698                    'data-parsoid',
699                    PHPUtils::jsonEncode(
700                        $codec->toJsonArray( $dp, $hints['data-parsoid'] )
701                    )
702                );
703            }
704        }
705
706        // Special handling for data-mw.  This should eventually go away
707        // and be replaced with the standard "rich attribute" handling:
708        // (a) now that DataMw is a class type, we should never actually
709        // have "invalid" data mw objects in practice;
710        // (b) eventually we can remove support for output content version
711        // older than 999.x.
712
713        // Strip invalid data-mw attributes
714        if ( self::validDataMw( $node ) ) {
715            if (
716                !empty( $options['storeInPageBundle'] ) &&
717                // The pagebundle didn't have data-mw before 999.x
718                Semver::satisfies( $options['outputContentVersion'] ?? '0.0.0', '^999.0.0' )
719            ) {
720                $data ??= new stdClass;
721                $data->mw = self::getDataMw( $node );
722            } else {
723                $node->setAttribute(
724                    'data-mw',
725                    PHPUtils::jsonEncode(
726                        $codec->toJsonArray( self::getDataMw( $node ), $hints['data-mw'] )
727                    )
728                );
729            }
730        }
731
732        // Serialize the rest of the rich attributes
733        // (This will eventually include data-mw.)
734        self::storeRichAttributes( $node, $options );
735
736        // Store pagebundle
737        if ( $data !== null ) {
738            self::storeInPageBundle( $options['storeInPageBundle'], $node, $data, $options['idIndex'] );
739        }
740
741        // Indicate that this node's data has been stored so that if we try
742        // to access it after the fact we're aware and remove the attribute
743        // since it's no longer needed.
744        $nd = self::getNodeData( $node );
745        $id = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
746        $nd->storedId = $id !== null ? intval( $id ) : null;
747        $node->removeAttribute( self::DATA_OBJECT_ATTR_NAME );
748    }
749
750    /**
751     * Clones a node and its data bag
752     * @param Element $elt
753     * @param bool $deep
754     * @return Element
755     */
756    public static function cloneNode( Element $elt, bool $deep ): Element {
757        $clone = $elt->cloneNode( $deep );
758        '@phan-var Element $clone'; // @var Element $clone
759        // We do not need to worry about $deep because a shallow clone does not have child nodes,
760        // so it's always cloning data on the cloned tree (which may be empty).
761        self::fixClonedData( $clone );
762        return $clone;
763    }
764
765    /**
766     * Clones a DocumentFragment and its associated data bags
767     */
768    public static function cloneDocumentFragment( DocumentFragment $df ): DocumentFragment {
769        $clone = $df->cloneNode( true );
770        '@phan-var DocumentFragment $clone'; // @var DocumentFragment $clone
771        foreach ( $clone->childNodes as $child ) {
772            if ( $child instanceof Element ) {
773                self::fixClonedData( $child );
774            }
775        }
776        return $clone;
777    }
778
779    /**
780     * Recursively fixes cloned data from $elt: to avoid conflicts of element IDs, we clone the
781     * data and set it in the node with a new element ID (which setNodeData does).
782     * @param Element $elt
783     */
784    private static function fixClonedData( Element $elt ): void {
785        if ( $elt->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
786            self::setNodeData( $elt, clone self::getNodeData( $elt ) );
787        }
788        foreach ( $elt->childNodes as $child ) {
789            if ( $child instanceof Element ) {
790                self::fixClonedData( $child );
791            }
792        }
793    }
794
795    // This is a generic (and somewhat optimistic) interface for
796    // complex-valued attributes in a DOM tree.  The object and DOM
797    // values are "live"; that is, they are passed by-reference and
798    // mutations to the object and DOM persist in the document.
799    // These values are only "frozen" into a standards-compliant
800    // HTML5 attribute representation when the document is serialized.
801    // (A corresponding 'parse' stage needs to occur on a new document
802    // to "thaw out" the HTML5 attribute representations.)
803
804    // Note that although we are expanding the possible attribute *values*
805    // we are still deliberately keeping attribute *names* restricted.
806    // This is a deliberate design choice.  Dynamically-generated
807    // attribute names are best handled by the "key value pair"
808    // fragment datatype, which is one of the fragment types from which
809    // the output document can be composed -- but that composition
810    // mechanism and the way the fragment composition is reflected in
811    // the DOM is out-of-scope for this API.  This just provides a
812    // richer way to embed complex information of that sort into a
813    // DOM document.
814
815    // An important design decision here was not to embed type information
816    // for attributes into the representation, which is done to avoid
817    // HTML bloat.  This leads directly to a "lazy load" implementation,
818    // as we can't actually load an attribute value until we know what
819    // its class type is, and that's only provided when the call to
820    // ::getAttributeObject() is made.  In order to implement an "eager
821    // load" implementation, we would need a schema for the document
822    // which maps every named attribute to an appropriate type.  This
823    // is possible if eager loading is desired in the future, or because
824    // you like the added structural documentation provided by a schema.
825
826    // Certain attributes have semantics given by HTML.  For example,
827    // the `class` and `alt` attributes shouldn't be serialized as a
828    // JSON blob, even if you want to store a rich value.  For these
829    // "HTML attributes with special semantics" (everything not
830    // starting with data-* at the moment) we tolerate a bit of bloat
831    // and store a flattened string representation of the rich value
832    // in the direct HTML attribute, and store the serialized rich
833    // value elsewhere. This value is used to provide the appropriate
834    // HTML semantics (ie, the browser will apply CSS styling to the
835    // flattened `class`, use the flattened `href` to navigate) but
836    // should not be used by clients /of the MediaWiki DOM spec/
837    // (including Parsoid), which should ignore the flattened value
838    // and consistently use the rich value in order to avoid
839    // losing/overwriting data.
840
841    // The JSON representation of a rich valued attribute can be
842    // customized using the mechanisms provided by the wikimedia/json-codec
843    // library; in particular you will want to use the "implicit typing"
844    // mechanism provided by the library to avoid bloating the output
845    // with explicit references to the PHP implementation classes.
846
847    // See
848    // https://www.mediawiki.org/wiki/Parsoid/MediaWiki_DOM_spec/Rich_Attributes
849    // for a more detailed discussion of this design.  The present
850    // implementation corresponds to "proposal 1a", the first step in
851    // the full proposal.
852
853    /**
854     * Determine whether the given attribute name has "special" HTML
855     * semantics.  For these attributes, a "stringified" flattened
856     * version of the attribute is stored in the attribute, for
857     * semantic compatibility with browsers etc, and the "rich" form
858     * of the attribute is stored in a separate attribute.
859     *
860     * Although in theory we could minimize this by looking at the
861     * names of attributes explicitly reserved for each tag name in
862     * the HTML spec, at this time we're going to be conservative and
863     * assume every attribute has "special" semantics that we should
864     * preserve except for those attributes whose names begin with
865     * `data-*`.
866     *
867     * In the future we might tweak the set of attributes with special
868     * semantics in order to reduce unnecessary bloat (ie storing
869     * flattened versions of attributes where the flattened value will
870     * never be used) and/or to include flattened values for certain
871     * data-* attributes (for example, if a gadget were to rely on a
872     * flattened value in `data-time`).
873     *
874     * @param string $tagName The tag name of the Element containing the
875     *   attribute
876     * @param string $attrName The name of the attribute
877     * @return bool True if the named attribute has special HTML semantics
878     */
879    private static function isHtmlAttributeWithSpecialSemantics( string $tagName, string $attrName ): bool {
880        return !(bool)preg_match( '/^data-/i', $attrName );
881    }
882
883    /**
884     * Return the value of a rich attribute as a live (by-reference) object.
885     * This also serves as an assertion that there are not conflicting types.
886     *
887     * @phan-template T
888     * @param Element $node The node on which the attribute is to be found.
889     * @param string $name The name of the attribute.
890     * @param class-string<T>|Hint<T> $classHint
891     * @return ?T The attribute value, or null if not present.
892     */
893    public static function getAttributeObject(
894        Element $node, string $name, $classHint
895    ): ?object {
896        self::loadRichAttributes( $node, $name ); // lazy load
897        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
898            // Don't create an empty node data object if we don't need to.
899            return null;
900        }
901        $nodeData = self::getNodeData( $node );
902        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
903        $value = $nodeData->$propName ?? null;
904        // We lazily decode rich values, because we need to know the $classHint
905        // before we decode.  Undecoded values are wrapped with an array so
906        // we can tell whether the value has been decoded already or not.
907        if ( is_array( $value ) ) {
908            // This value should be decoded
909            $codec = self::getCodec( $node->ownerDocument );
910            $value = $codec->newFromJsonArray( $value[0], $classHint );
911            if ( is_array( $value ) ) {
912                // JsonCodec allows class hints to indicate that the value
913                // is an array of some object type, but for our purposes
914                // the result must always be an object so that it is live.
915                $value = (object)$value;
916            }
917            // To signal that it's been decoded already we need $value
918            // not to be an array
919            Assert::invariant(
920                !is_array( $value ), "rich attribute can't be array"
921            );
922            $nodeData->$propName = $value;
923            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
924            $nodeData->$hintName = $classHint;
925        }
926        return $value;
927    }
928
929    /**
930     * Return the value of a rich attribute as a live (by-reference)
931     * object.  This also serves as an assertion that there are not
932     * conflicting types.  If the value is not present and the class
933     * hint is a RichCodecable, a default value will be created using
934     * `$className::defaultValue()` and stored as the value of the
935     * attribute.
936     *
937     * @note The $className should have be JsonCodecable (either directly
938     *  or via a custom JsonClassCodec).
939     *
940     * @phan-template T
941     * @param Element $node The node on which the attribute is to be found.
942     * @param string $name The name of the attribute.
943     * @param class-string<T>|Hint<T> $classHint
944     * @return ?T The attribute value, or null if not present.
945     */
946    public static function getAttributeObjectDefault(
947        Element $node, string $name, $classHint
948    ): ?object {
949        $value = self::getAttributeObject( $node, $name, $classHint );
950        if ( $value === null ) {
951            $className = $classHint;
952            while ( $className instanceof Hint ) {
953                Assert::invariant(
954                    $className->modifier !== Hint::LIST &&
955                    $className->modifier !== Hint::STDCLASS,
956                    "Can't create default value for list or object"
957                );
958                $className = $className->parent;
959            }
960            '@phan-var string $className';
961            if ( is_a( $className, RichCodecable::class, true ) ) {
962                $value = $className::defaultValue();
963            }
964            $value ??= new $className;
965            self::setAttributeObject( $node, $name, $value, $classHint );
966        }
967        return $value;
968    }
969
970    /**
971     * Set the value of a rich attribute, overwriting any previous
972     * value.  Generally mutating the result returned by the
973     * `::getAttribute*Default()` methods should be done instead of
974     * using this method, since the objects returned are live.
975     *
976     * @note For attribute names where
977     *  `::isHtmlAttributeWithSpecialSemantics()` returns `true` you
978     *  can customize the "flattened" representation used for HTML
979     *  semantics by having the value implement `RichCodecable::flatten()`.
980     *
981     * @phan-template T
982     * @param Element $node The node on which the attribute is to be found.
983     * @param string $name The name of the attribute.
984     * @phan-suppress-next-line PhanTypeMismatchDeclaredParam
985     * @param T $value The new (object) value for the attribute
986     * @param class-string<T>|Hint<T>|null $classHint Optional serialization hint
987     * @phpcs:ignore MediaWiki.Commenting.FunctionAnnotations.UnrecognizedAnnotation
988     * @phan-suppress-next-next-line PhanTemplateTypeNotUsedInFunctionReturn
989     */
990    public static function setAttributeObject(
991        Element $node, string $name, object $value, $classHint = null
992    ): void {
993        // Remove attribute from DOM; will be rewritten from node data during
994        // serialization.
995        self::removeAttributeObject( $node, $name );
996        $nodeData = self::getNodeData( $node );
997        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
998        $nodeData->$propName = $value;
999        if ( $classHint === null && is_a( $value, RichCodecable::class ) ) {
1000            $className = get_class( $value );
1001            $classHint = $className::hint();
1002        }
1003        $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1004        $nodeData->$hintName = $classHint;
1005    }
1006
1007    /**
1008     * Remove a rich attribute.
1009     *
1010     * @param Element $node The node on which the attribute is to be found.
1011     * @param string $name The name of the attribute.
1012     */
1013    public static function removeAttributeObject(
1014        Element $node, string $name
1015    ): void {
1016        $node->removeAttribute( $name );
1017        self::removeFromExpandedAttrs( $node, $name );
1018        if ( $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1019            $nodeData = self::getNodeData( $node );
1020            $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1021            unset( $nodeData->$propName );
1022            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1023            unset( $nodeData->$hintName );
1024        }
1025    }
1026
1027    /**
1028     * Helper function for code clarity: test whether there is
1029     * an existing data-mw value on a node which has already had
1030     * loadDataAttribs called on it.
1031     */
1032    private static function nodeHasDataMw( Element $node ): bool {
1033        // If data-mw were present, loadDataAttribs would have created
1034        // the DATA_OBJECT_ATTR_NAME attribute for associated NodeData
1035        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1036            return false;
1037        }
1038        $data = self::getNodeData( $node );
1039        return $data->mw !== null;
1040    }
1041
1042    /**
1043     * Helper function to remove any entries from data-mw.attribs which match
1044     * this attribute name.  They will be rewritten during rich attribute
1045     * serialization if necessary.
1046     * @param Element $node
1047     * @param string $name
1048     */
1049    private static function removeFromExpandedAttrs(
1050        Element $node, string $name
1051    ): void {
1052        // Don't create a new data-mw yet if we don't need one.
1053        if ( !self::nodehasDataMw( $node ) ) {
1054            return;
1055        }
1056        if ( !self::isHtmlAttributeWithSpecialSemantics( $node->tagName, $name ) ) {
1057            return;
1058        }
1059        // If there was a data-mw.attribs for this attribute, remove it
1060        // (it will be rewritten during serialization later)
1061        $dataMw = self::getDataMw( $node );
1062        $dataMw->attribs = array_values( array_filter(
1063            $dataMw->attribs ?? [],
1064            static function ( $a ) use ( $name ) {
1065                if ( !( $a instanceof DataMwAttrib ) ) {
1066                    return true;
1067                }
1068                $key = $a->key;
1069                if ( $key === $name ) {
1070                    return false; // Remove this entry
1071                }
1072                if ( is_array( $key ) && ( $key['txt'] ?? null ) == $name ) {
1073                    return false; // Remove this entry
1074                }
1075                return true;
1076            }
1077        ) );
1078        if ( count( $dataMw->attribs ) === 0 ) {
1079            unset( $dataMw->attribs );
1080            DOMUtils::removeTypeOf( $node, 'mw:ExpandedAttrs' );
1081        }
1082    }
1083
1084    // Serialization/deserialization support for rich attributes.
1085
1086    // There are many possible serializations which could be used.
1087    // For the moment we've chosen the simplest possible one, which
1088    // embeds big JSON blobs in attribute values.  For "attributes
1089    // with special HTML semantics" the JSON blobs are stored in
1090    // data-mw.attribs and the straight HTML attribute value is a
1091    // flattened form of the true value.
1092
1093    /**
1094     * Internal function to lazy-load rich attribute data from the HTML
1095     * DOM representation.
1096     * @param Element $node The node possibly containing the rich attribute
1097     * @param string $name The attribute name we are going to load values for
1098     */
1099    private static function loadRichAttributes(
1100        Element $node, string $name
1101    ): void {
1102        // Because we don't have a complete schema for the document which
1103        // identifies which attributes are 'rich' and which are not, we
1104        // lazily-load attributes one-by-one once we know their names and types
1105        // instead of trying to preload them in bulk.
1106
1107        // *However* in order to avoid O(N^2) manipulation of the
1108        // data-mw.attribs list, we do move all the values from data-mw.attribs
1109        // into NodeData, even those not matching our given name.  We can't
1110        // decode those yet: they will be decoded once getAttributeObject()
1111        // is called on them to provide the proper type hint (or else they
1112        // will eventually be reserialized in their undecoded form).
1113
1114        $flatValue = DOMCompat::getAttribute( $node, $name );
1115        if ( $flatValue === null ) {
1116            // Use the presence of the attribute in the DOM to indicate
1117            // whether this attribute has been loaded; this avoids (for
1118            // example) traversing AttributeExpander entries in
1119            // data-mw.attribs multiple times looking for the name of a
1120            // rich attribute.  If the attribute is not in the DOM either
1121            // there is no attribute of this name or it has already been
1122            // loaded.
1123            return;
1124        }
1125
1126        if ( self::isHtmlAttributeWithSpecialSemantics( $node->tagName, $name ) ) {
1127            // Look aside at data-mw for attributes with special semantics
1128            if ( !self::nodeHasDataMw( $node ) ) {
1129                // No data-mw, so no rich value for this attribute
1130                return;
1131            }
1132            $dataMw = self::getDataMw( $node );
1133            // Load all attribute values from $dataMw->attribs to avoid O(N^2)
1134            // loading of list
1135            if ( $dataMw->attribs ?? false ) {
1136                $unused = [];
1137                foreach ( $dataMw->attribs as $a ) {
1138                    if ( $a instanceof DataMwAttrib ) {
1139                        $key = $a->key;
1140                        $value = $a->value;
1141                        // Attribute expander may use array values for
1142                        // key, since it supports rich key values.
1143                        // Ignore any entries created this way, since
1144                        // we can't preserve their values: they will be
1145                        // added to $unused and replaced.
1146                        if ( is_string( $key ) || is_numeric( $key ) ) {
1147                            $propName = self::RICH_ATTR_DATA_PREFIX . $key;
1148                            $nodeData = self::getNodeData( $node );
1149                            // wrap $value with an array to indicate that
1150                            // is it not yet decoded. Preserve the flattened
1151                            // value as well in case we round-trip without
1152                            // modifying this value.
1153                            $nodeData->$propName = [ $value, $flatValue ];
1154                            // Signal that the value has been moved to NodeData
1155                            // (this will also short cut this iteration over
1156                            // data-mw.attribs in future calls)
1157                            $node->removeAttribute( $key );
1158                            continue;
1159                        }
1160                    }
1161                    $unused[] = $a;
1162                }
1163                if ( count( $unused ) === 0 ) {
1164                    unset( $dataMw->attribs );
1165                } else {
1166                    $dataMw->attribs = $unused;
1167                }
1168            }
1169            return;
1170        }
1171        // The attribute does not have "special HTML semantics"
1172        $decoded = json_decode( $flatValue, false );
1173        // $decoded is the 'non-string' form of the value; we can't finish
1174        // deserializing it into an object until we know the appropriate type
1175        // hint.
1176        self::removeAttributeObject( $node, $name );
1177        $nodeData = self::getNodeData( $node );
1178        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1179        // Mark this as undecoded by wrapping it as an array,
1180        // since decoded values will always be objects.
1181        // (Attribute values without "special HTML semantics" do not
1182        // have flattened versions, so 2nd element to this array isn't
1183        // needed.)
1184        $nodeData->$propName = [ $decoded ];
1185    }
1186
1187    /**
1188     * Internal function to encode rich attribute data into an HTML
1189     * DOM representation.
1190     * @param Element $node The node possibly containing the rich attribute
1191     * @param array $options The options provided to ::storeDataAttribs()
1192     */
1193    private static function storeRichAttributes( Element $node, array $options ): void {
1194        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1195            return; // No rich attributes here
1196        }
1197        $tagName = $node->tagName;
1198        $nodeData = self::getNodeData( $node );
1199        $codec = self::getCodec( $node->ownerDocument );
1200        foreach ( get_object_vars( $nodeData ) as $k => $v ) {
1201            // Look for dynamic properties with names w/ the proper prefix
1202            if ( str_starts_with( $k, self::RICH_ATTR_DATA_PREFIX ) ) {
1203                $attrName = substr( $k, strlen( self::RICH_ATTR_DATA_PREFIX ) );
1204                if (
1205                    ( $options['onlySpecial'] ?? false ) &&
1206                    !self::isHtmlAttributeWithSpecialSemantics( $tagName, $attrName )
1207                ) {
1208                    continue; // skip this for now
1209                }
1210                $flat = null;
1211                if ( is_array( $v ) ) {
1212                    // If $v is an array, it was never decoded.
1213                    $json = $v[0];
1214                    $flat = $v[1] ?? null;
1215                } else {
1216                    $hintName = self::RICH_ATTR_HINT_PREFIX . $attrName;
1217                    $classHint = $nodeData->$hintName ?? null;
1218                    if ( is_a( $v, RichCodecable::class ) ) {
1219                        $classHint ??= $v::hint();
1220                        $flat = $v->flatten();
1221                    }
1222                    $classHint ??= get_class( $v );
1223                    try {
1224                        $json = $codec->toJsonArray( $v, $classHint );
1225                    } catch ( InvalidArgumentException $e ) {
1226                        // For better debuggability, include the attribute name
1227                        throw new InvalidArgumentException( "$attrName" . $e->getMessage() );
1228                    }
1229                }
1230                if ( !self::isHtmlAttributeWithSpecialSemantics( $tagName, $attrName ) ) {
1231                    $encoded = PHPUtils::jsonEncode( $json );
1232                    $node->setAttribute( $attrName, $encoded );
1233                } else {
1234                    // For compatibility, store the rich value in data-mw.attrs
1235                    // and store a flattened version in the $attrName.
1236                    if ( $flat !== null ) {
1237                        $node->setAttribute( $attrName, $flat );
1238                    } else {
1239                        $node->removeAttribute( $attrName );
1240                    }
1241                    $dataMw = self::getDataMw( $node );
1242                    $dataMw->attribs[] = new DataMwAttrib( $attrName, $json );
1243                    DOMUtils::addTypeOf( $node, 'mw:ExpandedAttrs' );
1244                }
1245                unset( $nodeData->$k );
1246            }
1247        }
1248    }
1249}