const SUPPORTS_UNWRAP_TRANSFORM = 1;
/**
- * @var string $mText The output text
+ * @var string|null $mText The output text
*/
- public $mText;
+ public $mText = null;
/**
* @var array $mLanguageLinks List of the full text of language links,
/** @var int|null Assumed rev ID for {{REVISIONID}} if no revision is set */
private $mSpeculativeRevId;
+ /** string CSS classes to use for the wrapping div, stored in the array keys.
+ * If no class is given, no wrapper is added.
+ */
+ private $mWrapperDivClasses = [];
+
/** @var int Upper bound of expiry based on parse duration */
private $mMaxAdaptiveExpiry = INF;
const SLOW_AR_TTL = 3600; // adaptive TTL for "slow" pages
const MIN_AR_TTL = 15; // min adaptive TTL (for sanity, pool counter, and edit stashing)
+ /**
+ * @param string|null $text HTML. Use null to indicate that this ParserOutput contains only
+ * meta-data, and the HTML output is undetermined, as opposed to empty. Passing null
+ * here causes hasText() to return false.
+ * @param array $languageLinks
+ * @param array $categoryLinks
+ * @param bool $unused
+ * @param string $titletext
+ */
public function __construct( $text = '', $languageLinks = [], $categoryLinks = [],
$unused = false, $titletext = ''
) {
$this->mTitleText = $titletext;
}
+ /**
+ * Returns true if text was passed to the constructor, or set using setText(). Returns false
+ * if null was passed to the $text parameter of the constructor to indicate that this
+ * ParserOutput only contains meta-data, and the HTML output is undetermined.
+ *
+ * @since 1.32
+ *
+ * @return bool Whether this ParserOutput contains rendered text. If this returns false, the
+ * ParserOutput contains meta-data only.
+ */
+ public function hasText() {
+ return ( $this->mText !== null );
+ }
+
/**
* Get the cacheable text with <mw:editsection> markers still in it. The
* return value is suitable for writing back via setText() but is not valid
* @since 1.27
*/
public function getRawText() {
+ if ( $this->mText === null ) {
+ throw new LogicException( 'This ParserOutput contains no text!' );
+ }
+
return $this->mText;
}
* - enableSectionEditLinks: (bool) Include section edit links, assuming
* section edit link tokens are present in the HTML. Default is true,
* but might be statefully overridden.
- * - unwrap: (bool) Remove a wrapping mw-parser-output div. Default is false.
+ * - unwrap: (bool) Return text without a wrapper div. Default is false,
+ * meaning a wrapper div will be added if getWrapperDivClass() returns
+ * a non-empty string.
+ * - wrapperDivClass: (string) Wrap the output in a div and apply the given
+ * CSS class to that div. This overrides the output of getWrapperDivClass().
+ * Setting this to an empty string has the same effect as 'unwrap' => true.
* - deduplicateStyles: (bool) When true, which is the default, `<style>`
* tags with the `data-mw-deduplicate` attribute set are deduplicated by
* value of the attribute: all but the first will be replaced by `<link
* the scheme-specific-part of the href is the (percent-encoded) value
* of the `data-mw-deduplicate` attribute.
* @return string HTML
+ * @return-taint escaped
*/
public function getText( $options = [] ) {
$options += [
'enableSectionEditLinks' => true,
'unwrap' => false,
'deduplicateStyles' => true,
+ 'wrapperDivClass' => $this->getWrapperDivClass(),
];
- $text = $this->mText;
+ $text = $this->getRawText();
Hooks::runWithoutAbort( 'ParserOutputPostCacheTransform', [ $this, &$text, &$options ] );
- if ( $options['unwrap'] !== false ) {
- $start = Html::openElement( 'div', [
- 'class' => 'mw-parser-output'
- ] );
- $startLen = strlen( $start );
- $end = Html::closeElement( 'div' );
- $endPos = strrpos( $text, $end );
- $endLen = strlen( $end );
-
- if ( substr( $text, 0, $startLen ) === $start && $endPos !== false
- // if the closing div is followed by real content, bail out of unwrapping
- && preg_match( '/^(?>\s*<!--.*?-->)*\s*$/s', substr( $text, $endPos + $endLen ) )
- ) {
- $text = substr( $text, $startLen );
- $text = substr( $text, 0, $endPos - $startLen )
- . substr( $text, $endPos - $startLen + $endLen );
- }
+ if ( $options['wrapperDivClass'] !== '' && !$options['unwrap'] ) {
+ $text = Html::rawElement( 'div', [ 'class' => $options['wrapperDivClass'] ], $text );
}
if ( $options['enableSectionEditLinks'] ) {
return $skin->doEditSectionLink( $editsectionPage,
$editsectionSection,
$editsectionContent,
- $wgLang->getCode()
+ $wgLang
);
},
$text
);
}
+ // Hydrate slot section header placeholders generated by RevisionRenderer.
+ $text = preg_replace_callback(
+ '#<mw:slotheader>(.*?)</mw:slotheader>#',
+ function ( $m ) {
+ $role = htmlspecialchars_decode( $m[1] );
+ // TODO: map to message, using the interface language. Set lang="xyz" accordingly.
+ $headerText = $role;
+ return $headerText;
+ },
+ $text
+ );
return $text;
}
+ /**
+ * Add a CSS class to use for the wrapping div. If no class is given, no wrapper is added.
+ *
+ * @param string $class
+ */
+ public function addWrapperDivClass( $class ) {
+ $this->mWrapperDivClasses[$class] = true;
+ }
+
+ /**
+ * Clears the CSS class to use for the wrapping div, effectively disabling the wrapper div
+ * until addWrapperDivClass() is called.
+ */
+ public function clearWrapperDivClass() {
+ $this->mWrapperDivClasses = [];
+ }
+
+ /**
+ * Returns the class (or classes) to be used with the wrapper div for this otuput.
+ * If there is no wrapper class given, no wrapper div should be added.
+ * The wrapper div is added automatically by getText().
+ *
+ * @return string
+ */
+ public function getWrapperDivClass() {
+ return implode( ' ', array_keys( $this->mWrapperDivClasses ) );
+ }
+
/**
* @param int $id
* @since 1.28
return $this->mExternalLinks;
}
+ public function setNoGallery( $value ) {
+ $this->mNoGallery = (bool)$value;
+ }
public function getNoGallery() {
return $this->mNoGallery;
}
[ 'mParseStartTime' ]
);
}
+
+ /**
+ * Merges internal metadata such as flags, accessed options, and profiling info
+ * from $source into this ParserOutput. This should be used whenever the state of $source
+ * has any impact on the state of this ParserOutput.
+ *
+ * @param ParserOutput $source
+ */
+ public function mergeInternalMetaDataFrom( ParserOutput $source ) {
+ $this->mOutputHooks = self::mergeList( $this->mOutputHooks, $source->getOutputHooks() );
+ $this->mWarnings = self::mergeMap( $this->mWarnings, $source->mWarnings ); // don't use getter
+ $this->mTimestamp = $this->useMaxValue( $this->mTimestamp, $source->getTimestamp() );
+
+ if ( $this->mSpeculativeRevId && $source->mSpeculativeRevId
+ && $this->mSpeculativeRevId !== $source->mSpeculativeRevId
+ ) {
+ wfLogWarning(
+ 'Inconsistent speculative revision ID encountered while merging parser output!'
+ );
+ }
+
+ $this->mSpeculativeRevId = $this->useMaxValue(
+ $this->mSpeculativeRevId,
+ $source->getSpeculativeRevIdUsed()
+ );
+ $this->mParseStartTime = $this->useEachMinValue(
+ $this->mParseStartTime,
+ $source->mParseStartTime
+ );
+
+ $this->mFlags = self::mergeMap( $this->mFlags, $source->mFlags );
+ $this->mAccessedOptions = self::mergeMap( $this->mAccessedOptions, $source->mAccessedOptions );
+
+ // TODO: maintain per-slot limit reports!
+ if ( empty( $this->mLimitReportData ) ) {
+ $this->mLimitReportData = $source->mLimitReportData;
+ }
+ if ( empty( $this->mLimitReportJSData ) ) {
+ $this->mLimitReportJSData = $source->mLimitReportJSData;
+ }
+ }
+
+ /**
+ * Merges HTML metadata such as head items, JS config vars, and HTTP cache control info
+ * from $source into this ParserOutput. This should be used whenever the HTML in $source
+ * has been somehow mered into the HTML of this ParserOutput.
+ *
+ * @param ParserOutput $source
+ */
+ public function mergeHtmlMetaDataFrom( ParserOutput $source ) {
+ // HTML and HTTP
+ $this->mHeadItems = self::mergeMixedList( $this->mHeadItems, $source->getHeadItems() );
+ $this->mModules = self::mergeList( $this->mModules, $source->getModules() );
+ $this->mModuleScripts = self::mergeList( $this->mModuleScripts, $source->getModuleScripts() );
+ $this->mModuleStyles = self::mergeList( $this->mModuleStyles, $source->getModuleStyles() );
+ $this->mJsConfigVars = self::mergeMap( $this->mJsConfigVars, $source->getJsConfigVars() );
+ $this->mMaxAdaptiveExpiry = min( $this->mMaxAdaptiveExpiry, $source->mMaxAdaptiveExpiry );
+
+ // "noindex" always wins!
+ if ( $this->mIndexPolicy === 'noindex' || $source->mIndexPolicy === 'noindex' ) {
+ $this->mIndexPolicy = 'noindex';
+ } elseif ( $this->mIndexPolicy !== 'index' ) {
+ $this->mIndexPolicy = $source->mIndexPolicy;
+ }
+
+ // Skin control
+ $this->mNewSection = $this->mNewSection || $source->getNewSection();
+ $this->mHideNewSection = $this->mHideNewSection || $source->getHideNewSection();
+ $this->mNoGallery = $this->mNoGallery || $source->getNoGallery();
+ $this->mEnableOOUI = $this->mEnableOOUI || $source->getEnableOOUI();
+ $this->mPreventClickjacking = $this->mPreventClickjacking || $source->preventClickjacking();
+
+ // TODO: we'll have to be smarter about this!
+ $this->mSections = array_merge( $this->mSections, $source->getSections() );
+ $this->mTOCHTML = $this->mTOCHTML . $source->mTOCHTML;
+
+ // XXX: we don't want to concatenate title text, so first write wins.
+ // We should use the first *modified* title text, but we don't have the original to check.
+ if ( $this->mTitleText === null || $this->mTitleText === '' ) {
+ $this->mTitleText = $source->mTitleText;
+ }
+
+ // class names are stored in array keys
+ $this->mWrapperDivClasses = self::mergeMap(
+ $this->mWrapperDivClasses,
+ $source->mWrapperDivClasses
+ );
+
+ // NOTE: last write wins, same as within one ParserOutput
+ $this->mIndicators = self::mergeMap( $this->mIndicators, $source->getIndicators() );
+
+ // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+ // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+ // kinds of extension data to be merged in different ways.
+ $this->mExtensionData = self::mergeMap(
+ $this->mExtensionData,
+ $source->mExtensionData
+ );
+ }
+
+ /**
+ * Merges dependency tracking metadata such as backlinks, images used, and extension data
+ * from $source into this ParserOutput. This allows dependency tracking to be done for the
+ * combined output of multiple content slots.
+ *
+ * @param ParserOutput $source
+ */
+ public function mergeTrackingMetaDataFrom( ParserOutput $source ) {
+ $this->mLanguageLinks = self::mergeList( $this->mLanguageLinks, $source->getLanguageLinks() );
+ $this->mCategories = self::mergeMap( $this->mCategories, $source->getCategories() );
+ $this->mLinks = self::merge2D( $this->mLinks, $source->getLinks() );
+ $this->mTemplates = self::merge2D( $this->mTemplates, $source->getTemplates() );
+ $this->mTemplateIds = self::merge2D( $this->mTemplateIds, $source->getTemplateIds() );
+ $this->mImages = self::mergeMap( $this->mImages, $source->getImages() );
+ $this->mFileSearchOptions = self::mergeMap(
+ $this->mFileSearchOptions,
+ $source->getFileSearchOptions()
+ );
+ $this->mExternalLinks = self::mergeMap( $this->mExternalLinks, $source->getExternalLinks() );
+ $this->mInterwikiLinks = self::merge2D(
+ $this->mInterwikiLinks,
+ $source->getInterwikiLinks()
+ );
+
+ // TODO: add a $mergeStrategy parameter to setProperty to allow different
+ // kinds of properties to be merged in different ways.
+ $this->mProperties = self::mergeMap( $this->mProperties, $source->getProperties() );
+
+ // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+ // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+ // kinds of extension data to be merged in different ways.
+ $this->mExtensionData = self::mergeMap(
+ $this->mExtensionData,
+ $source->mExtensionData
+ );
+ }
+
+ private static function mergeMixedList( array $a, array $b ) {
+ return array_unique( array_merge( $a, $b ), SORT_REGULAR );
+ }
+
+ private static function mergeList( array $a, array $b ) {
+ return array_values( array_unique( array_merge( $a, $b ), SORT_REGULAR ) );
+ }
+
+ private static function mergeMap( array $a, array $b ) {
+ return array_replace( $a, $b );
+ }
+
+ private static function merge2D( array $a, array $b ) {
+ $values = [];
+ $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+ foreach ( $keys as $k ) {
+ if ( empty( $a[$k] ) ) {
+ $values[$k] = $b[$k];
+ } elseif ( empty( $b[$k] ) ) {
+ $values[$k] = $a[$k];
+ } elseif ( is_array( $a[$k] ) && is_array( $b[$k] ) ) {
+ $values[$k] = array_replace( $a[$k], $b[$k] );
+ } else {
+ $values[$k] = $b[$k];
+ }
+ }
+
+ return $values;
+ }
+
+ private static function useEachMinValue( array $a, array $b ) {
+ $values = [];
+ $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+ foreach ( $keys as $k ) {
+ if ( is_array( $a[$k] ?? null ) && is_array( $b[$k] ?? null ) ) {
+ $values[$k] = self::useEachMinValue( $a[$k], $b[$k] );
+ } else {
+ $values[$k] = self::useMinValue( $a[$k] ?? null, $b[$k] ?? null );
+ }
+ }
+
+ return $values;
+ }
+
+ private static function useMinValue( $a, $b ) {
+ if ( $a === null ) {
+ return $b;
+ }
+
+ if ( $b === null ) {
+ return $a;
+ }
+
+ return min( $a, $b );
+ }
+
+ private static function useMaxValue( $a, $b ) {
+ if ( $a === null ) {
+ return $b;
+ }
+
+ if ( $b === null ) {
+ return $a;
+ }
+
+ return max( $a, $b );
+ }
+
}