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