mw.user.getPageviewToken to better capture its function.
* Passing Revision objects to ContentHandler::getUndoContent() is deprecated,
Content object should be passed instead.
+* (T197179) Parameters 'notice', 'notice-messages', 'notice-message',
+ previously used by OOUI HTMLForm fields, are now deprecated. Use
+ 'help', 'help-message', 'help-messages' instead.
+* (T197179) HTMLFormField::getNotices() is now deprecated.
+* The jquery.localize module is now deprecated. Use jquery.i18n instead.
=== Other changes in 1.32 ===
* (T198811) The following tables have had their UNIQUE indexes turned into
* they should be carefully handled in the function processing the
* request.
*
+ * phan-taint-check triggers as it is not smart enough to understand
+ * the early return if func_name not in AjaxExportList.
+ * @suppress SecurityCheck-XSS
* @param User $user
*/
function performAction( User $user ) {
$this->sectiontitle = $request->getVal( 'preloadtitle' );
// Once wpSummary isn't being use for setting section titles, we should delete this.
$this->summary = $request->getVal( 'preloadtitle' );
- } elseif ( $this->section != 'new' && $request->getVal( 'summary' ) ) {
+ } elseif ( $this->section != 'new' && $request->getVal( 'summary' ) !== '' ) {
$this->summary = $request->getText( 'summary' );
if ( $this->summary !== '' ) {
$this->hasPresetSummary = true;
if ( $this->summary === '' ) {
$cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle );
return $this->context->msg( 'newsectionsummary' )
- ->rawParams( $cleanSectionTitle )->inContentLanguage()->text();
+ ->plaintextParams( $cleanSectionTitle )->inContentLanguage()->text();
}
} elseif ( $this->summary !== '' ) {
$sectionanchor = $this->guessSectionName( $this->summary );
# in the revision summary.
$cleanSummary = $wgParser->stripSectionName( $this->summary );
return $this->context->msg( 'newsectionsummary' )
- ->rawParams( $cleanSummary )->inContentLanguage()->text();
+ ->plaintextParams( $cleanSummary )->inContentLanguage()->text();
}
return $this->summary;
}
$this->autoSumm = md5( '' );
}
- $autosumm = $this->autoSumm ?: md5( $this->summary );
+ $autosumm = $this->autoSumm !== '' ? $this->autoSumm : md5( $this->summary );
$out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) );
$out->addHTML( Html::hidden( 'oldid', $this->oldid ) );
}
/**
- * Output a "<script>" tag with the given contents.
+ * Output an HTML script tag with the given contents.
*
- * @todo do some useful escaping as well, like if $contents contains
- * literal "</script>" or (for XML) literal "]]>".
+ * It is unsupported for the contents to contain the sequence `<script` or `</script`
+ * (case-insensitive). This ensures the script can be terminated easily and consistently.
+ * It is the responsibility of the caller to avoid such character sequence by escaping
+ * or avoiding it. If found at run-time, the contents are replaced with a comment, and
+ * a warning is logged server-side.
*
* @param string $contents JavaScript
* @param string|null $nonce Nonce for CSP header, from OutputPage::getCSPNonce()
}
}
- if ( preg_match( '/[<&]/', $contents ) ) {
- $contents = "/*<![CDATA[*/$contents/*]]>*/";
+ if ( preg_match( '/<\/?script/i', $contents ) ) {
+ wfLogWarning( __METHOD__ . ': Illegal character sequence found in inline script.' );
+ $contents = '/* ERROR: Invalid script */';
}
return self::rawElement( 'script', $attrs, $contents );
* @param string|null $wikiId Id of the wiki to link to (if not the local wiki),
* as used by WikiMap.
*
- * @return string
+ * @return string HTML
+ * @return-taint escapes_html
*/
public static function formatLinksInComment(
$comment, $title = null, $local = false, $wikiId = null
foreach ( $this->contentOverrideCallbacks as $callback ) {
$content = $callback( $title );
if ( $content !== null ) {
+ $text = ContentHandler::getContentText( $content );
+ if ( strpos( $text, '</script>' ) !== false ) {
+ // Proactively replace this so that we can display a message
+ // to the user, instead of letting it go to Html::inlineScript(),
+ // where it would be considered a server-side issue.
+ $titleFormatted = $title->getPrefixedText();
+ $content = new JavaScriptContent(
+ Xml::encodeJsCall( 'mw.log.error', [
+ "Cannot preview $titleFormatted due to script-closing tag."
+ ] )
+ );
+ }
return $content;
}
}
$result_array['text'] = $p_result->getText( [
'allowTOC' => !$params['disabletoc'],
'enableSectionEditLinks' => !$params['disableeditsection'],
- 'unwrap' => $params['wrapoutputclass'] === '',
+ 'wrapperDivClass' => $params['wrapoutputclass'],
'deduplicateStyles' => !$params['disablestylededuplication'],
] );
$result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
"Umherirrender",
"NeverBehave",
"Wbxshiori",
- "Wxyveronica"
+ "Wxyveronica",
+ "WhitePhosphorus"
]
},
"apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|文档]]\n* [[mw:Special:MyLanguage/API:FAQ|常见问题]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 邮件列表]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 程序错误与功能请求]\n</div>\n<strong>状态信息:</strong>MediaWiki API是一个成熟稳定的,不断受到支持和改进的界面。尽管我们尽力避免,但偶尔也需要作出重大更新;请订阅[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 邮件列表]以便获得更新通知。\n\n<strong>错误请求:</strong>当API收到错误请求时,HTTP header将会返回一个包含\"MediaWiki-API-Error\"的值,随后header的值与error code将会送回并设置为相同的值。详细信息请参阅[[mw:Special:MyLanguage/API:Errors_and_warnings|API:错误与警告]]。\n\n<p class=\"mw-apisandbox-link\"><strong>测试中:</strong>测试API请求的易用性,请参见[[Special:ApiSandbox]]。</p>",
"apierror-mustbeloggedin-linkaccounts": "您必须登录以链接账户。",
"apierror-mustbeloggedin-removeauth": "您必须登录以移除身份验证数据。",
"apierror-mustbeloggedin-uploadstash": "上传暂存功能只对已登录用户可用。",
- "apierror-mustbeloggedin": "您必须登录至$1。",
+ "apierror-mustbeloggedin": "您必须登录才能$1。",
"apierror-mustbeposted": "<kbd>$1</kbd>模块需要POST请求。",
"apierror-mustpostparams": "以下{{PLURAL:$2|参数}}在查询字符串中被找到,但必须在POST正文中:$1。",
"apierror-noapiwrite": "通过API编辑此wiki已禁用。请确保<code>$wgEnableWriteAPI=true;</code>声明包含在wiki的<code>LocalSettings.php</code>文件中。",
"Wwycheuk",
"Wbxshiori",
"Sanmosa",
- "Kly"
+ "Kly",
+ "WhitePhosphorus"
]
},
"apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|說明文件]]\n* [[mw:Special:MyLanguage/API:FAQ|常見問題]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 郵寄清單]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 報告錯誤及請求功能]\n</div>\n<strong>狀態資訊:</strong>MediaWiki API 已是成熟、穩定,並積極支援以改善的介面。儘管我們儘可能避免,但仍偶有需要重大變更的情況,請訂閱[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 郵寄清單]以便獲得更新通知。\n\n<strong>錯誤的請求:</strong>當 API 收到錯誤的請求,會發出以「MediaWiki-API-Error」為鍵的 HTTP 標頭欄位,隨後標頭欄位的值,以及傳回的錯誤碼會設為相同值。詳細資訊請參閱 [[mw:Special:MyLanguage/API:Errors_and_warnings|API: 錯誤與警告]]。\n\n<p class=\"mw-apisandbox-link\"><strong>測試:</strong>要簡化 API 請求的測試過程,請見 [[Special:ApiSandbox]]。</p>",
"apierror-mustbeloggedin-generic": "您必須登入。",
"apierror-mustbeloggedin-linkaccounts": "您必須登入到連結帳號。",
"apierror-mustbeloggedin-removeauth": "必須登入,才能移除身分核對資取。",
- "apierror-mustbeloggedin": "您必須登入至$1。",
+ "apierror-mustbeloggedin": "您必須登入才能$1。",
"apierror-nodeleteablefile": "沒有這樣檔案的舊版本。",
"apierror-noedit-anon": "匿名使用者不可編輯頁面。",
"apierror-noedit": "您沒有權限來編輯頁面。",
}
// insert a row into change_tag for each new tag
+ $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
if ( count( $tagsToAdd ) ) {
$changeTagMapping = [];
if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_OLD ) {
- $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
-
foreach ( $tagsToAdd as $tag ) {
$changeTagMapping[$tag] = $changeTagDefStore->acquireId( $tag );
}
$tagsRows = [];
foreach ( $tagsToAdd as $tag ) {
+ if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ $tagName = null;
+ } else {
+ $tagName = $tag;
+ }
// Filter so we don't insert NULLs as zero accidentally.
// Keep in mind that $rc_id === null means "I don't care/know about the
// rc_id, just delete $tag on this revision/log entry". It doesn't
// mean "only delete tags on this revision/log WHERE rc_id IS NULL".
$tagsRows[] = array_filter(
[
- 'ct_tag' => $tag,
+ 'ct_tag' => $tagName,
'ct_rc_id' => $rc_id,
'ct_log_id' => $log_id,
'ct_rev_id' => $rev_id,
// delete from change_tag
if ( count( $tagsToRemove ) ) {
foreach ( $tagsToRemove as $tag ) {
+ if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ $tagName = null;
+ $tagId = $changeTagDefStore->getId( $tag );
+ } else {
+ $tagName = $tag;
+ $tagId = null;
+ }
$conds = array_filter(
[
- 'ct_tag' => $tag,
+ 'ct_tag' => $tagName,
'ct_rc_id' => $rc_id,
'ct_log_id' => $log_id,
- 'ct_rev_id' => $rev_id
+ 'ct_rev_id' => $rev_id,
+ 'ct_tag_id' => $tagId,
]
);
$dbw->delete( 'change_tag', $conds, __METHOD__ );
public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
&$join_conds, &$options, $filter_tag = ''
) {
- global $wgUseTagFilter;
+ global $wgUseTagFilter, $wgChangeTagsSchemaMigrationStage;
// Normalize to arrays
$tables = (array)$tables;
throw new MWException( 'Unable to determine appropriate JOIN condition for tagging.' );
}
+ if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ $tables[] = 'change_tag_def';
+ $join_cond = [ $join_cond, 'ct_tag_id=ctd_id' ];
+ $field = 'ctd_name';
+ } else {
+ $field = 'ct_tag';
+ }
+
$fields['ts_tags'] = wfGetDB( DB_REPLICA )->buildGroupConcatField(
- ',', 'change_tag', 'ct_tag', $join_cond
+ ',', 'change_tag', $field, $join_cond
);
if ( $wgUseTagFilter && $filter_tag ) {
$tables[] = 'change_tag';
$join_conds['change_tag'] = [ 'INNER JOIN', $join_cond ];
- $conds['ct_tag'] = $filter_tag;
+ if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ $tables[] = 'change_tag_def';
+ $join_conds['change_tag_def'] = [ 'INNER JOIN', 'ct_tag_id=ctd_id' ];
+ $conds['ctd_name'] = $filter_tag;
+ } else {
+ $conds['ct_tag'] = $filter_tag;
+ }
+
if (
is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
!in_array( 'DISTINCT', $options )
// delete from valid_tag and/or set ctd_user_defined = 0
self::undefineTag( $tag );
+ if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ $tagId = MediaWikiServices::getInstance()->getChangeTagDefStore()->getId( $tag );
+ $conditions = [ 'ct_tag_id' => $tagId ];
+ } else {
+ $conditions = [ 'ct_tag' => $tag ];
+ }
+
// find out which revisions use this tag, so we can delete from tag_summary
$result = $dbw->select( 'change_tag',
- [ 'ct_rc_id', 'ct_log_id', 'ct_rev_id', 'ct_tag' ],
- [ 'ct_tag' => $tag ],
+ [ 'ct_rc_id', 'ct_log_id', 'ct_rev_id' ],
+ $conditions,
__METHOD__ );
foreach ( $result as $row ) {
// remove the tag from the relevant row of tag_summary
}
// delete from change_tag
- $dbw->delete( 'change_tag', [ 'ct_tag' => $tag ], __METHOD__ );
+ if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+ $tagId = MediaWikiServices::getInstance()->getChangeTagDefStore()->getId( $tag );
+ $dbw->delete( 'change_tag', [ 'ct_tag_id' => $tagId ], __METHOD__ );
+ } else {
+ $dbw->delete( 'change_tag', [ 'ct_tag' => $tag ], __METHOD__ );
+ }
if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_OLD ) {
$dbw->delete( 'change_tag_def', [ 'ctd_name' => $tag ], __METHOD__ );
$html = '';
}
+ $output->clearWrapperDivClass();
$output->setText( $html );
}
// Make sure all links update threads see the changes of each other.
// This handles the case when updates have to batched into several COMMITs.
$scopedLock = LinksUpdate::acquirePageLock( $this->getDB(), $id );
+ if ( !$scopedLock ) {
+ throw new RuntimeException( "Could not acquire lock for page ID '{$id}'." );
+ }
}
$title = $this->page->getTitle();
*/
use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use Wikimedia\ScopedCallback;
// Make sure all links update threads see the changes of each other.
// This handles the case when updates have to batched into several COMMITs.
$scopedLock = self::acquirePageLock( $this->getDB(), $this->mId );
+ if ( !$scopedLock ) {
+ throw new RuntimeException( "Could not acquire lock for page ID '{$this->mId}'." );
+ }
}
// Avoid PHP 7.1 warning from passing $this by reference
* @param IDatabase $dbw
* @param int $pageId
* @param string $why One of (job, atomicity)
- * @return ScopedCallback
- * @throws RuntimeException
+ * @return ScopedCallback|null
* @since 1.27
*/
public static function acquirePageLock( IDatabase $dbw, $pageId, $why = 'atomicity' ) {
$key = "LinksUpdate:$why:pageid:$pageId";
$scopedLock = $dbw->getScopedLockAndFlush( $key, __METHOD__, 15 );
if ( !$scopedLock ) {
- throw new RuntimeException( "Could not acquire lock '$key'." );
+ $logger = LoggerFactory::getInstance( 'SecondaryDataUpdate' );
+ $logger->info( "Could not acquire lock '{key}' for page ID '{page_id}'.", [
+ 'key' => $key,
+ 'page_id' => $pageId,
+ ] );
+ return null;
}
return $scopedLock;
* 'help-inline' -- Whether help text (defined using options above) will be shown
* inline after the input field, rather than in a popup.
* Defaults to true. Only used by OOUI form fields.
- * 'notice' -- message text for a message to use as a notice in the field.
- * Currently used by OOUI form fields only.
- * 'notice-messages' -- array of message keys/objects to use for notice.
- * Overrides 'notice'.
- * 'notice-message' -- message key or object to use as a notice.
+ * 'notice' -- (deprecated, use 'help' instead)
+ * 'notice-messages' -- (deprecated, use 'help-messages' instead)
+ * 'notice-message' -- (deprecated, use 'help-message' instead)
* 'required' -- passed through to the object, indicating that it
* is a required field.
* 'size' -- the length of text fields
if ( isset( $params['hide-if'] ) ) {
$this->mHideIf = $params['hide-if'];
}
+
+ if ( isset( $this->mParams['notice-message'] ) ) {
+ wfDeprecated( "'notice-message' parameter in HTMLForm", '1.32' );
+ }
+ if ( isset( $this->mParams['notice-messages'] ) ) {
+ wfDeprecated( "'notice-messages' parameter in HTMLForm", '1.32' );
+ }
+ if ( isset( $this->mParams['notice'] ) ) {
+ wfDeprecated( "'notice' parameter in HTMLForm", '1.32' );
+ }
}
/**
$error = new OOUI\HtmlSnippet( $error );
}
- $notices = $this->getNotices();
+ $notices = $this->getNotices( 'skip deprecation' );
foreach ( $notices as &$notice ) {
$notice = new OOUI\HtmlSnippet( $notice );
}
* Determine form errors to display and their classes
* @since 1.20
*
+ * phan-taint-check gets confused with returning both classes
+ * and errors and thinks double escaping is happening, so specify
+ * that return value has no taint.
+ *
* @param string $value The value of the input
* @return array array( $errors, $errorClass )
+ * @return-taint none
*/
public function getErrorsAndErrorClass( $value ) {
$errors = $this->validate( $value, $this->mParent->mFieldData );
* Determine notices to display for the field.
*
* @since 1.28
+ * @deprecated since 1.32
+ * @param string $skipDeprecation Pass 'skip deprecation' to avoid the deprecation
+ * warning (since 1.32)
* @return string[]
*/
- public function getNotices() {
+ public function getNotices( $skipDeprecation = null ) {
+ if ( $skipDeprecation !== 'skip deprecation' ) {
+ wfDeprecated( __METHOD__, '1.32' );
+ }
+
$notices = [];
if ( isset( $this->mParams['notice-message'] ) ) {
* Formats one or more errors as accepted by field validation-callback.
*
* @param string|Message|array $errors Array of strings or Message instances
+ * To work around limitations in phan-taint-check the calling
+ * class has taintedness disabled. So instead we pretend that
+ * this method outputs html, since the result is eventually
+ * outputted anyways without escaping and this allows us to verify
+ * stuff is safe even though the caller has taintedness cleared.
+ * @param-taint $errors exec_html
* @return string HTML
* @since 1.18
*/
// Serialize links updates by page ID so they see each others' changes
$scopedLock = LinksUpdate::acquirePageLock( wfGetDB( DB_MASTER ), $pageId, 'job' );
+ if ( $scopedLock === null ) {
+ $this->setLastError( 'LinksUpdate already running for this page, try again later.' );
+ return false;
+ }
if ( WikiPage::newFromID( $pageId, WikiPage::READ_LATEST ) ) {
// The page was restored somehow or something went wrong
$dbw = $lbFactory->getMainLB()->getConnection( DB_MASTER );
/** @noinspection PhpUnusedLocalVariableInspection */
$scopedLock = LinksUpdate::acquirePageLock( $dbw, $page->getId(), 'job' );
+ if ( $scopedLock === null ) {
+ // Another job is already updating the page, likely for an older revision (T170596).
+ $this->setLastError( 'LinksUpdate already running for this page, try again later.' );
+ return false;
+ }
// Get the latest ID *after* acquirePageLock() flushed the transaction.
// This is used to detect edits/moves after loadPageData() but before the scope lock.
// The works around the chicken/egg problem of determining the scope lock key.
$lookAhead = ( $idx + 1 < $maxLen ) ? $str[$idx + 1] : '';
$lookBehind = ( $idx - 1 >= 0 ) ? $str[$idx - 1] : '';
if ( $inString ) {
- continue;
+ break;
} elseif ( !$inComment &&
( $lookAhead === '/' || $lookAhead === '*' )
* moved to separate EditPage and HTMLFileCache classes.
*/
class Article implements Page {
- /** @var IContextSource The context this Article is executed in */
+ /**
+ * @var IContextSource|null The context this Article is executed in.
+ * If null, REquestContext::getMain() is used.
+ */
protected $mContext;
/** @var WikiPage The WikiPage object of this instance */
protected $mPage;
- /** @var ParserOptions ParserOptions object for $wgUser articles */
+ /**
+ * @var ParserOptions|null ParserOptions object for $wgUser articles.
+ * Initialized by getParserOptions by calling $this->mPage->makeParserOptions().
+ */
public $mParserOptions;
/**
- * @var string Text of the revision we are working on
+ * @var string|null Text of the revision we are working on
* @todo BC cruft
*/
public $mContent;
/**
- * @var Content Content of the revision we are working on
+ * @var Content|null Content of the revision we are working on.
+ * Initialized by fetchContentObject().
* @since 1.21
*/
public $mContentObject;
/** @var int|null The oldid of the article that is to be shown, 0 for the current revision */
public $mOldId;
- /** @var Title Title from which we were redirected here */
+ /** @var Title|null Title from which we were redirected here, if any. */
public $mRedirectedFrom = null;
/** @var string|bool URL to redirect to or false if none */
/** @var int Revision ID of revision we are working on */
public $mRevIdFetched = 0;
- /** @var Revision Revision we are working on */
+ /**
+ * @var Revision|null Revision we are working on. Initialized by getOldIDFromRequest()
+ * or fetchContentObject().
+ */
public $mRevision = null;
- /** @var ParserOutput */
+ /**
+ * @var ParserOutput|null|false The ParserOutput generated for viewing the page,
+ * initialized by view(). If no ParserOutput could be generated, this is set to false.
+ */
public $mParserOutput;
/**
# Note that $this->mParserOutput is the *current*/oldid version output.
$pOutput = ( $outputDone instanceof ParserOutput )
? $outputDone // object fetched by hook
- : $this->mParserOutput;
+ : $this->mParserOutput ?: null; // ParserOutput or null, avoid false
# Adjust title for main page & pages with displaytitle
if ( $pOutput ) {
# with CSS (T37247)
$class = $this->mOptions->getWrapOutputClass();
if ( $class !== false && !$this->mOptions->getInterfaceMessage() ) {
- $text = Html::rawElement( 'div', [ 'class' => $class ], $text );
+ $this->mOutput->addWrapperDivClass( $class );
}
$this->mOutput->setText( $text );
/** @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;
* - 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
'enableSectionEditLinks' => true,
'unwrap' => false,
'deduplicateStyles' => true,
+ 'wrapperDivClass' => $this->getWrapperDivClass(),
];
$text = $this->mText;
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 $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
/**
* convert text to different variants of a language.
*
- * @param string $text
- * @return string
+ * @param string $text Content that has been already escaped for use in HTML
+ * @return string HTML
*/
public function convert( $text ) {
return $this->mConverter->convert( $text );
$warningDone = true;
}
$startPos += 2;
- continue;
+ break;
}
// Recursively parse another rule
$inner .= $this->recursiveConvertRule( $text, $variant, $startPos, $depth + 1 );
"backend-fail-hashes": "Немагчыма атрымаць хэшы файлаў для параўнаньня.",
"backend-fail-notsame": "Ужо існуе неідэнтычны файл «$1».",
"backend-fail-invalidpath": "«$1» не зьяўляецца слушным шляхам да сховішча.",
- "backend-fail-delete": "Немагчыма выдаліць файл $1.",
+ "backend-fail-delete": "Немагчыма выдаліць файл «$1».",
"backend-fail-describe": "Не атрымалася зьмяніць мэтазьвесткі для файла «$1».",
"backend-fail-alreadyexists": "Файл $1 ужо існуе.",
"backend-fail-store": "Немагчыма захаваць файл $1 у $2.",
"feed-atom": "অ্যাটম",
"red-link-title": "$1 (পাতার অস্তিত্ব নেই)",
"sort-descending": "উল্টো বর্ণক্রমে সাজান",
- "sort-ascending": "বরà§\8dণানুক্রমে সাজান",
+ "sort-ascending": "à¦\8aরà§\8dদà§\8dধানুক্রমে সাজান",
"nstab-main": "পাতা",
"nstab-user": "ব্যবহারকারীর পাতা",
"nstab-media": "মিডিয়া পাতা",
"edit-error-long": "Chyby:\n\n$1",
"revid": "revize $1",
"pageid": "Stránka s ID $1",
- "interfaceadmin-info": "$1\n\nOprávnění editovat celoprojektové soubory s CSS/JS/JSON bylo nedávno odděleno od práva <code>editinterface</code>. Pokud nerozumíte tomu, proč vidíte tuto chybu, podívejte se na [[mw:MediaWiki_1.32/interface-admin]].",
+ "interfaceadmin-info": "Oprávnění editovat celoprojektové soubory s CSS/JS/JSON bylo nedávno omezeno na členy skupiny [[{{int:grouppage-interface-admin}}|{{int:group-interface-admin}}]]. Pro více informací viz [[m:Creation of separate user group for editing sitewide CSS/JS]].",
"rawhtml-notallowed": "Značky <html> nelze používat mimo běžné stránky.",
"gotointerwiki": "Opustit {{GRAMMAR:4sg|{{SITENAME}}}}",
"gotointerwiki-invalid": "Zadaný název je neplatný.",
"Kenn.jensen",
"Saederup92",
"Fitoschido",
- "Jorn Ari"
+ "Jorn Ari",
+ "Fnielsen"
]
},
"tog-underline": "Understreg henvisninger:",
"right-editcontentmodel": "Redigere indholdsmodellen for en side",
"right-editinterface": "Ændre brugergrænsefladens tekster",
"right-editusercss": "Ændre andre brugeres CSS filer",
+ "right-edituserjson": "Redigér andre brugeres JSON-filter",
"right-edituserjs": "Ændre andre brugeres JS filer",
"right-editsitecss": "Rediger CSS for hele siden",
"right-editsitejson": "Rediger JSON for hele siden",
"right-editsitejs": "Rediger JavaScript for hele siden",
"right-editmyusercss": "Redigere dine egne CSS-filer",
+ "right-editmyuserjson": "Redigér dine egne bruger-JSON-filer",
"right-editmyuserjs": "Redigere dine egne JavaScript-filer",
"right-viewmywatchlist": "Se din egen overvågningsliste",
"right-editmywatchlist": "Redigere din egen overvågningsliste. Bemærk nogle handlinger tilføjer sider selv uden denne rettelse.",
"rcfilters-advancedfilters": "Avancerede filtre",
"rcfilters-limit-title": "Antal resultater som skal vises",
"rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|ændring|ændringer}}, $2",
+ "rcfilters-date-popup-title": "Tidsperiode at søge i",
"rcfilters-days-title": "De sidste dage",
"rcfilters-hours-title": "De sidste timer",
"rcfilters-days-show-days": "$1 {{PLURAL:$1|dag|dage}}",
"rcfilters-highlighted-filters-list": "Fremhævede: $1",
"rcfilters-quickfilters": "Gemte filtre",
"rcfilters-quickfilters-placeholder-title": "Ingen filtre gemt endnu",
+ "rcfilters-quickfilters-placeholder-description": "For at gemme filterindstillingerne og genbruge dem senere, klik på bogmærkeikonet i området Aktive Filtre herunder.",
"rcfilters-savedqueries-defaultlabel": "Gemte filtre",
"rcfilters-savedqueries-rename": "Omdøb",
"rcfilters-savedqueries-setdefault": "Vælg som grundindstilling",
"protectedtitles-submit": "Vis sidetitler",
"listusers": "Brugerliste",
"listusers-editsonly": "Vis kun brugere med redigeringer",
+ "listusers-temporarygroupsonly": "Vis kun brugere i midlertidige brugergrupper",
"listusers-creationsort": "Sorter efter oprettelsesdato",
"listusers-desc": "Sortér i faldende rækkefølge",
"usereditcount": "{{PLURAL:$1|én redigering|$1 redigeringer}}",
"apihelp": "API-hjælp",
"apihelp-no-such-module": "Modul \"$1\" ikke fundet.",
"apisandbox": "API-sandkassen",
+ "apisandbox-jsonly": "JavaScript kræves for at bruge API-sandkassen.",
"apisandbox-api-disabled": "API er deaktiveret på dette websted.",
"apisandbox-intro": "Brug denne side til at eksperimentere med '''MediaWiki web service API'''.\nVi henviser til [https://www.mediawiki.org/wiki/API:Main_page dokumentationen af API] for yderligere oplysninger om brug af API. Eksempel: [https://www.mediawiki.org/wiki/API#A_simple_example få indholdet af en forside]. Vælg en handling at se flere eksempler.\n\nBemærk, at selv om dette er en sandkasse, vil handlinger du udfører på denne side redigere wikien.",
"apisandbox-submit": "Lav forespørgsel",
"pagedata-bad-title": "Virheellinen otsikko: $1.",
"unregistered-user-config": "Turvallisuussyistä JavaScript-, CSS- ja JSON-käyttäjäalasivuja ei voi ladata rekisteröimättömiltä käyttäjiltä.",
"passwordpolicies": "Salasanakäytännöt",
- "passwordpolicies-summary": "Tämä on luettelo käytössä olevista salasanakäytännöistä tämän wikin käyttäjäryhmille.",
+ "passwordpolicies-summary": "Tämä on luettelo voimassa olevista salasanakäytännöistä tämän wikin käyttäjäryhmille.",
"passwordpolicies-group": "Ryhmä",
"passwordpolicies-policies": "Käytännöt",
- "passwordpolicies-policy-minimalpasswordlength": "Salasanan on oltava ainakin $1 {{PLURAL:$1|merkki|merkkiä}} pitkä",
+ "passwordpolicies-policy-minimalpasswordlength": "Salasanan tulee olla vähintään {{PLURAL:$1|yhden merkin|$1 merkin}} pituinen",
"passwordpolicies-policy-minimumpasswordlengthtologin": "Salasanassa on oltava vähintään $1 {{PLURAL:$1|merkki|merkkiä}} pystyäksesi kirjautumaan",
- "passwordpolicies-policy-passwordcannotmatchusername": "Salasana ei voi olla sama kuin käyttäjänimi",
- "passwordpolicies-policy-passwordcannotmatchblacklist": "Salasana ei voi vastata mustalla listalla olevia salasanoja",
- "passwordpolicies-policy-maximalpasswordlength": "Salasanan on oltava vähemmän kuin $1 {{PLURAL:$1|merkki|merkkiä}} pitkä",
- "passwordpolicies-policy-passwordcannotbepopular": "Salasana ei voi olla {{PLURAL:$1|suosittu salasana|$1 suositun salasanan listalla}}"
+ "passwordpolicies-policy-passwordcannotmatchusername": "Salasana ei saa olla sama kuin käyttäjänimi",
+ "passwordpolicies-policy-passwordcannotmatchblacklist": "Salasana ei saa olla mustalla listalla",
+ "passwordpolicies-policy-maximalpasswordlength": "Salasanan tulee olla lyhyempi kuin $1 {{PLURAL:$1|merkki|merkkiä}}",
+ "passwordpolicies-policy-passwordcannotbepopular": "Salasana ei saa olla {{PLURAL:$1|suosittu salasana|$1 suosituimman salasanan listalla}}"
}
*/
function indexEntry( $filename ) {
return "\t<sitemap>\n" .
- "\t\t<loc>{$this->urlpath}$filename</loc>\n" .
+ "\t\t<loc>" . wfGetServerUrl( PROTO_CANONICAL ) .
+ ( substr( $this->urlpath, 0, 1 ) === "/" ? "" : "/" ) .
+ "{$this->urlpath}$filename</loc>\n" .
"\t\t<lastmod>{$this->timestamp}</lastmod>\n" .
"\t</sitemap>\n";
}
],
'jquery.localize' => [
'scripts' => 'resources/src/jquery/jquery.localize.js',
+ 'deprecated' => 'Please use "jquery.i18n" instead.',
],
'jquery.makeCollapsible' => [
'dependencies' => [ 'jquery.makeCollapsible.styles' ],
<?php
class HtmlTest extends MediaWikiTestCase {
+ private $restoreWarnings;
protected function setUp() {
parent::setUp();
] );
$this->setUserLang( $langObj );
$this->setContentLang( $langObj );
+ $this->restoreWarnings = false;
+ }
+
+ protected function tearDown() {
+ if ( $this->restoreWarnings ) {
+ $this->restoreWarnings = false;
+ Wikimedia\restoreWarnings();
+ }
+ parent::tearDown();
}
/**
],
'Ampersand' => [
'EXAMPLE.is(a && b);',
- '<script>/*<![CDATA[*/EXAMPLE.is(a && b);/*]]>*/</script>'
+ '<script>EXAMPLE.is(a && b);</script>'
],
'HTML' => [
'EXAMPLE.label("<a>");',
- '<script>/*<![CDATA[*/EXAMPLE.label("<a>");/*]]>*/</script>'
+ '<script>EXAMPLE.label("<a>");</script>'
],
- 'Script closing string' => [
+ 'Script closing string (lower)' => [
'EXAMPLE.label("</script>");',
- // Broken: First </script> ends the script in HTML
- '<script>/*<![CDATA[*/EXAMPLE.label("</script>");/*]]>*/</script>'
+ '<script>/* ERROR: Invalid script */</script>',
+ true,
],
- 'CDATA string' => [
- 'EXAMPLE.label("&> CDATA ]]>");',
- // Broken: Works in HTML, but is invalid XML.
- '<script>/*<![CDATA[*/EXAMPLE.label("&> CDATA ]]>");/*]]>*/</script>'
+ 'Script closing with non-standard attributes (mixed)' => [
+ 'EXAMPLE.label("</SCriPT and STyLE>");',
+ '<script>/* ERROR: Invalid script */</script>',
+ true,
+ ],
+ 'HTML-comment-open and script-open' => [
+ // In HTML, <script> contents aren't just plain CDATA until </script>,
+ // there are levels of escaping modes, and the below sequence puts an
+ // HTML parser in a state where </script> would *not* close the script.
+ // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-end-state
+ 'var a = "<!--<script>";',
+ '<script>/* ERROR: Invalid script */</script>',
+ true,
],
];
}
* @dataProvider provideInlineScript
* @covers Html::inlineScript
*/
- public function testInlineScript( $code, $expected ) {
+ public function testInlineScript( $code, $expected, $error = false ) {
+ if ( $error ) {
+ Wikimedia\suppressWarnings();
+ $this->restoreWarnings = true;
+ }
$this->assertSame( Html::inlineScript( $code ), $expected );
}
}
$expectedEnd = "</div>";
$this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );
+ $unexpectedEnd = '#<!-- \nNewPP limit report|' .
+ '<!--\nTransclusion expansion time report#s';
+ $this->assertNotRegExp( $unexpectedEnd, $html );
+
$html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
} else {
$expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
'<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
- '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s';
+ '(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?</div>$#s';
$this->assertRegExp( $expectedEnd, $html );
$html = preg_replace( $expectedEnd, '', $html );
--- /dev/null
+<?php
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use PHPUnit\Framework\MockObject\MockObject;
+
+/**
+ * @covers \Article::view()
+ */
+class ArticleViewTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setUserLang( 'qqx' );
+ }
+
+ private function getHtml( OutputPage $output ) {
+ return preg_replace( '/<!--.*?-->/s', '', $output->getHTML() );
+ }
+
+ /**
+ * @param string|Title $title
+ * @param Content[]|string[] $revisionContents Content of the revisions to create
+ * (as Content or string).
+ * @param RevisionRecord[] &$revisions will be filled with the RevisionRecord for $content.
+ *
+ * @return WikiPage
+ * @throws MWException
+ */
+ private function getPage( $title, array $revisionContents = [], array &$revisions = [] ) {
+ if ( is_string( $title ) ) {
+ $title = Title::makeTitle( $this->getDefaultWikitextNS(), $title );
+ }
+
+ $page = WikiPage::factory( $title );
+
+ $user = $this->getTestUser()->getUser();
+
+ foreach ( $revisionContents as $key => $cont ) {
+ if ( is_string( $cont ) ) {
+ $cont = new WikitextContent( $cont );
+ }
+
+ $u = $page->newPageUpdater( $user );
+ $u->setContent( 'main', $cont );
+ $rev = $u->saveRevision( CommentStoreComment::newUnsavedComment( 'Rev ' . $key ) );
+
+ $revisions[ $key ] = $rev;
+ }
+
+ return $page;
+ }
+
+ /**
+ * @covers Article::getOldId()
+ * @covers Article::getRevIdFetched()
+ */
+ public function testGetOldId() {
+ $revisions = [];
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+
+ $idA = $revisions[1]->getId();
+ $idB = $revisions[2]->getId();
+
+ // oldid in constructor
+ $article = new Article( $page->getTitle(), $idA );
+ $this->assertSame( $idA, $article->getOldID() );
+ $article->getRevisionFetched();
+ $this->assertSame( $idA, $article->getRevIdFetched() );
+
+ // oldid 0 in constructor
+ $article = new Article( $page->getTitle(), 0 );
+ $this->assertSame( 0, $article->getOldID() );
+ $article->getRevisionFetched();
+ $this->assertSame( $idB, $article->getRevIdFetched() );
+
+ // oldid in request
+ $article = new Article( $page->getTitle() );
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( [ 'oldid' => $idA ] ) );
+ $article->setContext( $context );
+ $this->assertSame( $idA, $article->getOldID() );
+ $article->getRevisionFetched();
+ $this->assertSame( $idA, $article->getRevIdFetched() );
+
+ // no oldid
+ $article = new Article( $page->getTitle() );
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( [] ) );
+ $article->setContext( $context );
+ $this->assertSame( 0, $article->getOldID() );
+ $article->getRevisionFetched();
+ $this->assertSame( $idB, $article->getRevIdFetched() );
+ }
+
+ public function testView() {
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
+
+ $article = new Article( $page->getTitle(), 0 );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'Test B', $this->getHtml( $output ) );
+ $this->assertNotContains( 'id="mw-revision-info"', $this->getHtml( $output ) );
+ $this->assertNotContains( 'id="mw-revision-nav"', $this->getHtml( $output ) );
+ }
+
+ public function testViewCached() {
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
+
+ $po = new ParserOutput( 'Cached Text' );
+
+ $article = new Article( $page->getTitle(), 0 );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+ $cache = MediaWikiServices::getInstance()->getParserCache();
+ $cache->save( $po, $page, $article->getParserOptions() );
+
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'Cached Text', $this->getHtml( $output ) );
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+ }
+
+ /**
+ * @covers Article::getRedirectTarget()
+ */
+ public function testViewRedirect() {
+ $target = Title::makeTitle( $this->getDefaultWikitextNS(), 'Test_Target' );
+ $redirectText = '#REDIRECT [[' . $target->getPrefixedText() . ']]';
+
+ $page = $this->getPage( __METHOD__, [ $redirectText ] );
+
+ $article = new Article( $page->getTitle(), 0 );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $this->assertNotNull(
+ $article->getRedirectTarget()->getPrefixedDBkey()
+ );
+ $this->assertSame(
+ $target->getPrefixedDBkey(),
+ $article->getRedirectTarget()->getPrefixedDBkey()
+ );
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'class="redirectText"', $this->getHtml( $output ) );
+ $this->assertContains(
+ '>' . htmlspecialchars( $target->getPrefixedText() ) . '<',
+ $this->getHtml( $output )
+ );
+ }
+
+ public function testViewNonText() {
+ $dummy = $this->getPage( __METHOD__, [ 'Dummy' ] );
+ $dummyRev = $dummy->getRevision()->getRevisionRecord();
+ $title = $dummy->getTitle();
+
+ /** @var MockObject|ContentHandler $mockHandler */
+ $mockHandler = $this->getMockBuilder( ContentHandler::class )
+ ->setMethods(
+ [
+ 'isParserCacheSupported',
+ 'serializeContent',
+ 'unserializeContent',
+ 'makeEmptyContent',
+ ]
+ )
+ ->setConstructorArgs( [ 'NotText', [ 'application/frobnitz' ] ] )
+ ->getMock();
+
+ $mockHandler->method( 'isParserCacheSupported' )
+ ->willReturn( false );
+
+ $this->setTemporaryHook(
+ 'ContentHandlerForModelID',
+ function ( $id, &$handler ) use ( $mockHandler ) {
+ $handler = $mockHandler;
+ }
+ );
+
+ /** @var MockObject|Content $content */
+ $content = $this->getMock( Content::class );
+ $content->method( 'getParserOutput' )
+ ->willReturn( new ParserOutput( 'Structured Output' ) );
+ $content->method( 'getModel' )
+ ->willReturn( 'NotText' );
+ $content->method( 'getNativeData' )
+ ->willReturn( [ (object)[ 'x' => 'stuff' ] ] );
+ $content->method( 'copy' )
+ ->willReturn( $content );
+
+ $rev = new MutableRevisionRecord( $title );
+ $rev->setId( $dummyRev->getId() );
+ $rev->setPageId( $title->getArticleID() );
+ $rev->setUser( $dummyRev->getUser() );
+ $rev->setComment( $dummyRev->getComment() );
+ $rev->setTimestamp( $dummyRev->getTimestamp() );
+
+ $rev->setContent( 'main', $content );
+
+ $rev = new Revision( $rev );
+
+ /** @var MockObject|WikiPage $page */
+ $page = $this->getMockBuilder( WikiPage::class )
+ ->setMethods( [ 'getRevision', 'getLatest' ] )
+ ->setConstructorArgs( [ $title ] )
+ ->getMock();
+
+ $page->method( 'getRevision' )
+ ->willReturn( $rev );
+ $page->method( 'getLatest' )
+ ->willReturn( $rev->getId() );
+
+ $article = Article::newFromWikiPage( $page, RequestContext::getMain() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'Structured Output', $this->getHtml( $output ) );
+ $this->assertNotContains( 'Dummy', $this->getHtml( $output ) );
+ }
+
+ public function testViewOfOldRevision() {
+ $revisions = [];
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+ $idA = $revisions[1]->getId();
+
+ $article = new Article( $page->getTitle(), $idA );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertContains( 'id="mw-revision-info"', $output->getSubtitle() );
+ $this->assertContains( 'id="mw-revision-nav"', $output->getSubtitle() );
+
+ $this->assertNotContains( 'id="revision-info-current"', $output->getSubtitle() );
+ $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+ }
+
+ public function testViewOfCurrentRevision() {
+ $revisions = [];
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+ $idB = $revisions[2]->getId();
+
+ $article = new Article( $page->getTitle(), $idB );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'Test B', $this->getHtml( $output ) );
+ $this->assertContains( 'id="mw-revision-info-current"', $output->getSubtitle() );
+ $this->assertContains( 'id="mw-revision-nav"', $output->getSubtitle() );
+ }
+
+ public function testViewOfMissingRevision() {
+ $revisions = [];
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ], $revisions );
+ $badId = $revisions[1]->getId() + 100;
+
+ $article = new Article( $page->getTitle(), $badId );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'missing-revision: ' . $badId, $this->getHtml( $output ) );
+
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ }
+
+ public function testViewOfDeletedRevision() {
+ $revisions = [];
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+ $idA = $revisions[1]->getId();
+
+ $revDelList = new RevDelRevisionList(
+ RequestContext::getMain(), $page->getTitle(), [ $idA ]
+ );
+ $revDelList->setVisibility( [
+ 'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
+ 'comment' => "Testing",
+ ] );
+
+ $article = new Article( $page->getTitle(), $idA );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( '(rev-deleted-text-permission)', $this->getHtml( $output ) );
+
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+ }
+
+ public function testViewMissingPage() {
+ $page = $this->getPage( __METHOD__ );
+
+ $article = new Article( $page->getTitle() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+ }
+
+ public function testViewDeletedPage() {
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
+ $page->doDeleteArticle( 'Test' );
+
+ $article = new Article( $page->getTitle() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( 'moveddeleted', $this->getHtml( $output ) );
+ $this->assertContains( 'logentry-delete-delete', $this->getHtml( $output ) );
+ $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+ }
+
+ public function testViewMessagePage() {
+ $title = Title::makeTitle( NS_MEDIAWIKI, 'Mainpage' );
+ $page = $this->getPage( $title );
+
+ $article = new Article( $page->getTitle() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains(
+ wfMessage( 'mainpage' )->inContentLanguage()->parse(),
+ $this->getHtml( $output )
+ );
+ $this->assertNotContains( '(noarticletextanon)', $this->getHtml( $output ) );
+ }
+
+ public function testViewMissingUserPage() {
+ $user = $this->getTestUser()->getUser();
+ $user->addToDatabase();
+
+ $title = Title::makeTitle( NS_USER, $user->getName() );
+
+ $page = $this->getPage( $title );
+
+ $article = new Article( $page->getTitle() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+ $this->assertNotContains( '(userpage-userdoesnotexist-view)', $this->getHtml( $output ) );
+ }
+
+ public function testViewUserPageOfNonexistingUser() {
+ $user = User::newFromName( 'Testing ' . __METHOD__ );
+
+ $title = Title::makeTitle( NS_USER, $user->getName() );
+
+ $page = $this->getPage( $title );
+
+ $article = new Article( $page->getTitle() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+ $this->assertContains( '(userpage-userdoesnotexist-view:', $this->getHtml( $output ) );
+ }
+
+ public function testArticleViewHeaderHook() {
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
+
+ $article = new Article( $page->getTitle(), 0 );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+ $this->setTemporaryHook(
+ 'ArticleViewHeader',
+ function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
+ $this->assertSame( $article, $articlePage, '$articlePage' );
+
+ $outputDone = new ParserOutput( 'Hook Text' );
+ $outputDone->setTitleText( 'Hook Title' );
+
+ $articlePage->getContext()->getOutput()->addParserOutput( $outputDone );
+ }
+ );
+
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+ $this->assertSame( 'Hook Title', $output->getPageTitle() );
+ }
+
+ public function testArticleContentViewCustomHook() {
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
+
+ $article = new Article( $page->getTitle(), 0 );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+ // use ArticleViewHeader hook to bypass the parser cache
+ $this->setTemporaryHook(
+ 'ArticleViewHeader',
+ function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
+ $useParserCache = false;
+ }
+ );
+
+ $this->setTemporaryHook(
+ 'ArticleContentViewCustom',
+ function ( Content $content, Title $title, OutputPage $output ) use ( $page ) {
+ $this->assertSame( $page->getTitle(), $title, '$title' );
+ $this->assertSame( 'Test A', $content->getNativeData(), '$content' );
+
+ $output->addHTML( 'Hook Text' );
+ return false;
+ }
+ );
+
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+ }
+
+ public function testArticleAfterFetchContentObjectHook() {
+ $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
+
+ $article = new Article( $page->getTitle(), 0 );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+ // use ArticleViewHeader hook to bypass the parser cache
+ $this->setTemporaryHook(
+ 'ArticleViewHeader',
+ function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
+ $useParserCache = false;
+ }
+ );
+
+ $this->setTemporaryHook(
+ 'ArticleAfterFetchContentObject',
+ function ( Article &$articlePage, Content &$content ) use ( $page, $article ) {
+ $this->assertSame( $article, $articlePage, '$articlePage' );
+ $this->assertSame( 'Test A', $content->getNativeData(), '$content' );
+
+ $content = new WikitextContent( 'Hook Text' );
+ }
+ );
+
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+ $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+ }
+
+ public function testShowMissingArticleHook() {
+ $page = $this->getPage( __METHOD__ );
+
+ $article = new Article( $page->getTitle() );
+ $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+ $this->setTemporaryHook(
+ 'ShowMissingArticle',
+ function ( Article $articlePage ) use ( $article ) {
+ $this->assertSame( $article, $articlePage, '$articlePage' );
+
+ $articlePage->getContext()->getOutput()->addHTML( 'Hook Text' );
+ }
+ );
+
+ $article->view();
+
+ $output = $article->getContext()->getOutput();
+ $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+ $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+ }
+
+}
];
}
+ public function testWrapOutput() {
+ global $wgParser;
+ $title = Title::newFromText( 'foo' );
+ $po = new ParserOptions();
+ $wgParser->parse( 'Hello World', $title, $po );
+ $text = $wgParser->getOutput()->getText();
+
+ $this->assertContains( 'Hello World', $text );
+ $this->assertContains( '<div', $text );
+ $this->assertContains( 'class="mw-parser-output"', $text );
+ }
+
// @todo Add tests for cleanSig() / cleanSigInSig(), getSection(),
// replaceSection(), getPreloadText()
}
$this->assertArrayNotHasKey( 'foo', $properties );
}
+ /**
+ * @covers ParserOutput::getWrapperDivClass
+ * @covers ParserOutput::addWrapperDivClass
+ * @covers ParserOutput::clearWrapperDivClass
+ * @covers ParserOutput::getText
+ */
+ public function testWrapperDivClass() {
+ $po = new ParserOutput();
+
+ $po->setText( 'Kittens' );
+ $this->assertContains( 'Kittens', $po->getText() );
+ $this->assertNotContains( '<div', $po->getText() );
+ $this->assertSame( 'Kittens', $po->getRawText() );
+
+ $po->addWrapperDivClass( 'foo' );
+ $text = $po->getText();
+ $this->assertContains( 'Kittens', $text );
+ $this->assertContains( '<div', $text );
+ $this->assertContains( 'class="foo"', $text );
+
+ $po->addWrapperDivClass( 'bar' );
+ $text = $po->getText();
+ $this->assertContains( 'Kittens', $text );
+ $this->assertContains( '<div', $text );
+ $this->assertContains( 'class="foo bar"', $text );
+
+ $po->addWrapperDivClass( 'bar' ); // second time does nothing, no "foo bar bar".
+ $text = $po->getText( [ 'unwrap' => true ] );
+ $this->assertContains( 'Kittens', $text );
+ $this->assertNotContains( '<div', $text );
+ $this->assertNotContains( 'class="foo bar"', $text );
+
+ $text = $po->getText( [ 'wrapperDivClass' => '' ] );
+ $this->assertContains( 'Kittens', $text );
+ $this->assertNotContains( '<div', $text );
+ $this->assertNotContains( 'class="foo bar"', $text );
+
+ $text = $po->getText( [ 'wrapperDivClass' => 'xyzzy' ] );
+ $this->assertContains( 'Kittens', $text );
+ $this->assertContains( '<div', $text );
+ $this->assertContains( 'class="xyzzy"', $text );
+ $this->assertNotContains( 'class="foo bar"', $text );
+
+ $text = $po->getRawText();
+ $this->assertSame( 'Kittens', $text );
+
+ $po->clearWrapperDivClass();
+ $text = $po->getText();
+ $this->assertContains( 'Kittens', $text );
+ $this->assertNotContains( '<div', $text );
+ $this->assertNotContains( 'class="foo bar"', $text );
+ }
+
/**
* @covers ParserOutput::getText
* @dataProvider provideGetText
public static function provideGetText() {
// phpcs:disable Generic.Files.LineLength
$text = <<<EOF
-<div class="mw-parser-output"><p>Test document.
+<p>Test document.
</p>
<mw:toc><div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
<ul>
</p>
<h2><span class="mw-headline" id="Section_3">Section 3</span><mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
<p>Three
-</p></div>
+</p>
EOF;
$dedupText = <<<EOF
return [
'No options' => [
[], $text, <<<EOF
-<div class="mw-parser-output"><p>Test document.
+<p>Test document.
</p>
<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
<ul>
</p>
<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&action=edit&section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
<p>Three
-</p></div>
+</p>
EOF
],
'Disable section edit links' => [
[ 'enableSectionEditLinks' => false ], $text, <<<EOF
-<div class="mw-parser-output"><p>Test document.
+<p>Test document.
</p>
<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
<ul>
</p>
<h2><span class="mw-headline" id="Section_3">Section 3</span></h2>
<p>Three
-</p></div>
+</p>
EOF
],
- 'Disable TOC' => [
- [ 'allowTOC' => false ], $text, <<<EOF
+ 'Disable TOC, but wrap' => [
+ [ 'allowTOC' => false, 'wrapperDivClass' => 'mw-parser-output' ], $text, <<<EOF
<div class="mw-parser-output"><p>Test document.
</p>
<p>Three
</p></div>
EOF
- ],
- 'Unwrap text' => [
- [ 'unwrap' => true ], $text, <<<EOF
-<p>Test document.
-</p>
-<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
-<ul>
-<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
-<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
-<ul>
-<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
-</ul>
-</li>
-<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
-</ul>
-</div>
-
-<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&action=edit&section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
-<p>One
-</p>
-<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&action=edit&section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
-<p>Two
-</p>
-<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&action=edit&section=3" title="Edit section: Section 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
-<p>Two point one
-</p>
-<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&action=edit&section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
-<p>Three
-</p>
-EOF
- ],
- 'Unwrap without a mw-parser-output wrapper' => [
- [ 'unwrap' => true ], '<div class="foobar">Content</div>', '<div class="foobar">Content</div>'
- ],
- 'Unwrap with extra comment at end' => [
- [ 'unwrap' => true ], '<div class="mw-parser-output"><p>Test document.</p></div>
-<!-- Saved in parser cache... -->', '<p>Test document.</p>
-<!-- Saved in parser cache... -->'
],
'Style deduplication' => [
[], $dedupText, <<<EOF