Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.41% |
66 / 73 |
|
62.50% |
5 / 8 |
CRAP | |
0.00% |
0 / 1 |
| MakeSectionsTransform | |
90.41% |
66 / 73 |
|
62.50% |
5 / 8 |
26.60 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getHeadingName | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
5.26 | |||
| makeSections | |
93.33% |
28 / 30 |
|
0.00% |
0 / 1 |
7.01 | |||
| prepareHeading | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| createSectionBodyElement | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| getTopHeadings | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
7.14 | |||
| interimTogglingSupport | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| apply | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MobileFrontend\Transforms; |
| 4 | |
| 5 | use DomException; |
| 6 | use LogicException; |
| 7 | use MediaWiki\Html\Html; |
| 8 | use MediaWiki\ResourceLoader\ResourceLoader; |
| 9 | use Wikimedia\Parsoid\DOM\Document; |
| 10 | use Wikimedia\Parsoid\DOM\Element; |
| 11 | use Wikimedia\Parsoid\DOM\Node; |
| 12 | use Wikimedia\Parsoid\Utils\DOMCompat; |
| 13 | |
| 14 | /** |
| 15 | * Implements IMobileTransform, that splits the body of the document into |
| 16 | * sections demarcated by the $headings elements. Also moves the first paragraph |
| 17 | * in the lead section above the infobox. |
| 18 | * |
| 19 | * All member elements of the sections are added to a <code><div></code> so |
| 20 | * that the section bodies are clearly defined (to be "expandable" for |
| 21 | * example). |
| 22 | * |
| 23 | * @see IMobileTransform |
| 24 | */ |
| 25 | class MakeSectionsTransform implements IMobileTransform { |
| 26 | |
| 27 | /** |
| 28 | * Class name for collapsible section wrappers |
| 29 | */ |
| 30 | public const STYLE_COLLAPSIBLE_SECTION_CLASS = 'collapsible-block'; |
| 31 | |
| 32 | /** |
| 33 | * Whether scripts can be added in the output. |
| 34 | */ |
| 35 | private bool $scriptsEnabled; |
| 36 | |
| 37 | /** |
| 38 | * List of tags that could be considered as section headers. |
| 39 | * @var string[] |
| 40 | */ |
| 41 | private array $topHeadingTags; |
| 42 | |
| 43 | /** |
| 44 | * @param string[] $topHeadingTags list of tags could be considered as sections |
| 45 | * @param bool $scriptsEnabled whether scripts are enabled |
| 46 | */ |
| 47 | public function __construct( |
| 48 | array $topHeadingTags, |
| 49 | bool $scriptsEnabled |
| 50 | ) { |
| 51 | $this->topHeadingTags = $topHeadingTags; |
| 52 | $this->scriptsEnabled = $scriptsEnabled; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * @param Node|null $node |
| 57 | * @return string|false Heading tag name if the node is a heading |
| 58 | */ |
| 59 | private function getHeadingName( $node ): bool|string { |
| 60 | if ( !( $node instanceof Element ) ) { |
| 61 | return false; |
| 62 | } |
| 63 | // We accept both kinds of nodes that can be returned by getTopHeadings(): |
| 64 | // a `<h1>` to `<h6>` node, or a `<div class="mw-heading">` node wrapping it. |
| 65 | // In the future `<div class="mw-heading">` will be required (T13555). |
| 66 | if ( DOMCompat::getClassList( $node )->contains( 'mw-heading' ) ) { |
| 67 | $node = DOMCompat::querySelector( $node, implode( ',', $this->topHeadingTags ) ); |
| 68 | if ( !( $node instanceof Element ) ) { |
| 69 | return false; |
| 70 | } |
| 71 | } |
| 72 | return $node->tagName; |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Actually splits the body of the document into sections |
| 77 | * |
| 78 | * @param Element $body representing the HTML of the current article. In the HTML the sections |
| 79 | * should not be wrapped. |
| 80 | * @param Element[] $headingWrappers The headings (or wrappers) returned by getTopHeadings(): |
| 81 | * `<h1>` to `<h6>` nodes, or `<div class="mw-heading">` nodes wrapping them. |
| 82 | * In the future `<div class="mw-heading">` will be required (T13555). |
| 83 | * @throws DomException |
| 84 | */ |
| 85 | private function makeSections( Element $body, array $headingWrappers ): void { |
| 86 | $ownerDocument = $body->ownerDocument; |
| 87 | if ( $ownerDocument === null ) { |
| 88 | return; |
| 89 | } |
| 90 | // Find the parser output wrapper div |
| 91 | $container = DOMCompat::querySelector( |
| 92 | $ownerDocument, 'body > div.mw-parser-output' |
| 93 | ); |
| 94 | if ( !$container ) { |
| 95 | // No wrapper? This could be an old parser cache entry, or perhaps the |
| 96 | // OutputPage contained something that was not generated by the parser. |
| 97 | // Try using the <body> as the container. |
| 98 | $container = DOMCompat::getBody( $ownerDocument ); |
| 99 | if ( !$container ) { |
| 100 | throw new LogicException( "HTML lacked body element even though we put it there ourselves" ); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | $containerChild = $container->firstChild; |
| 105 | $firstHeading = reset( $headingWrappers ); |
| 106 | $firstHeadingName = $this->getHeadingName( $firstHeading ); |
| 107 | $sectionNumber = 0; |
| 108 | $sectionBody = $this->createSectionBodyElement( $ownerDocument, $sectionNumber, false ); |
| 109 | |
| 110 | while ( $containerChild ) { |
| 111 | $node = $containerChild; |
| 112 | $containerChild = $containerChild->nextSibling; |
| 113 | // If we've found a top level heading, insert the previous section if |
| 114 | // necessary and clear the container div. |
| 115 | if ( $firstHeadingName && $this->getHeadingName( $node ) === $firstHeadingName ) { |
| 116 | // The heading we are transforming is always 1 section ahead of the |
| 117 | // section we are currently processing |
| 118 | /** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType Node vs. Element */ |
| 119 | $this->prepareHeading( $ownerDocument, $node, $sectionNumber + 1, $this->scriptsEnabled ); |
| 120 | // Insert the previous section body and reset it for the new section |
| 121 | $container->insertBefore( $sectionBody, $node ); |
| 122 | |
| 123 | $sectionNumber += 1; |
| 124 | $sectionBody = $this->createSectionBodyElement( |
| 125 | $ownerDocument, |
| 126 | $sectionNumber, |
| 127 | $this->scriptsEnabled |
| 128 | ); |
| 129 | continue; |
| 130 | } |
| 131 | |
| 132 | // If it is not a top level heading, keep appending the nodes to the |
| 133 | // section body container. |
| 134 | $sectionBody->appendChild( $node ); |
| 135 | } |
| 136 | |
| 137 | // Append the last section body. |
| 138 | $container->appendChild( $sectionBody ); |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Prepare section headings, add required classes and onclick actions |
| 143 | * |
| 144 | * @param Document $doc |
| 145 | * @param Element $heading |
| 146 | * @param int $sectionNumber |
| 147 | * @param bool $isCollapsible |
| 148 | * @throws DOMException |
| 149 | */ |
| 150 | private function prepareHeading( |
| 151 | Document $doc, Element $heading, $sectionNumber, $isCollapsible |
| 152 | ): void { |
| 153 | $className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : ''; |
| 154 | $heading->setAttribute( 'class', $className . 'section-heading' ); |
| 155 | if ( $isCollapsible ) { |
| 156 | $heading->setAttribute( 'onclick', 'mfTempOpenSection(' . $sectionNumber . ')' ); |
| 157 | } |
| 158 | |
| 159 | // prepend indicator - this avoids a reflow by creating a placeholder for a toggling indicator |
| 160 | $indicator = $doc->createElement( 'span' ); |
| 161 | $indicator->setAttribute( 'class', 'indicator mf-icon mf-icon-expand mf-icon--small' ); |
| 162 | $heading->insertBefore( $indicator, $heading->firstChild ); |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Creates a Section body element |
| 167 | * |
| 168 | * @param Document $doc |
| 169 | * @param int $sectionNumber |
| 170 | * @param bool $isCollapsible |
| 171 | * |
| 172 | * @return Element |
| 173 | * @throws DOMException |
| 174 | */ |
| 175 | private function createSectionBodyElement( Document $doc, $sectionNumber, $isCollapsible ): Element { |
| 176 | $sectionClass = 'mf-section-' . $sectionNumber; |
| 177 | if ( $isCollapsible ) { |
| 178 | // TODO: Probably good to rename this to the more generic 'section'. |
| 179 | // We have no idea how the skin will use this. |
| 180 | $sectionClass .= ' ' . self::STYLE_COLLAPSIBLE_SECTION_CLASS; |
| 181 | } |
| 182 | |
| 183 | // FIXME: The class `/mf\-section\-[0-9]+/` is kept for caching reasons |
| 184 | // but given class is unique usage is discouraged. [T126825] |
| 185 | $sectionBody = $doc->createElement( 'section' ); |
| 186 | $sectionBody->setAttribute( 'class', $sectionClass ); |
| 187 | $sectionBody->setAttribute( 'id', 'mf-section-' . $sectionNumber ); |
| 188 | return $sectionBody; |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Gets top headings in the document. |
| 193 | * |
| 194 | * Note well that the rank order is defined by the |
| 195 | * <code>MobileFormatter#topHeadingTags</code> property. |
| 196 | * |
| 197 | * @param Element $doc |
| 198 | * @return array An array first is the highest rank headings |
| 199 | */ |
| 200 | private function getTopHeadings( Element $doc ): array { |
| 201 | $headings = []; |
| 202 | |
| 203 | foreach ( $this->topHeadingTags as $tagName ) { |
| 204 | $allTags = DOMCompat::querySelectorAll( $doc, $tagName ); |
| 205 | |
| 206 | foreach ( $allTags as $el ) { |
| 207 | $parent = $el->parentNode; |
| 208 | if ( !( $parent instanceof Element ) ) { |
| 209 | continue; |
| 210 | } |
| 211 | // Use the `<div class="mw-heading">` wrapper if it is present. When they are required |
| 212 | // (T13555), the querySelectorAll() above can use the class and this can be removed. |
| 213 | if ( DOMCompat::getClassList( $parent )->contains( 'mw-heading' ) ) { |
| 214 | $el = $parent; |
| 215 | } |
| 216 | // This check can be removed too when we require the wrappers. |
| 217 | if ( $parent->getAttribute( 'class' ) !== 'toctitle' ) { |
| 218 | $headings[] = $el; |
| 219 | } |
| 220 | } |
| 221 | if ( $headings ) { |
| 222 | return $headings; |
| 223 | } |
| 224 | |
| 225 | } |
| 226 | |
| 227 | return $headings; |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * Make it possible to open sections while JavaScript is still loading. |
| 232 | * |
| 233 | * @return string The JavaScript code to add event handlers to the skin |
| 234 | */ |
| 235 | public static function interimTogglingSupport(): string { |
| 236 | $js = <<<JAVASCRIPT |
| 237 | function mfTempOpenSection( id ) { |
| 238 | var block = document.getElementById( "mf-section-" + id ); |
| 239 | block.className += " open-block"; |
| 240 | // The previous sibling to the content block is guaranteed to be the |
| 241 | // associated heading due to mobileformatter. We need to add the same |
| 242 | // class to flip the collapse arrow icon. |
| 243 | // <h[1-6]>heading</h[1-6]><div id="mf-section-[1-9]+"></div> |
| 244 | block.previousSibling.className += " open-block"; |
| 245 | } |
| 246 | JAVASCRIPT; |
| 247 | return Html::inlineScript( |
| 248 | ResourceLoader::filter( 'minify-js', $js ) |
| 249 | ); |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * Performs html transformation that splits the body of the document into |
| 254 | * sections demarcated by the $headings elements. Also moves the first paragraph |
| 255 | * in the lead section above the infobox. |
| 256 | * @param Element $node to be transformed |
| 257 | * @throws DomException |
| 258 | */ |
| 259 | public function apply( Element $node ): void { |
| 260 | $this->makeSections( $node, $this->getTopHeadings( $node ) ); |
| 261 | } |
| 262 | } |