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