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