Improve type hints in parser related classes
[lhc/web/wiklou.git] / includes / parser / Parser.php
1 <?php
2 /**
3 * PHP parser that converts wiki markup to HTML.
4 *
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.
9 *
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.
14 *
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
19 *
20 * @file
21 * @ingroup Parser
22 */
23 use MediaWiki\Config\ServiceOptions;
24 use MediaWiki\Linker\LinkRenderer;
25 use MediaWiki\Linker\LinkRendererFactory;
26 use MediaWiki\Linker\LinkTarget;
27 use MediaWiki\MediaWikiServices;
28 use MediaWiki\Special\SpecialPageFactory;
29 use Wikimedia\ScopedCallback;
30
31 /**
32 * @defgroup Parser Parser
33 */
34
35 /**
36 * PHP Parser - Processes wiki markup (which uses a more user-friendly
37 * syntax, such as "[[link]]" for making links), and provides a one-way
38 * transformation of that wiki markup it into (X)HTML output / markup
39 * (which in turn the browser understands, and can display).
40 *
41 * There are seven main entry points into the Parser class:
42 *
43 * - Parser::parse()
44 * produces HTML output
45 * - Parser::preSaveTransform()
46 * produces altered wiki markup
47 * - Parser::preprocess()
48 * removes HTML comments and expands templates
49 * - Parser::cleanSig() and Parser::cleanSigInSig()
50 * cleans a signature before saving it to preferences
51 * - Parser::getSection()
52 * return the content of a section from an article for section editing
53 * - Parser::replaceSection()
54 * replaces a section by number inside an article
55 * - Parser::getPreloadText()
56 * removes <noinclude> sections and <includeonly> tags
57 *
58 * @warning $wgUser or $wgTitle or $wgRequest or $wgLang. Keep them away!
59 *
60 * @par Settings:
61 * $wgNamespacesWithSubpages
62 *
63 * @par Settings only within ParserOptions:
64 * $wgAllowExternalImages
65 * $wgAllowSpecialInclusion
66 * $wgInterwikiMagic
67 * $wgMaxArticleSize
68 *
69 * @ingroup Parser
70 */
71 class Parser {
72 /**
73 * Update this version number when the ParserOutput format
74 * changes in an incompatible way, so the parser cache
75 * can automatically discard old data.
76 */
77 const VERSION = '1.6.4';
78
79 /**
80 * Update this version number when the output of serialiseHalfParsedText()
81 * changes in an incompatible way
82 */
83 const HALF_PARSED_VERSION = 2;
84
85 # Flags for Parser::setFunctionHook
86 const SFH_NO_HASH = 1;
87 const SFH_OBJECT_ARGS = 2;
88
89 # Constants needed for external link processing
90 # Everything except bracket, space, or control characters
91 # \p{Zs} is unicode 'separator, space' category. It covers the space 0x20
92 # as well as U+3000 is IDEOGRAPHIC SPACE for T21052
93 # \x{FFFD} is the Unicode replacement character, which Preprocessor_DOM
94 # uses to replace invalid HTML characters.
95 const EXT_LINK_URL_CLASS = '[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]';
96 # Simplified expression to match an IPv4 or IPv6 address, or
97 # at least one character of a host name (embeds EXT_LINK_URL_CLASS)
98 const EXT_LINK_ADDR = '(?:[0-9.]+|\\[(?i:[0-9a-f:.]+)\\]|[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}])';
99 # RegExp to make image URLs (embeds IPv6 part of EXT_LINK_ADDR)
100 // phpcs:ignore Generic.Files.LineLength
101 const EXT_IMAGE_REGEX = '/^(http:\/\/|https:\/\/)((?:\\[(?i:[0-9a-f:.]+)\\])?[^][<>"\\x00-\\x20\\x7F\p{Zs}\x{FFFD}]+)
102 \\/([A-Za-z0-9_.,~%\\-+&;#*?!=()@\\x80-\\xFF]+)\\.((?i)gif|png|jpg|jpeg)$/Sxu';
103
104 # Regular expression for a non-newline space
105 const SPACE_NOT_NL = '(?:\t|&nbsp;|&\#0*160;|&\#[Xx]0*[Aa]0;|\p{Zs})';
106
107 # Flags for preprocessToDom
108 const PTD_FOR_INCLUSION = 1;
109
110 # Allowed values for $this->mOutputType
111 # Parameter to startExternalParse().
112 const OT_HTML = 1; # like parse()
113 const OT_WIKI = 2; # like preSaveTransform()
114 const OT_PREPROCESS = 3; # like preprocess()
115 const OT_MSG = 3;
116 const OT_PLAIN = 4; # like extractSections() - portions of the original are returned unchanged.
117
118 /**
119 * @var string Prefix and suffix for temporary replacement strings
120 * for the multipass parser.
121 *
122 * \x7f should never appear in input as it's disallowed in XML.
123 * Using it at the front also gives us a little extra robustness
124 * since it shouldn't match when butted up against identifier-like
125 * string constructs.
126 *
127 * Must not consist of all title characters, or else it will change
128 * the behavior of <nowiki> in a link.
129 *
130 * Must have a character that needs escaping in attributes, otherwise
131 * someone could put a strip marker in an attribute, to get around
132 * escaping quote marks, and break out of the attribute. Thus we add
133 * `'".
134 */
135 const MARKER_SUFFIX = "-QINU`\"'\x7f";
136 const MARKER_PREFIX = "\x7f'\"`UNIQ-";
137
138 # Markers used for wrapping the table of contents
139 const TOC_START = '<mw:toc>';
140 const TOC_END = '</mw:toc>';
141
142 /** @var int Assume that no output will later be saved this many seconds after parsing */
143 const MAX_TTS = 900;
144
145 # Persistent:
146 public $mTagHooks = [];
147 public $mTransparentTagHooks = [];
148 public $mFunctionHooks = [];
149 public $mFunctionSynonyms = [ 0 => [], 1 => [] ];
150 public $mFunctionTagHooks = [];
151 public $mStripList = [];
152 public $mDefaultStripList = [];
153 public $mVarCache = [];
154 public $mImageParams = [];
155 public $mImageParamsMagicArray = [];
156 public $mMarkerIndex = 0;
157 /**
158 * @var bool Whether firstCallInit still needs to be called
159 */
160 public $mFirstCall = true;
161
162 # Initialised by initialiseVariables()
163
164 /**
165 * @var MagicWordArray
166 */
167 public $mVariables;
168
169 /**
170 * @var MagicWordArray
171 */
172 public $mSubstWords;
173
174 /**
175 * @deprecated since 1.34, there should be no need to use this
176 * @var array
177 */
178 public $mConf;
179
180 # Initialised in constructor
181 public $mExtLinkBracketedRegex, $mUrlProtocols;
182
183 # Initialized in getPreprocessor()
184 /** @var Preprocessor */
185 public $mPreprocessor;
186
187 # Cleared with clearState():
188 /**
189 * @var ParserOutput
190 */
191 public $mOutput;
192 public $mAutonumber;
193
194 /**
195 * @var StripState
196 */
197 public $mStripState;
198
199 public $mIncludeCount;
200 /**
201 * @var LinkHolderArray
202 */
203 public $mLinkHolders;
204
205 public $mLinkID;
206 public $mIncludeSizes, $mPPNodeCount, $mGeneratedPPNodeCount, $mHighestExpansionDepth;
207 public $mDefaultSort;
208 public $mTplRedirCache, $mHeadings, $mDoubleUnderscores;
209 public $mExpensiveFunctionCount; # number of expensive parser function calls
210 public $mShowToc, $mForceTocPosition;
211 /** @var array */
212 public $mTplDomCache;
213
214 /**
215 * @var User
216 */
217 public $mUser; # User object; only used when doing pre-save transform
218
219 # Temporary
220 # These are variables reset at least once per parse regardless of $clearState
221
222 /**
223 * @var ParserOptions
224 */
225 public $mOptions;
226
227 /**
228 * @var Title
229 */
230 public $mTitle; # Title context, used for self-link rendering and similar things
231 public $mOutputType; # Output type, one of the OT_xxx constants
232 public $ot; # Shortcut alias, see setOutputType()
233 public $mRevisionObject; # The revision object of the specified revision ID
234 public $mRevisionId; # ID to display in {{REVISIONID}} tags
235 public $mRevisionTimestamp; # The timestamp of the specified revision ID
236 public $mRevisionUser; # User to display in {{REVISIONUSER}} tag
237 public $mRevisionSize; # Size to display in {{REVISIONSIZE}} variable
238 public $mRevIdForTs; # The revision ID which was used to fetch the timestamp
239 public $mInputSize = false; # For {{PAGESIZE}} on current page.
240
241 /**
242 * @var array Array with the language name of each language link (i.e. the
243 * interwiki prefix) in the key, value arbitrary. Used to avoid sending
244 * duplicate language links to the ParserOutput.
245 */
246 public $mLangLinkLanguages;
247
248 /**
249 * @var MapCacheLRU|null
250 * @since 1.24
251 *
252 * A cache of the current revisions of titles. Keys are $title->getPrefixedDbKey()
253 */
254 public $currentRevisionCache;
255
256 /**
257 * @var bool|string Recursive call protection.
258 * This variable should be treated as if it were private.
259 */
260 public $mInParse = false;
261
262 /** @var SectionProfiler */
263 protected $mProfiler;
264
265 /**
266 * @var LinkRenderer
267 */
268 protected $mLinkRenderer;
269
270 /** @var MagicWordFactory */
271 private $magicWordFactory;
272
273 /** @var Language */
274 private $contLang;
275
276 /** @var ParserFactory */
277 private $factory;
278
279 /** @var SpecialPageFactory */
280 private $specialPageFactory;
281
282 /**
283 * This is called $svcOptions instead of $options like elsewhere to avoid confusion with
284 * $mOptions, which is public and widely used, and also with the local variable $options used
285 * for ParserOptions throughout this file.
286 *
287 * @var ServiceOptions
288 */
289 private $svcOptions;
290
291 /** @var LinkRendererFactory */
292 private $linkRendererFactory;
293
294 /** @var NamespaceInfo */
295 private $nsInfo;
296
297 /**
298 * TODO Make this a const when HHVM support is dropped (T192166)
299 *
300 * @var array
301 * @since 1.33
302 */
303 public static $constructorOptions = [
304 // See $wgParserConf documentation
305 'class',
306 'preprocessorClass',
307 // See documentation for the corresponding config options
308 'ArticlePath',
309 'EnableScaryTranscluding',
310 'ExtraInterlanguageLinkPrefixes',
311 'FragmentMode',
312 'LanguageCode',
313 'MaxSigChars',
314 'MaxTocLevel',
315 'MiserMode',
316 'ScriptPath',
317 'Server',
318 'ServerName',
319 'ShowHostnames',
320 'Sitename',
321 'StylePath',
322 'TranscludeCacheExpiry',
323 ];
324
325 /**
326 * Constructing parsers directly is deprecated! Use a ParserFactory.
327 *
328 * @param ServiceOptions|null $svcOptions
329 * @param MagicWordFactory|null $magicWordFactory
330 * @param Language|null $contLang Content language
331 * @param ParserFactory|null $factory
332 * @param string|null $urlProtocols As returned from wfUrlProtocols()
333 * @param SpecialPageFactory|null $spFactory
334 * @param LinkRendererFactory|null $linkRendererFactory
335 * @param NamespaceInfo|null $nsInfo
336 */
337 public function __construct(
338 $svcOptions = null, MagicWordFactory $magicWordFactory = null,
339 Language $contLang = null, ParserFactory $factory = null, $urlProtocols = null,
340 SpecialPageFactory $spFactory = null, $linkRendererFactory = null, $nsInfo = null
341 ) {
342 $services = MediaWikiServices::getInstance();
343 if ( !$svcOptions || is_array( $svcOptions ) ) {
344 // Pre-1.34 calling convention is the first parameter is just ParserConf, the seventh is
345 // Config, and the eighth is LinkRendererFactory.
346 $this->mConf = (array)$svcOptions;
347 if ( empty( $this->mConf['class'] ) ) {
348 $this->mConf['class'] = self::class;
349 }
350 if ( empty( $this->mConf['preprocessorClass'] ) ) {
351 $this->mConf['preprocessorClass'] = self::getDefaultPreprocessorClass();
352 }
353 $this->svcOptions = new ServiceOptions( self::$constructorOptions,
354 $this->mConf,
355 func_num_args() > 6 ? func_get_arg( 6 ) : $services->getMainConfig()
356 );
357 $linkRendererFactory = func_num_args() > 7 ? func_get_arg( 7 ) : null;
358 $nsInfo = func_num_args() > 8 ? func_get_arg( 8 ) : null;
359 } else {
360 // New calling convention
361 $svcOptions->assertRequiredOptions( self::$constructorOptions );
362 // $this->mConf is public, so we'll keep those two options there as well for
363 // compatibility until it's removed
364 $this->mConf = [
365 'class' => $svcOptions->get( 'class' ),
366 'preprocessorClass' => $svcOptions->get( 'preprocessorClass' ),
367 ];
368 $this->svcOptions = $svcOptions;
369 }
370
371 $this->mUrlProtocols = $urlProtocols ?? wfUrlProtocols();
372 $this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->mUrlProtocols . ')' .
373 self::EXT_LINK_ADDR .
374 self::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*?)\]/Su';
375
376 $this->magicWordFactory = $magicWordFactory ??
377 $services->getMagicWordFactory();
378
379 $this->contLang = $contLang ?? $services->getContentLanguage();
380
381 $this->factory = $factory ?? $services->getParserFactory();
382 $this->specialPageFactory = $spFactory ?? $services->getSpecialPageFactory();
383 $this->linkRendererFactory = $linkRendererFactory ?? $services->getLinkRendererFactory();
384 $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
385 }
386
387 /**
388 * Reduce memory usage to reduce the impact of circular references
389 */
390 public function __destruct() {
391 if ( isset( $this->mLinkHolders ) ) {
392 unset( $this->mLinkHolders );
393 }
394 foreach ( $this as $name => $value ) {
395 unset( $this->$name );
396 }
397 }
398
399 /**
400 * Allow extensions to clean up when the parser is cloned
401 */
402 public function __clone() {
403 $this->mInParse = false;
404
405 // T58226: When you create a reference "to" an object field, that
406 // makes the object field itself be a reference too (until the other
407 // reference goes out of scope). When cloning, any field that's a
408 // reference is copied as a reference in the new object. Both of these
409 // are defined PHP5 behaviors, as inconvenient as it is for us when old
410 // hooks from PHP4 days are passing fields by reference.
411 foreach ( [ 'mStripState', 'mVarCache' ] as $k ) {
412 // Make a non-reference copy of the field, then rebind the field to
413 // reference the new copy.
414 $tmp = $this->$k;
415 $this->$k =& $tmp;
416 unset( $tmp );
417 }
418
419 Hooks::run( 'ParserCloned', [ $this ] );
420 }
421
422 /**
423 * Which class should we use for the preprocessor if not otherwise specified?
424 *
425 * @since 1.34
426 * @deprecated since 1.34, removing configurability of preprocessor
427 * @return string
428 */
429 public static function getDefaultPreprocessorClass() {
430 return Preprocessor_Hash::class;
431 }
432
433 /**
434 * Do various kinds of initialisation on the first call of the parser
435 */
436 public function firstCallInit() {
437 if ( !$this->mFirstCall ) {
438 return;
439 }
440 $this->mFirstCall = false;
441
442 CoreParserFunctions::register( $this );
443 CoreTagHooks::register( $this );
444 $this->initialiseVariables();
445
446 // Avoid PHP 7.1 warning from passing $this by reference
447 $parser = $this;
448 Hooks::run( 'ParserFirstCallInit', [ &$parser ] );
449 }
450
451 /**
452 * Clear Parser state
453 *
454 * @private
455 */
456 public function clearState() {
457 $this->firstCallInit();
458 $this->mOutput = new ParserOutput;
459 $this->mOptions->registerWatcher( [ $this->mOutput, 'recordOption' ] );
460 $this->mAutonumber = 0;
461 $this->mIncludeCount = [];
462 $this->mLinkHolders = new LinkHolderArray( $this );
463 $this->mLinkID = 0;
464 $this->mRevisionObject = $this->mRevisionTimestamp =
465 $this->mRevisionId = $this->mRevisionUser = $this->mRevisionSize = null;
466 $this->mVarCache = [];
467 $this->mUser = null;
468 $this->mLangLinkLanguages = [];
469 $this->currentRevisionCache = null;
470
471 $this->mStripState = new StripState( $this );
472
473 # Clear these on every parse, T6549
474 $this->mTplRedirCache = $this->mTplDomCache = [];
475
476 $this->mShowToc = true;
477 $this->mForceTocPosition = false;
478 $this->mIncludeSizes = [
479 'post-expand' => 0,
480 'arg' => 0,
481 ];
482 $this->mPPNodeCount = 0;
483 $this->mGeneratedPPNodeCount = 0;
484 $this->mHighestExpansionDepth = 0;
485 $this->mDefaultSort = false;
486 $this->mHeadings = [];
487 $this->mDoubleUnderscores = [];
488 $this->mExpensiveFunctionCount = 0;
489
490 # Fix cloning
491 if ( isset( $this->mPreprocessor ) && $this->mPreprocessor->parser !== $this ) {
492 $this->mPreprocessor = null;
493 }
494
495 $this->mProfiler = new SectionProfiler();
496
497 // Avoid PHP 7.1 warning from passing $this by reference
498 $parser = $this;
499 Hooks::run( 'ParserClearState', [ &$parser ] );
500 }
501
502 /**
503 * Convert wikitext to HTML
504 * Do not call this function recursively.
505 *
506 * @param string $text Text we want to parse
507 * @param-taint $text escapes_htmlnoent
508 * @param Title $title
509 * @param ParserOptions $options
510 * @param bool $linestart
511 * @param bool $clearState
512 * @param int|null $revid Number to pass in {{REVISIONID}}
513 * @return ParserOutput A ParserOutput
514 * @return-taint escaped
515 */
516 public function parse(
517 $text, Title $title, ParserOptions $options,
518 $linestart = true, $clearState = true, $revid = null
519 ) {
520 if ( $clearState ) {
521 // We use U+007F DELETE to construct strip markers, so we have to make
522 // sure that this character does not occur in the input text.
523 $text = strtr( $text, "\x7f", "?" );
524 $magicScopeVariable = $this->lock();
525 }
526 // Strip U+0000 NULL (T159174)
527 $text = str_replace( "\000", '', $text );
528
529 $this->startParse( $title, $options, self::OT_HTML, $clearState );
530
531 $this->currentRevisionCache = null;
532 $this->mInputSize = strlen( $text );
533 if ( $this->mOptions->getEnableLimitReport() ) {
534 $this->mOutput->resetParseStartTime();
535 }
536
537 $oldRevisionId = $this->mRevisionId;
538 $oldRevisionObject = $this->mRevisionObject;
539 $oldRevisionTimestamp = $this->mRevisionTimestamp;
540 $oldRevisionUser = $this->mRevisionUser;
541 $oldRevisionSize = $this->mRevisionSize;
542 if ( $revid !== null ) {
543 $this->mRevisionId = $revid;
544 $this->mRevisionObject = null;
545 $this->mRevisionTimestamp = null;
546 $this->mRevisionUser = null;
547 $this->mRevisionSize = null;
548 }
549
550 // Avoid PHP 7.1 warning from passing $this by reference
551 $parser = $this;
552 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
553 # No more strip!
554 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
555 $text = $this->internalParse( $text );
556 Hooks::run( 'ParserAfterParse', [ &$parser, &$text, &$this->mStripState ] );
557
558 $text = $this->internalParseHalfParsed( $text, true, $linestart );
559
560 /**
561 * A converted title will be provided in the output object if title and
562 * content conversion are enabled, the article text does not contain
563 * a conversion-suppressing double-underscore tag, and no
564 * {{DISPLAYTITLE:...}} is present. DISPLAYTITLE takes precedence over
565 * automatic link conversion.
566 */
567 if ( !( $options->getDisableTitleConversion()
568 || isset( $this->mDoubleUnderscores['nocontentconvert'] )
569 || isset( $this->mDoubleUnderscores['notitleconvert'] )
570 || $this->mOutput->getDisplayTitle() !== false )
571 ) {
572 $convruletitle = $this->getTargetLanguage()->getConvRuleTitle();
573 if ( $convruletitle ) {
574 $this->mOutput->setTitleText( $convruletitle );
575 } else {
576 $titleText = $this->getTargetLanguage()->convertTitle( $title );
577 $this->mOutput->setTitleText( $titleText );
578 }
579 }
580
581 # Compute runtime adaptive expiry if set
582 $this->mOutput->finalizeAdaptiveCacheExpiry();
583
584 # Warn if too many heavyweight parser functions were used
585 if ( $this->mExpensiveFunctionCount > $this->mOptions->getExpensiveParserFunctionLimit() ) {
586 $this->limitationWarn( 'expensive-parserfunction',
587 $this->mExpensiveFunctionCount,
588 $this->mOptions->getExpensiveParserFunctionLimit()
589 );
590 }
591
592 # Information on limits, for the benefit of users who try to skirt them
593 if ( $this->mOptions->getEnableLimitReport() ) {
594 $text .= $this->makeLimitReport();
595 }
596
597 # Wrap non-interface parser output in a <div> so it can be targeted
598 # with CSS (T37247)
599 $class = $this->mOptions->getWrapOutputClass();
600 if ( $class !== false && !$this->mOptions->getInterfaceMessage() ) {
601 $this->mOutput->addWrapperDivClass( $class );
602 }
603
604 $this->mOutput->setText( $text );
605
606 $this->mRevisionId = $oldRevisionId;
607 $this->mRevisionObject = $oldRevisionObject;
608 $this->mRevisionTimestamp = $oldRevisionTimestamp;
609 $this->mRevisionUser = $oldRevisionUser;
610 $this->mRevisionSize = $oldRevisionSize;
611 $this->mInputSize = false;
612 $this->currentRevisionCache = null;
613
614 return $this->mOutput;
615 }
616
617 /**
618 * Set the limit report data in the current ParserOutput, and return the
619 * limit report HTML comment.
620 *
621 * @return string
622 */
623 protected function makeLimitReport() {
624 $maxIncludeSize = $this->mOptions->getMaxIncludeSize();
625
626 $cpuTime = $this->mOutput->getTimeSinceStart( 'cpu' );
627 if ( $cpuTime !== null ) {
628 $this->mOutput->setLimitReportData( 'limitreport-cputime',
629 sprintf( "%.3f", $cpuTime )
630 );
631 }
632
633 $wallTime = $this->mOutput->getTimeSinceStart( 'wall' );
634 $this->mOutput->setLimitReportData( 'limitreport-walltime',
635 sprintf( "%.3f", $wallTime )
636 );
637
638 $this->mOutput->setLimitReportData( 'limitreport-ppvisitednodes',
639 [ $this->mPPNodeCount, $this->mOptions->getMaxPPNodeCount() ]
640 );
641 $this->mOutput->setLimitReportData( 'limitreport-ppgeneratednodes',
642 [ $this->mGeneratedPPNodeCount, $this->mOptions->getMaxGeneratedPPNodeCount() ]
643 );
644 $this->mOutput->setLimitReportData( 'limitreport-postexpandincludesize',
645 [ $this->mIncludeSizes['post-expand'], $maxIncludeSize ]
646 );
647 $this->mOutput->setLimitReportData( 'limitreport-templateargumentsize',
648 [ $this->mIncludeSizes['arg'], $maxIncludeSize ]
649 );
650 $this->mOutput->setLimitReportData( 'limitreport-expansiondepth',
651 [ $this->mHighestExpansionDepth, $this->mOptions->getMaxPPExpandDepth() ]
652 );
653 $this->mOutput->setLimitReportData( 'limitreport-expensivefunctioncount',
654 [ $this->mExpensiveFunctionCount, $this->mOptions->getExpensiveParserFunctionLimit() ]
655 );
656
657 foreach ( $this->mStripState->getLimitReport() as list( $key, $value ) ) {
658 $this->mOutput->setLimitReportData( $key, $value );
659 }
660
661 Hooks::run( 'ParserLimitReportPrepare', [ $this, $this->mOutput ] );
662
663 $limitReport = "NewPP limit report\n";
664 if ( $this->svcOptions->get( 'ShowHostnames' ) ) {
665 $limitReport .= 'Parsed by ' . wfHostname() . "\n";
666 }
667 $limitReport .= 'Cached time: ' . $this->mOutput->getCacheTime() . "\n";
668 $limitReport .= 'Cache expiry: ' . $this->mOutput->getCacheExpiry() . "\n";
669 $limitReport .= 'Dynamic content: ' .
670 ( $this->mOutput->hasDynamicContent() ? 'true' : 'false' ) .
671 "\n";
672 $limitReport .= 'Complications: [' . implode( ', ', $this->mOutput->getAllFlags() ) . "]\n";
673
674 foreach ( $this->mOutput->getLimitReportData() as $key => $value ) {
675 if ( Hooks::run( 'ParserLimitReportFormat',
676 [ $key, &$value, &$limitReport, false, false ]
677 ) ) {
678 $keyMsg = wfMessage( $key )->inLanguage( 'en' )->useDatabase( false );
679 $valueMsg = wfMessage( [ "$key-value-text", "$key-value" ] )
680 ->inLanguage( 'en' )->useDatabase( false );
681 if ( !$valueMsg->exists() ) {
682 $valueMsg = new RawMessage( '$1' );
683 }
684 if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
685 $valueMsg->params( $value );
686 $limitReport .= "{$keyMsg->text()}: {$valueMsg->text()}\n";
687 }
688 }
689 }
690 // Since we're not really outputting HTML, decode the entities and
691 // then re-encode the things that need hiding inside HTML comments.
692 $limitReport = htmlspecialchars_decode( $limitReport );
693
694 // Sanitize for comment. Note '‐' in the replacement is U+2010,
695 // which looks much like the problematic '-'.
696 $limitReport = str_replace( [ '-', '&' ], [ '‐', '&amp;' ], $limitReport );
697 $text = "\n<!-- \n$limitReport-->\n";
698
699 // Add on template profiling data in human/machine readable way
700 $dataByFunc = $this->mProfiler->getFunctionStats();
701 uasort( $dataByFunc, function ( $a, $b ) {
702 return $b['real'] <=> $a['real']; // descending order
703 } );
704 $profileReport = [];
705 foreach ( array_slice( $dataByFunc, 0, 10 ) as $item ) {
706 $profileReport[] = sprintf( "%6.2f%% %8.3f %6d %s",
707 $item['%real'], $item['real'], $item['calls'],
708 htmlspecialchars( $item['name'] ) );
709 }
710 $text .= "<!--\nTransclusion expansion time report (%,ms,calls,template)\n";
711 $text .= implode( "\n", $profileReport ) . "\n-->\n";
712
713 $this->mOutput->setLimitReportData( 'limitreport-timingprofile', $profileReport );
714
715 // Add other cache related metadata
716 if ( $this->svcOptions->get( 'ShowHostnames' ) ) {
717 $this->mOutput->setLimitReportData( 'cachereport-origin', wfHostname() );
718 }
719 $this->mOutput->setLimitReportData( 'cachereport-timestamp',
720 $this->mOutput->getCacheTime() );
721 $this->mOutput->setLimitReportData( 'cachereport-ttl',
722 $this->mOutput->getCacheExpiry() );
723 $this->mOutput->setLimitReportData( 'cachereport-transientcontent',
724 $this->mOutput->hasDynamicContent() );
725
726 if ( $this->mGeneratedPPNodeCount > $this->mOptions->getMaxGeneratedPPNodeCount() / 10 ) {
727 wfDebugLog( 'generated-pp-node-count', $this->mGeneratedPPNodeCount . ' ' .
728 $this->mTitle->getPrefixedDBkey() );
729 }
730 return $text;
731 }
732
733 /**
734 * Half-parse wikitext to half-parsed HTML. This recursive parser entry point
735 * can be called from an extension tag hook.
736 *
737 * The output of this function IS NOT SAFE PARSED HTML; it is "half-parsed"
738 * instead, which means that lists and links have not been fully parsed yet,
739 * and strip markers are still present.
740 *
741 * Use recursiveTagParseFully() to fully parse wikitext to output-safe HTML.
742 *
743 * Use this function if you're a parser tag hook and you want to parse
744 * wikitext before or after applying additional transformations, and you
745 * intend to *return the result as hook output*, which will cause it to go
746 * through the rest of parsing process automatically.
747 *
748 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
749 * $text are not expanded
750 *
751 * @param string $text Text extension wants to have parsed
752 * @param-taint $text escapes_htmlnoent
753 * @param bool|PPFrame $frame The frame to use for expanding any template variables
754 * @return string UNSAFE half-parsed HTML
755 * @return-taint escaped
756 */
757 public function recursiveTagParse( $text, $frame = false ) {
758 // Avoid PHP 7.1 warning from passing $this by reference
759 $parser = $this;
760 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
761 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
762 $text = $this->internalParse( $text, false, $frame );
763 return $text;
764 }
765
766 /**
767 * Fully parse wikitext to fully parsed HTML. This recursive parser entry
768 * point can be called from an extension tag hook.
769 *
770 * The output of this function is fully-parsed HTML that is safe for output.
771 * If you're a parser tag hook, you might want to use recursiveTagParse()
772 * instead.
773 *
774 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
775 * $text are not expanded
776 *
777 * @since 1.25
778 *
779 * @param string $text Text extension wants to have parsed
780 * @param-taint $text escapes_htmlnoent
781 * @param bool|PPFrame $frame The frame to use for expanding any template variables
782 * @return string Fully parsed HTML
783 * @return-taint escaped
784 */
785 public function recursiveTagParseFully( $text, $frame = false ) {
786 $text = $this->recursiveTagParse( $text, $frame );
787 $text = $this->internalParseHalfParsed( $text, false );
788 return $text;
789 }
790
791 /**
792 * Expand templates and variables in the text, producing valid, static wikitext.
793 * Also removes comments.
794 * Do not call this function recursively.
795 * @param string $text
796 * @param Title|null $title
797 * @param ParserOptions $options
798 * @param int|null $revid
799 * @param bool|PPFrame $frame
800 * @return mixed|string
801 */
802 public function preprocess( $text, Title $title = null,
803 ParserOptions $options, $revid = null, $frame = false
804 ) {
805 $magicScopeVariable = $this->lock();
806 $this->startParse( $title, $options, self::OT_PREPROCESS, true );
807 if ( $revid !== null ) {
808 $this->mRevisionId = $revid;
809 }
810 // Avoid PHP 7.1 warning from passing $this by reference
811 $parser = $this;
812 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
813 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
814 $text = $this->replaceVariables( $text, $frame );
815 $text = $this->mStripState->unstripBoth( $text );
816 return $text;
817 }
818
819 /**
820 * Recursive parser entry point that can be called from an extension tag
821 * hook.
822 *
823 * @param string $text Text to be expanded
824 * @param bool|PPFrame $frame The frame to use for expanding any template variables
825 * @return string
826 * @since 1.19
827 */
828 public function recursivePreprocess( $text, $frame = false ) {
829 $text = $this->replaceVariables( $text, $frame );
830 $text = $this->mStripState->unstripBoth( $text );
831 return $text;
832 }
833
834 /**
835 * Process the wikitext for the "?preload=" feature. (T7210)
836 *
837 * "<noinclude>", "<includeonly>" etc. are parsed as for template
838 * transclusion, comments, templates, arguments, tags hooks and parser
839 * functions are untouched.
840 *
841 * @param string $text
842 * @param Title $title
843 * @param ParserOptions $options
844 * @param array $params
845 * @return string
846 */
847 public function getPreloadText( $text, Title $title, ParserOptions $options, $params = [] ) {
848 $msg = new RawMessage( $text );
849 $text = $msg->params( $params )->plain();
850
851 # Parser (re)initialisation
852 $magicScopeVariable = $this->lock();
853 $this->startParse( $title, $options, self::OT_PLAIN, true );
854
855 $flags = PPFrame::NO_ARGS | PPFrame::NO_TEMPLATES;
856 $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
857 $text = $this->getPreprocessor()->newFrame()->expand( $dom, $flags );
858 $text = $this->mStripState->unstripBoth( $text );
859 return $text;
860 }
861
862 /**
863 * Set the current user.
864 * Should only be used when doing pre-save transform.
865 *
866 * @param User|null $user User object or null (to reset)
867 */
868 public function setUser( $user ) {
869 $this->mUser = $user;
870 }
871
872 /**
873 * Set the context title
874 *
875 * @param Title $t
876 */
877 public function setTitle( $t ) {
878 if ( !$t ) {
879 $t = Title::newFromText( 'NO TITLE' );
880 }
881
882 if ( $t->hasFragment() ) {
883 # Strip the fragment to avoid various odd effects
884 $this->mTitle = $t->createFragmentTarget( '' );
885 } else {
886 $this->mTitle = $t;
887 }
888 }
889
890 /**
891 * Accessor for the Title object
892 *
893 * @return Title
894 */
895 public function getTitle() {
896 return $this->mTitle;
897 }
898
899 /**
900 * Accessor/mutator for the Title object
901 *
902 * @param Title|null $x Title object or null to just get the current one
903 * @return Title
904 */
905 public function Title( $x = null ) {
906 return wfSetVar( $this->mTitle, $x );
907 }
908
909 /**
910 * Set the output type
911 *
912 * @param int $ot New value
913 */
914 public function setOutputType( $ot ) {
915 $this->mOutputType = $ot;
916 # Shortcut alias
917 $this->ot = [
918 'html' => $ot == self::OT_HTML,
919 'wiki' => $ot == self::OT_WIKI,
920 'pre' => $ot == self::OT_PREPROCESS,
921 'plain' => $ot == self::OT_PLAIN,
922 ];
923 }
924
925 /**
926 * Accessor/mutator for the output type
927 *
928 * @param int|null $x New value or null to just get the current one
929 * @return int
930 */
931 public function OutputType( $x = null ) {
932 return wfSetVar( $this->mOutputType, $x );
933 }
934
935 /**
936 * Get the ParserOutput object
937 *
938 * @return ParserOutput
939 */
940 public function getOutput() {
941 return $this->mOutput;
942 }
943
944 /**
945 * Get the ParserOptions object
946 *
947 * @return ParserOptions
948 */
949 public function getOptions() {
950 return $this->mOptions;
951 }
952
953 /**
954 * Accessor/mutator for the ParserOptions object
955 *
956 * @param ParserOptions|null $x New value or null to just get the current one
957 * @return ParserOptions Current ParserOptions object
958 */
959 public function Options( $x = null ) {
960 return wfSetVar( $this->mOptions, $x );
961 }
962
963 /**
964 * @return int
965 */
966 public function nextLinkID() {
967 return $this->mLinkID++;
968 }
969
970 /**
971 * @param int $id
972 */
973 public function setLinkID( $id ) {
974 $this->mLinkID = $id;
975 }
976
977 /**
978 * Get a language object for use in parser functions such as {{FORMATNUM:}}
979 * @return Language
980 */
981 public function getFunctionLang() {
982 return $this->getTargetLanguage();
983 }
984
985 /**
986 * Get the target language for the content being parsed. This is usually the
987 * language that the content is in.
988 *
989 * @since 1.19
990 *
991 * @throws MWException
992 * @return Language
993 */
994 public function getTargetLanguage() {
995 $target = $this->mOptions->getTargetLanguage();
996
997 if ( $target !== null ) {
998 return $target;
999 } elseif ( $this->mOptions->getInterfaceMessage() ) {
1000 return $this->mOptions->getUserLangObj();
1001 } elseif ( is_null( $this->mTitle ) ) {
1002 throw new MWException( __METHOD__ . ': $this->mTitle is null' );
1003 }
1004
1005 return $this->mTitle->getPageLanguage();
1006 }
1007
1008 /**
1009 * Get the language object for language conversion
1010 * @deprecated since 1.32, just use getTargetLanguage()
1011 * @return Language|null
1012 */
1013 public function getConverterLanguage() {
1014 return $this->getTargetLanguage();
1015 }
1016
1017 /**
1018 * Get a User object either from $this->mUser, if set, or from the
1019 * ParserOptions object otherwise
1020 *
1021 * @return User
1022 */
1023 public function getUser() {
1024 if ( !is_null( $this->mUser ) ) {
1025 return $this->mUser;
1026 }
1027 return $this->mOptions->getUser();
1028 }
1029
1030 /**
1031 * Get a preprocessor object
1032 *
1033 * @return Preprocessor
1034 */
1035 public function getPreprocessor() {
1036 if ( !isset( $this->mPreprocessor ) ) {
1037 $class = $this->svcOptions->get( 'preprocessorClass' );
1038 $this->mPreprocessor = new $class( $this );
1039 }
1040 return $this->mPreprocessor;
1041 }
1042
1043 /**
1044 * Get a LinkRenderer instance to make links with
1045 *
1046 * @since 1.28
1047 * @return LinkRenderer
1048 */
1049 public function getLinkRenderer() {
1050 // XXX We make the LinkRenderer with current options and then cache it forever
1051 if ( !$this->mLinkRenderer ) {
1052 $this->mLinkRenderer = $this->linkRendererFactory->create();
1053 $this->mLinkRenderer->setStubThreshold(
1054 $this->getOptions()->getStubThreshold()
1055 );
1056 }
1057
1058 return $this->mLinkRenderer;
1059 }
1060
1061 /**
1062 * Get the MagicWordFactory that this Parser is using
1063 *
1064 * @since 1.32
1065 * @return MagicWordFactory
1066 */
1067 public function getMagicWordFactory() {
1068 return $this->magicWordFactory;
1069 }
1070
1071 /**
1072 * Get the content language that this Parser is using
1073 *
1074 * @since 1.32
1075 * @return Language
1076 */
1077 public function getContentLanguage() {
1078 return $this->contLang;
1079 }
1080
1081 /**
1082 * Replaces all occurrences of HTML-style comments and the given tags
1083 * in the text with a random marker and returns the next text. The output
1084 * parameter $matches will be an associative array filled with data in
1085 * the form:
1086 *
1087 * @code
1088 * 'UNIQ-xxxxx' => [
1089 * 'element',
1090 * 'tag content',
1091 * [ 'param' => 'x' ],
1092 * '<element param="x">tag content</element>' ]
1093 * @endcode
1094 *
1095 * @param array $elements List of element names. Comments are always extracted.
1096 * @param string $text Source text string.
1097 * @param array &$matches Out parameter, Array: extracted tags
1098 * @return string Stripped text
1099 */
1100 public static function extractTagsAndParams( $elements, $text, &$matches ) {
1101 static $n = 1;
1102 $stripped = '';
1103 $matches = [];
1104
1105 $taglist = implode( '|', $elements );
1106 $start = "/<($taglist)(\\s+[^>]*?|\\s*?)(\/?>)|<(!--)/i";
1107
1108 while ( $text != '' ) {
1109 $p = preg_split( $start, $text, 2, PREG_SPLIT_DELIM_CAPTURE );
1110 $stripped .= $p[0];
1111 if ( count( $p ) < 5 ) {
1112 break;
1113 }
1114 if ( count( $p ) > 5 ) {
1115 # comment
1116 $element = $p[4];
1117 $attributes = '';
1118 $close = '';
1119 $inside = $p[5];
1120 } else {
1121 # tag
1122 list( , $element, $attributes, $close, $inside ) = $p;
1123 }
1124
1125 $marker = self::MARKER_PREFIX . "-$element-" . sprintf( '%08X', $n++ ) . self::MARKER_SUFFIX;
1126 $stripped .= $marker;
1127
1128 if ( $close === '/>' ) {
1129 # Empty element tag, <tag />
1130 $content = null;
1131 $text = $inside;
1132 $tail = null;
1133 } else {
1134 if ( $element === '!--' ) {
1135 $end = '/(-->)/';
1136 } else {
1137 $end = "/(<\\/$element\\s*>)/i";
1138 }
1139 $q = preg_split( $end, $inside, 2, PREG_SPLIT_DELIM_CAPTURE );
1140 $content = $q[0];
1141 if ( count( $q ) < 3 ) {
1142 # No end tag -- let it run out to the end of the text.
1143 $tail = '';
1144 $text = '';
1145 } else {
1146 list( , $tail, $text ) = $q;
1147 }
1148 }
1149
1150 $matches[$marker] = [ $element,
1151 $content,
1152 Sanitizer::decodeTagAttributes( $attributes ),
1153 "<$element$attributes$close$content$tail" ];
1154 }
1155 return $stripped;
1156 }
1157
1158 /**
1159 * Get a list of strippable XML-like elements
1160 *
1161 * @return array
1162 */
1163 public function getStripList() {
1164 return $this->mStripList;
1165 }
1166
1167 /**
1168 * Add an item to the strip state
1169 * Returns the unique tag which must be inserted into the stripped text
1170 * The tag will be replaced with the original text in unstrip()
1171 *
1172 * @param string $text
1173 *
1174 * @return string
1175 */
1176 public function insertStripItem( $text ) {
1177 $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX;
1178 $this->mMarkerIndex++;
1179 $this->mStripState->addGeneral( $marker, $text );
1180 return $marker;
1181 }
1182
1183 /**
1184 * parse the wiki syntax used to render tables
1185 *
1186 * @private
1187 * @param string $text
1188 * @return string
1189 */
1190 public function doTableStuff( $text ) {
1191 $lines = StringUtils::explode( "\n", $text );
1192 $out = '';
1193 $td_history = []; # Is currently a td tag open?
1194 $last_tag_history = []; # Save history of last lag activated (td, th or caption)
1195 $tr_history = []; # Is currently a tr tag open?
1196 $tr_attributes = []; # history of tr attributes
1197 $has_opened_tr = []; # Did this table open a <tr> element?
1198 $indent_level = 0; # indent level of the table
1199
1200 foreach ( $lines as $outLine ) {
1201 $line = trim( $outLine );
1202
1203 if ( $line === '' ) { # empty line, go to next line
1204 $out .= $outLine . "\n";
1205 continue;
1206 }
1207
1208 $first_character = $line[0];
1209 $first_two = substr( $line, 0, 2 );
1210 $matches = [];
1211
1212 if ( preg_match( '/^(:*)\s*\{\|(.*)$/', $line, $matches ) ) {
1213 # First check if we are starting a new table
1214 $indent_level = strlen( $matches[1] );
1215
1216 $attributes = $this->mStripState->unstripBoth( $matches[2] );
1217 $attributes = Sanitizer::fixTagAttributes( $attributes, 'table' );
1218
1219 $outLine = str_repeat( '<dl><dd>', $indent_level ) . "<table{$attributes}>";
1220 array_push( $td_history, false );
1221 array_push( $last_tag_history, '' );
1222 array_push( $tr_history, false );
1223 array_push( $tr_attributes, '' );
1224 array_push( $has_opened_tr, false );
1225 } elseif ( count( $td_history ) == 0 ) {
1226 # Don't do any of the following
1227 $out .= $outLine . "\n";
1228 continue;
1229 } elseif ( $first_two === '|}' ) {
1230 # We are ending a table
1231 $line = '</table>' . substr( $line, 2 );
1232 $last_tag = array_pop( $last_tag_history );
1233
1234 if ( !array_pop( $has_opened_tr ) ) {
1235 $line = "<tr><td></td></tr>{$line}";
1236 }
1237
1238 if ( array_pop( $tr_history ) ) {
1239 $line = "</tr>{$line}";
1240 }
1241
1242 if ( array_pop( $td_history ) ) {
1243 $line = "</{$last_tag}>{$line}";
1244 }
1245 array_pop( $tr_attributes );
1246 if ( $indent_level > 0 ) {
1247 $outLine = rtrim( $line ) . str_repeat( '</dd></dl>', $indent_level );
1248 } else {
1249 $outLine = $line;
1250 }
1251 } elseif ( $first_two === '|-' ) {
1252 # Now we have a table row
1253 $line = preg_replace( '#^\|-+#', '', $line );
1254
1255 # Whats after the tag is now only attributes
1256 $attributes = $this->mStripState->unstripBoth( $line );
1257 $attributes = Sanitizer::fixTagAttributes( $attributes, 'tr' );
1258 array_pop( $tr_attributes );
1259 array_push( $tr_attributes, $attributes );
1260
1261 $line = '';
1262 $last_tag = array_pop( $last_tag_history );
1263 array_pop( $has_opened_tr );
1264 array_push( $has_opened_tr, true );
1265
1266 if ( array_pop( $tr_history ) ) {
1267 $line = '</tr>';
1268 }
1269
1270 if ( array_pop( $td_history ) ) {
1271 $line = "</{$last_tag}>{$line}";
1272 }
1273
1274 $outLine = $line;
1275 array_push( $tr_history, false );
1276 array_push( $td_history, false );
1277 array_push( $last_tag_history, '' );
1278 } elseif ( $first_character === '|'
1279 || $first_character === '!'
1280 || $first_two === '|+'
1281 ) {
1282 # This might be cell elements, td, th or captions
1283 if ( $first_two === '|+' ) {
1284 $first_character = '+';
1285 $line = substr( $line, 2 );
1286 } else {
1287 $line = substr( $line, 1 );
1288 }
1289
1290 // Implies both are valid for table headings.
1291 if ( $first_character === '!' ) {
1292 $line = StringUtils::replaceMarkup( '!!', '||', $line );
1293 }
1294
1295 # Split up multiple cells on the same line.
1296 # FIXME : This can result in improper nesting of tags processed
1297 # by earlier parser steps.
1298 $cells = explode( '||', $line );
1299
1300 $outLine = '';
1301
1302 # Loop through each table cell
1303 foreach ( $cells as $cell ) {
1304 $previous = '';
1305 if ( $first_character !== '+' ) {
1306 $tr_after = array_pop( $tr_attributes );
1307 if ( !array_pop( $tr_history ) ) {
1308 $previous = "<tr{$tr_after}>\n";
1309 }
1310 array_push( $tr_history, true );
1311 array_push( $tr_attributes, '' );
1312 array_pop( $has_opened_tr );
1313 array_push( $has_opened_tr, true );
1314 }
1315
1316 $last_tag = array_pop( $last_tag_history );
1317
1318 if ( array_pop( $td_history ) ) {
1319 $previous = "</{$last_tag}>\n{$previous}";
1320 }
1321
1322 if ( $first_character === '|' ) {
1323 $last_tag = 'td';
1324 } elseif ( $first_character === '!' ) {
1325 $last_tag = 'th';
1326 } elseif ( $first_character === '+' ) {
1327 $last_tag = 'caption';
1328 } else {
1329 $last_tag = '';
1330 }
1331
1332 array_push( $last_tag_history, $last_tag );
1333
1334 # A cell could contain both parameters and data
1335 $cell_data = explode( '|', $cell, 2 );
1336
1337 # T2553: Note that a '|' inside an invalid link should not
1338 # be mistaken as delimiting cell parameters
1339 # Bug T153140: Neither should language converter markup.
1340 if ( preg_match( '/\[\[|-\{/', $cell_data[0] ) === 1 ) {
1341 $cell = "{$previous}<{$last_tag}>" . trim( $cell );
1342 } elseif ( count( $cell_data ) == 1 ) {
1343 // Whitespace in cells is trimmed
1344 $cell = "{$previous}<{$last_tag}>" . trim( $cell_data[0] );
1345 } else {
1346 $attributes = $this->mStripState->unstripBoth( $cell_data[0] );
1347 $attributes = Sanitizer::fixTagAttributes( $attributes, $last_tag );
1348 // Whitespace in cells is trimmed
1349 $cell = "{$previous}<{$last_tag}{$attributes}>" . trim( $cell_data[1] );
1350 }
1351
1352 $outLine .= $cell;
1353 array_push( $td_history, true );
1354 }
1355 }
1356 $out .= $outLine . "\n";
1357 }
1358
1359 # Closing open td, tr && table
1360 while ( count( $td_history ) > 0 ) {
1361 if ( array_pop( $td_history ) ) {
1362 $out .= "</td>\n";
1363 }
1364 if ( array_pop( $tr_history ) ) {
1365 $out .= "</tr>\n";
1366 }
1367 if ( !array_pop( $has_opened_tr ) ) {
1368 $out .= "<tr><td></td></tr>\n";
1369 }
1370
1371 $out .= "</table>\n";
1372 }
1373
1374 # Remove trailing line-ending (b/c)
1375 if ( substr( $out, -1 ) === "\n" ) {
1376 $out = substr( $out, 0, -1 );
1377 }
1378
1379 # special case: don't return empty table
1380 if ( $out === "<table>\n<tr><td></td></tr>\n</table>" ) {
1381 $out = '';
1382 }
1383
1384 return $out;
1385 }
1386
1387 /**
1388 * Helper function for parse() that transforms wiki markup into half-parsed
1389 * HTML. Only called for $mOutputType == self::OT_HTML.
1390 *
1391 * @private
1392 *
1393 * @param string $text The text to parse
1394 * @param-taint $text escapes_html
1395 * @param bool $isMain Whether this is being called from the main parse() function
1396 * @param PPFrame|bool $frame A pre-processor frame
1397 *
1398 * @return string
1399 */
1400 public function internalParse( $text, $isMain = true, $frame = false ) {
1401 $origText = $text;
1402
1403 // Avoid PHP 7.1 warning from passing $this by reference
1404 $parser = $this;
1405
1406 # Hook to suspend the parser in this state
1407 if ( !Hooks::run( 'ParserBeforeInternalParse', [ &$parser, &$text, &$this->mStripState ] ) ) {
1408 return $text;
1409 }
1410
1411 # if $frame is provided, then use $frame for replacing any variables
1412 if ( $frame ) {
1413 # use frame depth to infer how include/noinclude tags should be handled
1414 # depth=0 means this is the top-level document; otherwise it's an included document
1415 if ( !$frame->depth ) {
1416 $flag = 0;
1417 } else {
1418 $flag = self::PTD_FOR_INCLUSION;
1419 }
1420 $dom = $this->preprocessToDom( $text, $flag );
1421 $text = $frame->expand( $dom );
1422 } else {
1423 # if $frame is not provided, then use old-style replaceVariables
1424 $text = $this->replaceVariables( $text );
1425 }
1426
1427 Hooks::run( 'InternalParseBeforeSanitize', [ &$parser, &$text, &$this->mStripState ] );
1428 $text = Sanitizer::removeHTMLtags(
1429 $text,
1430 [ $this, 'attributeStripCallback' ],
1431 false,
1432 array_keys( $this->mTransparentTagHooks ),
1433 [],
1434 [ $this, 'addTrackingCategory' ]
1435 );
1436 Hooks::run( 'InternalParseBeforeLinks', [ &$parser, &$text, &$this->mStripState ] );
1437
1438 # Tables need to come after variable replacement for things to work
1439 # properly; putting them before other transformations should keep
1440 # exciting things like link expansions from showing up in surprising
1441 # places.
1442 $text = $this->doTableStuff( $text );
1443
1444 $text = preg_replace( '/(^|\n)-----*/', '\\1<hr />', $text );
1445
1446 $text = $this->doDoubleUnderscore( $text );
1447
1448 $text = $this->doHeadings( $text );
1449 $text = $this->replaceInternalLinks( $text );
1450 $text = $this->doAllQuotes( $text );
1451 $text = $this->replaceExternalLinks( $text );
1452
1453 # replaceInternalLinks may sometimes leave behind
1454 # absolute URLs, which have to be masked to hide them from replaceExternalLinks
1455 $text = str_replace( self::MARKER_PREFIX . 'NOPARSE', '', $text );
1456
1457 $text = $this->doMagicLinks( $text );
1458 $text = $this->formatHeadings( $text, $origText, $isMain );
1459
1460 return $text;
1461 }
1462
1463 /**
1464 * Helper function for parse() that transforms half-parsed HTML into fully
1465 * parsed HTML.
1466 *
1467 * @param string $text
1468 * @param bool $isMain
1469 * @param bool $linestart
1470 * @return string
1471 */
1472 private function internalParseHalfParsed( $text, $isMain = true, $linestart = true ) {
1473 $text = $this->mStripState->unstripGeneral( $text );
1474
1475 // Avoid PHP 7.1 warning from passing $this by reference
1476 $parser = $this;
1477
1478 if ( $isMain ) {
1479 Hooks::run( 'ParserAfterUnstrip', [ &$parser, &$text ] );
1480 }
1481
1482 # Clean up special characters, only run once, next-to-last before doBlockLevels
1483 $text = Sanitizer::armorFrenchSpaces( $text );
1484
1485 $text = $this->doBlockLevels( $text, $linestart );
1486
1487 $this->replaceLinkHolders( $text );
1488
1489 /**
1490 * The input doesn't get language converted if
1491 * a) It's disabled
1492 * b) Content isn't converted
1493 * c) It's a conversion table
1494 * d) it is an interface message (which is in the user language)
1495 */
1496 if ( !( $this->mOptions->getDisableContentConversion()
1497 || isset( $this->mDoubleUnderscores['nocontentconvert'] ) )
1498 && !$this->mOptions->getInterfaceMessage()
1499 ) {
1500 # The position of the convert() call should not be changed. it
1501 # assumes that the links are all replaced and the only thing left
1502 # is the <nowiki> mark.
1503 $text = $this->getTargetLanguage()->convert( $text );
1504 }
1505
1506 $text = $this->mStripState->unstripNoWiki( $text );
1507
1508 if ( $isMain ) {
1509 Hooks::run( 'ParserBeforeTidy', [ &$parser, &$text ] );
1510 }
1511
1512 $text = $this->replaceTransparentTags( $text );
1513 $text = $this->mStripState->unstripGeneral( $text );
1514
1515 $text = Sanitizer::normalizeCharReferences( $text );
1516
1517 if ( MWTidy::isEnabled() ) {
1518 if ( $this->mOptions->getTidy() ) {
1519 $text = MWTidy::tidy( $text );
1520 }
1521 } else {
1522 # attempt to sanitize at least some nesting problems
1523 # (T4702 and quite a few others)
1524 # This code path is buggy and deprecated!
1525 wfDeprecated( 'disabling tidy', '1.33' );
1526 $tidyregs = [
1527 # ''Something [http://www.cool.com cool''] -->
1528 # <i>Something</i><a href="http://www.cool.com"..><i>cool></i></a>
1529 '/(<([bi])>)(<([bi])>)?([^<]*)(<\/?a[^<]*>)([^<]*)(<\/\\4>)?(<\/\\2>)/' =>
1530 '\\1\\3\\5\\8\\9\\6\\1\\3\\7\\8\\9',
1531 # fix up an anchor inside another anchor, only
1532 # at least for a single single nested link (T5695)
1533 '/(<a[^>]+>)([^<]*)(<a[^>]+>[^<]*)<\/a>(.*)<\/a>/' =>
1534 '\\1\\2</a>\\3</a>\\1\\4</a>',
1535 # fix div inside inline elements- doBlockLevels won't wrap a line which
1536 # contains a div, so fix it up here; replace
1537 # div with escaped text
1538 '/(<([aib]) [^>]+>)([^<]*)(<div([^>]*)>)(.*)(<\/div>)([^<]*)(<\/\\2>)/' =>
1539 '\\1\\3&lt;div\\5&gt;\\6&lt;/div&gt;\\8\\9',
1540 # remove empty italic or bold tag pairs, some
1541 # introduced by rules above
1542 '/<([bi])><\/\\1>/' => '',
1543 ];
1544
1545 $text = preg_replace(
1546 array_keys( $tidyregs ),
1547 array_values( $tidyregs ),
1548 $text );
1549 }
1550
1551 if ( $isMain ) {
1552 Hooks::run( 'ParserAfterTidy', [ &$parser, &$text ] );
1553 }
1554
1555 return $text;
1556 }
1557
1558 /**
1559 * Replace special strings like "ISBN xxx" and "RFC xxx" with
1560 * magic external links.
1561 *
1562 * DML
1563 * @private
1564 *
1565 * @param string $text
1566 *
1567 * @return string
1568 */
1569 public function doMagicLinks( $text ) {
1570 $prots = wfUrlProtocolsWithoutProtRel();
1571 $urlChar = self::EXT_LINK_URL_CLASS;
1572 $addr = self::EXT_LINK_ADDR;
1573 $space = self::SPACE_NOT_NL; # non-newline space
1574 $spdash = "(?:-|$space)"; # a dash or a non-newline space
1575 $spaces = "$space++"; # possessive match of 1 or more spaces
1576 $text = preg_replace_callback(
1577 '!(?: # Start cases
1578 (<a[ \t\r\n>].*?</a>) | # m[1]: Skip link text
1579 (<.*?>) | # m[2]: Skip stuff inside HTML elements' . "
1580 (\b # m[3]: Free external links
1581 (?i:$prots)
1582 ($addr$urlChar*) # m[4]: Post-protocol path
1583 ) |
1584 \b(?:RFC|PMID) $spaces # m[5]: RFC or PMID, capture number
1585 ([0-9]+)\b |
1586 \bISBN $spaces ( # m[6]: ISBN, capture number
1587 (?: 97[89] $spdash? )? # optional 13-digit ISBN prefix
1588 (?: [0-9] $spdash? ){9} # 9 digits with opt. delimiters
1589 [0-9Xx] # check digit
1590 )\b
1591 )!xu", [ $this, 'magicLinkCallback' ], $text );
1592 return $text;
1593 }
1594
1595 /**
1596 * @throws MWException
1597 * @param array $m
1598 * @return string HTML
1599 */
1600 public function magicLinkCallback( $m ) {
1601 if ( isset( $m[1] ) && $m[1] !== '' ) {
1602 # Skip anchor
1603 return $m[0];
1604 } elseif ( isset( $m[2] ) && $m[2] !== '' ) {
1605 # Skip HTML element
1606 return $m[0];
1607 } elseif ( isset( $m[3] ) && $m[3] !== '' ) {
1608 # Free external link
1609 return $this->makeFreeExternalLink( $m[0], strlen( $m[4] ) );
1610 } elseif ( isset( $m[5] ) && $m[5] !== '' ) {
1611 # RFC or PMID
1612 if ( substr( $m[0], 0, 3 ) === 'RFC' ) {
1613 if ( !$this->mOptions->getMagicRFCLinks() ) {
1614 return $m[0];
1615 }
1616 $keyword = 'RFC';
1617 $urlmsg = 'rfcurl';
1618 $cssClass = 'mw-magiclink-rfc';
1619 $trackingCat = 'magiclink-tracking-rfc';
1620 $id = $m[5];
1621 } elseif ( substr( $m[0], 0, 4 ) === 'PMID' ) {
1622 if ( !$this->mOptions->getMagicPMIDLinks() ) {
1623 return $m[0];
1624 }
1625 $keyword = 'PMID';
1626 $urlmsg = 'pubmedurl';
1627 $cssClass = 'mw-magiclink-pmid';
1628 $trackingCat = 'magiclink-tracking-pmid';
1629 $id = $m[5];
1630 } else {
1631 throw new MWException( __METHOD__ . ': unrecognised match type "' .
1632 substr( $m[0], 0, 20 ) . '"' );
1633 }
1634 $url = wfMessage( $urlmsg, $id )->inContentLanguage()->text();
1635 $this->addTrackingCategory( $trackingCat );
1636 return Linker::makeExternalLink( $url, "{$keyword} {$id}", true, $cssClass, [], $this->mTitle );
1637 } elseif ( isset( $m[6] ) && $m[6] !== ''
1638 && $this->mOptions->getMagicISBNLinks()
1639 ) {
1640 # ISBN
1641 $isbn = $m[6];
1642 $space = self::SPACE_NOT_NL; # non-newline space
1643 $isbn = preg_replace( "/$space/", ' ', $isbn );
1644 $num = strtr( $isbn, [
1645 '-' => '',
1646 ' ' => '',
1647 'x' => 'X',
1648 ] );
1649 $this->addTrackingCategory( 'magiclink-tracking-isbn' );
1650 return $this->getLinkRenderer()->makeKnownLink(
1651 SpecialPage::getTitleFor( 'Booksources', $num ),
1652 "ISBN $isbn",
1653 [
1654 'class' => 'internal mw-magiclink-isbn',
1655 'title' => false // suppress title attribute
1656 ]
1657 );
1658 } else {
1659 return $m[0];
1660 }
1661 }
1662
1663 /**
1664 * Make a free external link, given a user-supplied URL
1665 *
1666 * @param string $url
1667 * @param int $numPostProto
1668 * The number of characters after the protocol.
1669 * @return string HTML
1670 * @private
1671 */
1672 public function makeFreeExternalLink( $url, $numPostProto ) {
1673 $trail = '';
1674
1675 # The characters '<' and '>' (which were escaped by
1676 # removeHTMLtags()) should not be included in
1677 # URLs, per RFC 2396.
1678 # Make &nbsp; terminate a URL as well (bug T84937)
1679 $m2 = [];
1680 if ( preg_match(
1681 '/&(lt|gt|nbsp|#x0*(3[CcEe]|[Aa]0)|#0*(60|62|160));/',
1682 $url,
1683 $m2,
1684 PREG_OFFSET_CAPTURE
1685 ) ) {
1686 $trail = substr( $url, $m2[0][1] ) . $trail;
1687 $url = substr( $url, 0, $m2[0][1] );
1688 }
1689
1690 # Move trailing punctuation to $trail
1691 $sep = ',;\.:!?';
1692 # If there is no left bracket, then consider right brackets fair game too
1693 if ( strpos( $url, '(' ) === false ) {
1694 $sep .= ')';
1695 }
1696
1697 $urlRev = strrev( $url );
1698 $numSepChars = strspn( $urlRev, $sep );
1699 # Don't break a trailing HTML entity by moving the ; into $trail
1700 # This is in hot code, so use substr_compare to avoid having to
1701 # create a new string object for the comparison
1702 if ( $numSepChars && substr_compare( $url, ";", -$numSepChars, 1 ) === 0 ) {
1703 # more optimization: instead of running preg_match with a $
1704 # anchor, which can be slow, do the match on the reversed
1705 # string starting at the desired offset.
1706 # un-reversed regexp is: /&([a-z]+|#x[\da-f]+|#\d+)$/i
1707 if ( preg_match( '/\G([a-z]+|[\da-f]+x#|\d+#)&/i', $urlRev, $m2, 0, $numSepChars ) ) {
1708 $numSepChars--;
1709 }
1710 }
1711 if ( $numSepChars ) {
1712 $trail = substr( $url, -$numSepChars ) . $trail;
1713 $url = substr( $url, 0, -$numSepChars );
1714 }
1715
1716 # Verify that we still have a real URL after trail removal, and
1717 # not just lone protocol
1718 if ( strlen( $trail ) >= $numPostProto ) {
1719 return $url . $trail;
1720 }
1721
1722 $url = Sanitizer::cleanUrl( $url );
1723
1724 # Is this an external image?
1725 $text = $this->maybeMakeExternalImage( $url );
1726 if ( $text === false ) {
1727 # Not an image, make a link
1728 $text = Linker::makeExternalLink( $url,
1729 $this->getTargetLanguage()->getConverter()->markNoConversion( $url ),
1730 true, 'free',
1731 $this->getExternalLinkAttribs( $url ), $this->mTitle );
1732 # Register it in the output object...
1733 $this->mOutput->addExternalLink( $url );
1734 }
1735 return $text . $trail;
1736 }
1737
1738 /**
1739 * Parse headers and return html
1740 *
1741 * @private
1742 *
1743 * @param string $text
1744 *
1745 * @return string
1746 */
1747 public function doHeadings( $text ) {
1748 for ( $i = 6; $i >= 1; --$i ) {
1749 $h = str_repeat( '=', $i );
1750 // Trim non-newline whitespace from headings
1751 // Using \s* will break for: "==\n===\n" and parse as <h2>=</h2>
1752 $text = preg_replace( "/^(?:$h)[ \\t]*(.+?)[ \\t]*(?:$h)\\s*$/m", "<h$i>\\1</h$i>", $text );
1753 }
1754 return $text;
1755 }
1756
1757 /**
1758 * Replace single quotes with HTML markup
1759 * @private
1760 *
1761 * @param string $text
1762 *
1763 * @return string The altered text
1764 */
1765 public function doAllQuotes( $text ) {
1766 $outtext = '';
1767 $lines = StringUtils::explode( "\n", $text );
1768 foreach ( $lines as $line ) {
1769 $outtext .= $this->doQuotes( $line ) . "\n";
1770 }
1771 $outtext = substr( $outtext, 0, -1 );
1772 return $outtext;
1773 }
1774
1775 /**
1776 * Helper function for doAllQuotes()
1777 *
1778 * @param string $text
1779 *
1780 * @return string
1781 */
1782 public function doQuotes( $text ) {
1783 $arr = preg_split( "/(''+)/", $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1784 $countarr = count( $arr );
1785 if ( $countarr == 1 ) {
1786 return $text;
1787 }
1788
1789 // First, do some preliminary work. This may shift some apostrophes from
1790 // being mark-up to being text. It also counts the number of occurrences
1791 // of bold and italics mark-ups.
1792 $numbold = 0;
1793 $numitalics = 0;
1794 for ( $i = 1; $i < $countarr; $i += 2 ) {
1795 $thislen = strlen( $arr[$i] );
1796 // If there are ever four apostrophes, assume the first is supposed to
1797 // be text, and the remaining three constitute mark-up for bold text.
1798 // (T15227: ''''foo'''' turns into ' ''' foo ' ''')
1799 if ( $thislen == 4 ) {
1800 $arr[$i - 1] .= "'";
1801 $arr[$i] = "'''";
1802 $thislen = 3;
1803 } elseif ( $thislen > 5 ) {
1804 // If there are more than 5 apostrophes in a row, assume they're all
1805 // text except for the last 5.
1806 // (T15227: ''''''foo'''''' turns into ' ''''' foo ' ''''')
1807 $arr[$i - 1] .= str_repeat( "'", $thislen - 5 );
1808 $arr[$i] = "'''''";
1809 $thislen = 5;
1810 }
1811 // Count the number of occurrences of bold and italics mark-ups.
1812 if ( $thislen == 2 ) {
1813 $numitalics++;
1814 } elseif ( $thislen == 3 ) {
1815 $numbold++;
1816 } elseif ( $thislen == 5 ) {
1817 $numitalics++;
1818 $numbold++;
1819 }
1820 }
1821
1822 // If there is an odd number of both bold and italics, it is likely
1823 // that one of the bold ones was meant to be an apostrophe followed
1824 // by italics. Which one we cannot know for certain, but it is more
1825 // likely to be one that has a single-letter word before it.
1826 if ( ( $numbold % 2 == 1 ) && ( $numitalics % 2 == 1 ) ) {
1827 $firstsingleletterword = -1;
1828 $firstmultiletterword = -1;
1829 $firstspace = -1;
1830 for ( $i = 1; $i < $countarr; $i += 2 ) {
1831 if ( strlen( $arr[$i] ) == 3 ) {
1832 $x1 = substr( $arr[$i - 1], -1 );
1833 $x2 = substr( $arr[$i - 1], -2, 1 );
1834 if ( $x1 === ' ' ) {
1835 if ( $firstspace == -1 ) {
1836 $firstspace = $i;
1837 }
1838 } elseif ( $x2 === ' ' ) {
1839 $firstsingleletterword = $i;
1840 // if $firstsingleletterword is set, we don't
1841 // look at the other options, so we can bail early.
1842 break;
1843 } elseif ( $firstmultiletterword == -1 ) {
1844 $firstmultiletterword = $i;
1845 }
1846 }
1847 }
1848
1849 // If there is a single-letter word, use it!
1850 if ( $firstsingleletterword > -1 ) {
1851 $arr[$firstsingleletterword] = "''";
1852 $arr[$firstsingleletterword - 1] .= "'";
1853 } elseif ( $firstmultiletterword > -1 ) {
1854 // If not, but there's a multi-letter word, use that one.
1855 $arr[$firstmultiletterword] = "''";
1856 $arr[$firstmultiletterword - 1] .= "'";
1857 } elseif ( $firstspace > -1 ) {
1858 // ... otherwise use the first one that has neither.
1859 // (notice that it is possible for all three to be -1 if, for example,
1860 // there is only one pentuple-apostrophe in the line)
1861 $arr[$firstspace] = "''";
1862 $arr[$firstspace - 1] .= "'";
1863 }
1864 }
1865
1866 // Now let's actually convert our apostrophic mush to HTML!
1867 $output = '';
1868 $buffer = '';
1869 $state = '';
1870 $i = 0;
1871 foreach ( $arr as $r ) {
1872 if ( ( $i % 2 ) == 0 ) {
1873 if ( $state === 'both' ) {
1874 $buffer .= $r;
1875 } else {
1876 $output .= $r;
1877 }
1878 } else {
1879 $thislen = strlen( $r );
1880 if ( $thislen == 2 ) {
1881 if ( $state === 'i' ) {
1882 $output .= '</i>';
1883 $state = '';
1884 } elseif ( $state === 'bi' ) {
1885 $output .= '</i>';
1886 $state = 'b';
1887 } elseif ( $state === 'ib' ) {
1888 $output .= '</b></i><b>';
1889 $state = 'b';
1890 } elseif ( $state === 'both' ) {
1891 $output .= '<b><i>' . $buffer . '</i>';
1892 $state = 'b';
1893 } else { // $state can be 'b' or ''
1894 $output .= '<i>';
1895 $state .= 'i';
1896 }
1897 } elseif ( $thislen == 3 ) {
1898 if ( $state === 'b' ) {
1899 $output .= '</b>';
1900 $state = '';
1901 } elseif ( $state === 'bi' ) {
1902 $output .= '</i></b><i>';
1903 $state = 'i';
1904 } elseif ( $state === 'ib' ) {
1905 $output .= '</b>';
1906 $state = 'i';
1907 } elseif ( $state === 'both' ) {
1908 $output .= '<i><b>' . $buffer . '</b>';
1909 $state = 'i';
1910 } else { // $state can be 'i' or ''
1911 $output .= '<b>';
1912 $state .= 'b';
1913 }
1914 } elseif ( $thislen == 5 ) {
1915 if ( $state === 'b' ) {
1916 $output .= '</b><i>';
1917 $state = 'i';
1918 } elseif ( $state === 'i' ) {
1919 $output .= '</i><b>';
1920 $state = 'b';
1921 } elseif ( $state === 'bi' ) {
1922 $output .= '</i></b>';
1923 $state = '';
1924 } elseif ( $state === 'ib' ) {
1925 $output .= '</b></i>';
1926 $state = '';
1927 } elseif ( $state === 'both' ) {
1928 $output .= '<i><b>' . $buffer . '</b></i>';
1929 $state = '';
1930 } else { // ($state == '')
1931 $buffer = '';
1932 $state = 'both';
1933 }
1934 }
1935 }
1936 $i++;
1937 }
1938 // Now close all remaining tags. Notice that the order is important.
1939 if ( $state === 'b' || $state === 'ib' ) {
1940 $output .= '</b>';
1941 }
1942 if ( $state === 'i' || $state === 'bi' || $state === 'ib' ) {
1943 $output .= '</i>';
1944 }
1945 if ( $state === 'bi' ) {
1946 $output .= '</b>';
1947 }
1948 // There might be lonely ''''', so make sure we have a buffer
1949 if ( $state === 'both' && $buffer ) {
1950 $output .= '<b><i>' . $buffer . '</i></b>';
1951 }
1952 return $output;
1953 }
1954
1955 /**
1956 * Replace external links (REL)
1957 *
1958 * Note: this is all very hackish and the order of execution matters a lot.
1959 * Make sure to run tests/parser/parserTests.php if you change this code.
1960 *
1961 * @private
1962 *
1963 * @param string $text
1964 *
1965 * @throws MWException
1966 * @return string
1967 */
1968 public function replaceExternalLinks( $text ) {
1969 $bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1970 if ( $bits === false ) {
1971 throw new MWException( "PCRE needs to be compiled with "
1972 . "--enable-unicode-properties in order for MediaWiki to function" );
1973 }
1974 $s = array_shift( $bits );
1975
1976 $i = 0;
1977 while ( $i < count( $bits ) ) {
1978 $url = $bits[$i++];
1979 $i++; // protocol
1980 $text = $bits[$i++];
1981 $trail = $bits[$i++];
1982
1983 # The characters '<' and '>' (which were escaped by
1984 # removeHTMLtags()) should not be included in
1985 # URLs, per RFC 2396.
1986 $m2 = [];
1987 if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) {
1988 $text = substr( $url, $m2[0][1] ) . ' ' . $text;
1989 $url = substr( $url, 0, $m2[0][1] );
1990 }
1991
1992 # If the link text is an image URL, replace it with an <img> tag
1993 # This happened by accident in the original parser, but some people used it extensively
1994 $img = $this->maybeMakeExternalImage( $text );
1995 if ( $img !== false ) {
1996 $text = $img;
1997 }
1998
1999 $dtrail = '';
2000
2001 # Set linktype for CSS
2002 $linktype = 'text';
2003
2004 # No link text, e.g. [http://domain.tld/some.link]
2005 if ( $text == '' ) {
2006 # Autonumber
2007 $langObj = $this->getTargetLanguage();
2008 $text = '[' . $langObj->formatNum( ++$this->mAutonumber ) . ']';
2009 $linktype = 'autonumber';
2010 } else {
2011 # Have link text, e.g. [http://domain.tld/some.link text]s
2012 # Check for trail
2013 list( $dtrail, $trail ) = Linker::splitTrail( $trail );
2014 }
2015
2016 // Excluding protocol-relative URLs may avoid many false positives.
2017 if ( preg_match( '/^(?:' . wfUrlProtocolsWithoutProtRel() . ')/', $text ) ) {
2018 $text = $this->getTargetLanguage()->getConverter()->markNoConversion( $text );
2019 }
2020
2021 $url = Sanitizer::cleanUrl( $url );
2022
2023 # Use the encoded URL
2024 # This means that users can paste URLs directly into the text
2025 # Funny characters like ö aren't valid in URLs anyway
2026 # This was changed in August 2004
2027 $s .= Linker::makeExternalLink( $url, $text, false, $linktype,
2028 $this->getExternalLinkAttribs( $url ), $this->mTitle ) . $dtrail . $trail;
2029
2030 # Register link in the output object.
2031 $this->mOutput->addExternalLink( $url );
2032 }
2033
2034 return $s;
2035 }
2036
2037 /**
2038 * Get the rel attribute for a particular external link.
2039 *
2040 * @since 1.21
2041 * @param string|bool $url Optional URL, to extract the domain from for rel =>
2042 * nofollow if appropriate
2043 * @param LinkTarget|null $title Optional LinkTarget, for wgNoFollowNsExceptions lookups
2044 * @return string|null Rel attribute for $url
2045 */
2046 public static function getExternalLinkRel( $url = false, $title = null ) {
2047 global $wgNoFollowLinks, $wgNoFollowNsExceptions, $wgNoFollowDomainExceptions;
2048 $ns = $title ? $title->getNamespace() : false;
2049 if ( $wgNoFollowLinks && !in_array( $ns, $wgNoFollowNsExceptions )
2050 && !wfMatchesDomainList( $url, $wgNoFollowDomainExceptions )
2051 ) {
2052 return 'nofollow';
2053 }
2054 return null;
2055 }
2056
2057 /**
2058 * Get an associative array of additional HTML attributes appropriate for a
2059 * particular external link. This currently may include rel => nofollow
2060 * (depending on configuration, namespace, and the URL's domain) and/or a
2061 * target attribute (depending on configuration).
2062 *
2063 * @param string $url URL to extract the domain from for rel =>
2064 * nofollow if appropriate
2065 * @return array Associative array of HTML attributes
2066 */
2067 public function getExternalLinkAttribs( $url ) {
2068 $attribs = [];
2069 $rel = self::getExternalLinkRel( $url, $this->mTitle );
2070
2071 $target = $this->mOptions->getExternalLinkTarget();
2072 if ( $target ) {
2073 $attribs['target'] = $target;
2074 if ( !in_array( $target, [ '_self', '_parent', '_top' ] ) ) {
2075 // T133507. New windows can navigate parent cross-origin.
2076 // Including noreferrer due to lacking browser
2077 // support of noopener. Eventually noreferrer should be removed.
2078 if ( $rel !== '' ) {
2079 $rel .= ' ';
2080 }
2081 $rel .= 'noreferrer noopener';
2082 }
2083 }
2084 $attribs['rel'] = $rel;
2085 return $attribs;
2086 }
2087
2088 /**
2089 * Replace unusual escape codes in a URL with their equivalent characters
2090 *
2091 * This generally follows the syntax defined in RFC 3986, with special
2092 * consideration for HTTP query strings.
2093 *
2094 * @param string $url
2095 * @return string
2096 */
2097 public static function normalizeLinkUrl( $url ) {
2098 # Test for RFC 3986 IPv6 syntax
2099 $scheme = '[a-z][a-z0-9+.-]*:';
2100 $userinfo = '(?:[a-z0-9\-._~!$&\'()*+,;=:]|%[0-9a-f]{2})*';
2101 $ipv6Host = '\\[((?:[0-9a-f:]|%3[0-A]|%[46][1-6])+)\\]';
2102 if ( preg_match( "<^(?:{$scheme})?//(?:{$userinfo}@)?{$ipv6Host}(?:[:/?#].*|)$>i", $url, $m ) &&
2103 IP::isValid( rawurldecode( $m[1] ) )
2104 ) {
2105 $isIPv6 = rawurldecode( $m[1] );
2106 } else {
2107 $isIPv6 = false;
2108 }
2109
2110 # Make sure unsafe characters are encoded
2111 $url = preg_replace_callback( '/[\x00-\x20"<>\[\\\\\]^`{|}\x7F-\xFF]/',
2112 function ( $m ) {
2113 return rawurlencode( $m[0] );
2114 },
2115 $url
2116 );
2117
2118 $ret = '';
2119 $end = strlen( $url );
2120
2121 # Fragment part - 'fragment'
2122 $start = strpos( $url, '#' );
2123 if ( $start !== false && $start < $end ) {
2124 $ret = self::normalizeUrlComponent(
2125 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}' ) . $ret;
2126 $end = $start;
2127 }
2128
2129 # Query part - 'query' minus &=+;
2130 $start = strpos( $url, '?' );
2131 if ( $start !== false && $start < $end ) {
2132 $ret = self::normalizeUrlComponent(
2133 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}&=+;' ) . $ret;
2134 $end = $start;
2135 }
2136
2137 # Scheme and path part - 'pchar'
2138 # (we assume no userinfo or encoded colons in the host)
2139 $ret = self::normalizeUrlComponent(
2140 substr( $url, 0, $end ), '"#%<>[\]^`{|}/?' ) . $ret;
2141
2142 # Fix IPv6 syntax
2143 if ( $isIPv6 !== false ) {
2144 $ipv6Host = "%5B({$isIPv6})%5D";
2145 $ret = preg_replace(
2146 "<^((?:{$scheme})?//(?:{$userinfo}@)?){$ipv6Host}(?=[:/?#]|$)>i",
2147 "$1[$2]",
2148 $ret
2149 );
2150 }
2151
2152 return $ret;
2153 }
2154
2155 private static function normalizeUrlComponent( $component, $unsafe ) {
2156 $callback = function ( $matches ) use ( $unsafe ) {
2157 $char = urldecode( $matches[0] );
2158 $ord = ord( $char );
2159 if ( $ord > 32 && $ord < 127 && strpos( $unsafe, $char ) === false ) {
2160 # Unescape it
2161 return $char;
2162 } else {
2163 # Leave it escaped, but use uppercase for a-f
2164 return strtoupper( $matches[0] );
2165 }
2166 };
2167 return preg_replace_callback( '/%[0-9A-Fa-f]{2}/', $callback, $component );
2168 }
2169
2170 /**
2171 * make an image if it's allowed, either through the global
2172 * option, through the exception, or through the on-wiki whitelist
2173 *
2174 * @param string $url
2175 *
2176 * @return string
2177 */
2178 private function maybeMakeExternalImage( $url ) {
2179 $imagesfrom = $this->mOptions->getAllowExternalImagesFrom();
2180 $imagesexception = !empty( $imagesfrom );
2181 $text = false;
2182 # $imagesfrom could be either a single string or an array of strings, parse out the latter
2183 if ( $imagesexception && is_array( $imagesfrom ) ) {
2184 $imagematch = false;
2185 foreach ( $imagesfrom as $match ) {
2186 if ( strpos( $url, $match ) === 0 ) {
2187 $imagematch = true;
2188 break;
2189 }
2190 }
2191 } elseif ( $imagesexception ) {
2192 $imagematch = ( strpos( $url, $imagesfrom ) === 0 );
2193 } else {
2194 $imagematch = false;
2195 }
2196
2197 if ( $this->mOptions->getAllowExternalImages()
2198 || ( $imagesexception && $imagematch )
2199 ) {
2200 if ( preg_match( self::EXT_IMAGE_REGEX, $url ) ) {
2201 # Image found
2202 $text = Linker::makeExternalImage( $url );
2203 }
2204 }
2205 if ( !$text && $this->mOptions->getEnableImageWhitelist()
2206 && preg_match( self::EXT_IMAGE_REGEX, $url )
2207 ) {
2208 $whitelist = explode(
2209 "\n",
2210 wfMessage( 'external_image_whitelist' )->inContentLanguage()->text()
2211 );
2212
2213 foreach ( $whitelist as $entry ) {
2214 # Sanitize the regex fragment, make it case-insensitive, ignore blank entries/comments
2215 if ( strpos( $entry, '#' ) === 0 || $entry === '' ) {
2216 continue;
2217 }
2218 if ( preg_match( '/' . str_replace( '/', '\\/', $entry ) . '/i', $url ) ) {
2219 # Image matches a whitelist entry
2220 $text = Linker::makeExternalImage( $url );
2221 break;
2222 }
2223 }
2224 }
2225 return $text;
2226 }
2227
2228 /**
2229 * Process [[ ]] wikilinks
2230 *
2231 * @param string $s
2232 *
2233 * @return string Processed text
2234 *
2235 * @private
2236 */
2237 public function replaceInternalLinks( $s ) {
2238 $this->mLinkHolders->merge( $this->replaceInternalLinks2( $s ) );
2239 return $s;
2240 }
2241
2242 /**
2243 * Process [[ ]] wikilinks (RIL)
2244 * @param string &$s
2245 * @throws MWException
2246 * @return LinkHolderArray
2247 *
2248 * @private
2249 */
2250 public function replaceInternalLinks2( &$s ) {
2251 static $tc = false, $e1, $e1_img;
2252 # the % is needed to support urlencoded titles as well
2253 if ( !$tc ) {
2254 $tc = Title::legalChars() . '#%';
2255 # Match a link having the form [[namespace:link|alternate]]trail
2256 $e1 = "/^([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD";
2257 # Match cases where there is no "]]", which might still be images
2258 $e1_img = "/^([{$tc}]+)\\|(.*)\$/sD";
2259 }
2260
2261 $holders = new LinkHolderArray( $this );
2262
2263 # split the entire text string on occurrences of [[
2264 $a = StringUtils::explode( '[[', ' ' . $s );
2265 # get the first element (all text up to first [[), and remove the space we added
2266 $s = $a->current();
2267 $a->next();
2268 $line = $a->current(); # Workaround for broken ArrayIterator::next() that returns "void"
2269 $s = substr( $s, 1 );
2270
2271 $useLinkPrefixExtension = $this->getTargetLanguage()->linkPrefixExtension();
2272 $e2 = null;
2273 if ( $useLinkPrefixExtension ) {
2274 # Match the end of a line for a word that's not followed by whitespace,
2275 # e.g. in the case of 'The Arab al[[Razi]]', 'al' will be matched
2276 $charset = $this->contLang->linkPrefixCharset();
2277 $e2 = "/^((?>.*[^$charset]|))(.+)$/sDu";
2278 }
2279
2280 if ( is_null( $this->mTitle ) ) {
2281 throw new MWException( __METHOD__ . ": \$this->mTitle is null\n" );
2282 }
2283 $nottalk = !$this->mTitle->isTalkPage();
2284
2285 if ( $useLinkPrefixExtension ) {
2286 $m = [];
2287 if ( preg_match( $e2, $s, $m ) ) {
2288 $first_prefix = $m[2];
2289 } else {
2290 $first_prefix = false;
2291 }
2292 } else {
2293 $prefix = '';
2294 }
2295
2296 $useSubpages = $this->areSubpagesAllowed();
2297
2298 # Loop for each link
2299 for ( ; $line !== false && $line !== null; $a->next(), $line = $a->current() ) {
2300 # Check for excessive memory usage
2301 if ( $holders->isBig() ) {
2302 # Too big
2303 # Do the existence check, replace the link holders and clear the array
2304 $holders->replace( $s );
2305 $holders->clear();
2306 }
2307
2308 if ( $useLinkPrefixExtension ) {
2309 if ( preg_match( $e2, $s, $m ) ) {
2310 list( , $s, $prefix ) = $m;
2311 } else {
2312 $prefix = '';
2313 }
2314 # first link
2315 if ( $first_prefix ) {
2316 $prefix = $first_prefix;
2317 $first_prefix = false;
2318 }
2319 }
2320
2321 $might_be_img = false;
2322
2323 if ( preg_match( $e1, $line, $m ) ) { # page with normal text or alt
2324 $text = $m[2];
2325 # If we get a ] at the beginning of $m[3] that means we have a link that's something like:
2326 # [[Image:Foo.jpg|[http://example.com desc]]] <- having three ] in a row fucks up,
2327 # the real problem is with the $e1 regex
2328 # See T1500.
2329 # Still some problems for cases where the ] is meant to be outside punctuation,
2330 # and no image is in sight. See T4095.
2331 if ( $text !== ''
2332 && substr( $m[3], 0, 1 ) === ']'
2333 && strpos( $text, '[' ) !== false
2334 ) {
2335 $text .= ']'; # so that replaceExternalLinks($text) works later
2336 $m[3] = substr( $m[3], 1 );
2337 }
2338 # fix up urlencoded title texts
2339 if ( strpos( $m[1], '%' ) !== false ) {
2340 # Should anchors '#' also be rejected?
2341 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2342 }
2343 $trail = $m[3];
2344 } elseif ( preg_match( $e1_img, $line, $m ) ) {
2345 # Invalid, but might be an image with a link in its caption
2346 $might_be_img = true;
2347 $text = $m[2];
2348 if ( strpos( $m[1], '%' ) !== false ) {
2349 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2350 }
2351 $trail = "";
2352 } else { # Invalid form; output directly
2353 $s .= $prefix . '[[' . $line;
2354 continue;
2355 }
2356
2357 $origLink = ltrim( $m[1], ' ' );
2358
2359 # Don't allow internal links to pages containing
2360 # PROTO: where PROTO is a valid URL protocol; these
2361 # should be external links.
2362 if ( preg_match( '/^(?i:' . $this->mUrlProtocols . ')/', $origLink ) ) {
2363 $s .= $prefix . '[[' . $line;
2364 continue;
2365 }
2366
2367 # Make subpage if necessary
2368 if ( $useSubpages ) {
2369 $link = $this->maybeDoSubpageLink( $origLink, $text );
2370 } else {
2371 $link = $origLink;
2372 }
2373
2374 // \x7f isn't a default legal title char, so most likely strip
2375 // markers will force us into the "invalid form" path above. But,
2376 // just in case, let's assert that xmlish tags aren't valid in
2377 // the title position.
2378 $unstrip = $this->mStripState->killMarkers( $link );
2379 $noMarkers = ( $unstrip === $link );
2380
2381 $nt = $noMarkers ? Title::newFromText( $link ) : null;
2382 if ( $nt === null ) {
2383 $s .= $prefix . '[[' . $line;
2384 continue;
2385 }
2386
2387 $ns = $nt->getNamespace();
2388 $iw = $nt->getInterwiki();
2389
2390 $noforce = ( substr( $origLink, 0, 1 ) !== ':' );
2391
2392 if ( $might_be_img ) { # if this is actually an invalid link
2393 if ( $ns == NS_FILE && $noforce ) { # but might be an image
2394 $found = false;
2395 while ( true ) {
2396 # look at the next 'line' to see if we can close it there
2397 $a->next();
2398 $next_line = $a->current();
2399 if ( $next_line === false || $next_line === null ) {
2400 break;
2401 }
2402 $m = explode( ']]', $next_line, 3 );
2403 if ( count( $m ) == 3 ) {
2404 # the first ]] closes the inner link, the second the image
2405 $found = true;
2406 $text .= "[[{$m[0]}]]{$m[1]}";
2407 $trail = $m[2];
2408 break;
2409 } elseif ( count( $m ) == 2 ) {
2410 # if there's exactly one ]] that's fine, we'll keep looking
2411 $text .= "[[{$m[0]}]]{$m[1]}";
2412 } else {
2413 # if $next_line is invalid too, we need look no further
2414 $text .= '[[' . $next_line;
2415 break;
2416 }
2417 }
2418 if ( !$found ) {
2419 # we couldn't find the end of this imageLink, so output it raw
2420 # but don't ignore what might be perfectly normal links in the text we've examined
2421 $holders->merge( $this->replaceInternalLinks2( $text ) );
2422 $s .= "{$prefix}[[$link|$text";
2423 # note: no $trail, because without an end, there *is* no trail
2424 continue;
2425 }
2426 } else { # it's not an image, so output it raw
2427 $s .= "{$prefix}[[$link|$text";
2428 # note: no $trail, because without an end, there *is* no trail
2429 continue;
2430 }
2431 }
2432
2433 $wasblank = ( $text == '' );
2434 if ( $wasblank ) {
2435 $text = $link;
2436 if ( !$noforce ) {
2437 # Strip off leading ':'
2438 $text = substr( $text, 1 );
2439 }
2440 } else {
2441 # T6598 madness. Handle the quotes only if they come from the alternate part
2442 # [[Lista d''e paise d''o munno]] -> <a href="...">Lista d''e paise d''o munno</a>
2443 # [[Criticism of Harry Potter|Criticism of ''Harry Potter'']]
2444 # -> <a href="Criticism of Harry Potter">Criticism of <i>Harry Potter</i></a>
2445 $text = $this->doQuotes( $text );
2446 }
2447
2448 # Link not escaped by : , create the various objects
2449 if ( $noforce && !$nt->wasLocalInterwiki() ) {
2450 # Interwikis
2451 if (
2452 $iw && $this->mOptions->getInterwikiMagic() && $nottalk && (
2453 Language::fetchLanguageName( $iw, null, 'mw' ) ||
2454 in_array( $iw, $this->svcOptions->get( 'ExtraInterlanguageLinkPrefixes' ) )
2455 )
2456 ) {
2457 # T26502: filter duplicates
2458 if ( !isset( $this->mLangLinkLanguages[$iw] ) ) {
2459 $this->mLangLinkLanguages[$iw] = true;
2460 $this->mOutput->addLanguageLink( $nt->getFullText() );
2461 }
2462
2463 /**
2464 * Strip the whitespace interwiki links produce, see T10897
2465 */
2466 $s = rtrim( $s . $prefix ) . $trail; # T175416
2467 continue;
2468 }
2469
2470 if ( $ns == NS_FILE ) {
2471 if ( !wfIsBadImage( $nt->getDBkey(), $this->mTitle ) ) {
2472 if ( $wasblank ) {
2473 # if no parameters were passed, $text
2474 # becomes something like "File:Foo.png",
2475 # which we don't want to pass on to the
2476 # image generator
2477 $text = '';
2478 } else {
2479 # recursively parse links inside the image caption
2480 # actually, this will parse them in any other parameters, too,
2481 # but it might be hard to fix that, and it doesn't matter ATM
2482 $text = $this->replaceExternalLinks( $text );
2483 $holders->merge( $this->replaceInternalLinks2( $text ) );
2484 }
2485 # cloak any absolute URLs inside the image markup, so replaceExternalLinks() won't touch them
2486 $s .= $prefix . $this->armorLinks(
2487 $this->makeImage( $nt, $text, $holders ) ) . $trail;
2488 continue;
2489 }
2490 } elseif ( $ns == NS_CATEGORY ) {
2491 /**
2492 * Strip the whitespace Category links produce, see T2087
2493 */
2494 $s = rtrim( $s . $prefix ) . $trail; # T2087, T87753
2495
2496 if ( $wasblank ) {
2497 $sortkey = $this->getDefaultSort();
2498 } else {
2499 $sortkey = $text;
2500 }
2501 $sortkey = Sanitizer::decodeCharReferences( $sortkey );
2502 $sortkey = str_replace( "\n", '', $sortkey );
2503 $sortkey = $this->getTargetLanguage()->convertCategoryKey( $sortkey );
2504 $this->mOutput->addCategory( $nt->getDBkey(), $sortkey );
2505
2506 continue;
2507 }
2508 }
2509
2510 # Self-link checking. For some languages, variants of the title are checked in
2511 # LinkHolderArray::doVariants() to allow batching the existence checks necessary
2512 # for linking to a different variant.
2513 if ( $ns != NS_SPECIAL && $nt->equals( $this->mTitle ) && !$nt->hasFragment() ) {
2514 $s .= $prefix . Linker::makeSelfLinkObj( $nt, $text, '', $trail );
2515 continue;
2516 }
2517
2518 # NS_MEDIA is a pseudo-namespace for linking directly to a file
2519 # @todo FIXME: Should do batch file existence checks, see comment below
2520 if ( $ns == NS_MEDIA ) {
2521 # Give extensions a chance to select the file revision for us
2522 $options = [];
2523 $descQuery = false;
2524 Hooks::run( 'BeforeParserFetchFileAndTitle',
2525 [ $this, $nt, &$options, &$descQuery ] );
2526 # Fetch and register the file (file title may be different via hooks)
2527 list( $file, $nt ) = $this->fetchFileAndTitle( $nt, $options );
2528 # Cloak with NOPARSE to avoid replacement in replaceExternalLinks
2529 $s .= $prefix . $this->armorLinks(
2530 Linker::makeMediaLinkFile( $nt, $file, $text ) ) . $trail;
2531 continue;
2532 }
2533
2534 # Some titles, such as valid special pages or files in foreign repos, should
2535 # be shown as bluelinks even though they're not included in the page table
2536 # @todo FIXME: isAlwaysKnown() can be expensive for file links; we should really do
2537 # batch file existence checks for NS_FILE and NS_MEDIA
2538 if ( $iw == '' && $nt->isAlwaysKnown() ) {
2539 $this->mOutput->addLink( $nt );
2540 $s .= $this->makeKnownLinkHolder( $nt, $text, $trail, $prefix );
2541 } else {
2542 # Links will be added to the output link list after checking
2543 $s .= $holders->makeHolder( $nt, $text, [], $trail, $prefix );
2544 }
2545 }
2546 return $holders;
2547 }
2548
2549 /**
2550 * Render a forced-blue link inline; protect against double expansion of
2551 * URLs if we're in a mode that prepends full URL prefixes to internal links.
2552 * Since this little disaster has to split off the trail text to avoid
2553 * breaking URLs in the following text without breaking trails on the
2554 * wiki links, it's been made into a horrible function.
2555 *
2556 * @param Title $nt
2557 * @param string $text
2558 * @param string $trail
2559 * @param string $prefix
2560 * @return string HTML-wikitext mix oh yuck
2561 */
2562 protected function makeKnownLinkHolder( $nt, $text = '', $trail = '', $prefix = '' ) {
2563 list( $inside, $trail ) = Linker::splitTrail( $trail );
2564
2565 if ( $text == '' ) {
2566 $text = htmlspecialchars( $nt->getPrefixedText() );
2567 }
2568
2569 $link = $this->getLinkRenderer()->makeKnownLink(
2570 $nt, new HtmlArmor( "$prefix$text$inside" )
2571 );
2572
2573 return $this->armorLinks( $link ) . $trail;
2574 }
2575
2576 /**
2577 * Insert a NOPARSE hacky thing into any inline links in a chunk that's
2578 * going to go through further parsing steps before inline URL expansion.
2579 *
2580 * Not needed quite as much as it used to be since free links are a bit
2581 * more sensible these days. But bracketed links are still an issue.
2582 *
2583 * @param string $text More-or-less HTML
2584 * @return string Less-or-more HTML with NOPARSE bits
2585 */
2586 public function armorLinks( $text ) {
2587 return preg_replace( '/\b((?i)' . $this->mUrlProtocols . ')/',
2588 self::MARKER_PREFIX . "NOPARSE$1", $text );
2589 }
2590
2591 /**
2592 * Return true if subpage links should be expanded on this page.
2593 * @return bool
2594 */
2595 public function areSubpagesAllowed() {
2596 # Some namespaces don't allow subpages
2597 return $this->nsInfo->hasSubpages( $this->mTitle->getNamespace() );
2598 }
2599
2600 /**
2601 * Handle link to subpage if necessary
2602 *
2603 * @param string $target The source of the link
2604 * @param string &$text The link text, modified as necessary
2605 * @return string The full name of the link
2606 * @private
2607 */
2608 public function maybeDoSubpageLink( $target, &$text ) {
2609 return Linker::normalizeSubpageLink( $this->mTitle, $target, $text );
2610 }
2611
2612 /**
2613 * Make lists from lines starting with ':', '*', '#', etc. (DBL)
2614 *
2615 * @param string $text
2616 * @param bool $linestart Whether or not this is at the start of a line.
2617 * @private
2618 * @return string The lists rendered as HTML
2619 */
2620 public function doBlockLevels( $text, $linestart ) {
2621 return BlockLevelPass::doBlockLevels( $text, $linestart );
2622 }
2623
2624 /**
2625 * Return value of a magic variable (like PAGENAME)
2626 *
2627 * @private
2628 *
2629 * @param string $index Magic variable identifier as mapped in MagicWordFactory::$mVariableIDs
2630 * @param bool|PPFrame $frame
2631 *
2632 * @throws MWException
2633 * @return string
2634 */
2635 public function getVariableValue( $index, $frame = false ) {
2636 if ( is_null( $this->mTitle ) ) {
2637 // If no title set, bad things are going to happen
2638 // later. Title should always be set since this
2639 // should only be called in the middle of a parse
2640 // operation (but the unit-tests do funky stuff)
2641 throw new MWException( __METHOD__ . ' Should only be '
2642 . ' called while parsing (no title set)' );
2643 }
2644
2645 // Avoid PHP 7.1 warning from passing $this by reference
2646 $parser = $this;
2647
2648 /**
2649 * Some of these require message or data lookups and can be
2650 * expensive to check many times.
2651 */
2652 if (
2653 Hooks::run( 'ParserGetVariableValueVarCache', [ &$parser, &$this->mVarCache ] ) &&
2654 isset( $this->mVarCache[$index] )
2655 ) {
2656 return $this->mVarCache[$index];
2657 }
2658
2659 $ts = wfTimestamp( TS_UNIX, $this->mOptions->getTimestamp() );
2660 Hooks::run( 'ParserGetVariableValueTs', [ &$parser, &$ts ] );
2661
2662 $pageLang = $this->getFunctionLang();
2663
2664 switch ( $index ) {
2665 case '!':
2666 $value = '|';
2667 break;
2668 case 'currentmonth':
2669 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'm' ), true );
2670 break;
2671 case 'currentmonth1':
2672 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'n' ), true );
2673 break;
2674 case 'currentmonthname':
2675 $value = $pageLang->getMonthName( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2676 break;
2677 case 'currentmonthnamegen':
2678 $value = $pageLang->getMonthNameGen( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2679 break;
2680 case 'currentmonthabbrev':
2681 $value = $pageLang->getMonthAbbreviation( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2682 break;
2683 case 'currentday':
2684 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'j' ), true );
2685 break;
2686 case 'currentday2':
2687 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'd' ), true );
2688 break;
2689 case 'localmonth':
2690 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'm' ), true );
2691 break;
2692 case 'localmonth1':
2693 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'n' ), true );
2694 break;
2695 case 'localmonthname':
2696 $value = $pageLang->getMonthName( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2697 break;
2698 case 'localmonthnamegen':
2699 $value = $pageLang->getMonthNameGen( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2700 break;
2701 case 'localmonthabbrev':
2702 $value = $pageLang->getMonthAbbreviation( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2703 break;
2704 case 'localday':
2705 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'j' ), true );
2706 break;
2707 case 'localday2':
2708 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'd' ), true );
2709 break;
2710 case 'pagename':
2711 $value = wfEscapeWikiText( $this->mTitle->getText() );
2712 break;
2713 case 'pagenamee':
2714 $value = wfEscapeWikiText( $this->mTitle->getPartialURL() );
2715 break;
2716 case 'fullpagename':
2717 $value = wfEscapeWikiText( $this->mTitle->getPrefixedText() );
2718 break;
2719 case 'fullpagenamee':
2720 $value = wfEscapeWikiText( $this->mTitle->getPrefixedURL() );
2721 break;
2722 case 'subpagename':
2723 $value = wfEscapeWikiText( $this->mTitle->getSubpageText() );
2724 break;
2725 case 'subpagenamee':
2726 $value = wfEscapeWikiText( $this->mTitle->getSubpageUrlForm() );
2727 break;
2728 case 'rootpagename':
2729 $value = wfEscapeWikiText( $this->mTitle->getRootText() );
2730 break;
2731 case 'rootpagenamee':
2732 $value = wfEscapeWikiText( wfUrlencode( str_replace(
2733 ' ',
2734 '_',
2735 $this->mTitle->getRootText()
2736 ) ) );
2737 break;
2738 case 'basepagename':
2739 $value = wfEscapeWikiText( $this->mTitle->getBaseText() );
2740 break;
2741 case 'basepagenamee':
2742 $value = wfEscapeWikiText( wfUrlencode( str_replace(
2743 ' ',
2744 '_',
2745 $this->mTitle->getBaseText()
2746 ) ) );
2747 break;
2748 case 'talkpagename':
2749 if ( $this->mTitle->canHaveTalkPage() ) {
2750 $talkPage = $this->mTitle->getTalkPage();
2751 $value = wfEscapeWikiText( $talkPage->getPrefixedText() );
2752 } else {
2753 $value = '';
2754 }
2755 break;
2756 case 'talkpagenamee':
2757 if ( $this->mTitle->canHaveTalkPage() ) {
2758 $talkPage = $this->mTitle->getTalkPage();
2759 $value = wfEscapeWikiText( $talkPage->getPrefixedURL() );
2760 } else {
2761 $value = '';
2762 }
2763 break;
2764 case 'subjectpagename':
2765 $subjPage = $this->mTitle->getSubjectPage();
2766 $value = wfEscapeWikiText( $subjPage->getPrefixedText() );
2767 break;
2768 case 'subjectpagenamee':
2769 $subjPage = $this->mTitle->getSubjectPage();
2770 $value = wfEscapeWikiText( $subjPage->getPrefixedURL() );
2771 break;
2772 case 'pageid': // requested in T25427
2773 $pageid = $this->getTitle()->getArticleID();
2774 if ( $pageid == 0 ) {
2775 # 0 means the page doesn't exist in the database,
2776 # which means the user is previewing a new page.
2777 # The vary-revision flag must be set, because the magic word
2778 # will have a different value once the page is saved.
2779 $this->mOutput->setFlag( 'vary-revision' );
2780 wfDebug( __METHOD__ . ": {{PAGEID}} used in a new page, setting vary-revision...\n" );
2781 }
2782 $value = $pageid ?: null;
2783 break;
2784 case 'revisionid':
2785 if (
2786 $this->svcOptions->get( 'MiserMode' ) &&
2787 !$this->mOptions->getInterfaceMessage() &&
2788 // @TODO: disallow this word on all namespaces
2789 $this->nsInfo->isContent( $this->mTitle->getNamespace() )
2790 ) {
2791 // Use a stub result instead of the actual revision ID in order to avoid
2792 // double parses on page save but still allow preview detection (T137900)
2793 if ( $this->getRevisionId() || $this->mOptions->getSpeculativeRevId() ) {
2794 $value = '-';
2795 } else {
2796 $this->mOutput->setFlag( 'vary-revision-exists' );
2797 $value = '';
2798 }
2799 } else {
2800 # Inform the edit saving system that getting the canonical output after
2801 # revision insertion requires another parse using the actual revision ID
2802 $this->mOutput->setFlag( 'vary-revision-id' );
2803 wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" );
2804 $value = $this->getRevisionId();
2805 if ( $value === 0 ) {
2806 $rev = $this->getRevisionObject();
2807 $value = $rev ? $rev->getId() : $value;
2808 }
2809 if ( !$value ) {
2810 $value = $this->mOptions->getSpeculativeRevId();
2811 if ( $value ) {
2812 $this->mOutput->setSpeculativeRevIdUsed( $value );
2813 }
2814 }
2815 }
2816 break;
2817 case 'revisionday':
2818 $value = (int)$this->getRevisionTimestampSubstring( 6, 2, self::MAX_TTS, $index );
2819 break;
2820 case 'revisionday2':
2821 $value = $this->getRevisionTimestampSubstring( 6, 2, self::MAX_TTS, $index );
2822 break;
2823 case 'revisionmonth':
2824 $value = $this->getRevisionTimestampSubstring( 4, 2, self::MAX_TTS, $index );
2825 break;
2826 case 'revisionmonth1':
2827 $value = (int)$this->getRevisionTimestampSubstring( 4, 2, self::MAX_TTS, $index );
2828 break;
2829 case 'revisionyear':
2830 $value = $this->getRevisionTimestampSubstring( 0, 4, self::MAX_TTS, $index );
2831 break;
2832 case 'revisiontimestamp':
2833 # Let the edit saving system know we should parse the page
2834 # *after* a revision ID has been assigned. This is for null edits.
2835 $this->mOutput->setFlag( 'vary-revision' );
2836 wfDebug( __METHOD__ . ": {{REVISIONTIMESTAMP}} used, setting vary-revision...\n" );
2837 $value = $this->getRevisionTimestamp();
2838 break;
2839 case 'revisionuser':
2840 # Let the edit saving system know we should parse the page
2841 # *after* a revision ID has been assigned for null edits.
2842 $this->mOutput->setFlag( 'vary-user' );
2843 wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user...\n" );
2844 $value = $this->getRevisionUser();
2845 break;
2846 case 'revisionsize':
2847 $value = $this->getRevisionSize();
2848 break;
2849 case 'namespace':
2850 $value = str_replace( '_', ' ',
2851 $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2852 break;
2853 case 'namespacee':
2854 $value = wfUrlencode( $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2855 break;
2856 case 'namespacenumber':
2857 $value = $this->mTitle->getNamespace();
2858 break;
2859 case 'talkspace':
2860 $value = $this->mTitle->canHaveTalkPage()
2861 ? str_replace( '_', ' ', $this->mTitle->getTalkNsText() )
2862 : '';
2863 break;
2864 case 'talkspacee':
2865 $value = $this->mTitle->canHaveTalkPage() ? wfUrlencode( $this->mTitle->getTalkNsText() ) : '';
2866 break;
2867 case 'subjectspace':
2868 $value = str_replace( '_', ' ', $this->mTitle->getSubjectNsText() );
2869 break;
2870 case 'subjectspacee':
2871 $value = ( wfUrlencode( $this->mTitle->getSubjectNsText() ) );
2872 break;
2873 case 'currentdayname':
2874 $value = $pageLang->getWeekdayName( (int)MWTimestamp::getInstance( $ts )->format( 'w' ) + 1 );
2875 break;
2876 case 'currentyear':
2877 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'Y' ), true );
2878 break;
2879 case 'currenttime':
2880 $value = $pageLang->time( wfTimestamp( TS_MW, $ts ), false, false );
2881 break;
2882 case 'currenthour':
2883 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'H' ), true );
2884 break;
2885 case 'currentweek':
2886 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2887 # int to remove the padding
2888 $value = $pageLang->formatNum( (int)MWTimestamp::getInstance( $ts )->format( 'W' ) );
2889 break;
2890 case 'currentdow':
2891 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'w' ) );
2892 break;
2893 case 'localdayname':
2894 $value = $pageLang->getWeekdayName(
2895 (int)MWTimestamp::getLocalInstance( $ts )->format( 'w' ) + 1
2896 );
2897 break;
2898 case 'localyear':
2899 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'Y' ), true );
2900 break;
2901 case 'localtime':
2902 $value = $pageLang->time(
2903 MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' ),
2904 false,
2905 false
2906 );
2907 break;
2908 case 'localhour':
2909 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'H' ), true );
2910 break;
2911 case 'localweek':
2912 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2913 # int to remove the padding
2914 $value = $pageLang->formatNum( (int)MWTimestamp::getLocalInstance( $ts )->format( 'W' ) );
2915 break;
2916 case 'localdow':
2917 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'w' ) );
2918 break;
2919 case 'numberofarticles':
2920 $value = $pageLang->formatNum( SiteStats::articles() );
2921 break;
2922 case 'numberoffiles':
2923 $value = $pageLang->formatNum( SiteStats::images() );
2924 break;
2925 case 'numberofusers':
2926 $value = $pageLang->formatNum( SiteStats::users() );
2927 break;
2928 case 'numberofactiveusers':
2929 $value = $pageLang->formatNum( SiteStats::activeUsers() );
2930 break;
2931 case 'numberofpages':
2932 $value = $pageLang->formatNum( SiteStats::pages() );
2933 break;
2934 case 'numberofadmins':
2935 $value = $pageLang->formatNum( SiteStats::numberingroup( 'sysop' ) );
2936 break;
2937 case 'numberofedits':
2938 $value = $pageLang->formatNum( SiteStats::edits() );
2939 break;
2940 case 'currenttimestamp':
2941 $value = wfTimestamp( TS_MW, $ts );
2942 break;
2943 case 'localtimestamp':
2944 $value = MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' );
2945 break;
2946 case 'currentversion':
2947 $value = SpecialVersion::getVersion();
2948 break;
2949 case 'articlepath':
2950 return $this->svcOptions->get( 'ArticlePath' );
2951 case 'sitename':
2952 return $this->svcOptions->get( 'Sitename' );
2953 case 'server':
2954 return $this->svcOptions->get( 'Server' );
2955 case 'servername':
2956 return $this->svcOptions->get( 'ServerName' );
2957 case 'scriptpath':
2958 return $this->svcOptions->get( 'ScriptPath' );
2959 case 'stylepath':
2960 return $this->svcOptions->get( 'StylePath' );
2961 case 'directionmark':
2962 return $pageLang->getDirMark();
2963 case 'contentlanguage':
2964 return $this->svcOptions->get( 'LanguageCode' );
2965 case 'pagelanguage':
2966 $value = $pageLang->getCode();
2967 break;
2968 case 'cascadingsources':
2969 $value = CoreParserFunctions::cascadingsources( $this );
2970 break;
2971 default:
2972 $ret = null;
2973 Hooks::run(
2974 'ParserGetVariableValueSwitch',
2975 [ &$parser, &$this->mVarCache, &$index, &$ret, &$frame ]
2976 );
2977
2978 return $ret;
2979 }
2980
2981 if ( $index ) {
2982 $this->mVarCache[$index] = $value;
2983 }
2984
2985 return $value;
2986 }
2987
2988 /**
2989 * @param int $start
2990 * @param int $len
2991 * @param int $mtts Max time-till-save; sets vary-revision if result might change by then
2992 * @param string $variable Parser variable name
2993 * @return string
2994 */
2995 private function getRevisionTimestampSubstring( $start, $len, $mtts, $variable ) {
2996 # Get the timezone-adjusted timestamp to be used for this revision
2997 $resNow = substr( $this->getRevisionTimestamp(), $start, $len );
2998 # Possibly set vary-revision if there is not yet an associated revision
2999 if ( !$this->getRevisionObject() ) {
3000 # Get the timezone-adjusted timestamp $mtts seconds in the future
3001 $resThen = substr(
3002 $this->contLang->userAdjust( wfTimestamp( TS_MW, time() + $mtts ), '' ),
3003 $start,
3004 $len
3005 );
3006
3007 if ( $resNow !== $resThen ) {
3008 # Let the edit saving system know we should parse the page
3009 # *after* a revision ID has been assigned. This is for null edits.
3010 $this->mOutput->setFlag( 'vary-revision' );
3011 wfDebug( __METHOD__ . ": $variable used, setting vary-revision...\n" );
3012 }
3013 }
3014
3015 return $resNow;
3016 }
3017
3018 /**
3019 * initialise the magic variables (like CURRENTMONTHNAME) and substitution modifiers
3020 *
3021 * @private
3022 */
3023 public function initialiseVariables() {
3024 $variableIDs = $this->magicWordFactory->getVariableIDs();
3025 $substIDs = $this->magicWordFactory->getSubstIDs();
3026
3027 $this->mVariables = $this->magicWordFactory->newArray( $variableIDs );
3028 $this->mSubstWords = $this->magicWordFactory->newArray( $substIDs );
3029 }
3030
3031 /**
3032 * Preprocess some wikitext and return the document tree.
3033 * This is the ghost of replace_variables().
3034 *
3035 * @param string $text The text to parse
3036 * @param int $flags Bitwise combination of:
3037 * - self::PTD_FOR_INCLUSION: Handle "<noinclude>" and "<includeonly>" as if the text is being
3038 * included. Default is to assume a direct page view.
3039 *
3040 * The generated DOM tree must depend only on the input text and the flags.
3041 * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a regression of T6899.
3042 *
3043 * Any flag added to the $flags parameter here, or any other parameter liable to cause a
3044 * change in the DOM tree for a given text, must be passed through the section identifier
3045 * in the section edit link and thus back to extractSections().
3046 *
3047 * The output of this function is currently only cached in process memory, but a persistent
3048 * cache may be implemented at a later date which takes further advantage of these strict
3049 * dependency requirements.
3050 *
3051 * @return PPNode
3052 */
3053 public function preprocessToDom( $text, $flags = 0 ) {
3054 $dom = $this->getPreprocessor()->preprocessToObj( $text, $flags );
3055 return $dom;
3056 }
3057
3058 /**
3059 * Return a three-element array: leading whitespace, string contents, trailing whitespace
3060 *
3061 * @param string $s
3062 *
3063 * @return array
3064 */
3065 public static function splitWhitespace( $s ) {
3066 $ltrimmed = ltrim( $s );
3067 $w1 = substr( $s, 0, strlen( $s ) - strlen( $ltrimmed ) );
3068 $trimmed = rtrim( $ltrimmed );
3069 $diff = strlen( $ltrimmed ) - strlen( $trimmed );
3070 if ( $diff > 0 ) {
3071 $w2 = substr( $ltrimmed, -$diff );
3072 } else {
3073 $w2 = '';
3074 }
3075 return [ $w1, $trimmed, $w2 ];
3076 }
3077
3078 /**
3079 * Replace magic variables, templates, and template arguments
3080 * with the appropriate text. Templates are substituted recursively,
3081 * taking care to avoid infinite loops.
3082 *
3083 * Note that the substitution depends on value of $mOutputType:
3084 * self::OT_WIKI: only {{subst:}} templates
3085 * self::OT_PREPROCESS: templates but not extension tags
3086 * self::OT_HTML: all templates and extension tags
3087 *
3088 * @param string $text The text to transform
3089 * @param false|PPFrame|array $frame Object describing the arguments passed to the
3090 * template. Arguments may also be provided as an associative array, as
3091 * was the usual case before MW1.12. Providing arguments this way may be
3092 * useful for extensions wishing to perform variable replacement
3093 * explicitly.
3094 * @param bool $argsOnly Only do argument (triple-brace) expansion, not
3095 * double-brace expansion.
3096 * @return string
3097 */
3098 public function replaceVariables( $text, $frame = false, $argsOnly = false ) {
3099 # Is there any text? Also, Prevent too big inclusions!
3100 $textSize = strlen( $text );
3101 if ( $textSize < 1 || $textSize > $this->mOptions->getMaxIncludeSize() ) {
3102 return $text;
3103 }
3104
3105 if ( $frame === false ) {
3106 $frame = $this->getPreprocessor()->newFrame();
3107 } elseif ( !( $frame instanceof PPFrame ) ) {
3108 wfDebug( __METHOD__ . " called using plain parameters instead of "
3109 . "a PPFrame instance. Creating custom frame.\n" );
3110 $frame = $this->getPreprocessor()->newCustomFrame( $frame );
3111 }
3112
3113 $dom = $this->preprocessToDom( $text );
3114 $flags = $argsOnly ? PPFrame::NO_TEMPLATES : 0;
3115 $text = $frame->expand( $dom, $flags );
3116
3117 return $text;
3118 }
3119
3120 /**
3121 * Clean up argument array - refactored in 1.9 so parserfunctions can use it, too.
3122 *
3123 * @param array $args
3124 *
3125 * @return array
3126 */
3127 public static function createAssocArgs( $args ) {
3128 $assocArgs = [];
3129 $index = 1;
3130 foreach ( $args as $arg ) {
3131 $eqpos = strpos( $arg, '=' );
3132 if ( $eqpos === false ) {
3133 $assocArgs[$index++] = $arg;
3134 } else {
3135 $name = trim( substr( $arg, 0, $eqpos ) );
3136 $value = trim( substr( $arg, $eqpos + 1 ) );
3137 if ( $value === false ) {
3138 $value = '';
3139 }
3140 if ( $name !== false ) {
3141 $assocArgs[$name] = $value;
3142 }
3143 }
3144 }
3145
3146 return $assocArgs;
3147 }
3148
3149 /**
3150 * Warn the user when a parser limitation is reached
3151 * Will warn at most once the user per limitation type
3152 *
3153 * The results are shown during preview and run through the Parser (See EditPage.php)
3154 *
3155 * @param string $limitationType Should be one of:
3156 * 'expensive-parserfunction' (corresponding messages:
3157 * 'expensive-parserfunction-warning',
3158 * 'expensive-parserfunction-category')
3159 * 'post-expand-template-argument' (corresponding messages:
3160 * 'post-expand-template-argument-warning',
3161 * 'post-expand-template-argument-category')
3162 * 'post-expand-template-inclusion' (corresponding messages:
3163 * 'post-expand-template-inclusion-warning',
3164 * 'post-expand-template-inclusion-category')
3165 * 'node-count-exceeded' (corresponding messages:
3166 * 'node-count-exceeded-warning',
3167 * 'node-count-exceeded-category')
3168 * 'expansion-depth-exceeded' (corresponding messages:
3169 * 'expansion-depth-exceeded-warning',
3170 * 'expansion-depth-exceeded-category')
3171 * @param string|int|null $current Current value
3172 * @param string|int|null $max Maximum allowed, when an explicit limit has been
3173 * exceeded, provide the values (optional)
3174 */
3175 public function limitationWarn( $limitationType, $current = '', $max = '' ) {
3176 # does no harm if $current and $max are present but are unnecessary for the message
3177 # Not doing ->inLanguage( $this->mOptions->getUserLangObj() ), since this is shown
3178 # only during preview, and that would split the parser cache unnecessarily.
3179 $warning = wfMessage( "$limitationType-warning" )->numParams( $current, $max )
3180 ->text();
3181 $this->mOutput->addWarning( $warning );
3182 $this->addTrackingCategory( "$limitationType-category" );
3183 }
3184
3185 /**
3186 * Return the text of a template, after recursively
3187 * replacing any variables or templates within the template.
3188 *
3189 * @param array $piece The parts of the template
3190 * $piece['title']: the title, i.e. the part before the |
3191 * $piece['parts']: the parameter array
3192 * $piece['lineStart']: whether the brace was at the start of a line
3193 * @param PPFrame $frame The current frame, contains template arguments
3194 * @throws Exception
3195 * @return string|array The text of the template
3196 */
3197 public function braceSubstitution( $piece, $frame ) {
3198 // Flags
3199
3200 // $text has been filled
3201 $found = false;
3202 // wiki markup in $text should be escaped
3203 $nowiki = false;
3204 // $text is HTML, armour it against wikitext transformation
3205 $isHTML = false;
3206 // Force interwiki transclusion to be done in raw mode not rendered
3207 $forceRawInterwiki = false;
3208 // $text is a DOM node needing expansion in a child frame
3209 $isChildObj = false;
3210 // $text is a DOM node needing expansion in the current frame
3211 $isLocalObj = false;
3212
3213 # Title object, where $text came from
3214 $title = false;
3215
3216 # $part1 is the bit before the first |, and must contain only title characters.
3217 # Various prefixes will be stripped from it later.
3218 $titleWithSpaces = $frame->expand( $piece['title'] );
3219 $part1 = trim( $titleWithSpaces );
3220 $titleText = false;
3221
3222 # Original title text preserved for various purposes
3223 $originalTitle = $part1;
3224
3225 # $args is a list of argument nodes, starting from index 0, not including $part1
3226 # @todo FIXME: If piece['parts'] is null then the call to getLength()
3227 # below won't work b/c this $args isn't an object
3228 $args = ( $piece['parts'] == null ) ? [] : $piece['parts'];
3229
3230 $profileSection = null; // profile templates
3231
3232 # SUBST
3233 if ( !$found ) {
3234 $substMatch = $this->mSubstWords->matchStartAndRemove( $part1 );
3235
3236 # Possibilities for substMatch: "subst", "safesubst" or FALSE
3237 # Decide whether to expand template or keep wikitext as-is.
3238 if ( $this->ot['wiki'] ) {
3239 if ( $substMatch === false ) {
3240 $literal = true; # literal when in PST with no prefix
3241 } else {
3242 $literal = false; # expand when in PST with subst: or safesubst:
3243 }
3244 } else {
3245 if ( $substMatch == 'subst' ) {
3246 $literal = true; # literal when not in PST with plain subst:
3247 } else {
3248 $literal = false; # expand when not in PST with safesubst: or no prefix
3249 }
3250 }
3251 if ( $literal ) {
3252 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3253 $isLocalObj = true;
3254 $found = true;
3255 }
3256 }
3257
3258 # Variables
3259 if ( !$found && $args->getLength() == 0 ) {
3260 $id = $this->mVariables->matchStartToEnd( $part1 );
3261 if ( $id !== false ) {
3262 $text = $this->getVariableValue( $id, $frame );
3263 if ( $this->magicWordFactory->getCacheTTL( $id ) > -1 ) {
3264 $this->mOutput->updateCacheExpiry(
3265 $this->magicWordFactory->getCacheTTL( $id ) );
3266 }
3267 $found = true;
3268 }
3269 }
3270
3271 # MSG, MSGNW and RAW
3272 if ( !$found ) {
3273 # Check for MSGNW:
3274 $mwMsgnw = $this->magicWordFactory->get( 'msgnw' );
3275 if ( $mwMsgnw->matchStartAndRemove( $part1 ) ) {
3276 $nowiki = true;
3277 } else {
3278 # Remove obsolete MSG:
3279 $mwMsg = $this->magicWordFactory->get( 'msg' );
3280 $mwMsg->matchStartAndRemove( $part1 );
3281 }
3282
3283 # Check for RAW:
3284 $mwRaw = $this->magicWordFactory->get( 'raw' );
3285 if ( $mwRaw->matchStartAndRemove( $part1 ) ) {
3286 $forceRawInterwiki = true;
3287 }
3288 }
3289
3290 # Parser functions
3291 if ( !$found ) {
3292 $colonPos = strpos( $part1, ':' );
3293 if ( $colonPos !== false ) {
3294 $func = substr( $part1, 0, $colonPos );
3295 $funcArgs = [ trim( substr( $part1, $colonPos + 1 ) ) ];
3296 $argsLength = $args->getLength();
3297 for ( $i = 0; $i < $argsLength; $i++ ) {
3298 $funcArgs[] = $args->item( $i );
3299 }
3300
3301 $result = $this->callParserFunction( $frame, $func, $funcArgs );
3302
3303 // Extract any forwarded flags
3304 if ( isset( $result['title'] ) ) {
3305 $title = $result['title'];
3306 }
3307 if ( isset( $result['found'] ) ) {
3308 $found = $result['found'];
3309 }
3310 if ( array_key_exists( 'text', $result ) ) {
3311 // a string or null
3312 $text = $result['text'];
3313 }
3314 if ( isset( $result['nowiki'] ) ) {
3315 $nowiki = $result['nowiki'];
3316 }
3317 if ( isset( $result['isHTML'] ) ) {
3318 $isHTML = $result['isHTML'];
3319 }
3320 if ( isset( $result['forceRawInterwiki'] ) ) {
3321 $forceRawInterwiki = $result['forceRawInterwiki'];
3322 }
3323 if ( isset( $result['isChildObj'] ) ) {
3324 $isChildObj = $result['isChildObj'];
3325 }
3326 if ( isset( $result['isLocalObj'] ) ) {
3327 $isLocalObj = $result['isLocalObj'];
3328 }
3329 }
3330 }
3331
3332 # Finish mangling title and then check for loops.
3333 # Set $title to a Title object and $titleText to the PDBK
3334 if ( !$found ) {
3335 $ns = NS_TEMPLATE;
3336 # Split the title into page and subpage
3337 $subpage = '';
3338 $relative = $this->maybeDoSubpageLink( $part1, $subpage );
3339 if ( $part1 !== $relative ) {
3340 $part1 = $relative;
3341 $ns = $this->mTitle->getNamespace();
3342 }
3343 $title = Title::newFromText( $part1, $ns );
3344 if ( $title ) {
3345 $titleText = $title->getPrefixedText();
3346 # Check for language variants if the template is not found
3347 if ( $this->getTargetLanguage()->hasVariants() && $title->getArticleID() == 0 ) {
3348 $this->getTargetLanguage()->findVariantLink( $part1, $title, true );
3349 }
3350 # Do recursion depth check
3351 $limit = $this->mOptions->getMaxTemplateDepth();
3352 if ( $frame->depth >= $limit ) {
3353 $found = true;
3354 $text = '<span class="error">'
3355 . wfMessage( 'parser-template-recursion-depth-warning' )
3356 ->numParams( $limit )->inContentLanguage()->text()
3357 . '</span>';
3358 }
3359 }
3360 }
3361
3362 # Load from database
3363 if ( !$found && $title ) {
3364 $profileSection = $this->mProfiler->scopedProfileIn( $title->getPrefixedDBkey() );
3365 if ( !$title->isExternal() ) {
3366 if ( $title->isSpecialPage()
3367 && $this->mOptions->getAllowSpecialInclusion()
3368 && $this->ot['html']
3369 ) {
3370 $specialPage = $this->specialPageFactory->getPage( $title->getDBkey() );
3371 // Pass the template arguments as URL parameters.
3372 // "uselang" will have no effect since the Language object
3373 // is forced to the one defined in ParserOptions.
3374 $pageArgs = [];
3375 $argsLength = $args->getLength();
3376 for ( $i = 0; $i < $argsLength; $i++ ) {
3377 $bits = $args->item( $i )->splitArg();
3378 if ( strval( $bits['index'] ) === '' ) {
3379 $name = trim( $frame->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
3380 $value = trim( $frame->expand( $bits['value'] ) );
3381 $pageArgs[$name] = $value;
3382 }
3383 }
3384
3385 // Create a new context to execute the special page
3386 $context = new RequestContext;
3387 $context->setTitle( $title );
3388 $context->setRequest( new FauxRequest( $pageArgs ) );
3389 if ( $specialPage && $specialPage->maxIncludeCacheTime() === 0 ) {
3390 $context->setUser( $this->getUser() );
3391 } else {
3392 // If this page is cached, then we better not be per user.
3393 $context->setUser( User::newFromName( '127.0.0.1', false ) );
3394 }
3395 $context->setLanguage( $this->mOptions->getUserLangObj() );
3396 $ret = $this->specialPageFactory->capturePath( $title, $context, $this->getLinkRenderer() );
3397 if ( $ret ) {
3398 $text = $context->getOutput()->getHTML();
3399 $this->mOutput->addOutputPageMetadata( $context->getOutput() );
3400 $found = true;
3401 $isHTML = true;
3402 if ( $specialPage && $specialPage->maxIncludeCacheTime() !== false ) {
3403 $this->mOutput->updateRuntimeAdaptiveExpiry(
3404 $specialPage->maxIncludeCacheTime()
3405 );
3406 }
3407 }
3408 } elseif ( $this->nsInfo->isNonincludable( $title->getNamespace() ) ) {
3409 $found = false; # access denied
3410 wfDebug( __METHOD__ . ": template inclusion denied for " .
3411 $title->getPrefixedDBkey() . "\n" );
3412 } else {
3413 list( $text, $title ) = $this->getTemplateDom( $title );
3414 if ( $text !== false ) {
3415 $found = true;
3416 $isChildObj = true;
3417 }
3418 }
3419
3420 # If the title is valid but undisplayable, make a link to it
3421 if ( !$found && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3422 $text = "[[:$titleText]]";
3423 $found = true;
3424 }
3425 } elseif ( $title->isTrans() ) {
3426 # Interwiki transclusion
3427 if ( $this->ot['html'] && !$forceRawInterwiki ) {
3428 $text = $this->interwikiTransclude( $title, 'render' );
3429 $isHTML = true;
3430 } else {
3431 $text = $this->interwikiTransclude( $title, 'raw' );
3432 # Preprocess it like a template
3433 $text = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
3434 $isChildObj = true;
3435 }
3436 $found = true;
3437 }
3438
3439 # Do infinite loop check
3440 # This has to be done after redirect resolution to avoid infinite loops via redirects
3441 if ( !$frame->loopCheck( $title ) ) {
3442 $found = true;
3443 $text = '<span class="error">'
3444 . wfMessage( 'parser-template-loop-warning', $titleText )->inContentLanguage()->text()
3445 . '</span>';
3446 $this->addTrackingCategory( 'template-loop-category' );
3447 $this->mOutput->addWarning( wfMessage( 'template-loop-warning',
3448 wfEscapeWikiText( $titleText ) )->text() );
3449 wfDebug( __METHOD__ . ": template loop broken at '$titleText'\n" );
3450 }
3451 }
3452
3453 # If we haven't found text to substitute by now, we're done
3454 # Recover the source wikitext and return it
3455 if ( !$found ) {
3456 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3457 if ( $profileSection ) {
3458 $this->mProfiler->scopedProfileOut( $profileSection );
3459 }
3460 return [ 'object' => $text ];
3461 }
3462
3463 # Expand DOM-style return values in a child frame
3464 if ( $isChildObj ) {
3465 # Clean up argument array
3466 $newFrame = $frame->newChild( $args, $title );
3467
3468 if ( $nowiki ) {
3469 $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
3470 } elseif ( $titleText !== false && $newFrame->isEmpty() ) {
3471 # Expansion is eligible for the empty-frame cache
3472 $text = $newFrame->cachedExpand( $titleText, $text );
3473 } else {
3474 # Uncached expansion
3475 $text = $newFrame->expand( $text );
3476 }
3477 }
3478 if ( $isLocalObj && $nowiki ) {
3479 $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
3480 $isLocalObj = false;
3481 }
3482
3483 if ( $profileSection ) {
3484 $this->mProfiler->scopedProfileOut( $profileSection );
3485 }
3486
3487 # Replace raw HTML by a placeholder
3488 if ( $isHTML ) {
3489 $text = $this->insertStripItem( $text );
3490 } elseif ( $nowiki && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3491 # Escape nowiki-style return values
3492 $text = wfEscapeWikiText( $text );
3493 } elseif ( is_string( $text )
3494 && !$piece['lineStart']
3495 && preg_match( '/^(?:{\\||:|;|#|\*)/', $text )
3496 ) {
3497 # T2529: if the template begins with a table or block-level
3498 # element, it should be treated as beginning a new line.
3499 # This behavior is somewhat controversial.
3500 $text = "\n" . $text;
3501 }
3502
3503 if ( is_string( $text ) && !$this->incrementIncludeSize( 'post-expand', strlen( $text ) ) ) {
3504 # Error, oversize inclusion
3505 if ( $titleText !== false ) {
3506 # Make a working, properly escaped link if possible (T25588)
3507 $text = "[[:$titleText]]";
3508 } else {
3509 # This will probably not be a working link, but at least it may
3510 # provide some hint of where the problem is
3511 preg_replace( '/^:/', '', $originalTitle );
3512 $text = "[[:$originalTitle]]";
3513 }
3514 $text .= $this->insertStripItem( '<!-- WARNING: template omitted, '
3515 . 'post-expand include size too large -->' );
3516 $this->limitationWarn( 'post-expand-template-inclusion' );
3517 }
3518
3519 if ( $isLocalObj ) {
3520 $ret = [ 'object' => $text ];
3521 } else {
3522 $ret = [ 'text' => $text ];
3523 }
3524
3525 return $ret;
3526 }
3527
3528 /**
3529 * Call a parser function and return an array with text and flags.
3530 *
3531 * The returned array will always contain a boolean 'found', indicating
3532 * whether the parser function was found or not. It may also contain the
3533 * following:
3534 * text: string|object, resulting wikitext or PP DOM object
3535 * isHTML: bool, $text is HTML, armour it against wikitext transformation
3536 * isChildObj: bool, $text is a DOM node needing expansion in a child frame
3537 * isLocalObj: bool, $text is a DOM node needing expansion in the current frame
3538 * nowiki: bool, wiki markup in $text should be escaped
3539 *
3540 * @since 1.21
3541 * @param PPFrame $frame The current frame, contains template arguments
3542 * @param string $function Function name
3543 * @param array $args Arguments to the function
3544 * @throws MWException
3545 * @return array
3546 */
3547 public function callParserFunction( $frame, $function, array $args = [] ) {
3548 # Case sensitive functions
3549 if ( isset( $this->mFunctionSynonyms[1][$function] ) ) {
3550 $function = $this->mFunctionSynonyms[1][$function];
3551 } else {
3552 # Case insensitive functions
3553 $function = $this->contLang->lc( $function );
3554 if ( isset( $this->mFunctionSynonyms[0][$function] ) ) {
3555 $function = $this->mFunctionSynonyms[0][$function];
3556 } else {
3557 return [ 'found' => false ];
3558 }
3559 }
3560
3561 list( $callback, $flags ) = $this->mFunctionHooks[$function];
3562
3563 // Avoid PHP 7.1 warning from passing $this by reference
3564 $parser = $this;
3565
3566 $allArgs = [ &$parser ];
3567 if ( $flags & self::SFH_OBJECT_ARGS ) {
3568 # Convert arguments to PPNodes and collect for appending to $allArgs
3569 $funcArgs = [];
3570 foreach ( $args as $k => $v ) {
3571 if ( $v instanceof PPNode || $k === 0 ) {
3572 $funcArgs[] = $v;
3573 } else {
3574 $funcArgs[] = $this->mPreprocessor->newPartNodeArray( [ $k => $v ] )->item( 0 );
3575 }
3576 }
3577
3578 # Add a frame parameter, and pass the arguments as an array
3579 $allArgs[] = $frame;
3580 $allArgs[] = $funcArgs;
3581 } else {
3582 # Convert arguments to plain text and append to $allArgs
3583 foreach ( $args as $k => $v ) {
3584 if ( $v instanceof PPNode ) {
3585 $allArgs[] = trim( $frame->expand( $v ) );
3586 } elseif ( is_int( $k ) && $k >= 0 ) {
3587 $allArgs[] = trim( $v );
3588 } else {
3589 $allArgs[] = trim( "$k=$v" );
3590 }
3591 }
3592 }
3593
3594 $result = $callback( ...$allArgs );
3595
3596 # The interface for function hooks allows them to return a wikitext
3597 # string or an array containing the string and any flags. This mungs
3598 # things around to match what this method should return.
3599 if ( !is_array( $result ) ) {
3600 $result = [
3601 'found' => true,
3602 'text' => $result,
3603 ];
3604 } else {
3605 if ( isset( $result[0] ) && !isset( $result['text'] ) ) {
3606 $result['text'] = $result[0];
3607 }
3608 unset( $result[0] );
3609 $result += [
3610 'found' => true,
3611 ];
3612 }
3613
3614 $noparse = true;
3615 $preprocessFlags = 0;
3616 if ( isset( $result['noparse'] ) ) {
3617 $noparse = $result['noparse'];
3618 }
3619 if ( isset( $result['preprocessFlags'] ) ) {
3620 $preprocessFlags = $result['preprocessFlags'];
3621 }
3622
3623 if ( !$noparse ) {
3624 $result['text'] = $this->preprocessToDom( $result['text'], $preprocessFlags );
3625 $result['isChildObj'] = true;
3626 }
3627
3628 return $result;
3629 }
3630
3631 /**
3632 * Get the semi-parsed DOM representation of a template with a given title,
3633 * and its redirect destination title. Cached.
3634 *
3635 * @param Title $title
3636 *
3637 * @return array
3638 */
3639 public function getTemplateDom( $title ) {
3640 $cacheTitle = $title;
3641 $titleText = $title->getPrefixedDBkey();
3642
3643 if ( isset( $this->mTplRedirCache[$titleText] ) ) {
3644 list( $ns, $dbk ) = $this->mTplRedirCache[$titleText];
3645 $title = Title::makeTitle( $ns, $dbk );
3646 $titleText = $title->getPrefixedDBkey();
3647 }
3648 if ( isset( $this->mTplDomCache[$titleText] ) ) {
3649 return [ $this->mTplDomCache[$titleText], $title ];
3650 }
3651
3652 # Cache miss, go to the database
3653 list( $text, $title ) = $this->fetchTemplateAndTitle( $title );
3654
3655 if ( $text === false ) {
3656 $this->mTplDomCache[$titleText] = false;
3657 return [ false, $title ];
3658 }
3659
3660 $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
3661 $this->mTplDomCache[$titleText] = $dom;
3662
3663 if ( !$title->equals( $cacheTitle ) ) {
3664 $this->mTplRedirCache[$cacheTitle->getPrefixedDBkey()] =
3665 [ $title->getNamespace(), $title->getDBkey() ];
3666 }
3667
3668 return [ $dom, $title ];
3669 }
3670
3671 /**
3672 * Fetch the current revision of a given title. Note that the revision
3673 * (and even the title) may not exist in the database, so everything
3674 * contributing to the output of the parser should use this method
3675 * where possible, rather than getting the revisions themselves. This
3676 * method also caches its results, so using it benefits performance.
3677 *
3678 * @since 1.24
3679 * @param Title $title
3680 * @return Revision
3681 */
3682 public function fetchCurrentRevisionOfTitle( $title ) {
3683 $cacheKey = $title->getPrefixedDBkey();
3684 if ( !$this->currentRevisionCache ) {
3685 $this->currentRevisionCache = new MapCacheLRU( 100 );
3686 }
3687 if ( !$this->currentRevisionCache->has( $cacheKey ) ) {
3688 $this->currentRevisionCache->set( $cacheKey,
3689 // Defaults to Parser::statelessFetchRevision()
3690 call_user_func( $this->mOptions->getCurrentRevisionCallback(), $title, $this )
3691 );
3692 }
3693 return $this->currentRevisionCache->get( $cacheKey );
3694 }
3695
3696 /**
3697 * Wrapper around Revision::newFromTitle to allow passing additional parameters
3698 * without passing them on to it.
3699 *
3700 * @since 1.24
3701 * @param Title $title
3702 * @param Parser|bool $parser
3703 * @return Revision|bool False if missing
3704 */
3705 public static function statelessFetchRevision( Title $title, $parser = false ) {
3706 $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title );
3707
3708 return $rev;
3709 }
3710
3711 /**
3712 * Fetch the unparsed text of a template and register a reference to it.
3713 * @param Title $title
3714 * @return array ( string or false, Title )
3715 */
3716 public function fetchTemplateAndTitle( $title ) {
3717 // Defaults to Parser::statelessFetchTemplate()
3718 $templateCb = $this->mOptions->getTemplateCallback();
3719 $stuff = call_user_func( $templateCb, $title, $this );
3720 // We use U+007F DELETE to distinguish strip markers from regular text.
3721 $text = $stuff['text'];
3722 if ( is_string( $stuff['text'] ) ) {
3723 $text = strtr( $text, "\x7f", "?" );
3724 }
3725 $finalTitle = $stuff['finalTitle'] ?? $title;
3726 if ( isset( $stuff['deps'] ) ) {
3727 foreach ( $stuff['deps'] as $dep ) {
3728 $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] );
3729 if ( $dep['title']->equals( $this->getTitle() ) ) {
3730 // If we transclude ourselves, the final result
3731 // will change based on the new version of the page
3732 $this->mOutput->setFlag( 'vary-revision' );
3733 }
3734 }
3735 }
3736 return [ $text, $finalTitle ];
3737 }
3738
3739 /**
3740 * Fetch the unparsed text of a template and register a reference to it.
3741 * @param Title $title
3742 * @return string|bool
3743 */
3744 public function fetchTemplate( $title ) {
3745 return $this->fetchTemplateAndTitle( $title )[0];
3746 }
3747
3748 /**
3749 * Static function to get a template
3750 * Can be overridden via ParserOptions::setTemplateCallback().
3751 *
3752 * @param Title $title
3753 * @param bool|Parser $parser
3754 *
3755 * @return array
3756 */
3757 public static function statelessFetchTemplate( $title, $parser = false ) {
3758 $text = $skip = false;
3759 $finalTitle = $title;
3760 $deps = [];
3761
3762 # Loop to fetch the article, with up to 1 redirect
3763 for ( $i = 0; $i < 2 && is_object( $title ); $i++ ) {
3764 # Give extensions a chance to select the revision instead
3765 $id = false; # Assume current
3766 Hooks::run( 'BeforeParserFetchTemplateAndtitle',
3767 [ $parser, $title, &$skip, &$id ] );
3768
3769 if ( $skip ) {
3770 $text = false;
3771 $deps[] = [
3772 'title' => $title,
3773 'page_id' => $title->getArticleID(),
3774 'rev_id' => null
3775 ];
3776 break;
3777 }
3778 # Get the revision
3779 if ( $id ) {
3780 $rev = Revision::newFromId( $id );
3781 } elseif ( $parser ) {
3782 $rev = $parser->fetchCurrentRevisionOfTitle( $title );
3783 } else {
3784 $rev = Revision::newFromTitle( $title );
3785 }
3786 $rev_id = $rev ? $rev->getId() : 0;
3787 # If there is no current revision, there is no page
3788 if ( $id === false && !$rev ) {
3789 $linkCache = MediaWikiServices::getInstance()->getLinkCache();
3790 $linkCache->addBadLinkObj( $title );
3791 }
3792
3793 $deps[] = [
3794 'title' => $title,
3795 'page_id' => $title->getArticleID(),
3796 'rev_id' => $rev_id ];
3797 if ( $rev && !$title->equals( $rev->getTitle() ) ) {
3798 # We fetched a rev from a different title; register it too...
3799 $deps[] = [
3800 'title' => $rev->getTitle(),
3801 'page_id' => $rev->getPage(),
3802 'rev_id' => $rev_id ];
3803 }
3804
3805 if ( $rev ) {
3806 $content = $rev->getContent();
3807 $text = $content ? $content->getWikitextForTransclusion() : null;
3808
3809 Hooks::run( 'ParserFetchTemplate',
3810 [ $parser, $title, $rev, &$text, &$deps ] );
3811
3812 if ( $text === false || $text === null ) {
3813 $text = false;
3814 break;
3815 }
3816 } elseif ( $title->getNamespace() == NS_MEDIAWIKI ) {
3817 $message = wfMessage( MediaWikiServices::getInstance()->getContentLanguage()->
3818 lcfirst( $title->getText() ) )->inContentLanguage();
3819 if ( !$message->exists() ) {
3820 $text = false;
3821 break;
3822 }
3823 $content = $message->content();
3824 $text = $message->plain();
3825 } else {
3826 break;
3827 }
3828 if ( !$content ) {
3829 break;
3830 }
3831 # Redirect?
3832 $finalTitle = $title;
3833 $title = $content->getRedirectTarget();
3834 }
3835 return [
3836 'text' => $text,
3837 'finalTitle' => $finalTitle,
3838 'deps' => $deps ];
3839 }
3840
3841 /**
3842 * Fetch a file and its title and register a reference to it.
3843 * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
3844 * @param Title $title
3845 * @param array $options Array of options to RepoGroup::findFile
3846 * @return File|bool
3847 * @deprecated since 1.32, use fetchFileAndTitle instead
3848 */
3849 public function fetchFile( $title, $options = [] ) {
3850 wfDeprecated( __METHOD__, '1.32' );
3851 return $this->fetchFileAndTitle( $title, $options )[0];
3852 }
3853
3854 /**
3855 * Fetch a file and its title and register a reference to it.
3856 * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
3857 * @param Title $title
3858 * @param array $options Array of options to RepoGroup::findFile
3859 * @return array ( File or false, Title of file )
3860 */
3861 public function fetchFileAndTitle( $title, $options = [] ) {
3862 $file = $this->fetchFileNoRegister( $title, $options );
3863
3864 $time = $file ? $file->getTimestamp() : false;
3865 $sha1 = $file ? $file->getSha1() : false;
3866 # Register the file as a dependency...
3867 $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
3868 if ( $file && !$title->equals( $file->getTitle() ) ) {
3869 # Update fetched file title
3870 $title = $file->getTitle();
3871 $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
3872 }
3873 return [ $file, $title ];
3874 }
3875
3876 /**
3877 * Helper function for fetchFileAndTitle.
3878 *
3879 * Also useful if you need to fetch a file but not use it yet,
3880 * for example to get the file's handler.
3881 *
3882 * @param Title $title
3883 * @param array $options Array of options to RepoGroup::findFile
3884 * @return File|bool
3885 */
3886 protected function fetchFileNoRegister( $title, $options = [] ) {
3887 if ( isset( $options['broken'] ) ) {
3888 $file = false; // broken thumbnail forced by hook
3889 } elseif ( isset( $options['sha1'] ) ) { // get by (sha1,timestamp)
3890 $file = RepoGroup::singleton()->findFileFromKey( $options['sha1'], $options );
3891 } else { // get by (name,timestamp)
3892 $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title, $options );
3893 }
3894 return $file;
3895 }
3896
3897 /**
3898 * Transclude an interwiki link.
3899 *
3900 * @param Title $title
3901 * @param string $action Usually one of (raw, render)
3902 *
3903 * @return string
3904 */
3905 public function interwikiTransclude( $title, $action ) {
3906 if ( !$this->svcOptions->get( 'EnableScaryTranscluding' ) ) {
3907 return wfMessage( 'scarytranscludedisabled' )->inContentLanguage()->text();
3908 }
3909
3910 $url = $title->getFullURL( [ 'action' => $action ] );
3911 if ( strlen( $url ) > 1024 ) {
3912 return wfMessage( 'scarytranscludetoolong' )->inContentLanguage()->text();
3913 }
3914
3915 $wikiId = $title->getTransWikiID(); // remote wiki ID or false
3916
3917 $fname = __METHOD__;
3918 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
3919
3920 $data = $cache->getWithSetCallback(
3921 $cache->makeGlobalKey(
3922 'interwiki-transclude',
3923 ( $wikiId !== false ) ? $wikiId : 'external',
3924 sha1( $url )
3925 ),
3926 $this->svcOptions->get( 'TranscludeCacheExpiry' ),
3927 function ( $oldValue, &$ttl ) use ( $url, $fname, $cache ) {
3928 $req = MWHttpRequest::factory( $url, [], $fname );
3929
3930 $status = $req->execute(); // Status object
3931 if ( !$status->isOK() ) {
3932 $ttl = $cache::TTL_UNCACHEABLE;
3933 } elseif ( $req->getResponseHeader( 'X-Database-Lagged' ) !== null ) {
3934 $ttl = min( $cache::TTL_LAGGED, $ttl );
3935 }
3936
3937 return [
3938 'text' => $status->isOK() ? $req->getContent() : null,
3939 'code' => $req->getStatus()
3940 ];
3941 },
3942 [
3943 'checkKeys' => ( $wikiId !== false )
3944 ? [ $cache->makeGlobalKey( 'interwiki-page', $wikiId, $title->getDBkey() ) ]
3945 : [],
3946 'pcGroup' => 'interwiki-transclude:5',
3947 'pcTTL' => $cache::TTL_PROC_LONG
3948 ]
3949 );
3950
3951 if ( is_string( $data['text'] ) ) {
3952 $text = $data['text'];
3953 } elseif ( $data['code'] != 200 ) {
3954 // Though we failed to fetch the content, this status is useless.
3955 $text = wfMessage( 'scarytranscludefailed-httpstatus' )
3956 ->params( $url, $data['code'] )->inContentLanguage()->text();
3957 } else {
3958 $text = wfMessage( 'scarytranscludefailed', $url )->inContentLanguage()->text();
3959 }
3960
3961 return $text;
3962 }
3963
3964 /**
3965 * Triple brace replacement -- used for template arguments
3966 * @private
3967 *
3968 * @param array $piece
3969 * @param PPFrame $frame
3970 *
3971 * @return array
3972 */
3973 public function argSubstitution( $piece, $frame ) {
3974 $error = false;
3975 $parts = $piece['parts'];
3976 $nameWithSpaces = $frame->expand( $piece['title'] );
3977 $argName = trim( $nameWithSpaces );
3978 $object = false;
3979 $text = $frame->getArgument( $argName );
3980 if ( $text === false && $parts->getLength() > 0
3981 && ( $this->ot['html']
3982 || $this->ot['pre']
3983 || ( $this->ot['wiki'] && $frame->isTemplate() )
3984 )
3985 ) {
3986 # No match in frame, use the supplied default
3987 $object = $parts->item( 0 )->getChildren();
3988 }
3989 if ( !$this->incrementIncludeSize( 'arg', strlen( $text ) ) ) {
3990 $error = '<!-- WARNING: argument omitted, expansion size too large -->';
3991 $this->limitationWarn( 'post-expand-template-argument' );
3992 }
3993
3994 if ( $text === false && $object === false ) {
3995 # No match anywhere
3996 $object = $frame->virtualBracketedImplode( '{{{', '|', '}}}', $nameWithSpaces, $parts );
3997 }
3998 if ( $error !== false ) {
3999 $text .= $error;
4000 }
4001 if ( $object !== false ) {
4002 $ret = [ 'object' => $object ];
4003 } else {
4004 $ret = [ 'text' => $text ];
4005 }
4006
4007 return $ret;
4008 }
4009
4010 /**
4011 * Return the text to be used for a given extension tag.
4012 * This is the ghost of strip().
4013 *
4014 * @param array $params Associative array of parameters:
4015 * name PPNode for the tag name
4016 * attr PPNode for unparsed text where tag attributes are thought to be
4017 * attributes Optional associative array of parsed attributes
4018 * inner Contents of extension element
4019 * noClose Original text did not have a close tag
4020 * @param PPFrame $frame
4021 *
4022 * @throws MWException
4023 * @return string
4024 */
4025 public function extensionSubstitution( $params, $frame ) {
4026 static $errorStr = '<span class="error">';
4027 static $errorLen = 20;
4028
4029 $name = $frame->expand( $params['name'] );
4030 if ( substr( $name, 0, $errorLen ) === $errorStr ) {
4031 // Probably expansion depth or node count exceeded. Just punt the
4032 // error up.
4033 return $name;
4034 }
4035
4036 $attrText = !isset( $params['attr'] ) ? null : $frame->expand( $params['attr'] );
4037 if ( substr( $attrText, 0, $errorLen ) === $errorStr ) {
4038 // See above
4039 return $attrText;
4040 }
4041
4042 // We can't safely check if the expansion for $content resulted in an
4043 // error, because the content could happen to be the error string
4044 // (T149622).
4045 $content = !isset( $params['inner'] ) ? null : $frame->expand( $params['inner'] );
4046
4047 $marker = self::MARKER_PREFIX . "-$name-"
4048 . sprintf( '%08X', $this->mMarkerIndex++ ) . self::MARKER_SUFFIX;
4049
4050 $isFunctionTag = isset( $this->mFunctionTagHooks[strtolower( $name )] ) &&
4051 ( $this->ot['html'] || $this->ot['pre'] );
4052 if ( $isFunctionTag ) {
4053 $markerType = 'none';
4054 } else {
4055 $markerType = 'general';
4056 }
4057 if ( $this->ot['html'] || $isFunctionTag ) {
4058 $name = strtolower( $name );
4059 $attributes = Sanitizer::decodeTagAttributes( $attrText );
4060 if ( isset( $params['attributes'] ) ) {
4061 $attributes += $params['attributes'];
4062 }
4063
4064 if ( isset( $this->mTagHooks[$name] ) ) {
4065 $output = call_user_func_array( $this->mTagHooks[$name],
4066 [ $content, $attributes, $this, $frame ] );
4067 } elseif ( isset( $this->mFunctionTagHooks[$name] ) ) {
4068 list( $callback, ) = $this->mFunctionTagHooks[$name];
4069
4070 // Avoid PHP 7.1 warning from passing $this by reference
4071 $parser = $this;
4072 $output = call_user_func_array( $callback, [ &$parser, $frame, $content, $attributes ] );
4073 } else {
4074 $output = '<span class="error">Invalid tag extension name: ' .
4075 htmlspecialchars( $name ) . '</span>';
4076 }
4077
4078 if ( is_array( $output ) ) {
4079 // Extract flags
4080 $flags = $output;
4081 $output = $flags[0];
4082 if ( isset( $flags['markerType'] ) ) {
4083 $markerType = $flags['markerType'];
4084 }
4085 }
4086 } else {
4087 if ( is_null( $attrText ) ) {
4088 $attrText = '';
4089 }
4090 if ( isset( $params['attributes'] ) ) {
4091 foreach ( $params['attributes'] as $attrName => $attrValue ) {
4092 $attrText .= ' ' . htmlspecialchars( $attrName ) . '="' .
4093 htmlspecialchars( $attrValue ) . '"';
4094 }
4095 }
4096 if ( $content === null ) {
4097 $output = "<$name$attrText/>";
4098 } else {
4099 $close = is_null( $params['close'] ) ? '' : $frame->expand( $params['close'] );
4100 if ( substr( $close, 0, $errorLen ) === $errorStr ) {
4101 // See above
4102 return $close;
4103 }
4104 $output = "<$name$attrText>$content$close";
4105 }
4106 }
4107
4108 if ( $markerType === 'none' ) {
4109 return $output;
4110 } elseif ( $markerType === 'nowiki' ) {
4111 $this->mStripState->addNoWiki( $marker, $output );
4112 } elseif ( $markerType === 'general' ) {
4113 $this->mStripState->addGeneral( $marker, $output );
4114 } else {
4115 throw new MWException( __METHOD__ . ': invalid marker type' );
4116 }
4117 return $marker;
4118 }
4119
4120 /**
4121 * Increment an include size counter
4122 *
4123 * @param string $type The type of expansion
4124 * @param int $size The size of the text
4125 * @return bool False if this inclusion would take it over the maximum, true otherwise
4126 */
4127 public function incrementIncludeSize( $type, $size ) {
4128 if ( $this->mIncludeSizes[$type] + $size > $this->mOptions->getMaxIncludeSize() ) {
4129 return false;
4130 } else {
4131 $this->mIncludeSizes[$type] += $size;
4132 return true;
4133 }
4134 }
4135
4136 /**
4137 * Increment the expensive function count
4138 *
4139 * @return bool False if the limit has been exceeded
4140 */
4141 public function incrementExpensiveFunctionCount() {
4142 $this->mExpensiveFunctionCount++;
4143 return $this->mExpensiveFunctionCount <= $this->mOptions->getExpensiveParserFunctionLimit();
4144 }
4145
4146 /**
4147 * Strip double-underscore items like __NOGALLERY__ and __NOTOC__
4148 * Fills $this->mDoubleUnderscores, returns the modified text
4149 *
4150 * @param string $text
4151 *
4152 * @return string
4153 */
4154 public function doDoubleUnderscore( $text ) {
4155 # The position of __TOC__ needs to be recorded
4156 $mw = $this->magicWordFactory->get( 'toc' );
4157 if ( $mw->match( $text ) ) {
4158 $this->mShowToc = true;
4159 $this->mForceTocPosition = true;
4160
4161 # Set a placeholder. At the end we'll fill it in with the TOC.
4162 $text = $mw->replace( '<!--MWTOC\'"-->', $text, 1 );
4163
4164 # Only keep the first one.
4165 $text = $mw->replace( '', $text );
4166 }
4167
4168 # Now match and remove the rest of them
4169 $mwa = $this->magicWordFactory->getDoubleUnderscoreArray();
4170 $this->mDoubleUnderscores = $mwa->matchAndRemove( $text );
4171
4172 if ( isset( $this->mDoubleUnderscores['nogallery'] ) ) {
4173 $this->mOutput->mNoGallery = true;
4174 }
4175 if ( isset( $this->mDoubleUnderscores['notoc'] ) && !$this->mForceTocPosition ) {
4176 $this->mShowToc = false;
4177 }
4178 if ( isset( $this->mDoubleUnderscores['hiddencat'] )
4179 && $this->mTitle->getNamespace() == NS_CATEGORY
4180 ) {
4181 $this->addTrackingCategory( 'hidden-category-category' );
4182 }
4183 # (T10068) Allow control over whether robots index a page.
4184 # __INDEX__ always overrides __NOINDEX__, see T16899
4185 if ( isset( $this->mDoubleUnderscores['noindex'] ) && $this->mTitle->canUseNoindex() ) {
4186 $this->mOutput->setIndexPolicy( 'noindex' );
4187 $this->addTrackingCategory( 'noindex-category' );
4188 }
4189 if ( isset( $this->mDoubleUnderscores['index'] ) && $this->mTitle->canUseNoindex() ) {
4190 $this->mOutput->setIndexPolicy( 'index' );
4191 $this->addTrackingCategory( 'index-category' );
4192 }
4193
4194 # Cache all double underscores in the database
4195 foreach ( $this->mDoubleUnderscores as $key => $val ) {
4196 $this->mOutput->setProperty( $key, '' );
4197 }
4198
4199 return $text;
4200 }
4201
4202 /**
4203 * @see ParserOutput::addTrackingCategory()
4204 * @param string $msg Message key
4205 * @return bool Whether the addition was successful
4206 */
4207 public function addTrackingCategory( $msg ) {
4208 return $this->mOutput->addTrackingCategory( $msg, $this->mTitle );
4209 }
4210
4211 /**
4212 * This function accomplishes several tasks:
4213 * 1) Auto-number headings if that option is enabled
4214 * 2) Add an [edit] link to sections for users who have enabled the option and can edit the page
4215 * 3) Add a Table of contents on the top for users who have enabled the option
4216 * 4) Auto-anchor headings
4217 *
4218 * It loops through all headlines, collects the necessary data, then splits up the
4219 * string and re-inserts the newly formatted headlines.
4220 *
4221 * @param string $text
4222 * @param string $origText Original, untouched wikitext
4223 * @param bool $isMain
4224 * @return mixed|string
4225 * @private
4226 */
4227 public function formatHeadings( $text, $origText, $isMain = true ) {
4228 # Inhibit editsection links if requested in the page
4229 if ( isset( $this->mDoubleUnderscores['noeditsection'] ) ) {
4230 $maybeShowEditLink = false;
4231 } else {
4232 $maybeShowEditLink = true; /* Actual presence will depend on post-cache transforms */
4233 }
4234
4235 # Get all headlines for numbering them and adding funky stuff like [edit]
4236 # links - this is for later, but we need the number of headlines right now
4237 # NOTE: white space in headings have been trimmed in doHeadings. They shouldn't
4238 # be trimmed here since whitespace in HTML headings is significant.
4239 $matches = [];
4240 $numMatches = preg_match_all(
4241 '/<H(?P<level>[1-6])(?P<attrib>.*?>)(?P<header>[\s\S]*?)<\/H[1-6] *>/i',
4242 $text,
4243 $matches
4244 );
4245
4246 # if there are fewer than 4 headlines in the article, do not show TOC
4247 # unless it's been explicitly enabled.
4248 $enoughToc = $this->mShowToc &&
4249 ( ( $numMatches >= 4 ) || $this->mForceTocPosition );
4250
4251 # Allow user to stipulate that a page should have a "new section"
4252 # link added via __NEWSECTIONLINK__
4253 if ( isset( $this->mDoubleUnderscores['newsectionlink'] ) ) {
4254 $this->mOutput->setNewSection( true );
4255 }
4256
4257 # Allow user to remove the "new section"
4258 # link via __NONEWSECTIONLINK__
4259 if ( isset( $this->mDoubleUnderscores['nonewsectionlink'] ) ) {
4260 $this->mOutput->hideNewSection( true );
4261 }
4262
4263 # if the string __FORCETOC__ (not case-sensitive) occurs in the HTML,
4264 # override above conditions and always show TOC above first header
4265 if ( isset( $this->mDoubleUnderscores['forcetoc'] ) ) {
4266 $this->mShowToc = true;
4267 $enoughToc = true;
4268 }
4269
4270 # headline counter
4271 $headlineCount = 0;
4272 $numVisible = 0;
4273
4274 # Ugh .. the TOC should have neat indentation levels which can be
4275 # passed to the skin functions. These are determined here
4276 $toc = '';
4277 $full = '';
4278 $head = [];
4279 $sublevelCount = [];
4280 $levelCount = [];
4281 $level = 0;
4282 $prevlevel = 0;
4283 $toclevel = 0;
4284 $prevtoclevel = 0;
4285 $markerRegex = self::MARKER_PREFIX . "-h-(\d+)-" . self::MARKER_SUFFIX;
4286 $baseTitleText = $this->mTitle->getPrefixedDBkey();
4287 $oldType = $this->mOutputType;
4288 $this->setOutputType( self::OT_WIKI );
4289 $frame = $this->getPreprocessor()->newFrame();
4290 $root = $this->preprocessToDom( $origText );
4291 $node = $root->getFirstChild();
4292 $byteOffset = 0;
4293 $tocraw = [];
4294 $refers = [];
4295
4296 $headlines = $numMatches !== false ? $matches[3] : [];
4297
4298 $maxTocLevel = $this->svcOptions->get( 'MaxTocLevel' );
4299 foreach ( $headlines as $headline ) {
4300 $isTemplate = false;
4301 $titleText = false;
4302 $sectionIndex = false;
4303 $numbering = '';
4304 $markerMatches = [];
4305 if ( preg_match( "/^$markerRegex/", $headline, $markerMatches ) ) {
4306 $serial = $markerMatches[1];
4307 list( $titleText, $sectionIndex ) = $this->mHeadings[$serial];
4308 $isTemplate = ( $titleText != $baseTitleText );
4309 $headline = preg_replace( "/^$markerRegex\\s*/", "", $headline );
4310 }
4311
4312 if ( $toclevel ) {
4313 $prevlevel = $level;
4314 }
4315 $level = $matches[1][$headlineCount];
4316
4317 if ( $level > $prevlevel ) {
4318 # Increase TOC level
4319 $toclevel++;
4320 $sublevelCount[$toclevel] = 0;
4321 if ( $toclevel < $maxTocLevel ) {
4322 $prevtoclevel = $toclevel;
4323 $toc .= Linker::tocIndent();
4324 $numVisible++;
4325 }
4326 } elseif ( $level < $prevlevel && $toclevel > 1 ) {
4327 # Decrease TOC level, find level to jump to
4328
4329 for ( $i = $toclevel; $i > 0; $i-- ) {
4330 if ( $levelCount[$i] == $level ) {
4331 # Found last matching level
4332 $toclevel = $i;
4333 break;
4334 } elseif ( $levelCount[$i] < $level ) {
4335 # Found first matching level below current level
4336 $toclevel = $i + 1;
4337 break;
4338 }
4339 }
4340 if ( $i == 0 ) {
4341 $toclevel = 1;
4342 }
4343 if ( $toclevel < $maxTocLevel ) {
4344 if ( $prevtoclevel < $maxTocLevel ) {
4345 # Unindent only if the previous toc level was shown :p
4346 $toc .= Linker::tocUnindent( $prevtoclevel - $toclevel );
4347 $prevtoclevel = $toclevel;
4348 } else {
4349 $toc .= Linker::tocLineEnd();
4350 }
4351 }
4352 } else {
4353 # No change in level, end TOC line
4354 if ( $toclevel < $maxTocLevel ) {
4355 $toc .= Linker::tocLineEnd();
4356 }
4357 }
4358
4359 $levelCount[$toclevel] = $level;
4360
4361 # count number of headlines for each level
4362 $sublevelCount[$toclevel]++;
4363 $dot = 0;
4364 for ( $i = 1; $i <= $toclevel; $i++ ) {
4365 if ( !empty( $sublevelCount[$i] ) ) {
4366 if ( $dot ) {
4367 $numbering .= '.';
4368 }
4369 $numbering .= $this->getTargetLanguage()->formatNum( $sublevelCount[$i] );
4370 $dot = 1;
4371 }
4372 }
4373
4374 # The safe header is a version of the header text safe to use for links
4375
4376 # Remove link placeholders by the link text.
4377 # <!--LINK number-->
4378 # turns into
4379 # link text with suffix
4380 # Do this before unstrip since link text can contain strip markers
4381 $safeHeadline = $this->replaceLinkHoldersText( $headline );
4382
4383 # Avoid insertion of weird stuff like <math> by expanding the relevant sections
4384 $safeHeadline = $this->mStripState->unstripBoth( $safeHeadline );
4385
4386 # Remove any <style> or <script> tags (T198618)
4387 $safeHeadline = preg_replace(
4388 '#<(style|script)(?: [^>]*[^>/])?>.*?</\1>#is',
4389 '',
4390 $safeHeadline
4391 );
4392
4393 # Strip out HTML (first regex removes any tag not allowed)
4394 # Allowed tags are:
4395 # * <sup> and <sub> (T10393)
4396 # * <i> (T28375)
4397 # * <b> (r105284)
4398 # * <bdi> (T74884)
4399 # * <span dir="rtl"> and <span dir="ltr"> (T37167)
4400 # * <s> and <strike> (T35715)
4401 # We strip any parameter from accepted tags (second regex), except dir="rtl|ltr" from <span>,
4402 # to allow setting directionality in toc items.
4403 $tocline = preg_replace(
4404 [
4405 '#<(?!/?(span|sup|sub|bdi|i|b|s|strike)(?: [^>]*)?>).*?>#',
4406 '#<(/?(?:span(?: dir="(?:rtl|ltr)")?|sup|sub|bdi|i|b|s|strike))(?: .*?)?>#'
4407 ],
4408 [ '', '<$1>' ],
4409 $safeHeadline
4410 );
4411
4412 # Strip '<span></span>', which is the result from the above if
4413 # <span id="foo"></span> is used to produce an additional anchor
4414 # for a section.
4415 $tocline = str_replace( '<span></span>', '', $tocline );
4416
4417 $tocline = trim( $tocline );
4418
4419 # For the anchor, strip out HTML-y stuff period
4420 $safeHeadline = preg_replace( '/<.*?>/', '', $safeHeadline );
4421 $safeHeadline = Sanitizer::normalizeSectionNameWhitespace( $safeHeadline );
4422
4423 # Save headline for section edit hint before it's escaped
4424 $headlineHint = $safeHeadline;
4425
4426 # Decode HTML entities
4427 $safeHeadline = Sanitizer::decodeCharReferences( $safeHeadline );
4428
4429 $safeHeadline = self::normalizeSectionName( $safeHeadline );
4430
4431 $fallbackHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_FALLBACK );
4432 $linkAnchor = Sanitizer::escapeIdForLink( $safeHeadline );
4433 $safeHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_PRIMARY );
4434 if ( $fallbackHeadline === $safeHeadline ) {
4435 # No reason to have both (in fact, we can't)
4436 $fallbackHeadline = false;
4437 }
4438
4439 # HTML IDs must be case-insensitively unique for IE compatibility (T12721).
4440 # @todo FIXME: We may be changing them depending on the current locale.
4441 $arrayKey = strtolower( $safeHeadline );
4442 if ( $fallbackHeadline === false ) {
4443 $fallbackArrayKey = false;
4444 } else {
4445 $fallbackArrayKey = strtolower( $fallbackHeadline );
4446 }
4447
4448 # Create the anchor for linking from the TOC to the section
4449 $anchor = $safeHeadline;
4450 $fallbackAnchor = $fallbackHeadline;
4451 if ( isset( $refers[$arrayKey] ) ) {
4452 // phpcs:ignore Generic.Formatting.DisallowMultipleStatements
4453 for ( $i = 2; isset( $refers["${arrayKey}_$i"] ); ++$i );
4454 $anchor .= "_$i";
4455 $linkAnchor .= "_$i";
4456 $refers["${arrayKey}_$i"] = true;
4457 } else {
4458 $refers[$arrayKey] = true;
4459 }
4460 if ( $fallbackHeadline !== false && isset( $refers[$fallbackArrayKey] ) ) {
4461 // phpcs:ignore Generic.Formatting.DisallowMultipleStatements
4462 for ( $i = 2; isset( $refers["${fallbackArrayKey}_$i"] ); ++$i );
4463 $fallbackAnchor .= "_$i";
4464 $refers["${fallbackArrayKey}_$i"] = true;
4465 } else {
4466 $refers[$fallbackArrayKey] = true;
4467 }
4468
4469 # Don't number the heading if it is the only one (looks silly)
4470 if ( count( $matches[3] ) > 1 && $this->mOptions->getNumberHeadings() ) {
4471 # the two are different if the line contains a link
4472 $headline = Html::element(
4473 'span',
4474 [ 'class' => 'mw-headline-number' ],
4475 $numbering
4476 ) . ' ' . $headline;
4477 }
4478
4479 if ( $enoughToc && ( !isset( $maxTocLevel ) || $toclevel < $maxTocLevel ) ) {
4480 $toc .= Linker::tocLine( $linkAnchor, $tocline,
4481 $numbering, $toclevel, ( $isTemplate ? false : $sectionIndex ) );
4482 }
4483
4484 # Add the section to the section tree
4485 # Find the DOM node for this header
4486 $noOffset = ( $isTemplate || $sectionIndex === false );
4487 while ( $node && !$noOffset ) {
4488 if ( $node->getName() === 'h' ) {
4489 $bits = $node->splitHeading();
4490 if ( $bits['i'] == $sectionIndex ) {
4491 break;
4492 }
4493 }
4494 $byteOffset += mb_strlen( $this->mStripState->unstripBoth(
4495 $frame->expand( $node, PPFrame::RECOVER_ORIG ) ) );
4496 $node = $node->getNextSibling();
4497 }
4498 $tocraw[] = [
4499 'toclevel' => $toclevel,
4500 'level' => $level,
4501 'line' => $tocline,
4502 'number' => $numbering,
4503 'index' => ( $isTemplate ? 'T-' : '' ) . $sectionIndex,
4504 'fromtitle' => $titleText,
4505 'byteoffset' => ( $noOffset ? null : $byteOffset ),
4506 'anchor' => $anchor,
4507 ];
4508
4509 # give headline the correct <h#> tag
4510 if ( $maybeShowEditLink && $sectionIndex !== false ) {
4511 // Output edit section links as markers with styles that can be customized by skins
4512 if ( $isTemplate ) {
4513 # Put a T flag in the section identifier, to indicate to extractSections()
4514 # that sections inside <includeonly> should be counted.
4515 $editsectionPage = $titleText;
4516 $editsectionSection = "T-$sectionIndex";
4517 $editsectionContent = null;
4518 } else {
4519 $editsectionPage = $this->mTitle->getPrefixedText();
4520 $editsectionSection = $sectionIndex;
4521 $editsectionContent = $headlineHint;
4522 }
4523 // We use a bit of pesudo-xml for editsection markers. The
4524 // language converter is run later on. Using a UNIQ style marker
4525 // leads to the converter screwing up the tokens when it
4526 // converts stuff. And trying to insert strip tags fails too. At
4527 // this point all real inputted tags have already been escaped,
4528 // so we don't have to worry about a user trying to input one of
4529 // these markers directly. We use a page and section attribute
4530 // to stop the language converter from converting these
4531 // important bits of data, but put the headline hint inside a
4532 // content block because the language converter is supposed to
4533 // be able to convert that piece of data.
4534 // Gets replaced with html in ParserOutput::getText
4535 $editlink = '<mw:editsection page="' . htmlspecialchars( $editsectionPage );
4536 $editlink .= '" section="' . htmlspecialchars( $editsectionSection ) . '"';
4537 if ( $editsectionContent !== null ) {
4538 $editlink .= '>' . $editsectionContent . '</mw:editsection>';
4539 } else {
4540 $editlink .= '/>';
4541 }
4542 } else {
4543 $editlink = '';
4544 }
4545 $head[$headlineCount] = Linker::makeHeadline( $level,
4546 $matches['attrib'][$headlineCount], $anchor, $headline,
4547 $editlink, $fallbackAnchor );
4548
4549 $headlineCount++;
4550 }
4551
4552 $this->setOutputType( $oldType );
4553
4554 # Never ever show TOC if no headers
4555 if ( $numVisible < 1 ) {
4556 $enoughToc = false;
4557 }
4558
4559 if ( $enoughToc ) {
4560 if ( $prevtoclevel > 0 && $prevtoclevel < $maxTocLevel ) {
4561 $toc .= Linker::tocUnindent( $prevtoclevel - 1 );
4562 }
4563 $toc = Linker::tocList( $toc, $this->mOptions->getUserLangObj() );
4564 $this->mOutput->setTOCHTML( $toc );
4565 $toc = self::TOC_START . $toc . self::TOC_END;
4566 }
4567
4568 if ( $isMain ) {
4569 $this->mOutput->setSections( $tocraw );
4570 }
4571
4572 # split up and insert constructed headlines
4573 $blocks = preg_split( '/<H[1-6].*?>[\s\S]*?<\/H[1-6]>/i', $text );
4574 $i = 0;
4575
4576 // build an array of document sections
4577 $sections = [];
4578 foreach ( $blocks as $block ) {
4579 // $head is zero-based, sections aren't.
4580 if ( empty( $head[$i - 1] ) ) {
4581 $sections[$i] = $block;
4582 } else {
4583 $sections[$i] = $head[$i - 1] . $block;
4584 }
4585
4586 /**
4587 * Send a hook, one per section.
4588 * The idea here is to be able to make section-level DIVs, but to do so in a
4589 * lower-impact, more correct way than r50769
4590 *
4591 * $this : caller
4592 * $section : the section number
4593 * &$sectionContent : ref to the content of the section
4594 * $maybeShowEditLinks : boolean describing whether this section has an edit link
4595 */
4596 Hooks::run( 'ParserSectionCreate', [ $this, $i, &$sections[$i], $maybeShowEditLink ] );
4597
4598 $i++;
4599 }
4600
4601 if ( $enoughToc && $isMain && !$this->mForceTocPosition ) {
4602 // append the TOC at the beginning
4603 // Top anchor now in skin
4604 $sections[0] .= $toc . "\n";
4605 }
4606
4607 $full .= implode( '', $sections );
4608
4609 if ( $this->mForceTocPosition ) {
4610 return str_replace( '<!--MWTOC\'"-->', $toc, $full );
4611 } else {
4612 return $full;
4613 }
4614 }
4615
4616 /**
4617 * Transform wiki markup when saving a page by doing "\r\n" -> "\n"
4618 * conversion, substituting signatures, {{subst:}} templates, etc.
4619 *
4620 * @param string $text The text to transform
4621 * @param Title $title The Title object for the current article
4622 * @param User $user The User object describing the current user
4623 * @param ParserOptions $options Parsing options
4624 * @param bool $clearState Whether to clear the parser state first
4625 * @return string The altered wiki markup
4626 */
4627 public function preSaveTransform( $text, Title $title, User $user,
4628 ParserOptions $options, $clearState = true
4629 ) {
4630 if ( $clearState ) {
4631 $magicScopeVariable = $this->lock();
4632 }
4633 $this->startParse( $title, $options, self::OT_WIKI, $clearState );
4634 $this->setUser( $user );
4635
4636 // Strip U+0000 NULL (T159174)
4637 $text = str_replace( "\000", '', $text );
4638
4639 // We still normalize line endings for backwards-compatibility
4640 // with other code that just calls PST, but this should already
4641 // be handled in TextContent subclasses
4642 $text = TextContent::normalizeLineEndings( $text );
4643
4644 if ( $options->getPreSaveTransform() ) {
4645 $text = $this->pstPass2( $text, $user );
4646 }
4647 $text = $this->mStripState->unstripBoth( $text );
4648
4649 $this->setUser( null ); # Reset
4650
4651 return $text;
4652 }
4653
4654 /**
4655 * Pre-save transform helper function
4656 *
4657 * @param string $text
4658 * @param User $user
4659 *
4660 * @return string
4661 */
4662 private function pstPass2( $text, $user ) {
4663 # Note: This is the timestamp saved as hardcoded wikitext to the database, we use
4664 # $this->contLang here in order to give everyone the same signature and use the default one
4665 # rather than the one selected in each user's preferences. (see also T14815)
4666 $ts = $this->mOptions->getTimestamp();
4667 $timestamp = MWTimestamp::getLocalInstance( $ts );
4668 $ts = $timestamp->format( 'YmdHis' );
4669 $tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text();
4670
4671 $d = $this->contLang->timeanddate( $ts, false, false ) . " ($tzMsg)";
4672
4673 # Variable replacement
4674 # Because mOutputType is OT_WIKI, this will only process {{subst:xxx}} type tags
4675 $text = $this->replaceVariables( $text );
4676
4677 # This works almost by chance, as the replaceVariables are done before the getUserSig(),
4678 # which may corrupt this parser instance via its wfMessage()->text() call-
4679
4680 # Signatures
4681 if ( strpos( $text, '~~~' ) !== false ) {
4682 $sigText = $this->getUserSig( $user );
4683 $text = strtr( $text, [
4684 '~~~~~' => $d,
4685 '~~~~' => "$sigText $d",
4686 '~~~' => $sigText
4687 ] );
4688 # The main two signature forms used above are time-sensitive
4689 $this->mOutput->setFlag( 'user-signature' );
4690 }
4691
4692 # Context links ("pipe tricks"): [[|name]] and [[name (context)|]]
4693 $tc = '[' . Title::legalChars() . ']';
4694 $nc = '[ _0-9A-Za-z\x80-\xff-]'; # Namespaces can use non-ascii!
4695
4696 // [[ns:page (context)|]]
4697 $p1 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\))\\|]]/";
4698 // [[ns:page(context)|]] (double-width brackets, added in r40257)
4699 $p4 = "/\[\[(:?$nc+:|:|)($tc+?)( ?($tc+))\\|]]/";
4700 // [[ns:page (context), context|]] (using either single or double-width comma)
4701 $p3 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\)|)((?:, |,)$tc+|)\\|]]/";
4702 // [[|page]] (reverse pipe trick: add context from page title)
4703 $p2 = "/\[\[\\|($tc+)]]/";
4704
4705 # try $p1 first, to turn "[[A, B (C)|]]" into "[[A, B (C)|A, B]]"
4706 $text = preg_replace( $p1, '[[\\1\\2\\3|\\2]]', $text );
4707 $text = preg_replace( $p4, '[[\\1\\2\\3|\\2]]', $text );
4708 $text = preg_replace( $p3, '[[\\1\\2\\3\\4|\\2]]', $text );
4709
4710 $t = $this->mTitle->getText();
4711 $m = [];
4712 if ( preg_match( "/^($nc+:|)$tc+?( \\($tc+\\))$/", $t, $m ) ) {
4713 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4714 } elseif ( preg_match( "/^($nc+:|)$tc+?(, $tc+|)$/", $t, $m ) && "$m[1]$m[2]" != '' ) {
4715 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4716 } else {
4717 # if there's no context, don't bother duplicating the title
4718 $text = preg_replace( $p2, '[[\\1]]', $text );
4719 }
4720
4721 return $text;
4722 }
4723
4724 /**
4725 * Fetch the user's signature text, if any, and normalize to
4726 * validated, ready-to-insert wikitext.
4727 * If you have pre-fetched the nickname or the fancySig option, you can
4728 * specify them here to save a database query.
4729 * Do not reuse this parser instance after calling getUserSig(),
4730 * as it may have changed.
4731 *
4732 * @param User &$user
4733 * @param string|bool $nickname Nickname to use or false to use user's default nickname
4734 * @param bool|null $fancySig whether the nicknname is the complete signature
4735 * or null to use default value
4736 * @return string
4737 */
4738 public function getUserSig( &$user, $nickname = false, $fancySig = null ) {
4739 $username = $user->getName();
4740
4741 # If not given, retrieve from the user object.
4742 if ( $nickname === false ) {
4743 $nickname = $user->getOption( 'nickname' );
4744 }
4745
4746 if ( is_null( $fancySig ) ) {
4747 $fancySig = $user->getBoolOption( 'fancysig' );
4748 }
4749
4750 $nickname = $nickname == null ? $username : $nickname;
4751
4752 if ( mb_strlen( $nickname ) > $this->svcOptions->get( 'MaxSigChars' ) ) {
4753 $nickname = $username;
4754 wfDebug( __METHOD__ . ": $username has overlong signature.\n" );
4755 } elseif ( $fancySig !== false ) {
4756 # Sig. might contain markup; validate this
4757 if ( $this->validateSig( $nickname ) !== false ) {
4758 # Validated; clean up (if needed) and return it
4759 return $this->cleanSig( $nickname, true );
4760 } else {
4761 # Failed to validate; fall back to the default
4762 $nickname = $username;
4763 wfDebug( __METHOD__ . ": $username has bad XML tags in signature.\n" );
4764 }
4765 }
4766
4767 # Make sure nickname doesnt get a sig in a sig
4768 $nickname = self::cleanSigInSig( $nickname );
4769
4770 # If we're still here, make it a link to the user page
4771 $userText = wfEscapeWikiText( $username );
4772 $nickText = wfEscapeWikiText( $nickname );
4773 $msgName = $user->isAnon() ? 'signature-anon' : 'signature';
4774
4775 return wfMessage( $msgName, $userText, $nickText )->inContentLanguage()
4776 ->title( $this->getTitle() )->text();
4777 }
4778
4779 /**
4780 * Check that the user's signature contains no bad XML
4781 *
4782 * @param string $text
4783 * @return string|bool An expanded string, or false if invalid.
4784 */
4785 public function validateSig( $text ) {
4786 return Xml::isWellFormedXmlFragment( $text ) ? $text : false;
4787 }
4788
4789 /**
4790 * Clean up signature text
4791 *
4792 * 1) Strip 3, 4 or 5 tildes out of signatures @see cleanSigInSig
4793 * 2) Substitute all transclusions
4794 *
4795 * @param string $text
4796 * @param bool $parsing Whether we're cleaning (preferences save) or parsing
4797 * @return string Signature text
4798 */
4799 public function cleanSig( $text, $parsing = false ) {
4800 if ( !$parsing ) {
4801 global $wgTitle;
4802 $magicScopeVariable = $this->lock();
4803 $this->startParse( $wgTitle, new ParserOptions, self::OT_PREPROCESS, true );
4804 }
4805
4806 # Option to disable this feature
4807 if ( !$this->mOptions->getCleanSignatures() ) {
4808 return $text;
4809 }
4810
4811 # @todo FIXME: Regex doesn't respect extension tags or nowiki
4812 # => Move this logic to braceSubstitution()
4813 $substWord = $this->magicWordFactory->get( 'subst' );
4814 $substRegex = '/\{\{(?!(?:' . $substWord->getBaseRegex() . '))/x' . $substWord->getRegexCase();
4815 $substText = '{{' . $substWord->getSynonym( 0 );
4816
4817 $text = preg_replace( $substRegex, $substText, $text );
4818 $text = self::cleanSigInSig( $text );
4819 $dom = $this->preprocessToDom( $text );
4820 $frame = $this->getPreprocessor()->newFrame();
4821 $text = $frame->expand( $dom );
4822
4823 if ( !$parsing ) {
4824 $text = $this->mStripState->unstripBoth( $text );
4825 }
4826
4827 return $text;
4828 }
4829
4830 /**
4831 * Strip 3, 4 or 5 tildes out of signatures.
4832 *
4833 * @param string $text
4834 * @return string Signature text with /~{3,5}/ removed
4835 */
4836 public static function cleanSigInSig( $text ) {
4837 $text = preg_replace( '/~{3,5}/', '', $text );
4838 return $text;
4839 }
4840
4841 /**
4842 * Set up some variables which are usually set up in parse()
4843 * so that an external function can call some class members with confidence
4844 *
4845 * @param Title|null $title
4846 * @param ParserOptions $options
4847 * @param int $outputType
4848 * @param bool $clearState
4849 */
4850 public function startExternalParse( Title $title = null, ParserOptions $options,
4851 $outputType, $clearState = true
4852 ) {
4853 $this->startParse( $title, $options, $outputType, $clearState );
4854 }
4855
4856 /**
4857 * @param Title|null $title
4858 * @param ParserOptions $options
4859 * @param int $outputType
4860 * @param bool $clearState
4861 */
4862 private function startParse( Title $title = null, ParserOptions $options,
4863 $outputType, $clearState = true
4864 ) {
4865 $this->setTitle( $title );
4866 $this->mOptions = $options;
4867 $this->setOutputType( $outputType );
4868 if ( $clearState ) {
4869 $this->clearState();
4870 }
4871 }
4872
4873 /**
4874 * Wrapper for preprocess()
4875 *
4876 * @param string $text The text to preprocess
4877 * @param ParserOptions $options
4878 * @param Title|null $title Title object or null to use $wgTitle
4879 * @return string
4880 */
4881 public function transformMsg( $text, $options, $title = null ) {
4882 static $executing = false;
4883
4884 # Guard against infinite recursion
4885 if ( $executing ) {
4886 return $text;
4887 }
4888 $executing = true;
4889
4890 if ( !$title ) {
4891 global $wgTitle;
4892 $title = $wgTitle;
4893 }
4894
4895 $text = $this->preprocess( $text, $title, $options );
4896
4897 $executing = false;
4898 return $text;
4899 }
4900
4901 /**
4902 * Create an HTML-style tag, e.g. "<yourtag>special text</yourtag>"
4903 * The callback should have the following form:
4904 * function myParserHook( $text, $params, $parser, $frame ) { ... }
4905 *
4906 * Transform and return $text. Use $parser for any required context, e.g. use
4907 * $parser->getTitle() and $parser->getOptions() not $wgTitle or $wgOut->mParserOptions
4908 *
4909 * Hooks may return extended information by returning an array, of which the
4910 * first numbered element (index 0) must be the return string, and all other
4911 * entries are extracted into local variables within an internal function
4912 * in the Parser class.
4913 *
4914 * This interface (introduced r61913) appears to be undocumented, but
4915 * 'markerType' is used by some core tag hooks to override which strip
4916 * array their results are placed in. **Use great caution if attempting
4917 * this interface, as it is not documented and injudicious use could smash
4918 * private variables.**
4919 *
4920 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4921 * @param callable $callback The callback function (and object) to use for the tag
4922 * @throws MWException
4923 * @return callable|null The old value of the mTagHooks array associated with the hook
4924 */
4925 public function setHook( $tag, callable $callback ) {
4926 $tag = strtolower( $tag );
4927 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4928 throw new MWException( "Invalid character {$m[0]} in setHook('$tag', ...) call" );
4929 }
4930 $oldVal = $this->mTagHooks[$tag] ?? null;
4931 $this->mTagHooks[$tag] = $callback;
4932 if ( !in_array( $tag, $this->mStripList ) ) {
4933 $this->mStripList[] = $tag;
4934 }
4935
4936 return $oldVal;
4937 }
4938
4939 /**
4940 * As setHook(), but letting the contents be parsed.
4941 *
4942 * Transparent tag hooks are like regular XML-style tag hooks, except they
4943 * operate late in the transformation sequence, on HTML instead of wikitext.
4944 *
4945 * This is probably obsoleted by things dealing with parser frames?
4946 * The only extension currently using it is geoserver.
4947 *
4948 * @since 1.10
4949 * @todo better document or deprecate this
4950 *
4951 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4952 * @param callable $callback The callback function (and object) to use for the tag
4953 * @throws MWException
4954 * @return callable|null The old value of the mTagHooks array associated with the hook
4955 */
4956 public function setTransparentTagHook( $tag, callable $callback ) {
4957 $tag = strtolower( $tag );
4958 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4959 throw new MWException( "Invalid character {$m[0]} in setTransparentHook('$tag', ...) call" );
4960 }
4961 $oldVal = $this->mTransparentTagHooks[$tag] ?? null;
4962 $this->mTransparentTagHooks[$tag] = $callback;
4963
4964 return $oldVal;
4965 }
4966
4967 /**
4968 * Remove all tag hooks
4969 */
4970 public function clearTagHooks() {
4971 $this->mTagHooks = [];
4972 $this->mFunctionTagHooks = [];
4973 $this->mStripList = $this->mDefaultStripList;
4974 }
4975
4976 /**
4977 * Create a function, e.g. {{sum:1|2|3}}
4978 * The callback function should have the form:
4979 * function myParserFunction( &$parser, $arg1, $arg2, $arg3 ) { ... }
4980 *
4981 * Or with Parser::SFH_OBJECT_ARGS:
4982 * function myParserFunction( $parser, $frame, $args ) { ... }
4983 *
4984 * The callback may either return the text result of the function, or an array with the text
4985 * in element 0, and a number of flags in the other elements. The names of the flags are
4986 * specified in the keys. Valid flags are:
4987 * found The text returned is valid, stop processing the template. This
4988 * is on by default.
4989 * nowiki Wiki markup in the return value should be escaped
4990 * isHTML The returned text is HTML, armour it against wikitext transformation
4991 *
4992 * @param string $id The magic word ID
4993 * @param callable $callback The callback function (and object) to use
4994 * @param int $flags A combination of the following flags:
4995 * Parser::SFH_NO_HASH No leading hash, i.e. {{plural:...}} instead of {{#if:...}}
4996 *
4997 * Parser::SFH_OBJECT_ARGS Pass the template arguments as PPNode objects instead of text.
4998 * This allows for conditional expansion of the parse tree, allowing you to eliminate dead
4999 * branches and thus speed up parsing. It is also possible to analyse the parse tree of
5000 * the arguments, and to control the way they are expanded.
5001 *
5002 * The $frame parameter is a PPFrame. This can be used to produce expanded text from the
5003 * arguments, for instance:
5004 * $text = isset( $args[0] ) ? $frame->expand( $args[0] ) : '';
5005 *
5006 * For technical reasons, $args[0] is pre-expanded and will be a string. This may change in
5007 * future versions. Please call $frame->expand() on it anyway so that your code keeps
5008 * working if/when this is changed.
5009 *
5010 * If you want whitespace to be trimmed from $args, you need to do it yourself, post-
5011 * expansion.
5012 *
5013 * Please read the documentation in includes/parser/Preprocessor.php for more information
5014 * about the methods available in PPFrame and PPNode.
5015 *
5016 * @throws MWException
5017 * @return string|callable The old callback function for this name, if any
5018 */
5019 public function setFunctionHook( $id, callable $callback, $flags = 0 ) {
5020 $oldVal = isset( $this->mFunctionHooks[$id] ) ? $this->mFunctionHooks[$id][0] : null;
5021 $this->mFunctionHooks[$id] = [ $callback, $flags ];
5022
5023 # Add to function cache
5024 $mw = $this->magicWordFactory->get( $id );
5025 if ( !$mw ) {
5026 throw new MWException( __METHOD__ . '() expecting a magic word identifier.' );
5027 }
5028
5029 $synonyms = $mw->getSynonyms();
5030 $sensitive = intval( $mw->isCaseSensitive() );
5031
5032 foreach ( $synonyms as $syn ) {
5033 # Case
5034 if ( !$sensitive ) {
5035 $syn = $this->contLang->lc( $syn );
5036 }
5037 # Add leading hash
5038 if ( !( $flags & self::SFH_NO_HASH ) ) {
5039 $syn = '#' . $syn;
5040 }
5041 # Remove trailing colon
5042 if ( substr( $syn, -1, 1 ) === ':' ) {
5043 $syn = substr( $syn, 0, -1 );
5044 }
5045 $this->mFunctionSynonyms[$sensitive][$syn] = $id;
5046 }
5047 return $oldVal;
5048 }
5049
5050 /**
5051 * Get all registered function hook identifiers
5052 *
5053 * @return array
5054 */
5055 public function getFunctionHooks() {
5056 $this->firstCallInit();
5057 return array_keys( $this->mFunctionHooks );
5058 }
5059
5060 /**
5061 * Create a tag function, e.g. "<test>some stuff</test>".
5062 * Unlike tag hooks, tag functions are parsed at preprocessor level.
5063 * Unlike parser functions, their content is not preprocessed.
5064 * @param string $tag
5065 * @param callable $callback
5066 * @param int $flags
5067 * @throws MWException
5068 * @return null
5069 */
5070 public function setFunctionTagHook( $tag, callable $callback, $flags ) {
5071 $tag = strtolower( $tag );
5072 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
5073 throw new MWException( "Invalid character {$m[0]} in setFunctionTagHook('$tag', ...) call" );
5074 }
5075 $old = $this->mFunctionTagHooks[$tag] ?? null;
5076 $this->mFunctionTagHooks[$tag] = [ $callback, $flags ];
5077
5078 if ( !in_array( $tag, $this->mStripList ) ) {
5079 $this->mStripList[] = $tag;
5080 }
5081
5082 return $old;
5083 }
5084
5085 /**
5086 * Replace "<!--LINK-->" link placeholders with actual links, in the buffer
5087 * Placeholders created in Linker::link()
5088 *
5089 * @param string &$text
5090 * @param int $options
5091 */
5092 public function replaceLinkHolders( &$text, $options = 0 ) {
5093 $this->mLinkHolders->replace( $text );
5094 }
5095
5096 /**
5097 * Replace "<!--LINK-->" link placeholders with plain text of links
5098 * (not HTML-formatted).
5099 *
5100 * @param string $text
5101 * @return string
5102 */
5103 public function replaceLinkHoldersText( $text ) {
5104 return $this->mLinkHolders->replaceText( $text );
5105 }
5106
5107 /**
5108 * Renders an image gallery from a text with one line per image.
5109 * text labels may be given by using |-style alternative text. E.g.
5110 * Image:one.jpg|The number "1"
5111 * Image:tree.jpg|A tree
5112 * given as text will return the HTML of a gallery with two images,
5113 * labeled 'The number "1"' and
5114 * 'A tree'.
5115 *
5116 * @param string $text
5117 * @param array $params
5118 * @return string HTML
5119 */
5120 public function renderImageGallery( $text, $params ) {
5121 $mode = false;
5122 if ( isset( $params['mode'] ) ) {
5123 $mode = $params['mode'];
5124 }
5125
5126 try {
5127 $ig = ImageGalleryBase::factory( $mode );
5128 } catch ( Exception $e ) {
5129 // If invalid type set, fallback to default.
5130 $ig = ImageGalleryBase::factory( false );
5131 }
5132
5133 $ig->setContextTitle( $this->mTitle );
5134 $ig->setShowBytes( false );
5135 $ig->setShowDimensions( false );
5136 $ig->setShowFilename( false );
5137 $ig->setParser( $this );
5138 $ig->setHideBadImages();
5139 $ig->setAttributes( Sanitizer::validateTagAttributes( $params, 'ul' ) );
5140
5141 if ( isset( $params['showfilename'] ) ) {
5142 $ig->setShowFilename( true );
5143 } else {
5144 $ig->setShowFilename( false );
5145 }
5146 if ( isset( $params['caption'] ) ) {
5147 // NOTE: We aren't passing a frame here or below. Frame info
5148 // is currently opaque to Parsoid, which acts on OT_PREPROCESS.
5149 // See T107332#4030581
5150 $caption = $this->recursiveTagParse( $params['caption'] );
5151 $ig->setCaptionHtml( $caption );
5152 }
5153 if ( isset( $params['perrow'] ) ) {
5154 $ig->setPerRow( $params['perrow'] );
5155 }
5156 if ( isset( $params['widths'] ) ) {
5157 $ig->setWidths( $params['widths'] );
5158 }
5159 if ( isset( $params['heights'] ) ) {
5160 $ig->setHeights( $params['heights'] );
5161 }
5162 $ig->setAdditionalOptions( $params );
5163
5164 // Avoid PHP 7.1 warning from passing $this by reference
5165 $parser = $this;
5166 Hooks::run( 'BeforeParserrenderImageGallery', [ &$parser, &$ig ] );
5167
5168 $lines = StringUtils::explode( "\n", $text );
5169 foreach ( $lines as $line ) {
5170 # match lines like these:
5171 # Image:someimage.jpg|This is some image
5172 $matches = [];
5173 preg_match( "/^([^|]+)(\\|(.*))?$/", $line, $matches );
5174 # Skip empty lines
5175 if ( count( $matches ) == 0 ) {
5176 continue;
5177 }
5178
5179 if ( strpos( $matches[0], '%' ) !== false ) {
5180 $matches[1] = rawurldecode( $matches[1] );
5181 }
5182 $title = Title::newFromText( $matches[1], NS_FILE );
5183 if ( is_null( $title ) ) {
5184 # Bogus title. Ignore these so we don't bomb out later.
5185 continue;
5186 }
5187
5188 # We need to get what handler the file uses, to figure out parameters.
5189 # Note, a hook can overide the file name, and chose an entirely different
5190 # file (which potentially could be of a different type and have different handler).
5191 $options = [];
5192 $descQuery = false;
5193 Hooks::run( 'BeforeParserFetchFileAndTitle',
5194 [ $this, $title, &$options, &$descQuery ] );
5195 # Don't register it now, as TraditionalImageGallery does that later.
5196 $file = $this->fetchFileNoRegister( $title, $options );
5197 $handler = $file ? $file->getHandler() : false;
5198
5199 $paramMap = [
5200 'img_alt' => 'gallery-internal-alt',
5201 'img_link' => 'gallery-internal-link',
5202 ];
5203 if ( $handler ) {
5204 $paramMap += $handler->getParamMap();
5205 // We don't want people to specify per-image widths.
5206 // Additionally the width parameter would need special casing anyhow.
5207 unset( $paramMap['img_width'] );
5208 }
5209
5210 $mwArray = $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5211
5212 $label = '';
5213 $alt = '';
5214 $link = '';
5215 $handlerOptions = [];
5216 if ( isset( $matches[3] ) ) {
5217 // look for an |alt= definition while trying not to break existing
5218 // captions with multiple pipes (|) in it, until a more sensible grammar
5219 // is defined for images in galleries
5220
5221 // FIXME: Doing recursiveTagParse at this stage, and the trim before
5222 // splitting on '|' is a bit odd, and different from makeImage.
5223 $matches[3] = $this->recursiveTagParse( trim( $matches[3] ) );
5224 // Protect LanguageConverter markup
5225 $parameterMatches = StringUtils::delimiterExplode(
5226 '-{', '}-', '|', $matches[3], true /* nested */
5227 );
5228
5229 foreach ( $parameterMatches as $parameterMatch ) {
5230 list( $magicName, $match ) = $mwArray->matchVariableStartToEnd( $parameterMatch );
5231 if ( $magicName ) {
5232 $paramName = $paramMap[$magicName];
5233
5234 switch ( $paramName ) {
5235 case 'gallery-internal-alt':
5236 $alt = $this->stripAltText( $match, false );
5237 break;
5238 case 'gallery-internal-link':
5239 $linkValue = $this->stripAltText( $match, false );
5240 if ( preg_match( '/^-{R|(.*)}-$/', $linkValue ) ) {
5241 // Result of LanguageConverter::markNoConversion
5242 // invoked on an external link.
5243 $linkValue = substr( $linkValue, 4, -2 );
5244 }
5245 list( $type, $target ) = $this->parseLinkParameter( $linkValue );
5246 if ( $type === 'link-url' ) {
5247 $link = $target;
5248 $this->mOutput->addExternalLink( $target );
5249 } elseif ( $type === 'link-title' ) {
5250 $link = $target->getLinkURL();
5251 $this->mOutput->addLink( $target );
5252 }
5253 break;
5254 default:
5255 // Must be a handler specific parameter.
5256 if ( $handler->validateParam( $paramName, $match ) ) {
5257 $handlerOptions[$paramName] = $match;
5258 } else {
5259 // Guess not, consider it as caption.
5260 wfDebug( "$parameterMatch failed parameter validation\n" );
5261 $label = $parameterMatch;
5262 }
5263 }
5264
5265 } else {
5266 // Last pipe wins.
5267 $label = $parameterMatch;
5268 }
5269 }
5270 }
5271
5272 $ig->add( $title, $label, $alt, $link, $handlerOptions );
5273 }
5274 $html = $ig->toHTML();
5275 Hooks::run( 'AfterParserFetchFileAndTitle', [ $this, $ig, &$html ] );
5276 return $html;
5277 }
5278
5279 /**
5280 * @param MediaHandler $handler
5281 * @return array
5282 */
5283 public function getImageParams( $handler ) {
5284 if ( $handler ) {
5285 $handlerClass = get_class( $handler );
5286 } else {
5287 $handlerClass = '';
5288 }
5289 if ( !isset( $this->mImageParams[$handlerClass] ) ) {
5290 # Initialise static lists
5291 static $internalParamNames = [
5292 'horizAlign' => [ 'left', 'right', 'center', 'none' ],
5293 'vertAlign' => [ 'baseline', 'sub', 'super', 'top', 'text-top', 'middle',
5294 'bottom', 'text-bottom' ],
5295 'frame' => [ 'thumbnail', 'manualthumb', 'framed', 'frameless',
5296 'upright', 'border', 'link', 'alt', 'class' ],
5297 ];
5298 static $internalParamMap;
5299 if ( !$internalParamMap ) {
5300 $internalParamMap = [];
5301 foreach ( $internalParamNames as $type => $names ) {
5302 foreach ( $names as $name ) {
5303 // For grep: img_left, img_right, img_center, img_none,
5304 // img_baseline, img_sub, img_super, img_top, img_text_top, img_middle,
5305 // img_bottom, img_text_bottom,
5306 // img_thumbnail, img_manualthumb, img_framed, img_frameless, img_upright,
5307 // img_border, img_link, img_alt, img_class
5308 $magicName = str_replace( '-', '_', "img_$name" );
5309 $internalParamMap[$magicName] = [ $type, $name ];
5310 }
5311 }
5312 }
5313
5314 # Add handler params
5315 $paramMap = $internalParamMap;
5316 if ( $handler ) {
5317 $handlerParamMap = $handler->getParamMap();
5318 foreach ( $handlerParamMap as $magic => $paramName ) {
5319 $paramMap[$magic] = [ 'handler', $paramName ];
5320 }
5321 }
5322 $this->mImageParams[$handlerClass] = $paramMap;
5323 $this->mImageParamsMagicArray[$handlerClass] =
5324 $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5325 }
5326 return [ $this->mImageParams[$handlerClass], $this->mImageParamsMagicArray[$handlerClass] ];
5327 }
5328
5329 /**
5330 * Parse image options text and use it to make an image
5331 *
5332 * @param Title $title
5333 * @param string $options
5334 * @param LinkHolderArray|bool $holders
5335 * @return string HTML
5336 */
5337 public function makeImage( $title, $options, $holders = false ) {
5338 # Check if the options text is of the form "options|alt text"
5339 # Options are:
5340 # * thumbnail make a thumbnail with enlarge-icon and caption, alignment depends on lang
5341 # * left no resizing, just left align. label is used for alt= only
5342 # * right same, but right aligned
5343 # * none same, but not aligned
5344 # * ___px scale to ___ pixels width, no aligning. e.g. use in taxobox
5345 # * center center the image
5346 # * frame Keep original image size, no magnify-button.
5347 # * framed Same as "frame"
5348 # * frameless like 'thumb' but without a frame. Keeps user preferences for width
5349 # * upright reduce width for upright images, rounded to full __0 px
5350 # * border draw a 1px border around the image
5351 # * alt Text for HTML alt attribute (defaults to empty)
5352 # * class Set a class for img node
5353 # * link Set the target of the image link. Can be external, interwiki, or local
5354 # vertical-align values (no % or length right now):
5355 # * baseline
5356 # * sub
5357 # * super
5358 # * top
5359 # * text-top
5360 # * middle
5361 # * bottom
5362 # * text-bottom
5363
5364 # Protect LanguageConverter markup when splitting into parts
5365 $parts = StringUtils::delimiterExplode(
5366 '-{', '}-', '|', $options, true /* allow nesting */
5367 );
5368
5369 # Give extensions a chance to select the file revision for us
5370 $options = [];
5371 $descQuery = false;
5372 Hooks::run( 'BeforeParserFetchFileAndTitle',
5373 [ $this, $title, &$options, &$descQuery ] );
5374 # Fetch and register the file (file title may be different via hooks)
5375 list( $file, $title ) = $this->fetchFileAndTitle( $title, $options );
5376
5377 # Get parameter map
5378 $handler = $file ? $file->getHandler() : false;
5379
5380 list( $paramMap, $mwArray ) = $this->getImageParams( $handler );
5381
5382 if ( !$file ) {
5383 $this->addTrackingCategory( 'broken-file-category' );
5384 }
5385
5386 # Process the input parameters
5387 $caption = '';
5388 $params = [ 'frame' => [], 'handler' => [],
5389 'horizAlign' => [], 'vertAlign' => [] ];
5390 $seenformat = false;
5391 foreach ( $parts as $part ) {
5392 $part = trim( $part );
5393 list( $magicName, $value ) = $mwArray->matchVariableStartToEnd( $part );
5394 $validated = false;
5395 if ( isset( $paramMap[$magicName] ) ) {
5396 list( $type, $paramName ) = $paramMap[$magicName];
5397
5398 # Special case; width and height come in one variable together
5399 if ( $type === 'handler' && $paramName === 'width' ) {
5400 $parsedWidthParam = self::parseWidthParam( $value );
5401 if ( isset( $parsedWidthParam['width'] ) ) {
5402 $width = $parsedWidthParam['width'];
5403 if ( $handler->validateParam( 'width', $width ) ) {
5404 $params[$type]['width'] = $width;
5405 $validated = true;
5406 }
5407 }
5408 if ( isset( $parsedWidthParam['height'] ) ) {
5409 $height = $parsedWidthParam['height'];
5410 if ( $handler->validateParam( 'height', $height ) ) {
5411 $params[$type]['height'] = $height;
5412 $validated = true;
5413 }
5414 }
5415 # else no validation -- T15436
5416 } else {
5417 if ( $type === 'handler' ) {
5418 # Validate handler parameter
5419 $validated = $handler->validateParam( $paramName, $value );
5420 } else {
5421 # Validate internal parameters
5422 switch ( $paramName ) {
5423 case 'manualthumb':
5424 case 'alt':
5425 case 'class':
5426 # @todo FIXME: Possibly check validity here for
5427 # manualthumb? downstream behavior seems odd with
5428 # missing manual thumbs.
5429 $validated = true;
5430 $value = $this->stripAltText( $value, $holders );
5431 break;
5432 case 'link':
5433 list( $paramName, $value ) =
5434 $this->parseLinkParameter(
5435 $this->stripAltText( $value, $holders )
5436 );
5437 if ( $paramName ) {
5438 $validated = true;
5439 if ( $paramName === 'no-link' ) {
5440 $value = true;
5441 }
5442 if ( ( $paramName === 'link-url' ) && $this->mOptions->getExternalLinkTarget() ) {
5443 $params[$type]['link-target'] = $this->mOptions->getExternalLinkTarget();
5444 }
5445 }
5446 break;
5447 case 'frameless':
5448 case 'framed':
5449 case 'thumbnail':
5450 // use first appearing option, discard others.
5451 $validated = !$seenformat;
5452 $seenformat = true;
5453 break;
5454 default:
5455 # Most other things appear to be empty or numeric...
5456 $validated = ( $value === false || is_numeric( trim( $value ) ) );
5457 }
5458 }
5459
5460 if ( $validated ) {
5461 $params[$type][$paramName] = $value;
5462 }
5463 }
5464 }
5465 if ( !$validated ) {
5466 $caption = $part;
5467 }
5468 }
5469
5470 # Process alignment parameters
5471 if ( $params['horizAlign'] ) {
5472 $params['frame']['align'] = key( $params['horizAlign'] );
5473 }
5474 if ( $params['vertAlign'] ) {
5475 $params['frame']['valign'] = key( $params['vertAlign'] );
5476 }
5477
5478 $params['frame']['caption'] = $caption;
5479
5480 # Will the image be presented in a frame, with the caption below?
5481 $imageIsFramed = isset( $params['frame']['frame'] )
5482 || isset( $params['frame']['framed'] )
5483 || isset( $params['frame']['thumbnail'] )
5484 || isset( $params['frame']['manualthumb'] );
5485
5486 # In the old days, [[Image:Foo|text...]] would set alt text. Later it
5487 # came to also set the caption, ordinary text after the image -- which
5488 # makes no sense, because that just repeats the text multiple times in
5489 # screen readers. It *also* came to set the title attribute.
5490 # Now that we have an alt attribute, we should not set the alt text to
5491 # equal the caption: that's worse than useless, it just repeats the
5492 # text. This is the framed/thumbnail case. If there's no caption, we
5493 # use the unnamed parameter for alt text as well, just for the time be-
5494 # ing, if the unnamed param is set and the alt param is not.
5495 # For the future, we need to figure out if we want to tweak this more,
5496 # e.g., introducing a title= parameter for the title; ignoring the un-
5497 # named parameter entirely for images without a caption; adding an ex-
5498 # plicit caption= parameter and preserving the old magic unnamed para-
5499 # meter for BC; ...
5500 if ( $imageIsFramed ) { # Framed image
5501 if ( $caption === '' && !isset( $params['frame']['alt'] ) ) {
5502 # No caption or alt text, add the filename as the alt text so
5503 # that screen readers at least get some description of the image
5504 $params['frame']['alt'] = $title->getText();
5505 }
5506 # Do not set $params['frame']['title'] because tooltips don't make sense
5507 # for framed images
5508 } else { # Inline image
5509 if ( !isset( $params['frame']['alt'] ) ) {
5510 # No alt text, use the "caption" for the alt text
5511 if ( $caption !== '' ) {
5512 $params['frame']['alt'] = $this->stripAltText( $caption, $holders );
5513 } else {
5514 # No caption, fall back to using the filename for the
5515 # alt text
5516 $params['frame']['alt'] = $title->getText();
5517 }
5518 }
5519 # Use the "caption" for the tooltip text
5520 $params['frame']['title'] = $this->stripAltText( $caption, $holders );
5521 }
5522 $params['handler']['targetlang'] = $this->getTargetLanguage()->getCode();
5523
5524 Hooks::run( 'ParserMakeImageParams', [ $title, $file, &$params, $this ] );
5525
5526 # Linker does the rest
5527 $time = $options['time'] ?? false;
5528 $ret = Linker::makeImageLink( $this, $title, $file, $params['frame'], $params['handler'],
5529 $time, $descQuery, $this->mOptions->getThumbSize() );
5530
5531 # Give the handler a chance to modify the parser object
5532 if ( $handler ) {
5533 $handler->parserTransformHook( $this, $file );
5534 }
5535
5536 return $ret;
5537 }
5538
5539 /**
5540 * Parse the value of 'link' parameter in image syntax (`[[File:Foo.jpg|link=<value>]]`).
5541 *
5542 * Adds an entry to appropriate link tables.
5543 *
5544 * @since 1.32
5545 * @param string $value
5546 * @return array of `[ type, target ]`, where:
5547 * - `type` is one of:
5548 * - `null`: Given value is not a valid link target, use default
5549 * - `'no-link'`: Given value is empty, do not generate a link
5550 * - `'link-url'`: Given value is a valid external link
5551 * - `'link-title'`: Given value is a valid internal link
5552 * - `target` is:
5553 * - When `type` is `null` or `'no-link'`: `false`
5554 * - When `type` is `'link-url'`: URL string corresponding to given value
5555 * - When `type` is `'link-title'`: Title object corresponding to given value
5556 */
5557 public function parseLinkParameter( $value ) {
5558 $chars = self::EXT_LINK_URL_CLASS;
5559 $addr = self::EXT_LINK_ADDR;
5560 $prots = $this->mUrlProtocols;
5561 $type = null;
5562 $target = false;
5563 if ( $value === '' ) {
5564 $type = 'no-link';
5565 } elseif ( preg_match( "/^((?i)$prots)/", $value ) ) {
5566 if ( preg_match( "/^((?i)$prots)$addr$chars*$/u", $value, $m ) ) {
5567 $this->mOutput->addExternalLink( $value );
5568 $type = 'link-url';
5569 $target = $value;
5570 }
5571 } else {
5572 $linkTitle = Title::newFromText( $value );
5573 if ( $linkTitle ) {
5574 $this->mOutput->addLink( $linkTitle );
5575 $type = 'link-title';
5576 $target = $linkTitle;
5577 }
5578 }
5579 return [ $type, $target ];
5580 }
5581
5582 /**
5583 * @param string $caption
5584 * @param LinkHolderArray|bool $holders
5585 * @return mixed|string
5586 */
5587 protected function stripAltText( $caption, $holders ) {
5588 # Strip bad stuff out of the title (tooltip). We can't just use
5589 # replaceLinkHoldersText() here, because if this function is called
5590 # from replaceInternalLinks2(), mLinkHolders won't be up-to-date.
5591 if ( $holders ) {
5592 $tooltip = $holders->replaceText( $caption );
5593 } else {
5594 $tooltip = $this->replaceLinkHoldersText( $caption );
5595 }
5596
5597 # make sure there are no placeholders in thumbnail attributes
5598 # that are later expanded to html- so expand them now and
5599 # remove the tags
5600 $tooltip = $this->mStripState->unstripBoth( $tooltip );
5601 # Compatibility hack! In HTML certain entity references not terminated
5602 # by a semicolon are decoded (but not if we're in an attribute; that's
5603 # how link URLs get away without properly escaping & in queries).
5604 # But wikitext has always required semicolon-termination of entities,
5605 # so encode & where needed to avoid decode of semicolon-less entities.
5606 # See T209236 and
5607 # https://www.w3.org/TR/html5/syntax.html#named-character-references
5608 # T210437 discusses moving this workaround to Sanitizer::stripAllTags.
5609 $tooltip = preg_replace( "/
5610 & # 1. entity prefix
5611 (?= # 2. followed by:
5612 (?: # a. one of the legacy semicolon-less named entities
5613 A(?:Elig|MP|acute|circ|grave|ring|tilde|uml)|
5614 C(?:OPY|cedil)|E(?:TH|acute|circ|grave|uml)|
5615 GT|I(?:acute|circ|grave|uml)|LT|Ntilde|
5616 O(?:acute|circ|grave|slash|tilde|uml)|QUOT|REG|THORN|
5617 U(?:acute|circ|grave|uml)|Yacute|
5618 a(?:acute|c(?:irc|ute)|elig|grave|mp|ring|tilde|uml)|brvbar|
5619 c(?:cedil|edil|urren)|cent(?!erdot;)|copy(?!sr;)|deg|
5620 divide(?!ontimes;)|e(?:acute|circ|grave|th|uml)|
5621 frac(?:1(?:2|4)|34)|
5622 gt(?!c(?:c|ir)|dot|lPar|quest|r(?:a(?:pprox|rr)|dot|eq(?:less|qless)|less|sim);)|
5623 i(?:acute|circ|excl|grave|quest|uml)|laquo|
5624 lt(?!c(?:c|ir)|dot|hree|imes|larr|quest|r(?:Par|i(?:e|f|));)|
5625 m(?:acr|i(?:cro|ddot))|n(?:bsp|tilde)|
5626 not(?!in(?:E|dot|v(?:a|b|c)|)|ni(?:v(?:a|b|c)|);)|
5627 o(?:acute|circ|grave|rd(?:f|m)|slash|tilde|uml)|
5628 p(?:lusmn|ound)|para(?!llel;)|quot|r(?:aquo|eg)|
5629 s(?:ect|hy|up(?:1|2|3)|zlig)|thorn|times(?!b(?:ar|)|d;)|
5630 u(?:acute|circ|grave|ml|uml)|y(?:acute|en|uml)
5631 )
5632 (?:[^;]|$)) # b. and not followed by a semicolon
5633 # S = study, for efficiency
5634 /Sx", '&amp;', $tooltip );
5635 $tooltip = Sanitizer::stripAllTags( $tooltip );
5636
5637 return $tooltip;
5638 }
5639
5640 /**
5641 * Set a flag in the output object indicating that the content is dynamic and
5642 * shouldn't be cached.
5643 * @deprecated since 1.28; use getOutput()->updateCacheExpiry()
5644 */
5645 public function disableCache() {
5646 wfDebug( "Parser output marked as uncacheable.\n" );
5647 if ( !$this->mOutput ) {
5648 throw new MWException( __METHOD__ .
5649 " can only be called when actually parsing something" );
5650 }
5651 $this->mOutput->updateCacheExpiry( 0 ); // new style, for consistency
5652 }
5653
5654 /**
5655 * Callback from the Sanitizer for expanding items found in HTML attribute
5656 * values, so they can be safely tested and escaped.
5657 *
5658 * @param string &$text
5659 * @param bool|PPFrame $frame
5660 * @return string
5661 */
5662 public function attributeStripCallback( &$text, $frame = false ) {
5663 $text = $this->replaceVariables( $text, $frame );
5664 $text = $this->mStripState->unstripBoth( $text );
5665 return $text;
5666 }
5667
5668 /**
5669 * Accessor
5670 *
5671 * @return array
5672 */
5673 public function getTags() {
5674 $this->firstCallInit();
5675 return array_merge(
5676 array_keys( $this->mTransparentTagHooks ),
5677 array_keys( $this->mTagHooks ),
5678 array_keys( $this->mFunctionTagHooks )
5679 );
5680 }
5681
5682 /**
5683 * @since 1.32
5684 * @return array
5685 */
5686 public function getFunctionSynonyms() {
5687 $this->firstCallInit();
5688 return $this->mFunctionSynonyms;
5689 }
5690
5691 /**
5692 * @since 1.32
5693 * @return string
5694 */
5695 public function getUrlProtocols() {
5696 return $this->mUrlProtocols;
5697 }
5698
5699 /**
5700 * Replace transparent tags in $text with the values given by the callbacks.
5701 *
5702 * Transparent tag hooks are like regular XML-style tag hooks, except they
5703 * operate late in the transformation sequence, on HTML instead of wikitext.
5704 *
5705 * @param string $text
5706 *
5707 * @return string
5708 */
5709 public function replaceTransparentTags( $text ) {
5710 $matches = [];
5711 $elements = array_keys( $this->mTransparentTagHooks );
5712 $text = self::extractTagsAndParams( $elements, $text, $matches );
5713 $replacements = [];
5714
5715 foreach ( $matches as $marker => $data ) {
5716 list( $element, $content, $params, $tag ) = $data;
5717 $tagName = strtolower( $element );
5718 if ( isset( $this->mTransparentTagHooks[$tagName] ) ) {
5719 $output = call_user_func_array(
5720 $this->mTransparentTagHooks[$tagName],
5721 [ $content, $params, $this ]
5722 );
5723 } else {
5724 $output = $tag;
5725 }
5726 $replacements[$marker] = $output;
5727 }
5728 return strtr( $text, $replacements );
5729 }
5730
5731 /**
5732 * Break wikitext input into sections, and either pull or replace
5733 * some particular section's text.
5734 *
5735 * External callers should use the getSection and replaceSection methods.
5736 *
5737 * @param string $text Page wikitext
5738 * @param string|int $sectionId A section identifier string of the form:
5739 * "<flag1> - <flag2> - ... - <section number>"
5740 *
5741 * Currently the only recognised flag is "T", which means the target section number
5742 * was derived during a template inclusion parse, in other words this is a template
5743 * section edit link. If no flags are given, it was an ordinary section edit link.
5744 * This flag is required to avoid a section numbering mismatch when a section is
5745 * enclosed by "<includeonly>" (T8563).
5746 *
5747 * The section number 0 pulls the text before the first heading; other numbers will
5748 * pull the given section along with its lower-level subsections. If the section is
5749 * not found, $mode=get will return $newtext, and $mode=replace will return $text.
5750 *
5751 * Section 0 is always considered to exist, even if it only contains the empty
5752 * string. If $text is the empty string and section 0 is replaced, $newText is
5753 * returned.
5754 *
5755 * @param string $mode One of "get" or "replace"
5756 * @param string $newText Replacement text for section data.
5757 * @return string For "get", the extracted section text.
5758 * for "replace", the whole page with the section replaced.
5759 */
5760 private function extractSections( $text, $sectionId, $mode, $newText = '' ) {
5761 global $wgTitle; # not generally used but removes an ugly failure mode
5762
5763 $magicScopeVariable = $this->lock();
5764 $this->startParse( $wgTitle, new ParserOptions, self::OT_PLAIN, true );
5765 $outText = '';
5766 $frame = $this->getPreprocessor()->newFrame();
5767
5768 # Process section extraction flags
5769 $flags = 0;
5770 $sectionParts = explode( '-', $sectionId );
5771 $sectionIndex = array_pop( $sectionParts );
5772 foreach ( $sectionParts as $part ) {
5773 if ( $part === 'T' ) {
5774 $flags |= self::PTD_FOR_INCLUSION;
5775 }
5776 }
5777
5778 # Check for empty input
5779 if ( strval( $text ) === '' ) {
5780 # Only sections 0 and T-0 exist in an empty document
5781 if ( $sectionIndex == 0 ) {
5782 if ( $mode === 'get' ) {
5783 return '';
5784 }
5785
5786 return $newText;
5787 } else {
5788 if ( $mode === 'get' ) {
5789 return $newText;
5790 }
5791
5792 return $text;
5793 }
5794 }
5795
5796 # Preprocess the text
5797 $root = $this->preprocessToDom( $text, $flags );
5798
5799 # <h> nodes indicate section breaks
5800 # They can only occur at the top level, so we can find them by iterating the root's children
5801 $node = $root->getFirstChild();
5802
5803 # Find the target section
5804 if ( $sectionIndex == 0 ) {
5805 # Section zero doesn't nest, level=big
5806 $targetLevel = 1000;
5807 } else {
5808 while ( $node ) {
5809 if ( $node->getName() === 'h' ) {
5810 $bits = $node->splitHeading();
5811 if ( $bits['i'] == $sectionIndex ) {
5812 $targetLevel = $bits['level'];
5813 break;
5814 }
5815 }
5816 if ( $mode === 'replace' ) {
5817 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5818 }
5819 $node = $node->getNextSibling();
5820 }
5821 }
5822
5823 if ( !$node ) {
5824 # Not found
5825 if ( $mode === 'get' ) {
5826 return $newText;
5827 } else {
5828 return $text;
5829 }
5830 }
5831
5832 # Find the end of the section, including nested sections
5833 do {
5834 if ( $node->getName() === 'h' ) {
5835 $bits = $node->splitHeading();
5836 $curLevel = $bits['level'];
5837 if ( $bits['i'] != $sectionIndex && $curLevel <= $targetLevel ) {
5838 break;
5839 }
5840 }
5841 if ( $mode === 'get' ) {
5842 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5843 }
5844 $node = $node->getNextSibling();
5845 } while ( $node );
5846
5847 # Write out the remainder (in replace mode only)
5848 if ( $mode === 'replace' ) {
5849 # Output the replacement text
5850 # Add two newlines on -- trailing whitespace in $newText is conventionally
5851 # stripped by the editor, so we need both newlines to restore the paragraph gap
5852 # Only add trailing whitespace if there is newText
5853 if ( $newText != "" ) {
5854 $outText .= $newText . "\n\n";
5855 }
5856
5857 while ( $node ) {
5858 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5859 $node = $node->getNextSibling();
5860 }
5861 }
5862
5863 if ( is_string( $outText ) ) {
5864 # Re-insert stripped tags
5865 $outText = rtrim( $this->mStripState->unstripBoth( $outText ) );
5866 }
5867
5868 return $outText;
5869 }
5870
5871 /**
5872 * This function returns the text of a section, specified by a number ($section).
5873 * A section is text under a heading like == Heading == or \<h1\>Heading\</h1\>, or
5874 * the first section before any such heading (section 0).
5875 *
5876 * If a section contains subsections, these are also returned.
5877 *
5878 * @param string $text Text to look in
5879 * @param string|int $sectionId Section identifier as a number or string
5880 * (e.g. 0, 1 or 'T-1').
5881 * @param string $defaultText Default to return if section is not found
5882 *
5883 * @return string Text of the requested section
5884 */
5885 public function getSection( $text, $sectionId, $defaultText = '' ) {
5886 return $this->extractSections( $text, $sectionId, 'get', $defaultText );
5887 }
5888
5889 /**
5890 * This function returns $oldtext after the content of the section
5891 * specified by $section has been replaced with $text. If the target
5892 * section does not exist, $oldtext is returned unchanged.
5893 *
5894 * @param string $oldText Former text of the article
5895 * @param string|int $sectionId Section identifier as a number or string
5896 * (e.g. 0, 1 or 'T-1').
5897 * @param string $newText Replacing text
5898 *
5899 * @return string Modified text
5900 */
5901 public function replaceSection( $oldText, $sectionId, $newText ) {
5902 return $this->extractSections( $oldText, $sectionId, 'replace', $newText );
5903 }
5904
5905 /**
5906 * Get the ID of the revision we are parsing
5907 *
5908 * The return value will be either:
5909 * - a) Positive, indicating a specific revision ID (current or old)
5910 * - b) Zero, meaning the revision ID specified by getCurrentRevisionCallback()
5911 * - c) Null, meaning the parse is for preview mode and there is no revision
5912 *
5913 * @return int|null
5914 */
5915 public function getRevisionId() {
5916 return $this->mRevisionId;
5917 }
5918
5919 /**
5920 * Get the revision object for $this->mRevisionId
5921 *
5922 * @return Revision|null Either a Revision object or null
5923 * @since 1.23 (public since 1.23)
5924 */
5925 public function getRevisionObject() {
5926 if ( !is_null( $this->mRevisionObject ) ) {
5927 return $this->mRevisionObject;
5928 }
5929
5930 // NOTE: try to get the RevisionObject even if mRevisionId is null.
5931 // This is useful when parsing revision that has not yet been saved.
5932 // However, if we get back a saved revision even though we are in
5933 // preview mode, we'll have to ignore it, see below.
5934 // NOTE: This callback may be used to inject an OLD revision that was
5935 // already loaded, so "current" is a bit of a misnomer. We can't just
5936 // skip it if mRevisionId is set.
5937 $rev = call_user_func(
5938 $this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this
5939 );
5940
5941 if ( $this->mRevisionId === null && $rev && $rev->getId() ) {
5942 // We are in preview mode (mRevisionId is null), and the current revision callback
5943 // returned an existing revision. Ignore it and return null, it's probably the page's
5944 // current revision, which is not what we want here. Note that we do want to call the
5945 // callback to allow the unsaved revision to be injected here, e.g. for
5946 // self-transclusion previews.
5947 return null;
5948 }
5949
5950 // If the parse is for a new revision, then the callback should have
5951 // already been set to force the object and should match mRevisionId.
5952 // If not, try to fetch by mRevisionId for sanity.
5953 if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) {
5954 $rev = Revision::newFromId( $this->mRevisionId );
5955 }
5956
5957 $this->mRevisionObject = $rev;
5958
5959 return $this->mRevisionObject;
5960 }
5961
5962 /**
5963 * Get the timestamp associated with the current revision, adjusted for
5964 * the default server-local timestamp
5965 * @return string
5966 */
5967 public function getRevisionTimestamp() {
5968 if ( is_null( $this->mRevisionTimestamp ) ) {
5969 $revObject = $this->getRevisionObject();
5970 $timestamp = $revObject ? $revObject->getTimestamp() : wfTimestampNow();
5971
5972 # The cryptic '' timezone parameter tells to use the site-default
5973 # timezone offset instead of the user settings.
5974 # Since this value will be saved into the parser cache, served
5975 # to other users, and potentially even used inside links and such,
5976 # it needs to be consistent for all visitors.
5977 $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' );
5978 }
5979 return $this->mRevisionTimestamp;
5980 }
5981
5982 /**
5983 * Get the name of the user that edited the last revision
5984 *
5985 * @return string User name
5986 */
5987 public function getRevisionUser() {
5988 if ( is_null( $this->mRevisionUser ) ) {
5989 $revObject = $this->getRevisionObject();
5990
5991 # if this template is subst: the revision id will be blank,
5992 # so just use the current user's name
5993 if ( $revObject ) {
5994 $this->mRevisionUser = $revObject->getUserText();
5995 } elseif ( $this->ot['wiki'] || $this->mOptions->getIsPreview() ) {
5996 $this->mRevisionUser = $this->getUser()->getName();
5997 }
5998 }
5999 return $this->mRevisionUser;
6000 }
6001
6002 /**
6003 * Get the size of the revision
6004 *
6005 * @return int|null Revision size
6006 */
6007 public function getRevisionSize() {
6008 if ( is_null( $this->mRevisionSize ) ) {
6009 $revObject = $this->getRevisionObject();
6010
6011 # if this variable is subst: the revision id will be blank,
6012 # so just use the parser input size, because the own substituation
6013 # will change the size.
6014 if ( $revObject ) {
6015 $this->mRevisionSize = $revObject->getSize();
6016 } else {
6017 $this->mRevisionSize = $this->mInputSize;
6018 }
6019 }
6020 return $this->mRevisionSize;
6021 }
6022
6023 /**
6024 * Mutator for $mDefaultSort
6025 *
6026 * @param string $sort New value
6027 */
6028 public function setDefaultSort( $sort ) {
6029 $this->mDefaultSort = $sort;
6030 $this->mOutput->setProperty( 'defaultsort', $sort );
6031 }
6032
6033 /**
6034 * Accessor for $mDefaultSort
6035 * Will use the empty string if none is set.
6036 *
6037 * This value is treated as a prefix, so the
6038 * empty string is equivalent to sorting by
6039 * page name.
6040 *
6041 * @return string
6042 */
6043 public function getDefaultSort() {
6044 if ( $this->mDefaultSort !== false ) {
6045 return $this->mDefaultSort;
6046 } else {
6047 return '';
6048 }
6049 }
6050
6051 /**
6052 * Accessor for $mDefaultSort
6053 * Unlike getDefaultSort(), will return false if none is set
6054 *
6055 * @return string|bool
6056 */
6057 public function getCustomDefaultSort() {
6058 return $this->mDefaultSort;
6059 }
6060
6061 private static function getSectionNameFromStrippedText( $text ) {
6062 $text = Sanitizer::normalizeSectionNameWhitespace( $text );
6063 $text = Sanitizer::decodeCharReferences( $text );
6064 $text = self::normalizeSectionName( $text );
6065 return $text;
6066 }
6067
6068 private static function makeAnchor( $sectionName ) {
6069 return '#' . Sanitizer::escapeIdForLink( $sectionName );
6070 }
6071
6072 private function makeLegacyAnchor( $sectionName ) {
6073 $fragmentMode = $this->svcOptions->get( 'FragmentMode' );
6074 if ( isset( $fragmentMode[1] ) && $fragmentMode[1] === 'legacy' ) {
6075 // ForAttribute() and ForLink() are the same for legacy encoding
6076 $id = Sanitizer::escapeIdForAttribute( $sectionName, Sanitizer::ID_FALLBACK );
6077 } else {
6078 $id = Sanitizer::escapeIdForLink( $sectionName );
6079 }
6080
6081 return "#$id";
6082 }
6083
6084 /**
6085 * Try to guess the section anchor name based on a wikitext fragment
6086 * presumably extracted from a heading, for example "Header" from
6087 * "== Header ==".
6088 *
6089 * @param string $text
6090 * @return string Anchor (starting with '#')
6091 */
6092 public function guessSectionNameFromWikiText( $text ) {
6093 # Strip out wikitext links(they break the anchor)
6094 $text = $this->stripSectionName( $text );
6095 $sectionName = self::getSectionNameFromStrippedText( $text );
6096 return self::makeAnchor( $sectionName );
6097 }
6098
6099 /**
6100 * Same as guessSectionNameFromWikiText(), but produces legacy anchors
6101 * instead, if possible. For use in redirects, since various versions
6102 * of Microsoft browsers interpret Location: headers as something other
6103 * than UTF-8, resulting in breakage.
6104 *
6105 * @param string $text The section name
6106 * @return string Anchor (starting with '#')
6107 */
6108 public function guessLegacySectionNameFromWikiText( $text ) {
6109 # Strip out wikitext links(they break the anchor)
6110 $text = $this->stripSectionName( $text );
6111 $sectionName = self::getSectionNameFromStrippedText( $text );
6112 return $this->makeLegacyAnchor( $sectionName );
6113 }
6114
6115 /**
6116 * Like guessSectionNameFromWikiText(), but takes already-stripped text as input.
6117 * @param string $text Section name (plain text)
6118 * @return string Anchor (starting with '#')
6119 */
6120 public static function guessSectionNameFromStrippedText( $text ) {
6121 $sectionName = self::getSectionNameFromStrippedText( $text );
6122 return self::makeAnchor( $sectionName );
6123 }
6124
6125 /**
6126 * Apply the same normalization as code making links to this section would
6127 *
6128 * @param string $text
6129 * @return string
6130 */
6131 private static function normalizeSectionName( $text ) {
6132 # T90902: ensure the same normalization is applied for IDs as to links
6133 $titleParser = MediaWikiServices::getInstance()->getTitleParser();
6134 try {
6135
6136 $parts = $titleParser->splitTitleString( "#$text" );
6137 } catch ( MalformedTitleException $ex ) {
6138 return $text;
6139 }
6140 return $parts['fragment'];
6141 }
6142
6143 /**
6144 * Strips a text string of wikitext for use in a section anchor
6145 *
6146 * Accepts a text string and then removes all wikitext from the
6147 * string and leaves only the resultant text (i.e. the result of
6148 * [[User:WikiSysop|Sysop]] would be "Sysop" and the result of
6149 * [[User:WikiSysop]] would be "User:WikiSysop") - this is intended
6150 * to create valid section anchors by mimicing the output of the
6151 * parser when headings are parsed.
6152 *
6153 * @param string $text Text string to be stripped of wikitext
6154 * for use in a Section anchor
6155 * @return string Filtered text string
6156 */
6157 public function stripSectionName( $text ) {
6158 # Strip internal link markup
6159 $text = preg_replace( '/\[\[:?([^[|]+)\|([^[]+)\]\]/', '$2', $text );
6160 $text = preg_replace( '/\[\[:?([^[]+)\|?\]\]/', '$1', $text );
6161
6162 # Strip external link markup
6163 # @todo FIXME: Not tolerant to blank link text
6164 # I.E. [https://www.mediawiki.org] will render as [1] or something depending
6165 # on how many empty links there are on the page - need to figure that out.
6166 $text = preg_replace( '/\[(?i:' . $this->mUrlProtocols . ')([^ ]+?) ([^[]+)\]/', '$2', $text );
6167
6168 # Parse wikitext quotes (italics & bold)
6169 $text = $this->doQuotes( $text );
6170
6171 # Strip HTML tags
6172 $text = StringUtils::delimiterReplace( '<', '>', '', $text );
6173 return $text;
6174 }
6175
6176 /**
6177 * strip/replaceVariables/unstrip for preprocessor regression testing
6178 *
6179 * @param string $text
6180 * @param Title $title
6181 * @param ParserOptions $options
6182 * @param int $outputType
6183 *
6184 * @return string
6185 */
6186 public function testSrvus( $text, Title $title, ParserOptions $options,
6187 $outputType = self::OT_HTML
6188 ) {
6189 $magicScopeVariable = $this->lock();
6190 $this->startParse( $title, $options, $outputType, true );
6191
6192 $text = $this->replaceVariables( $text );
6193 $text = $this->mStripState->unstripBoth( $text );
6194 $text = Sanitizer::removeHTMLtags( $text );
6195 return $text;
6196 }
6197
6198 /**
6199 * @param string $text
6200 * @param Title $title
6201 * @param ParserOptions $options
6202 * @return string
6203 */
6204 public function testPst( $text, Title $title, ParserOptions $options ) {
6205 return $this->preSaveTransform( $text, $title, $options->getUser(), $options );
6206 }
6207
6208 /**
6209 * @param string $text
6210 * @param Title $title
6211 * @param ParserOptions $options
6212 * @return string
6213 */
6214 public function testPreprocess( $text, Title $title, ParserOptions $options ) {
6215 return $this->testSrvus( $text, $title, $options, self::OT_PREPROCESS );
6216 }
6217
6218 /**
6219 * Call a callback function on all regions of the given text that are not
6220 * inside strip markers, and replace those regions with the return value
6221 * of the callback. For example, with input:
6222 *
6223 * aaa<MARKER>bbb
6224 *
6225 * This will call the callback function twice, with 'aaa' and 'bbb'. Those
6226 * two strings will be replaced with the value returned by the callback in
6227 * each case.
6228 *
6229 * @param string $s
6230 * @param callable $callback
6231 *
6232 * @return string
6233 */
6234 public function markerSkipCallback( $s, $callback ) {
6235 $i = 0;
6236 $out = '';
6237 while ( $i < strlen( $s ) ) {
6238 $markerStart = strpos( $s, self::MARKER_PREFIX, $i );
6239 if ( $markerStart === false ) {
6240 $out .= call_user_func( $callback, substr( $s, $i ) );
6241 break;
6242 } else {
6243 $out .= call_user_func( $callback, substr( $s, $i, $markerStart - $i ) );
6244 $markerEnd = strpos( $s, self::MARKER_SUFFIX, $markerStart );
6245 if ( $markerEnd === false ) {
6246 $out .= substr( $s, $markerStart );
6247 break;
6248 } else {
6249 $markerEnd += strlen( self::MARKER_SUFFIX );
6250 $out .= substr( $s, $markerStart, $markerEnd - $markerStart );
6251 $i = $markerEnd;
6252 }
6253 }
6254 }
6255 return $out;
6256 }
6257
6258 /**
6259 * Remove any strip markers found in the given text.
6260 *
6261 * @param string $text
6262 * @return string
6263 */
6264 public function killMarkers( $text ) {
6265 return $this->mStripState->killMarkers( $text );
6266 }
6267
6268 /**
6269 * Save the parser state required to convert the given half-parsed text to
6270 * HTML. "Half-parsed" in this context means the output of
6271 * recursiveTagParse() or internalParse(). This output has strip markers
6272 * from replaceVariables (extensionSubstitution() etc.), and link
6273 * placeholders from replaceLinkHolders().
6274 *
6275 * Returns an array which can be serialized and stored persistently. This
6276 * array can later be loaded into another parser instance with
6277 * unserializeHalfParsedText(). The text can then be safely incorporated into
6278 * the return value of a parser hook.
6279 *
6280 * @deprecated since 1.31
6281 * @param string $text
6282 *
6283 * @return array
6284 */
6285 public function serializeHalfParsedText( $text ) {
6286 wfDeprecated( __METHOD__, '1.31' );
6287 $data = [
6288 'text' => $text,
6289 'version' => self::HALF_PARSED_VERSION,
6290 'stripState' => $this->mStripState->getSubState( $text ),
6291 'linkHolders' => $this->mLinkHolders->getSubArray( $text )
6292 ];
6293 return $data;
6294 }
6295
6296 /**
6297 * Load the parser state given in the $data array, which is assumed to
6298 * have been generated by serializeHalfParsedText(). The text contents is
6299 * extracted from the array, and its markers are transformed into markers
6300 * appropriate for the current Parser instance. This transformed text is
6301 * returned, and can be safely included in the return value of a parser
6302 * hook.
6303 *
6304 * If the $data array has been stored persistently, the caller should first
6305 * check whether it is still valid, by calling isValidHalfParsedText().
6306 *
6307 * @deprecated since 1.31
6308 * @param array $data Serialized data
6309 * @throws MWException
6310 * @return string
6311 */
6312 public function unserializeHalfParsedText( $data ) {
6313 wfDeprecated( __METHOD__, '1.31' );
6314 if ( !isset( $data['version'] ) || $data['version'] != self::HALF_PARSED_VERSION ) {
6315 throw new MWException( __METHOD__ . ': invalid version' );
6316 }
6317
6318 # First, extract the strip state.
6319 $texts = [ $data['text'] ];
6320 $texts = $this->mStripState->merge( $data['stripState'], $texts );
6321
6322 # Now renumber links
6323 $texts = $this->mLinkHolders->mergeForeign( $data['linkHolders'], $texts );
6324
6325 # Should be good to go.
6326 return $texts[0];
6327 }
6328
6329 /**
6330 * Returns true if the given array, presumed to be generated by
6331 * serializeHalfParsedText(), is compatible with the current version of the
6332 * parser.
6333 *
6334 * @deprecated since 1.31
6335 * @param array $data
6336 *
6337 * @return bool
6338 */
6339 public function isValidHalfParsedText( $data ) {
6340 wfDeprecated( __METHOD__, '1.31' );
6341 return isset( $data['version'] ) && $data['version'] == self::HALF_PARSED_VERSION;
6342 }
6343
6344 /**
6345 * Parsed a width param of imagelink like 300px or 200x300px
6346 *
6347 * @param string $value
6348 * @param bool $parseHeight
6349 *
6350 * @return array
6351 * @since 1.20
6352 */
6353 public static function parseWidthParam( $value, $parseHeight = true ) {
6354 $parsedWidthParam = [];
6355 if ( $value === '' ) {
6356 return $parsedWidthParam;
6357 }
6358 $m = [];
6359 # (T15500) In both cases (width/height and width only),
6360 # permit trailing "px" for backward compatibility.
6361 if ( $parseHeight && preg_match( '/^([0-9]*)x([0-9]*)\s*(?:px)?\s*$/', $value, $m ) ) {
6362 $width = intval( $m[1] );
6363 $height = intval( $m[2] );
6364 $parsedWidthParam['width'] = $width;
6365 $parsedWidthParam['height'] = $height;
6366 } elseif ( preg_match( '/^[0-9]*\s*(?:px)?\s*$/', $value ) ) {
6367 $width = intval( $value );
6368 $parsedWidthParam['width'] = $width;
6369 }
6370 return $parsedWidthParam;
6371 }
6372
6373 /**
6374 * Lock the current instance of the parser.
6375 *
6376 * This is meant to stop someone from calling the parser
6377 * recursively and messing up all the strip state.
6378 *
6379 * @throws MWException If parser is in a parse
6380 * @return ScopedCallback The lock will be released once the return value goes out of scope.
6381 */
6382 protected function lock() {
6383 if ( $this->mInParse ) {
6384 throw new MWException( "Parser state cleared while parsing. "
6385 . "Did you call Parser::parse recursively? Lock is held by: " . $this->mInParse );
6386 }
6387
6388 // Save the backtrace when locking, so that if some code tries locking again,
6389 // we can print the lock owner's backtrace for easier debugging
6390 $e = new Exception;
6391 $this->mInParse = $e->getTraceAsString();
6392
6393 $recursiveCheck = new ScopedCallback( function () {
6394 $this->mInParse = false;
6395 } );
6396
6397 return $recursiveCheck;
6398 }
6399
6400 /**
6401 * Strip outer <p></p> tag from the HTML source of a single paragraph.
6402 *
6403 * Returns original HTML if the <p/> tag has any attributes, if there's no wrapping <p/> tag,
6404 * or if there is more than one <p/> tag in the input HTML.
6405 *
6406 * @param string $html
6407 * @return string
6408 * @since 1.24
6409 */
6410 public static function stripOuterParagraph( $html ) {
6411 $m = [];
6412 if ( preg_match( '/^<p>(.*)\n?<\/p>\n?$/sU', $html, $m ) && strpos( $m[1], '</p>' ) === false ) {
6413 $html = $m[1];
6414 }
6415
6416 return $html;
6417 }
6418
6419 /**
6420 * Return this parser if it is not doing anything, otherwise
6421 * get a fresh parser. You can use this method by doing
6422 * $newParser = $oldParser->getFreshParser(), or more simply
6423 * $oldParser->getFreshParser()->parse( ... );
6424 * if you're unsure if $oldParser is safe to use.
6425 *
6426 * @since 1.24
6427 * @return Parser A parser object that is not parsing anything
6428 */
6429 public function getFreshParser() {
6430 if ( $this->mInParse ) {
6431 return $this->factory->create();
6432 } else {
6433 return $this;
6434 }
6435 }
6436
6437 /**
6438 * Set's up the PHP implementation of OOUI for use in this request
6439 * and instructs OutputPage to enable OOUI for itself.
6440 *
6441 * @since 1.26
6442 */
6443 public function enableOOUI() {
6444 OutputPage::setupOOUI();
6445 $this->mOutput->setEnableOOUI( true );
6446 }
6447 }