Merge "Exclude redirects from Special:Fewestrevisions"
[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" );
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 wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-exists" );
2798 $value = '';
2799 }
2800 } else {
2801 # Inform the edit saving system that getting the canonical output after
2802 # revision insertion requires another parse using the actual revision ID
2803 $this->mOutput->setFlag( 'vary-revision-id' );
2804 wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id" );
2805 $value = $this->getRevisionId();
2806 if ( $value === 0 ) {
2807 $rev = $this->getRevisionObject();
2808 $value = $rev ? $rev->getId() : $value;
2809 }
2810 if ( !$value ) {
2811 $value = $this->mOptions->getSpeculativeRevId();
2812 if ( $value ) {
2813 $this->mOutput->setSpeculativeRevIdUsed( $value );
2814 }
2815 }
2816 }
2817 break;
2818 case 'revisionday':
2819 $value = (int)$this->getRevisionTimestampSubstring( 6, 2, self::MAX_TTS, $index );
2820 break;
2821 case 'revisionday2':
2822 $value = $this->getRevisionTimestampSubstring( 6, 2, self::MAX_TTS, $index );
2823 break;
2824 case 'revisionmonth':
2825 $value = $this->getRevisionTimestampSubstring( 4, 2, self::MAX_TTS, $index );
2826 break;
2827 case 'revisionmonth1':
2828 $value = (int)$this->getRevisionTimestampSubstring( 4, 2, self::MAX_TTS, $index );
2829 break;
2830 case 'revisionyear':
2831 $value = $this->getRevisionTimestampSubstring( 0, 4, self::MAX_TTS, $index );
2832 break;
2833 case 'revisiontimestamp':
2834 $value = $this->getRevisionTimestampSubstring( 0, 14, self::MAX_TTS, $index );
2835 break;
2836 case 'revisionuser':
2837 # Inform the edit saving system that getting the canonical output after
2838 # revision insertion requires a parse that used the actual user ID
2839 $this->mOutput->setFlag( 'vary-user' );
2840 wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user" );
2841 $value = $this->getRevisionUser();
2842 break;
2843 case 'revisionsize':
2844 $value = $this->getRevisionSize();
2845 break;
2846 case 'namespace':
2847 $value = str_replace( '_', ' ',
2848 $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2849 break;
2850 case 'namespacee':
2851 $value = wfUrlencode( $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2852 break;
2853 case 'namespacenumber':
2854 $value = $this->mTitle->getNamespace();
2855 break;
2856 case 'talkspace':
2857 $value = $this->mTitle->canHaveTalkPage()
2858 ? str_replace( '_', ' ', $this->mTitle->getTalkNsText() )
2859 : '';
2860 break;
2861 case 'talkspacee':
2862 $value = $this->mTitle->canHaveTalkPage() ? wfUrlencode( $this->mTitle->getTalkNsText() ) : '';
2863 break;
2864 case 'subjectspace':
2865 $value = str_replace( '_', ' ', $this->mTitle->getSubjectNsText() );
2866 break;
2867 case 'subjectspacee':
2868 $value = ( wfUrlencode( $this->mTitle->getSubjectNsText() ) );
2869 break;
2870 case 'currentdayname':
2871 $value = $pageLang->getWeekdayName( (int)MWTimestamp::getInstance( $ts )->format( 'w' ) + 1 );
2872 break;
2873 case 'currentyear':
2874 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'Y' ), true );
2875 break;
2876 case 'currenttime':
2877 $value = $pageLang->time( wfTimestamp( TS_MW, $ts ), false, false );
2878 break;
2879 case 'currenthour':
2880 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'H' ), true );
2881 break;
2882 case 'currentweek':
2883 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2884 # int to remove the padding
2885 $value = $pageLang->formatNum( (int)MWTimestamp::getInstance( $ts )->format( 'W' ) );
2886 break;
2887 case 'currentdow':
2888 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'w' ) );
2889 break;
2890 case 'localdayname':
2891 $value = $pageLang->getWeekdayName(
2892 (int)MWTimestamp::getLocalInstance( $ts )->format( 'w' ) + 1
2893 );
2894 break;
2895 case 'localyear':
2896 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'Y' ), true );
2897 break;
2898 case 'localtime':
2899 $value = $pageLang->time(
2900 MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' ),
2901 false,
2902 false
2903 );
2904 break;
2905 case 'localhour':
2906 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'H' ), true );
2907 break;
2908 case 'localweek':
2909 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2910 # int to remove the padding
2911 $value = $pageLang->formatNum( (int)MWTimestamp::getLocalInstance( $ts )->format( 'W' ) );
2912 break;
2913 case 'localdow':
2914 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'w' ) );
2915 break;
2916 case 'numberofarticles':
2917 $value = $pageLang->formatNum( SiteStats::articles() );
2918 break;
2919 case 'numberoffiles':
2920 $value = $pageLang->formatNum( SiteStats::images() );
2921 break;
2922 case 'numberofusers':
2923 $value = $pageLang->formatNum( SiteStats::users() );
2924 break;
2925 case 'numberofactiveusers':
2926 $value = $pageLang->formatNum( SiteStats::activeUsers() );
2927 break;
2928 case 'numberofpages':
2929 $value = $pageLang->formatNum( SiteStats::pages() );
2930 break;
2931 case 'numberofadmins':
2932 $value = $pageLang->formatNum( SiteStats::numberingroup( 'sysop' ) );
2933 break;
2934 case 'numberofedits':
2935 $value = $pageLang->formatNum( SiteStats::edits() );
2936 break;
2937 case 'currenttimestamp':
2938 $value = wfTimestamp( TS_MW, $ts );
2939 break;
2940 case 'localtimestamp':
2941 $value = MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' );
2942 break;
2943 case 'currentversion':
2944 $value = SpecialVersion::getVersion();
2945 break;
2946 case 'articlepath':
2947 return $this->svcOptions->get( 'ArticlePath' );
2948 case 'sitename':
2949 return $this->svcOptions->get( 'Sitename' );
2950 case 'server':
2951 return $this->svcOptions->get( 'Server' );
2952 case 'servername':
2953 return $this->svcOptions->get( 'ServerName' );
2954 case 'scriptpath':
2955 return $this->svcOptions->get( 'ScriptPath' );
2956 case 'stylepath':
2957 return $this->svcOptions->get( 'StylePath' );
2958 case 'directionmark':
2959 return $pageLang->getDirMark();
2960 case 'contentlanguage':
2961 return $this->svcOptions->get( 'LanguageCode' );
2962 case 'pagelanguage':
2963 $value = $pageLang->getCode();
2964 break;
2965 case 'cascadingsources':
2966 $value = CoreParserFunctions::cascadingsources( $this );
2967 break;
2968 default:
2969 $ret = null;
2970 Hooks::run(
2971 'ParserGetVariableValueSwitch',
2972 [ &$parser, &$this->mVarCache, &$index, &$ret, &$frame ]
2973 );
2974
2975 return $ret;
2976 }
2977
2978 if ( $index ) {
2979 $this->mVarCache[$index] = $value;
2980 }
2981
2982 return $value;
2983 }
2984
2985 /**
2986 * @param int $start
2987 * @param int $len
2988 * @param int $mtts Max time-till-save; sets vary-revision-timestamp if result changes by then
2989 * @param string $variable Parser variable name
2990 * @return string
2991 */
2992 private function getRevisionTimestampSubstring( $start, $len, $mtts, $variable ) {
2993 # Get the timezone-adjusted timestamp to be used for this revision
2994 $resNow = substr( $this->getRevisionTimestamp(), $start, $len );
2995 # Possibly set vary-revision if there is not yet an associated revision
2996 if ( !$this->getRevisionObject() ) {
2997 # Get the timezone-adjusted timestamp $mtts seconds in the future.
2998 # This future is relative to the current time and not that of the
2999 # parser options. The rendered timestamp can be compared to that
3000 # of the timestamp specified by the parser options.
3001 $resThen = substr(
3002 $this->contLang->userAdjust( wfTimestamp( TS_MW, time() + $mtts ), '' ),
3003 $start,
3004 $len
3005 );
3006
3007 if ( $resNow !== $resThen ) {
3008 # Inform the edit saving system that getting the canonical output after
3009 # revision insertion requires a parse that used an actual revision timestamp
3010 $this->mOutput->setFlag( 'vary-revision-timestamp' );
3011 wfDebug( __METHOD__ . ": $variable used, setting vary-revision-timestamp" );
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 * @param Title $title
3698 * @return bool
3699 * @since 1.34
3700 */
3701 public function isCurrentRevisionOfTitleCached( $title ) {
3702 return (
3703 $this->currentRevisionCache &&
3704 $this->currentRevisionCache->has( $title->getPrefixedText() )
3705 );
3706 }
3707
3708 /**
3709 * Wrapper around Revision::newFromTitle to allow passing additional parameters
3710 * without passing them on to it.
3711 *
3712 * @since 1.24
3713 * @param Title $title
3714 * @param Parser|bool $parser
3715 * @return Revision|bool False if missing
3716 */
3717 public static function statelessFetchRevision( Title $title, $parser = false ) {
3718 $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title );
3719
3720 return $rev;
3721 }
3722
3723 /**
3724 * Fetch the unparsed text of a template and register a reference to it.
3725 * @param Title $title
3726 * @return array ( string or false, Title )
3727 */
3728 public function fetchTemplateAndTitle( $title ) {
3729 // Defaults to Parser::statelessFetchTemplate()
3730 $templateCb = $this->mOptions->getTemplateCallback();
3731 $stuff = call_user_func( $templateCb, $title, $this );
3732 // We use U+007F DELETE to distinguish strip markers from regular text.
3733 $text = $stuff['text'];
3734 if ( is_string( $stuff['text'] ) ) {
3735 $text = strtr( $text, "\x7f", "?" );
3736 }
3737 $finalTitle = $stuff['finalTitle'] ?? $title;
3738 if ( isset( $stuff['deps'] ) ) {
3739 foreach ( $stuff['deps'] as $dep ) {
3740 $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] );
3741 if ( $dep['title']->equals( $this->getTitle() ) ) {
3742 // Self-transclusion; final result may change based on the new page version
3743 $this->mOutput->setFlag( 'vary-revision' );
3744 wfDebug( __METHOD__ . ": self transclusion, setting vary-revision" );
3745 }
3746 }
3747 }
3748 return [ $text, $finalTitle ];
3749 }
3750
3751 /**
3752 * Fetch the unparsed text of a template and register a reference to it.
3753 * @param Title $title
3754 * @return string|bool
3755 */
3756 public function fetchTemplate( $title ) {
3757 return $this->fetchTemplateAndTitle( $title )[0];
3758 }
3759
3760 /**
3761 * Static function to get a template
3762 * Can be overridden via ParserOptions::setTemplateCallback().
3763 *
3764 * @param Title $title
3765 * @param bool|Parser $parser
3766 *
3767 * @return array
3768 */
3769 public static function statelessFetchTemplate( $title, $parser = false ) {
3770 $text = $skip = false;
3771 $finalTitle = $title;
3772 $deps = [];
3773
3774 # Loop to fetch the article, with up to 1 redirect
3775 for ( $i = 0; $i < 2 && is_object( $title ); $i++ ) {
3776 # Give extensions a chance to select the revision instead
3777 $id = false; # Assume current
3778 Hooks::run( 'BeforeParserFetchTemplateAndtitle',
3779 [ $parser, $title, &$skip, &$id ] );
3780
3781 if ( $skip ) {
3782 $text = false;
3783 $deps[] = [
3784 'title' => $title,
3785 'page_id' => $title->getArticleID(),
3786 'rev_id' => null
3787 ];
3788 break;
3789 }
3790 # Get the revision
3791 if ( $id ) {
3792 $rev = Revision::newFromId( $id );
3793 } elseif ( $parser ) {
3794 $rev = $parser->fetchCurrentRevisionOfTitle( $title );
3795 } else {
3796 $rev = Revision::newFromTitle( $title );
3797 }
3798 $rev_id = $rev ? $rev->getId() : 0;
3799 # If there is no current revision, there is no page
3800 if ( $id === false && !$rev ) {
3801 $linkCache = MediaWikiServices::getInstance()->getLinkCache();
3802 $linkCache->addBadLinkObj( $title );
3803 }
3804
3805 $deps[] = [
3806 'title' => $title,
3807 'page_id' => $title->getArticleID(),
3808 'rev_id' => $rev_id ];
3809 if ( $rev && !$title->equals( $rev->getTitle() ) ) {
3810 # We fetched a rev from a different title; register it too...
3811 $deps[] = [
3812 'title' => $rev->getTitle(),
3813 'page_id' => $rev->getPage(),
3814 'rev_id' => $rev_id ];
3815 }
3816
3817 if ( $rev ) {
3818 $content = $rev->getContent();
3819 $text = $content ? $content->getWikitextForTransclusion() : null;
3820
3821 Hooks::run( 'ParserFetchTemplate',
3822 [ $parser, $title, $rev, &$text, &$deps ] );
3823
3824 if ( $text === false || $text === null ) {
3825 $text = false;
3826 break;
3827 }
3828 } elseif ( $title->getNamespace() == NS_MEDIAWIKI ) {
3829 $message = wfMessage( MediaWikiServices::getInstance()->getContentLanguage()->
3830 lcfirst( $title->getText() ) )->inContentLanguage();
3831 if ( !$message->exists() ) {
3832 $text = false;
3833 break;
3834 }
3835 $content = $message->content();
3836 $text = $message->plain();
3837 } else {
3838 break;
3839 }
3840 if ( !$content ) {
3841 break;
3842 }
3843 # Redirect?
3844 $finalTitle = $title;
3845 $title = $content->getRedirectTarget();
3846 }
3847 return [
3848 'text' => $text,
3849 'finalTitle' => $finalTitle,
3850 'deps' => $deps ];
3851 }
3852
3853 /**
3854 * Fetch a file and its title and register a reference to it.
3855 * If 'broken' is a key in $options then the file will appear as a broken thumbnail.
3856 * @param Title $title
3857 * @param array $options Array of options to RepoGroup::findFile
3858 * @return array ( File or false, Title of file )
3859 */
3860 public function fetchFileAndTitle( $title, $options = [] ) {
3861 $file = $this->fetchFileNoRegister( $title, $options );
3862
3863 $time = $file ? $file->getTimestamp() : false;
3864 $sha1 = $file ? $file->getSha1() : false;
3865 # Register the file as a dependency...
3866 $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
3867 if ( $file && !$title->equals( $file->getTitle() ) ) {
3868 # Update fetched file title
3869 $title = $file->getTitle();
3870 $this->mOutput->addImage( $title->getDBkey(), $time, $sha1 );
3871 }
3872 return [ $file, $title ];
3873 }
3874
3875 /**
3876 * Helper function for fetchFileAndTitle.
3877 *
3878 * Also useful if you need to fetch a file but not use it yet,
3879 * for example to get the file's handler.
3880 *
3881 * @param Title $title
3882 * @param array $options Array of options to RepoGroup::findFile
3883 * @return File|bool
3884 */
3885 protected function fetchFileNoRegister( $title, $options = [] ) {
3886 if ( isset( $options['broken'] ) ) {
3887 $file = false; // broken thumbnail forced by hook
3888 } elseif ( isset( $options['sha1'] ) ) { // get by (sha1,timestamp)
3889 $file = RepoGroup::singleton()->findFileFromKey( $options['sha1'], $options );
3890 } else { // get by (name,timestamp)
3891 $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title, $options );
3892 }
3893 return $file;
3894 }
3895
3896 /**
3897 * Transclude an interwiki link.
3898 *
3899 * @param Title $title
3900 * @param string $action Usually one of (raw, render)
3901 *
3902 * @return string
3903 */
3904 public function interwikiTransclude( $title, $action ) {
3905 if ( !$this->svcOptions->get( 'EnableScaryTranscluding' ) ) {
3906 return wfMessage( 'scarytranscludedisabled' )->inContentLanguage()->text();
3907 }
3908
3909 $url = $title->getFullURL( [ 'action' => $action ] );
3910 if ( strlen( $url ) > 1024 ) {
3911 return wfMessage( 'scarytranscludetoolong' )->inContentLanguage()->text();
3912 }
3913
3914 $wikiId = $title->getTransWikiID(); // remote wiki ID or false
3915
3916 $fname = __METHOD__;
3917 $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
3918
3919 $data = $cache->getWithSetCallback(
3920 $cache->makeGlobalKey(
3921 'interwiki-transclude',
3922 ( $wikiId !== false ) ? $wikiId : 'external',
3923 sha1( $url )
3924 ),
3925 $this->svcOptions->get( 'TranscludeCacheExpiry' ),
3926 function ( $oldValue, &$ttl ) use ( $url, $fname, $cache ) {
3927 $req = MWHttpRequest::factory( $url, [], $fname );
3928
3929 $status = $req->execute(); // Status object
3930 if ( !$status->isOK() ) {
3931 $ttl = $cache::TTL_UNCACHEABLE;
3932 } elseif ( $req->getResponseHeader( 'X-Database-Lagged' ) !== null ) {
3933 $ttl = min( $cache::TTL_LAGGED, $ttl );
3934 }
3935
3936 return [
3937 'text' => $status->isOK() ? $req->getContent() : null,
3938 'code' => $req->getStatus()
3939 ];
3940 },
3941 [
3942 'checkKeys' => ( $wikiId !== false )
3943 ? [ $cache->makeGlobalKey( 'interwiki-page', $wikiId, $title->getDBkey() ) ]
3944 : [],
3945 'pcGroup' => 'interwiki-transclude:5',
3946 'pcTTL' => $cache::TTL_PROC_LONG
3947 ]
3948 );
3949
3950 if ( is_string( $data['text'] ) ) {
3951 $text = $data['text'];
3952 } elseif ( $data['code'] != 200 ) {
3953 // Though we failed to fetch the content, this status is useless.
3954 $text = wfMessage( 'scarytranscludefailed-httpstatus' )
3955 ->params( $url, $data['code'] )->inContentLanguage()->text();
3956 } else {
3957 $text = wfMessage( 'scarytranscludefailed', $url )->inContentLanguage()->text();
3958 }
3959
3960 return $text;
3961 }
3962
3963 /**
3964 * Triple brace replacement -- used for template arguments
3965 * @private
3966 *
3967 * @param array $piece
3968 * @param PPFrame $frame
3969 *
3970 * @return array
3971 */
3972 public function argSubstitution( $piece, $frame ) {
3973 $error = false;
3974 $parts = $piece['parts'];
3975 $nameWithSpaces = $frame->expand( $piece['title'] );
3976 $argName = trim( $nameWithSpaces );
3977 $object = false;
3978 $text = $frame->getArgument( $argName );
3979 if ( $text === false && $parts->getLength() > 0
3980 && ( $this->ot['html']
3981 || $this->ot['pre']
3982 || ( $this->ot['wiki'] && $frame->isTemplate() )
3983 )
3984 ) {
3985 # No match in frame, use the supplied default
3986 $object = $parts->item( 0 )->getChildren();
3987 }
3988 if ( !$this->incrementIncludeSize( 'arg', strlen( $text ) ) ) {
3989 $error = '<!-- WARNING: argument omitted, expansion size too large -->';
3990 $this->limitationWarn( 'post-expand-template-argument' );
3991 }
3992
3993 if ( $text === false && $object === false ) {
3994 # No match anywhere
3995 $object = $frame->virtualBracketedImplode( '{{{', '|', '}}}', $nameWithSpaces, $parts );
3996 }
3997 if ( $error !== false ) {
3998 $text .= $error;
3999 }
4000 if ( $object !== false ) {
4001 $ret = [ 'object' => $object ];
4002 } else {
4003 $ret = [ 'text' => $text ];
4004 }
4005
4006 return $ret;
4007 }
4008
4009 /**
4010 * Return the text to be used for a given extension tag.
4011 * This is the ghost of strip().
4012 *
4013 * @param array $params Associative array of parameters:
4014 * name PPNode for the tag name
4015 * attr PPNode for unparsed text where tag attributes are thought to be
4016 * attributes Optional associative array of parsed attributes
4017 * inner Contents of extension element
4018 * noClose Original text did not have a close tag
4019 * @param PPFrame $frame
4020 *
4021 * @throws MWException
4022 * @return string
4023 */
4024 public function extensionSubstitution( $params, $frame ) {
4025 static $errorStr = '<span class="error">';
4026 static $errorLen = 20;
4027
4028 $name = $frame->expand( $params['name'] );
4029 if ( substr( $name, 0, $errorLen ) === $errorStr ) {
4030 // Probably expansion depth or node count exceeded. Just punt the
4031 // error up.
4032 return $name;
4033 }
4034
4035 $attrText = !isset( $params['attr'] ) ? null : $frame->expand( $params['attr'] );
4036 if ( substr( $attrText, 0, $errorLen ) === $errorStr ) {
4037 // See above
4038 return $attrText;
4039 }
4040
4041 // We can't safely check if the expansion for $content resulted in an
4042 // error, because the content could happen to be the error string
4043 // (T149622).
4044 $content = !isset( $params['inner'] ) ? null : $frame->expand( $params['inner'] );
4045
4046 $marker = self::MARKER_PREFIX . "-$name-"
4047 . sprintf( '%08X', $this->mMarkerIndex++ ) . self::MARKER_SUFFIX;
4048
4049 $isFunctionTag = isset( $this->mFunctionTagHooks[strtolower( $name )] ) &&
4050 ( $this->ot['html'] || $this->ot['pre'] );
4051 if ( $isFunctionTag ) {
4052 $markerType = 'none';
4053 } else {
4054 $markerType = 'general';
4055 }
4056 if ( $this->ot['html'] || $isFunctionTag ) {
4057 $name = strtolower( $name );
4058 $attributes = Sanitizer::decodeTagAttributes( $attrText );
4059 if ( isset( $params['attributes'] ) ) {
4060 $attributes += $params['attributes'];
4061 }
4062
4063 if ( isset( $this->mTagHooks[$name] ) ) {
4064 $output = call_user_func_array( $this->mTagHooks[$name],
4065 [ $content, $attributes, $this, $frame ] );
4066 } elseif ( isset( $this->mFunctionTagHooks[$name] ) ) {
4067 list( $callback, ) = $this->mFunctionTagHooks[$name];
4068
4069 // Avoid PHP 7.1 warning from passing $this by reference
4070 $parser = $this;
4071 $output = call_user_func_array( $callback, [ &$parser, $frame, $content, $attributes ] );
4072 } else {
4073 $output = '<span class="error">Invalid tag extension name: ' .
4074 htmlspecialchars( $name ) . '</span>';
4075 }
4076
4077 if ( is_array( $output ) ) {
4078 // Extract flags
4079 $flags = $output;
4080 $output = $flags[0];
4081 if ( isset( $flags['markerType'] ) ) {
4082 $markerType = $flags['markerType'];
4083 }
4084 }
4085 } else {
4086 if ( is_null( $attrText ) ) {
4087 $attrText = '';
4088 }
4089 if ( isset( $params['attributes'] ) ) {
4090 foreach ( $params['attributes'] as $attrName => $attrValue ) {
4091 $attrText .= ' ' . htmlspecialchars( $attrName ) . '="' .
4092 htmlspecialchars( $attrValue ) . '"';
4093 }
4094 }
4095 if ( $content === null ) {
4096 $output = "<$name$attrText/>";
4097 } else {
4098 $close = is_null( $params['close'] ) ? '' : $frame->expand( $params['close'] );
4099 if ( substr( $close, 0, $errorLen ) === $errorStr ) {
4100 // See above
4101 return $close;
4102 }
4103 $output = "<$name$attrText>$content$close";
4104 }
4105 }
4106
4107 if ( $markerType === 'none' ) {
4108 return $output;
4109 } elseif ( $markerType === 'nowiki' ) {
4110 $this->mStripState->addNoWiki( $marker, $output );
4111 } elseif ( $markerType === 'general' ) {
4112 $this->mStripState->addGeneral( $marker, $output );
4113 } else {
4114 throw new MWException( __METHOD__ . ': invalid marker type' );
4115 }
4116 return $marker;
4117 }
4118
4119 /**
4120 * Increment an include size counter
4121 *
4122 * @param string $type The type of expansion
4123 * @param int $size The size of the text
4124 * @return bool False if this inclusion would take it over the maximum, true otherwise
4125 */
4126 public function incrementIncludeSize( $type, $size ) {
4127 if ( $this->mIncludeSizes[$type] + $size > $this->mOptions->getMaxIncludeSize() ) {
4128 return false;
4129 } else {
4130 $this->mIncludeSizes[$type] += $size;
4131 return true;
4132 }
4133 }
4134
4135 /**
4136 * Increment the expensive function count
4137 *
4138 * @return bool False if the limit has been exceeded
4139 */
4140 public function incrementExpensiveFunctionCount() {
4141 $this->mExpensiveFunctionCount++;
4142 return $this->mExpensiveFunctionCount <= $this->mOptions->getExpensiveParserFunctionLimit();
4143 }
4144
4145 /**
4146 * Strip double-underscore items like __NOGALLERY__ and __NOTOC__
4147 * Fills $this->mDoubleUnderscores, returns the modified text
4148 *
4149 * @param string $text
4150 *
4151 * @return string
4152 */
4153 public function doDoubleUnderscore( $text ) {
4154 # The position of __TOC__ needs to be recorded
4155 $mw = $this->magicWordFactory->get( 'toc' );
4156 if ( $mw->match( $text ) ) {
4157 $this->mShowToc = true;
4158 $this->mForceTocPosition = true;
4159
4160 # Set a placeholder. At the end we'll fill it in with the TOC.
4161 $text = $mw->replace( '<!--MWTOC\'"-->', $text, 1 );
4162
4163 # Only keep the first one.
4164 $text = $mw->replace( '', $text );
4165 }
4166
4167 # Now match and remove the rest of them
4168 $mwa = $this->magicWordFactory->getDoubleUnderscoreArray();
4169 $this->mDoubleUnderscores = $mwa->matchAndRemove( $text );
4170
4171 if ( isset( $this->mDoubleUnderscores['nogallery'] ) ) {
4172 $this->mOutput->mNoGallery = true;
4173 }
4174 if ( isset( $this->mDoubleUnderscores['notoc'] ) && !$this->mForceTocPosition ) {
4175 $this->mShowToc = false;
4176 }
4177 if ( isset( $this->mDoubleUnderscores['hiddencat'] )
4178 && $this->mTitle->getNamespace() == NS_CATEGORY
4179 ) {
4180 $this->addTrackingCategory( 'hidden-category-category' );
4181 }
4182 # (T10068) Allow control over whether robots index a page.
4183 # __INDEX__ always overrides __NOINDEX__, see T16899
4184 if ( isset( $this->mDoubleUnderscores['noindex'] ) && $this->mTitle->canUseNoindex() ) {
4185 $this->mOutput->setIndexPolicy( 'noindex' );
4186 $this->addTrackingCategory( 'noindex-category' );
4187 }
4188 if ( isset( $this->mDoubleUnderscores['index'] ) && $this->mTitle->canUseNoindex() ) {
4189 $this->mOutput->setIndexPolicy( 'index' );
4190 $this->addTrackingCategory( 'index-category' );
4191 }
4192
4193 # Cache all double underscores in the database
4194 foreach ( $this->mDoubleUnderscores as $key => $val ) {
4195 $this->mOutput->setProperty( $key, '' );
4196 }
4197
4198 return $text;
4199 }
4200
4201 /**
4202 * @see ParserOutput::addTrackingCategory()
4203 * @param string $msg Message key
4204 * @return bool Whether the addition was successful
4205 */
4206 public function addTrackingCategory( $msg ) {
4207 return $this->mOutput->addTrackingCategory( $msg, $this->mTitle );
4208 }
4209
4210 /**
4211 * This function accomplishes several tasks:
4212 * 1) Auto-number headings if that option is enabled
4213 * 2) Add an [edit] link to sections for users who have enabled the option and can edit the page
4214 * 3) Add a Table of contents on the top for users who have enabled the option
4215 * 4) Auto-anchor headings
4216 *
4217 * It loops through all headlines, collects the necessary data, then splits up the
4218 * string and re-inserts the newly formatted headlines.
4219 *
4220 * @param string $text
4221 * @param string $origText Original, untouched wikitext
4222 * @param bool $isMain
4223 * @return mixed|string
4224 * @private
4225 */
4226 public function formatHeadings( $text, $origText, $isMain = true ) {
4227 # Inhibit editsection links if requested in the page
4228 if ( isset( $this->mDoubleUnderscores['noeditsection'] ) ) {
4229 $maybeShowEditLink = false;
4230 } else {
4231 $maybeShowEditLink = true; /* Actual presence will depend on post-cache transforms */
4232 }
4233
4234 # Get all headlines for numbering them and adding funky stuff like [edit]
4235 # links - this is for later, but we need the number of headlines right now
4236 # NOTE: white space in headings have been trimmed in doHeadings. They shouldn't
4237 # be trimmed here since whitespace in HTML headings is significant.
4238 $matches = [];
4239 $numMatches = preg_match_all(
4240 '/<H(?P<level>[1-6])(?P<attrib>.*?>)(?P<header>[\s\S]*?)<\/H[1-6] *>/i',
4241 $text,
4242 $matches
4243 );
4244
4245 # if there are fewer than 4 headlines in the article, do not show TOC
4246 # unless it's been explicitly enabled.
4247 $enoughToc = $this->mShowToc &&
4248 ( ( $numMatches >= 4 ) || $this->mForceTocPosition );
4249
4250 # Allow user to stipulate that a page should have a "new section"
4251 # link added via __NEWSECTIONLINK__
4252 if ( isset( $this->mDoubleUnderscores['newsectionlink'] ) ) {
4253 $this->mOutput->setNewSection( true );
4254 }
4255
4256 # Allow user to remove the "new section"
4257 # link via __NONEWSECTIONLINK__
4258 if ( isset( $this->mDoubleUnderscores['nonewsectionlink'] ) ) {
4259 $this->mOutput->hideNewSection( true );
4260 }
4261
4262 # if the string __FORCETOC__ (not case-sensitive) occurs in the HTML,
4263 # override above conditions and always show TOC above first header
4264 if ( isset( $this->mDoubleUnderscores['forcetoc'] ) ) {
4265 $this->mShowToc = true;
4266 $enoughToc = true;
4267 }
4268
4269 # headline counter
4270 $headlineCount = 0;
4271 $numVisible = 0;
4272
4273 # Ugh .. the TOC should have neat indentation levels which can be
4274 # passed to the skin functions. These are determined here
4275 $toc = '';
4276 $full = '';
4277 $head = [];
4278 $sublevelCount = [];
4279 $levelCount = [];
4280 $level = 0;
4281 $prevlevel = 0;
4282 $toclevel = 0;
4283 $prevtoclevel = 0;
4284 $markerRegex = self::MARKER_PREFIX . "-h-(\d+)-" . self::MARKER_SUFFIX;
4285 $baseTitleText = $this->mTitle->getPrefixedDBkey();
4286 $oldType = $this->mOutputType;
4287 $this->setOutputType( self::OT_WIKI );
4288 $frame = $this->getPreprocessor()->newFrame();
4289 $root = $this->preprocessToDom( $origText );
4290 $node = $root->getFirstChild();
4291 $byteOffset = 0;
4292 $tocraw = [];
4293 $refers = [];
4294
4295 $headlines = $numMatches !== false ? $matches[3] : [];
4296
4297 $maxTocLevel = $this->svcOptions->get( 'MaxTocLevel' );
4298 foreach ( $headlines as $headline ) {
4299 $isTemplate = false;
4300 $titleText = false;
4301 $sectionIndex = false;
4302 $numbering = '';
4303 $markerMatches = [];
4304 if ( preg_match( "/^$markerRegex/", $headline, $markerMatches ) ) {
4305 $serial = $markerMatches[1];
4306 list( $titleText, $sectionIndex ) = $this->mHeadings[$serial];
4307 $isTemplate = ( $titleText != $baseTitleText );
4308 $headline = preg_replace( "/^$markerRegex\\s*/", "", $headline );
4309 }
4310
4311 if ( $toclevel ) {
4312 $prevlevel = $level;
4313 }
4314 $level = $matches[1][$headlineCount];
4315
4316 if ( $level > $prevlevel ) {
4317 # Increase TOC level
4318 $toclevel++;
4319 $sublevelCount[$toclevel] = 0;
4320 if ( $toclevel < $maxTocLevel ) {
4321 $prevtoclevel = $toclevel;
4322 $toc .= Linker::tocIndent();
4323 $numVisible++;
4324 }
4325 } elseif ( $level < $prevlevel && $toclevel > 1 ) {
4326 # Decrease TOC level, find level to jump to
4327
4328 for ( $i = $toclevel; $i > 0; $i-- ) {
4329 if ( $levelCount[$i] == $level ) {
4330 # Found last matching level
4331 $toclevel = $i;
4332 break;
4333 } elseif ( $levelCount[$i] < $level ) {
4334 # Found first matching level below current level
4335 $toclevel = $i + 1;
4336 break;
4337 }
4338 }
4339 if ( $i == 0 ) {
4340 $toclevel = 1;
4341 }
4342 if ( $toclevel < $maxTocLevel ) {
4343 if ( $prevtoclevel < $maxTocLevel ) {
4344 # Unindent only if the previous toc level was shown :p
4345 $toc .= Linker::tocUnindent( $prevtoclevel - $toclevel );
4346 $prevtoclevel = $toclevel;
4347 } else {
4348 $toc .= Linker::tocLineEnd();
4349 }
4350 }
4351 } else {
4352 # No change in level, end TOC line
4353 if ( $toclevel < $maxTocLevel ) {
4354 $toc .= Linker::tocLineEnd();
4355 }
4356 }
4357
4358 $levelCount[$toclevel] = $level;
4359
4360 # count number of headlines for each level
4361 $sublevelCount[$toclevel]++;
4362 $dot = 0;
4363 for ( $i = 1; $i <= $toclevel; $i++ ) {
4364 if ( !empty( $sublevelCount[$i] ) ) {
4365 if ( $dot ) {
4366 $numbering .= '.';
4367 }
4368 $numbering .= $this->getTargetLanguage()->formatNum( $sublevelCount[$i] );
4369 $dot = 1;
4370 }
4371 }
4372
4373 # The safe header is a version of the header text safe to use for links
4374
4375 # Remove link placeholders by the link text.
4376 # <!--LINK number-->
4377 # turns into
4378 # link text with suffix
4379 # Do this before unstrip since link text can contain strip markers
4380 $safeHeadline = $this->replaceLinkHoldersText( $headline );
4381
4382 # Avoid insertion of weird stuff like <math> by expanding the relevant sections
4383 $safeHeadline = $this->mStripState->unstripBoth( $safeHeadline );
4384
4385 # Remove any <style> or <script> tags (T198618)
4386 $safeHeadline = preg_replace(
4387 '#<(style|script)(?: [^>]*[^>/])?>.*?</\1>#is',
4388 '',
4389 $safeHeadline
4390 );
4391
4392 # Strip out HTML (first regex removes any tag not allowed)
4393 # Allowed tags are:
4394 # * <sup> and <sub> (T10393)
4395 # * <i> (T28375)
4396 # * <b> (r105284)
4397 # * <bdi> (T74884)
4398 # * <span dir="rtl"> and <span dir="ltr"> (T37167)
4399 # * <s> and <strike> (T35715)
4400 # We strip any parameter from accepted tags (second regex), except dir="rtl|ltr" from <span>,
4401 # to allow setting directionality in toc items.
4402 $tocline = preg_replace(
4403 [
4404 '#<(?!/?(span|sup|sub|bdi|i|b|s|strike)(?: [^>]*)?>).*?>#',
4405 '#<(/?(?:span(?: dir="(?:rtl|ltr)")?|sup|sub|bdi|i|b|s|strike))(?: .*?)?>#'
4406 ],
4407 [ '', '<$1>' ],
4408 $safeHeadline
4409 );
4410
4411 # Strip '<span></span>', which is the result from the above if
4412 # <span id="foo"></span> is used to produce an additional anchor
4413 # for a section.
4414 $tocline = str_replace( '<span></span>', '', $tocline );
4415
4416 $tocline = trim( $tocline );
4417
4418 # For the anchor, strip out HTML-y stuff period
4419 $safeHeadline = preg_replace( '/<.*?>/', '', $safeHeadline );
4420 $safeHeadline = Sanitizer::normalizeSectionNameWhitespace( $safeHeadline );
4421
4422 # Save headline for section edit hint before it's escaped
4423 $headlineHint = $safeHeadline;
4424
4425 # Decode HTML entities
4426 $safeHeadline = Sanitizer::decodeCharReferences( $safeHeadline );
4427
4428 $safeHeadline = self::normalizeSectionName( $safeHeadline );
4429
4430 $fallbackHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_FALLBACK );
4431 $linkAnchor = Sanitizer::escapeIdForLink( $safeHeadline );
4432 $safeHeadline = Sanitizer::escapeIdForAttribute( $safeHeadline, Sanitizer::ID_PRIMARY );
4433 if ( $fallbackHeadline === $safeHeadline ) {
4434 # No reason to have both (in fact, we can't)
4435 $fallbackHeadline = false;
4436 }
4437
4438 # HTML IDs must be case-insensitively unique for IE compatibility (T12721).
4439 # @todo FIXME: We may be changing them depending on the current locale.
4440 $arrayKey = strtolower( $safeHeadline );
4441 if ( $fallbackHeadline === false ) {
4442 $fallbackArrayKey = false;
4443 } else {
4444 $fallbackArrayKey = strtolower( $fallbackHeadline );
4445 }
4446
4447 # Create the anchor for linking from the TOC to the section
4448 $anchor = $safeHeadline;
4449 $fallbackAnchor = $fallbackHeadline;
4450 if ( isset( $refers[$arrayKey] ) ) {
4451 // phpcs:ignore Generic.Formatting.DisallowMultipleStatements
4452 for ( $i = 2; isset( $refers["${arrayKey}_$i"] ); ++$i );
4453 $anchor .= "_$i";
4454 $linkAnchor .= "_$i";
4455 $refers["${arrayKey}_$i"] = true;
4456 } else {
4457 $refers[$arrayKey] = true;
4458 }
4459 if ( $fallbackHeadline !== false && isset( $refers[$fallbackArrayKey] ) ) {
4460 // phpcs:ignore Generic.Formatting.DisallowMultipleStatements
4461 for ( $i = 2; isset( $refers["${fallbackArrayKey}_$i"] ); ++$i );
4462 $fallbackAnchor .= "_$i";
4463 $refers["${fallbackArrayKey}_$i"] = true;
4464 } else {
4465 $refers[$fallbackArrayKey] = true;
4466 }
4467
4468 # Don't number the heading if it is the only one (looks silly)
4469 if ( count( $matches[3] ) > 1 && $this->mOptions->getNumberHeadings() ) {
4470 # the two are different if the line contains a link
4471 $headline = Html::element(
4472 'span',
4473 [ 'class' => 'mw-headline-number' ],
4474 $numbering
4475 ) . ' ' . $headline;
4476 }
4477
4478 if ( $enoughToc && ( !isset( $maxTocLevel ) || $toclevel < $maxTocLevel ) ) {
4479 $toc .= Linker::tocLine( $linkAnchor, $tocline,
4480 $numbering, $toclevel, ( $isTemplate ? false : $sectionIndex ) );
4481 }
4482
4483 # Add the section to the section tree
4484 # Find the DOM node for this header
4485 $noOffset = ( $isTemplate || $sectionIndex === false );
4486 while ( $node && !$noOffset ) {
4487 if ( $node->getName() === 'h' ) {
4488 $bits = $node->splitHeading();
4489 if ( $bits['i'] == $sectionIndex ) {
4490 break;
4491 }
4492 }
4493 $byteOffset += mb_strlen( $this->mStripState->unstripBoth(
4494 $frame->expand( $node, PPFrame::RECOVER_ORIG ) ) );
4495 $node = $node->getNextSibling();
4496 }
4497 $tocraw[] = [
4498 'toclevel' => $toclevel,
4499 'level' => $level,
4500 'line' => $tocline,
4501 'number' => $numbering,
4502 'index' => ( $isTemplate ? 'T-' : '' ) . $sectionIndex,
4503 'fromtitle' => $titleText,
4504 'byteoffset' => ( $noOffset ? null : $byteOffset ),
4505 'anchor' => $anchor,
4506 ];
4507
4508 # give headline the correct <h#> tag
4509 if ( $maybeShowEditLink && $sectionIndex !== false ) {
4510 // Output edit section links as markers with styles that can be customized by skins
4511 if ( $isTemplate ) {
4512 # Put a T flag in the section identifier, to indicate to extractSections()
4513 # that sections inside <includeonly> should be counted.
4514 $editsectionPage = $titleText;
4515 $editsectionSection = "T-$sectionIndex";
4516 $editsectionContent = null;
4517 } else {
4518 $editsectionPage = $this->mTitle->getPrefixedText();
4519 $editsectionSection = $sectionIndex;
4520 $editsectionContent = $headlineHint;
4521 }
4522 // We use a bit of pesudo-xml for editsection markers. The
4523 // language converter is run later on. Using a UNIQ style marker
4524 // leads to the converter screwing up the tokens when it
4525 // converts stuff. And trying to insert strip tags fails too. At
4526 // this point all real inputted tags have already been escaped,
4527 // so we don't have to worry about a user trying to input one of
4528 // these markers directly. We use a page and section attribute
4529 // to stop the language converter from converting these
4530 // important bits of data, but put the headline hint inside a
4531 // content block because the language converter is supposed to
4532 // be able to convert that piece of data.
4533 // Gets replaced with html in ParserOutput::getText
4534 $editlink = '<mw:editsection page="' . htmlspecialchars( $editsectionPage );
4535 $editlink .= '" section="' . htmlspecialchars( $editsectionSection ) . '"';
4536 if ( $editsectionContent !== null ) {
4537 $editlink .= '>' . $editsectionContent . '</mw:editsection>';
4538 } else {
4539 $editlink .= '/>';
4540 }
4541 } else {
4542 $editlink = '';
4543 }
4544 $head[$headlineCount] = Linker::makeHeadline( $level,
4545 $matches['attrib'][$headlineCount], $anchor, $headline,
4546 $editlink, $fallbackAnchor );
4547
4548 $headlineCount++;
4549 }
4550
4551 $this->setOutputType( $oldType );
4552
4553 # Never ever show TOC if no headers
4554 if ( $numVisible < 1 ) {
4555 $enoughToc = false;
4556 }
4557
4558 if ( $enoughToc ) {
4559 if ( $prevtoclevel > 0 && $prevtoclevel < $maxTocLevel ) {
4560 $toc .= Linker::tocUnindent( $prevtoclevel - 1 );
4561 }
4562 $toc = Linker::tocList( $toc, $this->mOptions->getUserLangObj() );
4563 $this->mOutput->setTOCHTML( $toc );
4564 $toc = self::TOC_START . $toc . self::TOC_END;
4565 }
4566
4567 if ( $isMain ) {
4568 $this->mOutput->setSections( $tocraw );
4569 }
4570
4571 # split up and insert constructed headlines
4572 $blocks = preg_split( '/<H[1-6].*?>[\s\S]*?<\/H[1-6]>/i', $text );
4573 $i = 0;
4574
4575 // build an array of document sections
4576 $sections = [];
4577 foreach ( $blocks as $block ) {
4578 // $head is zero-based, sections aren't.
4579 if ( empty( $head[$i - 1] ) ) {
4580 $sections[$i] = $block;
4581 } else {
4582 $sections[$i] = $head[$i - 1] . $block;
4583 }
4584
4585 /**
4586 * Send a hook, one per section.
4587 * The idea here is to be able to make section-level DIVs, but to do so in a
4588 * lower-impact, more correct way than r50769
4589 *
4590 * $this : caller
4591 * $section : the section number
4592 * &$sectionContent : ref to the content of the section
4593 * $maybeShowEditLinks : boolean describing whether this section has an edit link
4594 */
4595 Hooks::run( 'ParserSectionCreate', [ $this, $i, &$sections[$i], $maybeShowEditLink ] );
4596
4597 $i++;
4598 }
4599
4600 if ( $enoughToc && $isMain && !$this->mForceTocPosition ) {
4601 // append the TOC at the beginning
4602 // Top anchor now in skin
4603 $sections[0] .= $toc . "\n";
4604 }
4605
4606 $full .= implode( '', $sections );
4607
4608 if ( $this->mForceTocPosition ) {
4609 return str_replace( '<!--MWTOC\'"-->', $toc, $full );
4610 } else {
4611 return $full;
4612 }
4613 }
4614
4615 /**
4616 * Transform wiki markup when saving a page by doing "\r\n" -> "\n"
4617 * conversion, substituting signatures, {{subst:}} templates, etc.
4618 *
4619 * @param string $text The text to transform
4620 * @param Title $title The Title object for the current article
4621 * @param User $user The User object describing the current user
4622 * @param ParserOptions $options Parsing options
4623 * @param bool $clearState Whether to clear the parser state first
4624 * @return string The altered wiki markup
4625 */
4626 public function preSaveTransform( $text, Title $title, User $user,
4627 ParserOptions $options, $clearState = true
4628 ) {
4629 if ( $clearState ) {
4630 $magicScopeVariable = $this->lock();
4631 }
4632 $this->startParse( $title, $options, self::OT_WIKI, $clearState );
4633 $this->setUser( $user );
4634
4635 // Strip U+0000 NULL (T159174)
4636 $text = str_replace( "\000", '', $text );
4637
4638 // We still normalize line endings for backwards-compatibility
4639 // with other code that just calls PST, but this should already
4640 // be handled in TextContent subclasses
4641 $text = TextContent::normalizeLineEndings( $text );
4642
4643 if ( $options->getPreSaveTransform() ) {
4644 $text = $this->pstPass2( $text, $user );
4645 }
4646 $text = $this->mStripState->unstripBoth( $text );
4647
4648 $this->setUser( null ); # Reset
4649
4650 return $text;
4651 }
4652
4653 /**
4654 * Pre-save transform helper function
4655 *
4656 * @param string $text
4657 * @param User $user
4658 *
4659 * @return string
4660 */
4661 private function pstPass2( $text, $user ) {
4662 # Note: This is the timestamp saved as hardcoded wikitext to the database, we use
4663 # $this->contLang here in order to give everyone the same signature and use the default one
4664 # rather than the one selected in each user's preferences. (see also T14815)
4665 $ts = $this->mOptions->getTimestamp();
4666 $timestamp = MWTimestamp::getLocalInstance( $ts );
4667 $ts = $timestamp->format( 'YmdHis' );
4668 $tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text();
4669
4670 $d = $this->contLang->timeanddate( $ts, false, false ) . " ($tzMsg)";
4671
4672 # Variable replacement
4673 # Because mOutputType is OT_WIKI, this will only process {{subst:xxx}} type tags
4674 $text = $this->replaceVariables( $text );
4675
4676 # This works almost by chance, as the replaceVariables are done before the getUserSig(),
4677 # which may corrupt this parser instance via its wfMessage()->text() call-
4678
4679 # Signatures
4680 if ( strpos( $text, '~~~' ) !== false ) {
4681 $sigText = $this->getUserSig( $user );
4682 $text = strtr( $text, [
4683 '~~~~~' => $d,
4684 '~~~~' => "$sigText $d",
4685 '~~~' => $sigText
4686 ] );
4687 # The main two signature forms used above are time-sensitive
4688 $this->mOutput->setFlag( 'user-signature' );
4689 }
4690
4691 # Context links ("pipe tricks"): [[|name]] and [[name (context)|]]
4692 $tc = '[' . Title::legalChars() . ']';
4693 $nc = '[ _0-9A-Za-z\x80-\xff-]'; # Namespaces can use non-ascii!
4694
4695 // [[ns:page (context)|]]
4696 $p1 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\))\\|]]/";
4697 // [[ns:page(context)|]] (double-width brackets, added in r40257)
4698 $p4 = "/\[\[(:?$nc+:|:|)($tc+?)( ?($tc+))\\|]]/";
4699 // [[ns:page (context), context|]] (using either single or double-width comma)
4700 $p3 = "/\[\[(:?$nc+:|:|)($tc+?)( ?\\($tc+\\)|)((?:, |,)$tc+|)\\|]]/";
4701 // [[|page]] (reverse pipe trick: add context from page title)
4702 $p2 = "/\[\[\\|($tc+)]]/";
4703
4704 # try $p1 first, to turn "[[A, B (C)|]]" into "[[A, B (C)|A, B]]"
4705 $text = preg_replace( $p1, '[[\\1\\2\\3|\\2]]', $text );
4706 $text = preg_replace( $p4, '[[\\1\\2\\3|\\2]]', $text );
4707 $text = preg_replace( $p3, '[[\\1\\2\\3\\4|\\2]]', $text );
4708
4709 $t = $this->mTitle->getText();
4710 $m = [];
4711 if ( preg_match( "/^($nc+:|)$tc+?( \\($tc+\\))$/", $t, $m ) ) {
4712 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4713 } elseif ( preg_match( "/^($nc+:|)$tc+?(, $tc+|)$/", $t, $m ) && "$m[1]$m[2]" != '' ) {
4714 $text = preg_replace( $p2, "[[$m[1]\\1$m[2]|\\1]]", $text );
4715 } else {
4716 # if there's no context, don't bother duplicating the title
4717 $text = preg_replace( $p2, '[[\\1]]', $text );
4718 }
4719
4720 return $text;
4721 }
4722
4723 /**
4724 * Fetch the user's signature text, if any, and normalize to
4725 * validated, ready-to-insert wikitext.
4726 * If you have pre-fetched the nickname or the fancySig option, you can
4727 * specify them here to save a database query.
4728 * Do not reuse this parser instance after calling getUserSig(),
4729 * as it may have changed.
4730 *
4731 * @param User &$user
4732 * @param string|bool $nickname Nickname to use or false to use user's default nickname
4733 * @param bool|null $fancySig whether the nicknname is the complete signature
4734 * or null to use default value
4735 * @return string
4736 */
4737 public function getUserSig( &$user, $nickname = false, $fancySig = null ) {
4738 $username = $user->getName();
4739
4740 # If not given, retrieve from the user object.
4741 if ( $nickname === false ) {
4742 $nickname = $user->getOption( 'nickname' );
4743 }
4744
4745 if ( is_null( $fancySig ) ) {
4746 $fancySig = $user->getBoolOption( 'fancysig' );
4747 }
4748
4749 $nickname = $nickname == null ? $username : $nickname;
4750
4751 if ( mb_strlen( $nickname ) > $this->svcOptions->get( 'MaxSigChars' ) ) {
4752 $nickname = $username;
4753 wfDebug( __METHOD__ . ": $username has overlong signature.\n" );
4754 } elseif ( $fancySig !== false ) {
4755 # Sig. might contain markup; validate this
4756 if ( $this->validateSig( $nickname ) !== false ) {
4757 # Validated; clean up (if needed) and return it
4758 return $this->cleanSig( $nickname, true );
4759 } else {
4760 # Failed to validate; fall back to the default
4761 $nickname = $username;
4762 wfDebug( __METHOD__ . ": $username has bad XML tags in signature.\n" );
4763 }
4764 }
4765
4766 # Make sure nickname doesnt get a sig in a sig
4767 $nickname = self::cleanSigInSig( $nickname );
4768
4769 # If we're still here, make it a link to the user page
4770 $userText = wfEscapeWikiText( $username );
4771 $nickText = wfEscapeWikiText( $nickname );
4772 $msgName = $user->isAnon() ? 'signature-anon' : 'signature';
4773
4774 return wfMessage( $msgName, $userText, $nickText )->inContentLanguage()
4775 ->title( $this->getTitle() )->text();
4776 }
4777
4778 /**
4779 * Check that the user's signature contains no bad XML
4780 *
4781 * @param string $text
4782 * @return string|bool An expanded string, or false if invalid.
4783 */
4784 public function validateSig( $text ) {
4785 return Xml::isWellFormedXmlFragment( $text ) ? $text : false;
4786 }
4787
4788 /**
4789 * Clean up signature text
4790 *
4791 * 1) Strip 3, 4 or 5 tildes out of signatures @see cleanSigInSig
4792 * 2) Substitute all transclusions
4793 *
4794 * @param string $text
4795 * @param bool $parsing Whether we're cleaning (preferences save) or parsing
4796 * @return string Signature text
4797 */
4798 public function cleanSig( $text, $parsing = false ) {
4799 if ( !$parsing ) {
4800 global $wgTitle;
4801 $magicScopeVariable = $this->lock();
4802 $this->startParse( $wgTitle, new ParserOptions, self::OT_PREPROCESS, true );
4803 }
4804
4805 # Option to disable this feature
4806 if ( !$this->mOptions->getCleanSignatures() ) {
4807 return $text;
4808 }
4809
4810 # @todo FIXME: Regex doesn't respect extension tags or nowiki
4811 # => Move this logic to braceSubstitution()
4812 $substWord = $this->magicWordFactory->get( 'subst' );
4813 $substRegex = '/\{\{(?!(?:' . $substWord->getBaseRegex() . '))/x' . $substWord->getRegexCase();
4814 $substText = '{{' . $substWord->getSynonym( 0 );
4815
4816 $text = preg_replace( $substRegex, $substText, $text );
4817 $text = self::cleanSigInSig( $text );
4818 $dom = $this->preprocessToDom( $text );
4819 $frame = $this->getPreprocessor()->newFrame();
4820 $text = $frame->expand( $dom );
4821
4822 if ( !$parsing ) {
4823 $text = $this->mStripState->unstripBoth( $text );
4824 }
4825
4826 return $text;
4827 }
4828
4829 /**
4830 * Strip 3, 4 or 5 tildes out of signatures.
4831 *
4832 * @param string $text
4833 * @return string Signature text with /~{3,5}/ removed
4834 */
4835 public static function cleanSigInSig( $text ) {
4836 $text = preg_replace( '/~{3,5}/', '', $text );
4837 return $text;
4838 }
4839
4840 /**
4841 * Set up some variables which are usually set up in parse()
4842 * so that an external function can call some class members with confidence
4843 *
4844 * @param Title|null $title
4845 * @param ParserOptions $options
4846 * @param int $outputType
4847 * @param bool $clearState
4848 */
4849 public function startExternalParse( Title $title = null, ParserOptions $options,
4850 $outputType, $clearState = true
4851 ) {
4852 $this->startParse( $title, $options, $outputType, $clearState );
4853 }
4854
4855 /**
4856 * @param Title|null $title
4857 * @param ParserOptions $options
4858 * @param int $outputType
4859 * @param bool $clearState
4860 */
4861 private function startParse( Title $title = null, ParserOptions $options,
4862 $outputType, $clearState = true
4863 ) {
4864 $this->setTitle( $title );
4865 $this->mOptions = $options;
4866 $this->setOutputType( $outputType );
4867 if ( $clearState ) {
4868 $this->clearState();
4869 }
4870 }
4871
4872 /**
4873 * Wrapper for preprocess()
4874 *
4875 * @param string $text The text to preprocess
4876 * @param ParserOptions $options
4877 * @param Title|null $title Title object or null to use $wgTitle
4878 * @return string
4879 */
4880 public function transformMsg( $text, $options, $title = null ) {
4881 static $executing = false;
4882
4883 # Guard against infinite recursion
4884 if ( $executing ) {
4885 return $text;
4886 }
4887 $executing = true;
4888
4889 if ( !$title ) {
4890 global $wgTitle;
4891 $title = $wgTitle;
4892 }
4893
4894 $text = $this->preprocess( $text, $title, $options );
4895
4896 $executing = false;
4897 return $text;
4898 }
4899
4900 /**
4901 * Create an HTML-style tag, e.g. "<yourtag>special text</yourtag>"
4902 * The callback should have the following form:
4903 * function myParserHook( $text, $params, $parser, $frame ) { ... }
4904 *
4905 * Transform and return $text. Use $parser for any required context, e.g. use
4906 * $parser->getTitle() and $parser->getOptions() not $wgTitle or $wgOut->mParserOptions
4907 *
4908 * Hooks may return extended information by returning an array, of which the
4909 * first numbered element (index 0) must be the return string, and all other
4910 * entries are extracted into local variables within an internal function
4911 * in the Parser class.
4912 *
4913 * This interface (introduced r61913) appears to be undocumented, but
4914 * 'markerType' is used by some core tag hooks to override which strip
4915 * array their results are placed in. **Use great caution if attempting
4916 * this interface, as it is not documented and injudicious use could smash
4917 * private variables.**
4918 *
4919 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4920 * @param callable $callback The callback function (and object) to use for the tag
4921 * @throws MWException
4922 * @return callable|null The old value of the mTagHooks array associated with the hook
4923 */
4924 public function setHook( $tag, callable $callback ) {
4925 $tag = strtolower( $tag );
4926 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4927 throw new MWException( "Invalid character {$m[0]} in setHook('$tag', ...) call" );
4928 }
4929 $oldVal = $this->mTagHooks[$tag] ?? null;
4930 $this->mTagHooks[$tag] = $callback;
4931 if ( !in_array( $tag, $this->mStripList ) ) {
4932 $this->mStripList[] = $tag;
4933 }
4934
4935 return $oldVal;
4936 }
4937
4938 /**
4939 * As setHook(), but letting the contents be parsed.
4940 *
4941 * Transparent tag hooks are like regular XML-style tag hooks, except they
4942 * operate late in the transformation sequence, on HTML instead of wikitext.
4943 *
4944 * This is probably obsoleted by things dealing with parser frames?
4945 * The only extension currently using it is geoserver.
4946 *
4947 * @since 1.10
4948 * @todo better document or deprecate this
4949 *
4950 * @param string $tag The tag to use, e.g. 'hook' for "<hook>"
4951 * @param callable $callback The callback function (and object) to use for the tag
4952 * @throws MWException
4953 * @return callable|null The old value of the mTagHooks array associated with the hook
4954 */
4955 public function setTransparentTagHook( $tag, callable $callback ) {
4956 $tag = strtolower( $tag );
4957 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
4958 throw new MWException( "Invalid character {$m[0]} in setTransparentHook('$tag', ...) call" );
4959 }
4960 $oldVal = $this->mTransparentTagHooks[$tag] ?? null;
4961 $this->mTransparentTagHooks[$tag] = $callback;
4962
4963 return $oldVal;
4964 }
4965
4966 /**
4967 * Remove all tag hooks
4968 */
4969 public function clearTagHooks() {
4970 $this->mTagHooks = [];
4971 $this->mFunctionTagHooks = [];
4972 $this->mStripList = $this->mDefaultStripList;
4973 }
4974
4975 /**
4976 * Create a function, e.g. {{sum:1|2|3}}
4977 * The callback function should have the form:
4978 * function myParserFunction( &$parser, $arg1, $arg2, $arg3 ) { ... }
4979 *
4980 * Or with Parser::SFH_OBJECT_ARGS:
4981 * function myParserFunction( $parser, $frame, $args ) { ... }
4982 *
4983 * The callback may either return the text result of the function, or an array with the text
4984 * in element 0, and a number of flags in the other elements. The names of the flags are
4985 * specified in the keys. Valid flags are:
4986 * found The text returned is valid, stop processing the template. This
4987 * is on by default.
4988 * nowiki Wiki markup in the return value should be escaped
4989 * isHTML The returned text is HTML, armour it against wikitext transformation
4990 *
4991 * @param string $id The magic word ID
4992 * @param callable $callback The callback function (and object) to use
4993 * @param int $flags A combination of the following flags:
4994 * Parser::SFH_NO_HASH No leading hash, i.e. {{plural:...}} instead of {{#if:...}}
4995 *
4996 * Parser::SFH_OBJECT_ARGS Pass the template arguments as PPNode objects instead of text.
4997 * This allows for conditional expansion of the parse tree, allowing you to eliminate dead
4998 * branches and thus speed up parsing. It is also possible to analyse the parse tree of
4999 * the arguments, and to control the way they are expanded.
5000 *
5001 * The $frame parameter is a PPFrame. This can be used to produce expanded text from the
5002 * arguments, for instance:
5003 * $text = isset( $args[0] ) ? $frame->expand( $args[0] ) : '';
5004 *
5005 * For technical reasons, $args[0] is pre-expanded and will be a string. This may change in
5006 * future versions. Please call $frame->expand() on it anyway so that your code keeps
5007 * working if/when this is changed.
5008 *
5009 * If you want whitespace to be trimmed from $args, you need to do it yourself, post-
5010 * expansion.
5011 *
5012 * Please read the documentation in includes/parser/Preprocessor.php for more information
5013 * about the methods available in PPFrame and PPNode.
5014 *
5015 * @throws MWException
5016 * @return string|callable The old callback function for this name, if any
5017 */
5018 public function setFunctionHook( $id, callable $callback, $flags = 0 ) {
5019 $oldVal = isset( $this->mFunctionHooks[$id] ) ? $this->mFunctionHooks[$id][0] : null;
5020 $this->mFunctionHooks[$id] = [ $callback, $flags ];
5021
5022 # Add to function cache
5023 $mw = $this->magicWordFactory->get( $id );
5024 if ( !$mw ) {
5025 throw new MWException( __METHOD__ . '() expecting a magic word identifier.' );
5026 }
5027
5028 $synonyms = $mw->getSynonyms();
5029 $sensitive = intval( $mw->isCaseSensitive() );
5030
5031 foreach ( $synonyms as $syn ) {
5032 # Case
5033 if ( !$sensitive ) {
5034 $syn = $this->contLang->lc( $syn );
5035 }
5036 # Add leading hash
5037 if ( !( $flags & self::SFH_NO_HASH ) ) {
5038 $syn = '#' . $syn;
5039 }
5040 # Remove trailing colon
5041 if ( substr( $syn, -1, 1 ) === ':' ) {
5042 $syn = substr( $syn, 0, -1 );
5043 }
5044 $this->mFunctionSynonyms[$sensitive][$syn] = $id;
5045 }
5046 return $oldVal;
5047 }
5048
5049 /**
5050 * Get all registered function hook identifiers
5051 *
5052 * @return array
5053 */
5054 public function getFunctionHooks() {
5055 $this->firstCallInit();
5056 return array_keys( $this->mFunctionHooks );
5057 }
5058
5059 /**
5060 * Create a tag function, e.g. "<test>some stuff</test>".
5061 * Unlike tag hooks, tag functions are parsed at preprocessor level.
5062 * Unlike parser functions, their content is not preprocessed.
5063 * @param string $tag
5064 * @param callable $callback
5065 * @param int $flags
5066 * @throws MWException
5067 * @return null
5068 */
5069 public function setFunctionTagHook( $tag, callable $callback, $flags ) {
5070 $tag = strtolower( $tag );
5071 if ( preg_match( '/[<>\r\n]/', $tag, $m ) ) {
5072 throw new MWException( "Invalid character {$m[0]} in setFunctionTagHook('$tag', ...) call" );
5073 }
5074 $old = $this->mFunctionTagHooks[$tag] ?? null;
5075 $this->mFunctionTagHooks[$tag] = [ $callback, $flags ];
5076
5077 if ( !in_array( $tag, $this->mStripList ) ) {
5078 $this->mStripList[] = $tag;
5079 }
5080
5081 return $old;
5082 }
5083
5084 /**
5085 * Replace "<!--LINK-->" link placeholders with actual links, in the buffer
5086 * Placeholders created in Linker::link()
5087 *
5088 * @param string &$text
5089 * @param int $options
5090 */
5091 public function replaceLinkHolders( &$text, $options = 0 ) {
5092 $this->mLinkHolders->replace( $text );
5093 }
5094
5095 /**
5096 * Replace "<!--LINK-->" link placeholders with plain text of links
5097 * (not HTML-formatted).
5098 *
5099 * @param string $text
5100 * @return string
5101 */
5102 public function replaceLinkHoldersText( $text ) {
5103 return $this->mLinkHolders->replaceText( $text );
5104 }
5105
5106 /**
5107 * Renders an image gallery from a text with one line per image.
5108 * text labels may be given by using |-style alternative text. E.g.
5109 * Image:one.jpg|The number "1"
5110 * Image:tree.jpg|A tree
5111 * given as text will return the HTML of a gallery with two images,
5112 * labeled 'The number "1"' and
5113 * 'A tree'.
5114 *
5115 * @param string $text
5116 * @param array $params
5117 * @return string HTML
5118 */
5119 public function renderImageGallery( $text, $params ) {
5120 $mode = false;
5121 if ( isset( $params['mode'] ) ) {
5122 $mode = $params['mode'];
5123 }
5124
5125 try {
5126 $ig = ImageGalleryBase::factory( $mode );
5127 } catch ( Exception $e ) {
5128 // If invalid type set, fallback to default.
5129 $ig = ImageGalleryBase::factory( false );
5130 }
5131
5132 $ig->setContextTitle( $this->mTitle );
5133 $ig->setShowBytes( false );
5134 $ig->setShowDimensions( false );
5135 $ig->setShowFilename( false );
5136 $ig->setParser( $this );
5137 $ig->setHideBadImages();
5138 $ig->setAttributes( Sanitizer::validateTagAttributes( $params, 'ul' ) );
5139
5140 if ( isset( $params['showfilename'] ) ) {
5141 $ig->setShowFilename( true );
5142 } else {
5143 $ig->setShowFilename( false );
5144 }
5145 if ( isset( $params['caption'] ) ) {
5146 // NOTE: We aren't passing a frame here or below. Frame info
5147 // is currently opaque to Parsoid, which acts on OT_PREPROCESS.
5148 // See T107332#4030581
5149 $caption = $this->recursiveTagParse( $params['caption'] );
5150 $ig->setCaptionHtml( $caption );
5151 }
5152 if ( isset( $params['perrow'] ) ) {
5153 $ig->setPerRow( $params['perrow'] );
5154 }
5155 if ( isset( $params['widths'] ) ) {
5156 $ig->setWidths( $params['widths'] );
5157 }
5158 if ( isset( $params['heights'] ) ) {
5159 $ig->setHeights( $params['heights'] );
5160 }
5161 $ig->setAdditionalOptions( $params );
5162
5163 // Avoid PHP 7.1 warning from passing $this by reference
5164 $parser = $this;
5165 Hooks::run( 'BeforeParserrenderImageGallery', [ &$parser, &$ig ] );
5166
5167 $lines = StringUtils::explode( "\n", $text );
5168 foreach ( $lines as $line ) {
5169 # match lines like these:
5170 # Image:someimage.jpg|This is some image
5171 $matches = [];
5172 preg_match( "/^([^|]+)(\\|(.*))?$/", $line, $matches );
5173 # Skip empty lines
5174 if ( count( $matches ) == 0 ) {
5175 continue;
5176 }
5177
5178 if ( strpos( $matches[0], '%' ) !== false ) {
5179 $matches[1] = rawurldecode( $matches[1] );
5180 }
5181 $title = Title::newFromText( $matches[1], NS_FILE );
5182 if ( is_null( $title ) ) {
5183 # Bogus title. Ignore these so we don't bomb out later.
5184 continue;
5185 }
5186
5187 # We need to get what handler the file uses, to figure out parameters.
5188 # Note, a hook can overide the file name, and chose an entirely different
5189 # file (which potentially could be of a different type and have different handler).
5190 $options = [];
5191 $descQuery = false;
5192 Hooks::run( 'BeforeParserFetchFileAndTitle',
5193 [ $this, $title, &$options, &$descQuery ] );
5194 # Don't register it now, as TraditionalImageGallery does that later.
5195 $file = $this->fetchFileNoRegister( $title, $options );
5196 $handler = $file ? $file->getHandler() : false;
5197
5198 $paramMap = [
5199 'img_alt' => 'gallery-internal-alt',
5200 'img_link' => 'gallery-internal-link',
5201 ];
5202 if ( $handler ) {
5203 $paramMap += $handler->getParamMap();
5204 // We don't want people to specify per-image widths.
5205 // Additionally the width parameter would need special casing anyhow.
5206 unset( $paramMap['img_width'] );
5207 }
5208
5209 $mwArray = $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5210
5211 $label = '';
5212 $alt = '';
5213 $link = '';
5214 $handlerOptions = [];
5215 if ( isset( $matches[3] ) ) {
5216 // look for an |alt= definition while trying not to break existing
5217 // captions with multiple pipes (|) in it, until a more sensible grammar
5218 // is defined for images in galleries
5219
5220 // FIXME: Doing recursiveTagParse at this stage, and the trim before
5221 // splitting on '|' is a bit odd, and different from makeImage.
5222 $matches[3] = $this->recursiveTagParse( trim( $matches[3] ) );
5223 // Protect LanguageConverter markup
5224 $parameterMatches = StringUtils::delimiterExplode(
5225 '-{', '}-', '|', $matches[3], true /* nested */
5226 );
5227
5228 foreach ( $parameterMatches as $parameterMatch ) {
5229 list( $magicName, $match ) = $mwArray->matchVariableStartToEnd( $parameterMatch );
5230 if ( $magicName ) {
5231 $paramName = $paramMap[$magicName];
5232
5233 switch ( $paramName ) {
5234 case 'gallery-internal-alt':
5235 $alt = $this->stripAltText( $match, false );
5236 break;
5237 case 'gallery-internal-link':
5238 $linkValue = $this->stripAltText( $match, false );
5239 if ( preg_match( '/^-{R|(.*)}-$/', $linkValue ) ) {
5240 // Result of LanguageConverter::markNoConversion
5241 // invoked on an external link.
5242 $linkValue = substr( $linkValue, 4, -2 );
5243 }
5244 list( $type, $target ) = $this->parseLinkParameter( $linkValue );
5245 if ( $type === 'link-url' ) {
5246 $link = $target;
5247 $this->mOutput->addExternalLink( $target );
5248 } elseif ( $type === 'link-title' ) {
5249 $link = $target->getLinkURL();
5250 $this->mOutput->addLink( $target );
5251 }
5252 break;
5253 default:
5254 // Must be a handler specific parameter.
5255 if ( $handler->validateParam( $paramName, $match ) ) {
5256 $handlerOptions[$paramName] = $match;
5257 } else {
5258 // Guess not, consider it as caption.
5259 wfDebug( "$parameterMatch failed parameter validation\n" );
5260 $label = $parameterMatch;
5261 }
5262 }
5263
5264 } else {
5265 // Last pipe wins.
5266 $label = $parameterMatch;
5267 }
5268 }
5269 }
5270
5271 $ig->add( $title, $label, $alt, $link, $handlerOptions );
5272 }
5273 $html = $ig->toHTML();
5274 Hooks::run( 'AfterParserFetchFileAndTitle', [ $this, $ig, &$html ] );
5275 return $html;
5276 }
5277
5278 /**
5279 * @param MediaHandler $handler
5280 * @return array
5281 */
5282 public function getImageParams( $handler ) {
5283 if ( $handler ) {
5284 $handlerClass = get_class( $handler );
5285 } else {
5286 $handlerClass = '';
5287 }
5288 if ( !isset( $this->mImageParams[$handlerClass] ) ) {
5289 # Initialise static lists
5290 static $internalParamNames = [
5291 'horizAlign' => [ 'left', 'right', 'center', 'none' ],
5292 'vertAlign' => [ 'baseline', 'sub', 'super', 'top', 'text-top', 'middle',
5293 'bottom', 'text-bottom' ],
5294 'frame' => [ 'thumbnail', 'manualthumb', 'framed', 'frameless',
5295 'upright', 'border', 'link', 'alt', 'class' ],
5296 ];
5297 static $internalParamMap;
5298 if ( !$internalParamMap ) {
5299 $internalParamMap = [];
5300 foreach ( $internalParamNames as $type => $names ) {
5301 foreach ( $names as $name ) {
5302 // For grep: img_left, img_right, img_center, img_none,
5303 // img_baseline, img_sub, img_super, img_top, img_text_top, img_middle,
5304 // img_bottom, img_text_bottom,
5305 // img_thumbnail, img_manualthumb, img_framed, img_frameless, img_upright,
5306 // img_border, img_link, img_alt, img_class
5307 $magicName = str_replace( '-', '_', "img_$name" );
5308 $internalParamMap[$magicName] = [ $type, $name ];
5309 }
5310 }
5311 }
5312
5313 # Add handler params
5314 $paramMap = $internalParamMap;
5315 if ( $handler ) {
5316 $handlerParamMap = $handler->getParamMap();
5317 foreach ( $handlerParamMap as $magic => $paramName ) {
5318 $paramMap[$magic] = [ 'handler', $paramName ];
5319 }
5320 }
5321 $this->mImageParams[$handlerClass] = $paramMap;
5322 $this->mImageParamsMagicArray[$handlerClass] =
5323 $this->magicWordFactory->newArray( array_keys( $paramMap ) );
5324 }
5325 return [ $this->mImageParams[$handlerClass], $this->mImageParamsMagicArray[$handlerClass] ];
5326 }
5327
5328 /**
5329 * Parse image options text and use it to make an image
5330 *
5331 * @param Title $title
5332 * @param string $options
5333 * @param LinkHolderArray|bool $holders
5334 * @return string HTML
5335 */
5336 public function makeImage( $title, $options, $holders = false ) {
5337 # Check if the options text is of the form "options|alt text"
5338 # Options are:
5339 # * thumbnail make a thumbnail with enlarge-icon and caption, alignment depends on lang
5340 # * left no resizing, just left align. label is used for alt= only
5341 # * right same, but right aligned
5342 # * none same, but not aligned
5343 # * ___px scale to ___ pixels width, no aligning. e.g. use in taxobox
5344 # * center center the image
5345 # * frame Keep original image size, no magnify-button.
5346 # * framed Same as "frame"
5347 # * frameless like 'thumb' but without a frame. Keeps user preferences for width
5348 # * upright reduce width for upright images, rounded to full __0 px
5349 # * border draw a 1px border around the image
5350 # * alt Text for HTML alt attribute (defaults to empty)
5351 # * class Set a class for img node
5352 # * link Set the target of the image link. Can be external, interwiki, or local
5353 # vertical-align values (no % or length right now):
5354 # * baseline
5355 # * sub
5356 # * super
5357 # * top
5358 # * text-top
5359 # * middle
5360 # * bottom
5361 # * text-bottom
5362
5363 # Protect LanguageConverter markup when splitting into parts
5364 $parts = StringUtils::delimiterExplode(
5365 '-{', '}-', '|', $options, true /* allow nesting */
5366 );
5367
5368 # Give extensions a chance to select the file revision for us
5369 $options = [];
5370 $descQuery = false;
5371 Hooks::run( 'BeforeParserFetchFileAndTitle',
5372 [ $this, $title, &$options, &$descQuery ] );
5373 # Fetch and register the file (file title may be different via hooks)
5374 list( $file, $title ) = $this->fetchFileAndTitle( $title, $options );
5375
5376 # Get parameter map
5377 $handler = $file ? $file->getHandler() : false;
5378
5379 list( $paramMap, $mwArray ) = $this->getImageParams( $handler );
5380
5381 if ( !$file ) {
5382 $this->addTrackingCategory( 'broken-file-category' );
5383 }
5384
5385 # Process the input parameters
5386 $caption = '';
5387 $params = [ 'frame' => [], 'handler' => [],
5388 'horizAlign' => [], 'vertAlign' => [] ];
5389 $seenformat = false;
5390 foreach ( $parts as $part ) {
5391 $part = trim( $part );
5392 list( $magicName, $value ) = $mwArray->matchVariableStartToEnd( $part );
5393 $validated = false;
5394 if ( isset( $paramMap[$magicName] ) ) {
5395 list( $type, $paramName ) = $paramMap[$magicName];
5396
5397 # Special case; width and height come in one variable together
5398 if ( $type === 'handler' && $paramName === 'width' ) {
5399 $parsedWidthParam = self::parseWidthParam( $value );
5400 if ( isset( $parsedWidthParam['width'] ) ) {
5401 $width = $parsedWidthParam['width'];
5402 if ( $handler->validateParam( 'width', $width ) ) {
5403 $params[$type]['width'] = $width;
5404 $validated = true;
5405 }
5406 }
5407 if ( isset( $parsedWidthParam['height'] ) ) {
5408 $height = $parsedWidthParam['height'];
5409 if ( $handler->validateParam( 'height', $height ) ) {
5410 $params[$type]['height'] = $height;
5411 $validated = true;
5412 }
5413 }
5414 # else no validation -- T15436
5415 } else {
5416 if ( $type === 'handler' ) {
5417 # Validate handler parameter
5418 $validated = $handler->validateParam( $paramName, $value );
5419 } else {
5420 # Validate internal parameters
5421 switch ( $paramName ) {
5422 case 'manualthumb':
5423 case 'alt':
5424 case 'class':
5425 # @todo FIXME: Possibly check validity here for
5426 # manualthumb? downstream behavior seems odd with
5427 # missing manual thumbs.
5428 $validated = true;
5429 $value = $this->stripAltText( $value, $holders );
5430 break;
5431 case 'link':
5432 list( $paramName, $value ) =
5433 $this->parseLinkParameter(
5434 $this->stripAltText( $value, $holders )
5435 );
5436 if ( $paramName ) {
5437 $validated = true;
5438 if ( $paramName === 'no-link' ) {
5439 $value = true;
5440 }
5441 if ( ( $paramName === 'link-url' ) && $this->mOptions->getExternalLinkTarget() ) {
5442 $params[$type]['link-target'] = $this->mOptions->getExternalLinkTarget();
5443 }
5444 }
5445 break;
5446 case 'frameless':
5447 case 'framed':
5448 case 'thumbnail':
5449 // use first appearing option, discard others.
5450 $validated = !$seenformat;
5451 $seenformat = true;
5452 break;
5453 default:
5454 # Most other things appear to be empty or numeric...
5455 $validated = ( $value === false || is_numeric( trim( $value ) ) );
5456 }
5457 }
5458
5459 if ( $validated ) {
5460 $params[$type][$paramName] = $value;
5461 }
5462 }
5463 }
5464 if ( !$validated ) {
5465 $caption = $part;
5466 }
5467 }
5468
5469 # Process alignment parameters
5470 if ( $params['horizAlign'] ) {
5471 $params['frame']['align'] = key( $params['horizAlign'] );
5472 }
5473 if ( $params['vertAlign'] ) {
5474 $params['frame']['valign'] = key( $params['vertAlign'] );
5475 }
5476
5477 $params['frame']['caption'] = $caption;
5478
5479 # Will the image be presented in a frame, with the caption below?
5480 $imageIsFramed = isset( $params['frame']['frame'] )
5481 || isset( $params['frame']['framed'] )
5482 || isset( $params['frame']['thumbnail'] )
5483 || isset( $params['frame']['manualthumb'] );
5484
5485 # In the old days, [[Image:Foo|text...]] would set alt text. Later it
5486 # came to also set the caption, ordinary text after the image -- which
5487 # makes no sense, because that just repeats the text multiple times in
5488 # screen readers. It *also* came to set the title attribute.
5489 # Now that we have an alt attribute, we should not set the alt text to
5490 # equal the caption: that's worse than useless, it just repeats the
5491 # text. This is the framed/thumbnail case. If there's no caption, we
5492 # use the unnamed parameter for alt text as well, just for the time be-
5493 # ing, if the unnamed param is set and the alt param is not.
5494 # For the future, we need to figure out if we want to tweak this more,
5495 # e.g., introducing a title= parameter for the title; ignoring the un-
5496 # named parameter entirely for images without a caption; adding an ex-
5497 # plicit caption= parameter and preserving the old magic unnamed para-
5498 # meter for BC; ...
5499 if ( $imageIsFramed ) { # Framed image
5500 if ( $caption === '' && !isset( $params['frame']['alt'] ) ) {
5501 # No caption or alt text, add the filename as the alt text so
5502 # that screen readers at least get some description of the image
5503 $params['frame']['alt'] = $title->getText();
5504 }
5505 # Do not set $params['frame']['title'] because tooltips don't make sense
5506 # for framed images
5507 } else { # Inline image
5508 if ( !isset( $params['frame']['alt'] ) ) {
5509 # No alt text, use the "caption" for the alt text
5510 if ( $caption !== '' ) {
5511 $params['frame']['alt'] = $this->stripAltText( $caption, $holders );
5512 } else {
5513 # No caption, fall back to using the filename for the
5514 # alt text
5515 $params['frame']['alt'] = $title->getText();
5516 }
5517 }
5518 # Use the "caption" for the tooltip text
5519 $params['frame']['title'] = $this->stripAltText( $caption, $holders );
5520 }
5521 $params['handler']['targetlang'] = $this->getTargetLanguage()->getCode();
5522
5523 Hooks::run( 'ParserMakeImageParams', [ $title, $file, &$params, $this ] );
5524
5525 # Linker does the rest
5526 $time = $options['time'] ?? false;
5527 $ret = Linker::makeImageLink( $this, $title, $file, $params['frame'], $params['handler'],
5528 $time, $descQuery, $this->mOptions->getThumbSize() );
5529
5530 # Give the handler a chance to modify the parser object
5531 if ( $handler ) {
5532 $handler->parserTransformHook( $this, $file );
5533 }
5534
5535 return $ret;
5536 }
5537
5538 /**
5539 * Parse the value of 'link' parameter in image syntax (`[[File:Foo.jpg|link=<value>]]`).
5540 *
5541 * Adds an entry to appropriate link tables.
5542 *
5543 * @since 1.32
5544 * @param string $value
5545 * @return array of `[ type, target ]`, where:
5546 * - `type` is one of:
5547 * - `null`: Given value is not a valid link target, use default
5548 * - `'no-link'`: Given value is empty, do not generate a link
5549 * - `'link-url'`: Given value is a valid external link
5550 * - `'link-title'`: Given value is a valid internal link
5551 * - `target` is:
5552 * - When `type` is `null` or `'no-link'`: `false`
5553 * - When `type` is `'link-url'`: URL string corresponding to given value
5554 * - When `type` is `'link-title'`: Title object corresponding to given value
5555 */
5556 public function parseLinkParameter( $value ) {
5557 $chars = self::EXT_LINK_URL_CLASS;
5558 $addr = self::EXT_LINK_ADDR;
5559 $prots = $this->mUrlProtocols;
5560 $type = null;
5561 $target = false;
5562 if ( $value === '' ) {
5563 $type = 'no-link';
5564 } elseif ( preg_match( "/^((?i)$prots)/", $value ) ) {
5565 if ( preg_match( "/^((?i)$prots)$addr$chars*$/u", $value, $m ) ) {
5566 $this->mOutput->addExternalLink( $value );
5567 $type = 'link-url';
5568 $target = $value;
5569 }
5570 } else {
5571 $linkTitle = Title::newFromText( $value );
5572 if ( $linkTitle ) {
5573 $this->mOutput->addLink( $linkTitle );
5574 $type = 'link-title';
5575 $target = $linkTitle;
5576 }
5577 }
5578 return [ $type, $target ];
5579 }
5580
5581 /**
5582 * @param string $caption
5583 * @param LinkHolderArray|bool $holders
5584 * @return mixed|string
5585 */
5586 protected function stripAltText( $caption, $holders ) {
5587 # Strip bad stuff out of the title (tooltip). We can't just use
5588 # replaceLinkHoldersText() here, because if this function is called
5589 # from replaceInternalLinks2(), mLinkHolders won't be up-to-date.
5590 if ( $holders ) {
5591 $tooltip = $holders->replaceText( $caption );
5592 } else {
5593 $tooltip = $this->replaceLinkHoldersText( $caption );
5594 }
5595
5596 # make sure there are no placeholders in thumbnail attributes
5597 # that are later expanded to html- so expand them now and
5598 # remove the tags
5599 $tooltip = $this->mStripState->unstripBoth( $tooltip );
5600 # Compatibility hack! In HTML certain entity references not terminated
5601 # by a semicolon are decoded (but not if we're in an attribute; that's
5602 # how link URLs get away without properly escaping & in queries).
5603 # But wikitext has always required semicolon-termination of entities,
5604 # so encode & where needed to avoid decode of semicolon-less entities.
5605 # See T209236 and
5606 # https://www.w3.org/TR/html5/syntax.html#named-character-references
5607 # T210437 discusses moving this workaround to Sanitizer::stripAllTags.
5608 $tooltip = preg_replace( "/
5609 & # 1. entity prefix
5610 (?= # 2. followed by:
5611 (?: # a. one of the legacy semicolon-less named entities
5612 A(?:Elig|MP|acute|circ|grave|ring|tilde|uml)|
5613 C(?:OPY|cedil)|E(?:TH|acute|circ|grave|uml)|
5614 GT|I(?:acute|circ|grave|uml)|LT|Ntilde|
5615 O(?:acute|circ|grave|slash|tilde|uml)|QUOT|REG|THORN|
5616 U(?:acute|circ|grave|uml)|Yacute|
5617 a(?:acute|c(?:irc|ute)|elig|grave|mp|ring|tilde|uml)|brvbar|
5618 c(?:cedil|edil|urren)|cent(?!erdot;)|copy(?!sr;)|deg|
5619 divide(?!ontimes;)|e(?:acute|circ|grave|th|uml)|
5620 frac(?:1(?:2|4)|34)|
5621 gt(?!c(?:c|ir)|dot|lPar|quest|r(?:a(?:pprox|rr)|dot|eq(?:less|qless)|less|sim);)|
5622 i(?:acute|circ|excl|grave|quest|uml)|laquo|
5623 lt(?!c(?:c|ir)|dot|hree|imes|larr|quest|r(?:Par|i(?:e|f|));)|
5624 m(?:acr|i(?:cro|ddot))|n(?:bsp|tilde)|
5625 not(?!in(?:E|dot|v(?:a|b|c)|)|ni(?:v(?:a|b|c)|);)|
5626 o(?:acute|circ|grave|rd(?:f|m)|slash|tilde|uml)|
5627 p(?:lusmn|ound)|para(?!llel;)|quot|r(?:aquo|eg)|
5628 s(?:ect|hy|up(?:1|2|3)|zlig)|thorn|times(?!b(?:ar|)|d;)|
5629 u(?:acute|circ|grave|ml|uml)|y(?:acute|en|uml)
5630 )
5631 (?:[^;]|$)) # b. and not followed by a semicolon
5632 # S = study, for efficiency
5633 /Sx", '&amp;', $tooltip );
5634 $tooltip = Sanitizer::stripAllTags( $tooltip );
5635
5636 return $tooltip;
5637 }
5638
5639 /**
5640 * Set a flag in the output object indicating that the content is dynamic and
5641 * shouldn't be cached.
5642 * @deprecated since 1.28; use getOutput()->updateCacheExpiry()
5643 */
5644 public function disableCache() {
5645 wfDebug( "Parser output marked as uncacheable.\n" );
5646 if ( !$this->mOutput ) {
5647 throw new MWException( __METHOD__ .
5648 " can only be called when actually parsing something" );
5649 }
5650 $this->mOutput->updateCacheExpiry( 0 ); // new style, for consistency
5651 }
5652
5653 /**
5654 * Callback from the Sanitizer for expanding items found in HTML attribute
5655 * values, so they can be safely tested and escaped.
5656 *
5657 * @param string &$text
5658 * @param bool|PPFrame $frame
5659 * @return string
5660 */
5661 public function attributeStripCallback( &$text, $frame = false ) {
5662 $text = $this->replaceVariables( $text, $frame );
5663 $text = $this->mStripState->unstripBoth( $text );
5664 return $text;
5665 }
5666
5667 /**
5668 * Accessor
5669 *
5670 * @return array
5671 */
5672 public function getTags() {
5673 $this->firstCallInit();
5674 return array_merge(
5675 array_keys( $this->mTransparentTagHooks ),
5676 array_keys( $this->mTagHooks ),
5677 array_keys( $this->mFunctionTagHooks )
5678 );
5679 }
5680
5681 /**
5682 * @since 1.32
5683 * @return array
5684 */
5685 public function getFunctionSynonyms() {
5686 $this->firstCallInit();
5687 return $this->mFunctionSynonyms;
5688 }
5689
5690 /**
5691 * @since 1.32
5692 * @return string
5693 */
5694 public function getUrlProtocols() {
5695 return $this->mUrlProtocols;
5696 }
5697
5698 /**
5699 * Replace transparent tags in $text with the values given by the callbacks.
5700 *
5701 * Transparent tag hooks are like regular XML-style tag hooks, except they
5702 * operate late in the transformation sequence, on HTML instead of wikitext.
5703 *
5704 * @param string $text
5705 *
5706 * @return string
5707 */
5708 public function replaceTransparentTags( $text ) {
5709 $matches = [];
5710 $elements = array_keys( $this->mTransparentTagHooks );
5711 $text = self::extractTagsAndParams( $elements, $text, $matches );
5712 $replacements = [];
5713
5714 foreach ( $matches as $marker => $data ) {
5715 list( $element, $content, $params, $tag ) = $data;
5716 $tagName = strtolower( $element );
5717 if ( isset( $this->mTransparentTagHooks[$tagName] ) ) {
5718 $output = call_user_func_array(
5719 $this->mTransparentTagHooks[$tagName],
5720 [ $content, $params, $this ]
5721 );
5722 } else {
5723 $output = $tag;
5724 }
5725 $replacements[$marker] = $output;
5726 }
5727 return strtr( $text, $replacements );
5728 }
5729
5730 /**
5731 * Break wikitext input into sections, and either pull or replace
5732 * some particular section's text.
5733 *
5734 * External callers should use the getSection and replaceSection methods.
5735 *
5736 * @param string $text Page wikitext
5737 * @param string|int $sectionId A section identifier string of the form:
5738 * "<flag1> - <flag2> - ... - <section number>"
5739 *
5740 * Currently the only recognised flag is "T", which means the target section number
5741 * was derived during a template inclusion parse, in other words this is a template
5742 * section edit link. If no flags are given, it was an ordinary section edit link.
5743 * This flag is required to avoid a section numbering mismatch when a section is
5744 * enclosed by "<includeonly>" (T8563).
5745 *
5746 * The section number 0 pulls the text before the first heading; other numbers will
5747 * pull the given section along with its lower-level subsections. If the section is
5748 * not found, $mode=get will return $newtext, and $mode=replace will return $text.
5749 *
5750 * Section 0 is always considered to exist, even if it only contains the empty
5751 * string. If $text is the empty string and section 0 is replaced, $newText is
5752 * returned.
5753 *
5754 * @param string $mode One of "get" or "replace"
5755 * @param string $newText Replacement text for section data.
5756 * @return string For "get", the extracted section text.
5757 * for "replace", the whole page with the section replaced.
5758 */
5759 private function extractSections( $text, $sectionId, $mode, $newText = '' ) {
5760 global $wgTitle; # not generally used but removes an ugly failure mode
5761
5762 $magicScopeVariable = $this->lock();
5763 $this->startParse( $wgTitle, new ParserOptions, self::OT_PLAIN, true );
5764 $outText = '';
5765 $frame = $this->getPreprocessor()->newFrame();
5766
5767 # Process section extraction flags
5768 $flags = 0;
5769 $sectionParts = explode( '-', $sectionId );
5770 $sectionIndex = array_pop( $sectionParts );
5771 foreach ( $sectionParts as $part ) {
5772 if ( $part === 'T' ) {
5773 $flags |= self::PTD_FOR_INCLUSION;
5774 }
5775 }
5776
5777 # Check for empty input
5778 if ( strval( $text ) === '' ) {
5779 # Only sections 0 and T-0 exist in an empty document
5780 if ( $sectionIndex == 0 ) {
5781 if ( $mode === 'get' ) {
5782 return '';
5783 }
5784
5785 return $newText;
5786 } else {
5787 if ( $mode === 'get' ) {
5788 return $newText;
5789 }
5790
5791 return $text;
5792 }
5793 }
5794
5795 # Preprocess the text
5796 $root = $this->preprocessToDom( $text, $flags );
5797
5798 # <h> nodes indicate section breaks
5799 # They can only occur at the top level, so we can find them by iterating the root's children
5800 $node = $root->getFirstChild();
5801
5802 # Find the target section
5803 if ( $sectionIndex == 0 ) {
5804 # Section zero doesn't nest, level=big
5805 $targetLevel = 1000;
5806 } else {
5807 while ( $node ) {
5808 if ( $node->getName() === 'h' ) {
5809 $bits = $node->splitHeading();
5810 if ( $bits['i'] == $sectionIndex ) {
5811 $targetLevel = $bits['level'];
5812 break;
5813 }
5814 }
5815 if ( $mode === 'replace' ) {
5816 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5817 }
5818 $node = $node->getNextSibling();
5819 }
5820 }
5821
5822 if ( !$node ) {
5823 # Not found
5824 if ( $mode === 'get' ) {
5825 return $newText;
5826 } else {
5827 return $text;
5828 }
5829 }
5830
5831 # Find the end of the section, including nested sections
5832 do {
5833 if ( $node->getName() === 'h' ) {
5834 $bits = $node->splitHeading();
5835 $curLevel = $bits['level'];
5836 if ( $bits['i'] != $sectionIndex && $curLevel <= $targetLevel ) {
5837 break;
5838 }
5839 }
5840 if ( $mode === 'get' ) {
5841 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5842 }
5843 $node = $node->getNextSibling();
5844 } while ( $node );
5845
5846 # Write out the remainder (in replace mode only)
5847 if ( $mode === 'replace' ) {
5848 # Output the replacement text
5849 # Add two newlines on -- trailing whitespace in $newText is conventionally
5850 # stripped by the editor, so we need both newlines to restore the paragraph gap
5851 # Only add trailing whitespace if there is newText
5852 if ( $newText != "" ) {
5853 $outText .= $newText . "\n\n";
5854 }
5855
5856 while ( $node ) {
5857 $outText .= $frame->expand( $node, PPFrame::RECOVER_ORIG );
5858 $node = $node->getNextSibling();
5859 }
5860 }
5861
5862 if ( is_string( $outText ) ) {
5863 # Re-insert stripped tags
5864 $outText = rtrim( $this->mStripState->unstripBoth( $outText ) );
5865 }
5866
5867 return $outText;
5868 }
5869
5870 /**
5871 * This function returns the text of a section, specified by a number ($section).
5872 * A section is text under a heading like == Heading == or \<h1\>Heading\</h1\>, or
5873 * the first section before any such heading (section 0).
5874 *
5875 * If a section contains subsections, these are also returned.
5876 *
5877 * @param string $text Text to look in
5878 * @param string|int $sectionId Section identifier as a number or string
5879 * (e.g. 0, 1 or 'T-1').
5880 * @param string $defaultText Default to return if section is not found
5881 *
5882 * @return string Text of the requested section
5883 */
5884 public function getSection( $text, $sectionId, $defaultText = '' ) {
5885 return $this->extractSections( $text, $sectionId, 'get', $defaultText );
5886 }
5887
5888 /**
5889 * This function returns $oldtext after the content of the section
5890 * specified by $section has been replaced with $text. If the target
5891 * section does not exist, $oldtext is returned unchanged.
5892 *
5893 * @param string $oldText Former text of the article
5894 * @param string|int $sectionId Section identifier as a number or string
5895 * (e.g. 0, 1 or 'T-1').
5896 * @param string $newText Replacing text
5897 *
5898 * @return string Modified text
5899 */
5900 public function replaceSection( $oldText, $sectionId, $newText ) {
5901 return $this->extractSections( $oldText, $sectionId, 'replace', $newText );
5902 }
5903
5904 /**
5905 * Get the ID of the revision we are parsing
5906 *
5907 * The return value will be either:
5908 * - a) Positive, indicating a specific revision ID (current or old)
5909 * - b) Zero, meaning the revision ID is specified by getCurrentRevisionCallback()
5910 * - c) Null, meaning the parse is for preview mode and there is no revision
5911 *
5912 * @return int|null
5913 */
5914 public function getRevisionId() {
5915 return $this->mRevisionId;
5916 }
5917
5918 /**
5919 * Get the revision object for $this->mRevisionId
5920 *
5921 * @return Revision|null Either a Revision object or null
5922 * @since 1.23 (public since 1.23)
5923 */
5924 public function getRevisionObject() {
5925 if ( !is_null( $this->mRevisionObject ) ) {
5926 return $this->mRevisionObject;
5927 }
5928
5929 // NOTE: try to get the RevisionObject even if mRevisionId is null.
5930 // This is useful when parsing revision that has not yet been saved.
5931 // However, if we get back a saved revision even though we are in
5932 // preview mode, we'll have to ignore it, see below.
5933 // NOTE: This callback may be used to inject an OLD revision that was
5934 // already loaded, so "current" is a bit of a misnomer. We can't just
5935 // skip it if mRevisionId is set.
5936 $rev = call_user_func(
5937 $this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this
5938 );
5939
5940 if ( $this->mRevisionId === null && $rev && $rev->getId() ) {
5941 // We are in preview mode (mRevisionId is null), and the current revision callback
5942 // returned an existing revision. Ignore it and return null, it's probably the page's
5943 // current revision, which is not what we want here. Note that we do want to call the
5944 // callback to allow the unsaved revision to be injected here, e.g. for
5945 // self-transclusion previews.
5946 return null;
5947 }
5948
5949 // If the parse is for a new revision, then the callback should have
5950 // already been set to force the object and should match mRevisionId.
5951 // If not, try to fetch by mRevisionId for sanity.
5952 if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) {
5953 $rev = Revision::newFromId( $this->mRevisionId );
5954 }
5955
5956 $this->mRevisionObject = $rev;
5957
5958 return $this->mRevisionObject;
5959 }
5960
5961 /**
5962 * Get the timestamp associated with the current revision, adjusted for
5963 * the default server-local timestamp
5964 * @return string TS_MW timestamp
5965 */
5966 public function getRevisionTimestamp() {
5967 if ( $this->mRevisionTimestamp !== null ) {
5968 return $this->mRevisionTimestamp;
5969 }
5970
5971 # Use specified revision timestamp, falling back to the current timestamp
5972 $revObject = $this->getRevisionObject();
5973 $timestamp = $revObject ? $revObject->getTimestamp() : $this->mOptions->getTimestamp();
5974 $this->mOutput->setRevisionTimestampUsed( $timestamp ); // unadjusted time zone
5975
5976 # The cryptic '' timezone parameter tells to use the site-default
5977 # timezone offset instead of the user settings.
5978 # Since this value will be saved into the parser cache, served
5979 # to other users, and potentially even used inside links and such,
5980 # it needs to be consistent for all visitors.
5981 $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' );
5982
5983 return $this->mRevisionTimestamp;
5984 }
5985
5986 /**
5987 * Get the name of the user that edited the last revision
5988 *
5989 * @return string User name
5990 */
5991 public function getRevisionUser() {
5992 if ( is_null( $this->mRevisionUser ) ) {
5993 $revObject = $this->getRevisionObject();
5994
5995 # if this template is subst: the revision id will be blank,
5996 # so just use the current user's name
5997 if ( $revObject ) {
5998 $this->mRevisionUser = $revObject->getUserText();
5999 } elseif ( $this->ot['wiki'] || $this->mOptions->getIsPreview() ) {
6000 $this->mRevisionUser = $this->getUser()->getName();
6001 }
6002 }
6003 return $this->mRevisionUser;
6004 }
6005
6006 /**
6007 * Get the size of the revision
6008 *
6009 * @return int|null Revision size
6010 */
6011 public function getRevisionSize() {
6012 if ( is_null( $this->mRevisionSize ) ) {
6013 $revObject = $this->getRevisionObject();
6014
6015 # if this variable is subst: the revision id will be blank,
6016 # so just use the parser input size, because the own substituation
6017 # will change the size.
6018 if ( $revObject ) {
6019 $this->mRevisionSize = $revObject->getSize();
6020 } else {
6021 $this->mRevisionSize = $this->mInputSize;
6022 }
6023 }
6024 return $this->mRevisionSize;
6025 }
6026
6027 /**
6028 * Mutator for $mDefaultSort
6029 *
6030 * @param string $sort New value
6031 */
6032 public function setDefaultSort( $sort ) {
6033 $this->mDefaultSort = $sort;
6034 $this->mOutput->setProperty( 'defaultsort', $sort );
6035 }
6036
6037 /**
6038 * Accessor for $mDefaultSort
6039 * Will use the empty string if none is set.
6040 *
6041 * This value is treated as a prefix, so the
6042 * empty string is equivalent to sorting by
6043 * page name.
6044 *
6045 * @return string
6046 */
6047 public function getDefaultSort() {
6048 if ( $this->mDefaultSort !== false ) {
6049 return $this->mDefaultSort;
6050 } else {
6051 return '';
6052 }
6053 }
6054
6055 /**
6056 * Accessor for $mDefaultSort
6057 * Unlike getDefaultSort(), will return false if none is set
6058 *
6059 * @return string|bool
6060 */
6061 public function getCustomDefaultSort() {
6062 return $this->mDefaultSort;
6063 }
6064
6065 private static function getSectionNameFromStrippedText( $text ) {
6066 $text = Sanitizer::normalizeSectionNameWhitespace( $text );
6067 $text = Sanitizer::decodeCharReferences( $text );
6068 $text = self::normalizeSectionName( $text );
6069 return $text;
6070 }
6071
6072 private static function makeAnchor( $sectionName ) {
6073 return '#' . Sanitizer::escapeIdForLink( $sectionName );
6074 }
6075
6076 private function makeLegacyAnchor( $sectionName ) {
6077 $fragmentMode = $this->svcOptions->get( 'FragmentMode' );
6078 if ( isset( $fragmentMode[1] ) && $fragmentMode[1] === 'legacy' ) {
6079 // ForAttribute() and ForLink() are the same for legacy encoding
6080 $id = Sanitizer::escapeIdForAttribute( $sectionName, Sanitizer::ID_FALLBACK );
6081 } else {
6082 $id = Sanitizer::escapeIdForLink( $sectionName );
6083 }
6084
6085 return "#$id";
6086 }
6087
6088 /**
6089 * Try to guess the section anchor name based on a wikitext fragment
6090 * presumably extracted from a heading, for example "Header" from
6091 * "== Header ==".
6092 *
6093 * @param string $text
6094 * @return string Anchor (starting with '#')
6095 */
6096 public function guessSectionNameFromWikiText( $text ) {
6097 # Strip out wikitext links(they break the anchor)
6098 $text = $this->stripSectionName( $text );
6099 $sectionName = self::getSectionNameFromStrippedText( $text );
6100 return self::makeAnchor( $sectionName );
6101 }
6102
6103 /**
6104 * Same as guessSectionNameFromWikiText(), but produces legacy anchors
6105 * instead, if possible. For use in redirects, since various versions
6106 * of Microsoft browsers interpret Location: headers as something other
6107 * than UTF-8, resulting in breakage.
6108 *
6109 * @param string $text The section name
6110 * @return string Anchor (starting with '#')
6111 */
6112 public function guessLegacySectionNameFromWikiText( $text ) {
6113 # Strip out wikitext links(they break the anchor)
6114 $text = $this->stripSectionName( $text );
6115 $sectionName = self::getSectionNameFromStrippedText( $text );
6116 return $this->makeLegacyAnchor( $sectionName );
6117 }
6118
6119 /**
6120 * Like guessSectionNameFromWikiText(), but takes already-stripped text as input.
6121 * @param string $text Section name (plain text)
6122 * @return string Anchor (starting with '#')
6123 */
6124 public static function guessSectionNameFromStrippedText( $text ) {
6125 $sectionName = self::getSectionNameFromStrippedText( $text );
6126 return self::makeAnchor( $sectionName );
6127 }
6128
6129 /**
6130 * Apply the same normalization as code making links to this section would
6131 *
6132 * @param string $text
6133 * @return string
6134 */
6135 private static function normalizeSectionName( $text ) {
6136 # T90902: ensure the same normalization is applied for IDs as to links
6137 $titleParser = MediaWikiServices::getInstance()->getTitleParser();
6138 try {
6139
6140 $parts = $titleParser->splitTitleString( "#$text" );
6141 } catch ( MalformedTitleException $ex ) {
6142 return $text;
6143 }
6144 return $parts['fragment'];
6145 }
6146
6147 /**
6148 * Strips a text string of wikitext for use in a section anchor
6149 *
6150 * Accepts a text string and then removes all wikitext from the
6151 * string and leaves only the resultant text (i.e. the result of
6152 * [[User:WikiSysop|Sysop]] would be "Sysop" and the result of
6153 * [[User:WikiSysop]] would be "User:WikiSysop") - this is intended
6154 * to create valid section anchors by mimicing the output of the
6155 * parser when headings are parsed.
6156 *
6157 * @param string $text Text string to be stripped of wikitext
6158 * for use in a Section anchor
6159 * @return string Filtered text string
6160 */
6161 public function stripSectionName( $text ) {
6162 # Strip internal link markup
6163 $text = preg_replace( '/\[\[:?([^[|]+)\|([^[]+)\]\]/', '$2', $text );
6164 $text = preg_replace( '/\[\[:?([^[]+)\|?\]\]/', '$1', $text );
6165
6166 # Strip external link markup
6167 # @todo FIXME: Not tolerant to blank link text
6168 # I.E. [https://www.mediawiki.org] will render as [1] or something depending
6169 # on how many empty links there are on the page - need to figure that out.
6170 $text = preg_replace( '/\[(?i:' . $this->mUrlProtocols . ')([^ ]+?) ([^[]+)\]/', '$2', $text );
6171
6172 # Parse wikitext quotes (italics & bold)
6173 $text = $this->doQuotes( $text );
6174
6175 # Strip HTML tags
6176 $text = StringUtils::delimiterReplace( '<', '>', '', $text );
6177 return $text;
6178 }
6179
6180 /**
6181 * strip/replaceVariables/unstrip for preprocessor regression testing
6182 *
6183 * @param string $text
6184 * @param Title $title
6185 * @param ParserOptions $options
6186 * @param int $outputType
6187 *
6188 * @return string
6189 */
6190 public function testSrvus( $text, Title $title, ParserOptions $options,
6191 $outputType = self::OT_HTML
6192 ) {
6193 $magicScopeVariable = $this->lock();
6194 $this->startParse( $title, $options, $outputType, true );
6195
6196 $text = $this->replaceVariables( $text );
6197 $text = $this->mStripState->unstripBoth( $text );
6198 $text = Sanitizer::removeHTMLtags( $text );
6199 return $text;
6200 }
6201
6202 /**
6203 * @param string $text
6204 * @param Title $title
6205 * @param ParserOptions $options
6206 * @return string
6207 */
6208 public function testPst( $text, Title $title, ParserOptions $options ) {
6209 return $this->preSaveTransform( $text, $title, $options->getUser(), $options );
6210 }
6211
6212 /**
6213 * @param string $text
6214 * @param Title $title
6215 * @param ParserOptions $options
6216 * @return string
6217 */
6218 public function testPreprocess( $text, Title $title, ParserOptions $options ) {
6219 return $this->testSrvus( $text, $title, $options, self::OT_PREPROCESS );
6220 }
6221
6222 /**
6223 * Call a callback function on all regions of the given text that are not
6224 * inside strip markers, and replace those regions with the return value
6225 * of the callback. For example, with input:
6226 *
6227 * aaa<MARKER>bbb
6228 *
6229 * This will call the callback function twice, with 'aaa' and 'bbb'. Those
6230 * two strings will be replaced with the value returned by the callback in
6231 * each case.
6232 *
6233 * @param string $s
6234 * @param callable $callback
6235 *
6236 * @return string
6237 */
6238 public function markerSkipCallback( $s, $callback ) {
6239 $i = 0;
6240 $out = '';
6241 while ( $i < strlen( $s ) ) {
6242 $markerStart = strpos( $s, self::MARKER_PREFIX, $i );
6243 if ( $markerStart === false ) {
6244 $out .= call_user_func( $callback, substr( $s, $i ) );
6245 break;
6246 } else {
6247 $out .= call_user_func( $callback, substr( $s, $i, $markerStart - $i ) );
6248 $markerEnd = strpos( $s, self::MARKER_SUFFIX, $markerStart );
6249 if ( $markerEnd === false ) {
6250 $out .= substr( $s, $markerStart );
6251 break;
6252 } else {
6253 $markerEnd += strlen( self::MARKER_SUFFIX );
6254 $out .= substr( $s, $markerStart, $markerEnd - $markerStart );
6255 $i = $markerEnd;
6256 }
6257 }
6258 }
6259 return $out;
6260 }
6261
6262 /**
6263 * Remove any strip markers found in the given text.
6264 *
6265 * @param string $text
6266 * @return string
6267 */
6268 public function killMarkers( $text ) {
6269 return $this->mStripState->killMarkers( $text );
6270 }
6271
6272 /**
6273 * Save the parser state required to convert the given half-parsed text to
6274 * HTML. "Half-parsed" in this context means the output of
6275 * recursiveTagParse() or internalParse(). This output has strip markers
6276 * from replaceVariables (extensionSubstitution() etc.), and link
6277 * placeholders from replaceLinkHolders().
6278 *
6279 * Returns an array which can be serialized and stored persistently. This
6280 * array can later be loaded into another parser instance with
6281 * unserializeHalfParsedText(). The text can then be safely incorporated into
6282 * the return value of a parser hook.
6283 *
6284 * @deprecated since 1.31
6285 * @param string $text
6286 *
6287 * @return array
6288 */
6289 public function serializeHalfParsedText( $text ) {
6290 wfDeprecated( __METHOD__, '1.31' );
6291 $data = [
6292 'text' => $text,
6293 'version' => self::HALF_PARSED_VERSION,
6294 'stripState' => $this->mStripState->getSubState( $text ),
6295 'linkHolders' => $this->mLinkHolders->getSubArray( $text )
6296 ];
6297 return $data;
6298 }
6299
6300 /**
6301 * Load the parser state given in the $data array, which is assumed to
6302 * have been generated by serializeHalfParsedText(). The text contents is
6303 * extracted from the array, and its markers are transformed into markers
6304 * appropriate for the current Parser instance. This transformed text is
6305 * returned, and can be safely included in the return value of a parser
6306 * hook.
6307 *
6308 * If the $data array has been stored persistently, the caller should first
6309 * check whether it is still valid, by calling isValidHalfParsedText().
6310 *
6311 * @deprecated since 1.31
6312 * @param array $data Serialized data
6313 * @throws MWException
6314 * @return string
6315 */
6316 public function unserializeHalfParsedText( $data ) {
6317 wfDeprecated( __METHOD__, '1.31' );
6318 if ( !isset( $data['version'] ) || $data['version'] != self::HALF_PARSED_VERSION ) {
6319 throw new MWException( __METHOD__ . ': invalid version' );
6320 }
6321
6322 # First, extract the strip state.
6323 $texts = [ $data['text'] ];
6324 $texts = $this->mStripState->merge( $data['stripState'], $texts );
6325
6326 # Now renumber links
6327 $texts = $this->mLinkHolders->mergeForeign( $data['linkHolders'], $texts );
6328
6329 # Should be good to go.
6330 return $texts[0];
6331 }
6332
6333 /**
6334 * Returns true if the given array, presumed to be generated by
6335 * serializeHalfParsedText(), is compatible with the current version of the
6336 * parser.
6337 *
6338 * @deprecated since 1.31
6339 * @param array $data
6340 *
6341 * @return bool
6342 */
6343 public function isValidHalfParsedText( $data ) {
6344 wfDeprecated( __METHOD__, '1.31' );
6345 return isset( $data['version'] ) && $data['version'] == self::HALF_PARSED_VERSION;
6346 }
6347
6348 /**
6349 * Parsed a width param of imagelink like 300px or 200x300px
6350 *
6351 * @param string $value
6352 * @param bool $parseHeight
6353 *
6354 * @return array
6355 * @since 1.20
6356 */
6357 public static function parseWidthParam( $value, $parseHeight = true ) {
6358 $parsedWidthParam = [];
6359 if ( $value === '' ) {
6360 return $parsedWidthParam;
6361 }
6362 $m = [];
6363 # (T15500) In both cases (width/height and width only),
6364 # permit trailing "px" for backward compatibility.
6365 if ( $parseHeight && preg_match( '/^([0-9]*)x([0-9]*)\s*(?:px)?\s*$/', $value, $m ) ) {
6366 $width = intval( $m[1] );
6367 $height = intval( $m[2] );
6368 $parsedWidthParam['width'] = $width;
6369 $parsedWidthParam['height'] = $height;
6370 } elseif ( preg_match( '/^[0-9]*\s*(?:px)?\s*$/', $value ) ) {
6371 $width = intval( $value );
6372 $parsedWidthParam['width'] = $width;
6373 }
6374 return $parsedWidthParam;
6375 }
6376
6377 /**
6378 * Lock the current instance of the parser.
6379 *
6380 * This is meant to stop someone from calling the parser
6381 * recursively and messing up all the strip state.
6382 *
6383 * @throws MWException If parser is in a parse
6384 * @return ScopedCallback The lock will be released once the return value goes out of scope.
6385 */
6386 protected function lock() {
6387 if ( $this->mInParse ) {
6388 throw new MWException( "Parser state cleared while parsing. "
6389 . "Did you call Parser::parse recursively? Lock is held by: " . $this->mInParse );
6390 }
6391
6392 // Save the backtrace when locking, so that if some code tries locking again,
6393 // we can print the lock owner's backtrace for easier debugging
6394 $e = new Exception;
6395 $this->mInParse = $e->getTraceAsString();
6396
6397 $recursiveCheck = new ScopedCallback( function () {
6398 $this->mInParse = false;
6399 } );
6400
6401 return $recursiveCheck;
6402 }
6403
6404 /**
6405 * Strip outer <p></p> tag from the HTML source of a single paragraph.
6406 *
6407 * Returns original HTML if the <p/> tag has any attributes, if there's no wrapping <p/> tag,
6408 * or if there is more than one <p/> tag in the input HTML.
6409 *
6410 * @param string $html
6411 * @return string
6412 * @since 1.24
6413 */
6414 public static function stripOuterParagraph( $html ) {
6415 $m = [];
6416 if ( preg_match( '/^<p>(.*)\n?<\/p>\n?$/sU', $html, $m ) && strpos( $m[1], '</p>' ) === false ) {
6417 $html = $m[1];
6418 }
6419
6420 return $html;
6421 }
6422
6423 /**
6424 * Return this parser if it is not doing anything, otherwise
6425 * get a fresh parser. You can use this method by doing
6426 * $newParser = $oldParser->getFreshParser(), or more simply
6427 * $oldParser->getFreshParser()->parse( ... );
6428 * if you're unsure if $oldParser is safe to use.
6429 *
6430 * @since 1.24
6431 * @return Parser A parser object that is not parsing anything
6432 */
6433 public function getFreshParser() {
6434 if ( $this->mInParse ) {
6435 return $this->factory->create();
6436 } else {
6437 return $this;
6438 }
6439 }
6440
6441 /**
6442 * Set's up the PHP implementation of OOUI for use in this request
6443 * and instructs OutputPage to enable OOUI for itself.
6444 *
6445 * @since 1.26
6446 */
6447 public function enableOOUI() {
6448 OutputPage::setupOOUI();
6449 $this->mOutput->setEnableOOUI( true );
6450 }
6451 }