Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 385
References
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 15
16002
0.00% covered (danger)
0.00%
0 / 385
 hasRef
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 9
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 createReferences
0.00% covered (danger)
0.00%
0 / 1
72
0.00% covered (danger)
0.00%
0 / 32
 extractRefFromNode
0.00% covered (danger)
0.00%
0 / 1
2970
0.00% covered (danger)
0.00%
0 / 166
 setMisnested
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 4
 addErrorsToNode
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 5
 insertReferencesIntoDOM
0.00% covered (danger)
0.00%
0 / 1
380
0.00% covered (danger)
0.00%
0 / 44
 insertMissingReferencesIntoDOM
0.00% covered (danger)
0.00%
0 / 1
6
0.00% covered (danger)
0.00%
0 / 16
 processEmbeddedRefs
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 3
 processRefs
0.00% covered (danger)
0.00%
0 / 1
90
0.00% covered (danger)
0.00%
0 / 26
 addEmbeddedErrors
0.00% covered (danger)
0.00%
0 / 1
72
0.00% covered (danger)
0.00%
0 / 27
 sourceToDom
0.00% covered (danger)
0.00%
0 / 1
20
0.00% covered (danger)
0.00%
0 / 22
 domToWikitext
0.00% covered (danger)
0.00%
0 / 1
30
0.00% covered (danger)
0.00%
0 / 14
 lintHandler
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 5
 diffHandler
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 11
<?php
declare( strict_types = 1 );
namespace Wikimedia\Parsoid\Ext\Cite;
use stdClass;
use Wikimedia\Parsoid\Core\DomSourceRange;
use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\Ext\DOMDataUtils;
use Wikimedia\Parsoid\Ext\DOMUtils;
use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Ext\PHPUtils;
use Wikimedia\Parsoid\Ext\WTUtils;
use Wikimedia\Parsoid\NodeData\DataParsoid;
use Wikimedia\Parsoid\Utils\DOMCompat;
class References extends ExtensionTagHandler {
    /**
     * @param Node $node
     * @return bool
     */
    private static function hasRef( Node $node ): bool {
        $c = $node->firstChild;
        while ( $c ) {
            if ( $c instanceof Element ) {
                if ( WTUtils::isSealedFragmentOfType( $c, 'ref' ) ) {
                    return true;
                }
                if ( self::hasRef( $c ) ) {
                    return true;
                }
            }
            $c = $c->nextSibling;
        }
        return false;
    }
    /**
     * It should be sufficient to only include styles when we're rendering
     * a references tag.
     *
     * @return array
     */
    private static function getModuleStyles(): array {
        return [
            'ext.cite.style',
            'ext.cite.styles'
        ];
    }
    /**
     * @param ParsoidExtensionAPI $extApi
     * @param DocumentFragment $domFragment
     * @param array $refsOpts
     * @param ?callable $modifyDp
     * @param bool $autoGenerated
     * @return Element
     */
    private static function createReferences(
        ParsoidExtensionAPI $extApi, DocumentFragment $domFragment,
        array $refsOpts, ?callable $modifyDp, bool $autoGenerated = false
    ): Element {
        $doc = $domFragment->ownerDocument;
        $ol = $doc->createElement( 'ol' );
        DOMCompat::getClassList( $ol )->add( 'mw-references' );
        DOMCompat::getClassList( $ol )->add( 'references' );
        DOMUtils::migrateChildren( $domFragment, $ol );
        // Support the `responsive` parameter
        $rrOpts = $extApi->getSiteConfig()->responsiveReferences();
        $responsiveWrap = !empty( $rrOpts['enabled'] );
        if ( $refsOpts['responsive'] !== null ) {
            $responsiveWrap = $refsOpts['responsive'] !== '0';
        }
        if ( $responsiveWrap ) {
            $div = $doc->createElement( 'div' );
            DOMCompat::getClassList( $div )->add( 'mw-references-wrap' );
            $div->appendChild( $ol );
            $frag = $div;
        } else {
            $frag = $ol;
        }
        if ( $autoGenerated ) {
            // FIXME: This is very much trying to copy ExtensionHandler::onDocument
            DOMUtils::addAttributes( $frag, [
                'typeof' => 'mw:Extension/references',
                'about' => $extApi->newAboutId()
            ] );
            $dataMw = (object)[
                'name' => 'references',
                'attrs' => new stdClass
            ];
            // Dont emit empty keys
            if ( $refsOpts['group'] ) {
                $dataMw->attrs->group = $refsOpts['group'];
            }
            DOMDataUtils::setDataMw( $frag, $dataMw );
        }
        $dp = DOMDataUtils::getDataParsoid( $frag );
        if ( $refsOpts['group'] ) {  // No group for the empty string either
            $dp->group = $refsOpts['group'];
            $ol->setAttribute( 'data-mw-group', $refsOpts['group'] );
        }
        if ( $modifyDp ) {
            $modifyDp( $dp );
        }
        $extApi->addModuleStyles( self::getModuleStyles() );
        return $frag;
    }
    /**
     * @param ParsoidExtensionAPI $extApi
     * @param Element $node
     * @param ReferencesData $refsData
     */
    private static function extractRefFromNode(
        ParsoidExtensionAPI $extApi, Element $node, ReferencesData $refsData
    ): void {
        $doc = $node->ownerDocument;
        $errs = [];
        // This is data-parsoid from the dom fragment node that's gone through
        // dsr computation and template wrapping.
        $nodeDp = DOMDataUtils::getDataParsoid( $node );
        $typeOf = $node->getAttribute( 'typeof' ) ?? '';
        $types = explode( ' ', $typeOf );
        $isTplWrapper = in_array( 'mw:Transclusion', $types, true );
        $nodeType = implode( ' ', array_diff( $types, [ 'mw:DOMFragment/sealed/ref' ] ) );
        $contentId = $nodeDp->html;
        $tplDmw = $isTplWrapper ? DOMDataUtils::getDataMw( $node ) : null;
        // This is the <sup> that's the meat of the sealed fragment
        $c = $extApi->getContentDOM( $contentId )->firstChild;
        DOMUtils::assertElt( $c );
        $cDp = DOMDataUtils::getDataParsoid( $c );
        $refDmw = DOMDataUtils::getDataMw( $c );
        // Use the about attribute on the wrapper with priority, since it's
        // only added when the wrapper is a template sibling.
        $about = $node->hasAttribute( 'about' )
            ? $node->getAttribute( 'about' )
            : $c->getAttribute( 'about' );
        // FIXME(SSS): Need to clarify semantics here.
        // If both the containing <references> elt as well as the nested <ref>
        // elt has a group attribute, what takes precedence?
        $groupName = $refDmw->attrs->group ?? $refsData->referencesGroup;
        $group = $refsData->getRefGroup( $groupName );
        if (
            $refsData->inReferencesContent() &&
            $groupName !== $refsData->referencesGroup
        ) {
            $errs[] = [ 'key' => 'cite_error_references_group_mismatch',
                'params' => [ $refDmw->attrs->group ] ];
        }
        // NOTE: This will have been trimmed in Utils::getExtArgInfo()'s call
        // to TokenUtils::kvToHash() and ExtensionHandler::normalizeExtOptions()
        $refName = $refDmw->attrs->name ?? '';
        $followName = $refDmw->attrs->follow ?? '';
        $refDir = strtolower( $refDmw->attrs->dir ?? '' );
        // Add ref-index linkback
        $linkBack = $doc->createElement( 'sup' );
        $ref = null;
        $hasRefName = strlen( $refName ) > 0;
        $hasFollow = strlen( $followName ) > 0;
        $validFollow = false;
        if ( $hasFollow ) {
            // Always wrap follows content so that there's no ambiguity
            // where to find it when roundtripping
            $span = $doc->createElement( 'span' );
            DOMUtils::addTypeOf( $span, 'mw:Cite/Follow' );
            $span->setAttribute( 'about', $about );
            $span->appendChild(
                $doc->createTextNode( ' ' )
            );
            DOMUtils::migrateChildren( $c, $span );
            $c->appendChild( $span );
        }
        $html = '';
        $contentDiffers = false;
        if ( $hasRefName ) {
            if ( $hasFollow ) {
                // Presumably, "name" has higher precedence
                $errs[] = [ 'key' => 'cite_error_ref_too_many_keys' ];
            }
            if ( isset( $group->indexByName[$refName] ) ) {
                $ref = $group->indexByName[$refName];
                // If there are multiple <ref>s with the same name, but different content,
                // the content of the first <ref> shows up in the <references> section.
                // in order to ensure lossless RT-ing for later <refs>, we have to record
                // HTML inline for all of them.
                if ( $ref->contentId ) {
                    if ( $ref->cachedHtml === null ) {
                        $refContent = $extApi->getContentDOM( $ref->contentId )->firstChild;
                        $ref->cachedHtml = $extApi->domToHtml( $refContent, true, false );
                    }
                    // See the test, "Forward-referenced ref with magical follow edge case"
                    // Ideally, we should strip the mw:Cite/Follow wrappers before comparing
                    // But, we are going to ignore this edge case as not worth the complexity.
                    $html = $extApi->domToHtml( $c, true, false );
                    $contentDiffers = ( $html !== $ref->cachedHtml );
                }
            } else {
                if ( $refsData->inReferencesContent() ) {
                    $errs[] = [
                        'key' => 'cite_error_references_missing_key',
                        'params' => [ $refDmw->attrs->name ]
                    ];
                }
            }
        } else {
            if ( $hasFollow ) {
                // This is a follows ref, so check that a named ref has already
                // been defined
                if ( isset( $group->indexByName[$followName] ) ) {
                    $validFollow = true;
                    $ref = $group->indexByName[$followName];
                } else {
                    // FIXME: This key isn't exactly appropriate since this
                    // is more general than just being in a <references>
                    // section and it's the $followName we care about, but the
                    // extension to the legacy parser doesn't have an
                    // equivalent key and just outputs something wacky.
                    $errs[] = [ 'key' => 'cite_error_references_missing_key',
                        'params' => [ $refDmw->attrs->follow ] ];
                }
            } elseif ( $refsData->inReferencesContent() ) {
                $errs[] = [ 'key' => 'cite_error_references_no_key' ];
            }
        }
        // Process nested ref-in-ref
        //
        // Do this before possibly adding the a ref below or
        // migrating contents out of $c if we have a valid follow
        if ( empty( $cDp->empty ) && self::hasRef( $c ) ) {
            if ( $contentDiffers ) {
                $refsData->pushEmbeddedContentFlag();
            }
            self::processRefs( $extApi, $refsData, $c );
            if ( $contentDiffers ) {
                $refsData->popEmbeddedContentFlag();
                // If we have refs and the content differs, we need to
                // reserialize now that we processed the refs.  Unfortunately,
                // the cachedHtml we compared against already had its refs
                // processed so that would presumably never match and this will
                // always be considered a redefinition.  The implementation for
                // the legacy parser also considers this a redefinition so
                // there is likely little content out there like this :)
                $html = $extApi->domToHtml( $c, true, true );
            }
        }
        if ( $validFollow ) {
            // Migrate content from the follow to the ref
            if ( $ref->contentId ) {
                $refContent = $extApi->getContentDOM( $ref->contentId )->firstChild;
                DOMUtils::migrateChildren( $c, $refContent );
            } else {
                // Otherwise, we have a follow that comes after a named
                // ref without content so use the follow fragment as
                // the content
                // This will be set below with `$ref->contentId = $contentId;`
            }
        } else {
            // If we have !$ref, one might have been added in the call to
            // processRefs, ie. a self-referential ref.  We could try to look
            // it up again, but Parsoid is choosing not to support that.
            // Even worse would be if it tried to redefine itself!
            if ( !$ref ) {
                $ref = $refsData->add( $extApi, $groupName, $refName );
            }
            // Handle linkbacks
            if ( $refsData->inEmbeddedContent() ) {
                $ref->embeddedNodes[] = $about;
            } else {
                $ref->nodes[] = $linkBack;
                $ref->linkbacks[] = $ref->key . '-' . count( $ref->linkbacks );
            }
        }
        if ( isset( $refDmw->attrs->dir ) && $refDir !== 'rtl' && $refDir !== 'ltr' ) {
            $errs[] = [ 'key' => 'cite_error_ref_invalid_dir',
                'params' => [ $refDmw->attrs->dir ] ];
        }
        // FIXME: At some point this error message can be changed to a warning, as Parsoid Cite now
        // supports numerals as a name without it being an actual error, but core Cite does not.
        // Follow refs do not duplicate the error which can be correlated with the original ref.
        if ( ctype_digit( $refName ) ) {
            $errs[] = [ 'key' => 'cite_error_ref_numeric_key' ];
        }
        // Check for missing content, added ?? '' to fix T259676 crasher
        // FIXME: See T260082 for a more complete description of cause and deeper fix
        $missingContent = ( !empty( $cDp->empty ) || trim( $refDmw->body->extsrc ?? '' ) === '' );
        if ( $missingContent ) {
            // Check for missing name and content to generate error code
            //
            // In references content, refs should be used for definition so missing content
            // is an error.  It's possible that no name is present (!hasRefName), which also
            // gets the error "cite_error_references_no_key" above, so protect against that.
            if ( $refsData->inReferencesContent() ) {
                $errs[] = [ 'key' => 'cite_error_empty_references_define',
                    'params' => [ $refDmw->attrs->name ?? '' ] ];
            } elseif ( !$hasRefName ) {
                if ( !empty( $cDp->selfClose ) ) {
                    $errs[] = [ 'key' => 'cite_error_ref_no_key' ];
                } else {
                    $errs[] = [ 'key' => 'cite_error_ref_no_input' ];
                }
            }
            if ( !empty( $cDp->selfClose ) ) {
                unset( $refDmw->body );
            } else {
                // Empty the <sup> since we've serialized its children and
                // removing it below asserts everything has been migrated out
                DOMCompat::replaceChildren( $c );
                $refDmw->body = (object)[ 'html' => $refDmw->body->extsrc ?? '' ];
            }
        } else {
            if ( $ref->contentId && !$validFollow ) {
                // Empty the <sup> since we've serialized its children and
                // removing it below asserts everything has been migrated out
                DOMCompat::replaceChildren( $c );
            }
            if ( $contentDiffers ) {
                // TODO: Since this error is being placed on the ref, the
                // key should arguably be "cite_error_ref_duplicate_key"
                $errs[] = [
                    'key' => 'cite_error_references_duplicate_key',
                    'params' => [ $refDmw->attrs->name ]
                ];
                $refDmw->body = (object)[ 'html' => $html ];
            } else {
                $refDmw->body = (object)[ 'id' => 'mw-reference-text-' . $ref->target ];
            }
        }
        $class = 'mw-ref reference';
        if ( $validFollow ) {
            $class .= ' mw-ref-follow';
        }
        $lastLinkback = $ref->linkbacks[count( $ref->linkbacks ) - 1] ?? null;
        DOMUtils::addAttributes( $linkBack, [
                'about' => $about,
                'class' => $class,
                'id' => ( $refsData->inEmbeddedContent() || $validFollow ) ?
                    null : ( $ref->name ? $lastLinkback : $ref->id ),
                'rel' => 'dc:references',
                'typeof' => $nodeType
            ]
        );
        DOMUtils::addTypeOf( $linkBack, 'mw:Extension/ref' );
        $dataParsoid = new DataParsoid;
        if ( isset( $nodeDp->src ) ) {
            $dataParsoid->src = $nodeDp->src;
        }
        if ( isset( $nodeDp->dsr ) ) {
            $dataParsoid->dsr = $nodeDp->dsr;
        }
        if ( isset( $nodeDp->pi ) ) {
            $dataParsoid->pi = $nodeDp->pi;
        }
        DOMDataUtils::setDataParsoid( $linkBack, $dataParsoid );
        $dmw = $isTplWrapper ? $tplDmw : $refDmw;
        DOMDataUtils::setDataMw( $linkBack, $dmw );
        // FIXME(T214241): Should the errors be added to data-mw if
        // $isTplWrapper?  Here and other calls to addErrorsToNode.
        if ( count( $errs ) > 0 ) {
            self::addErrorsToNode( $linkBack, $errs );
        }
        // refLink is the link to the citation
        $refLink = $doc->createElement( 'a' );
        DOMUtils::addAttributes( $refLink, [
            'href' => $extApi->getPageUri() . '#' . $ref->target,
            'style' => 'counter-reset: mw-Ref ' . $ref->groupIndex . ';',
        ] );
        if ( $ref->group ) {
            $refLink->setAttribute( 'data-mw-group', $ref->group );
        }
        // refLink-span which will contain a default rendering of the cite link
        // for browsers that don't support counters
        $refLinkSpan = $doc->createElement( 'span' );
        $refLinkSpan->setAttribute( 'class', 'mw-reflink-text' );
        $refLinkSpan->appendChild( $doc->createTextNode(
            '[' . ( $ref->group ? $ref->group . ' ' : '' ) . $ref->groupIndex . ']'
        ) );
        $refLink->appendChild( $refLinkSpan );
        $linkBack->appendChild( $refLink );
        // Checking if the <ref> is nested in a link
        $aParent = DOMUtils::findAncestorOfName( $node, 'a' );
        if ( $aParent !== null ) {
            // If we find a parent link, we hoist the reference up, just after the link
            // But if there's multiple references in a single link, we want to insert in order -
            // so we look for other misnested references before inserting
            $insertionPoint = $aParent->nextSibling;
            while ( $insertionPoint instanceof Element &&
                DOMCompat::nodeName( $insertionPoint ) === 'sup' &&
                !empty( DOMDataUtils::getDataParsoid( $insertionPoint )->misnested )
            ) {
                $insertionPoint = $insertionPoint->nextSibling;
            }
            $aParent->parentNode->insertBefore( $linkBack, $insertionPoint );
            // set misnested to true and DSR to zero-sized to avoid round-tripping issues
            $dsrOffset = DOMDataUtils::getDataParsoid( $aParent )->dsr->end ?? null;
            // we created that node hierarchy above, so we know that it only contains these nodes,
            // hence there's no need for a visitor
            self::setMisnested( $linkBack, $dsrOffset );
            self::setMisnested( $refLink, $dsrOffset );
            self::setMisnested( $refLinkSpan, $dsrOffset );
            if ( $aParent->hasAttribute( 'about' ) ) {
                DOMUtils::addAttributes( $linkBack,
                    [ 'about' => $aParent->getAttribute( 'about' ) ]
                );
            }
            $node->parentNode->removeChild( $node );
        } else {
            // if not, we insert it where we planned in the first place
            $node->parentNode->replaceChild( $linkBack, $node );
        }
        // Keep the first content to compare multiple <ref>s with the same name.
        if ( $ref->contentId === null && !$missingContent ) {
            $ref->contentId = $contentId;
            $ref->dir = $refDir;
        } else {
            DOMCompat::remove( $c );
            $extApi->clearContentDOM( $contentId );
        }
    }
    /**
     * Sets a node as misnested and its DSR as zero-width.
     * @param Element $node
     * @param int|null $offset
     * @return void
     */
    private static function setMisnested( Element $node, ?int $offset ) {
        $dataParsoid = DOMDataUtils::getDataParsoid( $node );
        $dataParsoid->misnested = true;
        $dataParsoid->dsr = new DomSourceRange( $offset, $offset, null, null );
    }
    /**
     * @param Element $node
     * @param array $errs
     */
    private static function addErrorsToNode( Element $node, array $errs ) {
        DOMUtils::addTypeOf( $node, 'mw:Error' );
        $dmw = DOMDataUtils::getDataMw( $node );
        $dmw->errors = is_array( $dmw->errors ?? null ) ?
            array_merge( $dmw->errors, $errs ) : $errs;
    }
    /**
     * @param ParsoidExtensionAPI $extApi
     * @param Element $refsNode
     * @param ReferencesData $refsData
     * @param bool $autoGenerated
     */
    private static function insertReferencesIntoDOM(
        ParsoidExtensionAPI $extApi, Element $refsNode,
        ReferencesData $refsData, bool $autoGenerated = false
    ): void {
        $isTplWrapper = DOMUtils::hasTypeOf( $refsNode, 'mw:Transclusion' );
        $dp = DOMDataUtils::getDataParsoid( $refsNode );
        $group = $dp->group ?? '';
        $refGroup = $refsData->getRefGroup( $group );
        // Iterate through the ref list to back-patch typeof and data-mw error
        // information into ref for errors only known at time of references
        // insertion.  Refs in the top level dom will be processed immediately,
        // whereas embedded refs will be gathered for batch processing, since
        // we need to parse embedded content to find them.
        if ( $refGroup ) {
            $autoGeneratedWithGroup = ( $autoGenerated && $group !== '' );
            foreach ( $refGroup->refs as $ref ) {
                $errs = [];
                // Mark all refs that are part of a group that is autogenerated
                if ( $autoGeneratedWithGroup ) {
                    $errs[] = [ 'key' => 'cite_error_group_refs_without_references',
                        'params' => [ $group ] ];
                }
                // Mark all refs that are named without content
                if ( ( $ref->name !== '' ) && $ref->contentId === null ) {
                    // TODO: Since this error is being placed on the ref,
                    // the key should arguably be "cite_error_ref_no_text"
                    $errs[] = [ 'key' => 'cite_error_references_no_text' ];
                }
                if ( count( $errs ) > 0 ) {
                    foreach ( $ref->nodes as $node ) {
                        self::addErrorsToNode( $node, $errs );
                    }
                    foreach ( $ref->embeddedNodes as $about ) {
                        $refsData->embeddedErrors[$about] = $errs;
                    }
                }
            }
        }
        // Note that `$sup`s here are probably all we really need to check for
        // errors caught with `$refsData->inReferencesContent()` but it's
        // probably easier to just know that state while they're being
        // constructed.
        $nestedRefsHTML = array_map(
            static function ( Element $sup ) use ( $extApi ) {
                return $extApi->domToHtml( $sup, false, true ) . "\n";
            },
            PHPUtils::iterable_to_array( DOMCompat::querySelectorAll(
                $refsNode, 'sup[typeof~=\'mw:Extension/ref\']'
            ) )
        );
        if ( !$isTplWrapper ) {
            $dataMw = DOMDataUtils::getDataMw( $refsNode );
            // Mark this auto-generated so that we can skip this during
            // html -> wt and so that clients can strip it if necessary.
            if ( $autoGenerated ) {
                $dataMw->autoGenerated = true;
            } elseif ( count( $nestedRefsHTML ) > 0 ) {
                $dataMw->body = (object)[ 'html' => "\n" . implode( $nestedRefsHTML ) ];
            } elseif ( empty( $dp->selfClose ) ) {
                $dataMw->body = PHPUtils::arrayToObject( [ 'html' => '' ] );
            } else {
                unset( $dataMw->body );
            }
            unset( $dp->selfClose );
        }
        // Deal with responsive wrapper
        if ( DOMCompat::getClassList( $refsNode )->contains( 'mw-references-wrap' ) ) {
            $rrOpts = $extApi->getSiteConfig()->responsiveReferences();
            // NOTE: The default Cite implementation hardcodes this threshold to 10.
            // We use a configurable parameter here primarily for test coverage purposes.
            // See citeParserTests.txt where we set a threshold of 1 or 2.
            if ( $refGroup && count( $refGroup->refs ) > $rrOpts['threshold'] ) {
                DOMCompat::getClassList( $refsNode )->add( 'mw-references-columns' );
            }
            $refsNode = $refsNode->firstChild;
        }
        // Remove all children from the references node
        //
        // Ex: When {{Reflist}} is reused from the cache, it comes with
        // a bunch of references as well. We have to remove all those cached
        // references before generating fresh references.
        DOMCompat::replaceChildren( $refsNode );
        if ( $refGroup ) {
            foreach ( $refGroup->refs as $ref ) {
                $refGroup->renderLine( $extApi, $refsNode, $ref );
            }
        }
        // Remove the group from refsData
        $refsData->removeRefGroup( $group );
    }
    /**
     * Process `<ref>`s left behind after the DOM is fully processed.
     * We process them as if there was an implicit `<references />` tag at
     * the end of the DOM.
     *
     * @param ParsoidExtensionAPI $extApi
     * @param ReferencesData $refsData
     * @param Node $node
     */
    public static function insertMissingReferencesIntoDOM(
        ParsoidExtensionAPI $extApi, ReferencesData $refsData, Node $node
    ): void {
        $doc = $node->ownerDocument;
        foreach ( $refsData->getRefGroups() as $groupName => $refsGroup ) {
            $domFragment = $doc->createDocumentFragment();
            $frag = self::createReferences(
                $extApi,
                $domFragment,
                [
                    // Force string cast here since in the foreach above, $groupName
                    // is an array key. In that context, number-like strings are
                    // silently converted to a numeric value!
                    // Ex: In <ref group="2" />, the "2" becomes 2 in the foreach
                    'group' => (string)$groupName,
                    'responsive' => null,
                ],
                static function ( $dp ) use ( $extApi ) {
                    // The new references come out of "nowhere", so to make selser work
                    // properly, add a zero-sized DSR pointing to the end of the document.
                    $content = $extApi->getPageConfig()->getRevisionContent()->getContent( 'main' );
                    $contentLength = strlen( $content );
                    $dp->dsr = new DomSourceRange( $contentLength, $contentLength, 0, 0 );
                },
                true
            );
            // Add a \n before the <ol> so that when serialized to wikitext,
            // each <references /> tag appears on its own line.
            $node->appendChild( $doc->createTextNode( "\n" ) );
            $node->appendChild( $frag );
            self::insertReferencesIntoDOM( $extApi, $frag, $refsData, true );
        }
    }
    /**
     * @param ParsoidExtensionAPI $extApi
     * @param ReferencesData $refsData
     * @param string $str
     * @return string
     */
    private static function processEmbeddedRefs(
        ParsoidExtensionAPI $extApi, ReferencesData $refsData, string $str
    ): string {
        $domFragment = $extApi->htmlToDom( $str );
        self::processRefs( $extApi, $refsData, $domFragment );
        return $extApi->domToHtml( $domFragment, true, true );
    }
    /**
     * @param ParsoidExtensionAPI $extApi
     * @param ReferencesData $refsData
     * @param Node $node
     */
    public static function processRefs(
        ParsoidExtensionAPI $extApi, ReferencesData $refsData, Node $node
    ): void {
        $child = $node->firstChild;
        while ( $child !== null ) {
            $nextChild = $child->nextSibling;
            if ( $child instanceof Element ) {
                if ( WTUtils::isSealedFragmentOfType( $child, 'ref' ) ) {
                    self::extractRefFromNode( $extApi, $child, $refsData );
                } elseif ( DOMUtils::hasTypeOf( $child, 'mw:Extension/references' ) ) {
                    if ( !$refsData->inReferencesContent() ) {
                        $refsData->referencesGroup =
                            DOMDataUtils::getDataParsoid( $child )->group ?? '';
                    }
                    $refsData->pushEmbeddedContentFlag( 'references' );
                    if ( $child->hasChildNodes() ) {
                        self::processRefs( $extApi, $refsData, $child );
                    }
                    $refsData->popEmbeddedContentFlag();
                    if ( !$refsData->inReferencesContent() ) {
                        $refsData->referencesGroup = '';
                        self::insertReferencesIntoDOM( $extApi, $child, $refsData, false );
                    }
                } else {
                    $refsData->pushEmbeddedContentFlag();
                    // Look for <ref>s embedded in data attributes
                    $extApi->processHTMLHiddenInDataAttributes( $child,
                        function ( string $html ) use ( $extApi, $refsData ) {
                            return self::processEmbeddedRefs( $extApi, $refsData, $html );
                        }
                    );
                    $refsData->popEmbeddedContentFlag();
                    if ( $child->hasChildNodes() ) {
                        self::processRefs( $extApi, $refsData, $child );
                    }
                }
            }
            $child = $nextChild;
        }
    }
    /**
     * Traverse into all the embedded content and mark up the refs in there
     * that have errors that weren't known before the content was serialized.
     *
     * Some errors are only known at the time when we're inserting the
     * references lists, at which point, embedded content has already been
     * serialized and stored, so we no longer have live access to it.  We
     * therefore map about ids to errors for a ref at that time, and then do
     * one final walk of the dom to peak into all the embedded content and
     * mark up the errors where necessary.
     *
     * @param ParsoidExtensionAPI $extApi
     * @param ReferencesData $refsData
     * @param Node $node
     */
    public static function addEmbeddedErrors(
        ParsoidExtensionAPI $extApi, ReferencesData $refsData, Node $node
    ): void {
        $processEmbeddedErrors = function ( string $html ) use ( $extApi, $refsData ) {
            // Similar to processEmbeddedRefs
            $domFragment = $extApi->htmlToDom( $html );
            self::addEmbeddedErrors( $extApi, $refsData, $domFragment );
            return $extApi->domToHtml( $domFragment, true, true );
        };
        $processBodyHtml = static function ( Element $n ) use ( $processEmbeddedErrors ) {
            $dataMw = DOMDataUtils::getDataMw( $n );
            if ( is_string( $dataMw->body->html ?? null ) ) {
                $dataMw->body->html = $processEmbeddedErrors(
                    $dataMw->body->html
                );
            }
        };
        $child = $node->firstChild;
        while ( $child !== null ) {
            $nextChild = $child->nextSibling;
            if ( $child instanceof Element ) {
                if ( DOMUtils::hasTypeOf( $child, 'mw:Extension/ref' ) ) {
                    $processBodyHtml( $child );
                    $about = $child->getAttribute( 'about' ) ?? '';
                    $errs = $refsData->embeddedErrors[$about] ?? null;
                    if ( $errs ) {
                        self::addErrorsToNode( $child, $errs );
                    }
                } elseif ( DOMUtils::hasTypeOf( $child, 'mw:Extension/references' ) ) {
                    $processBodyHtml( $child );
                } else {
                    $extApi->processHTMLHiddenInDataAttributes(
                        $child, $processEmbeddedErrors
                    );
                }
                if ( $child->hasChildNodes() ) {
                    self::addEmbeddedErrors( $extApi, $refsData, $child );
                }
            }
            $child = $nextChild;
        }
    }
    /** @inheritDoc */
    public function sourceToDom(
        ParsoidExtensionAPI $extApi, string $txt, array $extArgs
    ): DocumentFragment {
        $domFragment = $extApi->extTagToDOM(
            $extArgs,
            '',
            $txt,
            [
                'wrapperTag' => 'div',
                'parseOpts' => [ 'extTag' => 'references' ],
            ]
        );
        $refsOpts = $extApi->extArgsToArray( $extArgs ) + [
            'group' => null,
            'responsive' => null,
        ];
        // Detect invalid parameters on the references tag
        $knownAttributes = [ 'group', 'responsive' ];
        foreach ( $refsOpts as $key => $value ) {
            if ( !in_array( strtolower( (string)$key ), $knownAttributes, true ) ) {
                $extApi->pushError( 'cite_error_references_invalid_parameters' );
                break;
            }
        }
        $frag = self::createReferences(
            $extApi,
            $domFragment,
            $refsOpts,
            static function ( $dp ) use ( $extApi ) {
                $dp->src = $extApi->extTag->getSource();
                // Setting redundant info on fragment.
                // $docBody->firstChild info feels cumbersome to use downstream.
                if ( $extApi->extTag->isSelfClosed() ) {
                    $dp->selfClose = true;
                }
            }
        );
        $domFragment->appendChild( $frag );
        return $domFragment;
    }
    /** @inheritDoc */
    public function domToWikitext(
        ParsoidExtensionAPI $extApi, Element $node, bool $wrapperUnmodified
    ) {
        $dataMw = DOMDataUtils::getDataMw( $node );
        // Autogenerated references aren't considered erroneous (the extension to the legacy
        // parser also generates them) and are not suppressed when serializing because apparently
        // that's the behaviour Parsoid clients want.  However, autogenerated references *with
        // group attributes* are errors (the legacy extension doesn't generate them at all) and
        // are suppressed when serialized since we considered them an error while parsing and
        // don't want them to persist in the content.
        if ( !empty( $dataMw->autoGenerated ) && ( $dataMw->attrs->group ?? '' ) !== '' ) {
            return '';
        } else {
            $startTagSrc = $extApi->extStartTagToWikitext( $node );
            if ( empty( $dataMw->body ) ) {
                return $startTagSrc; // We self-closed this already.
            } else {
                if ( is_string( $dataMw->body->html ) ) {
                    $src = $extApi->htmlToWikitext(
                        [ 'extName' => $dataMw->name ],
                        $dataMw->body->html
                    );
                    return $startTagSrc . $src . '</' . $dataMw->name . '>';
                } else {
                    $extApi->log( 'error',
                        'References body unavailable for: ' . DOMCompat::getOuterHTML( $node )
                    );
                    return ''; // Drop it!
                }
            }
        }
    }
    /** @inheritDoc */
    public function lintHandler(
        ParsoidExtensionAPI $extApi, Element $refs, callable $defaultHandler
    ): ?Node {
        $dataMw = DOMDataUtils::getDataMw( $refs );
        if ( is_string( $dataMw->body->html ?? null ) ) {
            $fragment = $extApi->htmlToDom( $dataMw->body->html );
            $defaultHandler( $fragment );
        }
        return $refs->nextSibling;
    }
    /** @inheritDoc */
    public function diffHandler(
        ParsoidExtensionAPI $extApi, callable $domDiff, Element $origNode,
        Element $editedNode
    ): bool {
        $origDataMw = DOMDataUtils::getDataMw( $origNode );
        $editedDataMw = DOMDataUtils::getDataMw( $editedNode );
        if ( isset( $origDataMw->body->html ) && isset( $editedDataMw->body->html ) ) {
            $origFragment = $extApi->htmlToDom(
                $origDataMw->body->html, $origNode->ownerDocument,
                [ 'markNew' => true ]
            );
            $editedFragment = $extApi->htmlToDom(
                $editedDataMw->body->html, $editedNode->ownerDocument,
                [ 'markNew' => true ]
            );
            return call_user_func( $domDiff, $origFragment, $editedFragment );
        }
        // FIXME: Similar to DOMDiff::subtreeDiffers, maybe $editNode should
        // be marked as inserted to avoid losing any edits, at the cost of
        // more normalization
        return false;
    }
}