%PDF- %PDF-
| Direktori : /www/varak.net/wiki.varak.net/extensions/MobileFrontend/includes/ |
| Current File : //www/varak.net/wiki.varak.net/extensions/MobileFrontend/includes/MobileFormatter.php |
<?php
use HtmlFormatter\HtmlFormatter;
use MobileFrontend\ContentProviders\IContentProvider;
use MobileFrontend\Transforms\MoveLeadParagraphTransform;
use MobileFrontend\Transforms\AddMobileTocTransform;
use MobileFrontend\Transforms\NoTransform;
use MobileFrontend\Transforms\LegacyMainPageTransform;
use MobileFrontend\Transforms\LazyImageTransform;
/**
* Converts HTML into a mobile-friendly version
*/
class MobileFormatter extends HtmlFormatter {
/**
* Class name for collapsible section wrappers
*/
const STYLE_COLLAPSIBLE_SECTION_CLASS = 'collapsible-block';
/**
* Whether scripts can be added in the output.
* @var boolean $scriptsEnabled
*/
private $scriptsEnabled = true;
/**
* The current revision id of the Title being worked on
* @var integer $revId
*/
private $revId;
/** @var array $topHeadingTags Array of strings with possible tags,
can be recognized as top headings. */
public $topHeadingTags = [];
/**
* @var LazyImageTransform $lazyTransform
*/
protected $lazyTransform;
/**
* Saves a Title Object
* @var Title $title
*/
protected $title;
/**
* Whether the table of contents is needed on this page
* @var boolean $isTOCEnabled
*/
protected $isTOCEnabled = false;
/**
* Are sections expandable?
* @var boolean $expandableSections
*/
protected $expandableSections = false;
/**
* Whether actual page is the main page and should be special cased
* @var boolean $mainPage
*/
protected $mainPage = false;
/**
* Name of the transformation option
* @const string SHOW_FIRST_PARAGRAPH_BEFORE_INFOBOX
*/
const SHOW_FIRST_PARAGRAPH_BEFORE_INFOBOX = 'showFirstParagraphBeforeInfobox';
/**
* @param string $html Text to process
* @param Title $title Title to which $html belongs
*/
public function __construct( $html, $title ) {
parent::__construct( $html );
$this->title = $title;
$this->revId = $title->getLatestRevID();
$config = MobileContext::singleton()->getMFConfig();
$this->topHeadingTags = $config->get( 'MFMobileFormatterHeadings' );
$this->lazyTransform = new LazyImageTransform(
$config->get( 'MFLazyLoadSkipSmallImages' )
);
}
/**
* Disables the generation of script tags in output HTML.
*/
public function disableScripts() {
$this->scriptsEnabled = false;
}
/**
* Creates and returns a MobileFormatter
*
* @param MobileContext $context in which the page is being rendered. Needed to access page title
* and MobileFrontend configuration.
* @param IContentProvider $provider
* @param bool $enableSections (optional)
* whether to wrap the content of sections
*
* @return MobileFormatter
*/
public static function newFromContext( MobileContext $context,
IContentProvider $provider,
$enableSections = false
) {
$mfSpecialCaseMainPage = $context->getMFConfig()->get( 'MFSpecialCaseMainPage' );
$title = $context->getTitle();
$isMainPage = $title->isMainPage();
$isFilePage = $title->inNamespace( NS_FILE );
$html = self::wrapHTML( $provider->getHTML() );
$formatter = new MobileFormatter( $html, $title );
if ( $isMainPage ) {
$formatter->enableExpandableSections( !$mfSpecialCaseMainPage );
} else {
$formatter->enableExpandableSections( $enableSections );
}
$formatter->setIsMainPage( $isMainPage && $mfSpecialCaseMainPage );
$formatter->enableTOCPlaceholder( strpos( $html, 'toclevel' ) !== false );
return $formatter;
}
/**
* Mark whether a placeholder table of contents should be included at the end of the lead
* section
* @param bool $flag should TOC be included?
*/
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 should expandable sections be supported?
*/
public function enableExpandableSections( $flag = true ) {
$this->expandableSections = $flag;
}
/**
* Change mainPage (is this the main page) to $value (standard: true)
* This enables special casing for the main page.
* @deprecated
* @param bool $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();
}
// Apply all removals before continuing with transforms (see T185040 for example)
$removed = parent::filterContent();
$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->makeHeadingsEditable( $subheadings );
$this->makeSections( $doc, $headings, $transformOptions );
} 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 $removed;
}
/**
* Apply filtering per element (section) in a document.
* @param DOMElement|DOMDocument $el
* @param DOMDocument $doc
* @param int $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->lazyTransform->apply( $el );
}
if ( $options['references'] ) {
$this->doRewriteReferencesListsForLazyLoading( $el, $doc );
}
}
/**
* 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 <references> 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' );
}
}
/**
* 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()->text() . ']';
} 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 ) {
$transform = new LegacyMainPageTransform();
$doc = $this->getDoc();
$transform->apply( $doc->getElementsByTagName( 'body' )->item( 0 ) );
}
return parent::getText( $element );
}
/**
* 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 <code><div></code> so
* that the section bodies are clearly defined (to be "expandable" for
* example).
*
* @param DOMDocument $doc representing the HTML of the current article. In the HTML the sections
* should not be wrapped.
* @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, array $transformOptions ) {
$noTransform = new NoTransform();
$tocTransform = $this->isTOCEnabled ? new AddMobileTocTransform() : $noTransform;
$leadTransform = $transformOptions[ self::SHOW_FIRST_PARAGRAPH_BEFORE_INFOBOX ] ?
new MoveLeadParagraphTransform( $this->title, $this->revId ) : $noTransform;
// Find the parser output wrapper div
$xpath = new DOMXPath( $doc );
$containers = $xpath->query( 'body/div[@class="mw-parser-output"][1]' );
if ( !$containers->length ) {
// No wrapper? This could be an old parser cache entry, or perhaps the
// OutputPage contained something that was not generated by the parser.
// Try using the <body> as the container.
$containers = $xpath->query( 'body' );
if ( !$containers->length ) {
throw new Exception( "HTML lacked body element even though we put it there ourselves" );
}
}
$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
$this->prepareHeading( $doc, $node, $sectionNumber + 1, $this->scriptsEnabled );
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
$container->insertBefore( $sectionBody, $node );
if ( $sectionNumber === 0 ) {
$tocTransform->apply( $sectionBody );
$leadTransform->apply( $sectionBody );
}
$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 ) {
$leadTransform->apply( $sectionBody );
}
if ( $sectionBody->hasChildNodes() ) {
// Apply transformations to the last section body
$this->filterContentInSection( $sectionBody, $doc, $sectionNumber, $transformOptions );
}
// Append the last section body.
$container->appendChild( $sectionBody );
}
/**
* Prepare section headings, add required classes and onclick actions
*
* @param DOMDocument $doc
* @param DOMElement $heading
* @param integer $sectionNumber
* @param bool $isCollapsible
*/
private function prepareHeading( DOMDocument $doc, $heading, $sectionNumber, $isCollapsible ) {
$className = $heading->hasAttribute( 'class' ) ? $heading->getAttribute( 'class' ) . ' ' : '';
$heading->setAttribute( 'class', $className . 'section-heading' );
if ( $isCollapsible ) {
$heading->setAttribute( 'onclick', 'javascript:mfTempOpenSection(' . $sectionNumber . ')' );
}
// prepend indicator - this avoids a reflow by creating a placeholder for a toggling 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 <code>in-block</code>
* class to each of them, if it hasn't already been added.
*
* FIXME: <code>in-block</code> isn't semantic in that it isn't
* obviously connected to being editable.
*
* @param DOMElement[] $headings Heading elements
*/
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
* <code>MobileFormatter#topHeadingTags</code> 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 ) {
$allTags = $doc->getElementsByTagName( $tagName );
$elements = [];
foreach ( $allTags as $el ) {
if ( $el->parentNode->getAttribute( 'class' ) !== 'toctitle' ) {
$elements[] = $el;
}
}
if ( !$headings ) {
$headings = $elements;
} else {
$subheadings = array_merge( $subheadings, $elements );
}
}
return [ $headings, $subheadings ];
}
}