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