Update git submodules
[mediawiki.git] / includes / parser / Parser.php
blob570c02858c2cbca28763f458b711f3518a179761
1 <?php
2 /**
3 * PHP parser that converts wiki markup to HTML.
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
20 * @file
21 * @ingroup Parser
24 use MediaWiki\Cache\CacheKeyHelper;
25 use MediaWiki\Category\TrackingCategories;
26 use MediaWiki\Config\ServiceOptions;
27 use MediaWiki\HookContainer\HookContainer;
28 use MediaWiki\HookContainer\HookRunner;
29 use MediaWiki\Http\HttpRequestFactory;
30 use MediaWiki\Language\RawMessage;
31 use MediaWiki\Languages\LanguageConverterFactory;
32 use MediaWiki\Languages\LanguageNameUtils;
33 use MediaWiki\Linker\Linker;
34 use MediaWiki\Linker\LinkRenderer;
35 use MediaWiki\Linker\LinkRendererFactory;
36 use MediaWiki\Linker\LinkTarget;
37 use MediaWiki\MainConfigNames;
38 use MediaWiki\MediaWikiServices;
39 use MediaWiki\Output\OutputPage;
40 use MediaWiki\Page\File\BadFileLookup;
41 use MediaWiki\Page\PageIdentity;
42 use MediaWiki\Page\PageReference;
43 use MediaWiki\Parser\MagicWordArray;
44 use MediaWiki\Parser\MagicWordFactory;
45 use MediaWiki\Parser\ParserOutputFlags;
46 use MediaWiki\Parser\Sanitizer;
47 use MediaWiki\Preferences\SignatureValidatorFactory;
48 use MediaWiki\Request\FauxRequest;
49 use MediaWiki\Revision\RevisionAccessException;
50 use MediaWiki\Revision\RevisionRecord;
51 use MediaWiki\Revision\SlotRecord;
52 use MediaWiki\SpecialPage\SpecialPage;
53 use MediaWiki\SpecialPage\SpecialPageFactory;
54 use MediaWiki\Tidy\TidyDriverBase;
55 use MediaWiki\Title\MalformedTitleException;
56 use MediaWiki\Title\MediaWikiTitleCodec;
57 use MediaWiki\Title\NamespaceInfo;
58 use MediaWiki\Title\Title;
59 use MediaWiki\Title\TitleFormatter;
60 use MediaWiki\User\User;
61 use MediaWiki\User\UserFactory;
62 use MediaWiki\User\UserIdentity;
63 use MediaWiki\User\UserNameUtils;
64 use MediaWiki\User\UserOptionsLookup;
65 use MediaWiki\Utils\MWTimestamp;
66 use MediaWiki\Utils\UrlUtils;
67 use Psr\Log\LoggerInterface;
68 use Wikimedia\Bcp47Code\Bcp47CodeValue;
69 use Wikimedia\IPUtils;
70 use Wikimedia\Parsoid\Core\SectionMetadata;
71 use Wikimedia\Parsoid\Core\TOCData;
72 use Wikimedia\ScopedCallback;
74 /**
75 * @defgroup Parser Parser
78 /**
79 * PHP Parser - Processes wiki markup (which uses a more user-friendly
80 * syntax, such as "[[link]]" for making links), and provides a one-way
81 * transformation of that wiki markup it into (X)HTML output / markup
82 * (which in turn the browser understands, and can display).
84 * There are seven main entry points into the Parser class:
86 * - Parser::parse()
87 * produces HTML output
88 * - Parser::preSaveTransform()
89 * produces altered wiki markup
90 * - Parser::preprocess()
91 * removes HTML comments and expands templates
92 * - Parser::cleanSig() and Parser::cleanSigInSig()
93 * cleans a signature before saving it to preferences
94 * - Parser::getSection()
95 * return the content of a section from an article for section editing
96 * - Parser::replaceSection()
97 * replaces a section by number inside an article
98 * - Parser::getPreloadText()
99 * removes <noinclude> sections and <includeonly> tags
101 * @warning $wgUser or $wgTitle or $wgRequest or $wgLang. Keep them away!
103 * @par Settings:
104 * $wgNamespacesWithSubpages
106 * @par Settings only within ParserOptions:
107 * $wgAllowExternalImages
108 * $wgAllowSpecialInclusion
109 * $wgInterwikiMagic
110 * $wgMaxArticleSize
112 * @ingroup Parser
114 #[AllowDynamicProperties]
115 class Parser {
117 # Flags for Parser::setFunctionHook
118 public const SFH_NO_HASH = 1;
119 public const SFH_OBJECT_ARGS = 2;
121 # Constants needed for external link processing
123 * Everything except bracket, space, or control characters.
124 * \p{Zs} is unicode 'separator, space' category. It covers the space 0x20
125 * as well as U+3000 is IDEOGRAPHIC SPACE for T21052.
126 * \x{FFFD} is the Unicode replacement character, which the HTML5 spec
127 * uses to replace invalid HTML characters.
129 public const EXT_LINK_URL_CLASS = '[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]';
131 * Simplified expression to match an IPv4 or IPv6 address, or
132 * at least one character of a host name (embeds Parser::EXT_LINK_URL_CLASS)
134 // phpcs:ignore Generic.Files.LineLength
135 private const EXT_LINK_ADDR = '(?:[0-9.]+|\\[(?i:[0-9a-f:.]+)\\]|[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}])';
136 /** RegExp to make image URLs (embeds IPv6 part of Parser::EXT_LINK_ADDR) */
137 // phpcs:ignore Generic.Files.LineLength
138 private const EXT_IMAGE_REGEX = '/^(http:\/\/|https:\/\/)((?:\\[(?i:[0-9a-f:.]+)\\])?[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]+)
139 \\/([A-Za-z0-9_.,~%\\-+&;#*?!=()@\\x80-\\xFF]+)\\.((?i)gif|png|jpg|jpeg)$/Sxu';
141 /** Regular expression for a non-newline space */
142 private const SPACE_NOT_NL = '(?:\t|&nbsp;|&\#0*160;|&\#[Xx]0*[Aa]0;|\p{Zs})';
145 * @var int Preprocess wikitext in transclusion mode
146 * @deprecated Since 1.36
148 public const PTD_FOR_INCLUSION = Preprocessor::DOM_FOR_INCLUSION;
150 # Allowed values for $this->mOutputType
151 /** Output type: like Parser::parse() */
152 public const OT_HTML = 1;
153 /** Output type: like Parser::preSaveTransform() */
154 public const OT_WIKI = 2;
155 /** Output type: like Parser::preprocess() */
156 public const OT_PREPROCESS = 3;
158 * Output type: like Parser::extractSections() - portions of the
159 * original are returned unchanged.
161 public const OT_PLAIN = 4;
164 * @var string Prefix and suffix for temporary replacement strings
165 * for the multipass parser.
167 * \x7f should never appear in input as it's disallowed in XML.
168 * Using it at the front also gives us a little extra robustness
169 * since it shouldn't match when butted up against identifier-like
170 * string constructs.
172 * Must not consist of all title characters, or else it will change
173 * the behavior of <nowiki> in a link.
175 * Must have a character that needs escaping in attributes, otherwise
176 * someone could put a strip marker in an attribute, to get around
177 * escaping quote marks, and break out of the attribute. Thus we add
178 * `'".
180 public const MARKER_SUFFIX = "-QINU`\"'\x7f";
181 public const MARKER_PREFIX = "\x7f'\"`UNIQ-";
184 * Internal marker used by parser to track where the table of
185 * contents should be. Various magic words can change the position
186 * during the parse. The table of contents is generated during
187 * the parse, however skins have the final decision on whether the
188 * table of contents is injected. This placeholder element
189 * identifies where in the page the table of contents should be
190 * injected, if at all.
191 * @var string
192 * @see Keep this in sync with BlockLevelPass::execute() and
193 * RemexCompatMunger::isTableOfContentsMarker()
194 * @internal Skins should *not* directly reference TOC_PLACEHOLDER
195 * but instead use Parser::replaceTableOfContentsMarker().
197 public const TOC_PLACEHOLDER = '<meta property="mw:PageProp/toc" />';
200 * Permissive regexp matching TOC_PLACEHOLDER. This allows for some
201 * minor modifications to the placeholder to be made by extensions
202 * without breaking the TOC (T317857); note also that Parsoid's version
203 * of the placeholder might include additional attributes.
204 * @var string
206 private const TOC_PLACEHOLDER_REGEX = '/<meta\\b[^>]*\\bproperty\\s*=\\s*"mw:PageProp\\/toc"[^>]*\\/>/';
208 # Persistent:
209 /** @var array<string,callable> */
210 private array $mTagHooks = [];
211 /** @var array<string,array{0:callable,1:int}> */
212 private array $mFunctionHooks = [];
213 /** @var array<int,array<string,string>> */
214 private array $mFunctionSynonyms = [ 0 => [], 1 => [] ];
215 /** @var string[] */
216 private array $mStripList = [];
217 /** @var array<string,string> */
218 private array $mVarCache = [];
219 /** @var array<string,array<string,string[]>> */
220 private array $mImageParams = [];
221 /** @var array<string,MagicWordArray> */
222 private array $mImageParamsMagicArray = [];
223 /** @deprecated since 1.35 */
224 public $mMarkerIndex = 0;
226 # Initialised by initializeVariables()
227 private MagicWordArray $mVariables;
228 private MagicWordArray $mSubstWords;
230 # Initialised in constructor
231 private string $mExtLinkBracketedRegex;
232 private UrlUtils $urlUtils;
233 private Preprocessor $mPreprocessor;
235 # Cleared with clearState():
236 private ParserOutput $mOutput;
237 private int $mAutonumber = 0;
238 private StripState $mStripState;
239 private LinkHolderArray $mLinkHolders;
240 private int $mLinkID = 0;
241 private array $mIncludeSizes;
242 /** @deprecated since 1.35 */
243 public $mPPNodeCount;
244 /** @deprecated since 1.35 */
245 public $mHighestExpansionDepth;
246 private array $mTplRedirCache;
247 /** @internal */
248 public array $mHeadings;
249 /** @var array<string,string> */
250 private array $mDoubleUnderscores;
252 * Number of expensive parser function calls
253 * @deprecated since 1.35
255 public $mExpensiveFunctionCount;
256 private bool $mShowToc;
257 private bool $mForceTocPosition;
258 private array $mTplDomCache;
259 private ?UserIdentity $mUser;
261 # Temporary
262 # These are variables reset at least once per parse regardless of $clearState
265 * @var ParserOptions|null
266 * @deprecated since 1.35, use Parser::getOptions()
268 public $mOptions;
271 * Title context, used for self-link rendering and similar things
273 * @deprecated since 1.35, use Parser::getPage()
275 public Title $mTitle;
276 /** Output type, one of the OT_xxx constants */
277 private int $mOutputType;
279 * Shortcut alias, see Parser::setOutputType()
280 * @deprecated since 1.35
282 public $ot;
283 /** ID to display in {{REVISIONID}} tags */
284 private ?int $mRevisionId = null;
285 /** The timestamp of the specified revision ID */
286 private ?string $mRevisionTimestamp = null;
287 /** User to display in {{REVISIONUSER}} tag */
288 private ?string $mRevisionUser = null;
289 /** Size to display in {{REVISIONSIZE}} variable */
290 private ?int $mRevisionSize = null;
291 /** @var int|false For {{PAGESIZE}} on current page */
292 private $mInputSize = false;
294 private ?RevisionRecord $mRevisionRecordObject = null;
297 * Array with the language name of each language link (i.e. the
298 * interwiki prefix) in the key, value arbitrary. Used to avoid sending
299 * duplicate language links to the ParserOutput.
301 private array $mLangLinkLanguages;
304 * A cache of the current revisions of titles. Keys are $title->getPrefixedDbKey()
306 * @since 1.24
308 private ?MapCacheLRU $currentRevisionCache = null;
311 * @var bool|string Recursive call protection.
312 * @internal
314 private $mInParse = false;
316 private SectionProfiler $mProfiler;
317 private ?LinkRenderer $mLinkRenderer = null;
319 private MagicWordFactory $magicWordFactory;
320 private Language $contLang;
321 private LanguageConverterFactory $languageConverterFactory;
322 private ParserFactory $factory;
323 private SpecialPageFactory $specialPageFactory;
324 private TitleFormatter $titleFormatter;
326 * This is called $svcOptions instead of $options like elsewhere to avoid confusion with
327 * $mOptions, which is public and widely used, and also with the local variable $options used
328 * for ParserOptions throughout this file.
330 private ServiceOptions $svcOptions;
331 private LinkRendererFactory $linkRendererFactory;
332 private NamespaceInfo $nsInfo;
333 private LoggerInterface $logger;
334 private BadFileLookup $badFileLookup;
335 private HookContainer $hookContainer;
336 private HookRunner $hookRunner;
337 private TidyDriverBase $tidy;
338 private WANObjectCache $wanCache;
339 private UserOptionsLookup $userOptionsLookup;
340 private UserFactory $userFactory;
341 private HttpRequestFactory $httpRequestFactory;
342 private TrackingCategories $trackingCategories;
343 private SignatureValidatorFactory $signatureValidatorFactory;
344 private UserNameUtils $userNameUtils;
347 * @internal For use by ServiceWiring
349 public const CONSTRUCTOR_OPTIONS = [
350 // See documentation for the corresponding config options
351 // Many of these are only used in (eg) CoreMagicVariables
352 MainConfigNames::AllowDisplayTitle,
353 MainConfigNames::AllowSlowParserFunctions,
354 MainConfigNames::ArticlePath,
355 MainConfigNames::EnableScaryTranscluding,
356 MainConfigNames::ExtraInterlanguageLinkPrefixes,
357 MainConfigNames::FragmentMode,
358 MainConfigNames::Localtimezone,
359 MainConfigNames::MaxSigChars,
360 MainConfigNames::MaxTocLevel,
361 MainConfigNames::MiserMode,
362 MainConfigNames::RawHtml,
363 MainConfigNames::ScriptPath,
364 MainConfigNames::Server,
365 MainConfigNames::ServerName,
366 MainConfigNames::ShowHostnames,
367 MainConfigNames::SignatureValidation,
368 MainConfigNames::Sitename,
369 MainConfigNames::StylePath,
370 MainConfigNames::TranscludeCacheExpiry,
371 MainConfigNames::PreprocessorCacheThreshold,
372 MainConfigNames::ParserEnableLegacyMediaDOM,
373 MainConfigNames::EnableParserLimitReporting,
377 * Constructing parsers directly is not allowed! Use a ParserFactory.
378 * @internal
380 * @param ServiceOptions $svcOptions
381 * @param MagicWordFactory $magicWordFactory
382 * @param Language $contLang Content language
383 * @param ParserFactory $factory
384 * @param UrlUtils $urlUtils
385 * @param SpecialPageFactory $spFactory
386 * @param LinkRendererFactory $linkRendererFactory
387 * @param NamespaceInfo $nsInfo
388 * @param LoggerInterface $logger
389 * @param BadFileLookup $badFileLookup
390 * @param LanguageConverterFactory $languageConverterFactory
391 * @param HookContainer $hookContainer
392 * @param TidyDriverBase $tidy
393 * @param WANObjectCache $wanCache
394 * @param UserOptionsLookup $userOptionsLookup
395 * @param UserFactory $userFactory
396 * @param TitleFormatter $titleFormatter
397 * @param HttpRequestFactory $httpRequestFactory
398 * @param TrackingCategories $trackingCategories
399 * @param SignatureValidatorFactory $signatureValidatorFactory
400 * @param UserNameUtils $userNameUtils
402 public function __construct(
403 ServiceOptions $svcOptions,
404 MagicWordFactory $magicWordFactory,
405 Language $contLang,
406 ParserFactory $factory,
407 UrlUtils $urlUtils,
408 SpecialPageFactory $spFactory,
409 LinkRendererFactory $linkRendererFactory,
410 NamespaceInfo $nsInfo,
411 LoggerInterface $logger,
412 BadFileLookup $badFileLookup,
413 LanguageConverterFactory $languageConverterFactory,
414 HookContainer $hookContainer,
415 TidyDriverBase $tidy,
416 WANObjectCache $wanCache,
417 UserOptionsLookup $userOptionsLookup,
418 UserFactory $userFactory,
419 TitleFormatter $titleFormatter,
420 HttpRequestFactory $httpRequestFactory,
421 TrackingCategories $trackingCategories,
422 SignatureValidatorFactory $signatureValidatorFactory,
423 UserNameUtils $userNameUtils
425 if ( ParserFactory::$inParserFactory === 0 ) {
426 // Direct construction of Parser was deprecated in 1.34 and
427 // removed in 1.36; use a ParserFactory instead.
428 throw new BadMethodCallException( 'Direct construction of Parser not allowed' );
430 $svcOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
431 $this->svcOptions = $svcOptions;
433 $this->urlUtils = $urlUtils;
434 $this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->urlUtils->validProtocols() . ')' .
435 self::EXT_LINK_ADDR .
436 self::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*)\]/Su';
438 $this->magicWordFactory = $magicWordFactory;
440 $this->contLang = $contLang;
442 $this->factory = $factory;
443 $this->specialPageFactory = $spFactory;
444 $this->linkRendererFactory = $linkRendererFactory;
445 $this->nsInfo = $nsInfo;
446 $this->logger = $logger;
447 $this->badFileLookup = $badFileLookup;
449 $this->languageConverterFactory = $languageConverterFactory;
451 $this->hookContainer = $hookContainer;
452 $this->hookRunner = new HookRunner( $hookContainer );
454 $this->tidy = $tidy;
456 $this->wanCache = $wanCache;
457 $this->mPreprocessor = new Preprocessor_Hash(
458 $this,
459 $this->wanCache,
461 'cacheThreshold' => $svcOptions->get( MainConfigNames::PreprocessorCacheThreshold ),
462 'disableLangConversion' => $languageConverterFactory->isConversionDisabled(),
466 $this->userOptionsLookup = $userOptionsLookup;
467 $this->userFactory = $userFactory;
468 $this->titleFormatter = $titleFormatter;
469 $this->httpRequestFactory = $httpRequestFactory;
470 $this->trackingCategories = $trackingCategories;
471 $this->signatureValidatorFactory = $signatureValidatorFactory;
472 $this->userNameUtils = $userNameUtils;
474 // These steps used to be done in "::firstCallInit()"
475 // (if you're chasing a reference from some old code)
476 CoreParserFunctions::register(
477 $this,
478 new ServiceOptions( CoreParserFunctions::REGISTER_OPTIONS, $svcOptions )
480 CoreTagHooks::register(
481 $this,
482 new ServiceOptions( CoreTagHooks::REGISTER_OPTIONS, $svcOptions )
484 $this->initializeVariables();
486 $this->hookRunner->onParserFirstCallInit( $this );
487 $this->mTitle = Title::makeTitle( NS_SPECIAL, 'Badtitle/Missing' );
491 * Reduce memory usage to reduce the impact of circular references
493 public function __destruct() {
494 // @phan-suppress-next-line PhanRedundantCondition Typed property not set in constructor, may be uninitialized
495 if ( isset( $this->mLinkHolders ) ) {
496 // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
497 unset( $this->mLinkHolders );
499 // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
500 foreach ( $this as $name => $value ) {
501 unset( $this->$name );
506 * Allow extensions to clean up when the parser is cloned
508 public function __clone() {
509 $this->mInParse = false;
511 // T58226: When you create a reference "to" an object field, that
512 // makes the object field itself be a reference too (until the other
513 // reference goes out of scope). When cloning, any field that's a
514 // reference is copied as a reference in the new object. Both of these
515 // are defined PHP5 behaviors, as inconvenient as it is for us when old
516 // hooks from PHP4 days are passing fields by reference.
517 foreach ( [ 'mStripState', 'mVarCache' ] as $k ) {
518 // Make a non-reference copy of the field, then rebind the field to
519 // reference the new copy.
520 $tmp = $this->$k;
521 $this->$k =& $tmp;
522 unset( $tmp );
525 $this->mPreprocessor = clone $this->mPreprocessor;
526 $this->mPreprocessor->resetParser( $this );
528 $this->hookRunner->onParserCloned( $this );
532 * Used to do various kinds of initialisation on the first call of the
533 * parser.
534 * @deprecated since 1.35, this initialization is done in the constructor
535 * and manual calls to ::firstCallInit() have no effect.
536 * @since 1.7
538 public function firstCallInit() {
540 * This method should be hard-deprecated once remaining calls are
541 * removed; it no longer does anything.
546 * Clear Parser state
548 * @internal
550 public function clearState() {
551 $this->resetOutput();
552 $this->mAutonumber = 0;
553 $this->mLinkHolders = new LinkHolderArray(
554 $this,
555 $this->getContentLanguageConverter(),
556 $this->getHookContainer()
558 $this->mLinkID = 0;
559 $this->mRevisionTimestamp = null;
560 $this->mRevisionId = null;
561 $this->mRevisionUser = null;
562 $this->mRevisionSize = null;
563 $this->mRevisionRecordObject = null;
564 $this->mVarCache = [];
565 $this->mUser = null;
566 $this->mLangLinkLanguages = [];
567 $this->currentRevisionCache = null;
569 $this->mStripState = new StripState( $this );
571 # Clear these on every parse, T6549
572 $this->mTplRedirCache = [];
573 $this->mTplDomCache = [];
575 $this->mShowToc = true;
576 $this->mForceTocPosition = false;
577 $this->mIncludeSizes = [
578 'post-expand' => 0,
579 'arg' => 0,
581 $this->mPPNodeCount = 0;
582 $this->mHighestExpansionDepth = 0;
583 $this->mHeadings = [];
584 $this->mDoubleUnderscores = [];
585 $this->mExpensiveFunctionCount = 0;
587 $this->mProfiler = new SectionProfiler();
589 $this->hookRunner->onParserClearState( $this );
593 * Reset the ParserOutput
594 * @since 1.34
596 public function resetOutput() {
597 $this->mOutput = new ParserOutput;
598 $this->mOptions->registerWatcher( [ $this->mOutput, 'recordOption' ] );
602 * Convert wikitext to HTML
603 * Do not call this function recursively.
605 * @param string $text Text we want to parse
606 * @param-taint $text escapes_htmlnoent
607 * @param PageReference $page
608 * @param ParserOptions $options
609 * @param bool $linestart
610 * @param bool $clearState
611 * @param int|null $revid ID of the revision being rendered. This is used to render
612 * REVISION* magic words. 0 means that any current revision will be used. Null means
613 * that {{REVISIONID}}/{{REVISIONUSER}} will be empty and {{REVISIONTIMESTAMP}} will
614 * use the current timestamp.
615 * @return ParserOutput
616 * @return-taint escaped
617 * @since 1.10 method is public
619 public function parse(
620 $text, PageReference $page, ParserOptions $options,
621 $linestart = true, $clearState = true, $revid = null
623 if ( $clearState ) {
624 // We use U+007F DELETE to construct strip markers, so we have to make
625 // sure that this character does not occur in the input text.
626 $text = strtr( $text, "\x7f", "?" );
627 $magicScopeVariable = $this->lock();
629 // Strip U+0000 NULL (T159174)
630 $text = str_replace( "\000", '', $text );
632 $this->startParse( $page, $options, self::OT_HTML, $clearState );
634 $this->currentRevisionCache = null;
635 $this->mInputSize = strlen( $text );
636 $this->mOutput->resetParseStartTime();
638 $oldRevisionId = $this->mRevisionId;
639 $oldRevisionRecordObject = $this->mRevisionRecordObject;
640 $oldRevisionTimestamp = $this->mRevisionTimestamp;
641 $oldRevisionUser = $this->mRevisionUser;
642 $oldRevisionSize = $this->mRevisionSize;
643 if ( $revid !== null ) {
644 $this->mRevisionId = $revid;
645 $this->mRevisionRecordObject = null;
646 $this->mRevisionTimestamp = null;
647 $this->mRevisionUser = null;
648 $this->mRevisionSize = null;
651 $text = $this->internalParse( $text );
652 $this->hookRunner->onParserAfterParse( $this, $text, $this->mStripState );
654 $text = $this->internalParseHalfParsed( $text, true, $linestart );
657 * A converted title will be provided in the output object if title and
658 * content conversion are enabled, the article text does not contain
659 * a conversion-suppressing double-underscore tag, and no
660 * {{DISPLAYTITLE:...}} is present. DISPLAYTITLE takes precedence over
661 * automatic link conversion.
663 if ( !$options->getDisableTitleConversion()
664 && !isset( $this->mDoubleUnderscores['nocontentconvert'] )
665 && !isset( $this->mDoubleUnderscores['notitleconvert'] )
666 && $this->mOutput->getDisplayTitle() === false
668 $titleText = $this->getTargetLanguageConverter()->getConvRuleTitle();
669 if ( $titleText !== false ) {
670 $titleText = Sanitizer::removeSomeTags( $titleText );
671 } else {
672 [ $nsText, $nsSeparator, $mainText ] = $this->getTargetLanguageConverter()->convertSplitTitle( $page );
673 // In the future, those three pieces could be stored separately rather than joined into $titleText,
674 // and OutputPage would format them and join them together, to resolve T314399.
675 $titleText = self::formatPageTitle( $nsText, $nsSeparator, $mainText );
677 $this->mOutput->setTitleText( $titleText );
680 # Compute runtime adaptive expiry if set
681 $this->mOutput->finalizeAdaptiveCacheExpiry();
683 # Warn if too many heavyweight parser functions were used
684 if ( $this->mExpensiveFunctionCount > $this->mOptions->getExpensiveParserFunctionLimit() ) {
685 $this->limitationWarn( 'expensive-parserfunction',
686 $this->mExpensiveFunctionCount,
687 $this->mOptions->getExpensiveParserFunctionLimit()
691 # Information on limits, for the benefit of users who try to skirt them
692 if ( $this->svcOptions->get( MainConfigNames::EnableParserLimitReporting ) ) {
693 $this->makeLimitReport();
696 # Wrap non-interface parser output in a <div> so it can be targeted
697 # with CSS (T37247)
698 $class = $this->mOptions->getWrapOutputClass();
699 if ( $class !== false && !$this->mOptions->getInterfaceMessage() ) {
700 $this->mOutput->addWrapperDivClass( $class );
703 $this->mOutput->setText( $text );
705 $this->mRevisionId = $oldRevisionId;
706 $this->mRevisionRecordObject = $oldRevisionRecordObject;
707 $this->mRevisionTimestamp = $oldRevisionTimestamp;
708 $this->mRevisionUser = $oldRevisionUser;
709 $this->mRevisionSize = $oldRevisionSize;
710 $this->mInputSize = false;
711 $this->currentRevisionCache = null;
713 return $this->mOutput;
717 * Set the limit report data in the current ParserOutput.
719 protected function makeLimitReport() {
720 $maxIncludeSize = $this->mOptions->getMaxIncludeSize();
722 $cpuTime = $this->mOutput->getTimeSinceStart( 'cpu' );
723 if ( $cpuTime !== null ) {
724 $this->mOutput->setLimitReportData( 'limitreport-cputime',
725 sprintf( "%.3f", $cpuTime )
729 $wallTime = $this->mOutput->getTimeSinceStart( 'wall' );
730 $this->mOutput->setLimitReportData( 'limitreport-walltime',
731 sprintf( "%.3f", $wallTime )
734 $this->mOutput->setLimitReportData( 'limitreport-ppvisitednodes',
735 [ $this->mPPNodeCount, $this->mOptions->getMaxPPNodeCount() ]
737 $this->mOutput->setLimitReportData( 'limitreport-postexpandincludesize',
738 [ $this->mIncludeSizes['post-expand'], $maxIncludeSize ]
740 $this->mOutput->setLimitReportData( 'limitreport-templateargumentsize',
741 [ $this->mIncludeSizes['arg'], $maxIncludeSize ]
743 $this->mOutput->setLimitReportData( 'limitreport-expansiondepth',
744 [ $this->mHighestExpansionDepth, $this->mOptions->getMaxPPExpandDepth() ]
746 $this->mOutput->setLimitReportData( 'limitreport-expensivefunctioncount',
747 [ $this->mExpensiveFunctionCount, $this->mOptions->getExpensiveParserFunctionLimit() ]
750 foreach ( $this->mStripState->getLimitReport() as [ $key, $value ] ) {
751 $this->mOutput->setLimitReportData( $key, $value );
754 $this->hookRunner->onParserLimitReportPrepare( $this, $this->mOutput );
756 // Add on template profiling data in human/machine readable way
757 $dataByFunc = $this->mProfiler->getFunctionStats();
758 uasort( $dataByFunc, static function ( $a, $b ) {
759 return $b['real'] <=> $a['real']; // descending order
760 } );
761 $profileReport = [];
762 foreach ( array_slice( $dataByFunc, 0, 10 ) as $item ) {
763 $profileReport[] = sprintf( "%6.2f%% %8.3f %6d %s",
764 $item['%real'], $item['real'], $item['calls'],
765 htmlspecialchars( $item['name'] ) );
768 $this->mOutput->setLimitReportData( 'limitreport-timingprofile', $profileReport );
770 // Add other cache related metadata
771 if ( $this->svcOptions->get( MainConfigNames::ShowHostnames ) ) {
772 $this->mOutput->setLimitReportData( 'cachereport-origin', wfHostname() );
774 $this->mOutput->setLimitReportData( 'cachereport-timestamp',
775 $this->mOutput->getCacheTime() );
776 $this->mOutput->setLimitReportData( 'cachereport-ttl',
777 $this->mOutput->getCacheExpiry() );
778 $this->mOutput->setLimitReportData( 'cachereport-transientcontent',
779 $this->mOutput->hasReducedExpiry() );
783 * Half-parse wikitext to half-parsed HTML. This recursive parser entry point
784 * can be called from an extension tag hook.
786 * The output of this function IS NOT SAFE PARSED HTML; it is "half-parsed"
787 * instead, which means that lists and links have not been fully parsed yet,
788 * and strip markers are still present.
790 * Use recursiveTagParseFully() to fully parse wikitext to output-safe HTML.
792 * Use this function if you're a parser tag hook and you want to parse
793 * wikitext before or after applying additional transformations, and you
794 * intend to *return the result as hook output*, which will cause it to go
795 * through the rest of parsing process automatically.
797 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
798 * $text are not expanded
800 * @param string $text Text extension wants to have parsed
801 * @param-taint $text escapes_htmlnoent
802 * @param PPFrame|false $frame The frame to use for expanding any template variables
803 * @return string UNSAFE half-parsed HTML
804 * @return-taint escaped
805 * @since 1.8
807 public function recursiveTagParse( $text, $frame = false ) {
808 $text = $this->internalParse( $text, false, $frame );
809 return $text;
813 * Fully parse wikitext to fully parsed HTML. This recursive parser entry
814 * point can be called from an extension tag hook.
816 * The output of this function is fully-parsed HTML that is safe for output.
817 * If you're a parser tag hook, you might want to use recursiveTagParse()
818 * instead.
820 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
821 * $text are not expanded
823 * @since 1.25
825 * @param string $text Text extension wants to have parsed
826 * @param-taint $text escapes_htmlnoent
827 * @param PPFrame|false $frame The frame to use for expanding any template variables
828 * @return string Fully parsed HTML
829 * @return-taint escaped
831 public function recursiveTagParseFully( $text, $frame = false ) {
832 $text = $this->recursiveTagParse( $text, $frame );
833 $text = $this->internalParseHalfParsed( $text, false );
834 return $text;
838 * Needed by Parsoid/PHP to ensure all the hooks for extensions
839 * are run in the right order. The primary differences between this
840 * and recursiveTagParseFully are:
841 * (a) absence of $frame
842 * (b) passing true to internalParseHalfParse so all hooks are run
843 * (c) running 'ParserAfterParse' hook at the same point in the parsing
844 * pipeline when parse() does it. This kinda mimics Parsoid/JS behavior
845 * where exttags are processed by the M/w API.
847 * This is a temporary convenience method and will go away as we proceed
848 * further with Parsoid <-> Parser.php integration.
850 * @internal
851 * @deprecated
852 * @param string $text Wikitext source of the extension
853 * @return string
854 * @return-taint escaped
856 public function parseExtensionTagAsTopLevelDoc( $text ) {
857 $text = $this->recursiveTagParse( $text );
858 $this->hookRunner->onParserAfterParse( $this, $text, $this->mStripState );
859 $text = $this->internalParseHalfParsed( $text, true );
860 return $text;
864 * Expand templates and variables in the text, producing valid, static wikitext.
865 * Also removes comments.
866 * Do not call this function recursively.
867 * @param string $text
868 * @param ?PageReference $page
869 * @param ParserOptions $options
870 * @param int|null $revid
871 * @param PPFrame|false $frame
872 * @return mixed|string
873 * @since 1.8
875 public function preprocess(
876 $text,
877 ?PageReference $page,
878 ParserOptions $options,
879 $revid = null,
880 $frame = false
882 $magicScopeVariable = $this->lock();
883 $this->startParse( $page, $options, self::OT_PREPROCESS, true );
884 if ( $revid !== null ) {
885 $this->mRevisionId = $revid;
887 $this->hookRunner->onParserBeforePreprocess( $this, $text, $this->mStripState );
888 $text = $this->replaceVariables( $text, $frame );
889 $text = $this->mStripState->unstripBoth( $text );
890 return $text;
894 * Recursive parser entry point that can be called from an extension tag
895 * hook.
897 * @param string $text Text to be expanded
898 * @param PPFrame|false $frame The frame to use for expanding any template variables
899 * @return string
900 * @since 1.19
902 public function recursivePreprocess( $text, $frame = false ) {
903 $text = $this->replaceVariables( $text, $frame );
904 $text = $this->mStripState->unstripBoth( $text );
905 return $text;
909 * Process the wikitext for the "?preload=" feature. (T7210)
911 * "<noinclude>", "<includeonly>" etc. are parsed as for template
912 * transclusion, comments, templates, arguments, tags hooks and parser
913 * functions are untouched.
915 * @param string $text
916 * @param PageReference $page
917 * @param ParserOptions $options
918 * @param array $params
919 * @return string
920 * @since 1.17
922 public function getPreloadText( $text, PageReference $page, ParserOptions $options, $params = [] ) {
923 $msg = new RawMessage( $text );
924 $text = $msg->params( $params )->plain();
926 # Parser (re)initialisation
927 $magicScopeVariable = $this->lock();
928 $this->startParse( $page, $options, self::OT_PLAIN, true );
930 $flags = PPFrame::NO_ARGS | PPFrame::NO_TEMPLATES;
931 $dom = $this->preprocessToDom( $text, Preprocessor::DOM_FOR_INCLUSION );
932 $text = $this->getPreprocessor()->newFrame()->expand( $dom, $flags );
933 $text = $this->mStripState->unstripBoth( $text );
934 return $text;
938 * Set the current user.
939 * Should only be used when doing pre-save transform.
941 * @param UserIdentity|null $user user identity or null (to reset)
942 * @since 1.17
944 public function setUser( ?UserIdentity $user ) {
945 $this->mUser = $user;
949 * Set the context title
951 * @deprecated since 1.37, use setPage() instead.
952 * @param Title|null $t
953 * @since 1.12
955 public function setTitle( Title $t = null ) {
956 $this->setPage( $t );
960 * @since 1.6
961 * @deprecated since 1.37, use getPage instead.
962 * @return Title
964 public function getTitle(): Title {
965 return $this->mTitle;
969 * Set the page used as context for parsing, e.g. when resolving relative subpage links.
971 * @since 1.37
972 * @param ?PageReference $t
974 public function setPage( ?PageReference $t = null ) {
975 if ( !$t ) {
976 $t = Title::makeTitle( NS_SPECIAL, 'Badtitle/Parser' );
977 } else {
978 // For now (early 1.37 alpha), always convert to Title, so we don't have to do it over
979 // and over again in other methods. Eventually, we will no longer need to have a Title
980 // instance internally.
981 $t = Title::newFromPageReference( $t );
984 if ( $t->hasFragment() ) {
985 # Strip the fragment to avoid various odd effects
986 $this->mTitle = $t->createFragmentTarget( '' );
987 } else {
988 $this->mTitle = $t;
993 * Returns the page used as context for parsing, e.g. when resolving relative subpage links.
994 * @since 1.37
995 * @return ?PageReference Null if no page is set (deprecated since 1.34)
997 public function getPage(): ?PageReference {
998 if ( $this->mTitle->isSpecial( 'Badtitle' ) ) {
999 [ , $subPage ] = $this->specialPageFactory->resolveAlias( $this->mTitle->getDBkey() );
1001 if ( $subPage === 'Missing' ) {
1002 wfDeprecated( __METHOD__ . ' without a Title set', '1.34' );
1003 return null;
1007 return $this->mTitle;
1011 * Accessor for the output type.
1012 * @return int One of the Parser::OT_... constants
1013 * @since 1.35
1015 public function getOutputType(): int {
1016 return $this->mOutputType;
1020 * Mutator for the output type.
1021 * @param int $ot One of the Parser::OT_… constants
1022 * @since 1.8
1024 public function setOutputType( $ot ): void {
1025 $this->mOutputType = $ot;
1026 # Shortcut alias
1027 $this->ot = [
1028 'html' => $ot == self::OT_HTML,
1029 'wiki' => $ot == self::OT_WIKI,
1030 'pre' => $ot == self::OT_PREPROCESS,
1031 'plain' => $ot == self::OT_PLAIN,
1036 * Accessor/mutator for the output type
1038 * @param int|null $x New value or null to just get the current one
1039 * @return int
1040 * @deprecated since 1.35, use getOutputType()/setOutputType()
1042 public function OutputType( $x = null ) {
1043 wfDeprecated( __METHOD__, '1.35' );
1044 return wfSetVar( $this->mOutputType, $x );
1048 * @return ParserOutput
1049 * @since 1.14
1051 public function getOutput() {
1052 // @phan-suppress-next-line PhanRedundantCondition False positive, see https://github.com/phan/phan/issues/4720
1053 if ( !isset( $this->mOutput ) ) {
1054 wfDeprecated( __METHOD__ . ' before initialization', '1.42' );
1055 // @phan-suppress-next-line PhanTypeMismatchReturnProbablyReal We don’t want to tell anyone we’re doing this
1056 return null;
1058 return $this->mOutput;
1062 * @return ParserOptions|null
1063 * @since 1.6
1065 public function getOptions() {
1066 return $this->mOptions;
1070 * Mutator for the ParserOptions object
1071 * @param ParserOptions $options The new parser options
1072 * @since 1.35
1074 public function setOptions( ParserOptions $options ): void {
1075 $this->mOptions = $options;
1079 * Accessor/mutator for the ParserOptions object
1081 * @param ParserOptions|null $x New value or null to just get the current one
1082 * @return ParserOptions Current ParserOptions object
1083 * @deprecated since 1.35, use getOptions() / setOptions()
1085 public function Options( $x = null ) {
1086 wfDeprecated( __METHOD__, '1.35' );
1087 return wfSetVar( $this->mOptions, $x );
1091 * @return int
1092 * @since 1.14
1094 public function nextLinkID() {
1095 return $this->mLinkID++;
1099 * @param int $id
1100 * @since 1.8
1102 public function setLinkID( $id ) {
1103 $this->mLinkID = $id;
1107 * Get a language object for use in parser functions such as {{FORMATNUM:}}
1108 * @return Language
1109 * @since 1.7
1110 * @deprecated since 1.40; use ::getTargetLanguage() instead.
1112 public function getFunctionLang() {
1113 wfDeprecated( __METHOD__, '1.40' );
1114 return $this->getTargetLanguage();
1118 * Get the target language for the content being parsed. This is usually the
1119 * language that the content is in.
1121 * @since 1.19
1123 * @return Language
1125 public function getTargetLanguage() {
1126 $target = $this->mOptions->getTargetLanguage();
1128 if ( $target !== null ) {
1129 return $target;
1130 } elseif ( $this->mOptions->getInterfaceMessage() ) {
1131 return $this->mOptions->getUserLangObj();
1134 return $this->getTitle()->getPageLanguage();
1138 * Get a user either from the user set on Parser if it's set,
1139 * or from the ParserOptions object otherwise.
1141 * @since 1.36
1142 * @return UserIdentity
1144 public function getUserIdentity(): UserIdentity {
1145 return $this->mUser ?? $this->getOptions()->getUserIdentity();
1149 * Get a preprocessor object
1151 * @return Preprocessor
1152 * @since 1.12.0
1154 public function getPreprocessor() {
1155 return $this->mPreprocessor;
1159 * Get a LinkRenderer instance to make links with
1161 * @since 1.28
1162 * @return LinkRenderer
1164 public function getLinkRenderer() {
1165 // XXX We make the LinkRenderer with current options and then cache it forever
1166 if ( !$this->mLinkRenderer ) {
1167 $this->mLinkRenderer = $this->linkRendererFactory->create();
1170 return $this->mLinkRenderer;
1174 * Get the MagicWordFactory that this Parser is using
1176 * @since 1.32
1177 * @return MagicWordFactory
1179 public function getMagicWordFactory() {
1180 return $this->magicWordFactory;
1184 * Get the content language that this Parser is using
1186 * @since 1.32
1187 * @return Language
1189 public function getContentLanguage() {
1190 return $this->contLang;
1194 * Get the BadFileLookup instance that this Parser is using
1196 * @since 1.35
1197 * @return BadFileLookup
1199 public function getBadFileLookup() {
1200 return $this->badFileLookup;
1204 * Replaces all occurrences of HTML-style comments and the given tags
1205 * in the text with a random marker and returns the next text. The output
1206 * parameter $matches will be an associative array filled with data in
1207 * the form:
1209 * @code
1210 * 'UNIQ-xxxxx' => [
1211 * 'element',
1212 * 'tag content',
1213 * [ 'param' => 'x' ],
1214 * '<element param="x">tag content</element>' ]
1215 * @endcode
1217 * @param string[] $elements List of element names. Comments are always extracted.
1218 * @param string $text Source text string.
1219 * @param array[] &$matches Out parameter, Array: extracted tags
1220 * @return string Stripped text
1222 public static function extractTagsAndParams( array $elements, $text, &$matches ) {
1223 static $n = 1;
1224 $stripped = '';
1225 $matches = [];
1227 $taglist = implode( '|', $elements );
1228 $start = "/<($taglist)(\\s+[^>]*?|\\s*?)(\/?>)|<(!--)/i";
1230 while ( $text != '' ) {
1231 $p = preg_split( $start, $text, 2, PREG_SPLIT_DELIM_CAPTURE );
1232 $stripped .= $p[0];
1233 if ( count( $p ) < 5 ) {
1234 break;
1236 if ( count( $p ) > 5 ) {
1237 # comment
1238 $element = $p[4];
1239 $attributes = '';
1240 $close = '';
1241 $inside = $p[5];
1242 } else {
1243 # tag
1244 [ , $element, $attributes, $close, $inside ] = $p;
1247 $marker = self::MARKER_PREFIX . "-$element-" . sprintf( '%08X', $n++ ) . self::MARKER_SUFFIX;
1248 $stripped .= $marker;
1250 if ( $close === '/>' ) {
1251 # Empty element tag, <tag />
1252 $content = null;
1253 $text = $inside;
1254 $tail = null;
1255 } else {
1256 if ( $element === '!--' ) {
1257 $end = '/(-->)/';
1258 } else {
1259 $end = "/(<\\/$element\\s*>)/i";
1261 $q = preg_split( $end, $inside, 2, PREG_SPLIT_DELIM_CAPTURE );
1262 $content = $q[0];
1263 if ( count( $q ) < 3 ) {
1264 # No end tag -- let it run out to the end of the text.
1265 $tail = '';
1266 $text = '';
1267 } else {
1268 [ , $tail, $text ] = $q;
1272 $matches[$marker] = [ $element,
1273 $content,
1274 Sanitizer::decodeTagAttributes( $attributes ),
1275 "<$element$attributes$close$content$tail" ];
1277 return $stripped;
1281 * Get a list of strippable XML-like elements
1283 * @return array
1285 public function getStripList() {
1286 return $this->mStripList;
1290 * @return StripState
1291 * @since 1.34
1293 public function getStripState() {
1294 return $this->mStripState;
1298 * Add an item to the strip state
1299 * Returns the unique tag which must be inserted into the stripped text
1300 * The tag will be replaced with the original text in unstrip()
1302 * @param string $text
1304 * @return string
1306 public function insertStripItem( $text ) {
1307 $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX;
1308 $this->mMarkerIndex++;
1309 $this->mStripState->addGeneral( $marker, $text );
1310 return $marker;
1314 * Parse the wiki syntax used to render tables.
1316 * @param string $text
1317 * @return string
1319 private function handleTables( $text ) {
1320 $lines = StringUtils::explode( "\n", $text );
1321 $out = '';
1322 $td_history = []; # Is currently a td tag open?
1323 $last_tag_history = []; # Save history of last lag activated (td, th or caption)
1324 $tr_history = []; # Is currently a tr tag open?
1325 $tr_attributes = []; # history of tr attributes
1326 $has_opened_tr = []; # Did this table open a <tr> element?
1327 $indent_level = 0; # indent level of the table
1329 foreach ( $lines as $outLine ) {
1330 $line = trim( $outLine );
1332 if ( $line === '' ) { # empty line, go to next line
1333 $out .= $outLine . "\n";
1334 continue;
1337 $first_character = $line[0];
1338 $first_two = substr( $line, 0, 2 );
1339 $matches = [];
1341 if ( preg_match( '/^(:*)\s*\{\|(.*)$/', $line, $matches ) ) {
1342 # First check if we are starting a new table
1343 $indent_level = strlen( $matches[1] );
1345 $attributes = $this->mStripState->unstripBoth( $matches[2] );
1346 $attributes = Sanitizer::fixTagAttributes( $attributes, 'table' );
1348 $outLine = str_repeat( '<dl><dd>', $indent_level ) . "<table{$attributes}>";
1349 $td_history[] = false;
1350 $last_tag_history[] = '';
1351 $tr_history[] = false;
1352 $tr_attributes[] = '';
1353 $has_opened_tr[] = false;
1354 } elseif ( count( $td_history ) == 0 ) {
1355 # Don't do any of the following
1356 $out .= $outLine . "\n";
1357 continue;
1358 } elseif ( $first_two === '|}' ) {
1359 # We are ending a table
1360 $line = '</table>' . substr( $line, 2 );
1361 $last_tag = array_pop( $last_tag_history );
1363 if ( !array_pop( $has_opened_tr ) ) {
1364 $line = "<tr><td></td></tr>{$line}";
1367 if ( array_pop( $tr_history ) ) {
1368 $line = "</tr>{$line}";
1371 if ( array_pop( $td_history ) ) {
1372 $line = "</{$last_tag}>{$line}";
1374 array_pop( $tr_attributes );
1375 if ( $indent_level > 0 ) {
1376 $outLine = rtrim( $line ) . str_repeat( '</dd></dl>', $indent_level );
1377 } else {
1378 $outLine = $line;
1380 } elseif ( $first_two === '|-' ) {
1381 # Now we have a table row
1382 $line = preg_replace( '#^\|-+#', '', $line );
1384 # Whats after the tag is now only attributes
1385 $attributes = $this->mStripState->unstripBoth( $line );
1386 $attributes = Sanitizer::fixTagAttributes( $attributes, 'tr' );
1387 array_pop( $tr_attributes );
1388 $tr_attributes[] = $attributes;
1390 $line = '';
1391 $last_tag = array_pop( $last_tag_history );
1392 array_pop( $has_opened_tr );
1393 $has_opened_tr[] = true;
1395 if ( array_pop( $tr_history ) ) {
1396 $line = '</tr>';
1399 if ( array_pop( $td_history ) ) {
1400 $line = "</{$last_tag}>{$line}";
1403 $outLine = $line;
1404 $tr_history[] = false;
1405 $td_history[] = false;
1406 $last_tag_history[] = '';
1407 } elseif ( $first_character === '|'
1408 || $first_character === '!'
1409 || $first_two === '|+'
1411 # This might be cell elements, td, th or captions
1412 if ( $first_two === '|+' ) {
1413 $first_character = '+';
1414 $line = substr( $line, 2 );
1415 } else {
1416 $line = substr( $line, 1 );
1419 // Implies both are valid for table headings.
1420 if ( $first_character === '!' ) {
1421 $line = StringUtils::replaceMarkup( '!!', '||', $line );
1424 # Split up multiple cells on the same line.
1425 # FIXME : This can result in improper nesting of tags processed
1426 # by earlier parser steps.
1427 $cells = explode( '||', $line );
1429 $outLine = '';
1431 # Loop through each table cell
1432 foreach ( $cells as $cell ) {
1433 $previous = '';
1434 if ( $first_character !== '+' ) {
1435 $tr_after = array_pop( $tr_attributes );
1436 if ( !array_pop( $tr_history ) ) {
1437 $previous = "<tr{$tr_after}>\n";
1439 $tr_history[] = true;
1440 $tr_attributes[] = '';
1441 array_pop( $has_opened_tr );
1442 $has_opened_tr[] = true;
1445 $last_tag = array_pop( $last_tag_history );
1447 if ( array_pop( $td_history ) ) {
1448 $previous = "</{$last_tag}>\n{$previous}";
1451 if ( $first_character === '|' ) {
1452 $last_tag = 'td';
1453 } elseif ( $first_character === '!' ) {
1454 $last_tag = 'th';
1455 } elseif ( $first_character === '+' ) {
1456 $last_tag = 'caption';
1457 } else {
1458 $last_tag = '';
1461 $last_tag_history[] = $last_tag;
1463 # A cell could contain both parameters and data
1464 $cell_data = explode( '|', $cell, 2 );
1466 # T2553: Note that a '|' inside an invalid link should not
1467 # be mistaken as delimiting cell parameters
1468 # Bug T153140: Neither should language converter markup.
1469 if ( preg_match( '/\[\[|-\{/', $cell_data[0] ) === 1 ) {
1470 $cell = "{$previous}<{$last_tag}>" . trim( $cell );
1471 } elseif ( count( $cell_data ) == 1 ) {
1472 // Whitespace in cells is trimmed
1473 $cell = "{$previous}<{$last_tag}>" . trim( $cell_data[0] );
1474 } else {
1475 $attributes = $this->mStripState->unstripBoth( $cell_data[0] );
1476 $attributes = Sanitizer::fixTagAttributes( $attributes, $last_tag );
1477 // Whitespace in cells is trimmed
1478 $cell = "{$previous}<{$last_tag}{$attributes}>" . trim( $cell_data[1] );
1481 $outLine .= $cell;
1482 $td_history[] = true;
1485 $out .= $outLine . "\n";
1488 # Closing open td, tr && table
1489 while ( count( $td_history ) > 0 ) {
1490 if ( array_pop( $td_history ) ) {
1491 $out .= "</td>\n";
1493 if ( array_pop( $tr_history ) ) {
1494 $out .= "</tr>\n";
1496 if ( !array_pop( $has_opened_tr ) ) {
1497 $out .= "<tr><td></td></tr>\n";
1500 $out .= "</table>\n";
1503 # Remove trailing line-ending (b/c)
1504 if ( substr( $out, -1 ) === "\n" ) {
1505 $out = substr( $out, 0, -1 );
1508 # special case: don't return empty table
1509 if ( $out === "<table>\n<tr><td></td></tr>\n</table>" ) {
1510 $out = '';
1513 return $out;
1517 * Helper function for parse() that transforms wiki markup into half-parsed
1518 * HTML. Only called for $mOutputType == self::OT_HTML.
1520 * @internal
1522 * @param string $text The text to parse
1523 * @param-taint $text escapes_html
1524 * @param bool $isMain Whether this is being called from the main parse() function
1525 * @param PPFrame|false $frame A pre-processor frame
1527 * @return string
1529 public function internalParse( $text, $isMain = true, $frame = false ) {
1530 $origText = $text;
1532 # Hook to suspend the parser in this state
1533 if ( !$this->hookRunner->onParserBeforeInternalParse( $this, $text, $this->mStripState ) ) {
1534 return $text;
1537 # if $frame is provided, then use $frame for replacing any variables
1538 if ( $frame ) {
1539 # use frame depth to infer how include/noinclude tags should be handled
1540 # depth=0 means this is the top-level document; otherwise it's an included document
1541 if ( !$frame->depth ) {
1542 $flag = 0;
1543 } else {
1544 $flag = Preprocessor::DOM_FOR_INCLUSION;
1546 $dom = $this->preprocessToDom( $text, $flag );
1547 $text = $frame->expand( $dom );
1548 } else {
1549 # if $frame is not provided, then use old-style replaceVariables
1550 $text = $this->replaceVariables( $text );
1553 $text = Sanitizer::internalRemoveHtmlTags(
1554 $text,
1555 // Callback from the Sanitizer for expanding items found in
1556 // HTML attribute values, so they can be safely tested and escaped.
1557 function ( &$text, $frame = false ) {
1558 $text = $this->replaceVariables( $text, $frame );
1559 $text = $this->mStripState->unstripBoth( $text );
1561 false,
1565 $this->hookRunner->onInternalParseBeforeLinks( $this, $text, $this->mStripState );
1567 # Tables need to come after variable replacement for things to work
1568 # properly; putting them before other transformations should keep
1569 # exciting things like link expansions from showing up in surprising
1570 # places.
1571 $text = $this->handleTables( $text );
1573 $text = preg_replace( '/(^|\n)-----*/', '\\1<hr />', $text );
1575 $text = $this->handleDoubleUnderscore( $text );
1577 $text = $this->handleHeadings( $text );
1578 $text = $this->handleInternalLinks( $text );
1579 $text = $this->handleAllQuotes( $text );
1580 $text = $this->handleExternalLinks( $text );
1582 # handleInternalLinks may sometimes leave behind
1583 # absolute URLs, which have to be masked to hide them from handleExternalLinks
1584 $text = str_replace( self::MARKER_PREFIX . 'NOPARSE', '', $text );
1586 $text = $this->handleMagicLinks( $text );
1587 $text = $this->finalizeHeadings( $text, $origText, $isMain );
1589 return $text;
1593 * Shorthand for getting a Language Converter for Target language
1595 * @since public since 1.38
1596 * @return ILanguageConverter
1598 public function getTargetLanguageConverter(): ILanguageConverter {
1599 return $this->languageConverterFactory->getLanguageConverter(
1600 $this->getTargetLanguage()
1605 * Shorthand for getting a Language Converter for Content language
1607 * @return ILanguageConverter
1609 private function getContentLanguageConverter(): ILanguageConverter {
1610 return $this->languageConverterFactory->getLanguageConverter(
1611 $this->getContentLanguage()
1616 * Get a HookContainer capable of returning metadata about hooks or running
1617 * extension hooks.
1619 * @since 1.35
1620 * @return HookContainer
1622 protected function getHookContainer() {
1623 return $this->hookContainer;
1627 * Get a HookRunner for calling core hooks
1629 * @internal This is for use by core only. Hook interfaces may be removed
1630 * without notice.
1631 * @since 1.35
1632 * @return HookRunner
1634 protected function getHookRunner() {
1635 return $this->hookRunner;
1639 * Helper function for parse() that transforms half-parsed HTML into fully
1640 * parsed HTML.
1642 * @param string $text
1643 * @param bool $isMain
1644 * @param bool $linestart
1645 * @return string
1647 private function internalParseHalfParsed( $text, $isMain = true, $linestart = true ) {
1648 $text = $this->mStripState->unstripGeneral( $text );
1650 $text = BlockLevelPass::doBlockLevels( $text, $linestart );
1652 $this->replaceLinkHoldersPrivate( $text );
1655 * The input doesn't get language converted if
1656 * a) It's disabled
1657 * b) Content isn't converted
1658 * c) It's a conversion table
1659 * d) it is an interface message (which is in the user language)
1661 $converter = null;
1662 if ( !( $this->mOptions->getDisableContentConversion()
1663 || isset( $this->mDoubleUnderscores['nocontentconvert'] )
1664 || $this->mOptions->getInterfaceMessage() )
1666 # The position of the convert() call should not be changed. it
1667 # assumes that the links are all replaced and the only thing left
1668 # is the <nowiki> mark.
1669 $converter = $this->getTargetLanguageConverter();
1670 $text = $converter->convert( $text );
1671 // TOC will be converted below.
1673 // Convert the TOC. This is done *after* the main text
1674 // so that all the editor-defined conversion rules (by convention
1675 // defined at the start of the article) are applied to the TOC
1676 self::localizeTOC(
1677 $this->mOutput->getTOCData(),
1678 $this->getTargetLanguage(),
1679 $converter // null if conversion is to be suppressed.
1681 if ( $converter ) {
1682 $this->mOutput->setLanguage( new Bcp47CodeValue(
1683 LanguageCode::bcp47( $converter->getPreferredVariant() )
1684 ) );
1685 } else {
1686 $this->mOutput->setLanguage( $this->getTargetLanguage() );
1689 $text = $this->mStripState->unstripNoWiki( $text );
1691 $text = $this->mStripState->unstripGeneral( $text );
1693 $text = $this->tidy->tidy( $text, [ Sanitizer::class, 'armorFrenchSpaces' ] );
1695 if ( $isMain ) {
1696 $this->hookRunner->onParserAfterTidy( $this, $text );
1699 return $text;
1703 * Replace special strings like "ISBN xxx" and "RFC xxx" with
1704 * magic external links.
1706 * DML
1708 * @param string $text
1710 * @return string
1712 private function handleMagicLinks( $text ) {
1713 $prots = $this->urlUtils->validAbsoluteProtocols();
1714 $urlChar = self::EXT_LINK_URL_CLASS;
1715 $addr = self::EXT_LINK_ADDR;
1716 $space = self::SPACE_NOT_NL; # non-newline space
1717 $spdash = "(?:-|$space)"; # a dash or a non-newline space
1718 $spaces = "$space++"; # possessive match of 1 or more spaces
1719 $text = preg_replace_callback(
1720 '!(?: # Start cases
1721 (<a[ \t\r\n>].*?</a>) | # m[1]: Skip link text
1722 (<.*?>) | # m[2]: Skip stuff inside HTML elements' . "
1723 (\b # m[3]: Free external links
1724 (?i:$prots)
1725 ($addr$urlChar*) # m[4]: Post-protocol path
1727 \b(?:RFC|PMID) $spaces # m[5]: RFC or PMID, capture number
1728 ([0-9]+)\b |
1729 \bISBN $spaces ( # m[6]: ISBN, capture number
1730 (?: 97[89] $spdash? )? # optional 13-digit ISBN prefix
1731 (?: [0-9] $spdash? ){9} # 9 digits with opt. delimiters
1732 [0-9Xx] # check digit
1734 )!xu",
1735 [ $this, 'magicLinkCallback' ],
1736 $text
1738 return $text;
1742 * @param array $m
1743 * @return string HTML
1745 private function magicLinkCallback( array $m ) {
1746 if ( isset( $m[1] ) && $m[1] !== '' ) {
1747 # Skip anchor
1748 return $m[0];
1749 } elseif ( isset( $m[2] ) && $m[2] !== '' ) {
1750 # Skip HTML element
1751 return $m[0];
1752 } elseif ( isset( $m[3] ) && $m[3] !== '' ) {
1753 # Free external link
1754 return $this->makeFreeExternalLink( $m[0], strlen( $m[4] ) );
1755 } elseif ( isset( $m[5] ) && $m[5] !== '' ) {
1756 # RFC or PMID
1757 if ( substr( $m[0], 0, 3 ) === 'RFC' ) {
1758 if ( !$this->mOptions->getMagicRFCLinks() ) {
1759 return $m[0];
1761 $keyword = 'RFC';
1762 $urlmsg = 'rfcurl';
1763 $cssClass = 'mw-magiclink-rfc';
1764 $trackingCat = 'magiclink-tracking-rfc';
1765 $id = $m[5];
1766 } elseif ( substr( $m[0], 0, 4 ) === 'PMID' ) {
1767 if ( !$this->mOptions->getMagicPMIDLinks() ) {
1768 return $m[0];
1770 $keyword = 'PMID';
1771 $urlmsg = 'pubmedurl';
1772 $cssClass = 'mw-magiclink-pmid';
1773 $trackingCat = 'magiclink-tracking-pmid';
1774 $id = $m[5];
1775 } else {
1776 // Should never happen
1777 throw new UnexpectedValueException( __METHOD__ . ': unrecognised match type "' .
1778 substr( $m[0], 0, 20 ) . '"' );
1780 $url = wfMessage( $urlmsg, $id )->inContentLanguage()->text();
1781 $this->addTrackingCategory( $trackingCat );
1782 return Linker::makeExternalLink(
1783 $url,
1784 "{$keyword} {$id}",
1785 true,
1786 $cssClass,
1788 $this->getTitle()
1790 } elseif ( isset( $m[6] ) && $m[6] !== ''
1791 && $this->mOptions->getMagicISBNLinks()
1793 # ISBN
1794 $isbn = $m[6];
1795 $space = self::SPACE_NOT_NL; # non-newline space
1796 $isbn = preg_replace( "/$space/", ' ', $isbn );
1797 $num = strtr( $isbn, [
1798 '-' => '',
1799 ' ' => '',
1800 'x' => 'X',
1801 ] );
1802 $this->addTrackingCategory( 'magiclink-tracking-isbn' );
1803 return $this->getLinkRenderer()->makeKnownLink(
1804 SpecialPage::getTitleFor( 'Booksources', $num ),
1805 "ISBN $isbn",
1807 'class' => 'internal mw-magiclink-isbn',
1808 'title' => false // suppress title attribute
1811 } else {
1812 return $m[0];
1817 * Make a free external link, given a user-supplied URL
1819 * @param string $url
1820 * @param int $numPostProto
1821 * The number of characters after the protocol.
1822 * @return string HTML
1823 * @internal
1825 private function makeFreeExternalLink( $url, $numPostProto ) {
1826 $trail = '';
1828 # The characters '<' and '>' (which were escaped by
1829 # internalRemoveHtmlTags()) should not be included in
1830 # URLs, per RFC 2396.
1831 # Make &nbsp; terminate a URL as well (bug T84937)
1832 $m2 = [];
1833 if ( preg_match(
1834 '/&(lt|gt|nbsp|#x0*(3[CcEe]|[Aa]0)|#0*(60|62|160));/',
1835 $url,
1836 $m2,
1837 PREG_OFFSET_CAPTURE
1838 ) ) {
1839 $trail = substr( $url, $m2[0][1] ) . $trail;
1840 $url = substr( $url, 0, $m2[0][1] );
1843 # Move trailing punctuation to $trail
1844 $sep = ',;\.:!?';
1845 # If there is no left bracket, then consider right brackets fair game too
1846 if ( strpos( $url, '(' ) === false ) {
1847 $sep .= ')';
1850 $urlRev = strrev( $url );
1851 $numSepChars = strspn( $urlRev, $sep );
1852 # Don't break a trailing HTML entity by moving the ; into $trail
1853 # This is in hot code, so use substr_compare to avoid having to
1854 # create a new string object for the comparison
1855 if ( $numSepChars && substr_compare( $url, ";", -$numSepChars, 1 ) === 0 ) {
1856 # more optimization: instead of running preg_match with a $
1857 # anchor, which can be slow, do the match on the reversed
1858 # string starting at the desired offset.
1859 # un-reversed regexp is: /&([a-z]+|#x[\da-f]+|#\d+)$/i
1860 if ( preg_match( '/\G([a-z]+|[\da-f]+x#|\d+#)&/i', $urlRev, $m2, 0, $numSepChars ) ) {
1861 $numSepChars--;
1864 if ( $numSepChars ) {
1865 $trail = substr( $url, -$numSepChars ) . $trail;
1866 $url = substr( $url, 0, -$numSepChars );
1869 # Verify that we still have a real URL after trail removal, and
1870 # not just lone protocol
1871 if ( strlen( $trail ) >= $numPostProto ) {
1872 return $url . $trail;
1875 $url = Sanitizer::cleanUrl( $url );
1877 # Is this an external image?
1878 $text = $this->maybeMakeExternalImage( $url );
1879 if ( $text === false ) {
1880 # Not an image, make a link
1881 $text = Linker::makeExternalLink(
1882 $url,
1883 $this->getTargetLanguageConverter()->markNoConversion( $url ),
1884 true,
1885 'free',
1886 $this->getExternalLinkAttribs( $url ),
1887 $this->getTitle()
1889 # Register it in the output object...
1890 $this->mOutput->addExternalLink( $url );
1892 return $text . $trail;
1896 * Parse headers and return html
1898 * @param string $text
1899 * @return string
1901 private function handleHeadings( $text ) {
1902 for ( $i = 6; $i >= 1; --$i ) {
1903 $h = str_repeat( '=', $i );
1904 // Trim non-newline whitespace from headings
1905 // Using \s* will break for: "==\n===\n" and parse as <h2>=</h2>
1906 $text = preg_replace( "/^(?:$h)[ \\t]*(.+?)[ \\t]*(?:$h)\\s*$/m", "<h$i>\\1</h$i>", $text );
1908 return $text;
1912 * Replace single quotes with HTML markup
1914 * @param string $text
1916 * @return string The altered text
1918 private function handleAllQuotes( $text ) {
1919 $outtext = '';
1920 $lines = StringUtils::explode( "\n", $text );
1921 foreach ( $lines as $line ) {
1922 $outtext .= $this->doQuotes( $line ) . "\n";
1924 $outtext = substr( $outtext, 0, -1 );
1925 return $outtext;
1929 * Helper function for handleAllQuotes()
1931 * @param string $text
1933 * @return string
1934 * @internal
1936 public function doQuotes( $text ) {
1937 $arr = preg_split( "/(''+)/", $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1938 $countarr = count( $arr );
1939 if ( $countarr == 1 ) {
1940 return $text;
1943 // First, do some preliminary work. This may shift some apostrophes from
1944 // being mark-up to being text. It also counts the number of occurrences
1945 // of bold and italics mark-ups.
1946 $numbold = 0;
1947 $numitalics = 0;
1948 for ( $i = 1; $i < $countarr; $i += 2 ) {
1949 $thislen = strlen( $arr[$i] );
1950 // If there are ever four apostrophes, assume the first is supposed to
1951 // be text, and the remaining three constitute mark-up for bold text.
1952 // (T15227: ''''foo'''' turns into ' ''' foo ' ''')
1953 if ( $thislen == 4 ) {
1954 $arr[$i - 1] .= "'";
1955 $arr[$i] = "'''";
1956 $thislen = 3;
1957 } elseif ( $thislen > 5 ) {
1958 // If there are more than 5 apostrophes in a row, assume they're all
1959 // text except for the last 5.
1960 // (T15227: ''''''foo'''''' turns into ' ''''' foo ' ''''')
1961 $arr[$i - 1] .= str_repeat( "'", $thislen - 5 );
1962 $arr[$i] = "'''''";
1963 $thislen = 5;
1965 // Count the number of occurrences of bold and italics mark-ups.
1966 if ( $thislen == 2 ) {
1967 $numitalics++;
1968 } elseif ( $thislen == 3 ) {
1969 $numbold++;
1970 } elseif ( $thislen == 5 ) {
1971 $numitalics++;
1972 $numbold++;
1976 // If there is an odd number of both bold and italics, it is likely
1977 // that one of the bold ones was meant to be an apostrophe followed
1978 // by italics. Which one we cannot know for certain, but it is more
1979 // likely to be one that has a single-letter word before it.
1980 if ( ( $numbold % 2 == 1 ) && ( $numitalics % 2 == 1 ) ) {
1981 $firstsingleletterword = -1;
1982 $firstmultiletterword = -1;
1983 $firstspace = -1;
1984 for ( $i = 1; $i < $countarr; $i += 2 ) {
1985 if ( strlen( $arr[$i] ) == 3 ) {
1986 $x1 = substr( $arr[$i - 1], -1 );
1987 $x2 = substr( $arr[$i - 1], -2, 1 );
1988 if ( $x1 === ' ' ) {
1989 if ( $firstspace == -1 ) {
1990 $firstspace = $i;
1992 } elseif ( $x2 === ' ' ) {
1993 $firstsingleletterword = $i;
1994 // if $firstsingleletterword is set, we don't
1995 // look at the other options, so we can bail early.
1996 break;
1997 } elseif ( $firstmultiletterword == -1 ) {
1998 $firstmultiletterword = $i;
2003 // If there is a single-letter word, use it!
2004 if ( $firstsingleletterword > -1 ) {
2005 $arr[$firstsingleletterword] = "''";
2006 $arr[$firstsingleletterword - 1] .= "'";
2007 } elseif ( $firstmultiletterword > -1 ) {
2008 // If not, but there's a multi-letter word, use that one.
2009 $arr[$firstmultiletterword] = "''";
2010 $arr[$firstmultiletterword - 1] .= "'";
2011 } elseif ( $firstspace > -1 ) {
2012 // ... otherwise use the first one that has neither.
2013 // (notice that it is possible for all three to be -1 if, for example,
2014 // there is only one pentuple-apostrophe in the line)
2015 $arr[$firstspace] = "''";
2016 $arr[$firstspace - 1] .= "'";
2020 // Now let's actually convert our apostrophic mush to HTML!
2021 $output = '';
2022 $buffer = '';
2023 $state = '';
2024 $i = 0;
2025 foreach ( $arr as $r ) {
2026 if ( ( $i % 2 ) == 0 ) {
2027 if ( $state === 'both' ) {
2028 $buffer .= $r;
2029 } else {
2030 $output .= $r;
2032 } else {
2033 $thislen = strlen( $r );
2034 if ( $thislen == 2 ) {
2035 // two quotes - open or close italics
2036 if ( $state === 'i' ) {
2037 $output .= '</i>';
2038 $state = '';
2039 } elseif ( $state === 'bi' ) {
2040 $output .= '</i>';
2041 $state = 'b';
2042 } elseif ( $state === 'ib' ) {
2043 $output .= '</b></i><b>';
2044 $state = 'b';
2045 } elseif ( $state === 'both' ) {
2046 $output .= '<b><i>' . $buffer . '</i>';
2047 $state = 'b';
2048 } else { // $state can be 'b' or ''
2049 $output .= '<i>';
2050 $state .= 'i';
2052 } elseif ( $thislen == 3 ) {
2053 // three quotes - open or close bold
2054 if ( $state === 'b' ) {
2055 $output .= '</b>';
2056 $state = '';
2057 } elseif ( $state === 'bi' ) {
2058 $output .= '</i></b><i>';
2059 $state = 'i';
2060 } elseif ( $state === 'ib' ) {
2061 $output .= '</b>';
2062 $state = 'i';
2063 } elseif ( $state === 'both' ) {
2064 $output .= '<i><b>' . $buffer . '</b>';
2065 $state = 'i';
2066 } else { // $state can be 'i' or ''
2067 $output .= '<b>';
2068 $state .= 'b';
2070 } elseif ( $thislen == 5 ) {
2071 // five quotes - open or close both separately
2072 if ( $state === 'b' ) {
2073 $output .= '</b><i>';
2074 $state = 'i';
2075 } elseif ( $state === 'i' ) {
2076 $output .= '</i><b>';
2077 $state = 'b';
2078 } elseif ( $state === 'bi' ) {
2079 $output .= '</i></b>';
2080 $state = '';
2081 } elseif ( $state === 'ib' ) {
2082 $output .= '</b></i>';
2083 $state = '';
2084 } elseif ( $state === 'both' ) {
2085 $output .= '<i><b>' . $buffer . '</b></i>';
2086 $state = '';
2087 } else { // ($state == '')
2088 $buffer = '';
2089 $state = 'both';
2093 $i++;
2095 // Now close all remaining tags. Notice that the order is important.
2096 if ( $state === 'b' || $state === 'ib' ) {
2097 $output .= '</b>';
2099 if ( $state === 'i' || $state === 'bi' || $state === 'ib' ) {
2100 $output .= '</i>';
2102 if ( $state === 'bi' ) {
2103 $output .= '</b>';
2105 // There might be lonely ''''', so make sure we have a buffer
2106 if ( $state === 'both' && $buffer ) {
2107 $output .= '<b><i>' . $buffer . '</i></b>';
2109 return $output;
2113 * Replace external links (REL)
2115 * Note: this is all very hackish and the order of execution matters a lot.
2116 * Make sure to run tests/parser/parserTests.php if you change this code.
2118 * @param string $text
2119 * @return string
2121 private function handleExternalLinks( $text ) {
2122 $bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE );
2123 // @phan-suppress-next-line PhanTypeComparisonFromArray See phan issue #3161
2124 if ( $bits === false ) {
2125 throw new RuntimeException( "PCRE failure" );
2127 $s = array_shift( $bits );
2129 $i = 0;
2130 while ( $i < count( $bits ) ) {
2131 $url = $bits[$i++];
2132 $i++; // protocol
2133 $text = $bits[$i++];
2134 $trail = $bits[$i++];
2136 # The characters '<' and '>' (which were escaped by
2137 # internalRemoveHtmlTags()) should not be included in
2138 # URLs, per RFC 2396.
2139 $m2 = [];
2140 if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) {
2141 $text = substr( $url, $m2[0][1] ) . ' ' . $text;
2142 $url = substr( $url, 0, $m2[0][1] );
2145 # If the link text is an image URL, replace it with an <img> tag
2146 # This happened by accident in the original parser, but some people used it extensively
2147 $img = $this->maybeMakeExternalImage( $text );
2148 if ( $img !== false ) {
2149 $text = $img;
2152 $dtrail = '';
2154 # Set linktype for CSS
2155 $linktype = 'text';
2157 # No link text, e.g. [http://domain.tld/some.link]
2158 if ( $text == '' ) {
2159 # Autonumber
2160 $langObj = $this->getTargetLanguage();
2161 $text = '[' . $langObj->formatNum( ++$this->mAutonumber ) . ']';
2162 $linktype = 'autonumber';
2163 } else {
2164 # Have link text, e.g. [http://domain.tld/some.link text]s
2165 # Check for trail
2166 [ $dtrail, $trail ] = Linker::splitTrail( $trail );
2169 // Excluding protocol-relative URLs may avoid many false positives.
2170 if ( preg_match( '/^(?:' . $this->urlUtils->validAbsoluteProtocols() . ')/', $text ) ) {
2171 $text = $this->getTargetLanguageConverter()->markNoConversion( $text );
2174 $url = Sanitizer::cleanUrl( $url );
2176 # Use the encoded URL
2177 # This means that users can paste URLs directly into the text
2178 # Funny characters like ö aren't valid in URLs anyway
2179 # This was changed in August 2004
2180 $s .= Linker::makeExternalLink( $url, $text, false, $linktype,
2181 $this->getExternalLinkAttribs( $url ), $this->getTitle() ) . $dtrail . $trail;
2183 # Register link in the output object.
2184 $this->mOutput->addExternalLink( $url );
2187 // @phan-suppress-next-line PhanTypeMismatchReturnNullable False positive from array_shift
2188 return $s;
2192 * Get the rel attribute for a particular external link.
2194 * @since 1.21
2195 * @internal
2196 * @param string|false $url Optional URL, to extract the domain from for rel =>
2197 * nofollow if appropriate
2198 * @param LinkTarget|null $title Optional LinkTarget, for wgNoFollowNsExceptions lookups
2199 * @return string|null Rel attribute for $url
2201 public static function getExternalLinkRel( $url = false, LinkTarget $title = null ) {
2202 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
2203 $noFollowLinks = $mainConfig->get( MainConfigNames::NoFollowLinks );
2204 $noFollowNsExceptions = $mainConfig->get( MainConfigNames::NoFollowNsExceptions );
2205 $noFollowDomainExceptions = $mainConfig->get( MainConfigNames::NoFollowDomainExceptions );
2206 $ns = $title ? $title->getNamespace() : false;
2207 if ( $noFollowLinks && !in_array( $ns, $noFollowNsExceptions )
2208 && !wfMatchesDomainList( $url, $noFollowDomainExceptions )
2210 return 'nofollow';
2212 return null;
2216 * Get an associative array of additional HTML attributes appropriate for a
2217 * particular external link. This currently may include rel => nofollow
2218 * (depending on configuration, namespace, and the URL's domain) and/or a
2219 * target attribute (depending on configuration).
2221 * @internal
2222 * @param string $url URL to extract the domain from for rel =>
2223 * nofollow if appropriate
2224 * @return array Associative array of HTML attributes
2226 public function getExternalLinkAttribs( $url ) {
2227 $attribs = [];
2228 $rel = self::getExternalLinkRel( $url, $this->getTitle() ) ?? '';
2230 $target = $this->mOptions->getExternalLinkTarget();
2231 if ( $target ) {
2232 $attribs['target'] = $target;
2233 if ( !in_array( $target, [ '_self', '_parent', '_top' ] ) ) {
2234 // T133507. New windows can navigate parent cross-origin.
2235 // Including noreferrer due to lacking browser
2236 // support of noopener. Eventually noreferrer should be removed.
2237 if ( $rel !== '' ) {
2238 $rel .= ' ';
2240 $rel .= 'noreferrer noopener';
2243 if ( $rel !== '' ) {
2244 $attribs['rel'] = $rel;
2246 return $attribs;
2250 * Replace unusual escape codes in a URL with their equivalent characters
2252 * This generally follows the syntax defined in RFC 3986, with special
2253 * consideration for HTTP query strings.
2255 * @internal
2256 * @param string $url
2257 * @return string
2259 public static function normalizeLinkUrl( $url ) {
2260 # Test for RFC 3986 IPv6 syntax
2261 $scheme = '[a-z][a-z0-9+.-]*:';
2262 $userinfo = '(?:[a-z0-9\-._~!$&\'()*+,;=:]|%[0-9a-f]{2})*';
2263 $ipv6Host = '\\[((?:[0-9a-f:]|%3[0-A]|%[46][1-6])+)\\]';
2264 if ( preg_match( "<^(?:{$scheme})?//(?:{$userinfo}@)?{$ipv6Host}(?:[:/?#].*|)$>i", $url, $m ) &&
2265 IPUtils::isValid( rawurldecode( $m[1] ) )
2267 $isIPv6 = rawurldecode( $m[1] );
2268 } else {
2269 $isIPv6 = false;
2272 # Make sure unsafe characters are encoded
2273 $url = preg_replace_callback(
2274 '/[\x00-\x20"<>\[\\\\\]^`{|}\x7F-\xFF]/',
2275 static function ( $m ) {
2276 return rawurlencode( $m[0] );
2278 $url
2281 $ret = '';
2282 $end = strlen( $url );
2284 # Fragment part - 'fragment'
2285 $start = strpos( $url, '#' );
2286 if ( $start !== false && $start < $end ) {
2287 $ret = self::normalizeUrlComponent(
2288 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}' ) . $ret;
2289 $end = $start;
2292 # Query part - 'query' minus &=+;
2293 $start = strpos( $url, '?' );
2294 if ( $start !== false && $start < $end ) {
2295 $ret = self::normalizeUrlComponent(
2296 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}&=+;' ) . $ret;
2297 $end = $start;
2300 # Scheme and path part - 'pchar'
2301 # (we assume no userinfo or encoded colons in the host)
2302 $ret = self::normalizeUrlComponent(
2303 substr( $url, 0, $end ), '"#%<>[\]^`{|}/?' ) . $ret;
2305 # Fix IPv6 syntax
2306 if ( $isIPv6 !== false ) {
2307 $ipv6Host = "%5B({$isIPv6})%5D";
2308 $ret = preg_replace(
2309 "<^((?:{$scheme})?//(?:{$userinfo}@)?){$ipv6Host}(?=[:/?#]|$)>i",
2310 "$1[$2]",
2311 $ret
2315 return $ret;
2318 private static function normalizeUrlComponent( $component, $unsafe ) {
2319 $callback = static function ( $matches ) use ( $unsafe ) {
2320 $char = urldecode( $matches[0] );
2321 $ord = ord( $char );
2322 if ( $ord > 32 && $ord < 127 && strpos( $unsafe, $char ) === false ) {
2323 # Unescape it
2324 return $char;
2325 } else {
2326 # Leave it escaped, but use uppercase for a-f
2327 return strtoupper( $matches[0] );
2330 return preg_replace_callback( '/%[0-9A-Fa-f]{2}/', $callback, $component );
2334 * make an image if it's allowed, either through the global
2335 * option, through the exception, or through the on-wiki whitelist
2337 * @param string $url
2339 * @return string
2341 private function maybeMakeExternalImage( $url ) {
2342 $imagesfrom = $this->mOptions->getAllowExternalImagesFrom();
2343 $imagesexception = (bool)$imagesfrom;
2344 $text = false;
2345 # $imagesfrom could be either a single string or an array of strings, parse out the latter
2346 if ( $imagesexception && is_array( $imagesfrom ) ) {
2347 $imagematch = false;
2348 foreach ( $imagesfrom as $match ) {
2349 if ( strpos( $url, $match ) === 0 ) {
2350 $imagematch = true;
2351 break;
2354 } elseif ( $imagesexception ) {
2355 $imagematch = ( strpos( $url, $imagesfrom ) === 0 );
2356 } else {
2357 $imagematch = false;
2360 if ( $this->mOptions->getAllowExternalImages()
2361 || ( $imagesexception && $imagematch )
2363 if ( preg_match( self::EXT_IMAGE_REGEX, $url ) ) {
2364 # Image found
2365 $text = Linker::makeExternalImage( $url );
2368 if ( !$text && $this->mOptions->getEnableImageWhitelist()
2369 && preg_match( self::EXT_IMAGE_REGEX, $url )
2371 $whitelist = explode(
2372 "\n",
2373 wfMessage( 'external_image_whitelist' )->inContentLanguage()->text()
2376 foreach ( $whitelist as $entry ) {
2377 # Sanitize the regex fragment, make it case-insensitive, ignore blank entries/comments
2378 if ( strpos( $entry, '#' ) === 0 || $entry === '' ) {
2379 continue;
2381 // @phan-suppress-next-line SecurityCheck-ReDoS preg_quote is not wanted here
2382 if ( preg_match( '/' . str_replace( '/', '\\/', $entry ) . '/i', $url ) ) {
2383 # Image matches a whitelist entry
2384 $text = Linker::makeExternalImage( $url );
2385 break;
2389 return $text;
2393 * Process [[ ]] wikilinks
2395 * @param string $text
2397 * @return string Processed text
2399 private function handleInternalLinks( $text ) {
2400 $this->mLinkHolders->merge( $this->handleInternalLinks2( $text ) );
2401 return $text;
2405 * Process [[ ]] wikilinks (RIL)
2406 * @param string &$s
2407 * @return LinkHolderArray
2409 private function handleInternalLinks2( &$s ) {
2410 static $tc = false, $e1, $e1_img;
2411 # the % is needed to support urlencoded titles as well
2412 if ( !$tc ) {
2413 $tc = Title::legalChars() . '#%';
2414 # Match a link having the form [[namespace:link|alternate]]trail
2415 $e1 = "/^([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD";
2416 # Match cases where there is no "]]", which might still be images
2417 $e1_img = "/^([{$tc}]+)\\|(.*)\$/sD";
2420 $holders = new LinkHolderArray(
2421 $this,
2422 $this->getContentLanguageConverter(),
2423 $this->getHookContainer() );
2425 # split the entire text string on occurrences of [[
2426 $a = StringUtils::explode( '[[', ' ' . $s );
2427 # get the first element (all text up to first [[), and remove the space we added
2428 $s = $a->current();
2429 $a->next();
2430 $line = $a->current(); # Workaround for broken ArrayIterator::next() that returns "void"
2431 $s = substr( $s, 1 );
2433 $nottalk = !$this->getTitle()->isTalkPage();
2435 $useLinkPrefixExtension = $this->getTargetLanguage()->linkPrefixExtension();
2436 $e2 = null;
2437 if ( $useLinkPrefixExtension ) {
2438 # Match the end of a line for a word that's not followed by whitespace,
2439 # e.g. in the case of 'The Arab al[[Razi]]', 'al' will be matched
2440 $charset = $this->contLang->linkPrefixCharset();
2441 $e2 = "/^((?>.*[^$charset]|))(.+)$/sDu";
2442 $m = [];
2443 if ( preg_match( $e2, $s, $m ) ) {
2444 $first_prefix = $m[2];
2445 } else {
2446 $first_prefix = false;
2448 $prefix = false;
2449 } else {
2450 $first_prefix = false;
2451 $prefix = '';
2454 # Some namespaces don't allow subpages
2455 $useSubpages = $this->nsInfo->hasSubpages(
2456 $this->getTitle()->getNamespace()
2459 # Loop for each link
2460 for ( ; $line !== false && $line !== null; $a->next(), $line = $a->current() ) {
2461 # Check for excessive memory usage
2462 if ( $holders->isBig() ) {
2463 # Too big
2464 # Do the existence check, replace the link holders and clear the array
2465 $holders->replace( $s );
2466 $holders->clear();
2469 if ( $useLinkPrefixExtension ) {
2470 // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal $e2 is set under this condition
2471 if ( preg_match( $e2, $s, $m ) ) {
2472 [ , $s, $prefix ] = $m;
2473 } else {
2474 $prefix = '';
2476 # first link
2477 if ( $first_prefix ) {
2478 $prefix = $first_prefix;
2479 $first_prefix = false;
2483 $might_be_img = false;
2485 if ( preg_match( $e1, $line, $m ) ) { # page with normal text or alt
2486 $text = $m[2];
2487 # If we get a ] at the beginning of $m[3] that means we have a link that's something like:
2488 # [[Image:Foo.jpg|[http://example.com desc]]] <- having three ] in a row fucks up,
2489 # the real problem is with the $e1 regex
2490 # See T1500.
2491 # Still some problems for cases where the ] is meant to be outside punctuation,
2492 # and no image is in sight. See T4095.
2493 if ( $text !== ''
2494 && substr( $m[3], 0, 1 ) === ']'
2495 && strpos( $text, '[' ) !== false
2497 $text .= ']'; # so that handleExternalLinks($text) works later
2498 $m[3] = substr( $m[3], 1 );
2500 # fix up urlencoded title texts
2501 if ( strpos( $m[1], '%' ) !== false ) {
2502 # Should anchors '#' also be rejected?
2503 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2505 $trail = $m[3];
2506 } elseif ( preg_match( $e1_img, $line, $m ) ) {
2507 # Invalid, but might be an image with a link in its caption
2508 $might_be_img = true;
2509 $text = $m[2];
2510 if ( strpos( $m[1], '%' ) !== false ) {
2511 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2513 $trail = "";
2514 } else { # Invalid form; output directly
2515 $s .= $prefix . '[[' . $line;
2516 continue;
2519 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset preg_match success when reached here
2520 $origLink = ltrim( $m[1], ' ' );
2522 # Don't allow internal links to pages containing
2523 # PROTO: where PROTO is a valid URL protocol; these
2524 # should be external links.
2525 if ( preg_match( '/^(?i:' . $this->urlUtils->validProtocols() . ')/', $origLink ) ) {
2526 $s .= $prefix . '[[' . $line;
2527 continue;
2530 # Make subpage if necessary
2531 if ( $useSubpages ) {
2532 $link = Linker::normalizeSubpageLink(
2533 $this->getTitle(), $origLink, $text
2535 } else {
2536 $link = $origLink;
2539 // \x7f isn't a default legal title char, so most likely strip
2540 // markers will force us into the "invalid form" path above. But,
2541 // just in case, let's assert that xmlish tags aren't valid in
2542 // the title position.
2543 $unstrip = $this->mStripState->killMarkers( $link );
2544 $noMarkers = ( $unstrip === $link );
2546 $nt = $noMarkers ? Title::newFromText( $link ) : null;
2547 if ( $nt === null ) {
2548 $s .= $prefix . '[[' . $line;
2549 continue;
2552 $ns = $nt->getNamespace();
2553 $iw = $nt->getInterwiki();
2555 $noforce = ( substr( $origLink, 0, 1 ) !== ':' );
2557 if ( $might_be_img ) { # if this is actually an invalid link
2558 if ( $ns === NS_FILE && $noforce ) { # but might be an image
2559 $found = false;
2560 while ( true ) {
2561 # look at the next 'line' to see if we can close it there
2562 $a->next();
2563 $next_line = $a->current();
2564 if ( $next_line === false || $next_line === null ) {
2565 break;
2567 $m = explode( ']]', $next_line, 3 );
2568 if ( count( $m ) == 3 ) {
2569 # the first ]] closes the inner link, the second the image
2570 $found = true;
2571 $text .= "[[{$m[0]}]]{$m[1]}";
2572 $trail = $m[2];
2573 break;
2574 } elseif ( count( $m ) == 2 ) {
2575 # if there's exactly one ]] that's fine, we'll keep looking
2576 $text .= "[[{$m[0]}]]{$m[1]}";
2577 } else {
2578 # if $next_line is invalid too, we need look no further
2579 $text .= '[[' . $next_line;
2580 break;
2583 if ( !$found ) {
2584 # we couldn't find the end of this imageLink, so output it raw
2585 # but don't ignore what might be perfectly normal links in the text we've examined
2586 $holders->merge( $this->handleInternalLinks2( $text ) );
2587 $s .= "{$prefix}[[$link|$text";
2588 # note: no $trail, because without an end, there *is* no trail
2589 continue;
2591 } else { # it's not an image, so output it raw
2592 $s .= "{$prefix}[[$link|$text";
2593 # note: no $trail, because without an end, there *is* no trail
2594 continue;
2598 $wasblank = ( $text == '' );
2599 if ( $wasblank ) {
2600 $text = $link;
2601 if ( !$noforce ) {
2602 # Strip off leading ':'
2603 $text = substr( $text, 1 );
2605 } else {
2606 # T6598 madness. Handle the quotes only if they come from the alternate part
2607 # [[Lista d''e paise d''o munno]] -> <a href="...">Lista d''e paise d''o munno</a>
2608 # [[Criticism of Harry Potter|Criticism of ''Harry Potter'']]
2609 # -> <a href="Criticism of Harry Potter">Criticism of <i>Harry Potter</i></a>
2610 $text = $this->doQuotes( $text );
2613 # Link not escaped by : , create the various objects
2614 if ( $noforce && !$nt->wasLocalInterwiki() ) {
2615 # Interwikis
2616 if (
2617 $iw && $this->mOptions->getInterwikiMagic() && $nottalk && (
2618 MediaWikiServices::getInstance()->getLanguageNameUtils()
2619 ->getLanguageName(
2620 $iw,
2621 LanguageNameUtils::AUTONYMS,
2622 LanguageNameUtils::DEFINED
2624 || in_array( $iw, $this->svcOptions->get( MainConfigNames::ExtraInterlanguageLinkPrefixes ) )
2627 # T26502: filter duplicates
2628 if ( !isset( $this->mLangLinkLanguages[$iw] ) ) {
2629 $this->mLangLinkLanguages[$iw] = true;
2630 $this->mOutput->addLanguageLink( $nt->getFullText() );
2634 * Strip the whitespace interwiki links produce, see T10897
2636 $s = rtrim( $s . $prefix ) . $trail; # T175416
2637 continue;
2640 if ( $ns === NS_FILE ) {
2641 if ( $wasblank ) {
2642 # if no parameters were passed, $text
2643 # becomes something like "File:Foo.png",
2644 # which we don't want to pass on to the
2645 # image generator
2646 $text = '';
2647 } else {
2648 # recursively parse links inside the image caption
2649 # actually, this will parse them in any other parameters, too,
2650 # but it might be hard to fix that, and it doesn't matter ATM
2651 $text = $this->handleExternalLinks( $text );
2652 $holders->merge( $this->handleInternalLinks2( $text ) );
2654 # cloak any absolute URLs inside the image markup, so handleExternalLinks() won't touch them
2655 $s .= $prefix . $this->armorLinks(
2656 $this->makeImage( $nt, $text, $holders ) ) . $trail;
2657 continue;
2658 } elseif ( $ns === NS_CATEGORY ) {
2660 * Strip the whitespace Category links produce, see T2087
2662 $s = rtrim( $s . $prefix ) . $trail; # T2087, T87753
2664 if ( $wasblank ) {
2665 $sortkey = $this->mOutput->getPageProperty( 'defaultsort' ) ?? '';
2666 } else {
2667 $sortkey = $text;
2669 $sortkey = Sanitizer::decodeCharReferences( $sortkey );
2670 $sortkey = str_replace( "\n", '', $sortkey );
2671 $sortkey = $this->getTargetLanguageConverter()->convertCategoryKey( $sortkey );
2672 $this->mOutput->addCategory( $nt->getDBkey(), $sortkey );
2674 continue;
2678 # Self-link checking. For some languages, variants of the title are checked in
2679 # LinkHolderArray::doVariants() to allow batching the existence checks necessary
2680 # for linking to a different variant.
2681 if ( $ns !== NS_SPECIAL && $nt->equals( $this->getTitle() ) ) {
2682 $s .= $prefix . Linker::makeSelfLinkObj( $nt, $text, '', $trail, '',
2683 Sanitizer::escapeIdForLink( $nt->getFragment() ) );
2684 continue;
2687 # NS_MEDIA is a pseudo-namespace for linking directly to a file
2688 # @todo FIXME: Should do batch file existence checks, see comment below
2689 if ( $ns === NS_MEDIA ) {
2690 # Give extensions a chance to select the file revision for us
2691 $options = [];
2692 $descQuery = false;
2693 $this->hookRunner->onBeforeParserFetchFileAndTitle(
2694 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
2695 $this, $nt, $options, $descQuery
2697 # Fetch and register the file (file title may be different via hooks)
2698 [ $file, $nt ] = $this->fetchFileAndTitle( $nt, $options );
2699 # Cloak with NOPARSE to avoid replacement in handleExternalLinks
2700 $s .= $prefix . $this->armorLinks(
2701 Linker::makeMediaLinkFile( $nt, $file, $text ) ) . $trail;
2702 continue;
2705 # Some titles, such as valid special pages or files in foreign repos, should
2706 # be shown as bluelinks even though they're not included in the page table
2707 # @todo FIXME: isAlwaysKnown() can be expensive for file links; we should really do
2708 # batch file existence checks for NS_FILE and NS_MEDIA
2709 if ( $iw == '' && $nt->isAlwaysKnown() ) {
2710 $this->mOutput->addLink( $nt );
2711 $s .= $this->makeKnownLinkHolder( $nt, $text, $trail, $prefix );
2712 } else {
2713 # Links will be added to the output link list after checking
2714 $s .= $holders->makeHolder( $nt, $text, $trail, $prefix );
2717 return $holders;
2721 * Render a forced-blue link inline; protect against double expansion of
2722 * URLs if we're in a mode that prepends full URL prefixes to internal links.
2723 * Since this little disaster has to split off the trail text to avoid
2724 * breaking URLs in the following text without breaking trails on the
2725 * wiki links, it's been made into a horrible function.
2727 * @param LinkTarget $nt
2728 * @param string $text
2729 * @param string $trail
2730 * @param string $prefix
2731 * @return string HTML-wikitext mix oh yuck
2733 private function makeKnownLinkHolder( LinkTarget $nt, $text = '', $trail = '', $prefix = '' ) {
2734 [ $inside, $trail ] = Linker::splitTrail( $trail );
2736 if ( $text == '' ) {
2737 $text = htmlspecialchars( $this->titleFormatter->getPrefixedText( $nt ) );
2740 $link = $this->getLinkRenderer()->makeKnownLink(
2741 $nt, new HtmlArmor( "$prefix$text$inside" )
2744 return $this->armorLinks( $link ) . $trail;
2748 * Insert a NOPARSE hacky thing into any inline links in a chunk that's
2749 * going to go through further parsing steps before inline URL expansion.
2751 * Not needed quite as much as it used to be since free links are a bit
2752 * more sensible these days. But bracketed links are still an issue.
2754 * @param string $text More-or-less HTML
2755 * @return string Less-or-more HTML with NOPARSE bits
2757 private function armorLinks( $text ) {
2758 return preg_replace( '/\b((?i)' . $this->urlUtils->validProtocols() . ')/',
2759 self::MARKER_PREFIX . "NOPARSE$1", $text );
2763 * Make lists from lines starting with ':', '*', '#', etc. (DBL)
2765 * @param string $text
2766 * @param bool $linestart Whether or not this is at the start of a line.
2767 * @internal
2768 * @return string The lists rendered as HTML
2769 * @deprecated since 1.35, will not be supported in future parsers
2771 public function doBlockLevels( $text, $linestart ) {
2772 wfDeprecated( __METHOD__, '1.35' );
2773 return BlockLevelPass::doBlockLevels( $text, $linestart );
2777 * Return value of a magic variable (like PAGENAME)
2779 * @param string $index Magic variable identifier as mapped in MagicWordFactory::$mVariableIDs
2780 * @param PPFrame|false $frame
2782 * @return string
2784 private function expandMagicVariable( $index, $frame = false ) {
2786 * Some of these require message or data lookups and can be
2787 * expensive to check many times.
2789 if ( isset( $this->mVarCache[$index] ) ) {
2790 return $this->mVarCache[$index];
2793 $ts = new MWTimestamp( $this->mOptions->getTimestamp() /* TS_MW */ );
2794 if ( $this->hookContainer->isRegistered( 'ParserGetVariableValueTs' ) ) {
2795 $s = $ts->getTimestamp( TS_UNIX );
2796 $this->hookRunner->onParserGetVariableValueTs( $this, $s );
2797 $ts = new MWTimestamp( $s );
2800 $value = CoreMagicVariables::expand(
2801 $this, $index, $ts, $this->svcOptions, $this->logger
2804 if ( $value === null ) {
2805 // Not a defined core magic word
2806 // Don't give this hook unrestricted access to mVarCache
2807 $fakeCache = [];
2808 $this->hookRunner->onParserGetVariableValueSwitch(
2809 // @phan-suppress-next-line PhanTypeMismatchArgument $value is passed as null but returned as string
2810 $this, $fakeCache, $index, $value, $frame
2812 // Cache the value returned by the hook by falling through here.
2813 // Assert the the hook returned a non-null value for this MV
2814 '@phan-var string $value';
2817 $this->mVarCache[$index] = $value;
2819 return $value;
2823 * Initialize the magic variables (like CURRENTMONTHNAME) and
2824 * substitution modifiers.
2826 private function initializeVariables() {
2827 $variableIDs = $this->magicWordFactory->getVariableIDs();
2828 $substIDs = $this->magicWordFactory->getSubstIDs();
2830 $this->mVariables = $this->magicWordFactory->newArray( $variableIDs );
2831 $this->mSubstWords = $this->magicWordFactory->newArray( $substIDs );
2835 * Get the document object model for the given wikitext
2837 * @see Preprocessor::preprocessToObj()
2839 * The generated DOM tree must depend only on the input text and the flags.
2840 * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a
2841 * regression of T6899.
2843 * Any flag added to the $flags parameter here, or any other parameter liable to cause a
2844 * change in the DOM tree for a given text, must be passed through the section identifier
2845 * in the section edit link and thus back to extractSections().
2847 * @param string $text Wikitext
2848 * @param int $flags Bit field of Preprocessor::DOM_* constants
2849 * @return PPNode
2850 * @since 1.23 method is public
2852 public function preprocessToDom( $text, $flags = 0 ) {
2853 return $this->getPreprocessor()->preprocessToObj( $text, $flags );
2857 * Replace magic variables, templates, and template arguments
2858 * with the appropriate text. Templates are substituted recursively,
2859 * taking care to avoid infinite loops.
2861 * Note that the substitution depends on value of $mOutputType:
2862 * self::OT_WIKI: only {{subst:}} templates
2863 * self::OT_PREPROCESS: templates but not extension tags
2864 * self::OT_HTML: all templates and extension tags
2866 * @param string $text The text to transform
2867 * @param false|PPFrame|array $frame Object describing the arguments passed to the
2868 * template. Arguments may also be provided as an associative array, as
2869 * was the usual case before MW1.12. Providing arguments this way may be
2870 * useful for extensions wishing to perform variable replacement
2871 * explicitly.
2872 * @param bool $argsOnly Only do argument (triple-brace) expansion, not
2873 * double-brace expansion.
2874 * @return string
2875 * @since 1.24 method is public
2877 public function replaceVariables( $text, $frame = false, $argsOnly = false ) {
2878 # Is there any text? Also, Prevent too big inclusions!
2879 $textSize = strlen( $text );
2880 if ( $textSize < 1 || $textSize > $this->mOptions->getMaxIncludeSize() ) {
2881 return $text;
2884 if ( $frame === false ) {
2885 $frame = $this->getPreprocessor()->newFrame();
2886 } elseif ( !( $frame instanceof PPFrame ) ) {
2887 $this->logger->debug(
2888 __METHOD__ . " called using plain parameters instead of " .
2889 "a PPFrame instance. Creating custom frame."
2891 $frame = $this->getPreprocessor()->newCustomFrame( $frame );
2894 $dom = $this->preprocessToDom( $text );
2895 $flags = $argsOnly ? PPFrame::NO_TEMPLATES : 0;
2896 $text = $frame->expand( $dom, $flags );
2898 return $text;
2902 * Warn the user when a parser limitation is reached
2903 * Will warn at most once the user per limitation type
2905 * The results are shown during preview and run through the Parser (See EditPage.php)
2907 * @param string $limitationType Should be one of:
2908 * 'expensive-parserfunction' (corresponding messages:
2909 * 'expensive-parserfunction-warning',
2910 * 'expensive-parserfunction-category')
2911 * 'post-expand-template-argument' (corresponding messages:
2912 * 'post-expand-template-argument-warning',
2913 * 'post-expand-template-argument-category')
2914 * 'post-expand-template-inclusion' (corresponding messages:
2915 * 'post-expand-template-inclusion-warning',
2916 * 'post-expand-template-inclusion-category')
2917 * 'node-count-exceeded' (corresponding messages:
2918 * 'node-count-exceeded-warning',
2919 * 'node-count-exceeded-category')
2920 * 'expansion-depth-exceeded' (corresponding messages:
2921 * 'expansion-depth-exceeded-warning',
2922 * 'expansion-depth-exceeded-category')
2923 * @param string|int|null $current Current value
2924 * @param string|int|null $max Maximum allowed, when an explicit limit has been
2925 * exceeded, provide the values (optional)
2926 * @internal
2928 public function limitationWarn( $limitationType, $current = '', $max = '' ) {
2929 # does no harm if $current and $max are present but are unnecessary for the message
2930 # Not doing ->inLanguage( $this->mOptions->getUserLangObj() ), since this is shown
2931 # only during preview, and that would split the parser cache unnecessarily.
2932 $this->mOutput->addWarningMsg(
2933 "$limitationType-warning",
2934 Message::numParam( $current ),
2935 Message::numParam( $max )
2937 $this->addTrackingCategory( "$limitationType-category" );
2941 * Return the text of a template, after recursively
2942 * replacing any variables or templates within the template.
2944 * @param array $piece The parts of the template
2945 * $piece['title']: the title, i.e. the part before the |
2946 * $piece['parts']: the parameter array
2947 * $piece['lineStart']: whether the brace was at the start of a line
2948 * @param PPFrame $frame The current frame, contains template arguments
2949 * @throws Exception
2950 * @return string|array The text of the template
2951 * @internal
2953 public function braceSubstitution( array $piece, PPFrame $frame ) {
2954 // Flags
2956 // $text has been filled
2957 $found = false;
2958 $text = '';
2959 // wiki markup in $text should be escaped
2960 $nowiki = false;
2961 // $text is HTML, armour it against wikitext transformation
2962 $isHTML = false;
2963 // Force interwiki transclusion to be done in raw mode not rendered
2964 $forceRawInterwiki = false;
2965 // $text is a DOM node needing expansion in a child frame
2966 $isChildObj = false;
2967 // $text is a DOM node needing expansion in the current frame
2968 $isLocalObj = false;
2970 # Title object, where $text came from
2971 $title = false;
2973 # $part1 is the bit before the first |, and must contain only title characters.
2974 # Various prefixes will be stripped from it later.
2975 $titleWithSpaces = $frame->expand( $piece['title'] );
2976 $part1 = trim( $titleWithSpaces );
2977 $titleText = false;
2979 # Original title text preserved for various purposes
2980 $originalTitle = $part1;
2982 # $args is a list of argument nodes, starting from index 0, not including $part1
2983 # @todo FIXME: If piece['parts'] is null then the call to getLength()
2984 # below won't work b/c this $args isn't an object
2985 $args = ( $piece['parts'] == null ) ? [] : $piece['parts'];
2987 $profileSection = null; // profile templates
2989 $sawDeprecatedTemplateEquals = false; // T91154
2991 # SUBST
2992 // @phan-suppress-next-line PhanImpossibleCondition
2993 if ( !$found ) {
2994 $substMatch = $this->mSubstWords->matchStartAndRemove( $part1 );
2995 $part1 = trim( $part1 );
2997 # Possibilities for substMatch: "subst", "safesubst" or FALSE
2998 # Decide whether to expand template or keep wikitext as-is.
2999 if ( $this->ot['wiki'] ) {
3000 if ( $substMatch === false ) {
3001 $literal = true; # literal when in PST with no prefix
3002 } else {
3003 $literal = false; # expand when in PST with subst: or safesubst:
3005 } else {
3006 if ( $substMatch == 'subst' ) {
3007 $literal = true; # literal when not in PST with plain subst:
3008 } else {
3009 $literal = false; # expand when not in PST with safesubst: or no prefix
3012 if ( $literal ) {
3013 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3014 $isLocalObj = true;
3015 $found = true;
3019 # Variables
3020 if ( !$found && $args->getLength() == 0 ) {
3021 $id = $this->mVariables->matchStartToEnd( $part1 );
3022 if ( $id !== false ) {
3023 if ( strpos( $part1, ':' ) !== false ) {
3024 wfDeprecatedMsg(
3025 'Registering a magic variable with a name including a colon',
3026 '1.39', false, false
3029 $text = $this->expandMagicVariable( $id, $frame );
3030 $found = true;
3034 # MSG, MSGNW and RAW
3035 if ( !$found ) {
3036 # Check for MSGNW:
3037 $mwMsgnw = $this->magicWordFactory->get( 'msgnw' );
3038 if ( $mwMsgnw->matchStartAndRemove( $part1 ) ) {
3039 $nowiki = true;
3040 } else {
3041 # Remove obsolete MSG:
3042 $mwMsg = $this->magicWordFactory->get( 'msg' );
3043 $mwMsg->matchStartAndRemove( $part1 );
3046 # Check for RAW:
3047 $mwRaw = $this->magicWordFactory->get( 'raw' );
3048 if ( $mwRaw->matchStartAndRemove( $part1 ) ) {
3049 $forceRawInterwiki = true;
3053 # Parser functions
3054 if ( !$found ) {
3055 $colonPos = strpos( $part1, ':' );
3056 if ( $colonPos !== false ) {
3057 $func = substr( $part1, 0, $colonPos );
3058 $funcArgs = [ trim( substr( $part1, $colonPos + 1 ) ) ];
3059 $argsLength = $args->getLength();
3060 for ( $i = 0; $i < $argsLength; $i++ ) {
3061 $funcArgs[] = $args->item( $i );
3064 $result = $this->callParserFunction( $frame, $func, $funcArgs );
3066 // Extract any forwarded flags
3067 if ( isset( $result['title'] ) ) {
3068 $title = $result['title'];
3070 if ( isset( $result['found'] ) ) {
3071 $found = $result['found'];
3073 if ( array_key_exists( 'text', $result ) ) {
3074 // a string or null
3075 $text = $result['text'];
3077 if ( isset( $result['nowiki'] ) ) {
3078 $nowiki = $result['nowiki'];
3080 if ( isset( $result['isHTML'] ) ) {
3081 $isHTML = $result['isHTML'];
3083 if ( isset( $result['forceRawInterwiki'] ) ) {
3084 $forceRawInterwiki = $result['forceRawInterwiki'];
3086 if ( isset( $result['isChildObj'] ) ) {
3087 $isChildObj = $result['isChildObj'];
3089 if ( isset( $result['isLocalObj'] ) ) {
3090 $isLocalObj = $result['isLocalObj'];
3095 # Finish mangling title and then check for loops.
3096 # Set $title to a Title object and $titleText to the PDBK
3097 if ( !$found ) {
3098 $ns = NS_TEMPLATE;
3099 # Split the title into page and subpage
3100 $subpage = '';
3101 $relative = Linker::normalizeSubpageLink(
3102 $this->getTitle(), $part1, $subpage
3104 if ( $part1 !== $relative ) {
3105 $part1 = $relative;
3106 $ns = $this->getTitle()->getNamespace();
3108 $title = Title::newFromText( $part1, $ns );
3109 if ( $title ) {
3110 $titleText = $title->getPrefixedText();
3111 # Check for language variants if the template is not found
3112 if ( $this->getTargetLanguageConverter()->hasVariants() && $title->getArticleID() == 0 ) {
3113 $this->getTargetLanguageConverter()->findVariantLink( $part1, $title, true );
3115 # Do recursion depth check
3116 $limit = $this->mOptions->getMaxTemplateDepth();
3117 if ( $frame->depth >= $limit ) {
3118 $found = true;
3119 $text = '<span class="error">'
3120 . wfMessage( 'parser-template-recursion-depth-warning' )
3121 ->numParams( $limit )->inContentLanguage()->text()
3122 . '</span>';
3127 # Load from database
3128 if ( !$found && $title ) {
3129 $profileSection = $this->mProfiler->scopedProfileIn( $title->getPrefixedDBkey() );
3130 if ( !$title->isExternal() ) {
3131 if ( $title->isSpecialPage()
3132 && $this->mOptions->getAllowSpecialInclusion()
3133 && $this->ot['html']
3135 $specialPage = $this->specialPageFactory->getPage( $title->getDBkey() );
3136 // Pass the template arguments as URL parameters.
3137 // "uselang" will have no effect since the Language object
3138 // is forced to the one defined in ParserOptions.
3139 $pageArgs = [];
3140 $argsLength = $args->getLength();
3141 for ( $i = 0; $i < $argsLength; $i++ ) {
3142 $bits = $args->item( $i )->splitArg();
3143 if ( strval( $bits['index'] ) === '' ) {
3144 $name = trim( $frame->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
3145 $value = trim( $frame->expand( $bits['value'] ) );
3146 $pageArgs[$name] = $value;
3150 // Create a new context to execute the special page
3151 $context = new RequestContext;
3152 $context->setTitle( $title );
3153 $context->setRequest( new FauxRequest( $pageArgs ) );
3154 if ( $specialPage && $specialPage->maxIncludeCacheTime() === 0 ) {
3155 $context->setUser( $this->userFactory->newFromUserIdentity( $this->getUserIdentity() ) );
3156 } else {
3157 // If this page is cached, then we better not be per user.
3158 $context->setUser( User::newFromName( '127.0.0.1', false ) );
3160 $context->setLanguage( $this->mOptions->getUserLangObj() );
3161 $ret = $this->specialPageFactory->capturePath( $title, $context, $this->getLinkRenderer() );
3162 if ( $ret ) {
3163 $text = $context->getOutput()->getHTML();
3164 $this->mOutput->addOutputPageMetadata( $context->getOutput() );
3165 $found = true;
3166 $isHTML = true;
3167 if ( $specialPage && $specialPage->maxIncludeCacheTime() !== false ) {
3168 $this->mOutput->updateRuntimeAdaptiveExpiry(
3169 $specialPage->maxIncludeCacheTime()
3173 } elseif ( $this->nsInfo->isNonincludable( $title->getNamespace() ) ) {
3174 $found = false; # access denied
3175 $this->logger->debug(
3176 __METHOD__ .
3177 ": template inclusion denied for " . $title->getPrefixedDBkey()
3179 } else {
3180 [ $text, $title ] = $this->getTemplateDom( $title );
3181 if ( $text !== false ) {
3182 $found = true;
3183 $isChildObj = true;
3184 if (
3185 $title->getNamespace() === NS_TEMPLATE &&
3186 $title->getDBkey() === '=' &&
3187 $originalTitle === '='
3189 // Note that we won't get here if `=` is evaluated
3190 // (in the future) as a parser function, nor if
3191 // the Template namespace is given explicitly,
3192 // ie `{{Template:=}}`. Only `{{=}}` triggers.
3193 $sawDeprecatedTemplateEquals = true; // T91154
3198 # If the title is valid but undisplayable, make a link to it
3199 if ( !$found && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3200 $text = "[[:$titleText]]";
3201 $found = true;
3203 } elseif ( $title->isTrans() ) {
3204 # Interwiki transclusion
3205 if ( $this->ot['html'] && !$forceRawInterwiki ) {
3206 $text = $this->interwikiTransclude( $title, 'render' );
3207 $isHTML = true;
3208 } else {
3209 $text = $this->interwikiTransclude( $title, 'raw' );
3210 # Preprocess it like a template
3211 $text = $this->preprocessToDom( $text, Preprocessor::DOM_FOR_INCLUSION );
3212 $isChildObj = true;
3214 $found = true;
3217 # Do infinite loop check
3218 # This has to be done after redirect resolution to avoid infinite loops via redirects
3219 if ( !$frame->loopCheck( $title ) ) {
3220 $found = true;
3221 $text = '<span class="error">'
3222 . wfMessage( 'parser-template-loop-warning', $titleText )->inContentLanguage()->text()
3223 . '</span>';
3224 $this->addTrackingCategory( 'template-loop-category' );
3225 $this->mOutput->addWarningMsg(
3226 'template-loop-warning',
3227 Message::plaintextParam( $titleText )
3229 $this->logger->debug( __METHOD__ . ": template loop broken at '$titleText'" );
3233 # If we haven't found text to substitute by now, we're done
3234 # Recover the source wikitext and return it
3235 if ( !$found ) {
3236 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3237 if ( $profileSection ) {
3238 $this->mProfiler->scopedProfileOut( $profileSection );
3240 return [ 'object' => $text ];
3243 # Expand DOM-style return values in a child frame
3244 if ( $isChildObj ) {
3245 # Clean up argument array
3246 $newFrame = $frame->newChild( $args, $title );
3248 if ( $nowiki ) {
3249 $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
3250 } elseif ( $titleText !== false && $newFrame->isEmpty() ) {
3251 # Expansion is eligible for the empty-frame cache
3252 $text = $newFrame->cachedExpand( $titleText, $text );
3253 } else {
3254 # Uncached expansion
3255 $text = $newFrame->expand( $text );
3258 if ( $isLocalObj && $nowiki ) {
3259 $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
3260 $isLocalObj = false;
3263 if ( $profileSection ) {
3264 $this->mProfiler->scopedProfileOut( $profileSection );
3266 if (
3267 $sawDeprecatedTemplateEquals &&
3268 $this->mStripState->unstripBoth( $text ) !== '='
3270 // T91154: {{=}} is deprecated when it doesn't expand to `=`;
3271 // use {{Template:=}} if you must.
3272 $this->addTrackingCategory( 'template-equals-category' );
3273 $this->mOutput->addWarningMsg( 'template-equals-warning' );
3276 # Replace raw HTML by a placeholder
3277 if ( $isHTML ) {
3278 $text = $this->insertStripItem( $text );
3279 } elseif ( $nowiki && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3280 # Escape nowiki-style return values
3281 $text = wfEscapeWikiText( $text );
3282 } elseif ( is_string( $text )
3283 && !$piece['lineStart']
3284 && preg_match( '/^(?:{\\||:|;|#|\*)/', $text )
3286 # T2529: if the template begins with a table or block-level
3287 # element, it should be treated as beginning a new line.
3288 # This behavior is somewhat controversial.
3289 $text = "\n" . $text;
3292 if ( is_string( $text ) && !$this->incrementIncludeSize( 'post-expand', strlen( $text ) ) ) {
3293 # Error, oversize inclusion
3294 if ( $titleText !== false ) {
3295 # Make a working, properly escaped link if possible (T25588)
3296 $text = "[[:$titleText]]";
3297 } else {
3298 # This will probably not be a working link, but at least it may
3299 # provide some hint of where the problem is
3300 $originalTitle = preg_replace( '/^:/', '', $originalTitle );
3301 $text = "[[:$originalTitle]]";
3303 $text .= $this->insertStripItem( '<!-- WARNING: template omitted, '
3304 . 'post-expand include size too large -->' );
3305 $this->limitationWarn( 'post-expand-template-inclusion' );
3308 if ( $isLocalObj ) {
3309 $ret = [ 'object' => $text ];
3310 } else {
3311 $ret = [ 'text' => $text ];
3314 return $ret;
3318 * Call a parser function and return an array with text and flags.
3320 * The returned array will always contain a boolean 'found', indicating
3321 * whether the parser function was found or not. It may also contain the
3322 * following:
3323 * text: string|object, resulting wikitext or PP DOM object
3324 * isHTML: bool, $text is HTML, armour it against wikitext transformation
3325 * isChildObj: bool, $text is a DOM node needing expansion in a child frame
3326 * isLocalObj: bool, $text is a DOM node needing expansion in the current frame
3327 * nowiki: bool, wiki markup in $text should be escaped
3329 * @since 1.21
3330 * @param PPFrame $frame The current frame, contains template arguments
3331 * @param string $function Function name
3332 * @param array $args Arguments to the function
3333 * @return array
3335 public function callParserFunction( PPFrame $frame, $function, array $args = [] ) {
3336 # Case sensitive functions
3337 if ( isset( $this->mFunctionSynonyms[1][$function] ) ) {
3338 $function = $this->mFunctionSynonyms[1][$function];
3339 } else {
3340 # Case insensitive functions
3341 $function = $this->contLang->lc( $function );
3342 if ( isset( $this->mFunctionSynonyms[0][$function] ) ) {
3343 $function = $this->mFunctionSynonyms[0][$function];
3344 } else {
3345 return [ 'found' => false ];
3349 [ $callback, $flags ] = $this->mFunctionHooks[$function];
3351 $allArgs = [ $this ];
3352 if ( $flags & self::SFH_OBJECT_ARGS ) {
3353 # Convert arguments to PPNodes and collect for appending to $allArgs
3354 $funcArgs = [];
3355 foreach ( $args as $k => $v ) {
3356 if ( $v instanceof PPNode || $k === 0 ) {
3357 $funcArgs[] = $v;
3358 } else {
3359 $funcArgs[] = $this->mPreprocessor->newPartNodeArray( [ $k => $v ] )->item( 0 );
3363 # Add a frame parameter, and pass the arguments as an array
3364 $allArgs[] = $frame;
3365 $allArgs[] = $funcArgs;
3366 } else {
3367 # Convert arguments to plain text and append to $allArgs
3368 foreach ( $args as $k => $v ) {
3369 if ( $v instanceof PPNode ) {
3370 $allArgs[] = trim( $frame->expand( $v ) );
3371 } elseif ( is_int( $k ) && $k >= 0 ) {
3372 $allArgs[] = trim( $v );
3373 } else {
3374 $allArgs[] = trim( "$k=$v" );
3379 $result = $callback( ...$allArgs );
3381 # The interface for function hooks allows them to return a wikitext
3382 # string or an array containing the string and any flags. This mungs
3383 # things around to match what this method should return.
3384 if ( !is_array( $result ) ) {
3385 $result = [
3386 'found' => true,
3387 'text' => $result,
3389 } else {
3390 if ( isset( $result[0] ) && !isset( $result['text'] ) ) {
3391 $result['text'] = $result[0];
3393 unset( $result[0] );
3394 $result += [
3395 'found' => true,
3399 $noparse = true;
3400 $preprocessFlags = 0;
3401 if ( isset( $result['noparse'] ) ) {
3402 $noparse = $result['noparse'];
3404 if ( isset( $result['preprocessFlags'] ) ) {
3405 $preprocessFlags = $result['preprocessFlags'];
3408 if ( !$noparse ) {
3409 $result['text'] = $this->preprocessToDom( $result['text'], $preprocessFlags );
3410 $result['isChildObj'] = true;
3413 return $result;
3417 * Get the semi-parsed DOM representation of a template with a given title,
3418 * and its redirect destination title. Cached.
3420 * @param LinkTarget $title
3422 * @return array
3423 * @since 1.12
3425 public function getTemplateDom( LinkTarget $title ) {
3426 $cacheTitle = $title;
3427 $titleKey = CacheKeyHelper::getKeyForPage( $title );
3429 if ( isset( $this->mTplRedirCache[$titleKey] ) ) {
3430 [ $ns, $dbk ] = $this->mTplRedirCache[$titleKey];
3431 $title = Title::makeTitle( $ns, $dbk );
3432 $titleKey = CacheKeyHelper::getKeyForPage( $title );
3434 if ( isset( $this->mTplDomCache[$titleKey] ) ) {
3435 return [ $this->mTplDomCache[$titleKey], $title ];
3438 # Cache miss, go to the database
3439 [ $text, $title ] = $this->fetchTemplateAndTitle( $title );
3441 if ( $text === false ) {
3442 $this->mTplDomCache[$titleKey] = false;
3443 return [ false, $title ];
3446 $dom = $this->preprocessToDom( $text, Preprocessor::DOM_FOR_INCLUSION );
3447 $this->mTplDomCache[$titleKey] = $dom;
3449 if ( !$title->isSamePageAs( $cacheTitle ) ) {
3450 $this->mTplRedirCache[ CacheKeyHelper::getKeyForPage( $cacheTitle ) ] =
3451 [ $title->getNamespace(), $title->getDBkey() ];
3454 return [ $dom, $title ];
3458 * Fetch the current revision of a given title as a RevisionRecord.
3459 * Note that the revision (and even the title) may not exist in the database,
3460 * so everything contributing to the output of the parser should use this method
3461 * where possible, rather than getting the revisions themselves. This
3462 * method also caches its results, so using it benefits performance.
3464 * This can return null if the callback returns false
3466 * @since 1.35
3467 * @param LinkTarget $link
3468 * @return RevisionRecord|null
3470 public function fetchCurrentRevisionRecordOfTitle( LinkTarget $link ) {
3471 $cacheKey = CacheKeyHelper::getKeyForPage( $link );
3472 if ( !$this->currentRevisionCache ) {
3473 $this->currentRevisionCache = new MapCacheLRU( 100 );
3475 if ( !$this->currentRevisionCache->has( $cacheKey ) ) {
3476 $title = Title::newFromLinkTarget( $link ); // hook signature compat
3477 $revisionRecord =
3478 // Defaults to Parser::statelessFetchRevisionRecord()
3479 call_user_func(
3480 $this->mOptions->getCurrentRevisionRecordCallback(),
3481 $title,
3482 $this
3484 if ( $revisionRecord === false ) {
3485 // Parser::statelessFetchRevisionRecord() can return false;
3486 // normalize it to null.
3487 $revisionRecord = null;
3489 $this->currentRevisionCache->set( $cacheKey, $revisionRecord );
3491 return $this->currentRevisionCache->get( $cacheKey );
3495 * @param LinkTarget $link
3496 * @return bool
3497 * @since 1.34
3498 * @internal
3500 public function isCurrentRevisionOfTitleCached( LinkTarget $link ) {
3501 $key = CacheKeyHelper::getKeyForPage( $link );
3502 return (
3503 $this->currentRevisionCache &&
3504 $this->currentRevisionCache->has( $key )
3509 * Wrapper around RevisionLookup::getKnownCurrentRevision
3511 * @since 1.34
3512 * @param LinkTarget $link
3513 * @param Parser|null $parser
3514 * @return RevisionRecord|false False if missing
3516 public static function statelessFetchRevisionRecord( LinkTarget $link, $parser = null ) {
3517 if ( $link instanceof PageIdentity ) {
3518 // probably a Title, just use it.
3519 $page = $link;
3520 } else {
3521 // XXX: use RevisionStore::getPageForLink()!
3522 // ...but get the info for the current revision at the same time?
3523 // Should RevisionStore::getKnownCurrentRevision accept a LinkTarget?
3524 $page = Title::newFromLinkTarget( $link );
3527 $revRecord = MediaWikiServices::getInstance()
3528 ->getRevisionLookup()
3529 ->getKnownCurrentRevision( $page );
3530 return $revRecord;
3534 * Fetch the unparsed text of a template and register a reference to it.
3535 * @param LinkTarget $link
3536 * @return array ( string or false, Title )
3537 * @since 1.11
3539 public function fetchTemplateAndTitle( LinkTarget $link ) {
3540 // Use Title for compatibility with callbacks and return type
3541 $title = Title::newFromLinkTarget( $link );
3543 // Defaults to Parser::statelessFetchTemplate()
3544 $templateCb = $this->mOptions->getTemplateCallback();
3545 $stuff = $templateCb( $title, $this );
3546 $revRecord = $stuff['revision-record'] ?? null;
3548 $text = $stuff['text'];
3549 if ( is_string( $stuff['text'] ) ) {
3550 // We use U+007F DELETE to distinguish strip markers from regular text
3551 $text = strtr( $text, "\x7f", "?" );
3553 $finalTitle = $stuff['finalTitle'] ?? $title;
3554 foreach ( ( $stuff['deps'] ?? [] ) as $dep ) {
3555 $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] );
3556 if ( $dep['title']->equals( $this->getTitle() ) && $revRecord instanceof RevisionRecord ) {
3557 // Self-transclusion; final result may change based on the new page version
3558 try {
3559 $sha1 = $revRecord->getSha1();
3560 } catch ( RevisionAccessException $e ) {
3561 $sha1 = null;
3563 $this->setOutputFlag( ParserOutputFlags::VARY_REVISION_SHA1, 'Self transclusion' );
3564 $this->getOutput()->setRevisionUsedSha1Base36( $sha1 );
3568 return [ $text, $finalTitle ];
3572 * Static function to get a template
3573 * Can be overridden via ParserOptions::setTemplateCallback().
3575 * @param LinkTarget $page
3576 * @param Parser|false $parser
3578 * @return array
3579 * @since 1.12
3581 public static function statelessFetchTemplate( $page, $parser = false ) {
3582 $title = Title::castFromLinkTarget( $page ); // for compatibility with return type
3583 $text = $skip = false;
3584 $finalTitle = $title;
3585 $deps = [];
3586 $revRecord = null;
3587 $contextTitle = $parser ? $parser->getTitle() : null;
3589 $services = MediaWikiServices::getInstance();
3590 # Loop to fetch the article, with up to 2 redirects
3591 $revLookup = $services->getRevisionLookup();
3592 $hookRunner = new HookRunner( $services->getHookContainer() );
3593 for ( $i = 0; $i < 3 && is_object( $title ); $i++ ) {
3594 # Give extensions a chance to select the revision instead
3595 $revRecord = null; # Assume no hook
3596 $id = false; # Assume current
3597 $origTitle = $title;
3598 $titleChanged = false;
3599 $hookRunner->onBeforeParserFetchTemplateRevisionRecord(
3600 # The $title is a not a PageIdentity, as it may
3601 # contain fragments or even represent an attempt to transclude
3602 # a broken or otherwise-missing Title, which the hook may
3603 # fix up. Similarly, the $contextTitle may represent a special
3604 # page or other page which "exists" as a parsing context but
3605 # is not in the DB.
3606 $contextTitle, $title,
3607 $skip, $revRecord
3610 if ( $skip ) {
3611 $text = false;
3612 $deps[] = [
3613 'title' => $title,
3614 'page_id' => $title->getArticleID(),
3615 'rev_id' => null
3617 break;
3619 # Get the revision
3620 if ( !$revRecord ) {
3621 if ( $id ) {
3622 # Handle $id returned by deprecated legacy hook
3623 $revRecord = $revLookup->getRevisionById( $id );
3624 } elseif ( $parser ) {
3625 $revRecord = $parser->fetchCurrentRevisionRecordOfTitle( $title );
3626 } else {
3627 $revRecord = $revLookup->getRevisionByTitle( $title );
3630 if ( $revRecord ) {
3631 # Update title, as $revRecord may have been changed by hook
3632 $title = Title::newFromLinkTarget(
3633 $revRecord->getPageAsLinkTarget()
3635 $deps[] = [
3636 'title' => $title,
3637 'page_id' => $revRecord->getPageId(),
3638 'rev_id' => $revRecord->getId(),
3640 } else {
3641 $deps[] = [
3642 'title' => $title,
3643 'page_id' => $title->getArticleID(),
3644 'rev_id' => null,
3647 if ( !$title->equals( $origTitle ) ) {
3648 # If we fetched a rev from a different title, register
3649 # the original title too...
3650 $deps[] = [
3651 'title' => $origTitle,
3652 'page_id' => $origTitle->getArticleID(),
3653 'rev_id' => null,
3655 $titleChanged = true;
3657 # If there is no current revision, there is no page
3658 if ( $revRecord === null || $revRecord->getId() === null ) {
3659 $linkCache = $services->getLinkCache();
3660 $linkCache->addBadLinkObj( $title );
3662 if ( $revRecord ) {
3663 if ( $titleChanged && !$revRecord->hasSlot( SlotRecord::MAIN ) ) {
3664 // We've added this (missing) title to the dependencies;
3665 // give the hook another chance to redirect it to an
3666 // actual page.
3667 $text = false;
3668 $finalTitle = $title;
3669 continue;
3671 if ( $revRecord->hasSlot( SlotRecord::MAIN ) ) { // T276476
3672 $content = $revRecord->getContent( SlotRecord::MAIN );
3673 $text = $content ? $content->getWikitextForTransclusion() : null;
3674 } else {
3675 $text = false;
3678 if ( $text === false || $text === null ) {
3679 $text = false;
3680 break;
3682 } elseif ( $title->getNamespace() === NS_MEDIAWIKI ) {
3683 $message = wfMessage( $services->getContentLanguage()->
3684 lcfirst( $title->getText() ) )->inContentLanguage();
3685 if ( !$message->exists() ) {
3686 $text = false;
3687 break;
3689 $text = $message->plain();
3690 break;
3691 } else {
3692 break;
3694 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Only reached when content is set
3695 if ( !$content ) {
3696 break;
3698 # Redirect?
3699 $finalTitle = $title;
3700 $title = $content->getRedirectTarget();
3703 $retValues = [
3704 // previously, when this also returned a Revision object, we set
3705 // 'revision-record' to false instead of null if it was unavailable,
3706 // so that callers to use isset and then rely on the revision-record
3707 // key instead of the revision key, even if there was no corresponding
3708 // object - we continue to set to false here for backwards compatability
3709 'revision-record' => $revRecord ?: false,
3710 'text' => $text,
3711 'finalTitle' => $finalTitle,
3712 'deps' => $deps
3714 return $retValues;
3718 * Fetch a file and its title and register a reference to it.
3719 * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
3720 * @param LinkTarget $link
3721 * @param array $options Array of options to RepoGroup::findFile
3722 * @return array ( File or false, Title of file )
3723 * @since 1.18
3725 public function fetchFileAndTitle( LinkTarget $link, array $options = [] ) {
3726 $file = $this->fetchFileNoRegister( $link, $options );
3728 $time = $file ? $file->getTimestamp() : false;
3729 $sha1 = $file ? $file->getSha1() : false;
3730 # Register the file as a dependency...
3731 $this->mOutput->addImage( $link->getDBkey(), $time, $sha1 );
3732 if ( $file && !$link->isSameLinkAs( $file->getTitle() ) ) {
3733 # Update fetched file title after resolving redirects, etc.
3734 $link = $file->getTitle();
3735 $this->mOutput->addImage( $link->getDBkey(), $time, $sha1 );
3738 $title = Title::newFromLinkTarget( $link ); // for return type compat
3739 return [ $file, $title ];
3743 * Helper function for fetchFileAndTitle.
3745 * Also useful if you need to fetch a file but not use it yet,
3746 * for example to get the file's handler.
3748 * @param LinkTarget $link
3749 * @param array $options Array of options to RepoGroup::findFile
3750 * @return File|false
3752 protected function fetchFileNoRegister( LinkTarget $link, array $options = [] ) {
3753 if ( isset( $options['broken'] ) ) {
3754 $file = false; // broken thumbnail forced by hook
3755 } else {
3756 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
3757 if ( isset( $options['sha1'] ) ) { // get by (sha1,timestamp)
3758 $file = $repoGroup->findFileFromKey( $options['sha1'], $options );
3759 } else { // get by (name,timestamp)
3760 $file = $repoGroup->findFile( $link, $options );
3763 return $file;
3767 * Transclude an interwiki link.
3769 * @param LinkTarget $link
3770 * @param string $action Usually one of (raw, render)
3772 * @return string
3773 * @internal
3775 public function interwikiTransclude( LinkTarget $link, $action ) {
3776 if ( !$this->svcOptions->get( MainConfigNames::EnableScaryTranscluding ) ) {
3777 return wfMessage( 'scarytranscludedisabled' )->inContentLanguage()->text();
3780 // TODO: extract relevant functionality from Title
3781 $title = Title::newFromLinkTarget( $link );
3783 $url = $title->getFullURL( [ 'action' => $action ] );
3784 if ( strlen( $url ) > 1024 ) {
3785 return wfMessage( 'scarytranscludetoolong' )->inContentLanguage()->text();
3788 $wikiId = $title->getTransWikiID(); // remote wiki ID or false
3790 $fname = __METHOD__;
3792 $cache = $this->wanCache;
3793 $data = $cache->getWithSetCallback(
3794 $cache->makeGlobalKey(
3795 'interwiki-transclude',
3796 ( $wikiId !== false ) ? $wikiId : 'external',
3797 sha1( $url )
3799 $this->svcOptions->get( MainConfigNames::TranscludeCacheExpiry ),
3800 function ( $oldValue, &$ttl ) use ( $url, $fname, $cache ) {
3801 $req = $this->httpRequestFactory->create( $url, [], $fname );
3803 $status = $req->execute(); // Status object
3804 if ( !$status->isOK() ) {
3805 $ttl = $cache::TTL_UNCACHEABLE;
3806 } elseif ( $req->getResponseHeader( 'X-Database-Lagged' ) !== null ) {
3807 $ttl = min( $cache::TTL_LAGGED, $ttl );
3810 return [
3811 'text' => $status->isOK() ? $req->getContent() : null,
3812 'code' => $req->getStatus()
3816 'checkKeys' => ( $wikiId !== false )
3817 ? [ $cache->makeGlobalKey( 'interwiki-page', $wikiId, $title->getDBkey() ) ]
3818 : [],
3819 'pcGroup' => 'interwiki-transclude:5',
3820 'pcTTL' => $cache::TTL_PROC_LONG
3824 if ( is_string( $data['text'] ) ) {
3825 $text = $data['text'];
3826 } elseif ( $data['code'] != 200 ) {
3827 // Though we failed to fetch the content, this status is useless.
3828 $text = wfMessage( 'scarytranscludefailed-httpstatus' )
3829 ->params( $url, $data['code'] )->inContentLanguage()->text();
3830 } else {
3831 $text = wfMessage( 'scarytranscludefailed', $url )->inContentLanguage()->text();
3834 return $text;
3838 * Triple brace replacement -- used for template arguments
3839 * @internal
3841 * @param array $piece
3842 * @param PPFrame $frame
3844 * @return array
3845 * @internal
3847 public function argSubstitution( array $piece, PPFrame $frame ) {
3848 $error = false;
3849 $parts = $piece['parts'];
3850 $nameWithSpaces = $frame->expand( $piece['title'] );
3851 $argName = trim( $nameWithSpaces );
3852 $object = false;
3853 $text = $frame->getArgument( $argName );
3854 if ( $text === false && $parts->getLength() > 0
3855 && ( $this->ot['html']
3856 || $this->ot['pre']
3857 || ( $this->ot['wiki'] && $frame->isTemplate() )
3860 # No match in frame, use the supplied default
3861 $object = $parts->item( 0 )->getChildren();
3863 if ( !$this->incrementIncludeSize( 'arg', strlen( $text ) ) ) {
3864 $error = '<!-- WARNING: argument omitted, expansion size too large -->';
3865 $this->limitationWarn( 'post-expand-template-argument' );
3868 if ( $text === false && $object === false ) {
3869 # No match anywhere
3870 $object = $frame->virtualBracketedImplode( '{{{', '|', '}}}', $nameWithSpaces, $parts );
3872 if ( $error !== false ) {
3873 $text .= $error;
3875 if ( $object !== false ) {
3876 $ret = [ 'object' => $object ];
3877 } else {
3878 $ret = [ 'text' => $text ];
3881 return $ret;
3885 * @param string $lowerTagName
3886 * @return bool
3888 public function tagNeedsNowikiStrippedInTagPF( string $lowerTagName ): bool {
3889 $parsoidSiteConfig = MediaWikiServices::getInstance()->getParsoidSiteConfig();
3890 return $parsoidSiteConfig->tagNeedsNowikiStrippedInTagPF( $lowerTagName );
3894 * Return the text to be used for a given extension tag.
3895 * This is the ghost of strip().
3897 * @param array $params Associative array of parameters:
3898 * name PPNode for the tag name
3899 * attr PPNode for unparsed text where tag attributes are thought to be
3900 * attributes Optional associative array of parsed attributes
3901 * inner Contents of extension element
3902 * noClose Original text did not have a close tag
3903 * @param PPFrame $frame
3904 * @param bool $processNowiki Process nowiki tags by running the nowiki tag handler
3905 * Normally, nowikis are only processed for the HTML output type. With this
3906 * arg set to true, they are processed (and converted to a nowiki strip marker)
3907 * for all output types.
3908 * @return string
3909 * @internal
3910 * @since 1.12
3912 public function extensionSubstitution( array $params, PPFrame $frame, bool $processNowiki = false ) {
3913 static $errorStr = '<span class="error">';
3915 $name = $frame->expand( $params['name'] );
3916 if ( str_starts_with( $name, $errorStr ) ) {
3917 // Probably expansion depth or node count exceeded. Just punt the
3918 // error up.
3919 return $name;
3922 // Parse attributes from XML-like wikitext syntax
3923 $attrText = !isset( $params['attr'] ) ? '' : $frame->expand( $params['attr'] );
3924 if ( str_starts_with( $attrText, $errorStr ) ) {
3925 // See above
3926 return $attrText;
3929 // We can't safely check if the expansion for $content resulted in an
3930 // error, because the content could happen to be the error string
3931 // (T149622).
3932 $content = !isset( $params['inner'] ) ? null : $frame->expand( $params['inner'] );
3934 $marker = self::MARKER_PREFIX . "-$name-"
3935 . sprintf( '%08X', $this->mMarkerIndex++ ) . self::MARKER_SUFFIX;
3937 $normalizedName = strtolower( $name );
3938 $isNowiki = $normalizedName === 'nowiki';
3939 $markerType = $isNowiki ? 'nowiki' : 'general';
3940 if ( $this->ot['html'] || ( $processNowiki && $isNowiki ) ) {
3941 $attributes = Sanitizer::decodeTagAttributes( $attrText );
3942 // Merge in attributes passed via {{#tag:}} parser function
3943 if ( isset( $params['attributes'] ) ) {
3944 $attributes += $params['attributes'];
3947 if ( isset( $this->mTagHooks[$normalizedName] ) ) {
3948 // Note that $content may be null here, for example if the
3949 // tag is self-closed.
3950 $output = call_user_func_array( $this->mTagHooks[$normalizedName],
3951 [ $content, $attributes, $this, $frame ] );
3952 } else {
3953 $output = '<span class="error">Invalid tag extension name: ' .
3954 htmlspecialchars( $normalizedName ) . '</span>';
3957 if ( is_array( $output ) ) {
3958 // Extract flags
3959 $flags = $output;
3960 $output = $flags[0];
3961 if ( isset( $flags['markerType'] ) ) {
3962 $markerType = $flags['markerType'];
3965 } else {
3966 // We're substituting a {{subst:#tag:}} parser function.
3967 // Convert the attributes it passed into the XML-like string.
3968 if ( isset( $params['attributes'] ) ) {
3969 foreach ( $params['attributes'] as $attrName => $attrValue ) {
3970 $attrText .= ' ' . htmlspecialchars( $attrName ) . '="' .
3971 htmlspecialchars( $attrValue, ENT_COMPAT ) . '"';
3974 if ( $content === null ) {
3975 $output = "<$name$attrText/>";
3976 } else {
3977 $close = $params['close'] === null ? '' : $frame->expand( $params['close'] );
3978 if ( str_starts_with( $close, $errorStr ) ) {
3979 // See above
3980 return $close;
3982 $output = "<$name$attrText>$content$close";
3986 if ( $markerType === 'none' ) {
3987 return $output;
3988 } elseif ( $markerType === 'nowiki' ) {
3989 $this->mStripState->addNoWiki( $marker, $output );
3990 } elseif ( $markerType === 'general' ) {
3991 $this->mStripState->addGeneral( $marker, $output );
3992 } else {
3993 throw new UnexpectedValueException( __METHOD__ . ': invalid marker type' );
3995 return $marker;
3999 * Increment an include size counter
4001 * @param string $type The type of expansion
4002 * @param int $size The size of the text
4003 * @return bool False if this inclusion would take it over the maximum, true otherwise
4005 private function incrementIncludeSize( $type, $size ) {
4006 if ( $this->mIncludeSizes[$type] + $size > $this->mOptions->getMaxIncludeSize() ) {
4007 return false;
4008 } else {
4009 $this->mIncludeSizes[$type] += $size;
4010 return true;
4015 * @return bool False if the limit has been exceeded
4016 * @since 1.13
4018 public function incrementExpensiveFunctionCount() {
4019 $this->mExpensiveFunctionCount++;
4020 return $this->mExpensiveFunctionCount <= $this->mOptions->getExpensiveParserFunctionLimit();
4024 * Strip double-underscore items like __NOGALLERY__ and __NOTOC__
4025 * Fills $this->mDoubleUnderscores, returns the modified text
4027 * @param string $text
4028 * @return string
4030 private function handleDoubleUnderscore( $text ) {
4031 # The position of __TOC__ needs to be recorded
4032 $mw = $this->magicWordFactory->get( 'toc' );
4033 if ( $mw->match( $text ) ) {
4034 $this->mShowToc = true;
4035 $this->mForceTocPosition = true;
4037 # Set a placeholder. At the end we'll fill it in with the TOC.
4038 $text = $mw->replace( self::TOC_PLACEHOLDER, $text, 1 );
4040 # Only keep the first one.
4041 $text = $mw->replace( '', $text );
4042 # For consistency with all other double-underscores
4043 # (see below)
4044 $this->mOutput->setPageProperty( 'toc', '' );
4047 # Now match and remove the rest of them
4048 $mwa = $this->magicWordFactory->getDoubleUnderscoreArray();
4049 $this->mDoubleUnderscores = $mwa->matchAndRemove( $text );
4051 if ( isset( $this->mDoubleUnderscores['nogallery'] ) ) {
4052 $this->mOutput->setNoGallery( true );
4054 if ( isset( $this->mDoubleUnderscores['notoc'] ) && !$this->mForceTocPosition ) {
4055 $this->mShowToc = false;
4057 if ( isset( $this->mDoubleUnderscores['hiddencat'] )
4058 && $this->getTitle()->getNamespace() === NS_CATEGORY
4060 $this->addTrackingCategory( 'hidden-category-category' );
4062 # (T10068) Allow control over whether robots index a page.
4063 # __INDEX__ always overrides __NOINDEX__, see T16899
4064 if ( isset( $this->mDoubleUnderscores['noindex'] ) && $this->getTitle()->canUseNoindex() ) {
4065 $this->mOutput->setIndexPolicy( 'noindex' );
4066 $this->addTrackingCategory( 'noindex-category' );
4068 if ( isset( $this->mDoubleUnderscores['index'] ) && $this->getTitle()->canUseNoindex() ) {
4069 $this->mOutput->setIndexPolicy( 'index' );
4070 $this->addTrackingCategory( 'index-category' );
4073 # Cache all double underscores in the database
4074 foreach ( $this->mDoubleUnderscores as $key => $val ) {
4075 $this->mOutput->setPageProperty( $key, '' );
4078 return $text;
4082 * @see TrackingCategories::addTrackingCategory()
4083 * @param string $msg Message key
4084 * @return bool Whether the addition was successful
4085 * @since 1.19 method is public
4087 public function addTrackingCategory( $msg ) {
4088 return $this->trackingCategories->addTrackingCategory(
4089 $this->mOutput, $msg, $this->getPage()
4094 * Helper function to correctly set the target language and title of
4095 * a message based on the parser context. Most uses of system messages
4096 * inside extensions or parser functions should use this method (instead
4097 * of directly using `wfMessage`) to ensure that the cache is not
4098 * polluted.
4100 * @param string $msg The localization message key
4101 * @param mixed ...$args Optional arguments for the message
4102 * @return Message
4103 * @since 1.40
4104 * @see https://phabricator.wikimedia.org/T202481
4106 public function msg( string $msg, ...$args ): Message {
4107 return wfMessage( $msg, ...$args )
4108 ->inLanguage( $this->getTargetLanguage() )
4109 ->page( $this->getPage() );
4113 * This function accomplishes several tasks:
4114 * 1) Auto-number headings if that option is enabled
4115 * 2) Add an [edit] link to sections for users who have enabled the option and can edit the page
4116 * 3) Add a Table of contents on the top for users who have enabled the option
4117 * 4) Auto-anchor headings
4119 * It loops through all headlines, collects the necessary data, then splits up the
4120 * string and re-inserts the newly formatted headlines.
4122 * @param string $text
4123 * @param string $origText Original, untouched wikitext
4124 * @param bool $isMain
4125 * @return string
4127 private function finalizeHeadings( $text, $origText, $isMain = true ) {
4128 # Inhibit editsection links if requested in the page
4129 if ( isset( $this->mDoubleUnderscores['noeditsection'] ) ) {
4130 $maybeShowEditLink = false;
4131 } else {
4132 $maybeShowEditLink = true; /* Actual presence will depend on post-cache transforms */
4135 # Get all headlines for numbering them and adding funky stuff like [edit]
4136 # links - this is for later, but we need the number of headlines right now
4137 # NOTE: white space in headings have been trimmed in handleHeadings. They shouldn't
4138 # be trimmed here since whitespace in HTML headings is significant.
4139 $matches = [];
4140 $numMatches = preg_match_all(
4141 '/<H(?P<level>[1-6])(?P<attrib>.*?>)(?P<header>[\s\S]*?)<\/H[1-6] *>/i',
4142 $text,
4143 $matches
4146 # if there are fewer than 4 headlines in the article, do not show TOC
4147 # unless it's been explicitly enabled.
4148 $enoughToc = $this->mShowToc &&
4149 ( ( $numMatches >= 4 ) || $this->mForceTocPosition );
4151 # Allow user to stipulate that a page should have a "new section"
4152 # link added via __NEWSECTIONLINK__
4153 if ( isset( $this->mDoubleUnderscores['newsectionlink'] ) ) {
4154 $this->mOutput->setNewSection( true );
4157 # Allow user to remove the "new section"
4158 # link via __NONEWSECTIONLINK__
4159 if ( isset( $this->mDoubleUnderscores['nonewsectionlink'] ) ) {
4160 $this->mOutput->setHideNewSection( true );
4163 # if the string __FORCETOC__ (not case-sensitive) occurs in the HTML,
4164 # override above conditions and always show TOC above first header
4165 if ( isset( $this->mDoubleUnderscores['forcetoc'] ) ) {
4166 $this->mShowToc = true;
4167 $enoughToc = true;
4170 # headline counter
4171 $headlineCount = 0;
4172 $haveTocEntries = false;
4174 # Ugh .. the TOC should have neat indentation levels which can be
4175 # passed to the skin functions. These are determined here
4176 $full = '';
4177 $head = [];
4178 $level = 0;
4179 $tocData = new TOCData();
4180 $markerRegex = self::MARKER_PREFIX . "-h-(\d+)-" . self::MARKER_SUFFIX;
4181 $baseTitleText = $this->getTitle()->getPrefixedDBkey();
4182 $oldType = $this->mOutputType;
4183 $this->setOutputType( self::OT_WIKI );
4184 $frame = $this->getPreprocessor()->newFrame();
4185 $root = $this->preprocessToDom( $origText );
4186 $node = $root->getFirstChild();
4187 $cpOffset = 0;
4188 $refers = [];
4190 $headlines = $numMatches !== false ? $matches[3] : [];
4192 $maxTocLevel = $this->svcOptions->get( MainConfigNames::MaxTocLevel );
4193 foreach ( $headlines as $headline ) {
4194 $isTemplate = false;
4195 $titleText = false;
4196 $sectionIndex = false;
4197 $markerMatches = [];
4198 if ( preg_match( "/^$markerRegex/", $headline, $markerMatches ) ) {
4199 $serial = (int)$markerMatches[1];
4200 [ $titleText, $sectionIndex ] = $this->mHeadings[$serial];
4201 $isTemplate = ( $titleText != $baseTitleText );
4202 $headline = preg_replace( "/^$markerRegex\\s*/", "", $headline );
4205 $sectionMetadata = SectionMetadata::fromLegacy( [
4206 "fromtitle" => $titleText ?: null,
4207 "index" => $sectionIndex === false
4208 ? '' : ( ( $isTemplate ? 'T-' : '' ) . $sectionIndex )
4209 ] );
4210 $tocData->addSection( $sectionMetadata );
4212 $oldLevel = $level;
4213 $level = (int)$matches[1][$headlineCount];
4214 $tocData->processHeading( $oldLevel, $level, $sectionMetadata );
4216 if ( $tocData->getCurrentTOCLevel() < $maxTocLevel ) {
4217 $haveTocEntries = true;
4220 # The safe header is a version of the header text safe to use for links
4222 # Remove link placeholders by the link text.
4223 # <!--LINK number-->
4224 # turns into
4225 # link text with suffix
4226 # Do this before unstrip since link text can contain strip markers
4227 $safeHeadline = $this->replaceLinkHoldersText( $headline );
4229 # Avoid insertion of weird stuff like <math> by expanding the relevant sections
4230 $safeHeadline = $this->mStripState->unstripBoth( $safeHeadline );
4232 # Remove any <style> or <script> tags (T198618)
4233 $safeHeadline = preg_replace(
4234 '#<(style|script)(?: [^>]*[^>/])?>.*?</\1>#is',
4236 $safeHeadline
4239 # Strip out HTML (first regex removes any tag not allowed)
4240 # Allowed tags are:
4241 # * <sup> and <sub> (T10393)
4242 # * <i> (T28375)
4243 # * <b> (r105284)
4244 # * <bdi> (T74884)
4245 # * <span dir="rtl"> and <span dir="ltr"> (T37167)
4246 # * <s> and <strike> (T35715)
4247 # * <q> (T251672)
4248 # We strip any parameter from accepted tags (second regex), except dir="rtl|ltr" from <span>,
4249 # to allow setting directionality in toc items.
4250 $tocline = preg_replace(
4252 '#<(?!/?(span|sup|sub|bdi|i|b|s|strike|q)(?: [^>]*)?>).*?>#',
4253 '#<(/?(?:span(?: dir="(?:rtl|ltr)")?|sup|sub|bdi|i|b|s|strike))(?: .*?)?>#'
4255 [ '', '<$1>' ],
4256 $safeHeadline
4259 # Strip '<span></span>', which is the result from the above if
4260 # <span id="foo"></span> is used to produce an additional anchor
4261 # for a section.
4262 $tocline = str_replace( '<span></span>', '', $tocline );
4264 $tocline = trim( $tocline );
4266 # For the anchor, strip out HTML-y stuff period
4267 $safeHeadline = preg_replace( '/<.*?>/', '', $safeHeadline );
4268 $safeHeadline = Sanitizer::normalizeSectionNameWhitespace( $safeHeadline );
4270 # Save headline for section edit hint before it's escaped
4271 $headlineHint = $safeHeadline;
4273 # Decode HTML entities
4274 $safeHeadline = Sanitizer::decodeCharReferences( $safeHeadline );
4276 $safeHeadline = self::normalizeSectionName( $safeHeadline );
4278 $fallbackHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_FALLBACK );
4279 $linkAnchor = Sanitizer::escapeIdForLink( $safeHeadline );
4280 $safeHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_PRIMARY );
4281 if ( $fallbackHeadline === $safeHeadline ) {
4282 # No reason to have both (in fact, we can't)
4283 $fallbackHeadline = false;
4286 # HTML IDs must be case-insensitively unique for IE compatibility (T12721).
4287 $arrayKey = strtolower( $safeHeadline );
4288 if ( $fallbackHeadline === false ) {
4289 $fallbackArrayKey = false;
4290 } else {
4291 $fallbackArrayKey = strtolower( $fallbackHeadline );
4294 # Create the anchor for linking from the TOC to the section
4295 $anchor = $safeHeadline;
4296 $fallbackAnchor = $fallbackHeadline;
4297 if ( isset( $refers[$arrayKey] ) ) {
4298 for ( $i = 2; isset( $refers["{$arrayKey}_$i"] ); ++$i );
4299 $anchor .= "_$i";
4300 $linkAnchor .= "_$i";
4301 $refers["{$arrayKey}_$i"] = true;
4302 } else {
4303 $refers[$arrayKey] = true;
4305 if ( $fallbackHeadline !== false && isset( $refers[$fallbackArrayKey] ) ) {
4306 for ( $i = 2; isset( $refers["{$fallbackArrayKey}_$i"] ); ++$i );
4307 $fallbackAnchor .= "_$i";
4308 $refers["{$fallbackArrayKey}_$i"] = true;
4309 } else {
4310 $refers[$fallbackArrayKey] = true;
4313 # Add the section to the section tree
4314 # Find the DOM node for this header
4315 $noOffset = ( $isTemplate || $sectionIndex === false );
4316 while ( $node && !$noOffset ) {
4317 if ( $node->getName() === 'h' ) {
4318 $bits = $node->splitHeading();
4319 if ( $bits['i'] == $sectionIndex ) {
4320 break;
4323 $cpOffset += mb_strlen(
4324 $this->mStripState->unstripBoth(
4325 $frame->expand( $node, PPFrame::RECOVER_ORIG )
4328 $node = $node->getNextSibling();
4330 $sectionMetadata->line = $tocline;
4331 $sectionMetadata->codepointOffset = ( $noOffset ? null : $cpOffset );
4332 $sectionMetadata->anchor = $anchor;
4333 $sectionMetadata->linkAnchor = $linkAnchor;
4335 # give headline the correct <h#> tag
4336 if ( $maybeShowEditLink && $sectionIndex !== false ) {
4337 // Output edit section links as markers with styles that can be customized by skins
4338 if ( $isTemplate ) {
4339 # Put a T flag in the section identifier, to indicate to extractSections()
4340 # that sections inside <includeonly> should be counted.
4341 $editsectionPage = $titleText;
4342 $editsectionSection = "T-$sectionIndex";
4343 } else {
4344 $editsectionPage = $this->getTitle()->getPrefixedText();
4345 $editsectionSection = $sectionIndex;
4347 $editsectionContent = $headlineHint;
4348 // We use a bit of pesudo-xml for editsection markers. The
4349 // language converter is run later on. Using a UNIQ style marker
4350 // leads to the converter screwing up the tokens when it
4351 // converts stuff. And trying to insert strip tags fails too. At
4352 // this point all real inputted tags have already been escaped,
4353 // so we don't have to worry about a user trying to input one of
4354 // these markers directly. We use a page and section attribute
4355 // to stop the language converter from converting these
4356 // important bits of data, but put the headline hint inside a
4357 // content block because the language converter is supposed to
4358 // be able to convert that piece of data.
4359 // Gets replaced with html in ParserOutput::getText
4360 $editlink = '<mw:editsection page="' . htmlspecialchars( $editsectionPage, ENT_COMPAT );
4361 $editlink .= '" section="' . htmlspecialchars( $editsectionSection, ENT_COMPAT ) . '"';
4362 $editlink .= '>' . $editsectionContent . '</mw:editsection>';
4363 } else {
4364 $editlink = '';
4366 $head[$headlineCount] = Linker::makeHeadline(
4367 $level,
4368 $matches['attrib'][$headlineCount],
4369 $anchor,
4370 $headline,
4371 $editlink,
4372 $fallbackAnchor
4375 $headlineCount++;
4378 $this->setOutputType( $oldType );
4380 # Never ever show TOC if no headers (or suppressed)
4381 $suppressToc = $this->mOptions->getSuppressTOC();
4382 if ( !$haveTocEntries ) {
4383 $enoughToc = false;
4385 $addTOCPlaceholder = false;
4387 if ( $isMain && !$suppressToc ) {
4388 // We generally output the section information via the API
4389 // even if there isn't "enough" of a ToC to merit showing
4390 // it -- but the "suppress TOC" parser option is set when
4391 // any sections that might be found aren't "really there"
4392 // (ie, JavaScript content that might have spurious === or
4393 // <h2>: T307691) so we will *not* set section information
4394 // in that case.
4395 $this->mOutput->setTOCData( $tocData );
4397 // T294950: Record a suggestion that the TOC should be shown.
4398 // We shouldn't be looking at ::getTOCHTML() for this because
4399 // that was replaced (T293513); and $tocData will contain sections
4400 // even if there aren't $enoughToc to show (T332243).
4401 // Skins are free to ignore this suggestion and implement their
4402 // own criteria for showing/suppressing TOC (T318186).
4403 if ( $enoughToc ) {
4404 $this->mOutput->setOutputFlag( ParserOutputFlags::SHOW_TOC );
4405 if ( !$this->mForceTocPosition ) {
4406 $addTOCPlaceholder = true;
4410 // If __NOTOC__ is used on the page (and not overridden by
4411 // __TOC__ or __FORCETOC__) set the NO_TOC flag to tell
4412 // the skin that although the section information is
4413 // valid, it should perhaps not be presented as a Table Of
4414 // Contents.
4415 if ( !$this->mShowToc ) {
4416 $this->mOutput->setOutputFlag( ParserOutputFlags::NO_TOC );
4420 # split up and insert constructed headlines
4421 $blocks = preg_split( '/<H[1-6].*?>[\s\S]*?<\/H[1-6]>/i', $text );
4422 $i = 0;
4424 // build an array of document sections
4425 $sections = [];
4426 foreach ( $blocks as $block ) {
4427 // $head is zero-based, sections aren't.
4428 if ( empty( $head[$i - 1] ) ) {
4429 $sections[$i] = $block;
4430 } else {
4431 $sections[$i] = $head[$i - 1] . $block;
4434 $i++;
4437 if ( $addTOCPlaceholder ) {
4438 // append the TOC at the beginning
4439 // Top anchor now in skin
4440 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset At least one element when enoughToc is true
4441 $sections[0] .= self::TOC_PLACEHOLDER . "\n";
4444 $full .= implode( '', $sections );
4446 return $full;
4450 * Localize the TOC into the given target language; this includes
4451 * invoking the language converter on the headings.
4452 * @param ?TOCData $tocData The Table of Contents
4453 * @param Language $lang The target language
4454 * @param ?ILanguageConverter $converter The target language converter, or
4455 * null if language conversion is to be suppressed.
4456 * @internal
4458 private static function localizeTOC(
4459 ?TOCData $tocData, Language $lang, ?ILanguageConverter $converter
4461 if ( $tocData === null ) {
4462 return; // Nothing to do
4464 foreach ( $tocData->getSections() as $s ) {
4465 // Localize heading
4466 if ( $converter ) {
4467 // T331316: don't use 'convert' or 'convertTo' as these reset
4468 // the language converter state.
4469 $s->line = $converter->convertTo(
4470 $s->line, $converter->getPreferredVariant(), false
4473 // Localize numbering
4474 $dot = '.';
4475 $pieces = explode( $dot, $s->number );
4476 $numbering = '';
4477 foreach ( $pieces as $i => $p ) {
4478 if ( $i > 0 ) {
4479 $numbering .= $dot;
4481 $numbering .= $lang->formatNum( $p );
4483 $s->number = $numbering;
4488 * Transform wiki markup when saving a page by doing "\r\n" -> "\n"
4489 * conversion, substituting signatures, {{subst:}} templates, etc.
4491 * @param string $text The text to transform
4492 * @param PageReference $page the current article
4493 * @param UserIdentity $user the current user
4494 * @param ParserOptions $options Parsing options
4495 * @param bool $clearState Whether to clear the parser state first
4496 * @return string The altered wiki markup
4497 * @since 1.3
4499 public function preSaveTransform(
4500 $text,
4501 PageReference $page,
4502 UserIdentity $user,
4503 ParserOptions $options,
4504 $clearState = true
4506 if ( $clearState ) {
4507 $magicScopeVariable = $this->lock();
4509 $this->startParse( $page, $options, self::OT_WIKI, $clearState );
4510 $this->setUser( $user );
4512 // Strip U+0000 NULL (T159174)
4513 $text = str_replace( "\000", '', $text );
4515 // We still normalize line endings (including trimming trailing whitespace) for
4516 // backwards-compatibility with other code that just calls PST, but this should already
4517 // be handled in TextContent subclasses
4518 $text = TextContent::normalizeLineEndings( $text );
4520 if ( $options->getPreSaveTransform() ) {
4521 $text = $this->pstPass2( $text, $user );
4523 $text = $this->mStripState->unstripBoth( $text );
4525 // Trim trailing whitespace again, because the previous steps can introduce it.
4526 $text = rtrim( $text );
4528 $this->hookRunner->onParserPreSaveTransformComplete( $this, $text );
4530 $this->setUser( null ); # Reset
4532 return $text;
4536 * Pre-save transform helper function
4538 * @param string $text
4539 * @param UserIdentity $user
4541 * @return string
4543 private function pstPass2( $text, UserIdentity $user ) {
4544 # Note: This is the timestamp saved as hardcoded wikitext to the database, we use
4545 # $this->contLang here in order to give everyone the same signature and use the default one
4546 # rather than the one selected in each user's preferences. (see also T14815)
4547 $ts = $this->mOptions->getTimestamp();
4548 $timestamp = MWTimestamp::getLocalInstance( $ts );
4549 $ts = $timestamp->format( 'YmdHis' );
4550 $tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text();
4552 $d = $this->contLang->timeanddate( $ts, false, false ) . " ($tzMsg)";
4554 # Variable replacement
4555 # Because mOutputType is OT_WIKI, this will only process {{subst:xxx}} type tags
4556 $text = $this->replaceVariables( $text );
4558 # This works almost by chance, as the replaceVariables are done before the getUserSig(),
4559 # which may corrupt this parser instance via its wfMessage()->text() call-
4561 # Signatures
4562 if ( strpos( $text, '~~~' ) !== false ) {
4563 $sigText = $this->getUserSig( $user );
4564 $text = strtr( $text, [
4565 '~~~~~' => $d,
4566 '~~~~' => "$sigText $d",
4567 '~~~' => $sigText
4568 ] );
4569 # The main two signature forms used above are time-sensitive
4570 $this->setOutputFlag( ParserOutputFlags::USER_SIGNATURE, 'User signature detected' );
4573 # Context links ("pipe tricks"): [[|name]] and [[name (context)|]]
4574 $tc = '[' . Title::legalChars() . ']';
4575 $nc = '[ _0-9A-Za-z\x80-\xff-]'; # Namespaces can use non-ascii!
4577 // [[ns:page (context)|]]
4578 $p1 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\))\\|]]/";
4579 // [[ns:page(context)|]] (double-width brackets, added in r40257)
4580 $p4 = "/\[\[(:?$nc+:|:|)($tc+?)( ?($tc+))\\|]]/";
4581 // [[ns:page (context), context|]] (using single, double-width or Arabic comma)
4582 $p3 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\)|)((?:, |,|، )$tc+|)\\|]]/";
4583 // [[|page]] (reverse pipe trick: add context from page title)
4584 $p2 = "/\[\[\\|($tc+)]]/";
4586 # try $p1 first, to turn "[[A, B (C)|]]" into "[[A, B (C)|A, B]]"
4587 $text = preg_replace( $p1, '[[\\1\\2\\3|\\2]]', $text );
4588 $text = preg_replace( $p4, '[[\\1\\2\\3|\\2]]', $text );
4589 $text = preg_replace( $p3, '[[\\1\\2\\3\\4|\\2]]', $text );
4591 $t = $this->getTitle()->getText();
4592 $m = [];
4593 if ( preg_match( "/^($nc+:|)$tc+?( \\($tc+\\))$/", $t, $m ) ) {
4594 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4595 } elseif ( preg_match( "/^($nc+:|)$tc+?(, $tc+|)$/", $t, $m ) && "$m[1]$m[2]" != '' ) {
4596 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4597 } else {
4598 # if there's no context, don't bother duplicating the title
4599 $text = preg_replace( $p2, '[[\\1]]', $text );
4602 return $text;
4606 * Fetch the user's signature text, if any, and normalize to
4607 * validated, ready-to-insert wikitext.
4608 * If you have pre-fetched the nickname or the fancySig option, you can
4609 * specify them here to save a database query.
4610 * Do not reuse this parser instance after calling getUserSig(),
4611 * as it may have changed.
4613 * @param UserIdentity $user
4614 * @param string|false $nickname Nickname to use or false to use user's default nickname
4615 * @param bool|null $fancySig whether the nicknname is the complete signature
4616 * or null to use default value
4617 * @return string
4618 * @since 1.6
4620 public function getUserSig( UserIdentity $user, $nickname = false, $fancySig = null ) {
4621 $username = $user->getName();
4623 # If not given, retrieve from the user object.
4624 if ( $nickname === false ) {
4625 $nickname = $this->userOptionsLookup->getOption( $user, 'nickname' );
4628 if ( $fancySig === null ) {
4629 $fancySig = $this->userOptionsLookup->getBoolOption( $user, 'fancysig' );
4632 if ( $nickname === null || $nickname === '' ) {
4633 // Empty value results in the default signature (even when fancysig is enabled)
4634 $nickname = $username;
4635 } elseif ( mb_strlen( $nickname ) > $this->svcOptions->get( MainConfigNames::MaxSigChars ) ) {
4636 $nickname = $username;
4637 $this->logger->debug( __METHOD__ . ": $username has overlong signature." );
4638 } elseif ( $fancySig !== false ) {
4639 # Sig. might contain markup; validate this
4640 $isValid = $this->validateSig( $nickname ) !== false;
4642 # New validator
4643 $sigValidation = $this->svcOptions->get( MainConfigNames::SignatureValidation );
4644 if ( $isValid && $sigValidation === 'disallow' ) {
4645 $parserOpts = new ParserOptions(
4646 $this->mOptions->getUserIdentity(),
4647 $this->contLang
4649 $validator = $this->signatureValidatorFactory
4650 ->newSignatureValidator( $user, null, $parserOpts );
4651 $isValid = !$validator->validateSignature( $nickname );
4654 if ( $isValid ) {
4655 # Validated; clean up (if needed) and return it
4656 return $this->cleanSig( $nickname, true );
4657 } else {
4658 # Failed to validate; fall back to the default
4659 $nickname = $username;
4660 $this->logger->debug( __METHOD__ . ": $username has invalid signature." );
4664 # Make sure nickname doesnt get a sig in a sig
4665 $nickname = self::cleanSigInSig( $nickname );
4667 # If we're still here, make it a link to the user page
4668 $userText = wfEscapeWikiText( $username );
4669 $nickText = wfEscapeWikiText( $nickname );
4670 if ( $this->userNameUtils->isTemp( $username ) ) {
4671 $msgName = 'signature-temp';
4672 } elseif ( $user->isRegistered() ) {
4673 $msgName = 'signature';
4674 } else {
4675 $msgName = 'signature-anon';
4678 return wfMessage( $msgName, $userText, $nickText )->inContentLanguage()
4679 ->page( $this->getPage() )->text();
4683 * Check that the user's signature contains no bad XML
4685 * @param string $text
4686 * @return string|false An expanded string, or false if invalid.
4687 * @since 1.6
4689 public function validateSig( $text ) {
4690 return Xml::isWellFormedXmlFragment( $text ) ? $text : false;
4694 * Clean up signature text
4696 * 1) Strip 3, 4 or 5 tildes out of signatures @see cleanSigInSig
4697 * 2) Substitute all transclusions
4699 * @param string $text
4700 * @param bool $parsing Whether we're cleaning (preferences save) or parsing
4701 * @return string Signature text
4702 * @since 1.6
4704 public function cleanSig( $text, $parsing = false ) {
4705 if ( !$parsing ) {
4706 $magicScopeVariable = $this->lock();
4707 $this->startParse(
4708 $this->mTitle,
4709 ParserOptions::newFromUser( RequestContext::getMain()->getUser() ),
4710 self::OT_PREPROCESS,
4711 true
4715 # Option to disable this feature
4716 if ( !$this->mOptions->getCleanSignatures() ) {
4717 return $text;
4720 # @todo FIXME: Regex doesn't respect extension tags or nowiki
4721 # => Move this logic to braceSubstitution()
4722 $substWord = $this->magicWordFactory->get( 'subst' );
4723 $substRegex = '/\{\{(?!(?:' . $substWord->getBaseRegex() . '))/x' . $substWord->getRegexCase();
4724 $substText = '{{' . $substWord->getSynonym( 0 );
4726 $text = preg_replace( $substRegex, $substText, $text );
4727 $text = self::cleanSigInSig( $text );
4728 $dom = $this->preprocessToDom( $text );
4729 $frame = $this->getPreprocessor()->newFrame();
4730 $text = $frame->expand( $dom );
4732 if ( !$parsing ) {
4733 $text = $this->mStripState->unstripBoth( $text );
4736 return $text;
4740 * Strip 3, 4 or 5 tildes out of signatures.
4742 * @param string $text
4743 * @return string Signature text with /~{3,5}/ removed
4744 * @since 1.7
4746 public static function cleanSigInSig( $text ) {
4747 $text = preg_replace( '/~{3,5}/', '', $text );
4748 return $text;
4752 * Replace table of contents marker in parsed HTML.
4754 * Used to remove or replace the marker. This method should be
4755 * used instead of direct access to Parser::TOC_PLACEHOLDER, since
4756 * in the future the placeholder might have additional attributes
4757 * attached which should be ignored when the replacement is made.
4759 * @since 1.38
4760 * @stable
4762 * @param string $text Parsed HTML
4763 * @param string $toc HTML table of contents string, or else an empty
4764 * string to remove the marker.
4765 * @return string Result HTML
4767 public static function replaceTableOfContentsMarker( $text, $toc ) {
4768 return preg_replace( self::TOC_PLACEHOLDER_REGEX,
4769 StringUtils::escapeRegexReplacement( $toc ), $text );
4773 * Set up some variables which are usually set up in parse()
4774 * so that an external function can call some class members with confidence
4776 * @param ?PageReference $page
4777 * @param ParserOptions $options
4778 * @param int $outputType One of the Parser::OT_… constants
4779 * @param bool $clearState
4780 * @param int|null $revId
4781 * @since 1.3
4783 public function startExternalParse( ?PageReference $page, ParserOptions $options,
4784 $outputType, $clearState = true, $revId = null
4786 $this->startParse( $page, $options, $outputType, $clearState );
4787 if ( $revId !== null ) {
4788 $this->mRevisionId = $revId;
4793 * @param ?PageReference $page
4794 * @param ParserOptions $options
4795 * @param int $outputType
4796 * @param bool $clearState
4798 private function startParse( ?PageReference $page, ParserOptions $options,
4799 $outputType, $clearState = true
4801 $this->setPage( $page );
4802 $this->mOptions = $options;
4803 $this->setOutputType( $outputType );
4804 if ( $clearState ) {
4805 $this->clearState();
4810 * Wrapper for preprocess()
4812 * @param string $text The text to preprocess
4813 * @param ParserOptions $options
4814 * @param ?PageReference $page The context page
4815 * @return string
4816 * @since 1.3
4818 public function transformMsg( $text, ParserOptions $options, ?PageReference $page = null ) {
4819 static $executing = false;
4821 # Guard against infinite recursion
4822 if ( $executing ) {
4823 return $text;
4825 $executing = true;
4827 $text = $this->preprocess( $text, $page ?? $this->mTitle, $options );
4829 $executing = false;
4830 return $text;
4834 * Create an HTML-style tag, e.g. "<yourtag>special text</yourtag>"
4835 * The callback should have the following form:
4836 * function myParserHook( $text, array $params, Parser $parser, PPFrame $frame ) { ... }
4838 * Transform and return $text. Use $parser for any required context, e.g. use
4839 * $parser->getTitle() and $parser->getOptions() not $wgTitle or $wgOut->mParserOptions
4841 * Hooks may return extended information by returning an array, of which the
4842 * first numbered element (index 0) must be the return string. The following other
4843 * keys are used:
4844 * - 'markerType': used by some core tag hooks to override which strip
4845 * array their results are placed in, 'general' or 'nowiki'.
4847 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4848 * @param callable $callback The callback to use for the tag
4849 * @return callable|null The old value of the mTagHooks array associated with the hook
4850 * @since 1.3
4852 public function setHook( $tag, callable $callback ) {
4853 $tag = strtolower( $tag );
4854 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4855 throw new InvalidArgumentException( "Invalid character {$m[0]} in setHook('$tag', ...) call" );
4857 $oldVal = $this->mTagHooks[$tag] ?? null;
4858 $this->mTagHooks[$tag] = $callback;
4859 if ( !in_array( $tag, $this->mStripList ) ) {
4860 $this->mStripList[] = $tag;
4863 return $oldVal;
4867 * Remove all tag hooks
4868 * @since 1.12
4870 public function clearTagHooks() {
4871 $this->mTagHooks = [];
4872 $this->mStripList = [];
4876 * Create a function, e.g. {{sum:1|2|3}}
4877 * The callback function should have the form:
4878 * function myParserFunction( &$parser, $arg1, $arg2, $arg3 ) { ... }
4880 * Or with Parser::SFH_OBJECT_ARGS:
4881 * function myParserFunction( $parser, $frame, $args ) { ... }
4883 * The callback may either return the text result of the function, or an array with the text
4884 * in element 0, and a number of flags in the other elements. The names of the flags are
4885 * specified in the keys. Valid flags are:
4886 * found The text returned is valid, stop processing the template. This
4887 * is on by default.
4888 * nowiki Wiki markup in the return value should be escaped
4889 * isHTML The returned text is HTML, armour it against wikitext transformation
4891 * @param string $id The magic word ID
4892 * @param callable $callback The callback function (and object) to use
4893 * @param int $flags A combination of the following flags:
4894 * Parser::SFH_NO_HASH No leading hash, i.e. {{plural:...}} instead of {{#if:...}}
4896 * Parser::SFH_OBJECT_ARGS Pass the template arguments as PPNode objects instead of text.
4897 * This allows for conditional expansion of the parse tree, allowing you to eliminate dead
4898 * branches and thus speed up parsing. It is also possible to analyse the parse tree of
4899 * the arguments, and to control the way they are expanded.
4901 * The $frame parameter is a PPFrame. This can be used to produce expanded text from the
4902 * arguments, for instance:
4903 * $text = isset( $args[0] ) ? $frame->expand( $args[0] ) : '';
4905 * For technical reasons, $args[0] is pre-expanded and will be a string. This may change in
4906 * future versions. Please call $frame->expand() on it anyway so that your code keeps
4907 * working if/when this is changed.
4909 * If you want whitespace to be trimmed from $args, you need to do it yourself, post-
4910 * expansion.
4912 * Please read the documentation in includes/parser/Preprocessor.php for more information
4913 * about the methods available in PPFrame and PPNode.
4915 * @return string|callable|null The old callback function for this name, if any
4916 * @since 1.6
4918 public function setFunctionHook( $id, callable $callback, $flags = 0 ) {
4919 $oldVal = $this->mFunctionHooks[$id][0] ?? null;
4920 $this->mFunctionHooks[$id] = [ $callback, $flags ];
4922 # Add to function cache
4923 $mw = $this->magicWordFactory->get( $id );
4924 if ( !$mw ) {
4925 throw new InvalidArgumentException( __METHOD__ . '() expecting a magic word identifier.' );
4928 $synonyms = $mw->getSynonyms();
4929 $sensitive = intval( $mw->isCaseSensitive() );
4931 foreach ( $synonyms as $syn ) {
4932 # Case
4933 if ( !$sensitive ) {
4934 $syn = $this->contLang->lc( $syn );
4936 # Add leading hash
4937 if ( !( $flags & self::SFH_NO_HASH ) ) {
4938 $syn = '#' . $syn;
4940 # Remove trailing colon
4941 if ( substr( $syn, -1, 1 ) === ':' ) {
4942 $syn = substr( $syn, 0, -1 );
4944 $this->mFunctionSynonyms[$sensitive][$syn] = $id;
4946 return $oldVal;
4950 * Get all registered function hook identifiers
4952 * @return array
4953 * @since 1.8
4955 public function getFunctionHooks() {
4956 return array_keys( $this->mFunctionHooks );
4960 * Replace "<!--LINK-->" link placeholders with actual links, in the buffer
4961 * Placeholders created in Linker::link()
4963 * @param string &$text
4964 * @deprecated since 1.34; should not be used outside parser class.
4966 public function replaceLinkHolders( &$text ) {
4967 $this->replaceLinkHoldersPrivate( $text );
4971 * Replace "<!--LINK-->" link placeholders with actual links, in the buffer
4972 * Placeholders created in Linker::link()
4974 * @param string &$text
4976 private function replaceLinkHoldersPrivate( &$text ) {
4977 $this->mLinkHolders->replace( $text );
4981 * Replace "<!--LINK-->" link placeholders with plain text of links
4982 * (not HTML-formatted).
4984 * @param string $text
4985 * @return string
4987 private function replaceLinkHoldersText( $text ) {
4988 return $this->mLinkHolders->replaceText( $text );
4992 * Renders an image gallery from a text with one line per image.
4993 * text labels may be given by using |-style alternative text. E.g.
4994 * Image:one.jpg|The number "1"
4995 * Image:tree.jpg|A tree
4996 * given as text will return the HTML of a gallery with two images,
4997 * labeled 'The number "1"' and
4998 * 'A tree'.
5000 * @param string $text
5001 * @param array $params
5002 * @return string HTML
5003 * @internal
5005 public function renderImageGallery( $text, array $params ) {
5006 $mode = false;
5007 if ( isset( $params['mode'] ) ) {
5008 $mode = $params['mode'];
5011 try {
5012 $ig = ImageGalleryBase::factory( $mode );
5013 } catch ( ImageGalleryClassNotFoundException $e ) {
5014 // If invalid type set, fallback to default.
5015 $ig = ImageGalleryBase::factory( false );
5018 $ig->setContextTitle( $this->getTitle() );
5019 $ig->setShowBytes( false );
5020 $ig->setShowDimensions( false );
5021 $ig->setShowFilename( false );
5022 $ig->setParser( $this );
5023 $ig->setHideBadImages();
5024 $ig->setAttributes( Sanitizer::validateTagAttributes( $params, 'ul' ) );
5026 if ( isset( $params['showfilename'] ) ) {
5027 $ig->setShowFilename( true );
5028 } else {
5029 $ig->setShowFilename( false );
5031 if ( isset( $params['caption'] ) ) {
5032 // NOTE: We aren't passing a frame here or below. Frame info
5033 // is currently opaque to Parsoid, which acts on OT_PREPROCESS.
5034 // See T107332#4030581
5035 $caption = $this->recursiveTagParse( $params['caption'] );
5036 $ig->setCaptionHtml( $caption );
5038 if ( isset( $params['perrow'] ) ) {
5039 $ig->setPerRow( $params['perrow'] );
5041 if ( isset( $params['widths'] ) ) {
5042 $ig->setWidths( $params['widths'] );
5044 if ( isset( $params['heights'] ) ) {
5045 $ig->setHeights( $params['heights'] );
5047 $ig->setAdditionalOptions( $params );
5049 $enableLegacyMediaDOM = $this->svcOptions->get( MainConfigNames::ParserEnableLegacyMediaDOM );
5051 $lines = StringUtils::explode( "\n", $text );
5052 foreach ( $lines as $line ) {
5053 # match lines like these:
5054 # Image:someimage.jpg|This is some image
5055 $matches = [];
5056 preg_match( "/^([^|]+)(\\|(.*))?$/", $line, $matches );
5057 # Skip empty lines
5058 if ( count( $matches ) == 0 ) {
5059 continue;
5062 if ( strpos( $matches[0], '%' ) !== false ) {
5063 $matches[1] = rawurldecode( $matches[1] );
5065 $title = Title::newFromText( $matches[1], NS_FILE );
5066 if ( $title === null ) {
5067 # Bogus title. Ignore these so we don't bomb out later.
5068 continue;
5071 # We need to get what handler the file uses, to figure out parameters.
5072 # Note, a hook can override the file name, and chose an entirely different
5073 # file (which potentially could be of a different type and have different handler).
5074 $options = [];
5075 $descQuery = false;
5076 $this->hookRunner->onBeforeParserFetchFileAndTitle(
5077 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
5078 $this, $title, $options, $descQuery
5080 # Don't register it now, as TraditionalImageGallery does that later.
5081 $file = $this->fetchFileNoRegister( $title, $options );
5082 $handler = $file ? $file->getHandler() : false;
5084 $paramMap = [
5085 'img_alt' => 'gallery-internal-alt',
5086 'img_link' => 'gallery-internal-link',
5088 if ( $handler ) {
5089 $paramMap += $handler->getParamMap();
5090 // We don't want people to specify per-image widths.
5091 // Additionally the width parameter would need special casing anyhow.
5092 unset( $paramMap['img_width'] );
5095 $mwArray = $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5097 $label = '';
5098 $alt = null;
5099 $handlerOptions = [];
5100 $imageOptions = [];
5101 $hasAlt = false;
5103 if ( isset( $matches[3] ) ) {
5104 // look for an |alt= definition while trying not to break existing
5105 // captions with multiple pipes (|) in it, until a more sensible grammar
5106 // is defined for images in galleries
5108 // FIXME: Doing recursiveTagParse at this stage, and the trim before
5109 // splitting on '|' is a bit odd, and different from makeImage.
5110 $matches[3] = $this->recursiveTagParse( trim( $matches[3] ) );
5111 // Protect LanguageConverter markup
5112 $parameterMatches = StringUtils::delimiterExplode(
5113 '-{', '}-',
5114 '|',
5115 $matches[3],
5116 true /* nested */
5119 foreach ( $parameterMatches as $parameterMatch ) {
5120 [ $magicName, $match ] = $mwArray->matchVariableStartToEnd( $parameterMatch );
5121 if ( !$magicName ) {
5122 // Last pipe wins.
5123 $label = $parameterMatch;
5124 continue;
5127 $paramName = $paramMap[$magicName];
5128 switch ( $paramName ) {
5129 case 'gallery-internal-alt':
5130 $hasAlt = true;
5131 $alt = $this->stripAltText( $match, false );
5132 break;
5133 case 'gallery-internal-link':
5134 $linkValue = $this->stripAltText( $match, false );
5135 if ( preg_match( '/^-{R\|(.*)}-$/', $linkValue ) ) {
5136 // Result of LanguageConverter::markNoConversion
5137 // invoked on an external link.
5138 $linkValue = substr( $linkValue, 4, -2 );
5140 [ $type, $target ] = $this->parseLinkParameter( $linkValue );
5141 if ( $type ) {
5142 if ( $type === 'no-link' ) {
5143 $target = true;
5145 $imageOptions[$type] = $target;
5147 break;
5148 default:
5149 // Must be a handler specific parameter.
5150 if ( $handler->validateParam( $paramName, $match ) ) {
5151 $handlerOptions[$paramName] = $match;
5152 } else {
5153 // Guess not, consider it as caption.
5154 $this->logger->debug(
5155 "$parameterMatch failed parameter validation" );
5156 $label = $parameterMatch;
5162 // Match makeImage when !$hasVisibleCaption
5163 if ( !$hasAlt ) {
5164 if ( $label !== '' ) {
5165 $alt = $this->stripAltText( $label, false );
5166 } else {
5167 if ( $enableLegacyMediaDOM ) {
5168 $alt = $title->getText();
5172 $imageOptions['title'] = $this->stripAltText( $label, false );
5174 // Match makeImage which sets this unconditionally
5175 $handlerOptions['targetlang'] = $this->getTargetLanguage()->getCode();
5177 $ig->add(
5178 $title, $label, $alt, '', $handlerOptions,
5179 ImageGalleryBase::LOADING_DEFAULT, $imageOptions
5182 $html = $ig->toHTML();
5183 $this->hookRunner->onAfterParserFetchFileAndTitle( $this, $ig, $html );
5184 return $html;
5188 * @param MediaHandler|false $handler
5189 * @return array
5191 private function getImageParams( $handler ) {
5192 if ( $handler ) {
5193 $handlerClass = get_class( $handler );
5194 } else {
5195 $handlerClass = '';
5197 if ( !isset( $this->mImageParams[$handlerClass] ) ) {
5198 # Initialise static lists
5199 static $internalParamNames = [
5200 'horizAlign' => [ 'left', 'right', 'center', 'none' ],
5201 'vertAlign' => [ 'baseline', 'sub', 'super', 'top', 'text-top', 'middle',
5202 'bottom', 'text-bottom' ],
5203 'frame' => [ 'thumbnail', 'manualthumb', 'framed', 'frameless',
5204 'upright', 'border', 'link', 'alt', 'class' ],
5206 static $internalParamMap;
5207 if ( !$internalParamMap ) {
5208 $internalParamMap = [];
5209 foreach ( $internalParamNames as $type => $names ) {
5210 foreach ( $names as $name ) {
5211 // For grep: img_left, img_right, img_center, img_none,
5212 // img_baseline, img_sub, img_super, img_top, img_text_top, img_middle,
5213 // img_bottom, img_text_bottom,
5214 // img_thumbnail, img_manualthumb, img_framed, img_frameless, img_upright,
5215 // img_border, img_link, img_alt, img_class
5216 $magicName = str_replace( '-', '_', "img_$name" );
5217 $internalParamMap[$magicName] = [ $type, $name ];
5222 # Add handler params
5223 $paramMap = $internalParamMap;
5224 if ( $handler ) {
5225 $handlerParamMap = $handler->getParamMap();
5226 foreach ( $handlerParamMap as $magic => $paramName ) {
5227 $paramMap[$magic] = [ 'handler', $paramName ];
5229 } else {
5230 // Parse the size for non-existent files. See T273013
5231 $paramMap[ 'img_width' ] = [ 'handler', 'width' ];
5233 $this->mImageParams[$handlerClass] = $paramMap;
5234 $this->mImageParamsMagicArray[$handlerClass] =
5235 $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5237 return [ $this->mImageParams[$handlerClass], $this->mImageParamsMagicArray[$handlerClass] ];
5241 * Parse image options text and use it to make an image
5243 * @param LinkTarget $link
5244 * @param string $options
5245 * @param LinkHolderArray|false $holders
5246 * @return string HTML
5247 * @since 1.5
5249 public function makeImage( LinkTarget $link, $options, $holders = false ) {
5250 # Check if the options text is of the form "options|alt text"
5251 # Options are:
5252 # * thumbnail make a thumbnail with enlarge-icon and caption, alignment depends on lang
5253 # * left no resizing, just left align. label is used for alt= only
5254 # * right same, but right aligned
5255 # * none same, but not aligned
5256 # * ___px scale to ___ pixels width, no aligning. e.g. use in taxobox
5257 # * center center the image
5258 # * framed Keep original image size, no magnify-button.
5259 # * frameless like 'thumb' but without a frame. Keeps user preferences for width
5260 # * upright reduce width for upright images, rounded to full __0 px
5261 # * border draw a 1px border around the image
5262 # * alt Text for HTML alt attribute (defaults to empty)
5263 # * class Set a class for img node
5264 # * link Set the target of the image link. Can be external, interwiki, or local
5265 # vertical-align values (no % or length right now):
5266 # * baseline
5267 # * sub
5268 # * super
5269 # * top
5270 # * text-top
5271 # * middle
5272 # * bottom
5273 # * text-bottom
5275 # Protect LanguageConverter markup when splitting into parts
5276 $parts = StringUtils::delimiterExplode(
5277 '-{', '}-', '|', $options, true /* allow nesting */
5280 # Give extensions a chance to select the file revision for us
5281 $options = [];
5282 $descQuery = false;
5283 $title = Title::castFromLinkTarget( $link ); // hook signature compat
5284 $this->hookRunner->onBeforeParserFetchFileAndTitle(
5285 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
5286 $this, $title, $options, $descQuery
5288 # Fetch and register the file (file title may be different via hooks)
5289 [ $file, $link ] = $this->fetchFileAndTitle( $link, $options );
5291 # Get parameter map
5292 $handler = $file ? $file->getHandler() : false;
5294 [ $paramMap, $mwArray ] = $this->getImageParams( $handler );
5296 if ( !$file ) {
5297 $this->addTrackingCategory( 'broken-file-category' );
5300 # Process the input parameters
5301 $caption = '';
5302 $params = [ 'frame' => [], 'handler' => [],
5303 'horizAlign' => [], 'vertAlign' => [] ];
5304 $seenformat = false;
5305 foreach ( $parts as $part ) {
5306 $part = trim( $part );
5307 [ $magicName, $value ] = $mwArray->matchVariableStartToEnd( $part );
5308 $validated = false;
5309 if ( isset( $paramMap[$magicName] ) ) {
5310 [ $type, $paramName ] = $paramMap[$magicName];
5312 # Special case; width and height come in one variable together
5313 if ( $type === 'handler' && $paramName === 'width' ) {
5314 $parsedWidthParam = self::parseWidthParam( $value );
5315 // Parsoid applies data-(width|height) attributes to broken
5316 // media spans, for client use. See T273013
5317 $validateFunc = static function ( $name, $value ) use ( $handler ) {
5318 return $handler
5319 ? $handler->validateParam( $name, $value )
5320 : $value > 0;
5322 if ( isset( $parsedWidthParam['width'] ) ) {
5323 $width = $parsedWidthParam['width'];
5324 if ( $validateFunc( 'width', $width ) ) {
5325 $params[$type]['width'] = $width;
5326 $validated = true;
5329 if ( isset( $parsedWidthParam['height'] ) ) {
5330 $height = $parsedWidthParam['height'];
5331 if ( $validateFunc( 'height', $height ) ) {
5332 $params[$type]['height'] = $height;
5333 $validated = true;
5336 # else no validation -- T15436
5337 } else {
5338 if ( $type === 'handler' ) {
5339 # Validate handler parameter
5340 $validated = $handler->validateParam( $paramName, $value );
5341 } else {
5342 # Validate internal parameters
5343 switch ( $paramName ) {
5344 case 'alt':
5345 case 'class':
5346 $validated = true;
5347 $value = $this->stripAltText( $value, $holders );
5348 break;
5349 case 'link':
5350 [ $paramName, $value ] =
5351 $this->parseLinkParameter(
5352 $this->stripAltText( $value, $holders )
5354 if ( $paramName ) {
5355 $validated = true;
5356 if ( $paramName === 'no-link' ) {
5357 $value = true;
5360 break;
5361 case 'manualthumb':
5362 # @todo FIXME: Possibly check validity here for
5363 # manualthumb? downstream behavior seems odd with
5364 # missing manual thumbs.
5365 $value = $this->stripAltText( $value, $holders );
5366 // fall through
5367 case 'frameless':
5368 case 'framed':
5369 case 'thumbnail':
5370 // use first appearing option, discard others.
5371 $validated = !$seenformat;
5372 $seenformat = true;
5373 break;
5374 default:
5375 # Most other things appear to be empty or numeric...
5376 $validated = ( $value === false || is_numeric( trim( $value ) ) );
5380 if ( $validated ) {
5381 $params[$type][$paramName] = $value;
5385 if ( !$validated ) {
5386 $caption = $part;
5390 # Process alignment parameters
5391 if ( $params['horizAlign'] !== [] ) {
5392 $params['frame']['align'] = array_key_first( $params['horizAlign'] );
5394 if ( $params['vertAlign'] !== [] ) {
5395 $params['frame']['valign'] = array_key_first( $params['vertAlign'] );
5398 $params['frame']['caption'] = $caption;
5400 $enableLegacyMediaDOM = $this->svcOptions->get( MainConfigNames::ParserEnableLegacyMediaDOM );
5402 # Will the image be presented in a frame, with the caption below?
5403 // @phan-suppress-next-line PhanImpossibleCondition
5404 $hasVisibleCaption = isset( $params['frame']['framed'] )
5405 // @phan-suppress-next-line PhanImpossibleCondition
5406 || isset( $params['frame']['thumbnail'] )
5407 // @phan-suppress-next-line PhanImpossibleCondition
5408 || isset( $params['frame']['manualthumb'] );
5410 # In the old days, [[Image:Foo|text...]] would set alt text. Later it
5411 # came to also set the caption, ordinary text after the image -- which
5412 # makes no sense, because that just repeats the text multiple times in
5413 # screen readers. It *also* came to set the title attribute.
5414 # Now that we have an alt attribute, we should not set the alt text to
5415 # equal the caption: that's worse than useless, it just repeats the
5416 # text. This is the framed/thumbnail case. If there's no caption, we
5417 # use the unnamed parameter for alt text as well, just for the time be-
5418 # ing, if the unnamed param is set and the alt param is not.
5419 # For the future, we need to figure out if we want to tweak this more,
5420 # e.g., introducing a title= parameter for the title; ignoring the un-
5421 # named parameter entirely for images without a caption; adding an ex-
5422 # plicit caption= parameter and preserving the old magic unnamed para-
5423 # meter for BC; ...
5424 if ( $hasVisibleCaption ) {
5425 if (
5426 // @phan-suppress-next-line PhanImpossibleCondition
5427 $caption === '' && !isset( $params['frame']['alt'] ) &&
5428 $enableLegacyMediaDOM
5430 # No caption or alt text, add the filename as the alt text so
5431 # that screen readers at least get some description of the image
5432 $params['frame']['alt'] = $link->getText();
5434 # Do not set $params['frame']['title'] because tooltips are unnecessary
5435 # for framed images, the caption is visible
5436 } else {
5437 // @phan-suppress-next-line PhanImpossibleCondition
5438 if ( !isset( $params['frame']['alt'] ) ) {
5439 # No alt text, use the "caption" for the alt text
5440 if ( $caption !== '' ) {
5441 $params['frame']['alt'] = $this->stripAltText( $caption, $holders );
5442 } elseif ( $enableLegacyMediaDOM ) {
5443 # No caption, fall back to using the filename for the
5444 # alt text
5445 $params['frame']['alt'] = $link->getText();
5448 # Use the "caption" for the tooltip text
5449 $params['frame']['title'] = $this->stripAltText( $caption, $holders );
5451 $params['handler']['targetlang'] = $this->getTargetLanguage()->getCode();
5453 // hook signature compat again, $link may have changed
5454 $title = Title::castFromLinkTarget( $link );
5455 $this->hookRunner->onParserMakeImageParams( $title, $file, $params, $this );
5457 # Linker does the rest
5458 $time = $options['time'] ?? false;
5459 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
5460 $ret = Linker::makeImageLink( $this, $link, $file, $params['frame'], $params['handler'],
5461 $time, $descQuery, $this->mOptions->getThumbSize() );
5463 # Give the handler a chance to modify the parser object
5464 if ( $handler ) {
5465 $handler->parserTransformHook( $this, $file );
5467 if ( $file ) {
5468 $this->modifyImageHtml( $file, $params, $ret );
5471 return $ret;
5475 * Parse the value of 'link' parameter in image syntax (`[[File:Foo.jpg|link=<value>]]`).
5477 * Adds an entry to appropriate link tables.
5479 * @since 1.32
5480 * @param string $value
5481 * @return array of `[ type, target ]`, where:
5482 * - `type` is one of:
5483 * - `null`: Given value is not a valid link target, use default
5484 * - `'no-link'`: Given value is empty, do not generate a link
5485 * - `'link-url'`: Given value is a valid external link
5486 * - `'link-title'`: Given value is a valid internal link
5487 * - `target` is:
5488 * - When `type` is `null` or `'no-link'`: `false`
5489 * - When `type` is `'link-url'`: URL string corresponding to given value
5490 * - When `type` is `'link-title'`: Title object corresponding to given value
5492 private function parseLinkParameter( $value ) {
5493 $chars = self::EXT_LINK_URL_CLASS;
5494 $addr = self::EXT_LINK_ADDR;
5495 $prots = $this->urlUtils->validProtocols();
5496 $type = null;
5497 $target = false;
5498 if ( $value === '' ) {
5499 $type = 'no-link';
5500 } elseif ( preg_match( "/^((?i)$prots)/", $value ) ) {
5501 if ( preg_match( "/^((?i)$prots)$addr$chars*$/u", $value ) ) {
5502 $this->mOutput->addExternalLink( $value );
5503 $type = 'link-url';
5504 $target = $value;
5506 } else {
5507 // Percent-decode link arguments for consistency with wikilink
5508 // handling (T216003#7836261).
5510 // There's slight concern here though. The |link= option supports
5511 // two formats, link=Test%22test vs link=[[Test%22test]], both of
5512 // which are about to be decoded.
5514 // In the former case, the decoding here is straightforward and
5515 // desirable.
5517 // In the latter case, there's a potential for double decoding,
5518 // because the wikilink syntax has a higher precedence and has
5519 // already been parsed as a link before we get here. $value
5520 // has had stripAltText() called on it, which in turn calls
5521 // replaceLinkHoldersText() on the link. So, the text we're
5522 // getting at this point has already been percent decoded.
5524 // The problematic case is if %25 is in the title, since that
5525 // decodes to %, which could combine with trailing characters.
5526 // However, % is not a valid link title character, so it would
5527 // not parse as a link and the string we received here would
5528 // still contain the encoded %25.
5530 // Hence, double decoded is not an issue. See the test,
5531 // "Should not double decode the link option"
5532 if ( strpos( $value, '%' ) !== false ) {
5533 $value = rawurldecode( $value );
5535 $linkTitle = Title::newFromText( $value );
5536 if ( $linkTitle ) {
5537 $this->mOutput->addLink( $linkTitle );
5538 $type = 'link-title';
5539 $target = $linkTitle;
5542 return [ $type, $target ];
5546 * Give hooks a chance to modify image thumbnail HTML
5548 * @param File $file
5549 * @param array $params
5550 * @param string &$html
5552 public function modifyImageHtml( File $file, array $params, string &$html ) {
5553 $this->hookRunner->onParserModifyImageHTML( $this, $file, $params, $html );
5557 * @param string $caption
5558 * @param LinkHolderArray|false $holders
5559 * @return string
5561 private function stripAltText( $caption, $holders ) {
5562 # Strip bad stuff out of the title (tooltip). We can't just use
5563 # replaceLinkHoldersText() here, because if this function is called
5564 # from handleInternalLinks2(), mLinkHolders won't be up-to-date.
5565 if ( $holders ) {
5566 $tooltip = $holders->replaceText( $caption );
5567 } else {
5568 $tooltip = $this->replaceLinkHoldersText( $caption );
5571 # make sure there are no placeholders in thumbnail attributes
5572 # that are later expanded to html- so expand them now and
5573 # remove the tags
5574 $tooltip = $this->mStripState->unstripBoth( $tooltip );
5575 # Compatibility hack! In HTML certain entity references not terminated
5576 # by a semicolon are decoded (but not if we're in an attribute; that's
5577 # how link URLs get away without properly escaping & in queries).
5578 # But wikitext has always required semicolon-termination of entities,
5579 # so encode & where needed to avoid decode of semicolon-less entities.
5580 # See T209236 and
5581 # https://www.w3.org/TR/html5/syntax.html#named-character-references
5582 # T210437 discusses moving this workaround to Sanitizer::stripAllTags.
5583 $tooltip = preg_replace( "/
5584 & # 1. entity prefix
5585 (?= # 2. followed by:
5586 (?: # a. one of the legacy semicolon-less named entities
5587 A(?:Elig|MP|acute|circ|grave|ring|tilde|uml)|
5588 C(?:OPY|cedil)|E(?:TH|acute|circ|grave|uml)|
5589 GT|I(?:acute|circ|grave|uml)|LT|Ntilde|
5590 O(?:acute|circ|grave|slash|tilde|uml)|QUOT|REG|THORN|
5591 U(?:acute|circ|grave|uml)|Yacute|
5592 a(?:acute|c(?:irc|ute)|elig|grave|mp|ring|tilde|uml)|brvbar|
5593 c(?:cedil|edil|urren)|cent(?!erdot;)|copy(?!sr;)|deg|
5594 divide(?!ontimes;)|e(?:acute|circ|grave|th|uml)|
5595 frac(?:1(?:2|4)|34)|
5596 gt(?!c(?:c|ir)|dot|lPar|quest|r(?:a(?:pprox|rr)|dot|eq(?:less|qless)|less|sim);)|
5597 i(?:acute|circ|excl|grave|quest|uml)|laquo|
5598 lt(?!c(?:c|ir)|dot|hree|imes|larr|quest|r(?:Par|i(?:e|f|));)|
5599 m(?:acr|i(?:cro|ddot))|n(?:bsp|tilde)|
5600 not(?!in(?:E|dot|v(?:a|b|c)|)|ni(?:v(?:a|b|c)|);)|
5601 o(?:acute|circ|grave|rd(?:f|m)|slash|tilde|uml)|
5602 p(?:lusmn|ound)|para(?!llel;)|quot|r(?:aquo|eg)|
5603 s(?:ect|hy|up(?:1|2|3)|zlig)|thorn|times(?!b(?:ar|)|d;)|
5604 u(?:acute|circ|grave|ml|uml)|y(?:acute|en|uml)
5606 (?:[^;]|$)) # b. and not followed by a semicolon
5607 # S = study, for efficiency
5608 /Sx", '&amp;', $tooltip );
5609 $tooltip = Sanitizer::stripAllTags( $tooltip );
5611 return $tooltip;
5615 * Callback from the Sanitizer for expanding items found in HTML attribute
5616 * values, so they can be safely tested and escaped.
5618 * @param string &$text
5619 * @param PPFrame|false $frame
5620 * @return string
5621 * @deprecated since 1.35, internal callback should not have been public
5623 public function attributeStripCallback( &$text, $frame = false ) {
5624 wfDeprecated( __METHOD__, '1.35' );
5625 $text = $this->replaceVariables( $text, $frame );
5626 $text = $this->mStripState->unstripBoth( $text );
5627 return $text;
5631 * Accessor
5633 * @return array
5634 * @since 1.6
5636 public function getTags(): array {
5637 return array_keys( $this->mTagHooks );
5641 * @since 1.32
5642 * @return array
5644 public function getFunctionSynonyms() {
5645 return $this->mFunctionSynonyms;
5649 * @since 1.32
5650 * @return string
5652 public function getUrlProtocols() {
5653 return $this->urlUtils->validProtocols();
5657 * Break wikitext input into sections, and either pull or replace
5658 * some particular section's text.
5660 * External callers should use the getSection and replaceSection methods.
5662 * @param string $text Page wikitext
5663 * @param string|int $sectionId A section identifier string of the form:
5664 * "<flag1> - <flag2> - ... - <section number>"
5666 * Currently the only recognised flag is "T", which means the target section number
5667 * was derived during a template inclusion parse, in other words this is a template
5668 * section edit link. If no flags are given, it was an ordinary section edit link.
5669 * This flag is required to avoid a section numbering mismatch when a section is
5670 * enclosed by "<includeonly>" (T8563).
5672 * The section number 0 pulls the text before the first heading; other numbers will
5673 * pull the given section along with its lower-level subsections. If the section is
5674 * not found, $mode=get will return $newtext, and $mode=replace will return $text.
5676 * Section 0 is always considered to exist, even if it only contains the empty
5677 * string. If $text is the empty string and section 0 is replaced, $newText is
5678 * returned.
5680 * @param string $mode One of "get" or "replace"
5681 * @param string|false $newText Replacement text for section data.
5682 * @param PageReference|null $page
5683 * @return string For "get", the extracted section text.
5684 * for "replace", the whole page with the section replaced.
5686 private function extractSections( $text, $sectionId, $mode, $newText, ?PageReference $page = null ) {
5687 $magicScopeVariable = $this->lock();
5688 $this->startParse(
5689 $page,
5690 ParserOptions::newFromUser( RequestContext::getMain()->getUser() ),
5691 self::OT_PLAIN,
5692 true
5694 $outText = '';
5695 $frame = $this->getPreprocessor()->newFrame();
5697 # Process section extraction flags
5698 $flags = 0;
5699 $sectionParts = explode( '-', $sectionId );
5700 // The section ID may either be a magic string such as 'new' (which should be treated as 0),
5701 // or a numbered section ID in the format of "T-<section index>".
5702 // Explicitly coerce the section index into a number accordingly. (T323373)
5703 $sectionIndex = (int)array_pop( $sectionParts );
5704 foreach ( $sectionParts as $part ) {
5705 if ( $part === 'T' ) {
5706 $flags |= Preprocessor::DOM_FOR_INCLUSION;
5710 # Check for empty input
5711 if ( strval( $text ) === '' ) {
5712 # Only sections 0 and T-0 exist in an empty document
5713 if ( $sectionIndex === 0 ) {
5714 if ( $mode === 'get' ) {
5715 return '';
5718 return $newText;
5719 } else {
5720 if ( $mode === 'get' ) {
5721 return $newText;
5724 return $text;
5728 # Preprocess the text
5729 $root = $this->preprocessToDom( $text, $flags );
5731 # <h> nodes indicate section breaks
5732 # They can only occur at the top level, so we can find them by iterating the root's children
5733 $node = $root->getFirstChild();
5735 # Find the target section
5736 if ( $sectionIndex === 0 ) {
5737 # Section zero doesn't nest, level=big
5738 $targetLevel = 1000;
5739 } else {
5740 while ( $node ) {
5741 if ( $node->getName() === 'h' ) {
5742 $bits = $node->splitHeading();
5743 if ( $bits['i'] == $sectionIndex ) {
5744 $targetLevel = $bits['level'];
5745 break;
5748 if ( $mode === 'replace' ) {
5749 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5751 $node = $node->getNextSibling();
5755 if ( !$node ) {
5756 # Not found
5757 if ( $mode === 'get' ) {
5758 return $newText;
5759 } else {
5760 return $text;
5764 # Find the end of the section, including nested sections
5765 do {
5766 if ( $node->getName() === 'h' ) {
5767 $bits = $node->splitHeading();
5768 $curLevel = $bits['level'];
5769 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable False positive
5770 if ( $bits['i'] != $sectionIndex && $curLevel <= $targetLevel ) {
5771 break;
5774 if ( $mode === 'get' ) {
5775 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5777 $node = $node->getNextSibling();
5778 } while ( $node );
5780 # Write out the remainder (in replace mode only)
5781 if ( $mode === 'replace' ) {
5782 # Output the replacement text
5783 # Add two newlines on -- trailing whitespace in $newText is conventionally
5784 # stripped by the editor, so we need both newlines to restore the paragraph gap
5785 # Only add trailing whitespace if there is newText
5786 if ( $newText != "" ) {
5787 $outText .= $newText . "\n\n";
5790 while ( $node ) {
5791 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5792 $node = $node->getNextSibling();
5796 # Re-insert stripped tags
5797 $outText = rtrim( $this->mStripState->unstripBoth( $outText ) );
5799 return $outText;
5803 * This function returns the text of a section, specified by a number ($section).
5804 * A section is text under a heading like == Heading == or \<h1\>Heading\</h1\>, or
5805 * the first section before any such heading (section 0).
5807 * If a section contains subsections, these are also returned.
5809 * @param string $text Text to look in
5810 * @param string|int $sectionId Section identifier as a number or string
5811 * (e.g. 0, 1 or 'T-1').
5812 * @param string|false $defaultText Default to return if section is not found
5814 * @return string Text of the requested section
5815 * @since 1.7
5817 public function getSection( $text, $sectionId, $defaultText = '' ) {
5818 return $this->extractSections( $text, $sectionId, 'get', $defaultText );
5822 * This function returns $oldtext after the content of the section
5823 * specified by $section has been replaced with $text. If the target
5824 * section does not exist, $oldtext is returned unchanged.
5826 * @param string $oldText Former text of the article
5827 * @param string|int $sectionId Section identifier as a number or string
5828 * (e.g. 0, 1 or 'T-1').
5829 * @param string|false $newText Replacing text
5831 * @return string Modified text
5832 * @since 1.7
5834 public function replaceSection( $oldText, $sectionId, $newText ) {
5835 return $this->extractSections( $oldText, $sectionId, 'replace', $newText );
5839 * Get an array of preprocessor section information.
5841 * Preprocessor sections are those identified by wikitext-style syntax, not
5842 * HTML-style syntax. Templates are not expanded, so these sections do not
5843 * include sections created by templates or parser functions. This is the
5844 * same definition of a section as used by section editing, but not the
5845 * same as TOC generation.
5847 * These sections are typically smaller than those acted on by getSection() and
5848 * replaceSection() since they are not nested. Section nesting could be
5849 * reconstructed from the heading levels.
5851 * The return value is an array of associative array info structures. Each
5852 * associative array contains the following keys, describing a section:
5854 * - index: An integer identifying the section.
5855 * - level: The heading level, e.g. 1 for <h1>. For the section before the
5856 * the first heading, this will be 0.
5857 * - offset: The byte offset within the wikitext at which the section starts
5858 * - heading: The wikitext for the header which introduces the section,
5859 * including equals signs. For the section before the first heading, this
5860 * will be an empty string.
5861 * - text: The complete text of the section.
5863 * @param string $text
5864 * @return array[]
5865 * @internal
5867 public function getFlatSectionInfo( $text ) {
5868 $magicScopeVariable = $this->lock();
5869 $this->startParse(
5870 null,
5871 ParserOptions::newFromUser( RequestContext::getMain()->getUser() ),
5872 self::OT_PLAIN,
5873 true
5875 $frame = $this->getPreprocessor()->newFrame();
5876 $root = $this->preprocessToDom( $text, 0 );
5877 $node = $root->getFirstChild();
5878 $offset = 0;
5879 $currentSection = [
5880 'index' => 0,
5881 'level' => 0,
5882 'offset' => 0,
5883 'heading' => '',
5884 'text' => ''
5886 $sections = [];
5888 while ( $node ) {
5889 $nodeText = $frame->expand( $node, PPFrame::RECOVER_ORIG );
5890 if ( $node->getName() === 'h' ) {
5891 $bits = $node->splitHeading();
5892 $sections[] = $currentSection;
5893 $currentSection = [
5894 'index' => $bits['i'],
5895 'level' => $bits['level'],
5896 'offset' => $offset,
5897 'heading' => $nodeText,
5898 'text' => $nodeText
5900 } else {
5901 $currentSection['text'] .= $nodeText;
5903 $offset += strlen( $nodeText );
5904 $node = $node->getNextSibling();
5906 $sections[] = $currentSection;
5907 return $sections;
5911 * Get the ID of the revision we are parsing
5913 * The return value will be either:
5914 * - a) Positive, indicating a specific revision ID (current or old)
5915 * - b) Zero, meaning the revision ID is specified by getCurrentRevisionRecordCallback()
5916 * - c) Null, meaning the parse is for preview mode and there is no revision
5918 * @return int|null
5919 * @since 1.13
5921 public function getRevisionId() {
5922 return $this->mRevisionId;
5926 * Get the revision record object for $this->mRevisionId
5928 * @return RevisionRecord|null Either a RevisionRecord object or null
5929 * @since 1.35
5931 public function getRevisionRecordObject() {
5932 if ( $this->mRevisionRecordObject ) {
5933 return $this->mRevisionRecordObject;
5936 // NOTE: try to get the RevisionRecord object even if mRevisionId is null.
5937 // This is useful when parsing a revision that has not yet been saved.
5938 // However, if we get back a saved revision even though we are in
5939 // preview mode, we'll have to ignore it, see below.
5940 // NOTE: This callback may be used to inject an OLD revision that was
5941 // already loaded, so "current" is a bit of a misnomer. We can't just
5942 // skip it if mRevisionId is set.
5943 $rev = call_user_func(
5944 $this->mOptions->getCurrentRevisionRecordCallback(),
5945 $this->getTitle(),
5946 $this
5949 if ( !$rev ) {
5950 // The revision record callback returns `false` (not null) to
5951 // indicate that the revision is missing. (See for example
5952 // Parser::statelessFetchRevisionRecord(), the default callback.)
5953 // This API expects `null` instead. (T251952)
5954 return null;
5957 if ( $this->mRevisionId === null && $rev->getId() ) {
5958 // We are in preview mode (mRevisionId is null), and the current revision callback
5959 // returned an existing revision. Ignore it and return null, it's probably the page's
5960 // current revision, which is not what we want here. Note that we do want to call the
5961 // callback to allow the unsaved revision to be injected here, e.g. for
5962 // self-transclusion previews.
5963 return null;
5966 // If the parse is for a new revision, then the callback should have
5967 // already been set to force the object and should match mRevisionId.
5968 // If not, try to fetch by mRevisionId instead.
5969 if ( $this->mRevisionId && $rev->getId() != $this->mRevisionId ) {
5970 $rev = MediaWikiServices::getInstance()
5971 ->getRevisionLookup()
5972 ->getRevisionById( $this->mRevisionId );
5975 $this->mRevisionRecordObject = $rev;
5977 return $this->mRevisionRecordObject;
5981 * Get the timestamp associated with the current revision, adjusted for
5982 * the default server-local timestamp
5983 * @return string TS_MW timestamp
5984 * @since 1.9
5986 public function getRevisionTimestamp() {
5987 if ( $this->mRevisionTimestamp !== null ) {
5988 return $this->mRevisionTimestamp;
5991 # Use specified revision timestamp, falling back to the current timestamp
5992 $revObject = $this->getRevisionRecordObject();
5993 $timestamp = $revObject && $revObject->getTimestamp()
5994 ? $revObject->getTimestamp()
5995 : $this->mOptions->getTimestamp();
5996 $this->mOutput->setRevisionTimestampUsed( $timestamp ); // unadjusted time zone
5998 # The cryptic '' timezone parameter tells to use the site-default
5999 # timezone offset instead of the user settings.
6000 # Since this value will be saved into the parser cache, served
6001 # to other users, and potentially even used inside links and such,
6002 # it needs to be consistent for all visitors.
6003 $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' );
6005 return $this->mRevisionTimestamp;
6009 * Get the name of the user that edited the last revision
6011 * @return string|null User name
6012 * @since 1.15
6014 public function getRevisionUser(): ?string {
6015 if ( $this->mRevisionUser === null ) {
6016 $revObject = $this->getRevisionRecordObject();
6018 # if this template is subst: the revision id will be blank,
6019 # so just use the current user's name
6020 if ( $revObject && $revObject->getUser() ) {
6021 $this->mRevisionUser = $revObject->getUser()->getName();
6022 } elseif ( $this->ot['wiki'] || $this->mOptions->getIsPreview() ) {
6023 $this->mRevisionUser = $this->getUserIdentity()->getName();
6024 } else {
6025 # Note that we fall through here with
6026 # $this->mRevisionUser still null
6029 return $this->mRevisionUser;
6033 * Get the size of the revision
6035 * @return int|null Revision size
6036 * @since 1.22
6038 public function getRevisionSize() {
6039 if ( $this->mRevisionSize === null ) {
6040 $revObject = $this->getRevisionRecordObject();
6042 # if this variable is subst: the revision id will be blank,
6043 # so just use the parser input size, because the own substitution
6044 # will change the size.
6045 if ( $revObject ) {
6046 $this->mRevisionSize = $revObject->getSize();
6047 } else {
6048 $this->mRevisionSize = $this->mInputSize;
6051 return $this->mRevisionSize;
6055 * Mutator for the 'defaultsort' page property.
6057 * @param string $sort New value
6058 * @since 1.0
6059 * @deprecated since 1.38, use
6060 * $parser->getOutput()->setPageProperty('defaultsort', $sort)
6062 public function setDefaultSort( $sort ) {
6063 wfDeprecated( __METHOD__, '1.38' );
6064 $this->mOutput->setPageProperty( 'defaultsort', $sort );
6068 * Accessor for the 'defaultsort' page property.
6069 * Will use the empty string if none is set.
6071 * This value is treated as a prefix, so the
6072 * empty string is equivalent to sorting by
6073 * page name.
6075 * @return string
6076 * @since 1.9
6077 * @deprecated since 1.38, use
6078 * $parser->getOutput()->getPageProperty('defaultsort') ?? ''
6080 public function getDefaultSort() {
6081 wfDeprecated( __METHOD__, '1.38' );
6082 return $this->mOutput->getPageProperty( 'defaultsort' ) ?? '';
6086 * Accessor for the 'defaultsort' page property.
6087 * Unlike getDefaultSort(), will return false if none is set
6089 * @return string|false
6090 * @since 1.14
6091 * @deprecated since 1.38, use
6092 * $parser->getOutput()->getPageProperty('defaultsort') ?? false
6094 public function getCustomDefaultSort() {
6095 wfDeprecated( __METHOD__, '1.38' );
6096 return $this->mOutput->getPageProperty( 'defaultsort' ) ?? false;
6099 private static function getSectionNameFromStrippedText( $text ) {
6100 $text = Sanitizer::normalizeSectionNameWhitespace( $text );
6101 $text = Sanitizer::decodeCharReferences( $text );
6102 $text = self::normalizeSectionName( $text );
6103 return $text;
6106 private static function makeAnchor( $sectionName ) {
6107 return '#' . Sanitizer::escapeIdForLink( $sectionName );
6110 private function makeLegacyAnchor( $sectionName ) {
6111 $fragmentMode = $this->svcOptions->get( MainConfigNames::FragmentMode );
6112 if ( isset( $fragmentMode[1] ) && $fragmentMode[1] === 'legacy' ) {
6113 // ForAttribute() and ForLink() are the same for legacy encoding
6114 $id = Sanitizer::escapeIdForAttribute( $sectionName, Sanitizer::ID_FALLBACK );
6115 } else {
6116 $id = Sanitizer::escapeIdForLink( $sectionName );
6119 return "#$id";
6123 * Try to guess the section anchor name based on a wikitext fragment
6124 * presumably extracted from a heading, for example "Header" from
6125 * "== Header ==".
6127 * @param string $text
6128 * @return string Anchor (starting with '#')
6129 * @since 1.12
6131 public function guessSectionNameFromWikiText( $text ) {
6132 # Strip out wikitext links(they break the anchor)
6133 $text = $this->stripSectionName( $text );
6134 $sectionName = self::getSectionNameFromStrippedText( $text );
6135 return self::makeAnchor( $sectionName );
6139 * Same as guessSectionNameFromWikiText(), but produces legacy anchors
6140 * instead, if possible. For use in redirects, since various versions
6141 * of Microsoft browsers interpret Location: headers as something other
6142 * than UTF-8, resulting in breakage.
6144 * @param string $text The section name
6145 * @return string Anchor (starting with '#')
6146 * @since 1.17
6148 public function guessLegacySectionNameFromWikiText( $text ) {
6149 # Strip out wikitext links(they break the anchor)
6150 $text = $this->stripSectionName( $text );
6151 $sectionName = self::getSectionNameFromStrippedText( $text );
6152 return $this->makeLegacyAnchor( $sectionName );
6156 * Like guessSectionNameFromWikiText(), but takes already-stripped text as input.
6157 * @param string $text Section name (plain text)
6158 * @return string Anchor (starting with '#')
6159 * @since 1.31
6161 public static function guessSectionNameFromStrippedText( $text ) {
6162 $sectionName = self::getSectionNameFromStrippedText( $text );
6163 return self::makeAnchor( $sectionName );
6167 * Apply the same normalization as code making links to this section would
6169 * @param string $text
6170 * @return string
6172 private static function normalizeSectionName( $text ) {
6173 # T90902: ensure the same normalization is applied for IDs as to links
6174 /** @var MediaWikiTitleCodec $titleParser */
6175 $titleParser = MediaWikiServices::getInstance()->getTitleParser();
6176 '@phan-var MediaWikiTitleCodec $titleParser';
6177 try {
6179 $parts = $titleParser->splitTitleString( "#$text" );
6180 } catch ( MalformedTitleException $ex ) {
6181 return $text;
6183 return $parts['fragment'];
6187 * Strips a text string of wikitext for use in a section anchor
6189 * Accepts a text string and then removes all wikitext from the
6190 * string and leaves only the resultant text (i.e. the result of
6191 * [[User:WikiSysop|Sysop]] would be "Sysop" and the result of
6192 * [[User:WikiSysop]] would be "User:WikiSysop") - this is intended
6193 * to create valid section anchors by mimicking the output of the
6194 * parser when headings are parsed.
6196 * @param string $text Text string to be stripped of wikitext
6197 * for use in a Section anchor
6198 * @return string Filtered text string
6199 * @since 1.12
6201 public function stripSectionName( $text ) {
6202 # Strip internal link markup
6203 $text = preg_replace( '/\[\[:?([^[|]+)\|([^[]+)\]\]/', '$2', $text );
6204 $text = preg_replace( '/\[\[:?([^[]+)\|?\]\]/', '$1', $text );
6206 # Strip external link markup
6207 # @todo FIXME: Not tolerant to blank link text
6208 # I.E. [https://www.mediawiki.org] will render as [1] or something depending
6209 # on how many empty links there are on the page - need to figure that out.
6210 $text = preg_replace(
6211 '/\[(?i:' . $this->urlUtils->validProtocols() . ')([^ ]+?) ([^[]+)\]/', '$2', $text );
6213 # Parse wikitext quotes (italics & bold)
6214 $text = $this->doQuotes( $text );
6216 # Strip HTML tags
6217 $text = StringUtils::delimiterReplace( '<', '>', '', $text );
6218 return $text;
6222 * Call a callback function on all regions of the given text that are not
6223 * inside strip markers, and replace those regions with the return value
6224 * of the callback. For example, with input:
6226 * aaa<MARKER>bbb
6228 * This will call the callback function twice, with 'aaa' and 'bbb'. Those
6229 * two strings will be replaced with the value returned by the callback in
6230 * each case.
6232 * @param string $s
6233 * @param callable $callback
6235 * @return string
6236 * @internal
6237 * @since 1.12
6239 public function markerSkipCallback( $s, callable $callback ) {
6240 $i = 0;
6241 $out = '';
6242 while ( $i < strlen( $s ) ) {
6243 $markerStart = strpos( $s, self::MARKER_PREFIX, $i );
6244 if ( $markerStart === false ) {
6245 $out .= call_user_func( $callback, substr( $s, $i ) );
6246 break;
6247 } else {
6248 $out .= call_user_func( $callback, substr( $s, $i, $markerStart - $i ) );
6249 $markerEnd = strpos( $s, self::MARKER_SUFFIX, $markerStart );
6250 if ( $markerEnd === false ) {
6251 $out .= substr( $s, $markerStart );
6252 break;
6253 } else {
6254 $markerEnd += strlen( self::MARKER_SUFFIX );
6255 $out .= substr( $s, $markerStart, $markerEnd - $markerStart );
6256 $i = $markerEnd;
6260 return $out;
6264 * Remove any strip markers found in the given text.
6266 * @param string $text
6267 * @return string
6268 * @since 1.19
6270 public function killMarkers( $text ) {
6271 return $this->mStripState->killMarkers( $text );
6275 * Parsed a width param of imagelink like 300px or 200x300px
6277 * @param string $value
6278 * @param bool $parseHeight
6280 * @return array
6281 * @since 1.20
6282 * @internal
6284 public static function parseWidthParam( $value, $parseHeight = true ) {
6285 $parsedWidthParam = [];
6286 if ( $value === '' ) {
6287 return $parsedWidthParam;
6289 $m = [];
6290 # (T15500) In both cases (width/height and width only),
6291 # permit trailing "px" for backward compatibility.
6292 if ( $parseHeight && preg_match( '/^([0-9]*)x([0-9]*)\s*(?:px)?\s*$/', $value, $m ) ) {
6293 $width = intval( $m[1] );
6294 $height = intval( $m[2] );
6295 $parsedWidthParam['width'] = $width;
6296 $parsedWidthParam['height'] = $height;
6297 } elseif ( preg_match( '/^[0-9]*\s*(?:px)?\s*$/', $value ) ) {
6298 $width = intval( $value );
6299 $parsedWidthParam['width'] = $width;
6301 return $parsedWidthParam;
6305 * Lock the current instance of the parser.
6307 * This is meant to stop someone from calling the parser
6308 * recursively and messing up all the strip state.
6310 * @throws MWException If parser is in a parse
6311 * @return ScopedCallback The lock will be released once the return value goes out of scope.
6313 protected function lock() {
6314 if ( $this->mInParse ) {
6315 throw new MWException( "Parser state cleared while parsing. "
6316 . "Did you call Parser::parse recursively? Lock is held by: " . $this->mInParse );
6319 // Save the backtrace when locking, so that if some code tries locking again,
6320 // we can print the lock owner's backtrace for easier debugging
6321 $e = new Exception;
6322 $this->mInParse = $e->getTraceAsString();
6324 $recursiveCheck = new ScopedCallback( function () {
6325 $this->mInParse = false;
6326 } );
6328 return $recursiveCheck;
6332 * Will entry points such as parse() throw an exception due to the parser
6333 * already being active?
6335 * @since 1.39
6336 * @return bool
6338 public function isLocked() {
6339 return (bool)$this->mInParse;
6343 * Strip outer <p></p> tag from the HTML source of a single paragraph.
6345 * Returns original HTML if the <p/> tag has any attributes, if there's no wrapping <p/> tag,
6346 * or if there is more than one <p/> tag in the input HTML.
6348 * @param string $html
6349 * @return string
6350 * @since 1.24
6352 public static function stripOuterParagraph( $html ) {
6353 $m = [];
6354 if ( preg_match( '/^<p>(.*)\n?<\/p>\n?$/sU', $html, $m ) && strpos( $m[1], '</p>' ) === false ) {
6355 $html = $m[1];
6358 return $html;
6362 * Add HTML tags marking the parts of a page title, to be displayed in the first heading of the page.
6364 * @internal
6365 * @since 1.39
6366 * @param string|HtmlArmor $nsText
6367 * @param string|HtmlArmor $nsSeparator
6368 * @param string|HtmlArmor $mainText
6369 * @return string HTML
6371 public static function formatPageTitle( $nsText, $nsSeparator, $mainText ): string {
6372 $html = '';
6373 if ( $nsText !== '' ) {
6374 $html .= '<span class="mw-page-title-namespace">' . HtmlArmor::getHtml( $nsText ) . '</span>';
6375 $html .= '<span class="mw-page-title-separator">' . HtmlArmor::getHtml( $nsSeparator ) . '</span>';
6377 $html .= '<span class="mw-page-title-main">' . HtmlArmor::getHtml( $mainText ) . '</span>';
6378 return $html;
6382 * Return this parser if it is not doing anything, otherwise
6383 * get a fresh parser. You can use this method by doing
6384 * $newParser = $oldParser->getFreshParser(), or more simply
6385 * $oldParser->getFreshParser()->parse( ... );
6386 * if you're unsure if $oldParser is safe to use.
6388 * @deprecated since 1.39, use ParserFactory::getInstance(), Hard-deprecated since 1.41.
6389 * @since 1.24
6390 * @return Parser A parser object that is not parsing anything
6392 public function getFreshParser() {
6393 wfDeprecated( __METHOD__, '1.39' );
6394 if ( $this->mInParse ) {
6395 return $this->factory->create();
6396 } else {
6397 return $this;
6402 * Set's up the PHP implementation of OOUI for use in this request
6403 * and instructs OutputPage to enable OOUI for itself.
6405 * @since 1.26
6406 * @deprecated since 1.35, use $parser->getOutput()->setEnableOOUI() instead.
6408 public function enableOOUI() {
6409 wfDeprecated( __METHOD__, '1.35' );
6410 OutputPage::setupOOUI();
6411 $this->mOutput->setEnableOOUI( true );
6415 * Sets the flag on the parser output but also does some debug logging.
6416 * Note that there is a copy of this method in CoreMagicVariables as well.
6417 * @param string $flag
6418 * @param string $reason
6420 private function setOutputFlag( string $flag, string $reason ): void {
6421 $this->mOutput->setOutputFlag( $flag );
6422 $name = $this->getTitle()->getPrefixedText();
6423 $this->logger->debug( __METHOD__ . ": set $flag flag on '$name'; $reason" );