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