diff --git a/README.md b/README.md
index 9084e72e..c3ad5d0a 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ Note that:
Name | Description | Values | Default
:--- | :--- | :--- | :---
`$wgCitizenThemeDefault` | The default theme of the skin | `auto` - switch between light and dark according to OS/browser settings; `light`; `dark` | `auto`
+`$wgCitizenEnableCollapsibleSections` | Enables or disable collapsible sections on content pages | `true` - enable; `false` - disable | `true`
`$wgCitizenShowPageTools` | The condition of page tools visibility | `true` - always visible; `login` - visible to logged-in users; `permission` - visible to users with the right permissions | `true`
`$wgCitizenEnableDrawerSubSearch` | Enables the drawer search box for menu entries | `true` - enable; `false` - disable | `false`
`$wgCitizenPortalAttach` | Label of the portal to attach links to upload and special pages to | string | `first`
diff --git a/includes/Partials/BodyContent.php b/includes/Partials/BodyContent.php
new file mode 100644
index 00000000..11ed2fa1
--- /dev/null
+++ b/includes/Partials/BodyContent.php
@@ -0,0 +1,244 @@
+.
+ *
+ * @file
+ * @ingroup Skins
+ */
+
+declare( strict_types=1 );
+
+namespace Citizen\Partials;
+
+use DOMDocument;
+use DOMElement;
+use DOMXpath;
+use Html;
+
+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 = 'section-collapsible';
+
+ /**
+ * List of tags that could be considered as section headers.
+ * @var array
+ */
+ private $topHeadingTags = [ "h1", "h2", "h3", "h4", "h5", "h6 " ];
+
+ /**
+ * Rebuild the body content
+ *
+ * @param string $out OutputPage
+ * @return string html
+ */
+ public function buildBodyContent( $out ) {
+ $printSource = Html::rawElement( 'div', [ 'class' => 'printfooter' ], $this->skin->printSource() );
+ $htmlBodyContent = $bodyContent = $out->getHTML() . "\n" . $printSource;
+ $title = $out->getTitle();
+
+ // Check if it is not the main page
+ if ( !$title->isMainPage() ) {
+ $doc = new DOMDocument;
+ $doc->loadHTML( $bodyContent );
+
+ // Make top level sections
+ $this->makeSections( $doc, $this->getTopHeadings( $doc ) );
+ // Mark subheadings
+ $this->markSubHeadings( $this->getSubHeadings( $doc ) );
+
+ $htmlBodyContent = $doc->saveHTML();
+ }
+
+ $newBodyContent = $this->skin->wrapHTMLPublic( $title, $htmlBodyContent );
+
+ return $newBodyContent;
+ }
+
+ /**
+ * Actually splits splits the body of the document into sections
+ *
+ * @param DOMElement $doc representing the HTML of the current article. In the HTML the sections
+ * should not be wrapped.
+ * @param DOMElement[] $headings The headings returned by
+ * @return DOMDocument
+ */
+ private function makeSections( DOMDocument $doc, array $headings ) {
+ $xpath = new DOMXpath( $doc );
+ $containers = $xpath->query( 'body/div[@class="mw-parser-output"][1]' );
+
+ // Return if no parser output is found
+ if ( !$containers->length ) {
+ return;
+ }
+
+ $container = $containers->item( 0 );
+ $containerChild = $container->firstChild;
+ $firstHeading = reset( $headings );
+ $firstHeadingName = $firstHeading ? $firstHeading->nodeName : false;
+ $sectionNumber = 0;
+ $sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber, false );
+
+ 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.
+ // Note well the use of DOMNode#nodeName here. Only DOMElement defines
+ // DOMElement#tagName. So, if there's trailing text - represented by
+ // DOMText - then accessing #tagName will trigger an error.
+ if ( $node->nodeName === $firstHeadingName ) {
+ // The heading we are transforming is always 1 section ahead of the
+ // section we are currently processing
+ /** @phan-suppress-next-line PhanTypeMismatchArgument DOMNode vs. DOMElement */
+ $this->prepareHeading( $doc, $node, $sectionNumber + 1 );
+ // Insert the previous section body and reset it for the new section
+ $container->insertBefore( $sectionBody, $node );
+
+ $sectionNumber += 1;
+ $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
+ * @param int $sectionNumber
+ */
+ private function prepareHeading( DOMDocument $doc, DOMElement $heading, $sectionNumber ) {
+ $className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
+ $heading->setAttribute( 'class', $className . 'section-heading' );
+
+ // prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator
+ $indicator = $doc->createElement( 'div' );
+ $indicator->setAttribute( 'class', 'section-toggle' );
+ $indicator->setAttribute( 'role', 'button' );
+ $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', '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 = $doc->getElementsByTagName( $tagName );
+
+ foreach ( $allTags as $el ) {
+ if ( $el->parentNode->getAttribute( 'class' ) !== 'toctitle' ) {
+ $headings[] = $el;
+ }
+ }
+ if ( $headings ) {
+ return $headings;
+ }
+
+ }
+
+ return $headings;
+ }
+
+ /**
+ * Marks the subheadings for the approiate styles by adding
+ * the section-subheading
class to each of them, if it
+ * hasn't already been added.
+ *
+ * @param DOMElement[] $headings Heading elements
+ */
+ protected function markSubHeadings( array $headings ) {
+ foreach ( $headings as $heading ) {
+ $class = $heading->getAttribute( 'class' );
+ if ( strpos( $class, 'section-subheading' ) === false ) {
+ $heading->setAttribute(
+ 'class',
+ ltrim( $class . ' section-subheading' )
+ );
+ }
+ }
+ }
+
+ /**
+ * Gets all subheadings in the document in rank order.
+ *
+ * @param DOMDocument $doc
+ * @return DOMElement[]
+ */
+ private function getSubHeadings( DOMDocument $doc ): array {
+ $found = false;
+ $subheadings = [];
+ foreach ( $this->topHeadingTags as $tagName ) {
+ $allTags = $doc->getElementsByTagName( $tagName );
+ $elements = [];
+ foreach ( $allTags as $el ) {
+ if ( $el->parentNode->getAttribute( 'class' ) !== 'toctitle' ) {
+ $elements[] = $el;
+ }
+ }
+
+ if ( $elements ) {
+ if ( !$found ) {
+ $found = true;
+ } else {
+ $subheadings = array_merge( $subheadings, $elements );
+ }
+ }
+ }
+
+ return $subheadings;
+ }
+}
diff --git a/includes/SkinCitizen.php b/includes/SkinCitizen.php
index 7e77af0d..de28a439 100644
--- a/includes/SkinCitizen.php
+++ b/includes/SkinCitizen.php
@@ -22,6 +22,7 @@
*/
use Citizen\GetConfigTrait;
+use Citizen\Partials\BodyContent;
use Citizen\Partials\Drawer;
use Citizen\Partials\Footer;
use Citizen\Partials\Header;
@@ -51,6 +52,7 @@ class SkinCitizen extends SkinMustache {
public function __construct( $options = [] ) {
$skin = $this;
$out = $skin->getOutput();
+ $title = $out->getTitle();
$metadata = new Metadata( $this );
$skinTheme = new Theme( $this );
@@ -60,6 +62,35 @@ class SkinCitizen extends SkinMustache {
// Theme handler
$skinTheme->setSkinTheme( $options );
+ // Only load in content pages
+ if ( $title->isContentPage() ) {
+ // Load Citizen collapsible sections modules if enabled
+ if ( $this->getConfigValue( 'CitizenEnableCollapsibleSections' ) === true ) {
+ $options['scripts'] = array_merge(
+ $options['scripts'],
+ [ 'skins.citizen.scripts.sections' ]
+ );
+ $options['styles'] = array_merge(
+ $options['styles'],
+ [
+ 'skins.citizen.styles.sections',
+ 'skins.citizen.icons.sections'
+ ]
+ );
+ }
+
+ // Load table of content script if ToC presents
+ if ( $out->isTOCEnabled() ) {
+ // Add class to body that notifies the page has TOC
+ $out->addBodyClasses( 'skin-citizen-has-toc' );
+ // Disabled style condition loading due to pop in
+ $options['scripts'] = array_merge(
+ $options['scripts'],
+ [ 'skins.citizen.scripts.toc' ]
+ );
+ }
+ }
+
// Load Citizen search suggestion styles if enabled
if ( $this->getConfigValue( 'CitizenEnableSearch' ) === true ) {
$options['styles'] = array_merge(
@@ -91,17 +122,6 @@ class SkinCitizen extends SkinMustache {
);
}
- // Load table of content script if ToC presents
- if ( $out->isTOCEnabled() ) {
- // Add class to body that notifies the page has TOC
- $out->addBodyClasses( 'skin-citizen-has-toc' );
- // Disabled style condition loading due to pop in
- $options['scripts'] = array_merge(
- $options['scripts'],
- [ 'skins.citizen.scripts.toc' ]
- );
- }
-
$options['templateDirectory'] = __DIR__ . '/templates';
parent::__construct( $options );
}
@@ -116,6 +136,7 @@ class SkinCitizen extends SkinMustache {
$header = new Header( $this );
$drawer = new Drawer( $this );
+ $bodycontent = new BodyContent( $this );
$footer = new Footer( $this );
$tools = new PageTools( $this );
@@ -161,6 +182,8 @@ class SkinCitizen extends SkinMustache {
'msg-tagline' => $this->msg( 'tagline' )->text(),
+ 'html-body-content--formatted' => $bodycontent->buildBodyContent( $out ),
+
'data-footer' => $footer->getFooterData(),
];
}
@@ -201,6 +224,17 @@ class SkinCitizen extends SkinMustache {
return parent::buildContentNavigationUrls();
}
+ /**
+ * Change access to public, as it is used in partials
+ *
+ * @param Title $title
+ * @param string $html body text
+ * @return array
+ */
+ final public function wrapHTMLPublic( $title, $html ) {
+ return parent::wrapHTML( $title, $html );
+ }
+
/**
* @param string $label to be used to derive the id and human readable label of the menu
* If the key has an entry in the constant MENU_LABEL_KEYS then that message will be used for the
diff --git a/includes/templates/skin.mustache b/includes/templates/skin.mustache
index 243569eb..861f84c9 100644
--- a/includes/templates/skin.mustache
+++ b/includes/templates/skin.mustache
@@ -39,7 +39,7 @@