Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
30.00% covered (danger)
30.00%
3 / 10
CRAP
74.12% covered (warning)
74.12%
189 / 255
DOMPostProcessor
0.00% covered (danger)
0.00%
0 / 1
30.00% covered (danger)
30.00%
3 / 10
144.83
74.12% covered (warning)
74.12%
189 / 255
 __construct
0.00% covered (danger)
0.00%
0 / 1
2.02
83.33% covered (warning)
83.33%
10 / 12
 registerProcessors
100.00% covered (success)
100.00%
1 / 1
10
100.00% covered (success)
100.00%
27 / 27
 getDefaultProcessors
0.00% covered (danger)
0.00%
0 / 1
12.74
69.84% covered (warning)
69.84%
44 / 63
 setSourceOffsets
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 resetState
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 updateBodyClasslist
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
11 / 11
 addMetaData
0.00% covered (danger)
0.00%
0 / 1
16.31
89.33% covered (warning)
89.33%
67 / 75
 doPostProcess
0.00% covered (danger)
0.00%
0 / 1
82.50
50.00% covered (danger)
50.00%
27 / 54
 process
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 processChunkily
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 6
<?php
declare( strict_types = 1 );
namespace Wikimedia\Parsoid\Wt2Html;
use Closure;
use DateTime;
use Generator;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\Ext\DOMProcessor as ExtDOMProcessor;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Tokens\SourceRange;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMDataUtils;
use Wikimedia\Parsoid\Utils\DOMTraverser;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Utils\PHPUtils;
use Wikimedia\Parsoid\Utils\Utils;
use Wikimedia\Parsoid\Utils\WTUtils;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\CleanUp;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\DedupeStyles;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\DisplaySpace;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\HandleLinkNeighbours;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\Headings;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\LiFixups;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\TableFixups;
use Wikimedia\Parsoid\Wt2Html\PP\Handlers\UnpackDOMFragments;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\AddLinkClasses;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\AddMediaInfo;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\AddRedLinks;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\ComputeDSR;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\ConvertOffsets;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\I18n;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\LangConverter;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\Linter;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\MarkFosteredContent;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\MigrateTemplateMarkerMetas;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\MigrateTrailingNLs;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\Normalize;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\ProcessTreeBuilderFixups;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\PWrap;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\WrapAnnotations;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\WrapSections;
use Wikimedia\Parsoid\Wt2Html\PP\Processors\WrapTemplates;
/**
 * Perform post-processing steps on an already-built HTML DOM.
 */
class DOMPostProcessor extends PipelineStage {
    /** @var array */
    private $options;
    /** @var array */
    private $seenIds;
    /** @var array */
    private $processors;
    /** @var ParsoidExtensionAPI Provides post-processing support to extensions */
    private $extApi;
    /** @var array */
    private $metadataMap;
    /** @var string */
    private $timeProfile = '';
    /**
     * @param Env $env
     * @param array $options
     * @param string $stageId
     * @param ?PipelineStage $prevStage
     */
    public function __construct(
        Env $env, array $options = [], string $stageId = "",
        ?PipelineStage $prevStage = null
    ) {
        parent::__construct( $env, $prevStage );
        $this->options = $options;
        $this->seenIds = [];
        $this->processors = [];
        $this->extApi = new ParsoidExtensionAPI( $env );
        // map from mediawiki metadata names to RDFa property names
        $this->metadataMap = [
            'ns' => [
                'property' => 'mw:pageNamespace',
                'content' => '%d',
            ],
            'id' => [
                'property' => 'mw:pageId',
                'content' => '%d',
            ],
            // DO NOT ADD rev_user, rev_userid, and rev_comment (See T125266)
            // 'rev_revid' is used to set the overall subject of the document, we don't
            // need to add a specific <meta> or <link> element for it.
            'rev_parentid' => [
                'rel' => 'dc:replaces',
                'resource' => 'mwr:revision/%d',
            ],
            'rev_timestamp' => [
                'property' => 'dc:modified',
                'content' => static function ( $m ) {
                    # Convert from TS_MW ("mediawiki timestamp") format
                    $dt = DateTime::createFromFormat( 'YmdHis', $m['rev_timestamp'] );
                    # Note that DateTime::ISO8601 is not actually ISO8601, alas.
                    return $dt->format( 'Y-m-d\TH:i:s.000\Z' );
                },
            ],
            'rev_sha1' => [
                'property' => 'mw:revisionSHA1',
                'content' => '%s',
            ]
        ];
    }
    /**
     * @param ?array $processors
     */
    public function registerProcessors( ?array $processors ): void {
        if ( empty( $processors ) ) {
            $processors = $this->getDefaultProcessors();
        }
        foreach ( $processors as $p ) {
            if ( !empty( $p['omit'] ) ) {
                continue;
            }
            if ( empty( $p['name'] ) ) {
                $p['name'] = Utils::stripNamespace( $p['Processor'] );
            }
            if ( empty( $p['shortcut'] ) ) {
                $p['shortcut'] = $p['name'];
            }
            if ( !empty( $p['isTraverser'] ) ) {
                $t = new DOMTraverser();
                foreach ( $p['handlers'] as $h ) {
                    $t->addHandler( $h['nodeName'], $h['action'] );
                }
                $p['proc'] = function ( ...$args ) use ( $t ) {
                    $args[] = null;
                    return $t->traverse( $this->env, ...$args );
                };
            } else {
                $classNameOrSpec = $p['Processor'];
                if ( empty( $p['isExtPP'] ) ) {
                    // Internal processor w/ ::run() method, class name given
                    $c = new $classNameOrSpec();
                    $p['proc'] = function ( ...$args ) use ( $c ) {
                        return $c->run( $this->env, ...$args );
                    };
                } else {
                    // Extension post processor, object factory spec given
                    $objectFactory = $this->env->getSiteConfig()->getObjectFactory();
                    $c = $objectFactory->createObject( $classNameOrSpec, [
                        'allowClassName' => true,
                        'assertClass' => ExtDOMProcessor::class,
                    ] );
                    $p['proc'] = function ( ...$args ) use ( $c ) {
                        return $c->wtPostprocess( $this->extApi, ...$args );
                    };
                }
            }
            $this->processors[] = $p;
        }
    }
    /**
     * @return array
     */
    public function getDefaultProcessors(): array {
        $env = $this->env;
        $options = $this->options;
        $seenIds = &$this->seenIds;
        $usedIdIndex = [];
        $abouts = [];
        $tableFixer = new TableFixups( $env );
        /* ---------------------------------------------------------------------------
         * FIXME:
         * 1. PipelineFactory caches pipelines per env
         * 2. PipelineFactory.parse uses a default cache key
         * 3. ParserTests uses a shared/global env object for all tests.
         * 4. ParserTests also uses PipelineFactory.parse (via env.getContentHandler())
         *    => the pipeline constructed for the first test that runs wt2html
         *       is used for all subsequent wt2html tests
         * 5. If we are selectively turning on/off options on a per-test basis
         *    in parser tests, those options won't work if those options are
         *    also used to configure pipeline construction (including which DOM passes
         *    are enabled).
         *
         *    Ex: if (env.wrapSections) { addPP('wrapSections', wrapSections); }
         *
         *    This won't do what you expect it to do. This is primarily a
         *    parser tests script issue -- but given the abstraction layers that
         *    are on top of the parser pipeline construction, fixing that is
         *    not straightforward right now. So, this note is a warning to future
         *    developers to pay attention to how they construct pipelines.
         * --------------------------------------------------------------------------- */
        $processors = [
            // Common post processing
            [
                'Processor' => MarkFosteredContent::class,
                'shortcut' => 'fostered'
            ],
            [
                'Processor' => ProcessTreeBuilderFixups::class,
                'shortcut' => 'process-fixups'
            ],
            [
                'Processor' => Normalize::class
            ],
            [
                'Processor' => PWrap::class,
                'shortcut' => 'pwrap',
                'skipNested' => true
            ],
            // This is run at all levels since, for now, we don't have a generic
            // solution to running top level passes on HTML stashed in data-mw.
            // See T214994 for that.
            //
            // Also, the gallery extension's "packed" mode would otherwise need a
            // post-processing pass to scale media after it has been fetched.  That
            // introduces an ordering dependency that may or may not complicate things.
            [
                'Processor' => AddMediaInfo::class,
                'shortcut' => 'media'
            ],
            // Run this after 'ProcessTreeBuilderFixups' because the mw:StartTag
            // and mw:EndTag metas would otherwise interfere with the
            // firstChild/lastChild check that this pass does.
            // FIXME: those metas no longer exist
            [
                'Processor' => MigrateTemplateMarkerMetas::class,
                'shortcut' => 'migrate-metas'
            ],
            [
                'Processor' => MigrateTrailingNLs::class,
                'shortcut' => 'migrate-nls'
            ],
            // dsr computation and tpl encap are only relevant for top-level content
            [
                'Processor' => ComputeDSR::class,
                'shortcut' => 'dsr',
                'omit' => $options['inTemplate']
            ],
            [
                'Processor' => WrapTemplates::class,
                'shortcut' => 'tplwrap',
                'omit' => $options['inTemplate']
            ],
            [
                'name' => 'AddAnnotationIds',
                'shortcut' => 'ann-ids',
                'isTraverser' => true,
                'handlers' => [
                    [
                        'nodeName' => 'meta',
                        'action' => static function ( $node, $env ) use ( &$abouts ) {
                            $isStart = false;
                            $t = WTUtils::extractAnnotationType( $node, $isStart );
                            if ( $t !== null ) {
                                $about = null;
                                if ( $isStart ) {
                                    // The 'mwa' prefix is specific to annotations;
                                    // if other DOM ranges are to use this mechanism, another prefix
                                    // should be used.
                                    $about = $env->newAnnotationId();
                                    if ( !array_key_exists( $t, $abouts ) ) {
                                        $abouts[$t] = [];
                                    }
                                    array_push( $abouts[$t], $about );
                                } else {
                                    if ( array_key_exists( $t, $abouts ) ) {
                                        $about = array_pop( $abouts[$t] );
                                    }
                                }
                                if ( $about !== null ) {
                                    $datamw = DOMDataUtils::getDataMw( $node );
                                    $datamw->rangeId = $about;
                                    DOMDataUtils::setDataMw( $node, $datamw );
                                }
                            }
                            return true;
                        }
                    ]
                ],
                'withAnnotations' => true
            ],
            [
                'Processor' => WrapAnnotations::class,
                'shortcut' => 'annwrap',
                'withAnnotations' => true
            ],
            // 1. Link prefixes and suffixes
            // 2. Unpack DOM fragments
            [
                'name' => 'HandleLinkNeighbours,UnpackDOMFragments',
                'shortcut' => 'dom-unpack',
                'isTraverser' => true,
                'handlers' => [
                    [
                        'nodeName' => 'a',
                        'action' => [ HandleLinkNeighbours::class, 'handler' ]
                    ],
                    [
                        'nodeName' => null,
                        'action' => [ UnpackDOMFragments::class, 'handler' ]
                    ]
                ]
            ]
        ];
        /**
         * FIXME: There are two potential ordering problems here.
         *
         * 1. unpackDOMFragment should always run immediately
         *    before these extensionPostProcessors, which we do currently.
         *    This ensures packed content get processed correctly by extensions
         *    before additional transformations are run on the DOM.
         *
         * This ordering issue is handled through documentation.
         *
         * 2. This has existed all along (in the PHP parser as well as Parsoid
         *    which is probably how the ref-in-ref hack works - because of how
         *    parser functions and extension tags are procesed, #tag:ref doesn't
         *    see a nested ref anymore) and this patch only exposes that problem
         *    more clearly with the unpackOutput property.
         *
         * * Consider the set of extensions that
         *   (a) process wikitext
         *   (b) provide an extensionPostProcessor
         *   (c) run the extensionPostProcessor only on the top-level
         *   As of today, there is exactly one extension (Cite) that has all
         *   these properties, so the problem below is a speculative problem
         *   for today. But, this could potentially be a problem in the future.
         *
         * * Let us say there are at least two of them, E1 and E2 that
         *   support extension tags <e1> and <e2> respectively.
         *
         * * Let us say in an instance of <e1> on the page, <e2> is present
         *   and in another instance of <e2> on the page, <e1> is present.
         *
         * * In what order should E1's and E2's extensionPostProcessors be
         *   run on the top-level? Depending on what these handlers do, you
         *   could get potentially different results. You can see this quite
         *   starkly with the unpackOutput flag.
         *
         * * The ideal solution to this problem is to require that every extension's
         *   extensionPostProcessor be idempotent which lets us run these
         *   post processors repeatedly till the DOM stabilizes. But, this
         *   still doesn't necessarily guarantee that ordering doesn't matter.
         *   It just guarantees that with the unpackOutput flag set to false
         *   multiple extensions, all sealed fragments get fully processed.
         *   So, we still need to worry about that problem.
         *
         *   But, idempotence *could* potentially be a sufficient property in most cases.
         *   To see this, consider that there is a Footnotes extension which is similar
         *   to the Cite extension in that they both extract inline content in the
         *   page source to a separate section of output and leave behind pointers to
         *   the global section in the output DOM. Given this, the Cite and Footnote
         *   extension post processors would essentially walk the dom and
         *   move any existing inline content into that global section till it is
         *   done. So, even if a <footnote> has a <ref> and a <ref> has a <footnote>,
         *   we ultimately end up with all footnote content in the footnotes section
         *   and all ref content in the references section and the DOM stabilizes.
         *   Ordering is irrelevant here.
         *
         *   So, perhaps one way of catching these problems would be in code review
         *   by analyzing what the DOM postprocessor does and see if it introduces
         *   potential ordering issues.
         */
        foreach ( $env->getSiteConfig()->getExtDOMProcessors() as $extName => $domProcs ) {
            foreach ( $domProcs as $i => $domProcSpec ) {
                $processors[] = [
                    'isExtPP' => true, // This is an extension DOM post processor
                    'name' => "pp:$extName:$i",
                    'Processor' => $domProcSpec,
                ];
            }
        }
        $processors = array_merge( $processors, [
            [
                'name' => 'MigrateTrailingCategories,TableFixups,DedupeStyles',
                'shortcut' => 'fixups',
                'isTraverser' => true,
                'skipNested' => true,
                'handlers' => [
                    // Move trailing categories in <li>s out of the list
                    [
                        'nodeName' => 'li',
                        'action' => [ LiFixups::class, 'migrateTrailingCategories' ]
                    ],
                    [
                        'nodeName' => 'dt',
                        'action' => [ LiFixups::class, 'migrateTrailingCategories' ]
                    ],
                    [
                        'nodeName' => 'dd',
                        'action' => [ LiFixups::class, 'migrateTrailingCategories' ]
                    ],
                    // 2. Fix up issues from templated table cells and table cell attributes
                    [
                        'nodeName' => 'td',
                        'action' => function ( $node, $env, $options ) use ( &$tableFixer ) {
                            return $tableFixer->stripDoubleTDs( $node, $this->frame );
                        }
                    ],
                    [
                        'nodeName' => 'td',
                        'action' => function ( $node, $env, $options ) use ( &$tableFixer ) {
                            return $tableFixer->handleTableCellTemplates( $node, $this->frame );
                        }
                    ],
                    [
                        'nodeName' => 'th',
                        'action' => function ( $node, $env, $options ) use ( &$tableFixer ) {
                            return $tableFixer->handleTableCellTemplates( $node, $this->frame );
                        }
                    ],
                    // 3. Deduplicate template styles
                    // (should run after dom-fragment expansion + after extension post-processors)
                    [
                        'nodeName' => 'style',
                        'action' => [ DedupeStyles::class, 'dedupe' ]
                    ]
                ]
            ],
            // Benefits from running after determining which media are redlinks
            [
                'name' => 'Headings-genAnchors',
                'shortcut' => 'heading-ids',
                'isTraverser' => true,
                'skipNested' => true,
                'handlers' => [
                    [
                        'nodeName' => null,
                        'action' => [ Headings::class, 'genAnchors' ]
                    ],
                    [
                        'nodeName' => null,
                        'action' => static function ( $node, $env ) use ( &$seenIds ) {
                            return Headings::dedupeHeadingIds( $seenIds, $node );
                        }
                    ]
                ]
            ],
            [
                'Processor' => Linter::class,
                'omit' => !$env->getSiteConfig()->linting(),
                'skipNested' => true
            ],
            // Strip marker metas -- removes left over marker metas (ex: metas
            // nested in expanded tpl/extension output).
            [
                'name' => 'CleanUp-stripMarkerMetas',
                'shortcut' => 'strip-metas',
                'isTraverser' => true,
                'handlers' => [
                    [
                        'nodeName' => 'meta',
                        'action' => [ CleanUp::class, 'stripMarkerMetas' ]
                    ]
                ]
            ],
            // Language conversion and Red link marking are done here
            // *before* we cleanup and save data-parsoid because they
            // are also used in pb2pb/html2html passes, and we want to
            // keep their input/output formats consistent.
            [
                'Processor' => LangConverter::class,
                'shortcut' => 'lang-converter',
                'skipNested' => true
            ],
            [
                'Processor' => AddRedLinks::class,
                'shortcut' => 'redlinks',
                'skipNested' => true,
            ],
            [
                'name' => 'DisplaySpace',
                'shortcut' => 'displayspace',
                'skipNested' => true,
                'isTraverser' => true,
                'handlers' => [
                    [
                        'nodeName' => '#text',
                        'action' => [ DisplaySpace::class, 'leftHandler' ]
                    ],
                    [
                        'nodeName' => '#text',
                        'action' => [ DisplaySpace::class, 'rightHandler' ]
                    ],
                ]
            ],
            [
                'Processor' => AddLinkClasses::class,
                'shortcut' => 'linkclasses',
                // Note that embedded content doesn't get these classes
                'skipNested' => true
            ],
            // Add <section> wrappers around sections
            [
                'Processor' => WrapSections::class,
                'shortcut' => 'sections',
                'skipNested' => true
            ],
            [
                'Processor' => ConvertOffsets::class,
                'shortcut' => 'convertoffsets',
                'skipNested' => true,
            ],
            [
                'Processor' => I18n::class,
                'shortcut' => 'i18n',
                // FIXME(T214994): This should probably be `true`, since we
                // want this to be another html2html type pass, but then our
                // processor would need to handle nested content.  Redlinks,
                // displayspace, and others are ignoring that for now though,
                // so let's wait until there's a more general mechanism.
                'skipNested' => false,
            ],
            [
                'name' => 'CleanUp-handleEmptyElts,CleanUp-cleanupAndSaveDataParsoid',
                'shortcut' => 'cleanup',
                'isTraverser' => true,
                'handlers' => [
                    // Strip empty elements from template content
                    [
                        'nodeName' => null,
                        'action' => [ CleanUp::class, 'handleEmptyElements' ]
                    ],
                    // Save data.parsoid into data-parsoid html attribute.
                    // Make this its own thing so that any changes to the DOM
                    // don't affect other handlers that run alongside it.
                    [
                        'nodeName' => null,
                        'action' => static function (
                            $node, $env, $options, $atTopLevel, $tplInfo
                        ) use ( &$usedIdIndex ) {
                            if ( $atTopLevel && DOMUtils::isBody( $node ) ) {
                                $usedIdIndex = DOMDataUtils::usedIdIndex( $node );
                            }
                            return CleanUp::cleanupAndSaveDataParsoid(
                                $usedIdIndex, $node, $env, $atTopLevel,
                                $tplInfo
                            );
                        }
                    ]
                ]
            ],
        ] );
        return $processors;
    }
    /**
     * @inheritDoc
     */
    public function setSourceOffsets( SourceRange $so ): void {
        $this->options['sourceOffsets'] = $so;
    }
    /**
     * @inheritDoc
     */
    public function resetState( array $options ): void {
        parent::resetState( $options );
        $this->seenIds = [];
    }
    /**
     * @param Element $body
     * @param Env $env
     */
    private function updateBodyClasslist( Element $body, Env $env ): void {
        $dir = $env->getPageConfig()->getPageLanguageDir();
        $bodyCL = DOMCompat::getClassList( $body );
        $bodyCL->add( 'mw-content-' . $dir );
        $bodyCL->add( 'sitedir-' . $dir );
        $bodyCL->add( $dir );
        $body->setAttribute( 'dir', $dir );
        // Set 'mw-body-content' directly on the body.
        // This is the designated successor for #bodyContent in core skins.
        $bodyCL->add( 'mw-body-content' );
        // Set 'parsoid-body' to add the desired layout styling from Vector.
        $bodyCL->add( 'parsoid-body' );
        // Also, add the 'mediawiki' class.
        // Some Mediawiki:Common.css seem to target this selector.
        $bodyCL->add( 'mediawiki' );
        // Set 'mw-parser-output' directly on the body.
        // Templates target this class as part of the TemplateStyles RFC
        // FIXME: This isn't expected to be found on the same element as the
        // body class above, since some css targets it as a descendant.
        // In visual diff'ing, we migrate the body contents to a wrapper div
        // with this class to reduce visual differences.  Consider getting
        // rid of it.
        $bodyCL->add( 'mw-parser-output' );
    }
    /**
     * FIXME: consider moving to DOMUtils or Env.
     *
     * @param Env $env
     * @param Document $document
     */
    public function addMetaData( Env $env, Document $document ): void {
        // Set the charset in the <head> first.
        // This also adds the <head> element if it was missing.
        DOMUtils::appendToHead( $document, 'meta', [ 'charset' => 'utf-8' ] );
        // add mw: and mwr: RDFa prefixes
        $prefixes = [
            'dc: http://purl.org/dc/terms/',
            'mw: http://mediawiki.org/rdf/'
        ];
        $document->documentElement->setAttribute( 'prefix', implode( ' ', $prefixes ) );
        // (From wfParseUrl in core:)
        // Protocol-relative URLs are handled really badly by parse_url().
        // It's so bad that the easiest way to handle them is to just prepend
        // 'https:' and strip the protocol out later.
        $baseURI = $env->getSiteConfig()->baseURI();
        $wasRelative = substr( $baseURI, 0, 2 ) == '//';
        if ( $wasRelative ) {
            $baseURI = "https:$baseURI";
        }
        // add 'https://' to baseURI if it was missing
        $pu = parse_url( $baseURI );
        $mwrPrefix = ( !empty( $pu['scheme'] ) ? '' : 'https://' ) .
            $baseURI . 'Special:Redirect/';
        ( DOMCompat::getHead( $document ) )->setAttribute( 'prefix', 'mwr: ' . $mwrPrefix );
        // add <head> content based on page meta data:
        // Add page / revision metadata to the <head>
        // PORT-FIXME: We will need to do some refactoring to eliminate
        // this hardcoding. Probably even merge this into metadataMap
        $pageConfig = $env->getPageConfig();
        $revProps = [
            'id' => $pageConfig->getPageId(),
            'ns' => $pageConfig->getNs(),
            'rev_parentid' => $pageConfig->getParentRevisionId(),
            'rev_revid' => $pageConfig->getRevisionId(),
            'rev_sha1' => $pageConfig->getRevisionSha1(),
            'rev_timestamp' => $pageConfig->getRevisionTimestamp()
        ];
        foreach ( $revProps as $key => $value ) {
            // generate proper attributes for the <meta> or <link> tag
            if ( $value === null || $value === '' || !isset( $this->metadataMap[$key] ) ) {
                continue;
            }
            $attrs = [];
            $mdm = $this->metadataMap[$key];
            /** FIXME: The JS side has a bunch of other checks here */
            foreach ( $mdm as $k => $v ) {
                // evaluate a function, or perform sprintf-style formatting, or
                // use string directly, depending on value in metadataMap
                if ( $v instanceof Closure ) {
                    $v = $v( $revProps );
                } elseif ( strpos( $v, '%' ) !== false ) {
                    // @phan-suppress-next-line PhanPluginPrintfVariableFormatString
                    $v = sprintf( $v, $value );
                }
                $attrs[$k] = $v;
            }
            // <link> is used if there's a resource or href attribute.
            DOMUtils::appendToHead( $document,
                isset( $attrs['resource'] ) || isset( $attrs['href'] ) ? 'link' : 'meta',
                $attrs
            );
        }
        if ( $revProps['rev_revid'] ) {
            $document->documentElement->setAttribute(
                'about', $mwrPrefix . 'revision/' . $revProps['rev_revid']
            );
        }
        // Normalize before comparison
        if (
            str_replace( '_', ' ', $env->getSiteConfig()->mainpage() ) ===
            str_replace( '_', ' ', $env->getPageConfig()->getTitle() )
        ) {
            DOMUtils::appendToHead( $document, 'meta', [
                'property' => 'isMainPage',
                'content' => 'true' /* HTML attribute values should be strings */
            ] );
        }
        // Set the parsoid content-type strings
        // FIXME: Should we be using http-equiv for this?
        DOMUtils::appendToHead( $document, 'meta', [
                'property' => 'mw:htmlVersion',
                'content' => $env->getOutputContentVersion()
            ]
        );
        // Temporary backward compatibility for clients
        // This could be skipped if we support a version downgrade path
        // with a major version bump.
        DOMUtils::appendToHead( $document, 'meta', [
                'property' => 'mw:html:version',
                'content' => $env->getOutputContentVersion()
            ]
        );
        $expTitle = strtr( $env->getPageConfig()->getTitle(), ' ', '_' );
        $expTitle = explode( '/', $expTitle );
        $expTitle = array_map( static function ( $comp ) {
            return PHPUtils::encodeURIComponent( $comp );
        }, $expTitle );
        DOMUtils::appendToHead( $document, 'link', [
            'rel' => 'dc:isVersionOf',
            'href' => $env->getSiteConfig()->baseURI() . implode( '/', $expTitle )
        ] );
        // Add base href pointing to the wiki root
        DOMUtils::appendToHead( $document, 'base', [
            'href' => $env->getSiteConfig()->baseURI()
        ] );
        // Stick data attributes in the head
        if ( $env->pageBundle ) {
            DOMDataUtils::injectPageBundle( $document, DOMDataUtils::getPageBundle( $document ) );
        }
        // PageConfig guarantees language will always be non-null.
        $lang = $env->getPageConfig()->getPageLanguage();
        $body = DOMCompat::getBody( $document );
        $body->setAttribute( 'lang', Utils::bcp47n( $lang ) );
        $this->updateBodyClasslist( $body, $env );
        $env->getSiteConfig()->exportMetadataToHead(
            $document, $env->getMetadata(),
            $env->getPageConfig()->getTitle(), $lang
        );
        // Indicate whether LanguageConverter is enabled, so that downstream
        // caches can split on variant (if necessary)
        DOMUtils::appendToHead( $document, 'meta', [
                'http-equiv' => 'content-language',
                'content' => $env->htmlContentLanguage()
            ]
        );
        DOMUtils::appendToHead( $document, 'meta', [
                'http-equiv' => 'vary',
                'content' => $env->htmlVary()
            ]
        );
        if ( $env->profiling() ) {
            $profile = $env->getCurrentProfile();
            $body->appendChild( $body->ownerDocument->createTextNode( "\n" ) );
            $body->appendChild( $body->ownerDocument->createComment( $this->timeProfile ) );
            $body->appendChild( $body->ownerDocument->createTextNode( "\n" ) );
        }
    }
    /**
     * @param Node $node
     */
    public function doPostProcess( Node $node ): void {
        $env = $this->env;
        $hasDumpFlags = $env->hasDumpFlags();
        if ( $hasDumpFlags && $env->hasDumpFlag( 'dom:post-builder' ) ) {
            $opts = [];
            $env->writeDump( ContentUtils::dumpDOM( $node, 'DOM: after tree builder', $opts ) );
        }
        $prefix = null;
        $traceLevel = null;
        $resourceCategory = null;
        $profile = null;
        if ( $env->profiling() ) {
            $profile = $env->getCurrentProfile();
            if ( $this->atTopLevel ) {
                $this->timeProfile = str_repeat( "-", 85 ) . "\n";
                $prefix = 'TOP';
                // Turn off DOM pass timing tracing on non-top-level documents
                $resourceCategory = 'DOMPasses:TOP';
            } else {
                $prefix = '---';
                $resourceCategory = 'DOMPasses:NESTED';
            }
        }
        for ( $i = 0;  $i < count( $this->processors );  $i++ ) {
            $pp = $this->processors[$i];
            if ( !empty( $pp['skipNested'] ) && !$this->atTopLevel ) {
                continue;
            }
            if ( !empty( $pp['withAnnotations'] ) && !$this->env->hasAnnotations ) {
                continue;
            }
            $ppName = null;
            $ppStart = null;
            // Trace
            if ( $profile ) {
                $ppName = $pp['name'] . str_repeat(
                    " ",
                    ( strlen( $pp['name'] ) < 30 ) ? 30 - strlen( $pp['name'] ) : 0
                );
                $ppStart = microtime( true );
            }
            $opts = null;
            if ( $hasDumpFlags ) {
                $opts = [
                    'env' => $env,
                    'dumpFragmentMap' => $this->atTopLevel,
                    'keepTmp' => true
                ];
                if ( $env->hasDumpFlag( 'dom:pre-' . $pp['shortcut'] )
                    || $env->hasDumpFlag( 'dom:pre-*' )
                ) {
                    $env->writeDump(
                        ContentUtils::dumpDOM( $node, 'DOM: pre-' . $pp['shortcut'], $opts )
                    );
                }
            }
            // Excessive to do it here always, but protects against future changes
            // to how $this->frame may be updated.
            $pp['proc']( $node, [ 'frame' => $this->frame ] + $this->options, $this->atTopLevel );
            if ( $hasDumpFlags && ( $env->hasDumpFlag( 'dom:post-' . $pp['shortcut'] )
                || $env->hasDumpFlag( 'dom:post-*' ) )
            ) {
                $env->writeDump(
                    ContentUtils::dumpDOM( $node, 'DOM: post-' . $pp['shortcut'], $opts )
                );
            }
            if ( $profile ) {
                $ppElapsed = 1000 * ( microtime( true ) - $ppStart );
                if ( $this->atTopLevel ) {
                    $this->timeProfile .= str_pad( $prefix . '; ' . $ppName, 65 ) .
                        ' time = ' .
                        str_pad( number_format( $ppElapsed, 2 ), 10, ' ', STR_PAD_LEFT ) . "\n";
                }
                $profile->bumpTimeUse( $resourceCategory, $ppElapsed, 'DOM' );
            }
        }
        // For sub-pipeline documents, we are done.
        // For the top-level document, we generate <head> and add it.
        if ( $this->atTopLevel ) {
            self::addMetaData( $env, $node->ownerDocument );
            if ( $env->hasDumpFlag( 'wt2html:limits' ) ) {
                /*
                 * PORT-FIXME: Not yet implemented
                $env->printWt2HtmlResourceUsage( [
                    'HTML Size' => strlen( DOMCompat::getOuterHTML( $document->documentElement ) )
                ] );
                */
            }
        }
    }
    /**
     * @inheritDoc
     */
    public function process( $node, array $opts = null ) {
        '@phan-var Node $node'; // @var Node $node
        $this->doPostProcess( $node );
        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
        return $node;
    }
    /**
     * @inheritDoc
     */
    public function processChunkily( $input, ?array $options ): Generator {
        if ( $this->prevStage ) {
            // The previous stage will yield a DOM.
            // FIXME: Should we change the signature of that to return a DOM
            // If we do so, a pipeline stage returns either a generator or
            // concrete output (in this case, a DOM).
            $node = $this->prevStage->processChunkily( $input, $options )->current();
        } else {
            $node = $input;
        }
        $this->process( $node );
        yield $node;
    }
}