2021-03-02 22:16:24 +00:00
|
|
|
<?php
|
|
|
|
/**
|
|
|
|
* Citizen - A responsive skin developed for the Star Citizen Wiki
|
|
|
|
*
|
|
|
|
* This file is part of Citizen.
|
|
|
|
*
|
|
|
|
* Citizen is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU General Public License as published by
|
|
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
|
|
* (at your option) any later version.
|
|
|
|
*
|
|
|
|
* Citizen is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU General Public License
|
|
|
|
* along with Citizen. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
*
|
|
|
|
* @file
|
|
|
|
* @ingroup Skins
|
|
|
|
*/
|
|
|
|
|
|
|
|
declare( strict_types=1 );
|
|
|
|
|
2022-05-26 20:54:52 +00:00
|
|
|
namespace MediaWiki\Skins\Citizen\Partials;
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
use DOMDocument;
|
|
|
|
use DOMElement;
|
2022-11-25 02:36:06 +00:00
|
|
|
use DOMNode;
|
2021-03-02 22:16:24 +00:00
|
|
|
use DOMXpath;
|
2021-04-04 15:39:57 +00:00
|
|
|
use HtmlFormatter\HtmlFormatter;
|
2022-04-27 19:58:16 +00:00
|
|
|
use MediaWiki\MediaWikiServices;
|
2023-08-25 23:52:58 +00:00
|
|
|
use MediaWiki\Title\Title;
|
2022-11-25 02:26:55 +00:00
|
|
|
use Wikimedia\Parsoid\Utils\DOMCompat;
|
2022-04-27 19:58:16 +00:00
|
|
|
use Wikimedia\Services\NoSuchServiceException;
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
2024-05-21 22:46:19 +00:00
|
|
|
* Class name for section wrappers
|
2021-03-02 22:16:24 +00:00
|
|
|
*/
|
2024-05-21 22:46:19 +00:00
|
|
|
public const SECTION_CLASS = 'citizen-section';
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* List of tags that could be considered as section headers.
|
|
|
|
* @var array
|
|
|
|
*/
|
2023-02-28 21:21:41 +00:00
|
|
|
private $topHeadingTags = [ "h1", "h2", "h3", "h4", "h5", "h6" ];
|
2021-03-02 22:16:24 +00:00
|
|
|
|
2021-05-13 13:44:34 +00:00
|
|
|
/**
|
2022-04-27 19:58:16 +00:00
|
|
|
* Helper function to decide if the page should be formatted
|
2021-05-13 13:44:34 +00:00
|
|
|
*
|
|
|
|
* @param Title $title
|
2023-08-25 21:39:37 +00:00
|
|
|
* @return bool
|
2021-05-13 13:44:34 +00:00
|
|
|
*/
|
2022-04-27 19:58:16 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2021-05-13 13:44:34 +00:00
|
|
|
return $this->getConfigValue( 'CitizenEnableCollapsibleSections' ) === true &&
|
|
|
|
!$title->isMainPage() &&
|
|
|
|
$title->isContentPage();
|
|
|
|
}
|
|
|
|
|
2021-03-03 00:09:39 +00:00
|
|
|
/**
|
|
|
|
* Rebuild the body content
|
|
|
|
*
|
2023-02-28 21:21:41 +00:00
|
|
|
* @param string $bodyContent HTML of the body content from core
|
2021-03-03 00:14:48 +00:00
|
|
|
* @return string html
|
2021-03-03 00:09:39 +00:00
|
|
|
*/
|
2023-02-28 21:21:41 +00:00
|
|
|
public function decorateBodyContent( $bodyContent ) {
|
2022-05-22 19:06:49 +00:00
|
|
|
$title = $this->title;
|
|
|
|
|
2021-03-03 08:05:13 +00:00
|
|
|
// Return the page if title is null
|
|
|
|
if ( $title === null ) {
|
2023-02-28 21:21:41 +00:00
|
|
|
return $bodyContent;
|
2021-03-03 08:05:13 +00:00
|
|
|
}
|
|
|
|
|
2021-05-13 13:44:34 +00:00
|
|
|
// Make section and sanitize the output
|
2022-04-27 19:58:16 +00:00
|
|
|
if ( $this->shouldFormatPage( $title ) ) {
|
2023-02-28 21:21:41 +00:00
|
|
|
$formatter = new HtmlFormatter( $bodyContent );
|
2021-05-13 13:44:34 +00:00
|
|
|
$doc = $formatter->getDoc();
|
2021-03-02 22:16:24 +00:00
|
|
|
// Make top level sections
|
|
|
|
$this->makeSections( $doc, $this->getTopHeadings( $doc ) );
|
2021-05-13 13:44:34 +00:00
|
|
|
$formatter->filterContent();
|
2023-02-28 21:21:41 +00:00
|
|
|
$bodyContent = $formatter->getText();
|
2021-05-13 13:44:34 +00:00
|
|
|
}
|
2021-04-04 15:39:57 +00:00
|
|
|
|
2023-02-28 21:21:41 +00:00
|
|
|
return $bodyContent;
|
2021-03-02 22:16:24 +00:00
|
|
|
}
|
|
|
|
|
2022-11-25 02:36:06 +00:00
|
|
|
/**
|
|
|
|
* @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 `<h1>` to `<h6>` node, or a `<div class="mw-heading">` node wrapping it.
|
|
|
|
// In the future `<div class="mw-heading">` 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;
|
|
|
|
}
|
|
|
|
|
2021-03-02 22:16:24 +00:00
|
|
|
/**
|
|
|
|
* Actually splits splits the body of the document into sections
|
|
|
|
*
|
2023-02-28 21:21:41 +00:00
|
|
|
* @param DOMDocument $doc representing the HTML of the body content. In the HTML the sections
|
2021-03-02 22:16:24 +00:00
|
|
|
* should not be wrapped.
|
2022-11-25 02:36:06 +00:00
|
|
|
* @param DOMElement[] $headingWrappers The headings (or wrappers) returned by getTopHeadings():
|
|
|
|
* `<h1>` to `<h6>` nodes, or `<div class="mw-heading">` nodes wrapping them.
|
|
|
|
* In the future `<div class="mw-heading">` will be required (T13555).
|
2021-03-03 00:09:39 +00:00
|
|
|
* @return DOMDocument
|
2021-03-02 22:16:24 +00:00
|
|
|
*/
|
2022-11-25 02:36:06 +00:00
|
|
|
private function makeSections( DOMDocument $doc, array $headingWrappers ) {
|
2021-03-02 22:16:24 +00:00
|
|
|
$xpath = new DOMXpath( $doc );
|
2023-11-15 20:15:25 +00:00
|
|
|
$containers = $xpath->query(
|
|
|
|
// Equivalent of CSS attribute `~=` to support multiple classes
|
|
|
|
'//div[contains(concat(" ",normalize-space(@class)," ")," mw-parser-output ")][1]'
|
|
|
|
);
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
// Return if no parser output is found
|
2021-03-03 08:05:13 +00:00
|
|
|
if ( !$containers->length || $containers->item( 0 ) === null ) {
|
|
|
|
return $doc;
|
2021-03-02 22:16:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
$container = $containers->item( 0 );
|
2021-03-03 08:05:13 +00:00
|
|
|
|
2021-03-02 22:16:24 +00:00
|
|
|
$containerChild = $container->firstChild;
|
2022-11-25 02:36:06 +00:00
|
|
|
$firstHeading = reset( $headingWrappers );
|
|
|
|
$firstHeadingName = $this->getHeadingName( $firstHeading );
|
2021-03-02 22:16:24 +00:00
|
|
|
$sectionNumber = 0;
|
2021-03-03 08:05:13 +00:00
|
|
|
$sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber );
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
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.
|
2022-11-25 02:36:06 +00:00
|
|
|
if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) {
|
2023-11-15 20:54:21 +00:00
|
|
|
$this->prepareHeading( $doc, $node );
|
2021-03-02 22:16:24 +00:00
|
|
|
// Insert the previous section body and reset it for the new section
|
|
|
|
$container->insertBefore( $sectionBody, $node );
|
|
|
|
|
2021-03-03 08:05:13 +00:00
|
|
|
++$sectionNumber;
|
2021-03-02 22:16:24 +00:00
|
|
|
$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
|
|
|
|
*/
|
2023-11-15 20:54:21 +00:00
|
|
|
private function prepareHeading( DOMDocument $doc, DOMElement $heading ) {
|
2021-03-02 22:16:24 +00:00
|
|
|
$className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
|
2023-09-10 23:00:52 +00:00
|
|
|
$heading->setAttribute( 'class', $className . 'citizen-section-heading' );
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
// prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
|
2022-11-08 17:27:58 +00:00
|
|
|
$indicator = $doc->createElement( 'span' );
|
2023-09-10 23:00:52 +00:00
|
|
|
$indicator->setAttribute( 'class', 'citizen-section-indicator citizen-ui-icon mw-ui-icon-wikimedia-collapse' );
|
2021-03-02 22:16:24 +00:00
|
|
|
$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' );
|
2024-05-21 22:46:19 +00:00
|
|
|
$sectionBody->setAttribute( 'class', self::SECTION_CLASS );
|
2023-09-10 23:00:52 +00:00
|
|
|
$sectionBody->setAttribute( 'id', 'citizen-section-collapsible-' . $sectionNumber );
|
2021-04-04 15:39:57 +00:00
|
|
|
|
2021-03-02 22:16:24 +00:00
|
|
|
return $sectionBody;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets top headings in the document.
|
|
|
|
*
|
2021-03-03 00:09:39 +00:00
|
|
|
* @param DOMDocument $doc
|
2021-03-02 22:16:24 +00:00
|
|
|
* @return array An array first is the highest rank headings
|
|
|
|
*/
|
|
|
|
private function getTopHeadings( DOMDocument $doc ): array {
|
|
|
|
$headings = [];
|
|
|
|
|
|
|
|
foreach ( $this->topHeadingTags as $tagName ) {
|
2022-11-25 02:26:55 +00:00
|
|
|
$allTags = DOMCompat::querySelectorAll( $doc, $tagName );
|
2021-03-02 22:16:24 +00:00
|
|
|
|
|
|
|
foreach ( $allTags as $el ) {
|
2022-11-25 02:36:06 +00:00
|
|
|
$parent = $el->parentNode;
|
|
|
|
if ( !( $parent instanceof DOMElement ) ) {
|
|
|
|
continue;
|
|
|
|
}
|
2023-09-11 00:51:28 +00:00
|
|
|
|
|
|
|
$parentClasses = DOMCompat::getClassList( $parent );
|
|
|
|
|
2022-11-25 02:36:06 +00:00
|
|
|
// Use the `<div class="mw-heading">` wrapper if it is present. When they are required
|
|
|
|
// (T13555), the querySelectorAll() above can use the class and this can be removed.
|
2023-09-11 00:51:28 +00:00
|
|
|
if ( $parentClasses->contains( 'mw-heading' ) ) {
|
2022-11-25 02:36:06 +00:00
|
|
|
$el = $parent;
|
2023-09-11 18:05:27 +00:00
|
|
|
} elseif ( !$parentClasses->contains( 'mw-parser-output' ) ) {
|
2023-09-11 18:04:43 +00:00
|
|
|
// Only target page headings, but not other heading tags
|
|
|
|
// TODO: Drop this when T13555 is deployed on LTS
|
|
|
|
continue;
|
2022-11-25 02:36:06 +00:00
|
|
|
}
|
2023-09-11 00:51:28 +00:00
|
|
|
|
2022-11-25 02:36:06 +00:00
|
|
|
// This check can be removed too when we require the wrappers.
|
|
|
|
if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) {
|
2021-03-02 22:16:24 +00:00
|
|
|
$headings[] = $el;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if ( $headings ) {
|
|
|
|
return $headings;
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return $headings;
|
|
|
|
}
|
|
|
|
}
|