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