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