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 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$heading>";
}
/**
* 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 = '
';
$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 = '- link 1
- 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
[
'aToday
' . $originalImage . 'Tomorrow
Test.',
'aToday
' . $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]',
$removeImages,
],
[
'
',
'' .
'[picture of kitty]',
$removeImages,
],
[
'
',
'[' .
wfMessage( 'mobile-frontend-missing-image' ) . ']',
$removeImages,
],
[
'
',
'[' .
wfMessage( 'mobile-frontend-missing-image' ) . ']',
$removeImages,
],
[
'
look at the cute kitty!' .
'
',
'[' .
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,
],
[
'testimages',
'test' .
'images',
$mainPage,
],
[
'testimages',
'' .
'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' ],
'AFoo
Bar
',
$this->makeSectionHtml( 0, 'A
' )
. $this->makeSectionHeading( 'h1', 'Foo' )
. $this->makeSectionHtml( 1, 'Bar
' ),
],
// Multiple headings are handled identically.
[
[ 'h1', 'h2' ],
'Foo
Bar
BazQux
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 );
}
}