. * * @file * @ingroup Skins */ declare( strict_types=1 ); namespace MediaWiki\Skins\Citizen\Partials; use DOMDocument; use DOMElement; use DOMNode; use DOMXpath; use HtmlFormatter\HtmlFormatter; use MediaWiki\MediaWikiServices; use MediaWiki\Title\Title; use Wikimedia\Parsoid\Utils\DOMCompat; use Wikimedia\Services\NoSuchServiceException; final class BodyContent extends Partial { /** * The code below is largely based on the extension MobileFrontend * All credits go to the author and contributors of the project */ /** * Class name for collapsible section wrappers */ public const STYLE_COLLAPSIBLE_SECTION_CLASS = 'citizen-section-collapsible'; /** * List of tags that could be considered as section headers. * @var array */ private $topHeadingTags = [ "h1", "h2", "h3", "h4", "h5", "h6" ]; /** * Helper function to decide if the page should be formatted * * @param Title $title * @return bool */ private function shouldFormatPage( $title ) { try { $mfCxt = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' ); // Check if page is in mobile view and let MF do the formatting return !$mfCxt->shouldDisplayMobileView(); } catch ( NoSuchServiceException $ex ) { // MobileFrontend not installed. Don't do anything } return $this->getConfigValue( 'CitizenEnableCollapsibleSections' ) === true && !$title->isMainPage() && $title->isContentPage(); } /** * Rebuild the body content * * @param string $bodyContent HTML of the body content from core * @return string html */ public function decorateBodyContent( $bodyContent ) { $title = $this->title; // Return the page if title is null if ( $title === null ) { return $bodyContent; } // Make section and sanitize the output if ( $this->shouldFormatPage( $title ) ) { $formatter = new HtmlFormatter( $bodyContent ); $doc = $formatter->getDoc(); // Make top level sections $this->makeSections( $doc, $this->getTopHeadings( $doc ) ); $formatter->filterContent(); $bodyContent = $formatter->getText(); } return $bodyContent; } /** * @param DOMNode|null $node * @return string|false Heading tag name if the node is a heading */ private function getHeadingName( $node ) { if ( !( $node instanceof DOMElement ) ) { return false; } // We accept both kinds of nodes that can be returned by getTopHeadings(): // a `

` to `

` node, or a `
` node wrapping it. // In the future `
` will be required (T13555). if ( DOMCompat::getClassList( $node )->contains( 'mw-heading' ) ) { $node = DOMCompat::querySelector( $node, implode( ',', $this->topHeadingTags ) ); if ( !( $node instanceof DOMElement ) ) { return false; } } return $node->tagName; } /** * Actually splits splits the body of the document into sections * * @param DOMDocument $doc representing the HTML of the body content. In the HTML the sections * should not be wrapped. * @param DOMElement[] $headingWrappers The headings (or wrappers) returned by getTopHeadings(): * `

` to `

` nodes, or `
` nodes wrapping them. * In the future `
` will be required (T13555). * @return DOMDocument */ private function makeSections( DOMDocument $doc, array $headingWrappers ) { $xpath = new DOMXpath( $doc ); $containers = $xpath->query( // Equivalent of CSS attribute `~=` to support multiple classes '//div[contains(concat(" ",normalize-space(@class)," ")," mw-parser-output ")][1]' ); // Return if no parser output is found if ( !$containers->length || $containers->item( 0 ) === null ) { return $doc; } $container = $containers->item( 0 ); $containerChild = $container->firstChild; $firstHeading = reset( $headingWrappers ); $firstHeadingName = $this->getHeadingName( $firstHeading ); $sectionNumber = 0; $sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber ); while ( $containerChild ) { $node = $containerChild; $containerChild = $containerChild->nextSibling; // If we've found a top level heading, insert the previous section if // necessary and clear the container div. if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) { $this->prepareHeading( $doc, $node ); // Insert the previous section body and reset it for the new section $container->insertBefore( $sectionBody, $node ); ++$sectionNumber; $sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber ); continue; } // If it is not a top level heading, keep appending the nodes to the // section body container. $sectionBody->appendChild( $node ); } // Append the last section body. $container->appendChild( $sectionBody ); return $doc; } /** * Prepare section headings, add required classes * * @param DOMDocument $doc * @param DOMElement $heading */ private function prepareHeading( DOMDocument $doc, DOMElement $heading ) { $className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : ''; $heading->setAttribute( 'class', $className . 'citizen-section-heading' ); // prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator $indicator = $doc->createElement( 'span' ); $indicator->setAttribute( 'class', 'citizen-section-indicator citizen-ui-icon mw-ui-icon-wikimedia-collapse' ); $heading->insertBefore( $indicator, $heading->firstChild ); } /** * Creates a Section body element * * @param DOMDocument $doc * @param int $sectionNumber * * @return DOMElement */ private function createSectionBodyElement( DOMDocument $doc, $sectionNumber ) { $sectionBody = $doc->createElement( 'section' ); $sectionBody->setAttribute( 'class', self::STYLE_COLLAPSIBLE_SECTION_CLASS ); $sectionBody->setAttribute( 'id', 'citizen-section-collapsible-' . $sectionNumber ); return $sectionBody; } /** * Gets top headings in the document. * * @param DOMDocument $doc * @return array An array first is the highest rank headings */ private function getTopHeadings( DOMDocument $doc ): array { $headings = []; foreach ( $this->topHeadingTags as $tagName ) { $allTags = DOMCompat::querySelectorAll( $doc, $tagName ); foreach ( $allTags as $el ) { $parent = $el->parentNode; if ( !( $parent instanceof DOMElement ) ) { continue; } $parentClasses = DOMCompat::getClassList( $parent ); // Use the `
` wrapper if it is present. When they are required // (T13555), the querySelectorAll() above can use the class and this can be removed. if ( $parentClasses->contains( 'mw-heading' ) ) { $el = $parent; } elseif ( !$parentClasses->contains( 'mw-parser-output' ) ) { // Only target page headings, but not other heading tags // TODO: Drop this when T13555 is deployed on LTS continue; } // This check can be removed too when we require the wrappers. if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) { $headings[] = $el; } } if ( $headings ) { return $headings; } } return $headings; } }