diff --git a/includes/MobileFormatter.php b/includes/MobileFormatter.php index 9a88fbb9e..0686414e4 100644 --- a/includes/MobileFormatter.php +++ b/includes/MobileFormatter.php @@ -1,735 +1,735 @@ title = $title; $this->revId = $title->getLatestRevID(); $this->topHeadingTags = MobileContext::singleton() ->getMFConfig()->get( 'MFMobileFormatterHeadings' ); } /** * Disables the generation of script tags in output HTML. */ public function disableScripts() { $this->scriptsEnabled = false; } /** * Creates and returns a MobileFormatter * * @param MobileContext $context * @param string $html * * @return MobileFormatter */ public static function newFromContext( MobileContext $context, $html ) { $mfSpecialCaseMainPage = $context->getMFConfig()->get( 'MFSpecialCaseMainPage' ); $title = $context->getTitle(); $isMainPage = $title->isMainPage() && $mfSpecialCaseMainPage; $isFilePage = $title->inNamespace( NS_FILE ); $isSpecialPage = $title->isSpecialPage(); $html = self::wrapHTML( $html ); $formatter = new MobileFormatter( $html, $title ); $formatter->enableExpandableSections( !$isMainPage && !$isSpecialPage ); $formatter->setIsMainPage( $isMainPage ); if ( $context->getContentTransformations() && !$isFilePage ) { $formatter->setRemoveMedia( $context->imagesDisabled() ); } return $formatter; } /** * Mark whether a placeholder table of contents should be included at the end of the lead * section * @param boolean $flag */ public function enableTOCPlaceholder( $flag = true ) { $this->isTOCEnabled = $flag; } /** * Set support of page for expandable sections to $flag (standard: true) * @todo kill with fire when there will be minimum of pre-1.1 app users remaining * @param bool $flag */ public function enableExpandableSections( $flag = true ) { $this->expandableSections = $flag; } /** * Change mainPage (is this the main page) to $value (standard: true) * @param boolean $value */ public function setIsMainPage( $value = true ) { $this->mainPage = $value; } /** * Performs various transformations to the content to make it appropiate for mobile devices. * @param bool $removeDefaults Whether default settings at $wgMFRemovableClasses should be used * @param bool $removeReferences Whether to remove references from the output * @param bool $removeImages Whether to move images into noscript tags * @param bool $showFirstParagraphBeforeInfobox Whether the first paragraph from the lead * section should be shown before all infoboxes that come earlier. * @return array */ public function filterContent( $removeDefaults = true, $removeReferences = false, $removeImages = false, $showFirstParagraphBeforeInfobox = false ) { $ctx = MobileContext::singleton(); $config = $ctx->getMFConfig(); $doc = $this->getDoc(); $isSpecialPage = $this->title->isSpecialPage(); $mfRemovableClasses = $config->get( 'MFRemovableClasses' ); $removableClasses = $mfRemovableClasses['base']; if ( $ctx->isBetaGroupMember() ) { $removableClasses = array_merge( $removableClasses, $mfRemovableClasses['beta'] ); } // Don't remove elements in special pages if ( !$isSpecialPage && $removeDefaults ) { $this->remove( $removableClasses ); } if ( $this->removeMedia ) { $this->doRemoveImages(); } $transformOptions = [ 'images' => $removeImages, 'references' => $removeReferences, self::SHOW_FIRST_PARAGRAPH_BEFORE_INFOBOX => $showFirstParagraphBeforeInfobox ]; // Sectionify the content and transform it if necessary per section if ( !$this->mainPage && $this->expandableSections ) { list( $headings, $subheadings ) = $this->getHeadings( $doc ); $this->makeSections( $doc, $headings, $transformOptions ); $this->makeHeadingsEditable( $subheadings ); } else { // Otherwise apply the per-section transformations to the document as a whole $this->filterContentInSection( $doc, $doc, 0, $transformOptions ); } if ( $transformOptions['references'] ) { $this->doRewriteReferencesLinksForLazyLoading( $doc ); } return parent::filterContent(); } /** * Apply filtering per element (section) in a document. * @param DOMElement|DOMDocument $el * @param DOMDocument $doc * @param number $sectionNumber Which section is it on the document * @param array $options options about the transformations per section */ private function filterContentInSection( $el, DOMDocument $doc, $sectionNumber, $options = [] ) { if ( !$this->removeMedia && $options['images'] && $sectionNumber > 0 ) { $this->doRewriteImagesForLazyLoading( $el, $doc ); } if ( $options['references'] ) { $this->doRewriteReferencesListsForLazyLoading( $el, $doc ); } } /** * Move the first paragraph in the lead section above the infobox * * In order for a paragraph to be moved the following conditions must be met: * - the lead section contains at least one infobox; * - the paragraph doesn't already appear before the first infobox * if any in the DOM; * - the paragraph contains text content, e.g. no

; * - the paragraph doesn't contain coordinates, i.e. span#coordinates. * * Note that the first paragraph is not moved before hatnotes, or mbox or other * elements that are not infoboxes. * * @param DOMElement $leadSectionBody * @param DOMDocument $doc Document to which the section belongs */ private function moveFirstParagraphBeforeInfobox( $leadSectionBody, $doc ) { $xPath = new DOMXPath( $doc ); // Find infoboxes and paragraphs that have text content, i.e. paragraphs // that are not empty nor are wrapper paragraphs that contain span#coordinates. $infoboxAndParagraphs = $xPath->query( './table[contains(@class,"infobox")] | ./p[string-length(text()) > 0]', $leadSectionBody ); // We need both an infobox and a paragraph and the first element of our query result // ought to be an infobox. if ( $infoboxAndParagraphs->length >= 2 && $infoboxAndParagraphs->item( 0 )->nodeName == 'table' ) { $firstP = null; for ( $i = 1; $i < $infoboxAndParagraphs->length; $i++ ) { if ( $infoboxAndParagraphs->item( $i )->nodeName == 'p' ) { $firstP = $infoboxAndParagraphs->item( $i ); break; } } if ( $firstP ) { $leadSectionBody->insertBefore( $firstP, $infoboxAndParagraphs->item( 0 ) ); } } /** * @see https://phabricator.wikimedia.org/T149884 * @todo remove after research is done */ if ( MobileContext::singleton()->getMFConfig()->get( 'MFLogWrappedInfoboxes' ) ) { $this->logInfoboxesWrappedInContainers( $leadSectionBody, $xPath ); } } /** * Finds all infoboxes which are one or more levels deep in $xPath content. When at least one * element is found - log the page title and revision * * @see https://phabricator.wikimedia.org/T149884 * @param $leadSectionBody * @param DOMXPath $xPath */ private function logInfoboxesWrappedInContainers( $leadSectionBody, DOMXPath $xPath ) { $infoboxes = $xPath->query( './/table[contains(@class,"infobox")]', $leadSectionBody ); if ( $infoboxes->length > 0 ) { - \MediaWiki\Logger\LoggerFactory::getInstance( 'MobileFrontend' )->debug( + \MediaWiki\Logger\LoggerFactory::getInstance( 'mobile' )->debug( "Found infobox wrapped with container on {$this->title} (rev:{$this->revId})" ); } } /** * Replaces any references links with a link to Special:MobileCite * * @param DOMDocument $doc Document to create and replace elements in */ private function doRewriteReferencesLinksForLazyLoading( DOMDocument $doc ) { $citePath = "$this->revId"; $xPath = new DOMXPath( $doc ); $nodes = $xPath->query( // sup.reference > a '//sup[contains(concat(" ", normalize-space(./@class), " "), " reference ")]/a[1]' ); foreach ( $nodes as $node ) { $fragment = $node->getAttribute( 'href' ); $node->setAttribute( 'href', SpecialPage::getTitleFor( 'MobileCite', $citePath )->getLocalUrl() . $fragment ); } } /** * Replaces any references list with a link to Special:MobileCite * * @param DOMElement|DOMDocument $el Element or document to rewrite references in. * @param DOMDocument $doc Document to create elements in */ private function doRewriteReferencesListsForLazyLoading( $el, DOMDocument $doc ) { $citePath = "$this->revId"; $isReferenceSection = false; // Accessing by tag is cheaper than class $nodes = $el->getElementsByTagName( 'ol' ); // PHP's DOM classes are recursive // but since we are manipulating the DOMList we have to // traverse it backwards // see http://php.net/manual/en/class.domnodelist.php for ( $i = $nodes->length - 1; $i >= 0; $i-- ) { $list = $nodes->item( $i ); // Use class to decide it is a list of references if ( strpos( $list->getAttribute( 'class' ), 'references' ) !== false ) { // Only mark the section as a reference container if we're transforming a section, not the // document. $isReferenceSection = $el instanceof DOMElement; $parent = $list->parentNode; $placeholder = $doc->createElement( 'a', wfMessage( 'mobile-frontend-references-list' ) ); $placeholder->setAttribute( 'class', 'mf-lazy-references-placeholder' ); // Note to render a reference we need to know only its reference // Note: You can have multiple tag on the same page, we render all of these in // the special page together. $placeholder->setAttribute( 'href', SpecialPage::getTitleFor( 'MobileCite', $citePath )->getLocalUrl() ); $parent->replaceChild( $placeholder, $list ); } } // Mark section as having references if ( $isReferenceSection ) { $el->setAttribute( 'data-is-reference-section', '1' ); } } /** * @see MobileFormatter#getImageDimensions * * @param DOMElement $img * @param string $dimension Either "width" or "height" * @return string|null */ private function getImageDimension( DOMElement $img, $dimension ) { $style = $img->getAttribute( 'style' ); $numMatches = preg_match( "/.*?{$dimension} *\: *([^;]*)/", $style, $matches ); if ( !$numMatches && !$img->hasAttribute( $dimension ) ) { return null; } return $numMatches ? trim( $matches[1] ) : $img->getAttribute( $dimension ) . 'px'; } /** * Determine the user perceived width and height of an image element based on `style`, `width`, * and `height` attributes. * * As in the browser, the `style` attribute takes precedence over the `width` and `height` * attributes. If the image has no `style`, `width` or `height` attributes, then the image is * dimensionless. * * @param DOMElement $img * @return array with width and height parameters if dimensions are found */ public function getImageDimensions( DOMElement $img ) { $result = []; foreach ( [ 'width', 'height' ] as $dimensionName ) { $dimension = $this->getImageDimension( $img, $dimensionName ); if ( $dimension ) { $result[$dimensionName] = $dimension; } } return $result; } /** * Enables images to be loaded asynchronously * * @param DOMElement|DOMDocument $el Element or document to rewrite images in. * @param DOMDocument $doc Document to create elements in */ private function doRewriteImagesForLazyLoading( $el, DOMDocument $doc ) { foreach ( $el->getElementsByTagName( 'img' ) as $img ) { $parent = $img->parentNode; $dimensions = $this->getImageDimensions( $img ); $dimensionsStyle = ( isset( $dimensions['width'] ) ? "width: {$dimensions['width']};" : '' ) . ( isset( $dimensions['height'] ) ? "height: {$dimensions['height']};" : '' ); // HTML only clients $noscript = $doc->createElement( 'noscript' ); // To be loaded image placeholder $imgPlaceholder = $doc->createElement( 'span' ); $imgPlaceholder->setAttribute( 'class', 'lazy-image-placeholder' ); $imgPlaceholder->setAttribute( 'style', $dimensionsStyle ); foreach ( [ 'src', 'alt', 'width', 'height', 'srcset', 'class' ] as $attr ) { if ( $img->hasAttribute( $attr ) ) { $imgPlaceholder->setAttribute( "data-$attr", $img->getAttribute( $attr ) ); } } // Assume data saving and remove srcset attribute from the non-js experience $img->removeAttribute( 'srcset' ); // T145222: Add a non-breaking space inside placeholders to ensure that they do not report // themselves as invisible when inline. $imgPlaceholder->appendChild( $doc->createEntityReference( 'nbsp' ) ); // Set the placeholder where the original image was $parent->replaceChild( $imgPlaceholder, $img ); // Add the original image to the HTML only markup $noscript->appendChild( $img ); // Insert the HTML only markup before the placeholder $parent->insertBefore( $noscript, $imgPlaceholder ); } } /** * Replaces images with [annotations from alt] */ private function doRemoveImages() { $doc = $this->getDoc(); $domElemsToReplace = []; foreach ( $doc->getElementsByTagName( 'img' ) as $element ) { $domElemsToReplace[] = $element; } /** @var $element DOMElement */ foreach ( $domElemsToReplace as $element ) { $alt = $element->getAttribute( 'alt' ); if ( $alt === '' ) { $alt = '[' . wfMessage( 'mobile-frontend-missing-image' )->inContentLanguage() . ']'; } else { $alt = '[' . $alt . ']'; } $replacement = $doc->createElement( 'span', htmlspecialchars( $alt ) ); $replacement->setAttribute( 'class', 'mw-mf-image-replacement' ); $element->parentNode->replaceChild( $replacement, $element ); } } /** * Performs final transformations to mobile format and returns resulting HTML * * @param DOMElement|string|null $element ID of element to get HTML from or * false to get it from the whole tree * @return string Processed HTML */ public function getText( $element = null ) { if ( $this->mainPage ) { $element = $this->parseMainPage( $this->getDoc() ); } return parent::getText( $element ); } /** * Returns interface message text * @param string $key Message key * @return string Wiki text */ protected function msg( $key ) { return wfMessage( $key )->text(); } /** * Performs transformations specific to main page * @param DOMDocument $mainPage Tree to process * @return DOMElement|null */ protected function parseMainPage( DOMDocument $mainPage ) { $featuredArticle = $mainPage->getElementById( 'mp-tfa' ); $newsItems = $mainPage->getElementById( 'mp-itn' ); $centralAuthImages = $mainPage->getElementById( 'central-auth-images' ); // Collect all the Main Page DOM elements that have an id starting with 'mf-' $xpath = new DOMXpath( $mainPage ); $elements = $xpath->query( '//*[starts-with(@id, "mf-")]' ); // These elements will be handled specially $commonAttributes = [ 'mp-tfa', 'mp-itn' ]; // Start building the new Main Page content in the $content var $content = $mainPage->createElement( 'div' ); $content->setAttribute( 'id', 'mainpage' ); // If there is a featured article section, add it to the new Main Page content if ( $featuredArticle ) { $h2FeaturedArticle = $mainPage->createElement( 'h2', $this->msg( 'mobile-frontend-featured-article' ) ); $content->appendChild( $h2FeaturedArticle ); $content->appendChild( $featuredArticle ); } // If there is a news section, add it to the new Main Page content if ( $newsItems ) { $h2NewsItems = $mainPage->createElement( 'h2', $this->msg( 'mobile-frontend-news-items' ) ); $content->appendChild( $h2NewsItems ); $content->appendChild( $newsItems ); } // Go through all the collected Main Page DOM elements and format them for mobile /** @var $element DOMElement */ foreach ( $elements as $element ) { if ( $element->hasAttribute( 'id' ) ) { $id = $element->getAttribute( 'id' ); // Filter out elements processed specially if ( !in_array( $id, $commonAttributes ) ) { // Convert title attributes into h2 headers $sectionTitle = $element->hasAttribute( 'title' ) ? $element->getAttribute( 'title' ) : ''; if ( $sectionTitle !== '' ) { $element->removeAttribute( 'title' ); $h2UnknownMobileSection = $mainPage->createElement( 'h2', htmlspecialchars( $sectionTitle ) ); $content->appendChild( $h2UnknownMobileSection ); } $br = $mainPage->createElement( 'br' ); $br->setAttribute( 'clear', 'all' ); $content->appendChild( $element ); $content->appendChild( $br ); } } } // If no mobile-appropriate content has been assembled at this point, return null. // This will cause HtmlFormatter to fall back to using the entire page. if ( $content->childNodes->length == 0 ) { return null; } // If there are CentralAuth 1x1 images, preserve them unmodified if ( $centralAuthImages ) { $content->appendChild( $centralAuthImages ); } return $content; } /** * Splits the body of the document into sections demarcated by the $headings elements. * Also moves the first paragraph in the lead section above the infobox. * * All member elements of the sections are added to a
so * that the section bodies are clearly defined (to be "expandable" for * example). * * @param DOMDocument $doc * @param [DOMElement] $headings The headings returned by * {@see MobileFormatter::getHeadings} * @param array $transformOptions Options to pass when transforming content per section */ protected function makeSections( DOMDocument $doc, array $headings, $transformOptions ) { $body = $doc->getElementsByTagName( 'body' )->item( 0 ); $sibling = $body->firstChild; $firstHeading = reset( $headings ); $sectionNumber = 0; $sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber, false ); $this->prepareHeadings( $doc, $headings, $this->scriptsEnabled ); while ( $sibling ) { $node = $sibling; $sibling = $sibling->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 ( $headings && $node->nodeName === $firstHeading->nodeName ) { if ( $sectionBody->hasChildNodes() ) { // Apply transformations to the section body $this->filterContentInSection( $sectionBody, $doc, $sectionNumber, $transformOptions ); } // Insert the previous section body and reset it for the new section $body->insertBefore( $sectionBody, $node ); if ( $sectionNumber === 0 ) { if ( $this->isTOCEnabled ) { // Insert table of content placeholder which will be progressively enhanced via JS $toc = $doc->createElement( 'div' ); $toc->setAttribute( 'id', 'toc' ); $toc->setAttribute( 'class', 'toc-mobile' ); $tocHeading = $doc->createElement( 'h2', wfMessage( 'toc' )->text() ); $toc->appendChild( $tocHeading ); $sectionBody->appendChild( $toc ); } if ( $transformOptions[ self::SHOW_FIRST_PARAGRAPH_BEFORE_INFOBOX ] ) { $this->moveFirstParagraphBeforeInfobox( $sectionBody, $doc ); } } $sectionNumber += 1; $sectionBody = $this->createSectionBodyElement( $doc, $sectionNumber, $this->scriptsEnabled ); continue; } // If it is not a top level heading, keep appending the nodes to the // section body container. $sectionBody->appendChild( $node ); } // If the document had the lead section only: if ( $sectionNumber == 0 && $transformOptions[ self::SHOW_FIRST_PARAGRAPH_BEFORE_INFOBOX ] ) { $this->moveFirstParagraphBeforeInfobox( $sectionBody, $doc ); } if ( $sectionBody->hasChildNodes() ) { // Apply transformations to the last section body $this->filterContentInSection( $sectionBody, $doc, $sectionNumber, $transformOptions ); } // Append the last section body. $body->appendChild( $sectionBody ); } /** * Prepare section headings, add required classes and onclick actions * * @param DOMDocument $doc * @param array $headings * @param bool $isCollapsible */ private function prepareHeadings( DOMDocument $doc, array $headings, $isCollapsible ) { $sectionNumber = 0; // Mark the top level headings which could control collapsing foreach ( $headings as $heading ) { $sectionNumber += 1; $className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : ''; $heading->setAttribute( 'class', $className . 'section-heading' ); if ( $isCollapsible ) { $heading->setAttribute( 'onclick', 'javascript:mfTempOpenSection(' . $sectionNumber . ')' ); } // prepend indicator $indicator = $doc->createElement( 'div' ); $indicator->setAttribute( 'class', MobileUI::iconClass( '', 'element', 'indicator' ) ); $heading->insertBefore( $indicator, $heading->firstChild ); } } /** * Creates a Section body element * * @param DOMDocument $doc * @param int $sectionNumber * @param bool $isCollapsible * * @return DOMElement */ private function createSectionBodyElement( DOMDocument $doc, $sectionNumber, $isCollapsible ) { $sectionClass = 'mf-section-' . $sectionNumber; if ( $isCollapsible ) { // TODO: Probably good to rename this to the more generic 'section'. // We have no idea how the skin will use this. $sectionClass .= ' ' . self::STYLE_COLLAPSIBLE_SECTION_CLASS; } // FIXME: The class `/mf\-section\-[0-9]+/` is kept for caching reasons // but given class is unique usage is discouraged. [T126825] $sectionBody = $doc->createElement( 'div' ); $sectionBody->setAttribute( 'class', $sectionClass ); $sectionBody->setAttribute( 'id', 'mf-section-' . $sectionNumber ); return $sectionBody; } /** * Marks the headings as editable by adding the in-block * class to each of them, if it hasn't already been added. * * FIXME: in-block isn't semantic in that it isn't * obviously connected to being editable. * * @param [DOMElement] $headings */ protected function makeHeadingsEditable( array $headings ) { foreach ( $headings as $heading ) { $class = $heading->getAttribute( 'class' ); if ( strpos( $class, 'in-block' ) === false ) { $heading->setAttribute( 'class', ltrim( $class . ' in-block' ) ); } } } /** * Gets all headings in the document in rank order. * * Note well that the rank order is defined by the * MobileFormatter#topHeadingTags property. * * @param DOMDocument $doc * @return array A two-element array where the first is the highest * rank headings and the second is all other headings */ private function getHeadings( DOMDocument $doc ) { $headings = $subheadings = []; foreach ( $this->topHeadingTags as $tagName ) { $elements = $doc->getElementsByTagName( $tagName ); if ( !$elements->length ) { continue; } // TODO: Under HHVM 3.6.6, `iterator_to_array` returns a one-indexed // array rather than a zero-indexed array. Create a minimal test case // and raise a bug. // FIXME: Remove array_values when HHVM bug fixed. $elements = array_values( iterator_to_array( $elements ) ); if ( !$headings ) { $headings = $elements; } else { $subheadings = array_merge( $subheadings, $elements ); } } return [ $headings, $subheadings ]; } } diff --git a/tests/phpunit/MobileFormatterTest.php b/tests/phpunit/MobileFormatterTest.php index 05773fc0c..1e2973ca1 100644 --- a/tests/phpunit/MobileFormatterTest.php +++ b/tests/phpunit/MobileFormatterTest.php @@ -1,852 +1,852 @@

Contents

'; const SECTION_INDICATOR = '
'; const HATNOTE_CLASSNAME = 'hatnote'; const INFOBOX_CLASSNAME = 'infobox'; /** * Helper function that creates section headings from a heading and title * * @param string $heading * @param string $innerHtml of the heading element * @param integer [$sectionNumber] heading corresponds to * @return string */ private function makeSectionHeading( $heading, $innerHtml, $sectionNumber = 1 ) { return "<$heading class=\"section-heading\"" . " onclick=\"javascript:mfTempOpenSection($sectionNumber)\">" . self::SECTION_INDICATOR . "$innerHtml"; } /** * Helper function that creates sections from section number and content HTML. * * @param string $sectionNumber * @param string $contentHtml * @param boolean $isReferenceSection whether the section contains references * @return string */ private function makeSectionHtml( $sectionNumber, $contentHtml = '', $isReferenceSection = false ) { $attrs = $isReferenceSection ? ' data-is-reference-section="1"' : ''; $className = "mf-section-$sectionNumber"; if ( $sectionNumber > 0 ) { $className = $className . ' ' . MobileFormatter::STYLE_COLLAPSIBLE_SECTION_CLASS; } return "
$contentHtml
"; } /** * @dataProvider provideHtmlTransform * * @param $input * @param $expected * @param callable|bool $callback * @param bool $removeDefaults * @param bool $lazyLoadReferences * @param bool $lazyLoadImages * @param bool $showFirstParagraphBeforeInfobox * @covers MobileFormatter::filterContent * @covers MobileFormatter::doRemoveImages */ public function testHtmlTransform( $input, $expected, $callback = false, $removeDefaults = false, $lazyLoadReferences = false, $lazyLoadImages = false, $showFirstParagraphBeforeInfobox = false ) { $t = Title::newFromText( 'Mobile' ); $input = str_replace( "\r", '', $input ); // "yay" to Windows! $mf = new MobileFormatter( MobileFormatter::wrapHTML( $input ), $t ); if ( $callback ) { $callback( $mf ); } $mf->topHeadingTags = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ]; $mf->filterContent( $removeDefaults, $lazyLoadReferences, $lazyLoadImages, $showFirstParagraphBeforeInfobox ); $html = $mf->getText(); $this->assertEquals( str_replace( "\n", '', $expected ), str_replace( "\n", '', $html ) ); } public function provideHtmlTransform() { $enableSections = function ( MobileFormatter $mf ) { $mf->enableExpandableSections(); }; $longLine = "\n" . str_repeat( 'A', 5000 ); $removeImages = function( MobileFormatter $f ) { $f->setRemoveMedia(); }; $mainPage = function( MobileFormatter $f ) { $f->setIsMainPage( true ); }; $citeUrl = SpecialPage::getTitleFor( 'MobileCite', '0' )->getLocalUrl(); $imageStyles = ''; $placeholderStyles = '' . ' ' . ''; $noscriptStyles = ''; $originalImage = 'foo'; $placeholder = '' . ' ' . ''; $noscript = ''; $refText = '

They saved the world with one single unit test' . '[1]

'; $expectedReftext = '

They saved the world with one single unit test' . '[1]

'; $refhtml = '
  1. link 1
  2. link 2
'; $refplaceholder = Html::element( 'a', [ 'class' => 'mf-lazy-references-placeholder', 'href' => $citeUrl, ], wfMessage( 'mobile-frontend-references-list' ) ); $refSectionHtml = $this->makeSectionHeading( 'h2', 'references' ) . $this->makeSectionHtml( 1, $refplaceholder, true ); return [ // # Lazy loading images // Main page not impacted [ '
a

Today

' . $originalImage . '

Tomorrow

Test.', '
a

Today

' . $originalImage . '

Tomorrow

Test.', $mainPage, false, false, true, ], // Lead section images not impacted [ '

' . $originalImage . '

heading 1

text

' . '

heading 2

abc', $this->makeSectionHtml( 0, '

' . $originalImage . '

' ) . $this->makeSectionHeading( 'h2', 'heading 1' ) . $this->makeSectionHtml( 1, '

text

' ) . $this->makeSectionHeading( 'h2', 'heading 2', 2 ) . $this->makeSectionHtml( 2, 'abc' ), $enableSections, false, false, true, ], // Test lazy loading of images outside the lead section [ '

text

heading 1

text

' . $originalImage . '

heading 2

abc', $this->makeSectionHtml( 0, '

text

' ) . $this->makeSectionHeading( 'h2', 'heading 1' ) . $this->makeSectionHtml( 1, '

text

' . $noscript . $placeholder ) . $this->makeSectionHeading( 'h2', 'heading 2', 2 ) . $this->makeSectionHtml( 2, 'abc' ), $enableSections, false, false, true, ], // Test lazy loading of images with style attributes [ '

text

heading 1

text

' . $imageStyles . '

heading 2

abc', $this->makeSectionHtml( 0, '

text

' ) . $this->makeSectionHeading( 'h2', 'heading 1' ) . $this->makeSectionHtml( 1, '

text

' . $noscriptStyles . $placeholderStyles ) . $this->makeSectionHeading( 'h2', 'heading 2', 2 ) . $this->makeSectionHtml( 2, 'abc' ), $enableSections, false, false, true, ], // https://phabricator.wikimedia.org/T130025, last section filtered [ '

text

heading 1

text

' . $originalImage .'

heading 2

' . $originalImage, $this->makeSectionHtml( 0, '

text

' ) . $this->makeSectionHeading( 'h2', 'heading 1' ) . $this->makeSectionHtml( 1, '

text

' . $noscript . $placeholder ) . $this->makeSectionHeading( 'h2', 'heading 2', 2 ) . $this->makeSectionHtml( 2, $noscript . $placeholder ), $enableSections, false, false, true, ], // # Lazy loading references [ $refText . '

references

' . $refhtml, $this->makeSectionHtml( 0, $expectedReftext ) . $refSectionHtml, $enableSections, false, true, false ], // T135923: Note the whitespace immediately inside the `sup` element. [ '

T135923 [1]

' . '

references

' . $refhtml, $this->makeSectionHtml( 0, '

T135923 ' . '[1]

' ) . $refSectionHtml, $enableSections, false, true, false ], // Empty reference class [ '

T135923

' . '

references

' . $refhtml, $this->makeSectionHtml( 0, '

T135923

' ) . $refSectionHtml, $enableSections, false, true, false ], // # Removal of images [ 'Blah', '[Blah]', $removeImages, ], [ 'picture of kitty', '' . '[picture of kitty]', $removeImages, ], [ '', '[' . wfMessage( 'mobile-frontend-missing-image' ) . ']', $removeImages, ], [ '', '[' . wfMessage( 'mobile-frontend-missing-image' ) . ']', $removeImages, ], [ 'look at the cute kitty!' . 'picture of angry dog', '[' . wfMessage( 'mobile-frontend-missing-image' ) . ']look at the cute kitty!' . '[picture of angry dog]', $removeImages, ], // # Section wrapping // \n in headers [ '

Forty-niners' . 'Edit

' . $longLine, $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h2', 'Forty-niners' . 'Edit' ) . $this->makeSectionHtml( 1, $longLine ), $enableSections ], // \n in headers [ '

h3

' . $longLine . '

h4

' . 'h4 text.', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h3', 'h3' ) . $this->makeSectionHtml( 1, $longLine . '

h4

' . 'h4 text.' ), $enableSections ], // \n in headers [ '
h6
' . $longLine, $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h6', 'h6' ) . $this->makeSectionHtml( 1, $longLine ), $enableSections ], // Bug 36670 [ '

' . 'HistoryEdit

' . $longLine, $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h2', '' . 'HistoryEdit' ) . $this->makeSectionHtml( 1, $longLine ), $enableSections ], // # Main page transformations [ 'fooo
bar
blah
', '
' . '

In the news

bar
' . '

custom

blah

', $mainPage, ], [ '
test
', '
test
', $mainPage, ], [ '
test
', '
' . '

A & B

test

', $mainPage, ], [ '
test
images
', '
test' . '
images
', $mainPage, ], [ '
test
images
', '
' . '

A & B

test

' . '
images
', $mainPage, ], // Infobox and the first paragraph in lead section transformations [ // no lead section, no infobox, a section '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // hat-note, lead section, no infobox, another section '
hatnote
' . '

paragraph 1

' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '
hatnote
' . '

paragraph 1

' . '

paragraph 2

' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // hat-note, lead section, infobox, another section '
hatnote
' . '
infobox
' . '

paragraph 1

' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '
hatnote
' . '

paragraph 1

' . '
infobox
' . '

paragraph 2

' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // first paragraph is already before the lead section '

paragraph 1

' . '
infobox
' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '

paragraph 1

' . '
infobox
' . '

paragraph 2

' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // infobox, but no paragraphs in the lead section '
infobox
' . '

Heading 1

' . '

paragraph 1

', $this->makeSectionHtml( 0, '
infobox
' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 1

' ), $enableSections, false, false, false, true, ], [ // no lead section, infobox after the first section '

Heading 1

' . '

paragraph 1

' . '
infobox
', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 1

' . '
infobox
' ), $enableSections, false, false, false, true, ], [ // two infoboxes, lead section, another section '
infobox 1
' . '
infobox 2
' . '

paragraph 1

' . '

Heading 1

' . '

paragraph 1

', $this->makeSectionHtml( 0, '

paragraph 1

' . '
infobox 1
' . '
infobox 2
' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 1

' ), $enableSections, false, false, false, true, ], [ // first paragraph (which has coordinates and is hidden on mobile), // infobox, lead section '

Coordinates

'. '
infobox
' . '

paragraph 2

', $this->makeSectionHtml( 0, '

Coordinates

'. '

paragraph 2

' . '
infobox
' ), $enableSections, false, false, false, true, ], [ // hatnote, infobox, thumbnail, lead section, another section '
hatnote
' . '
infobox
' . '
Thumbnail
' . '

paragraph 1

' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '
hatnote
' . '

paragraph 1

' . '
infobox
' . '
Thumbnail
' . '

paragraph 2

' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // empty first paragraph, infobox, second paragraph, another section '

' . '
infobox
' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '

' . '

paragraph 2

' . '
infobox
' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // infobox, empty first paragraph, second paragraph, another section '
infobox
' . '

' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '

paragraph 2

' . '
infobox
' . '

' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // 2 hat-notes, ambox, 2 infoboxes, 2 paragraphs, another section '
hatnote
' . '
hatnote
' . '
ambox
' . '
infobox 1
' . '
infobox 2
' . '

paragraph 1

' . '

paragraph 2

' . '

Heading 1

' . '

paragraph 3

', $this->makeSectionHtml( 0, '
hatnote
' . '
hatnote
' . '
ambox
' . '

paragraph 1

' . '
infobox 1
' . '
infobox 2
' . '

paragraph 2

' ) . $this->makeSectionHeading( 'h2', 'Heading 1' ) . $this->makeSectionHtml( 1, '

paragraph 3

' ), $enableSections, false, false, false, true, ], [ // Minimal test case for T149561: `p` elements should be immediate // descendants of the section container element (`div`, currently). '' . '

SURPRISE PARAGRAPH

' . '

paragraph 1

', $this->makeSectionHtml( 0, '

paragraph 1

' . '' . '

SURPRISE PARAGRAPH

' ), $enableSections, false, false, false, true, ], [ // T149389: If the infobox is inside one or more containers, i.e. not an // immediate child of the section container element, then // MobileFormatter#moveFirstParagraphBeforeInfobox will trigger a "Not // Found Error" warning. // Do not touch infoboxes that are not immediate children of the lead section '
infobox
' . '

paragraph 1

', $this->makeSectionHtml( 0, '
infobox
' . '

paragraph 1

' ), $enableSections, false, false, false, true, ], ]; } /** * @dataProvider provideHeadingTransform * @covers MobileFormatter::makeSections * @covers MobileFormatter::enableExpandableSections * @covers MobileFormatter::filterContent */ public function testHeadingTransform( array $topHeadingTags, $input, $expectedOutput ) { $t = Title::newFromText( 'Mobile' ); $formatter = new MobileFormatter( $input, $t ); // If MobileFormatter#enableExpandableSections isn't called, then headings // won't be transformed. $formatter->enableExpandableSections( true ); $formatter->topHeadingTags = $topHeadingTags; $formatter->filterContent(); $this->assertEquals( $expectedOutput, $formatter->getText() ); } public function provideHeadingTransform() { return [ // The "in-block" class is added to a subheading. [ [ 'h1', 'h2' ], '

Foo

Bar

', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h1', 'Foo' ) . $this->makeSectionHtml( 1, '

Bar

' ) ], // The "in-block" class is added to a subheading // without overwriting the existing attribute. [ [ 'h1', 'h2' ], '

Foo

Bar

', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h1', 'Foo' ) . $this->makeSectionHtml( 1, '

Bar

' ), ], // The "in-block" class is added to all subheadings. [ [ 'h1', 'h2', 'h3' ], '

Foo

Bar

Qux

', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h1', 'Foo' ) . $this->makeSectionHtml( 1, '

Bar

Qux

' ) ], // The first heading found is the highest ranked // subheading. [ [ 'h1', 'h2', 'h3' ], '

Bar

Qux

', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h2', 'Bar' ) . $this->makeSectionHtml( 1, '

Qux

' ), ], // Unenclosed text is appended to the expandable container. [ [ 'h1', 'h2' ], '

Foo

Bar

A', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h1', 'Foo' ) . $this->makeSectionHtml( 1, '

Bar

A' ) ], // Unencloded text that appears before the first // heading is appended to a container. // FIXME: This behaviour was included for backwards // compatibility but mightn't be necessary. [ [ 'h1', 'h2' ], 'A

Foo

Bar

', $this->makeSectionHtml( 0, '

A

' ) . $this->makeSectionHeading( 'h1', 'Foo' ) . $this->makeSectionHtml( 1, '

Bar

' ), ], // Multiple headings are handled identically. [ [ 'h1', 'h2' ], '

Foo

Bar

Baz

Qux

Quux', $this->makeSectionHtml( 0, '' ) . $this->makeSectionHeading( 'h1', 'Foo' ) . $this->makeSectionHtml( 1, '

Bar

Baz' ) . $this->makeSectionHeading( 'h1', 'Qux', 2 ) . $this->makeSectionHtml( 2, 'Quux' ), ], ]; } /** * @dataProvider provideGetImageDimensions * * @param array $expected what we expect the dimensions to be. * @param string $w value of width attribute (if any) * @param string $h value of height attribute (if any) * @param string $style value of style attribute (if any) * @covers MobileFormatter::getImageDimensions */ public function testGetImageDimensions( $expected, $w, $h, $style ) { $mf = new MobileFormatter( '', Title::newFromText( 'Mobile' ) ); $doc = new DOMDocument(); $img = $doc->createElement( 'img' ); if ( $style ) { $img->setAttribute( 'style', $style ); } if ( $w ) { $img->setAttribute( 'width', $w ); } if ( $h ) { $img->setAttribute( 'height', $h ); } $this->assertEquals( $expected, $mf->getImageDimensions( $img ) ); } public function provideGetImageDimensions() { return [ [ [ 'width' => '500px', 'height' => '500px' ], '500', '500', '' ], [ [ 'width' => '200px', 'height' => 'auto' ], '500', '500', 'width: 200px; height: auto;' ], [ [ 'width' => '24.412ex', 'height' => '7.343ex' ], '500', '500', 'width: 24.412ex; height: 7.343ex' ], [ [ 'width' => '24.412ex', 'height' => '7.343ex' ], '500', '500', 'height: 7.343ex; width: 24.412ex' ], [ [ 'width' => '24.412ex', 'height' => '7.343ex' ], '500', '500', 'height: 7.343ex; background-image: url(foo.jpg); width: 24.412ex ; ' . 'font-family: "Comic Sans";' ], // ... [ [], '', '', '' ] ]; } public function testInsertTOCPlaceholder() { $input = '

Hello world.

Heading

Text.'; $mf = new MobileFormatter( $input, Title::newFromText( 'Mobile' ) ); $mf->enableTOCPlaceholder(); $mf->enableExpandableSections(); $mf->topHeadingTags = [ 'h2' ]; $mf->filterContent( false, false, false ); $expected = $this->makeSectionHtml( 0, '

Hello world.

' . self::TOC ) . $this->makeSectionHeading( 'h2', 'Heading' ) . $this->makeSectionHtml( 1, 'Text.' ); $this->assertEquals( $expected, $mf->getText() ); } /** * @see https://phabricator.wikimedia.org/T137375 */ public function testT137375() { $input = '

Hello, world!

Section heading

    '; $formatter = new MobileFormatter( $input, Title::newFromText( 'Special:Foo' ) ); $formatter->filterContent( false, true, false ); } /** * @see https://phabricator.wikimedia.org/T149884 * @covers MobileFormatter::filterContent */ public function testLoggingOfInfoboxesBeingWrappedInContainers() { $this->setMwGlobals( [ 'wgMFLogWrappedInfoboxes' => true ] ); $input = '
    infobox
    ' . '

    paragraph 1

    '; $title = 'Special:T149884'; $formatter = new MobileFormatter( MobileFormatter::wrapHTML( $input ), Title::newFromText( $title ) ); $formatter->enableExpandableSections(); $loggerMock = $this->getMock( \Psr\Log\LoggerInterface::class ); $loggerMock->expects( $this->once() ) ->method( 'debug' ) ->will( $this->returnCallback( function( $message ) use ( $title ) { // Debug message contains Page title $this->assertContains( $title, $message ); // and contains revision id which is 0 by default $this->assertContains( '0', $message ); } ) ); - $this->setLogger( 'MobileFrontend', $loggerMock ); + $this->setLogger( 'mobile', $loggerMock ); $formatter->filterContent( false, false, false, true ); } }