Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.86% covered (danger)
12.86%
63 / 490
6.67% covered (danger)
6.67%
4 / 60
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMDataUtils
12.86% covered (danger)
12.86%
63 / 490
6.67% covered (danger)
6.67%
4 / 60
20441.16
0.00% covered (danger)
0.00%
0 / 1
 setExtensionData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getExtensionData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 hasExtensionData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 removeExtensionData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 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 / 2
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
 isPreparedAndLoaded
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 prepareDoc
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 prepareChildDoc
0.00% covered (danger)
0.00%
0 / 2
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
 dedupeNodeData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 dedupeNodeDataVisitor
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
90
 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 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 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
 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.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
6.06
 getCodecHints
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 visitAndLoadDataAttribs
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 loadDataAttribs
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
90
 usedIdIndex
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 visitAndStoreDataAttribs
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 storeDataAttribs
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
182
 cloneNode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 cloneElement
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cloneDocumentFragment
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 cloneDocument
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 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
5.02
 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
 getAttributeDom
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getAttributeDomDefault
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setAttributeDom
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 removeAttributeDom
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 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 / 33
0.00% covered (danger)
0.00%
0 / 1
132
 dumpRichAttribs
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Composer\Semver\Semver;
7use InvalidArgumentException;
8use LogicException;
9use stdClass;
10use TypeError;
11use UnexpectedValueException;
12use WeakMap;
13use Wikimedia\Assert\Assert;
14use Wikimedia\Assert\UnreachableException;
15use Wikimedia\JsonCodec\Hint;
16use Wikimedia\Parsoid\Config\SiteConfig;
17use Wikimedia\Parsoid\Core\BasePageBundle;
18use Wikimedia\Parsoid\Core\DomPageBundle;
19use Wikimedia\Parsoid\DOM\Document;
20use Wikimedia\Parsoid\DOM\DocumentFragment;
21use Wikimedia\Parsoid\DOM\Element;
22use Wikimedia\Parsoid\DOM\Node;
23use Wikimedia\Parsoid\NodeData\DataBag;
24use Wikimedia\Parsoid\NodeData\DataMw;
25use Wikimedia\Parsoid\NodeData\DataMwAttrib;
26use Wikimedia\Parsoid\NodeData\DataMwI18n;
27use Wikimedia\Parsoid\NodeData\DataParsoid;
28use Wikimedia\Parsoid\NodeData\DataParsoidDiff;
29use Wikimedia\Parsoid\NodeData\I18nInfo;
30use Wikimedia\Parsoid\NodeData\NodeData;
31use Wikimedia\Parsoid\NodeData\TempData;
32
33/**
34 * These helpers pertain to HTML and data attributes of a node.
35 */
36class DOMDataUtils {
37    public const DATA_OBJECT_ATTR_NAME = 'data-object-id';
38
39    /** The internal property prefix used for rich attribute data. */
40    private const RICH_ATTR_DATA_PREFIX = 'rich-data-';
41
42    /** The internal property prefix used for rich attribute type hints. */
43    private const RICH_ATTR_HINT_PREFIX = 'rich-hint-';
44
45    /** Backing storage for "extension data" on a Document. */
46    private static array $docMap = [];
47
48    /**
49     * Associate additional data with a Document, without resorting to
50     * a dynamic property.
51     *
52     * Note that our data is hung off the top-level Document because it is
53     * the only object guaranteed to be kept live during the duration of a
54     * parse.  Individual Nodes and Elements will be garbage collected as
55     * soon as local variable references are dropped (the fact that backing
56     * data is referenced from the Document does not prevent this) which
57     * would erase the entry from the WeakMap used here.
58     *
59     * @param Document $doc
60     * @param string $key
61     * @param mixed $value
62     */
63    private static function setExtensionData( Document $doc, string $key, $value ): void {
64        if ( !isset( self::$docMap[$key] ) ) {
65            self::$docMap[$key] = new WeakMap();
66        }
67        self::$docMap[$key]->offsetSet( $doc, $value );
68    }
69
70    /**
71     * Retrieve additional data associated with a Document.
72     *
73     * See discussion in ::setExtensionData() above.
74     *
75     * @param Document $doc
76     * @param string $key
77     * @return mixed
78     * @throws \Error if $key is not found.
79     */
80    private static function getExtensionData( Document $doc, string $key ) {
81        if ( !isset( self::$docMap[$key] ) ) {
82            throw new \Error( "$key not found" );
83        }
84        return self::$docMap[$key]->offsetGet( $doc );
85    }
86
87    /**
88     * Determine if there is extension data for $key associated with the
89     * given document.
90     */
91    private static function hasExtensionData( Document $doc, string $key ): bool {
92        if ( !isset( self::$docMap[$key] ) ) {
93            return false;
94        }
95        return self::$docMap[$key]->offsetExists( $doc );
96    }
97
98    /**
99     * Remove the association of extra data with a document.
100     * @param Document $doc
101     * @param string $key
102     * @see ::setExtensionData
103     */
104    private static function removeExtensionData( Document $doc, string $key ): void {
105        if ( !isset( self::$docMap[$key] ) ) {
106            throw new \Error( "$key not found" );
107        }
108        self::$docMap[$key]->offsetUnset( $doc );
109    }
110
111    /**
112     * Return the dynamic "bag" property of a Document.
113     * @param Document $doc
114     * @return DataBag
115     */
116    public static function getBag( Document $doc ): DataBag {
117        return self::getExtensionData( $doc, "bag" );
118    }
119
120    /**
121     * Return the JsonCodec used for rich attributes in a Document.
122     * @param Node $node
123     * @return DOMDataCodec
124     */
125    public static function getCodec( Node $node ): DOMDataCodec {
126        // Owner document is set for all nodes except Document itself.
127        $doc = $node->ownerDocument ?? $node;
128        return self::getExtensionData( $doc, "codec" );
129    }
130
131    public static function isPrepared( Document $doc ): bool {
132        return self::hasExtensionData( $doc, "bag" );
133    }
134
135    public static function isPreparedAndLoaded( Document $doc ): bool {
136        return self::isPrepared( $doc ) && self::getBag( $doc )->loaded;
137    }
138
139    public static function prepareDoc( Document $doc ): void {
140        $bag = new DataBag();
141        $codec = new DOMDataCodec( $doc, [] );
142        self::setExtensionData( $doc, "bag", $bag );
143        self::setExtensionData( $doc, "codec", $codec );
144
145        // Cache the head and body.
146        DOMCompat::getHead( $doc );
147        DOMCompat::getBody( $doc );
148    }
149
150    /**
151     * @param Document $topLevelDoc
152     * @param Document $childDoc
153     */
154    public static function prepareChildDoc( Document $topLevelDoc, Document $childDoc ): void {
155        self::setExtensionData( $childDoc, "bag", self::getExtensionData( $topLevelDoc, "bag" ) );
156        self::setExtensionData( $childDoc, "codec", self::getExtensionData( $topLevelDoc, "codec" ) );
157    }
158
159    /**
160     * Stash $obj in $doc and return an id for later retrieval
161     * @param Document $doc
162     * @param NodeData $obj
163     * @return int
164     */
165    public static function stashObjectInDoc( Document $doc, NodeData $obj ): int {
166        return self::getBag( $doc )->stashObject( $obj );
167    }
168
169    public static function dedupeNodeData( Node $clonedRoot ): void {
170        $bag = self::getBag( $clonedRoot->ownerDocument );
171        $aboutMap = [];
172        self::dedupeNodeDataVisitor( $bag, $aboutMap, $clonedRoot );
173    }
174
175    private static function dedupeNodeDataVisitor(
176        DataBag $bag, array &$aboutMap, Node $node
177    ): void {
178        if ( $node instanceof Element ) {
179            if ( $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
180                $id = (int)DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
181                // Object IDs should always be unique, so we don't have
182                // to remember what the new ID is.
183                // (Note that UnpackDOMFragments may call us with nodes which
184                // don't have unique ids, though!)
185                $nd = $bag->getObject( $id );
186                $node->removeAttribute( self::DATA_OBJECT_ATTR_NAME );
187                $nd = $nd->cloneNodeData();
188                self::setNodeData( $node, $nd );
189
190                // Deduplicate annotation range ids
191                // These can occur multiple times in a given subtree, so we
192                // need to record the mapping for future use.
193                // (There's no DataMw unless there was a DATA_OBJECT_ATTR_NAME)
194                if ( isset( $nd->mw->rangeId ) ) {
195                    $oldAbout = $nd->mw->rangeId;
196                    $isStart = false;
197                    $type = WTUtils::extractAnnotationType( $node, $isStart );
198                    if ( $type !== null && $isStart ) {
199                        $aboutMap[$oldAbout] = $bag->newAnnotationId();
200                    }
201                    $nd->mw->rangeId = $aboutMap[$oldAbout] ?? $oldAbout;
202                }
203            }
204            if ( $node->hasAttribute( 'about' ) ) {
205                // Deduplicate transclusion ids
206                // As with annotation ranges, these can occur multiple times
207                // in a given subtree, so we need to record the mapping used.
208                $oldAbout = DOMCompat::getAttribute( $node, 'about' );
209                if ( WTUtils::isFirstEncapsulationWrapperNode( $node ) ) {
210                    $aboutMap[$oldAbout] = $bag->newAboutId();
211                }
212                $node->setAttribute( 'about', $aboutMap[$oldAbout] ?? $oldAbout );
213            }
214        }
215        foreach ( DOMUtils::childNodes( $node ) as $child ) {
216            self::dedupeNodeDataVisitor( $bag, $aboutMap, $child );
217        }
218    }
219
220    /**
221     * Does this node have any attributes?
222     * @param Element $node
223     * @return bool
224     */
225    public static function noAttrs( Element $node ): bool {
226        // The 'xmlns' attribute is "invisible" T235295
227        if ( $node->hasAttribute( 'xmlns' ) ) {
228            return false;
229        }
230        $numAttrs = count( $node->attributes );
231        return $numAttrs === 0 ||
232            ( $numAttrs === 1 && $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) );
233    }
234
235    /**
236     * Get data object from a node.
237     *
238     * @param Element $node node
239     * @param ?BasePageBundle $pb Optional source for node data
240     * @return NodeData
241     */
242    public static function getNodeData( Element $node, ?BasePageBundle $pb = null ): NodeData {
243        $nodeId = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
244        if ( $nodeId === null ) {
245            // Initialized on first request
246            $nodeData = new NodeData;
247            self::setNodeData( $node, $nodeData );
248            $id = DOMCompat::getAttribute( $node, 'id' );
249            if ( $id !== null && $pb !== null ) {
250                // See if there is data-parsoid or data-mw in the page bundle
251                $codec = self::getCodec( $node );
252                $hints = self::getCodecHints();
253                if ( isset( $pb->parsoid['ids'][$id] ) ) {
254                    $dp = $codec->newFromJsonArray(
255                        $pb->parsoid['ids'][$id],
256                        $hints['data-parsoid']
257                    );
258                    $nodeData->parsoid = $dp;
259                }
260                if ( isset( $pb->mw['ids'][$id] ) ) {
261                    $dmw = $codec->newFromJsonArray(
262                        $pb->mw['ids'][$id],
263                        $hints['data-mw']
264                    );
265                    $nodeData->mw = $dmw;
266                }
267            }
268            return $nodeData;
269        }
270
271        $nodeData = self::getBag( $node->ownerDocument )->getObject( (int)$nodeId );
272        Assert::invariant( $nodeData !== null, 'Bogus nodeId given!' );
273        if ( isset( $nodeData->storedId ) ) {
274            throw new UnreachableException(
275                'Trying to fetch node data without loading! ' .
276                // If this node's data-object id is different from storedId,
277                // it will indicate that the data-parsoid object was shared
278                // between nodes without getting cloned. Useful for debugging.
279                'Node id: ' . $nodeId . ' ' .
280                'Stored data: ' . PHPUtils::jsonEncode( $nodeData )
281            );
282        }
283        return $nodeData;
284    }
285
286    /**
287     * Set node data.
288     *
289     * @param Element $node node
290     * @param NodeData $data data
291     */
292    public static function setNodeData( Element $node, NodeData $data ): void {
293        $nodeId = self::stashObjectInDoc( $node->ownerDocument, $data );
294        $node->setAttribute( self::DATA_OBJECT_ATTR_NAME, (string)$nodeId );
295    }
296
297    /**
298     * Get data parsoid info from a node.
299     *
300     * @param Element $node node
301     * @return DataParsoid
302     */
303    public static function getDataParsoid( Element $node ): DataParsoid {
304        $data = self::getNodeData( $node );
305        $data->parsoid ??= new DataParsoid;
306        return $data->parsoid;
307    }
308
309    /**
310     * Set data parsoid info on a node.
311     *
312     * @param Element $node node
313     * @param DataParsoid $dp data-parsoid
314     */
315    public static function setDataParsoid( Element $node, DataParsoid $dp ): void {
316        $data = self::getNodeData( $node );
317        $data->parsoid = $dp;
318    }
319
320    /**
321     * Returns the i18n information of a node. This is in private access because it shouldn't
322     * typically be used directly; instead getDataNodeI18n and getDataAttrI18n should be used.
323     * @param Element $node
324     * @return DataMwI18n|null
325     */
326    private static function getDataMwI18n( Element $node ): ?DataMwI18n {
327        // No default value; returns null if not present.
328        return self::getAttributeObject( $node, 'data-mw-i18n', DataMwI18n::hint() );
329    }
330
331    /**
332     * Returns the i18n information of a node, setting it to a default
333     * value if it is missing.  This should not typically be used
334     * directly; instead setDataNodeI18n and setDataAttrI18n should be
335     * used.
336     *
337     * @param Element $node
338     * @return DataMwI18n $i18n
339     */
340    private static function getDataMwI18nDefault( Element $node ): DataMwI18n {
341        return self::getAttributeObjectDefault( $node, 'data-mw-i18n', DataMwI18n::hint() );
342    }
343
344    /**
345     * Retrieves internationalization (i18n) information of a node (typically for localization)
346     * @param Element $node
347     * @return ?I18nInfo
348     */
349    public static function getDataNodeI18n( Element $node ): ?I18nInfo {
350        $i18n = self::getDataMwI18n( $node );
351        if ( $i18n === null ) {
352            return null;
353        }
354        return $i18n->getSpanInfo();
355    }
356
357    /**
358     * Sets internationalization (i18n) information of a node, used for later localization
359     * @param Element $node
360     * @param I18nInfo $info
361     * @return void
362     */
363    public static function setDataNodeI18n( Element $node, I18nInfo $info ) {
364        $i18n = self::getDataMwI18nDefault( $node );
365        $i18n->setSpanInfo( $info );
366    }
367
368    /**
369     * Retrieves internationalization (i18n) information of an attribute value (typically for
370     * localization)
371     * @param Element $node
372     * @param string $name
373     * @return ?I18nInfo
374     */
375    public static function getDataAttrI18n( Element $node, string $name ): ?I18nInfo {
376        $i18n = self::getDataMwI18n( $node );
377        if ( $i18n === null ) {
378            return null;
379        }
380        return $i18n->getAttributeInfo( $name );
381    }
382
383    /**
384     * Sets internationalization (i18n) information of a attribute value, used for later
385     * localization
386     * @param Element $node
387     * @param string $name
388     * @param I18nInfo $info
389     * @return void
390     */
391    public static function setDataAttrI18n( Element $node, string $name, I18nInfo $info ) {
392        $i18n = self::getDataMwI18nDefault( $node );
393        $i18n->setAttributeInfo( $name, $info );
394    }
395
396    /**
397     * @param Element $node
398     * @return array
399     */
400    public static function getDataAttrI18nNames( Element $node ): array {
401        $i18n = self::getDataMwI18n( $node );
402        if ( $i18n === null ) {
403            // We won't set a default value for this property
404            return [];
405        }
406        return $i18n->getAttributeNames();
407    }
408
409    /**
410     * Get data diff info from a node.
411     *
412     * @param Element $node node
413     * @return ?DataParsoidDiff
414     */
415    public static function getDataParsoidDiff( Element $node ): ?DataParsoidDiff {
416        // No default value; returns null if not present.
417        return self::getAttributeObject( $node, 'data-parsoid-diff', DataParsoidDiff::hint() );
418    }
419
420    /**
421     * Get data diff info from a node, setting a default value if not present.
422     *
423     * @param Element $node node
424     * @return DataParsoidDiff
425     */
426    public static function getDataParsoidDiffDefault( Element $node ): DataParsoidDiff {
427        return self::getAttributeObjectDefault( $node, 'data-parsoid-diff', DataParsoidDiff::hint() );
428    }
429
430    /**
431     * Set data diff info on a node.
432     *
433     * @param Element $node node
434     * @param ?DataParsoidDiff $diffObj data-parsoid-diff object
435     */
436    public static function setDataParsoidDiff( Element $node, ?DataParsoidDiff $diffObj ): void {
437        if ( $diffObj !== null ) {
438            self::setAttributeObject( $node, 'data-parsoid-diff', $diffObj, DataParsoidDiff::hint() );
439        } else {
440            self::removeAttributeObject( $node, 'data-parsoid-diff' );
441        }
442    }
443
444    /**
445     * Get data meta wiki info from a node.
446     *
447     * @param Element $node node
448     * @return DataMw
449     */
450    public static function getDataMw( Element $node ): DataMw {
451        $data = self::getNodeData( $node );
452        $data->mw ??= new DataMw;
453        return $data->mw;
454    }
455
456    /**
457     * Set data meta wiki info from a node.
458     *
459     * @param Element $node node
460     * @param ?DataMw $dmw data-mw
461     */
462    public static function setDataMw( Element $node, ?DataMw $dmw ): void {
463        $data = self::getNodeData( $node );
464        $data->mw = $dmw;
465    }
466
467    /**
468     * Get an object from a JSON-encoded XML attribute on a node.
469     *
470     * @param Element $node node
471     * @param string $name name
472     * @param mixed $defaultVal
473     * @return mixed
474     */
475    public static function getJSONAttribute( Element $node, string $name, $defaultVal ) {
476        $attVal = DOMCompat::getAttribute( $node, $name );
477        if ( $attVal === null ) {
478            return $defaultVal;
479        }
480        $decoded = PHPUtils::jsonDecode( $attVal, false );
481        if ( $decoded !== null ) {
482            return $decoded;
483        } else {
484            error_log( 'ERROR: Could not decode attribute-val ' . $attVal .
485                ' for ' . $name . ' on node ' . DOMUtils::nodeName( $node ) );
486            return $defaultVal;
487        }
488    }
489
490    /**
491     * Set a attribute on a node with a JSON-encoded object.
492     *
493     * @param Element $node node
494     * @param string $name Name of the attribute.
495     * @param mixed $obj value of the attribute to
496     */
497    public static function setJSONAttribute( Element $node, string $name, $obj ): void {
498        $val = $obj === [] ? '{}' : PHPUtils::jsonEncode( $obj );
499        $node->setAttribute( $name, $val );
500    }
501
502    // Shadow attributes should probably be unified with rich attributes
503    // at some point. [CSA 2024-10-15]
504
505    /**
506     * Set shadow info on a node; similar to the method on tokens.
507     * Records a key = value pair in data-parsoid['a'] property.
508     *
509     * This is effectively a call of 'setShadowInfoIfModified' except
510     * there is no original value, so by definition, $val is modified.
511     *
512     * @param Element $node node
513     * @param string $name Name of the attribute.
514     * @param mixed $val val
515     */
516    public static function setShadowInfo( Element $node, string $name, $val ): void {
517        $dp = self::getDataParsoid( $node );
518        $dp->a ??= [];
519        $dp->sa ??= [];
520        $dp->a[$name] = $val;
521    }
522
523    /**
524     * Set shadow info on a node; similar to the method on tokens.
525     *
526     * If the new value ($val) for the key ($name) is different from the
527     * original value ($origVal):
528     * - the new value is recorded in data-parsoid->a and
529     * - the original value is recorded in data-parsoid->sa
530     *
531     * @param Element $node node
532     * @param string $name Name of the attribute.
533     * @param mixed $val val
534     * @param mixed $origVal original value (null is a valid value)
535     * @param bool $skipOrig
536     */
537    public static function setShadowInfoIfModified(
538        Element $node, string $name, $val, $origVal, bool $skipOrig = false
539    ): void {
540        if ( !$skipOrig && ( $val === $origVal || $origVal === null ) ) {
541            return;
542        }
543        $dp = self::getDataParsoid( $node );
544        $dp->a ??= [];
545        $dp->sa ??= [];
546        // FIXME: This is a hack to not overwrite already shadowed info.
547        // We should either fix the call site that depends on this
548        // behaviour to do an explicit check, or double down on this
549        // by porting it to the token method as well.
550        if ( !$skipOrig && !array_key_exists( $name, $dp->a ) ) {
551            $dp->sa[$name] = $origVal;
552        }
553        $dp->a[$name] = $val;
554    }
555
556    /**
557     * Set an attribute and shadow info to a node.
558     * Similar to the method on tokens
559     *
560     * @param Element $node node
561     * @param string $name Name of the attribute.
562     * @param mixed $val value
563     * @param mixed $origVal original value
564     * @param bool $skipOrig
565     */
566    public static function addNormalizedAttribute(
567        Element $node, string $name, $val, $origVal, bool $skipOrig = false
568    ): void {
569        if ( $name === 'id' ) {
570            DOMCompat::setIdAttribute( $node, $val );
571        } else {
572            $node->setAttribute( $name, $val );
573        }
574        self::setShadowInfoIfModified( $node, $name, $val, $origVal, $skipOrig );
575    }
576
577    /**
578     * Removes the `data-*` attribute from a node, and migrates the data to the
579     * given DomPageBundle. Generates a unique id with the following format:
580     * ```
581     * mw<base64-encoded counter>
582     * ```
583     * but attempts to keep user defined ids.
584     *
585     * TODO: Note that $data is effective a partial PageBundle containing
586     * only the 'parsoid' and 'mw' properties.
587     *
588     * @param DomPageBundle $pb
589     * @param Element $node node
590     * @param stdClass $data data
591     * @param array $idIndex Index of used id attributes in the DOM
592     */
593    public static function storeInPageBundle(
594        DomPageBundle $pb, Element $node, stdClass $data, array $idIndex
595    ): void {
596        $hints = self::getCodecHints();
597        $uid = DOMCompat::getAttribute( $node, 'id' );
598        $codec = self::getCodec( $node );
599        $docDp = &$pb->parsoid;
600        $origId = $uid;
601        if ( $uid !== null && array_key_exists( $uid, $docDp['ids'] ) ) {
602            // Forcibly reset the ID if there's a conflict
603            $uid = null;
604        }
605        if ( $uid === '' ) {
606            // Forcibly reset the ID if it is invalid
607            $uid = null;
608        }
609        if ( $uid === null ) {
610            do {
611                $docDp['counter'] += 1;
612                // The idIndex maps all *existing* ids from the original
613                // document, so that we can ensure than any *newly assigned*
614                // UIDs don't happen to step on them.  We don't need to update
615                // the idIndex here because (a) we only add a new UID if it
616                // doesn't conflict with an existing ID, and (b) by
617                // construction, none of our new UIDs will conflict with each
618                // other.
619                $uid = 'mw' . PHPUtils::counterToBase64( $docDp['counter'] );
620            } while ( isset( $idIndex[$uid] ) );
621            self::addNormalizedAttribute( $node, 'id', $uid, $origId );
622        }
623        // Convert from DataParsoid/DataMw objects to associative array
624        $docDp['ids'][$uid] = $codec->toJsonArray( $data->parsoid, $hints['data-parsoid'] );
625        if ( isset( $data->mw ) ) {
626            $pb->mw['ids'][$uid] = $codec->toJsonArray( $data->mw, $hints['data-mw'] );
627        }
628    }
629
630    /**
631     * Helper function to create static Hint objects for JsonCodec.
632     * @return array<Hint>
633     */
634    public static function getCodecHints(): array {
635        static $hints = null;
636        if ( $hints === null ) {
637            $hints = [
638                'data-parsoid' => Hint::build( DataParsoid::class, Hint::ALLOW_OBJECT ),
639                'data-mw' => Hint::build( DataMw::class, Hint::ALLOW_OBJECT ),
640            ];
641        }
642        return $hints;
643    }
644
645    /**
646     * Walk DOM from node downward calling loadDataAttribs
647     *
648     * @param Node $node node
649     * @param array $options options
650     */
651    public static function visitAndLoadDataAttribs( Node $node, array $options = [] ): void {
652        $doc = $node->ownerDocument ?? $node;
653        Assert::invariant( self::isPrepared( $doc ), "document should be prepared" );
654        if ( $node === DOMCompat::getBody( $doc ) ) {
655            Assert::invariant( !self::getBag( $doc )->loaded, "redundant load" );
656        }
657        // If the 'markNew' flag is passed, it needs to be recorded in the
658        // Document codec's options, so that we can use this flag when
659        // loading embedded document fragments.
660        self::getCodec( $node )->setOptions( $options );
661        DOMUtils::visitDOM( $node, [ self::class, 'loadDataAttribs' ], $options );
662    }
663
664    /**
665     * These are intended be used on a document after post-processing, so that
666     * the underlying .dataobject is transparently applied (in the store case)
667     * and reloaded (in the load case), rather than worrying about keeping
668     * the attributes up-to-date throughout that phase.  For the most part,
669     * using this.ppTo* should be sufficient and using these directly should be
670     * avoided.
671     *
672     * @param Node $node node
673     * @param array $options options
674     */
675    public static function loadDataAttribs( Node $node, array $options ): void {
676        if ( !( $node instanceof Element ) ) {
677            return;
678        }
679        $bag = self::getBag( $node->ownerDocument ?? $node );
680        $nodeData = self::getNodeData( $node, $options['loadFromPageBundle'] ?? null );
681        $codec = self::getCodec( $node );
682        $dataParsoidAttr = DOMCompat::getAttribute( $node, 'data-parsoid' );
683        if ( $dataParsoidAttr === null ) {
684            // data-parsoid might have come from page bundle
685            $newDP = ( $nodeData->parsoid === null );
686            $dp = self::getDataParsoid( $node );
687        } else {
688            $newDP = false;
689            try {
690                $dp = $codec->newFromJsonString(
691                    $dataParsoidAttr, self::getCodecHints()['data-parsoid']
692                );
693                Assert::invariant( $dp instanceof DataParsoid, "Unexpected data-parsoid" );
694            } catch ( TypeError | LogicException $e ) {
695                // improve debuggability: T403208
696                throw new UnexpectedValueException( "Unable to decode data-parsoid [$dataParsoidAttr]", 0, $e );
697            }
698        }
699        if ( !empty( $options['markNew'] ) ) {
700            $dp->setTempFlag( TempData::IS_NEW, $newDP );
701        }
702        self::setDataParsoid( $node, $dp );
703        $node->removeAttribute( 'data-parsoid' );
704
705        $dataMwAttr = DOMCompat::getAttribute( $node, 'data-mw' );
706        // note that data-mw might already be present in node data from
707        // page bundle, but inline attribute takes precedence
708        if ( $dataMwAttr !== null ) {
709            try {
710                $dmw = $codec->newFromJsonString(
711                    $dataMwAttr, self::getCodecHints()['data-mw']
712                );
713                Assert::invariant( $dmw instanceof DataMw, "Unexpected data-mw" );
714            } catch ( TypeError | LogicException $e ) {
715                // improve debuggability: T388160
716                throw new UnexpectedValueException( "Unable to decode data-mw [$dataMwAttr]", 0, $e );
717            }
718            self::setDataMw( $node, $dmw );
719            $node->removeAttribute( 'data-mw' );
720        }
721
722        $about = DOMCompat::getAttribute( $node, 'about' );
723        if ( $about !== null ) {
724            $bag->seenAboutId( $about );
725        }
726        if ( isset( $nodeData->mw->rangeId ) ) {
727            $bag->seenAnnotationId( $nodeData->mw->rangeId );
728        }
729
730        // We don't load rich attributes here: that will be done lazily as
731        // getAttributeObject()/etc methods are called because we don't
732        // know the true types of the rich values yet.  In the future
733        // we might have a schema or self-labelling of values which would
734        // allow us to load rich attributes here as well.
735    }
736
737    /**
738     * Builds an index of id attributes seen in the DOM
739     *
740     * @param SiteConfig $siteConfig A SiteConfig is required to properly
741     *   traverse document fragments embedded in extension DOM.
742     * @param Document $doc
743     * @param array<string,DocumentFragment> $fragments
744     * @return array<string, true>
745     */
746    public static function usedIdIndex( SiteConfig $siteConfig, Document $doc, array $fragments = [] ): array {
747        $index = [];
748        $t = new DOMTraverser( false, true );
749        $t->addHandler( null, static function ( $n, $state ) use ( &$index ) {
750            if ( $n instanceof Element ) {
751                $id = DOMCompat::getAttribute( $n, 'id' );
752                if ( $id !== null ) {
753                    $index[$id] = true;
754                }
755            }
756            return true;
757        } );
758        $t->traverse( $siteConfig, DOMCompat::getBody( $doc ) );
759        foreach ( $fragments as $name => $f ) {
760            $t->traverse( $siteConfig, $f );
761        }
762        return $index;
763    }
764
765    /**
766     * Walk DOM from node downward calling storeDataAttribs
767     *
768     * @param Node $node node
769     * @param array $options options
770     */
771    public static function visitAndStoreDataAttribs( Node $node, array $options = [] ): void {
772        Assert::invariant( self::getBag( $node->ownerDocument ?? $node )->loaded,
773                          "store without load" );
774        // PORT-FIXME: storeDataAttribs calls storeInPageBundle which calls getElementById.
775        // PHP's `getElementById` implementation is broken, and we work around that by
776        // using Zest which uses XPath. So, getElementById call can be O(n) and calling it
777        // on on every element of the DOM via vistDOM here makes it O(n^2) instead of O(n).
778        // So, we work around that by building an index and avoiding getElementById entirely
779        // in storeInPageBundle.
780        if ( !empty( $options['storeInPageBundle'] ) ) {
781            Assert::invariant( isset( $options['idIndex'] ),
782                              "Page bundle requires idIndex to avoid conflicts" );
783        }
784        // Set the "storage options" and save the "loading options"
785        $codec = self::getCodec( $node );
786        $oldOptions = $codec->setOptions( $options );
787
788        DOMUtils::visitDOM( $node, [ self::class, 'storeDataAttribs' ], $options );
789
790        // Restore the "loading options"
791        $codec->setOptions( $oldOptions );
792    }
793
794    /**
795     * Copy data attributes from the bag to either JSON-encoded attributes on
796     * each node, or the page bundle, erasing the data-object-id attributes.
797     *
798     * @param Node $node node
799     * @param ?array $options options
800     *   - discardDataParsoid: Discard DataParsoid objects instead of storing them
801     *   - keepTmp: Preserve DataParsoid::$tmp
802     *   - storeInPageBundle: If set to a DomPageBundle, data will be stored
803     *     in the given page bundle instead of data-parsoid and data-mw.
804     *   - outputContentVersion: Version of output we're storing.  The page bundle
805     *     didn't have data-mw before 999.x
806     *   - idIndex: Array of used ID attributes
807     */
808    public static function storeDataAttribs( Node $node, ?array $options = null ): void {
809        $hints = self::getCodecHints();
810        $options ??= [];
811        if ( !( $node instanceof Element ) ) {
812            return;
813        }
814
815        // Store rich attributes.  Note that, at present, rich attributes may
816        // be serialized into the data-mw attributes which are serialized in
817        // the pagebundle; thus we need to serialize all the "attributes
818        // with special html semantics" (which will get added to data-mw)
819        // *before* we handle the other attributes and the page bundle.
820        self::storeRichAttributes( $node, [ 'onlySpecial' => true ] + $options );
821
822        Assert::invariant( empty( $options['discardDataParsoid'] ) || empty( $options['keepTmp'] ),
823            'Conflicting options: discardDataParsoid and keepTmp are both enabled.' );
824        $codec = self::getCodec( $node );
825        $dp = self::getDataParsoid( $node );
826        $discardDataParsoid = !empty( $options['discardDataParsoid'] );
827        if ( $dp->getTempFlag( TempData::IS_NEW ) && !$dp->isModified() ) {
828            // This hack ensures that a loadDataAttribs + storeDataAttribs pair
829            // don't dirty the node by introducing an empty data-parsoid attribute
830            // where one didn't exist before.
831            //
832            // Ideally, we'll find a better solution for this edge case later.
833            $discardDataParsoid = true;
834        }
835        $data = null;
836        if ( !$discardDataParsoid ) {
837            // FIXME: $dp->toJsonArray drops tmp so it's discarded regardless
838            // of this flag
839            if ( empty( $options['keepTmp'] ) ) {
840                // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
841                unset( $dp->tmp );
842            }
843
844            if ( !empty( $options['storeInPageBundle'] ) ) {
845                $data ??= new stdClass;
846                $data->parsoid = $dp;
847            } else {
848                $node->setAttribute(
849                    'data-parsoid',
850                    PHPUtils::jsonEncode(
851                        $codec->toJsonArray( $dp, $hints['data-parsoid'] )
852                    )
853                );
854            }
855        }
856
857        // Special handling for data-mw.  This should eventually go away
858        // and be replaced with the standard "rich attribute" handling:
859        // (a) now that DataMw is a class type, we should never actually
860        // have "invalid" data mw objects in practice;
861        // (b) eventually we can remove support for output content version
862        // older than 999.x.
863
864        // Strip empty data-mw attributes
865        $dmw = self::getDataMw( $node );
866        if ( !$dmw->isEmpty() ) {
867            if (
868                !empty( $options['storeInPageBundle'] ) &&
869                // The pagebundle didn't have data-mw before 999.x
870                Semver::satisfies( $options['outputContentVersion'] ?? '0.0.0', '^999.0.0' )
871            ) {
872                $data ??= new stdClass;
873                $data->mw = $dmw;
874            } else {
875                $node->setAttribute(
876                    'data-mw',
877                    PHPUtils::jsonEncode(
878                        $codec->toJsonArray( $dmw, $hints['data-mw'] )
879                    )
880                );
881            }
882        }
883
884        // Serialize the rest of the rich attributes
885        // (This will eventually include data-mw.)
886        self::storeRichAttributes( $node, $options );
887
888        // Store pagebundle
889        if ( $data !== null ) {
890            self::storeInPageBundle( $options['storeInPageBundle'], $node, $data, $options['idIndex'] );
891        }
892
893        // Indicate that this node's data has been stored so that if we try
894        // to access it after the fact we're aware and remove the attribute
895        // since it's no longer needed.
896        $nd = self::getNodeData( $node );
897        $id = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
898        $nd->storedId = $id !== null ? intval( $id ) : null;
899        $node->removeAttribute( self::DATA_OBJECT_ATTR_NAME );
900    }
901
902    /**
903     * Clones a node and its data bag.
904     */
905    public static function cloneNode( Node $elt, bool $deep ): Node {
906        if ( self::hasExtensionData( $elt->ownerDocument, "cloneTarget" ) ) {
907            // Special case for "clone into a brand new document", which
908            // is the exception rather than the rule since Parsoid tries
909            // to maintain a single ownerDocument context.
910            $cloneTarget = self::getExtensionData( $elt->ownerDocument, "cloneTarget" );
911            return $cloneTarget->importNode( $elt, $deep );
912        }
913        $clone = $elt->cloneNode( $deep );
914        '@phan-var Element $clone'; // @var Element $clone
915        // We do not need to worry about $deep because a shallow clone does not have child nodes,
916        // so it's always cloning data on the cloned tree (which may be empty).
917        self::dedupeNodeData( $clone );
918        return $clone;
919    }
920
921    // Two specific helper methods to work around the lack of constrainted
922    // templated types in phan.
923
924    /**
925     * Clones an element and its data bag(s)
926     */
927    public static function cloneElement( Element $elt, bool $deep ): Element {
928        $clone = self::cloneNode( $elt, $deep );
929        '@phan-var Element $clone'; // @var Element $clone
930        return $clone;
931    }
932
933    /**
934     * Deep clone a DocumentFragment and its associated data bags
935     */
936    public static function cloneDocumentFragment( DocumentFragment $df ): DocumentFragment {
937        $clone = self::cloneNode( $df, true );
938        '@phan-var DocumentFragment $clone'; // @var DocumentFragment $clone
939        return $clone;
940    }
941
942    /**
943     * Deep clone a Document and its associated data bags
944     */
945    public static function cloneDocument( Document $doc ): Document {
946        // Standard PHP clone works fine for the Document
947        $clone = clone $doc;
948        // But now we need to duplicate the extension data.
949        if ( self::isPrepared( $doc ) ) {
950            self::prepareDoc( $clone );
951            // Overwrite the empty Bag with a clone, after setting up
952            // to importNode rich data
953            self::setExtensionData( $doc, "cloneTarget", $clone );
954            self::setExtensionData( $clone, "bag", clone self::getBag( $doc ) );
955            self::removeExtensionData( $doc, "cloneTarget" );
956        }
957        return $clone;
958    }
959
960    // This is a generic (and somewhat optimistic) interface for
961    // complex-valued attributes in a DOM tree.  The object and DOM
962    // values are "live"; that is, they are passed by-reference and
963    // mutations to the object and DOM persist in the document.
964    // These values are only "frozen" into a standards-compliant
965    // HTML5 attribute representation when the document is serialized.
966    // (A corresponding 'parse' stage needs to occur on a new document
967    // to "thaw out" the HTML5 attribute representations.)
968
969    // Note that although we are expanding the possible attribute *values*
970    // we are still deliberately keeping attribute *names* restricted.
971    // This is a deliberate design choice.  Dynamically-generated
972    // attribute names are best handled by the "key value pair"
973    // fragment datatype, which is one of the fragment types from which
974    // the output document can be composed -- but that composition
975    // mechanism and the way the fragment composition is reflected in
976    // the DOM is out-of-scope for this API.  This just provides a
977    // richer way to embed complex information of that sort into a
978    // DOM document.
979
980    // An important design decision here was not to embed type information
981    // for attributes into the representation, which is done to avoid
982    // HTML bloat.  This leads directly to a "lazy load" implementation,
983    // as we can't actually load an attribute value until we know what
984    // its class type is, and that's only provided when the call to
985    // ::getAttributeObject() is made.  In order to implement an "eager
986    // load" implementation, we would need a schema for the document
987    // which maps every named attribute to an appropriate type.  This
988    // is possible if eager loading is desired in the future, or because
989    // you like the added structural documentation provided by a schema.
990
991    // Certain attributes have semantics given by HTML.  For example,
992    // the `class` and `alt` attributes shouldn't be serialized as a
993    // JSON blob, even if you want to store a rich value.  For these
994    // "HTML attributes with special semantics" (everything not
995    // starting with data-* at the moment) we tolerate a bit of bloat
996    // and store a flattened string representation of the rich value
997    // in the direct HTML attribute, and store the serialized rich
998    // value elsewhere. This value is used to provide the appropriate
999    // HTML semantics (ie, the browser will apply CSS styling to the
1000    // flattened `class`, use the flattened `href` to navigate) but
1001    // should not be used by clients /of the MediaWiki DOM spec/
1002    // (including Parsoid), which should ignore the flattened value
1003    // and consistently use the rich value in order to avoid
1004    // losing/overwriting data.
1005
1006    // The JSON representation of a rich valued attribute can be
1007    // customized using the mechanisms provided by the wikimedia/json-codec
1008    // library; in particular you will want to use the "implicit typing"
1009    // mechanism provided by the library to avoid bloating the output
1010    // with explicit references to the PHP implementation classes.
1011
1012    // See
1013    // https://www.mediawiki.org/wiki/Parsoid/MediaWiki_DOM_spec/Rich_Attributes
1014    // for a more detailed discussion of this design.  The present
1015    // implementation corresponds to "proposal 1a", the first step in
1016    // the full proposal.
1017
1018    /**
1019     * Determine whether the given attribute name has "special" HTML
1020     * semantics.  For these attributes, a "stringified" flattened
1021     * version of the attribute is stored in the attribute, for
1022     * semantic compatibility with browsers etc, and the "rich" form
1023     * of the attribute is stored in a separate attribute.
1024     *
1025     * Although in theory we could minimize this by looking at the
1026     * names of attributes explicitly reserved for each tag name in
1027     * the HTML spec, at this time we're going to be conservative and
1028     * assume every attribute has "special" semantics that we should
1029     * preserve except for those attributes whose names begin with
1030     * `data-*`.
1031     *
1032     * In the future we might tweak the set of attributes with special
1033     * semantics in order to reduce unnecessary bloat (ie storing
1034     * flattened versions of attributes where the flattened value will
1035     * never be used) and/or to include flattened values for certain
1036     * data-* attributes (for example, if a gadget were to rely on a
1037     * flattened value in `data-time`).
1038     *
1039     * @param string $tagName The tag name of the Element containing the
1040     *   attribute
1041     * @param string $attrName The name of the attribute
1042     * @return bool True if the named attribute has special HTML semantics
1043     */
1044    private static function isHtmlAttributeWithSpecialSemantics( string $tagName, string $attrName ): bool {
1045        return !(bool)preg_match( '/^data-/i', $attrName );
1046    }
1047
1048    /**
1049     * Return the value of a rich attribute as a live (by-reference) object.
1050     * This also serves as an assertion that there are not conflicting types.
1051     *
1052     * @template T
1053     * @param Element $node The node on which the attribute is to be found.
1054     * @param string $name The name of the attribute.
1055     * @param class-string<T>|Hint<T> $classHint
1056     * @return T|null The attribute value, or null if not present.
1057     */
1058    public static function getAttributeObject(
1059        Element $node, string $name, $classHint
1060    ): ?object {
1061        self::loadRichAttributes( $node, $name ); // lazy load
1062        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1063            // Don't create an empty node data object if we don't need to.
1064            return null;
1065        }
1066        $nodeData = self::getNodeData( $node );
1067        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1068        $value = $nodeData->$propName ?? null;
1069        // We lazily decode rich values, because we need to know the $classHint
1070        // before we decode.  Undecoded values are wrapped with an array so
1071        // we can tell whether the value has been decoded already or not.
1072        if ( is_array( $value ) ) {
1073            // This value should be decoded
1074            $codec = self::getCodec( $node );
1075            $value = $codec->newFromJsonArray( $value[0], $classHint );
1076            if ( is_array( $value ) ) {
1077                // JsonCodec allows class hints to indicate that the value
1078                // is an array of some object type, but for our purposes
1079                // the result must always be an object so that it is live.
1080                $value = (object)$value;
1081            }
1082            // To signal that it's been decoded already we need $value
1083            // not to be an array
1084            Assert::invariant(
1085                !is_array( $value ), "rich attribute can't be array"
1086            );
1087            $nodeData->$propName = $value;
1088            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1089            $nodeData->$hintName = $classHint;
1090        }
1091        return $value;
1092    }
1093
1094    /**
1095     * Return the value of a rich attribute as a live (by-reference)
1096     * object.  This also serves as an assertion that there are not
1097     * conflicting types.  If the value is not present, a default value
1098     * will be created using `$codec->defaultValue()` falling back to
1099     * `$className::defaultValue()` and stored as the value of the
1100     * attribute.
1101     *
1102     * @note The $className should have be JsonCodecable (either directly
1103     *  or via a custom JsonClassCodec).
1104     *
1105     * @template T
1106     * @param Element $node The node on which the attribute is to be found.
1107     * @param string $name The name of the attribute.
1108     * @param class-string<T>|Hint<T> $classHint
1109     * @return T|null The attribute value, or null if not present.
1110     */
1111    public static function getAttributeObjectDefault(
1112        Element $node, string $name, $classHint
1113    ): ?object {
1114        $value = self::getAttributeObject( $node, $name, $classHint );
1115        if ( $value === null ) {
1116            $className = $classHint;
1117            while ( $className instanceof Hint ) {
1118                Assert::invariant(
1119                    $className->modifier !== Hint::LIST &&
1120                    $className->modifier !== Hint::STDCLASS,
1121                    "Can't create default value for list or object"
1122                );
1123                $className = $className->parent;
1124            }
1125            '@phan-var string $className';
1126            $codec = self::getCodec( $node );
1127            $value = $codec->defaultValue( $className );
1128            $value ??= new $className;
1129            self::setAttributeObject( $node, $name, $value, $classHint );
1130        }
1131        return $value;
1132    }
1133
1134    /**
1135     * Set the value of a rich attribute, overwriting any previous
1136     * value.  Generally mutating the result returned by the
1137     * `::getAttribute*Default()` methods should be done instead of
1138     * using this method, since the objects returned are live.
1139     *
1140     * @note For attribute names where
1141     *  `::isHtmlAttributeWithSpecialSemantics()` returns `true` you
1142     *  can customize the "flattened" representation used for HTML
1143     *  semantics via `$codec->flatten()` which falls back to
1144     * `$className::flatten()`.
1145     *
1146     * @param Element $node The node on which the attribute is to be found.
1147     * @param string $name The name of the attribute.
1148     * @param object $value The new (object) value for the attribute
1149     * @param class-string|Hint|null $classHint Optional serialization hint
1150     */
1151    public static function setAttributeObject(
1152        Element $node, string $name, object $value, $classHint = null
1153    ): void {
1154        // Remove attribute from DOM; will be rewritten from node data during
1155        // serialization.
1156        self::removeAttributeObject( $node, $name );
1157        $nodeData = self::getNodeData( $node );
1158        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1159        $nodeData->$propName = $value;
1160        if ( $classHint === null && is_a( $value, RichCodecable::class ) ) {
1161            $className = get_class( $value );
1162            $classHint = $className::hint();
1163        }
1164        $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1165        $nodeData->$hintName = $classHint;
1166    }
1167
1168    /**
1169     * Remove a rich attribute.
1170     *
1171     * @param Element $node The node on which the attribute is to be found.
1172     * @param string $name The name of the attribute.
1173     */
1174    public static function removeAttributeObject(
1175        Element $node, string $name
1176    ): void {
1177        $node->removeAttribute( $name );
1178        self::removeFromExpandedAttrs( $node, $name );
1179        if ( $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1180            $nodeData = self::getNodeData( $node );
1181            $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1182            unset( $nodeData->$propName );
1183            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1184            unset( $nodeData->$hintName );
1185        }
1186    }
1187
1188    /**
1189     * Helper function for code clarity: test whether there is
1190     * an existing data-mw value on a node which has already had
1191     * loadDataAttribs called on it.
1192     */
1193    private static function nodeHasDataMw( Element $node ): bool {
1194        // If data-mw were present, loadDataAttribs would have created
1195        // the DATA_OBJECT_ATTR_NAME attribute for associated NodeData
1196        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1197            return false;
1198        }
1199        $data = self::getNodeData( $node );
1200        return $data->mw !== null;
1201    }
1202
1203    /**
1204     * Helper function to remove any entries from data-mw.attribs which match
1205     * this attribute name.  They will be rewritten during rich attribute
1206     * serialization if necessary.
1207     * @param Element $node
1208     * @param string $name
1209     */
1210    private static function removeFromExpandedAttrs(
1211        Element $node, string $name
1212    ): void {
1213        // Don't create a new data-mw yet if we don't need one.
1214        if ( !self::nodehasDataMw( $node ) ) {
1215            return;
1216        }
1217        if ( !self::isHtmlAttributeWithSpecialSemantics( $node->tagName, $name ) ) {
1218            return;
1219        }
1220        // If there was a data-mw.attribs for this attribute, remove it
1221        // (it will be rewritten during serialization later)
1222        $dataMw = self::getDataMw( $node );
1223        $dataMw->attribs = array_values( array_filter(
1224            $dataMw->attribs ?? [],
1225            static function ( $a ) use ( $name ) {
1226                if ( !( $a instanceof DataMwAttrib ) ) {
1227                    return true;
1228                }
1229                $key = $a->key;
1230                if ( $key === $name ) {
1231                    return false; // Remove this entry
1232                }
1233                if ( is_array( $key ) && ( $key['txt'] ?? null ) == $name ) {
1234                    return false; // Remove this entry
1235                }
1236                return true;
1237            }
1238        ) );
1239        if ( count( $dataMw->attribs ) === 0 ) {
1240            unset( $dataMw->attribs );
1241            DOMUtils::removeTypeOf( $node, 'mw:ExpandedAttrs' );
1242        }
1243    }
1244
1245    /**
1246     * Return the value of a rich attribute as a live `DocumentFragment`.
1247     * This also serves as an assertion that there are not conflicting types.
1248     *
1249     * @note A string-valued attribute will be returned as a DocumentFragment
1250     *   with a single Text node.  This supports the efficient serialization
1251     *   of 'simple' DocumentFragments as simple strings.
1252     *
1253     * @param Element $node The node on which the attribute is to be found.
1254     * @param string $name The name of the attribute.
1255     * @return ?DocumentFragment The attribute value, or null if not present.
1256     */
1257    public static function getAttributeDom(
1258        Element $node, string $name
1259    ): ?DocumentFragment {
1260        // As it turns out, the implementation for a DocumentFragment is
1261        // the same; all the implementation differences are in the codec
1262        return self::getAttributeObject(
1263            $node, $name, DocumentFragment::class
1264        );
1265    }
1266
1267    /**
1268     * Return the value of a rich attribute as a `DocumentFragment`,
1269     * creating a new document fragment and setting the attribute if the
1270     * attribute was not previously present.
1271     *
1272     * @param Element $node The node on which the attribute is to be found.
1273     * @param string $name The name of the attribute.
1274     * @return DocumentFragment The attribute value.
1275     */
1276    public static function getAttributeDomDefault(
1277        Element $node, string $name
1278    ): DocumentFragment {
1279        $value = self::getAttributeDom( $node, $name );
1280        if ( $value === null ) {
1281            $value = $node->ownerDocument->createDocumentFragment();
1282            self::setAttributeDOM( $node, $name, $value );
1283        }
1284        return $value;
1285    }
1286
1287    /**
1288     * Set the value of a rich attribute, overwriting any previous
1289     * value.  Generally mutating the result returned by the
1290     * `::getAttribute*Default()` methods should be done instead of
1291     * using this method, since the objects returned are live.
1292     *
1293     * @param Element $node The node on which the attribute is to be found.
1294     * @param string $name The name of the attribute.
1295     * @param DocumentFragment $value
1296     */
1297    public static function setAttributeDom(
1298        Element $node, string $name, DocumentFragment $value
1299    ): void {
1300        // Remove attribute from DOM; will be rewritten from node data during
1301        // serialization.
1302        self::removeAttributeDom( $node, $name );
1303        $nodeData = self::getNodeData( $node );
1304        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1305        $nodeData->$propName = $value;
1306        $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1307        $nodeData->$hintName = DocumentFragment::class;
1308    }
1309
1310    /**
1311     * Remove a rich attribute.
1312     *
1313     * @param Element $node The node on which the attribute is to be found.
1314     * @param string $name The name of the attribute.
1315     */
1316    public static function removeAttributeDom(
1317        Element $node, string $name
1318    ): void {
1319        // Remove attribute from DOM; will be rewritten from node data during
1320        // serialization.
1321        $node->removeAttribute( $name );
1322        self::removeFromExpandedAttrs( $node, $name );
1323        if ( $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1324            $nodeData = self::getNodeData( $node );
1325            $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1326            unset( $nodeData->$propName );
1327            $hintName = self::RICH_ATTR_HINT_PREFIX . $name;
1328            unset( $nodeData->$hintName );
1329        }
1330    }
1331
1332    // Serialization/deserialization support for rich attributes.
1333
1334    // There are many possible serializations which could be used.
1335    // For the moment we've chosen the simplest possible one, which
1336    // embeds big JSON blobs in attribute values.  For "attributes
1337    // with special HTML semantics" the JSON blobs are stored in
1338    // data-mw.attribs and the straight HTML attribute value is a
1339    // flattened form of the true value.
1340
1341    /**
1342     * Internal function to lazy-load rich attribute data from the HTML
1343     * DOM representation.
1344     * @param Element $node The node possibly containing the rich attribute
1345     * @param string $name The attribute name we are going to load values for
1346     */
1347    private static function loadRichAttributes(
1348        Element $node, string $name
1349    ): void {
1350        // Because we don't have a complete schema for the document which
1351        // identifies which attributes are 'rich' and which are not, we
1352        // lazily-load attributes one-by-one once we know their names and types
1353        // instead of trying to preload them in bulk.
1354
1355        // *However* in order to avoid O(N^2) manipulation of the
1356        // data-mw.attribs list, we do move all the values from data-mw.attribs
1357        // into NodeData, even those not matching our given name.  We can't
1358        // decode those yet: they will be decoded once getAttributeObject()
1359        // is called on them to provide the proper type hint (or else they
1360        // will eventually be reserialized in their undecoded form).
1361
1362        $flatValue = DOMCompat::getAttribute( $node, $name );
1363        if ( $flatValue === null ) {
1364            // Use the presence of the attribute in the DOM to indicate
1365            // whether this attribute has been loaded; this avoids (for
1366            // example) traversing AttributeExpander entries in
1367            // data-mw.attribs multiple times looking for the name of a
1368            // rich attribute.  If the attribute is not in the DOM either
1369            // there is no attribute of this name or it has already been
1370            // loaded.
1371            return;
1372        }
1373
1374        if ( self::isHtmlAttributeWithSpecialSemantics( $node->tagName, $name ) ) {
1375            // Look aside at data-mw for attributes with special semantics
1376            if ( !self::nodeHasDataMw( $node ) ) {
1377                // No data-mw, so no rich value for this attribute
1378                return;
1379            }
1380            $dataMw = self::getDataMw( $node );
1381            // Load all attribute values from $dataMw->attribs to avoid O(N^2)
1382            // loading of list
1383            if ( $dataMw->attribs ?? false ) {
1384                $unused = [];
1385                foreach ( $dataMw->attribs as $a ) {
1386                    if ( $a instanceof DataMwAttrib ) {
1387                        $key = $a->key;
1388                        $value = $a->value;
1389                        // Attribute expander may use array values for
1390                        // key, since it supports rich key values.
1391                        // Ignore any entries created this way, since
1392                        // we can't preserve their values: they will be
1393                        // added to $unused and replaced.
1394                        if ( is_string( $key ) || is_numeric( $key ) ) {
1395                            $propName = self::RICH_ATTR_DATA_PREFIX . $key;
1396                            $nodeData = self::getNodeData( $node );
1397                            // wrap $value with an array to indicate that
1398                            // is it not yet decoded. Preserve the flattened
1399                            // value as well in case we round-trip without
1400                            // modifying this value.
1401                            $nodeData->$propName = [ $value, $flatValue ];
1402                            // Signal that the value has been moved to NodeData
1403                            // (this will also short cut this iteration over
1404                            // data-mw.attribs in future calls)
1405                            $node->removeAttribute( $key );
1406                            continue;
1407                        }
1408                    }
1409                    $unused[] = $a;
1410                }
1411                if ( count( $unused ) === 0 ) {
1412                    unset( $dataMw->attribs );
1413                } else {
1414                    $dataMw->attribs = $unused;
1415                }
1416            }
1417            return;
1418        }
1419        // The attribute does not have "special HTML semantics"
1420        $decoded = json_decode( $flatValue, false );
1421        // $decoded is the 'non-string' form of the value; we can't finish
1422        // deserializing it into an object until we know the appropriate type
1423        // hint.
1424        self::removeAttributeObject( $node, $name );
1425        $nodeData = self::getNodeData( $node );
1426        $propName = self::RICH_ATTR_DATA_PREFIX . $name;
1427        // Mark this as undecoded by wrapping it as an array,
1428        // since decoded values will always be objects.
1429        // (Attribute values without "special HTML semantics" do not
1430        // have flattened versions, so 2nd element to this array isn't
1431        // needed.)
1432        $nodeData->$propName = [ $decoded ];
1433    }
1434
1435    /**
1436     * Internal function to encode rich attribute data into an HTML
1437     * DOM representation.
1438     * @param Element $node The node possibly containing the rich attribute
1439     * @param array $options The options provided to ::storeDataAttribs()
1440     */
1441    private static function storeRichAttributes( Element $node, array $options ): void {
1442        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1443            return; // No rich attributes here
1444        }
1445        $tagName = $node->tagName;
1446        $nodeData = self::getNodeData( $node );
1447        $codec = self::getCodec( $node );
1448        foreach ( get_object_vars( $nodeData ) as $k => $v ) {
1449            // Look for dynamic properties with names w/ the proper prefix
1450            if ( str_starts_with( $k, self::RICH_ATTR_DATA_PREFIX ) ) {
1451                $attrName = substr( $k, strlen( self::RICH_ATTR_DATA_PREFIX ) );
1452                if (
1453                    ( $options['onlySpecial'] ?? false ) &&
1454                    !self::isHtmlAttributeWithSpecialSemantics( $tagName, $attrName )
1455                ) {
1456                    continue; // skip this for now
1457                }
1458                if ( is_array( $v ) ) {
1459                    // If $v is an array, it was never decoded.
1460                    $json = $v[0];
1461                    $flat = $v[1] ?? null;
1462                } else {
1463                    $hintName = self::RICH_ATTR_HINT_PREFIX . $attrName;
1464                    $classHint = $nodeData->$hintName ?? null;
1465                    if ( is_a( $v, RichCodecable::class ) ) {
1466                        $classHint ??= $v::hint();
1467                    }
1468                    $classHint ??= get_class( $v );
1469                    try {
1470                        // NOTE: call 'flatten()' before 'toJsonArray()' since
1471                        // the latter may have side effects on $v.
1472                        $flat = $codec->flatten( $v );
1473                        $json = $codec->toJsonArray( $v, $classHint );
1474                    } catch ( InvalidArgumentException $e ) {
1475                        // For better debuggability, include the attribute name
1476                        throw new InvalidArgumentException( "$attrName" . $e->getMessage() );
1477                    }
1478                }
1479                if ( !self::isHtmlAttributeWithSpecialSemantics( $tagName, $attrName ) ) {
1480                    $encoded = PHPUtils::jsonEncode( $json );
1481                    $node->setAttribute( $attrName, $encoded );
1482                } else {
1483                    // For compatibility, store the rich value in data-mw.attribs
1484                    // and store a flattened version in the $attrName.
1485                    if ( $flat !== null ) {
1486                        $node->setAttribute( $attrName, $flat );
1487                    } else {
1488                        $node->removeAttribute( $attrName );
1489                    }
1490                    $dataMw = self::getDataMw( $node );
1491                    $dataMw->attribs[] = new DataMwAttrib( $attrName, $json );
1492                    DOMUtils::addTypeOf( $node, 'mw:ExpandedAttrs' );
1493                }
1494                unset( $nodeData->$k );
1495            }
1496        }
1497    }
1498
1499    /**
1500     * Modify the attribute array, replacing data-object-id with JSON
1501     * encoded data.  This is just a debugging hack, not to be confused with
1502     * DOMDataUtils::storeDataAttribs(), and does not store flattened
1503     * versions of attributes.
1504     *
1505     * @param Element $node
1506     * @param array &$attrs
1507     * @param bool $keepTmp
1508     * @param bool $storeDiffMark
1509     */
1510    public static function dumpRichAttribs( Element $node, array &$attrs, bool $keepTmp, bool $storeDiffMark ): void {
1511        if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
1512            return; // No rich attributes here
1513        }
1514        $nodeData = self::getNodeData( $node );
1515        $codec = self::getCodec( $node );
1516        // Reset to a default set of codec options
1517        // (in particular, make sure 'useFragmentBank' is not set)
1518        $oldOptions = $codec->setOptions( [ 'noSideEffects' => true, ] );
1519        foreach ( get_object_vars( $nodeData ) as $k => $v ) {
1520            // Look for dynamic properties with names w/ the proper prefix
1521            if ( str_starts_with( $k, self::RICH_ATTR_DATA_PREFIX ) ) {
1522                $attrName = substr( $k, strlen( self::RICH_ATTR_DATA_PREFIX ) );
1523                if ( is_array( $v ) ) {
1524                    // If $v is an array, it was never decoded.
1525                    $json = $v[0];
1526                } else {
1527                    $hintName = self::RICH_ATTR_HINT_PREFIX . $attrName;
1528                    $classHint = $nodeData->$hintName ?? null;
1529                    if ( is_a( $v, RichCodecable::class ) ) {
1530                        $classHint ??= $v::hint();
1531                    }
1532                    $classHint ??= get_class( $v );
1533                    $json = $codec->toJsonArray( $v, $classHint );
1534                }
1535                $encoded = PHPUtils::jsonEncode( $json );
1536                $attrs[$attrName] = $encoded;
1537            }
1538        }
1539        $dp = $nodeData->parsoid;
1540        if ( $dp ) {
1541            if ( !$keepTmp ) {
1542                $dp = clone $dp;
1543                // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
1544                unset( $dp->tmp );
1545            }
1546            $attrs['data-parsoid'] = $codec->toJsonString(
1547                $dp, self::getCodecHints()['data-parsoid']
1548            );
1549        }
1550        $dmw = $nodeData->mw;
1551        if ( $dmw ) {
1552            $attrs['data-mw'] = $codec->toJsonString(
1553                $dmw, self::getCodecHints()['data-mw']
1554            );
1555        }
1556        if ( !$storeDiffMark ) {
1557            unset( $attrs['data-parsoid-diff'] );
1558        }
1559        unset( $attrs[self::DATA_OBJECT_ATTR_NAME] );
1560        // Restore codec options
1561        $codec->setOptions( $oldOptions );
1562    }
1563}