Merge "always order by page_id for dumps of current revisions"
[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 $limitReport .= 'Complications: [' . implode( ', ', $this->mOutput->getAllFlags() ) . "]\n";
682
683 foreach ( $this->mOutput->getLimitReportData() as $key => $value ) {
684 if ( Hooks::run( 'ParserLimitReportFormat',
685 [ $key, &$value, &$limitReport, false, false ]
686 ) ) {
687 $keyMsg = wfMessage( $key )->inLanguage( 'en' )->useDatabase( false );
688 $valueMsg = wfMessage( [ "$key-value-text", "$key-value" ] )
689 ->inLanguage( 'en' )->useDatabase( false );
690 if ( !$valueMsg->exists() ) {
691 $valueMsg = new RawMessage( '$1' );
692 }
693 if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
694 $valueMsg->params( $value );
695 $limitReport .= "{$keyMsg->text()}: {$valueMsg->text()}\n";
696 }
697 }
698 }
699 // Since we're not really outputting HTML, decode the entities and
700 // then re-encode the things that need hiding inside HTML comments.
701 $limitReport = htmlspecialchars_decode( $limitReport );
702
703 // Sanitize for comment. Note '‐' in the replacement is U+2010,
704 // which looks much like the problematic '-'.
705 $limitReport = str_replace( [ '-', '&' ], [ '‐', '&amp;' ], $limitReport );
706 $text = "\n<!-- \n$limitReport-->\n";
707
708 // Add on template profiling data in human/machine readable way
709 $dataByFunc = $this->mProfiler->getFunctionStats();
710 uasort( $dataByFunc, function ( $a, $b ) {
711 return $b['real'] <=> $a['real']; // descending order
712 } );
713 $profileReport = [];
714 foreach ( array_slice( $dataByFunc, 0, 10 ) as $item ) {
715 $profileReport[] = sprintf( "%6.2f%% %8.3f %6d %s",
716 $item['%real'], $item['real'], $item['calls'],
717 htmlspecialchars( $item['name'] ) );
718 }
719 $text .= "<!--\nTransclusion expansion time report (%,ms,calls,template)\n";
720 $text .= implode( "\n", $profileReport ) . "\n-->\n";
721
722 $this->mOutput->setLimitReportData( 'limitreport-timingprofile', $profileReport );
723
724 // Add other cache related metadata
725 if ( $this->svcOptions->get( 'ShowHostnames' ) ) {
726 $this->mOutput->setLimitReportData( 'cachereport-origin', wfHostname() );
727 }
728 $this->mOutput->setLimitReportData( 'cachereport-timestamp',
729 $this->mOutput->getCacheTime() );
730 $this->mOutput->setLimitReportData( 'cachereport-ttl',
731 $this->mOutput->getCacheExpiry() );
732 $this->mOutput->setLimitReportData( 'cachereport-transientcontent',
733 $this->mOutput->hasDynamicContent() );
734
735 if ( $this->mGeneratedPPNodeCount > $this->mOptions->getMaxGeneratedPPNodeCount() / 10 ) {
736 wfDebugLog( 'generated-pp-node-count', $this->mGeneratedPPNodeCount . ' ' .
737 $this->mTitle->getPrefixedDBkey() );
738 }
739 return $text;
740 }
741
742 /**
743 * Half-parse wikitext to half-parsed HTML. This recursive parser entry point
744 * can be called from an extension tag hook.
745 *
746 * The output of this function IS NOT SAFE PARSED HTML; it is "half-parsed"
747 * instead, which means that lists and links have not been fully parsed yet,
748 * and strip markers are still present.
749 *
750 * Use recursiveTagParseFully() to fully parse wikitext to output-safe HTML.
751 *
752 * Use this function if you're a parser tag hook and you want to parse
753 * wikitext before or after applying additional transformations, and you
754 * intend to *return the result as hook output*, which will cause it to go
755 * through the rest of parsing process automatically.
756 *
757 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
758 * $text are not expanded
759 *
760 * @param string $text Text extension wants to have parsed
761 * @param-taint $text escapes_htmlnoent
762 * @param bool|PPFrame $frame The frame to use for expanding any template variables
763 * @return string UNSAFE half-parsed HTML
764 * @return-taint escaped
765 */
766 public function recursiveTagParse( $text, $frame = false ) {
767 // Avoid PHP 7.1 warning from passing $this by reference
768 $parser = $this;
769 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
770 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
771 $text = $this->internalParse( $text, false, $frame );
772 return $text;
773 }
774
775 /**
776 * Fully parse wikitext to fully parsed HTML. This recursive parser entry
777 * point can be called from an extension tag hook.
778 *
779 * The output of this function is fully-parsed HTML that is safe for output.
780 * If you're a parser tag hook, you might want to use recursiveTagParse()
781 * instead.
782 *
783 * If $frame is not provided, then template variables (e.g., {{{1}}}) within
784 * $text are not expanded
785 *
786 * @since 1.25
787 *
788 * @param string $text Text extension wants to have parsed
789 * @param-taint $text escapes_htmlnoent
790 * @param bool|PPFrame $frame The frame to use for expanding any template variables
791 * @return string Fully parsed HTML
792 * @return-taint escaped
793 */
794 public function recursiveTagParseFully( $text, $frame = false ) {
795 $text = $this->recursiveTagParse( $text, $frame );
796 $text = $this->internalParseHalfParsed( $text, false );
797 return $text;
798 }
799
800 /**
801 * Expand templates and variables in the text, producing valid, static wikitext.
802 * Also removes comments.
803 * Do not call this function recursively.
804 * @param string $text
805 * @param Title|null $title
806 * @param ParserOptions $options
807 * @param int|null $revid
808 * @param bool|PPFrame $frame
809 * @return mixed|string
810 */
811 public function preprocess( $text, Title $title = null,
812 ParserOptions $options, $revid = null, $frame = false
813 ) {
814 $magicScopeVariable = $this->lock();
815 $this->startParse( $title, $options, self::OT_PREPROCESS, true );
816 if ( $revid !== null ) {
817 $this->mRevisionId = $revid;
818 }
819 // Avoid PHP 7.1 warning from passing $this by reference
820 $parser = $this;
821 Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
822 Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
823 $text = $this->replaceVariables( $text, $frame );
824 $text = $this->mStripState->unstripBoth( $text );
825 return $text;
826 }
827
828 /**
829 * Recursive parser entry point that can be called from an extension tag
830 * hook.
831 *
832 * @param string $text Text to be expanded
833 * @param bool|PPFrame $frame The frame to use for expanding any template variables
834 * @return string
835 * @since 1.19
836 */
837 public function recursivePreprocess( $text, $frame = false ) {
838 $text = $this->replaceVariables( $text, $frame );
839 $text = $this->mStripState->unstripBoth( $text );
840 return $text;
841 }
842
843 /**
844 * Process the wikitext for the "?preload=" feature. (T7210)
845 *
846 * "<noinclude>", "<includeonly>" etc. are parsed as for template
847 * transclusion, comments, templates, arguments, tags hooks and parser
848 * functions are untouched.
849 *
850 * @param string $text
851 * @param Title $title
852 * @param ParserOptions $options
853 * @param array $params
854 * @return string
855 */
856 public function getPreloadText( $text, Title $title, ParserOptions $options, $params = [] ) {
857 $msg = new RawMessage( $text );
858 $text = $msg->params( $params )->plain();
859
860 # Parser (re)initialisation
861 $magicScopeVariable = $this->lock();
862 $this->startParse( $title, $options, self::OT_PLAIN, true );
863
864 $flags = PPFrame::NO_ARGS | PPFrame::NO_TEMPLATES;
865 $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
866 $text = $this->getPreprocessor()->newFrame()->expand( $dom, $flags );
867 $text = $this->mStripState->unstripBoth( $text );
868 return $text;
869 }
870
871 /**
872 * Set the current user.
873 * Should only be used when doing pre-save transform.
874 *
875 * @param User|null $user User object or null (to reset)
876 */
877 public function setUser( $user ) {
878 $this->mUser = $user;
879 }
880
881 /**
882 * Set the context title
883 *
884 * @param Title $t
885 */
886 public function setTitle( $t ) {
887 if ( !$t ) {
888 $t = Title::newFromText( 'NO TITLE' );
889 }
890
891 if ( $t->hasFragment() ) {
892 # Strip the fragment to avoid various odd effects
893 $this->mTitle = $t->createFragmentTarget( '' );
894 } else {
895 $this->mTitle = $t;
896 }
897 }
898
899 /**
900 * Accessor for the Title object
901 *
902 * @return Title
903 */
904 public function getTitle() {
905 return $this->mTitle;
906 }
907
908 /**
909 * Accessor/mutator for the Title object
910 *
911 * @param Title|null $x Title object or null to just get the current one
912 * @return Title
913 */
914 public function Title( $x = null ) {
915 return wfSetVar( $this->mTitle, $x );
916 }
917
918 /**
919 * Set the output type
920 *
921 * @param int $ot New value
922 */
923 public function setOutputType( $ot ) {
924 $this->mOutputType = $ot;
925 # Shortcut alias
926 $this->ot = [
927 'html' => $ot == self::OT_HTML,
928 'wiki' => $ot == self::OT_WIKI,
929 'pre' => $ot == self::OT_PREPROCESS,
930 'plain' => $ot == self::OT_PLAIN,
931 ];
932 }
933
934 /**
935 * Accessor/mutator for the output type
936 *
937 * @param int|null $x New value or null to just get the current one
938 * @return int
939 */
940 public function OutputType( $x = null ) {
941 return wfSetVar( $this->mOutputType, $x );
942 }
943
944 /**
945 * Get the ParserOutput object
946 *
947 * @return ParserOutput
948 */
949 public function getOutput() {
950 return $this->mOutput;
951 }
952
953 /**
954 * Get the ParserOptions object
955 *
956 * @return ParserOptions
957 */
958 public function getOptions() {
959 return $this->mOptions;
960 }
961
962 /**
963 * Accessor/mutator for the ParserOptions object
964 *
965 * @param ParserOptions|null $x New value or null to just get the current one
966 * @return ParserOptions Current ParserOptions object
967 */
968 public function Options( $x = null ) {
969 return wfSetVar( $this->mOptions, $x );
970 }
971
972 /**
973 * @return int
974 */
975 public function nextLinkID() {
976 return $this->mLinkID++;
977 }
978
979 /**
980 * @param int $id
981 */
982 public function setLinkID( $id ) {
983 $this->mLinkID = $id;
984 }
985
986 /**
987 * Get a language object for use in parser functions such as {{FORMATNUM:}}
988 * @return Language
989 */
990 public function getFunctionLang() {
991 return $this->getTargetLanguage();
992 }
993
994 /**
995 * Get the target language for the content being parsed. This is usually the
996 * language that the content is in.
997 *
998 * @since 1.19
999 *
1000 * @throws MWException
1001 * @return Language
1002 */
1003 public function getTargetLanguage() {
1004 $target = $this->mOptions->getTargetLanguage();
1005
1006 if ( $target !== null ) {
1007 return $target;
1008 } elseif ( $this->mOptions->getInterfaceMessage() ) {
1009 return $this->mOptions->getUserLangObj();
1010 } elseif ( is_null( $this->mTitle ) ) {
1011 throw new MWException( __METHOD__ . ': $this->mTitle is null' );
1012 }
1013
1014 return $this->mTitle->getPageLanguage();
1015 }
1016
1017 /**
1018 * Get the language object for language conversion
1019 * @deprecated since 1.32, just use getTargetLanguage()
1020 * @return Language|null
1021 */
1022 public function getConverterLanguage() {
1023 return $this->getTargetLanguage();
1024 }
1025
1026 /**
1027 * Get a User object either from $this->mUser, if set, or from the
1028 * ParserOptions object otherwise
1029 *
1030 * @return User
1031 */
1032 public function getUser() {
1033 if ( !is_null( $this->mUser ) ) {
1034 return $this->mUser;
1035 }
1036 return $this->mOptions->getUser();
1037 }
1038
1039 /**
1040 * Get a preprocessor object
1041 *
1042 * @return Preprocessor
1043 */
1044 public function getPreprocessor() {
1045 if ( !isset( $this->mPreprocessor ) ) {
1046 $class = $this->svcOptions->get( 'preprocessorClass' );
1047 $this->mPreprocessor = new $class( $this );
1048 }
1049 return $this->mPreprocessor;
1050 }
1051
1052 /**
1053 * Get a LinkRenderer instance to make links with
1054 *
1055 * @since 1.28
1056 * @return LinkRenderer
1057 */
1058 public function getLinkRenderer() {
1059 // XXX We make the LinkRenderer with current options and then cache it forever
1060 if ( !$this->mLinkRenderer ) {
1061 $this->mLinkRenderer = $this->linkRendererFactory->create();
1062 $this->mLinkRenderer->setStubThreshold(
1063 $this->getOptions()->getStubThreshold()
1064 );
1065 }
1066
1067 return $this->mLinkRenderer;
1068 }
1069
1070 /**
1071 * Get the MagicWordFactory that this Parser is using
1072 *
1073 * @since 1.32
1074 * @return MagicWordFactory
1075 */
1076 public function getMagicWordFactory() {
1077 return $this->magicWordFactory;
1078 }
1079
1080 /**
1081 * Get the content language that this Parser is using
1082 *
1083 * @since 1.32
1084 * @return Language
1085 */
1086 public function getContentLanguage() {
1087 return $this->contLang;
1088 }
1089
1090 /**
1091 * Replaces all occurrences of HTML-style comments and the given tags
1092 * in the text with a random marker and returns the next text. The output
1093 * parameter $matches will be an associative array filled with data in
1094 * the form:
1095 *
1096 * @code
1097 * 'UNIQ-xxxxx' => [
1098 * 'element',
1099 * 'tag content',
1100 * [ 'param' => 'x' ],
1101 * '<element param="x">tag content</element>' ]
1102 * @endcode
1103 *
1104 * @param array $elements List of element names. Comments are always extracted.
1105 * @param string $text Source text string.
1106 * @param array &$matches Out parameter, Array: extracted tags
1107 * @return string Stripped text
1108 */
1109 public static function extractTagsAndParams( $elements, $text, &$matches ) {
1110 static $n = 1;
1111 $stripped = '';
1112 $matches = [];
1113
1114 $taglist = implode( '|', $elements );
1115 $start = "/<($taglist)(\\s+[^>]*?|\\s*?)(\/?>)|<(!--)/i";
1116
1117 while ( $text != '' ) {
1118 $p = preg_split( $start, $text, 2, PREG_SPLIT_DELIM_CAPTURE );
1119 $stripped .= $p[0];
1120 if ( count( $p ) < 5 ) {
1121 break;
1122 }
1123 if ( count( $p ) > 5 ) {
1124 # comment
1125 $element = $p[4];
1126 $attributes = '';
1127 $close = '';
1128 $inside = $p[5];
1129 } else {
1130 # tag
1131 list( , $element, $attributes, $close, $inside ) = $p;
1132 }
1133
1134 $marker = self::MARKER_PREFIX . "-$element-" . sprintf( '%08X', $n++ ) . self::MARKER_SUFFIX;
1135 $stripped .= $marker;
1136
1137 if ( $close === '/>' ) {
1138 # Empty element tag, <tag />
1139 $content = null;
1140 $text = $inside;
1141 $tail = null;
1142 } else {
1143 if ( $element === '!--' ) {
1144 $end = '/(-->)/';
1145 } else {
1146 $end = "/(<\\/$element\\s*>)/i";
1147 }
1148 $q = preg_split( $end, $inside, 2, PREG_SPLIT_DELIM_CAPTURE );
1149 $content = $q[0];
1150 if ( count( $q ) < 3 ) {
1151 # No end tag -- let it run out to the end of the text.
1152 $tail = '';
1153 $text = '';
1154 } else {
1155 list( , $tail, $text ) = $q;
1156 }
1157 }
1158
1159 $matches[$marker] = [ $element,
1160 $content,
1161 Sanitizer::decodeTagAttributes( $attributes ),
1162 "<$element$attributes$close$content$tail" ];
1163 }
1164 return $stripped;
1165 }
1166
1167 /**
1168 * Get a list of strippable XML-like elements
1169 *
1170 * @return array
1171 */
1172 public function getStripList() {
1173 return $this->mStripList;
1174 }
1175
1176 /**
1177 * Add an item to the strip state
1178 * Returns the unique tag which must be inserted into the stripped text
1179 * The tag will be replaced with the original text in unstrip()
1180 *
1181 * @param string $text
1182 *
1183 * @return string
1184 */
1185 public function insertStripItem( $text ) {
1186 $marker = self::MARKER_PREFIX . "-item-{$this->mMarkerIndex}-" . self::MARKER_SUFFIX;
1187 $this->mMarkerIndex++;
1188 $this->mStripState->addGeneral( $marker, $text );
1189 return $marker;
1190 }
1191
1192 /**
1193 * parse the wiki syntax used to render tables
1194 *
1195 * @private
1196 * @param string $text
1197 * @return string
1198 */
1199 public function doTableStuff( $text ) {
1200 $lines = StringUtils::explode( "\n", $text );
1201 $out = '';
1202 $td_history = []; # Is currently a td tag open?
1203 $last_tag_history = []; # Save history of last lag activated (td, th or caption)
1204 $tr_history = []; # Is currently a tr tag open?
1205 $tr_attributes = []; # history of tr attributes
1206 $has_opened_tr = []; # Did this table open a <tr> element?
1207 $indent_level = 0; # indent level of the table
1208
1209 foreach ( $lines as $outLine ) {
1210 $line = trim( $outLine );
1211
1212 if ( $line === '' ) { # empty line, go to next line
1213 $out .= $outLine . "\n";
1214 continue;
1215 }
1216
1217 $first_character = $line[0];
1218 $first_two = substr( $line, 0, 2 );
1219 $matches = [];
1220
1221 if ( preg_match( '/^(:*)\s*\{\|(.*)$/', $line, $matches ) ) {
1222 # First check if we are starting a new table
1223 $indent_level = strlen( $matches[1] );
1224
1225 $attributes = $this->mStripState->unstripBoth( $matches[2] );
1226 $attributes = Sanitizer::fixTagAttributes( $attributes, 'table' );
1227
1228 $outLine = str_repeat( '<dl><dd>', $indent_level ) . "<table{$attributes}>";
1229 array_push( $td_history, false );
1230 array_push( $last_tag_history, '' );
1231 array_push( $tr_history, false );
1232 array_push( $tr_attributes, '' );
1233 array_push( $has_opened_tr, false );
1234 } elseif ( count( $td_history ) == 0 ) {
1235 # Don't do any of the following
1236 $out .= $outLine . "\n";
1237 continue;
1238 } elseif ( $first_two === '|}' ) {
1239 # We are ending a table
1240 $line = '</table>' . substr( $line, 2 );
1241 $last_tag = array_pop( $last_tag_history );
1242
1243 if ( !array_pop( $has_opened_tr ) ) {
1244 $line = "<tr><td></td></tr>{$line}";
1245 }
1246
1247 if ( array_pop( $tr_history ) ) {
1248 $line = "</tr>{$line}";
1249 }
1250
1251 if ( array_pop( $td_history ) ) {
1252 $line = "</{$last_tag}>{$line}";
1253 }
1254 array_pop( $tr_attributes );
1255 if ( $indent_level > 0 ) {
1256 $outLine = rtrim( $line ) . str_repeat( '</dd></dl>', $indent_level );
1257 } else {
1258 $outLine = $line;
1259 }
1260 } elseif ( $first_two === '|-' ) {
1261 # Now we have a table row
1262 $line = preg_replace( '#^\|-+#', '', $line );
1263
1264 # Whats after the tag is now only attributes
1265 $attributes = $this->mStripState->unstripBoth( $line );
1266 $attributes = Sanitizer::fixTagAttributes( $attributes, 'tr' );
1267 array_pop( $tr_attributes );
1268 array_push( $tr_attributes, $attributes );
1269
1270 $line = '';
1271 $last_tag = array_pop( $last_tag_history );
1272 array_pop( $has_opened_tr );
1273 array_push( $has_opened_tr, true );
1274
1275 if ( array_pop( $tr_history ) ) {
1276 $line = '</tr>';
1277 }
1278
1279 if ( array_pop( $td_history ) ) {
1280 $line = "</{$last_tag}>{$line}";
1281 }
1282
1283 $outLine = $line;
1284 array_push( $tr_history, false );
1285 array_push( $td_history, false );
1286 array_push( $last_tag_history, '' );
1287 } elseif ( $first_character === '|'
1288 || $first_character === '!'
1289 || $first_two === '|+'
1290 ) {
1291 # This might be cell elements, td, th or captions
1292 if ( $first_two === '|+' ) {
1293 $first_character = '+';
1294 $line = substr( $line, 2 );
1295 } else {
1296 $line = substr( $line, 1 );
1297 }
1298
1299 // Implies both are valid for table headings.
1300 if ( $first_character === '!' ) {
1301 $line = StringUtils::replaceMarkup( '!!', '||', $line );
1302 }
1303
1304 # Split up multiple cells on the same line.
1305 # FIXME : This can result in improper nesting of tags processed
1306 # by earlier parser steps.
1307 $cells = explode( '||', $line );
1308
1309 $outLine = '';
1310
1311 # Loop through each table cell
1312 foreach ( $cells as $cell ) {
1313 $previous = '';
1314 if ( $first_character !== '+' ) {
1315 $tr_after = array_pop( $tr_attributes );
1316 if ( !array_pop( $tr_history ) ) {
1317 $previous = "<tr{$tr_after}>\n";
1318 }
1319 array_push( $tr_history, true );
1320 array_push( $tr_attributes, '' );
1321 array_pop( $has_opened_tr );
1322 array_push( $has_opened_tr, true );
1323 }
1324
1325 $last_tag = array_pop( $last_tag_history );
1326
1327 if ( array_pop( $td_history ) ) {
1328 $previous = "</{$last_tag}>\n{$previous}";
1329 }
1330
1331 if ( $first_character === '|' ) {
1332 $last_tag = 'td';
1333 } elseif ( $first_character === '!' ) {
1334 $last_tag = 'th';
1335 } elseif ( $first_character === '+' ) {
1336 $last_tag = 'caption';
1337 } else {
1338 $last_tag = '';
1339 }
1340
1341 array_push( $last_tag_history, $last_tag );
1342
1343 # A cell could contain both parameters and data
1344 $cell_data = explode( '|', $cell, 2 );
1345
1346 # T2553: Note that a '|' inside an invalid link should not
1347 # be mistaken as delimiting cell parameters
1348 # Bug T153140: Neither should language converter markup.
1349 if ( preg_match( '/\[\[|-\{/', $cell_data[0] ) === 1 ) {
1350 $cell = "{$previous}<{$last_tag}>" . trim( $cell );
1351 } elseif ( count( $cell_data ) == 1 ) {
1352 // Whitespace in cells is trimmed
1353 $cell = "{$previous}<{$last_tag}>" . trim( $cell_data[0] );
1354 } else {
1355 $attributes = $this->mStripState->unstripBoth( $cell_data[0] );
1356 $attributes = Sanitizer::fixTagAttributes( $attributes, $last_tag );
1357 // Whitespace in cells is trimmed
1358 $cell = "{$previous}<{$last_tag}{$attributes}>" . trim( $cell_data[1] );
1359 }
1360
1361 $outLine .= $cell;
1362 array_push( $td_history, true );
1363 }
1364 }
1365 $out .= $outLine . "\n";
1366 }
1367
1368 # Closing open td, tr && table
1369 while ( count( $td_history ) > 0 ) {
1370 if ( array_pop( $td_history ) ) {
1371 $out .= "</td>\n";
1372 }
1373 if ( array_pop( $tr_history ) ) {
1374 $out .= "</tr>\n";
1375 }
1376 if ( !array_pop( $has_opened_tr ) ) {
1377 $out .= "<tr><td></td></tr>\n";
1378 }
1379
1380 $out .= "</table>\n";
1381 }
1382
1383 # Remove trailing line-ending (b/c)
1384 if ( substr( $out, -1 ) === "\n" ) {
1385 $out = substr( $out, 0, -1 );
1386 }
1387
1388 # special case: don't return empty table
1389 if ( $out === "<table>\n<tr><td></td></tr>\n</table>" ) {
1390 $out = '';
1391 }
1392
1393 return $out;
1394 }
1395
1396 /**
1397 * Helper function for parse() that transforms wiki markup into half-parsed
1398 * HTML. Only called for $mOutputType == self::OT_HTML.
1399 *
1400 * @private
1401 *
1402 * @param string $text The text to parse
1403 * @param-taint $text escapes_html
1404 * @param bool $isMain Whether this is being called from the main parse() function
1405 * @param PPFrame|bool $frame A pre-processor frame
1406 *
1407 * @return string
1408 */
1409 public function internalParse( $text, $isMain = true, $frame = false ) {
1410 $origText = $text;
1411
1412 // Avoid PHP 7.1 warning from passing $this by reference
1413 $parser = $this;
1414
1415 # Hook to suspend the parser in this state
1416 if ( !Hooks::run( 'ParserBeforeInternalParse', [ &$parser, &$text, &$this->mStripState ] ) ) {
1417 return $text;
1418 }
1419
1420 # if $frame is provided, then use $frame for replacing any variables
1421 if ( $frame ) {
1422 # use frame depth to infer how include/noinclude tags should be handled
1423 # depth=0 means this is the top-level document; otherwise it's an included document
1424 if ( !$frame->depth ) {
1425 $flag = 0;
1426 } else {
1427 $flag = self::PTD_FOR_INCLUSION;
1428 }
1429 $dom = $this->preprocessToDom( $text, $flag );
1430 $text = $frame->expand( $dom );
1431 } else {
1432 # if $frame is not provided, then use old-style replaceVariables
1433 $text = $this->replaceVariables( $text );
1434 }
1435
1436 Hooks::run( 'InternalParseBeforeSanitize', [ &$parser, &$text, &$this->mStripState ] );
1437 $text = Sanitizer::removeHTMLtags(
1438 $text,
1439 [ $this, 'attributeStripCallback' ],
1440 false,
1441 array_keys( $this->mTransparentTagHooks ),
1442 [],
1443 [ $this, 'addTrackingCategory' ]
1444 );
1445 Hooks::run( 'InternalParseBeforeLinks', [ &$parser, &$text, &$this->mStripState ] );
1446
1447 # Tables need to come after variable replacement for things to work
1448 # properly; putting them before other transformations should keep
1449 # exciting things like link expansions from showing up in surprising
1450 # places.
1451 $text = $this->doTableStuff( $text );
1452
1453 $text = preg_replace( '/(^|\n)-----*/', '\\1<hr />', $text );
1454
1455 $text = $this->doDoubleUnderscore( $text );
1456
1457 $text = $this->doHeadings( $text );
1458 $text = $this->replaceInternalLinks( $text );
1459 $text = $this->doAllQuotes( $text );
1460 $text = $this->replaceExternalLinks( $text );
1461
1462 # replaceInternalLinks may sometimes leave behind
1463 # absolute URLs, which have to be masked to hide them from replaceExternalLinks
1464 $text = str_replace( self::MARKER_PREFIX . 'NOPARSE', '', $text );
1465
1466 $text = $this->doMagicLinks( $text );
1467 $text = $this->formatHeadings( $text, $origText, $isMain );
1468
1469 return $text;
1470 }
1471
1472 /**
1473 * Helper function for parse() that transforms half-parsed HTML into fully
1474 * parsed HTML.
1475 *
1476 * @param string $text
1477 * @param bool $isMain
1478 * @param bool $linestart
1479 * @return string
1480 */
1481 private function internalParseHalfParsed( $text, $isMain = true, $linestart = true ) {
1482 $text = $this->mStripState->unstripGeneral( $text );
1483
1484 // Avoid PHP 7.1 warning from passing $this by reference
1485 $parser = $this;
1486
1487 if ( $isMain ) {
1488 Hooks::run( 'ParserAfterUnstrip', [ &$parser, &$text ] );
1489 }
1490
1491 # Clean up special characters, only run once, next-to-last before doBlockLevels
1492 $text = Sanitizer::armorFrenchSpaces( $text );
1493
1494 $text = $this->doBlockLevels( $text, $linestart );
1495
1496 $this->replaceLinkHolders( $text );
1497
1498 /**
1499 * The input doesn't get language converted if
1500 * a) It's disabled
1501 * b) Content isn't converted
1502 * c) It's a conversion table
1503 * d) it is an interface message (which is in the user language)
1504 */
1505 if ( !( $this->mOptions->getDisableContentConversion()
1506 || isset( $this->mDoubleUnderscores['nocontentconvert'] ) )
1507 && !$this->mOptions->getInterfaceMessage()
1508 ) {
1509 # The position of the convert() call should not be changed. it
1510 # assumes that the links are all replaced and the only thing left
1511 # is the <nowiki> mark.
1512 $text = $this->getTargetLanguage()->convert( $text );
1513 }
1514
1515 $text = $this->mStripState->unstripNoWiki( $text );
1516
1517 if ( $isMain ) {
1518 Hooks::run( 'ParserBeforeTidy', [ &$parser, &$text ] );
1519 }
1520
1521 $text = $this->replaceTransparentTags( $text );
1522 $text = $this->mStripState->unstripGeneral( $text );
1523
1524 $text = Sanitizer::normalizeCharReferences( $text );
1525
1526 if ( MWTidy::isEnabled() ) {
1527 if ( $this->mOptions->getTidy() ) {
1528 $text = MWTidy::tidy( $text );
1529 }
1530 } else {
1531 # attempt to sanitize at least some nesting problems
1532 # (T4702 and quite a few others)
1533 # This code path is buggy and deprecated!
1534 wfDeprecated( 'disabling tidy', '1.33' );
1535 $tidyregs = [
1536 # ''Something [http://www.cool.com cool''] -->
1537 # <i>Something</i><a href="http://www.cool.com"..><i>cool></i></a>
1538 '/(<([bi])>)(<([bi])>)?([^<]*)(<\/?a[^<]*>)([^<]*)(<\/\\4>)?(<\/\\2>)/' =>
1539 '\\1\\3\\5\\8\\9\\6\\1\\3\\7\\8\\9',
1540 # fix up an anchor inside another anchor, only
1541 # at least for a single single nested link (T5695)
1542 '/(<a[^>]+>)([^<]*)(<a[^>]+>[^<]*)<\/a>(.*)<\/a>/' =>
1543 '\\1\\2</a>\\3</a>\\1\\4</a>',
1544 # fix div inside inline elements- doBlockLevels won't wrap a line which
1545 # contains a div, so fix it up here; replace
1546 # div with escaped text
1547 '/(<([aib]) [^>]+>)([^<]*)(<div([^>]*)>)(.*)(<\/div>)([^<]*)(<\/\\2>)/' =>
1548 '\\1\\3&lt;div\\5&gt;\\6&lt;/div&gt;\\8\\9',
1549 # remove empty italic or bold tag pairs, some
1550 # introduced by rules above
1551 '/<([bi])><\/\\1>/' => '',
1552 ];
1553
1554 $text = preg_replace(
1555 array_keys( $tidyregs ),
1556 array_values( $tidyregs ),
1557 $text );
1558 }
1559
1560 if ( $isMain ) {
1561 Hooks::run( 'ParserAfterTidy', [ &$parser, &$text ] );
1562 }
1563
1564 return $text;
1565 }
1566
1567 /**
1568 * Replace special strings like "ISBN xxx" and "RFC xxx" with
1569 * magic external links.
1570 *
1571 * DML
1572 * @private
1573 *
1574 * @param string $text
1575 *
1576 * @return string
1577 */
1578 public function doMagicLinks( $text ) {
1579 $prots = wfUrlProtocolsWithoutProtRel();
1580 $urlChar = self::EXT_LINK_URL_CLASS;
1581 $addr = self::EXT_LINK_ADDR;
1582 $space = self::SPACE_NOT_NL; # non-newline space
1583 $spdash = "(?:-|$space)"; # a dash or a non-newline space
1584 $spaces = "$space++"; # possessive match of 1 or more spaces
1585 $text = preg_replace_callback(
1586 '!(?: # Start cases
1587 (<a[ \t\r\n>].*?</a>) | # m[1]: Skip link text
1588 (<.*?>) | # m[2]: Skip stuff inside HTML elements' . "
1589 (\b # m[3]: Free external links
1590 (?i:$prots)
1591 ($addr$urlChar*) # m[4]: Post-protocol path
1592 ) |
1593 \b(?:RFC|PMID) $spaces # m[5]: RFC or PMID, capture number
1594 ([0-9]+)\b |
1595 \bISBN $spaces ( # m[6]: ISBN, capture number
1596 (?: 97[89] $spdash? )? # optional 13-digit ISBN prefix
1597 (?: [0-9] $spdash? ){9} # 9 digits with opt. delimiters
1598 [0-9Xx] # check digit
1599 )\b
1600 )!xu", [ $this, 'magicLinkCallback' ], $text );
1601 return $text;
1602 }
1603
1604 /**
1605 * @throws MWException
1606 * @param array $m
1607 * @return string HTML
1608 */
1609 public function magicLinkCallback( $m ) {
1610 if ( isset( $m[1] ) && $m[1] !== '' ) {
1611 # Skip anchor
1612 return $m[0];
1613 } elseif ( isset( $m[2] ) && $m[2] !== '' ) {
1614 # Skip HTML element
1615 return $m[0];
1616 } elseif ( isset( $m[3] ) && $m[3] !== '' ) {
1617 # Free external link
1618 return $this->makeFreeExternalLink( $m[0], strlen( $m[4] ) );
1619 } elseif ( isset( $m[5] ) && $m[5] !== '' ) {
1620 # RFC or PMID
1621 if ( substr( $m[0], 0, 3 ) === 'RFC' ) {
1622 if ( !$this->mOptions->getMagicRFCLinks() ) {
1623 return $m[0];
1624 }
1625 $keyword = 'RFC';
1626 $urlmsg = 'rfcurl';
1627 $cssClass = 'mw-magiclink-rfc';
1628 $trackingCat = 'magiclink-tracking-rfc';
1629 $id = $m[5];
1630 } elseif ( substr( $m[0], 0, 4 ) === 'PMID' ) {
1631 if ( !$this->mOptions->getMagicPMIDLinks() ) {
1632 return $m[0];
1633 }
1634 $keyword = 'PMID';
1635 $urlmsg = 'pubmedurl';
1636 $cssClass = 'mw-magiclink-pmid';
1637 $trackingCat = 'magiclink-tracking-pmid';
1638 $id = $m[5];
1639 } else {
1640 throw new MWException( __METHOD__ . ': unrecognised match type "' .
1641 substr( $m[0], 0, 20 ) . '"' );
1642 }
1643 $url = wfMessage( $urlmsg, $id )->inContentLanguage()->text();
1644 $this->addTrackingCategory( $trackingCat );
1645 return Linker::makeExternalLink( $url, "{$keyword} {$id}", true, $cssClass, [], $this->mTitle );
1646 } elseif ( isset( $m[6] ) && $m[6] !== ''
1647 && $this->mOptions->getMagicISBNLinks()
1648 ) {
1649 # ISBN
1650 $isbn = $m[6];
1651 $space = self::SPACE_NOT_NL; # non-newline space
1652 $isbn = preg_replace( "/$space/", ' ', $isbn );
1653 $num = strtr( $isbn, [
1654 '-' => '',
1655 ' ' => '',
1656 'x' => 'X',
1657 ] );
1658 $this->addTrackingCategory( 'magiclink-tracking-isbn' );
1659 return $this->getLinkRenderer()->makeKnownLink(
1660 SpecialPage::getTitleFor( 'Booksources', $num ),
1661 "ISBN $isbn",
1662 [
1663 'class' => 'internal mw-magiclink-isbn',
1664 'title' => false // suppress title attribute
1665 ]
1666 );
1667 } else {
1668 return $m[0];
1669 }
1670 }
1671
1672 /**
1673 * Make a free external link, given a user-supplied URL
1674 *
1675 * @param string $url
1676 * @param int $numPostProto
1677 * The number of characters after the protocol.
1678 * @return string HTML
1679 * @private
1680 */
1681 public function makeFreeExternalLink( $url, $numPostProto ) {
1682 $trail = '';
1683
1684 # The characters '<' and '>' (which were escaped by
1685 # removeHTMLtags()) should not be included in
1686 # URLs, per RFC 2396.
1687 # Make &nbsp; terminate a URL as well (bug T84937)
1688 $m2 = [];
1689 if ( preg_match(
1690 '/&(lt|gt|nbsp|#x0*(3[CcEe]|[Aa]0)|#0*(60|62|160));/',
1691 $url,
1692 $m2,
1693 PREG_OFFSET_CAPTURE
1694 ) ) {
1695 $trail = substr( $url, $m2[0][1] ) . $trail;
1696 $url = substr( $url, 0, $m2[0][1] );
1697 }
1698
1699 # Move trailing punctuation to $trail
1700 $sep = ',;\.:!?';
1701 # If there is no left bracket, then consider right brackets fair game too
1702 if ( strpos( $url, '(' ) === false ) {
1703 $sep .= ')';
1704 }
1705
1706 $urlRev = strrev( $url );
1707 $numSepChars = strspn( $urlRev, $sep );
1708 # Don't break a trailing HTML entity by moving the ; into $trail
1709 # This is in hot code, so use substr_compare to avoid having to
1710 # create a new string object for the comparison
1711 if ( $numSepChars && substr_compare( $url, ";", -$numSepChars, 1 ) === 0 ) {
1712 # more optimization: instead of running preg_match with a $
1713 # anchor, which can be slow, do the match on the reversed
1714 # string starting at the desired offset.
1715 # un-reversed regexp is: /&([a-z]+|#x[\da-f]+|#\d+)$/i
1716 if ( preg_match( '/\G([a-z]+|[\da-f]+x#|\d+#)&/i', $urlRev, $m2, 0, $numSepChars ) ) {
1717 $numSepChars--;
1718 }
1719 }
1720 if ( $numSepChars ) {
1721 $trail = substr( $url, -$numSepChars ) . $trail;
1722 $url = substr( $url, 0, -$numSepChars );
1723 }
1724
1725 # Verify that we still have a real URL after trail removal, and
1726 # not just lone protocol
1727 if ( strlen( $trail ) >= $numPostProto ) {
1728 return $url . $trail;
1729 }
1730
1731 $url = Sanitizer::cleanUrl( $url );
1732
1733 # Is this an external image?
1734 $text = $this->maybeMakeExternalImage( $url );
1735 if ( $text === false ) {
1736 # Not an image, make a link
1737 $text = Linker::makeExternalLink( $url,
1738 $this->getTargetLanguage()->getConverter()->markNoConversion( $url ),
1739 true, 'free',
1740 $this->getExternalLinkAttribs( $url ), $this->mTitle );
1741 # Register it in the output object...
1742 $this->mOutput->addExternalLink( $url );
1743 }
1744 return $text . $trail;
1745 }
1746
1747 /**
1748 * Parse headers and return html
1749 *
1750 * @private
1751 *
1752 * @param string $text
1753 *
1754 * @return string
1755 */
1756 public function doHeadings( $text ) {
1757 for ( $i = 6; $i >= 1; --$i ) {
1758 $h = str_repeat( '=', $i );
1759 // Trim non-newline whitespace from headings
1760 // Using \s* will break for: "==\n===\n" and parse as <h2>=</h2>
1761 $text = preg_replace( "/^(?:$h)[ \\t]*(.+?)[ \\t]*(?:$h)\\s*$/m", "<h$i>\\1</h$i>", $text );
1762 }
1763 return $text;
1764 }
1765
1766 /**
1767 * Replace single quotes with HTML markup
1768 * @private
1769 *
1770 * @param string $text
1771 *
1772 * @return string The altered text
1773 */
1774 public function doAllQuotes( $text ) {
1775 $outtext = '';
1776 $lines = StringUtils::explode( "\n", $text );
1777 foreach ( $lines as $line ) {
1778 $outtext .= $this->doQuotes( $line ) . "\n";
1779 }
1780 $outtext = substr( $outtext, 0, -1 );
1781 return $outtext;
1782 }
1783
1784 /**
1785 * Helper function for doAllQuotes()
1786 *
1787 * @param string $text
1788 *
1789 * @return string
1790 */
1791 public function doQuotes( $text ) {
1792 $arr = preg_split( "/(''+)/", $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1793 $countarr = count( $arr );
1794 if ( $countarr == 1 ) {
1795 return $text;
1796 }
1797
1798 // First, do some preliminary work. This may shift some apostrophes from
1799 // being mark-up to being text. It also counts the number of occurrences
1800 // of bold and italics mark-ups.
1801 $numbold = 0;
1802 $numitalics = 0;
1803 for ( $i = 1; $i < $countarr; $i += 2 ) {
1804 $thislen = strlen( $arr[$i] );
1805 // If there are ever four apostrophes, assume the first is supposed to
1806 // be text, and the remaining three constitute mark-up for bold text.
1807 // (T15227: ''''foo'''' turns into ' ''' foo ' ''')
1808 if ( $thislen == 4 ) {
1809 $arr[$i - 1] .= "'";
1810 $arr[$i] = "'''";
1811 $thislen = 3;
1812 } elseif ( $thislen > 5 ) {
1813 // If there are more than 5 apostrophes in a row, assume they're all
1814 // text except for the last 5.
1815 // (T15227: ''''''foo'''''' turns into ' ''''' foo ' ''''')
1816 $arr[$i - 1] .= str_repeat( "'", $thislen - 5 );
1817 $arr[$i] = "'''''";
1818 $thislen = 5;
1819 }
1820 // Count the number of occurrences of bold and italics mark-ups.
1821 if ( $thislen == 2 ) {
1822 $numitalics++;
1823 } elseif ( $thislen == 3 ) {
1824 $numbold++;
1825 } elseif ( $thislen == 5 ) {
1826 $numitalics++;
1827 $numbold++;
1828 }
1829 }
1830
1831 // If there is an odd number of both bold and italics, it is likely
1832 // that one of the bold ones was meant to be an apostrophe followed
1833 // by italics. Which one we cannot know for certain, but it is more
1834 // likely to be one that has a single-letter word before it.
1835 if ( ( $numbold % 2 == 1 ) && ( $numitalics % 2 == 1 ) ) {
1836 $firstsingleletterword = -1;
1837 $firstmultiletterword = -1;
1838 $firstspace = -1;
1839 for ( $i = 1; $i < $countarr; $i += 2 ) {
1840 if ( strlen( $arr[$i] ) == 3 ) {
1841 $x1 = substr( $arr[$i - 1], -1 );
1842 $x2 = substr( $arr[$i - 1], -2, 1 );
1843 if ( $x1 === ' ' ) {
1844 if ( $firstspace == -1 ) {
1845 $firstspace = $i;
1846 }
1847 } elseif ( $x2 === ' ' ) {
1848 $firstsingleletterword = $i;
1849 // if $firstsingleletterword is set, we don't
1850 // look at the other options, so we can bail early.
1851 break;
1852 } elseif ( $firstmultiletterword == -1 ) {
1853 $firstmultiletterword = $i;
1854 }
1855 }
1856 }
1857
1858 // If there is a single-letter word, use it!
1859 if ( $firstsingleletterword > -1 ) {
1860 $arr[$firstsingleletterword] = "''";
1861 $arr[$firstsingleletterword - 1] .= "'";
1862 } elseif ( $firstmultiletterword > -1 ) {
1863 // If not, but there's a multi-letter word, use that one.
1864 $arr[$firstmultiletterword] = "''";
1865 $arr[$firstmultiletterword - 1] .= "'";
1866 } elseif ( $firstspace > -1 ) {
1867 // ... otherwise use the first one that has neither.
1868 // (notice that it is possible for all three to be -1 if, for example,
1869 // there is only one pentuple-apostrophe in the line)
1870 $arr[$firstspace] = "''";
1871 $arr[$firstspace - 1] .= "'";
1872 }
1873 }
1874
1875 // Now let's actually convert our apostrophic mush to HTML!
1876 $output = '';
1877 $buffer = '';
1878 $state = '';
1879 $i = 0;
1880 foreach ( $arr as $r ) {
1881 if ( ( $i % 2 ) == 0 ) {
1882 if ( $state === 'both' ) {
1883 $buffer .= $r;
1884 } else {
1885 $output .= $r;
1886 }
1887 } else {
1888 $thislen = strlen( $r );
1889 if ( $thislen == 2 ) {
1890 if ( $state === 'i' ) {
1891 $output .= '</i>';
1892 $state = '';
1893 } elseif ( $state === 'bi' ) {
1894 $output .= '</i>';
1895 $state = 'b';
1896 } elseif ( $state === 'ib' ) {
1897 $output .= '</b></i><b>';
1898 $state = 'b';
1899 } elseif ( $state === 'both' ) {
1900 $output .= '<b><i>' . $buffer . '</i>';
1901 $state = 'b';
1902 } else { // $state can be 'b' or ''
1903 $output .= '<i>';
1904 $state .= 'i';
1905 }
1906 } elseif ( $thislen == 3 ) {
1907 if ( $state === 'b' ) {
1908 $output .= '</b>';
1909 $state = '';
1910 } elseif ( $state === 'bi' ) {
1911 $output .= '</i></b><i>';
1912 $state = 'i';
1913 } elseif ( $state === 'ib' ) {
1914 $output .= '</b>';
1915 $state = 'i';
1916 } elseif ( $state === 'both' ) {
1917 $output .= '<i><b>' . $buffer . '</b>';
1918 $state = 'i';
1919 } else { // $state can be 'i' or ''
1920 $output .= '<b>';
1921 $state .= 'b';
1922 }
1923 } elseif ( $thislen == 5 ) {
1924 if ( $state === 'b' ) {
1925 $output .= '</b><i>';
1926 $state = 'i';
1927 } elseif ( $state === 'i' ) {
1928 $output .= '</i><b>';
1929 $state = 'b';
1930 } elseif ( $state === 'bi' ) {
1931 $output .= '</i></b>';
1932 $state = '';
1933 } elseif ( $state === 'ib' ) {
1934 $output .= '</b></i>';
1935 $state = '';
1936 } elseif ( $state === 'both' ) {
1937 $output .= '<i><b>' . $buffer . '</b></i>';
1938 $state = '';
1939 } else { // ($state == '')
1940 $buffer = '';
1941 $state = 'both';
1942 }
1943 }
1944 }
1945 $i++;
1946 }
1947 // Now close all remaining tags. Notice that the order is important.
1948 if ( $state === 'b' || $state === 'ib' ) {
1949 $output .= '</b>';
1950 }
1951 if ( $state === 'i' || $state === 'bi' || $state === 'ib' ) {
1952 $output .= '</i>';
1953 }
1954 if ( $state === 'bi' ) {
1955 $output .= '</b>';
1956 }
1957 // There might be lonely ''''', so make sure we have a buffer
1958 if ( $state === 'both' && $buffer ) {
1959 $output .= '<b><i>' . $buffer . '</i></b>';
1960 }
1961 return $output;
1962 }
1963
1964 /**
1965 * Replace external links (REL)
1966 *
1967 * Note: this is all very hackish and the order of execution matters a lot.
1968 * Make sure to run tests/parser/parserTests.php if you change this code.
1969 *
1970 * @private
1971 *
1972 * @param string $text
1973 *
1974 * @throws MWException
1975 * @return string
1976 */
1977 public function replaceExternalLinks( $text ) {
1978 $bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE );
1979 if ( $bits === false ) {
1980 throw new MWException( "PCRE needs to be compiled with "
1981 . "--enable-unicode-properties in order for MediaWiki to function" );
1982 }
1983 $s = array_shift( $bits );
1984
1985 $i = 0;
1986 while ( $i < count( $bits ) ) {
1987 $url = $bits[$i++];
1988 $i++; // protocol
1989 $text = $bits[$i++];
1990 $trail = $bits[$i++];
1991
1992 # The characters '<' and '>' (which were escaped by
1993 # removeHTMLtags()) should not be included in
1994 # URLs, per RFC 2396.
1995 $m2 = [];
1996 if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) {
1997 $text = substr( $url, $m2[0][1] ) . ' ' . $text;
1998 $url = substr( $url, 0, $m2[0][1] );
1999 }
2000
2001 # If the link text is an image URL, replace it with an <img> tag
2002 # This happened by accident in the original parser, but some people used it extensively
2003 $img = $this->maybeMakeExternalImage( $text );
2004 if ( $img !== false ) {
2005 $text = $img;
2006 }
2007
2008 $dtrail = '';
2009
2010 # Set linktype for CSS
2011 $linktype = 'text';
2012
2013 # No link text, e.g. [http://domain.tld/some.link]
2014 if ( $text == '' ) {
2015 # Autonumber
2016 $langObj = $this->getTargetLanguage();
2017 $text = '[' . $langObj->formatNum( ++$this->mAutonumber ) . ']';
2018 $linktype = 'autonumber';
2019 } else {
2020 # Have link text, e.g. [http://domain.tld/some.link text]s
2021 # Check for trail
2022 list( $dtrail, $trail ) = Linker::splitTrail( $trail );
2023 }
2024
2025 // Excluding protocol-relative URLs may avoid many false positives.
2026 if ( preg_match( '/^(?:' . wfUrlProtocolsWithoutProtRel() . ')/', $text ) ) {
2027 $text = $this->getTargetLanguage()->getConverter()->markNoConversion( $text );
2028 }
2029
2030 $url = Sanitizer::cleanUrl( $url );
2031
2032 # Use the encoded URL
2033 # This means that users can paste URLs directly into the text
2034 # Funny characters like ö aren't valid in URLs anyway
2035 # This was changed in August 2004
2036 $s .= Linker::makeExternalLink( $url, $text, false, $linktype,
2037 $this->getExternalLinkAttribs( $url ), $this->mTitle ) . $dtrail . $trail;
2038
2039 # Register link in the output object.
2040 $this->mOutput->addExternalLink( $url );
2041 }
2042
2043 return $s;
2044 }
2045
2046 /**
2047 * Get the rel attribute for a particular external link.
2048 *
2049 * @since 1.21
2050 * @param string|bool $url Optional URL, to extract the domain from for rel =>
2051 * nofollow if appropriate
2052 * @param LinkTarget|null $title Optional LinkTarget, for wgNoFollowNsExceptions lookups
2053 * @return string|null Rel attribute for $url
2054 */
2055 public static function getExternalLinkRel( $url = false, $title = null ) {
2056 global $wgNoFollowLinks, $wgNoFollowNsExceptions, $wgNoFollowDomainExceptions;
2057 $ns = $title ? $title->getNamespace() : false;
2058 if ( $wgNoFollowLinks && !in_array( $ns, $wgNoFollowNsExceptions )
2059 && !wfMatchesDomainList( $url, $wgNoFollowDomainExceptions )
2060 ) {
2061 return 'nofollow';
2062 }
2063 return null;
2064 }
2065
2066 /**
2067 * Get an associative array of additional HTML attributes appropriate for a
2068 * particular external link. This currently may include rel => nofollow
2069 * (depending on configuration, namespace, and the URL's domain) and/or a
2070 * target attribute (depending on configuration).
2071 *
2072 * @param string $url URL to extract the domain from for rel =>
2073 * nofollow if appropriate
2074 * @return array Associative array of HTML attributes
2075 */
2076 public function getExternalLinkAttribs( $url ) {
2077 $attribs = [];
2078 $rel = self::getExternalLinkRel( $url, $this->mTitle );
2079
2080 $target = $this->mOptions->getExternalLinkTarget();
2081 if ( $target ) {
2082 $attribs['target'] = $target;
2083 if ( !in_array( $target, [ '_self', '_parent', '_top' ] ) ) {
2084 // T133507. New windows can navigate parent cross-origin.
2085 // Including noreferrer due to lacking browser
2086 // support of noopener. Eventually noreferrer should be removed.
2087 if ( $rel !== '' ) {
2088 $rel .= ' ';
2089 }
2090 $rel .= 'noreferrer noopener';
2091 }
2092 }
2093 $attribs['rel'] = $rel;
2094 return $attribs;
2095 }
2096
2097 /**
2098 * Replace unusual escape codes in a URL with their equivalent characters
2099 *
2100 * This generally follows the syntax defined in RFC 3986, with special
2101 * consideration for HTTP query strings.
2102 *
2103 * @param string $url
2104 * @return string
2105 */
2106 public static function normalizeLinkUrl( $url ) {
2107 # Test for RFC 3986 IPv6 syntax
2108 $scheme = '[a-z][a-z0-9+.-]*:';
2109 $userinfo = '(?:[a-z0-9\-._~!$&\'()*+,;=:]|%[0-9a-f]{2})*';
2110 $ipv6Host = '\\[((?:[0-9a-f:]|%3[0-A]|%[46][1-6])+)\\]';
2111 if ( preg_match( "<^(?:{$scheme})?//(?:{$userinfo}@)?{$ipv6Host}(?:[:/?#].*|)$>i", $url, $m ) &&
2112 IP::isValid( rawurldecode( $m[1] ) )
2113 ) {
2114 $isIPv6 = rawurldecode( $m[1] );
2115 } else {
2116 $isIPv6 = false;
2117 }
2118
2119 # Make sure unsafe characters are encoded
2120 $url = preg_replace_callback( '/[\x00-\x20"<>\[\\\\\]^`{|}\x7F-\xFF]/',
2121 function ( $m ) {
2122 return rawurlencode( $m[0] );
2123 },
2124 $url
2125 );
2126
2127 $ret = '';
2128 $end = strlen( $url );
2129
2130 # Fragment part - 'fragment'
2131 $start = strpos( $url, '#' );
2132 if ( $start !== false && $start < $end ) {
2133 $ret = self::normalizeUrlComponent(
2134 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}' ) . $ret;
2135 $end = $start;
2136 }
2137
2138 # Query part - 'query' minus &=+;
2139 $start = strpos( $url, '?' );
2140 if ( $start !== false && $start < $end ) {
2141 $ret = self::normalizeUrlComponent(
2142 substr( $url, $start, $end - $start ), '"#%<>[\]^`{|}&=+;' ) . $ret;
2143 $end = $start;
2144 }
2145
2146 # Scheme and path part - 'pchar'
2147 # (we assume no userinfo or encoded colons in the host)
2148 $ret = self::normalizeUrlComponent(
2149 substr( $url, 0, $end ), '"#%<>[\]^`{|}/?' ) . $ret;
2150
2151 # Fix IPv6 syntax
2152 if ( $isIPv6 !== false ) {
2153 $ipv6Host = "%5B({$isIPv6})%5D";
2154 $ret = preg_replace(
2155 "<^((?:{$scheme})?//(?:{$userinfo}@)?){$ipv6Host}(?=[:/?#]|$)>i",
2156 "$1[$2]",
2157 $ret
2158 );
2159 }
2160
2161 return $ret;
2162 }
2163
2164 private static function normalizeUrlComponent( $component, $unsafe ) {
2165 $callback = function ( $matches ) use ( $unsafe ) {
2166 $char = urldecode( $matches[0] );
2167 $ord = ord( $char );
2168 if ( $ord > 32 && $ord < 127 && strpos( $unsafe, $char ) === false ) {
2169 # Unescape it
2170 return $char;
2171 } else {
2172 # Leave it escaped, but use uppercase for a-f
2173 return strtoupper( $matches[0] );
2174 }
2175 };
2176 return preg_replace_callback( '/%[0-9A-Fa-f]{2}/', $callback, $component );
2177 }
2178
2179 /**
2180 * make an image if it's allowed, either through the global
2181 * option, through the exception, or through the on-wiki whitelist
2182 *
2183 * @param string $url
2184 *
2185 * @return string
2186 */
2187 private function maybeMakeExternalImage( $url ) {
2188 $imagesfrom = $this->mOptions->getAllowExternalImagesFrom();
2189 $imagesexception = !empty( $imagesfrom );
2190 $text = false;
2191 # $imagesfrom could be either a single string or an array of strings, parse out the latter
2192 if ( $imagesexception && is_array( $imagesfrom ) ) {
2193 $imagematch = false;
2194 foreach ( $imagesfrom as $match ) {
2195 if ( strpos( $url, $match ) === 0 ) {
2196 $imagematch = true;
2197 break;
2198 }
2199 }
2200 } elseif ( $imagesexception ) {
2201 $imagematch = ( strpos( $url, $imagesfrom ) === 0 );
2202 } else {
2203 $imagematch = false;
2204 }
2205
2206 if ( $this->mOptions->getAllowExternalImages()
2207 || ( $imagesexception && $imagematch )
2208 ) {
2209 if ( preg_match( self::EXT_IMAGE_REGEX, $url ) ) {
2210 # Image found
2211 $text = Linker::makeExternalImage( $url );
2212 }
2213 }
2214 if ( !$text && $this->mOptions->getEnableImageWhitelist()
2215 && preg_match( self::EXT_IMAGE_REGEX, $url )
2216 ) {
2217 $whitelist = explode(
2218 "\n",
2219 wfMessage( 'external_image_whitelist' )->inContentLanguage()->text()
2220 );
2221
2222 foreach ( $whitelist as $entry ) {
2223 # Sanitize the regex fragment, make it case-insensitive, ignore blank entries/comments
2224 if ( strpos( $entry, '#' ) === 0 || $entry === '' ) {
2225 continue;
2226 }
2227 if ( preg_match( '/' . str_replace( '/', '\\/', $entry ) . '/i', $url ) ) {
2228 # Image matches a whitelist entry
2229 $text = Linker::makeExternalImage( $url );
2230 break;
2231 }
2232 }
2233 }
2234 return $text;
2235 }
2236
2237 /**
2238 * Process [[ ]] wikilinks
2239 *
2240 * @param string $s
2241 *
2242 * @return string Processed text
2243 *
2244 * @private
2245 */
2246 public function replaceInternalLinks( $s ) {
2247 $this->mLinkHolders->merge( $this->replaceInternalLinks2( $s ) );
2248 return $s;
2249 }
2250
2251 /**
2252 * Process [[ ]] wikilinks (RIL)
2253 * @param string &$s
2254 * @throws MWException
2255 * @return LinkHolderArray
2256 *
2257 * @private
2258 */
2259 public function replaceInternalLinks2( &$s ) {
2260 static $tc = false, $e1, $e1_img;
2261 # the % is needed to support urlencoded titles as well
2262 if ( !$tc ) {
2263 $tc = Title::legalChars() . '#%';
2264 # Match a link having the form [[namespace:link|alternate]]trail
2265 $e1 = "/^([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD";
2266 # Match cases where there is no "]]", which might still be images
2267 $e1_img = "/^([{$tc}]+)\\|(.*)\$/sD";
2268 }
2269
2270 $holders = new LinkHolderArray( $this );
2271
2272 # split the entire text string on occurrences of [[
2273 $a = StringUtils::explode( '[[', ' ' . $s );
2274 # get the first element (all text up to first [[), and remove the space we added
2275 $s = $a->current();
2276 $a->next();
2277 $line = $a->current(); # Workaround for broken ArrayIterator::next() that returns "void"
2278 $s = substr( $s, 1 );
2279
2280 $useLinkPrefixExtension = $this->getTargetLanguage()->linkPrefixExtension();
2281 $e2 = null;
2282 if ( $useLinkPrefixExtension ) {
2283 # Match the end of a line for a word that's not followed by whitespace,
2284 # e.g. in the case of 'The Arab al[[Razi]]', 'al' will be matched
2285 $charset = $this->contLang->linkPrefixCharset();
2286 $e2 = "/^((?>.*[^$charset]|))(.+)$/sDu";
2287 }
2288
2289 if ( is_null( $this->mTitle ) ) {
2290 throw new MWException( __METHOD__ . ": \$this->mTitle is null\n" );
2291 }
2292 $nottalk = !$this->mTitle->isTalkPage();
2293
2294 if ( $useLinkPrefixExtension ) {
2295 $m = [];
2296 if ( preg_match( $e2, $s, $m ) ) {
2297 $first_prefix = $m[2];
2298 } else {
2299 $first_prefix = false;
2300 }
2301 } else {
2302 $prefix = '';
2303 }
2304
2305 $useSubpages = $this->areSubpagesAllowed();
2306
2307 # Loop for each link
2308 for ( ; $line !== false && $line !== null; $a->next(), $line = $a->current() ) {
2309 # Check for excessive memory usage
2310 if ( $holders->isBig() ) {
2311 # Too big
2312 # Do the existence check, replace the link holders and clear the array
2313 $holders->replace( $s );
2314 $holders->clear();
2315 }
2316
2317 if ( $useLinkPrefixExtension ) {
2318 if ( preg_match( $e2, $s, $m ) ) {
2319 list( , $s, $prefix ) = $m;
2320 } else {
2321 $prefix = '';
2322 }
2323 # first link
2324 if ( $first_prefix ) {
2325 $prefix = $first_prefix;
2326 $first_prefix = false;
2327 }
2328 }
2329
2330 $might_be_img = false;
2331
2332 if ( preg_match( $e1, $line, $m ) ) { # page with normal text or alt
2333 $text = $m[2];
2334 # If we get a ] at the beginning of $m[3] that means we have a link that's something like:
2335 # [[Image:Foo.jpg|[http://example.com desc]]] <- having three ] in a row fucks up,
2336 # the real problem is with the $e1 regex
2337 # See T1500.
2338 # Still some problems for cases where the ] is meant to be outside punctuation,
2339 # and no image is in sight. See T4095.
2340 if ( $text !== ''
2341 && substr( $m[3], 0, 1 ) === ']'
2342 && strpos( $text, '[' ) !== false
2343 ) {
2344 $text .= ']'; # so that replaceExternalLinks($text) works later
2345 $m[3] = substr( $m[3], 1 );
2346 }
2347 # fix up urlencoded title texts
2348 if ( strpos( $m[1], '%' ) !== false ) {
2349 # Should anchors '#' also be rejected?
2350 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2351 }
2352 $trail = $m[3];
2353 } elseif ( preg_match( $e1_img, $line, $m ) ) {
2354 # Invalid, but might be an image with a link in its caption
2355 $might_be_img = true;
2356 $text = $m[2];
2357 if ( strpos( $m[1], '%' ) !== false ) {
2358 $m[1] = str_replace( [ '<', '>' ], [ '&lt;', '&gt;' ], rawurldecode( $m[1] ) );
2359 }
2360 $trail = "";
2361 } else { # Invalid form; output directly
2362 $s .= $prefix . '[[' . $line;
2363 continue;
2364 }
2365
2366 $origLink = ltrim( $m[1], ' ' );
2367
2368 # Don't allow internal links to pages containing
2369 # PROTO: where PROTO is a valid URL protocol; these
2370 # should be external links.
2371 if ( preg_match( '/^(?i:' . $this->mUrlProtocols . ')/', $origLink ) ) {
2372 $s .= $prefix . '[[' . $line;
2373 continue;
2374 }
2375
2376 # Make subpage if necessary
2377 if ( $useSubpages ) {
2378 $link = $this->maybeDoSubpageLink( $origLink, $text );
2379 } else {
2380 $link = $origLink;
2381 }
2382
2383 // \x7f isn't a default legal title char, so most likely strip
2384 // markers will force us into the "invalid form" path above. But,
2385 // just in case, let's assert that xmlish tags aren't valid in
2386 // the title position.
2387 $unstrip = $this->mStripState->killMarkers( $link );
2388 $noMarkers = ( $unstrip === $link );
2389
2390 $nt = $noMarkers ? Title::newFromText( $link ) : null;
2391 if ( $nt === null ) {
2392 $s .= $prefix . '[[' . $line;
2393 continue;
2394 }
2395
2396 $ns = $nt->getNamespace();
2397 $iw = $nt->getInterwiki();
2398
2399 $noforce = ( substr( $origLink, 0, 1 ) !== ':' );
2400
2401 if ( $might_be_img ) { # if this is actually an invalid link
2402 if ( $ns == NS_FILE && $noforce ) { # but might be an image
2403 $found = false;
2404 while ( true ) {
2405 # look at the next 'line' to see if we can close it there
2406 $a->next();
2407 $next_line = $a->current();
2408 if ( $next_line === false || $next_line === null ) {
2409 break;
2410 }
2411 $m = explode( ']]', $next_line, 3 );
2412 if ( count( $m ) == 3 ) {
2413 # the first ]] closes the inner link, the second the image
2414 $found = true;
2415 $text .= "[[{$m[0]}]]{$m[1]}";
2416 $trail = $m[2];
2417 break;
2418 } elseif ( count( $m ) == 2 ) {
2419 # if there's exactly one ]] that's fine, we'll keep looking
2420 $text .= "[[{$m[0]}]]{$m[1]}";
2421 } else {
2422 # if $next_line is invalid too, we need look no further
2423 $text .= '[[' . $next_line;
2424 break;
2425 }
2426 }
2427 if ( !$found ) {
2428 # we couldn't find the end of this imageLink, so output it raw
2429 # but don't ignore what might be perfectly normal links in the text we've examined
2430 $holders->merge( $this->replaceInternalLinks2( $text ) );
2431 $s .= "{$prefix}[[$link|$text";
2432 # note: no $trail, because without an end, there *is* no trail
2433 continue;
2434 }
2435 } else { # it's not an image, so output it raw
2436 $s .= "{$prefix}[[$link|$text";
2437 # note: no $trail, because without an end, there *is* no trail
2438 continue;
2439 }
2440 }
2441
2442 $wasblank = ( $text == '' );
2443 if ( $wasblank ) {
2444 $text = $link;
2445 if ( !$noforce ) {
2446 # Strip off leading ':'
2447 $text = substr( $text, 1 );
2448 }
2449 } else {
2450 # T6598 madness. Handle the quotes only if they come from the alternate part
2451 # [[Lista d''e paise d''o munno]] -> <a href="...">Lista d''e paise d''o munno</a>
2452 # [[Criticism of Harry Potter|Criticism of ''Harry Potter'']]
2453 # -> <a href="Criticism of Harry Potter">Criticism of <i>Harry Potter</i></a>
2454 $text = $this->doQuotes( $text );
2455 }
2456
2457 # Link not escaped by : , create the various objects
2458 if ( $noforce && !$nt->wasLocalInterwiki() ) {
2459 # Interwikis
2460 if (
2461 $iw && $this->mOptions->getInterwikiMagic() && $nottalk && (
2462 Language::fetchLanguageName( $iw, null, 'mw' ) ||
2463 in_array( $iw, $this->svcOptions->get( 'ExtraInterlanguageLinkPrefixes' ) )
2464 )
2465 ) {
2466 # T26502: filter duplicates
2467 if ( !isset( $this->mLangLinkLanguages[$iw] ) ) {
2468 $this->mLangLinkLanguages[$iw] = true;
2469 $this->mOutput->addLanguageLink( $nt->getFullText() );
2470 }
2471
2472 /**
2473 * Strip the whitespace interwiki links produce, see T10897
2474 */
2475 $s = rtrim( $s . $prefix ) . $trail; # T175416
2476 continue;
2477 }
2478
2479 if ( $ns == NS_FILE ) {
2480 if ( !wfIsBadImage( $nt->getDBkey(), $this->mTitle ) ) {
2481 if ( $wasblank ) {
2482 # if no parameters were passed, $text
2483 # becomes something like "File:Foo.png",
2484 # which we don't want to pass on to the
2485 # image generator
2486 $text = '';
2487 } else {
2488 # recursively parse links inside the image caption
2489 # actually, this will parse them in any other parameters, too,
2490 # but it might be hard to fix that, and it doesn't matter ATM
2491 $text = $this->replaceExternalLinks( $text );
2492 $holders->merge( $this->replaceInternalLinks2( $text ) );
2493 }
2494 # cloak any absolute URLs inside the image markup, so replaceExternalLinks() won't touch them
2495 $s .= $prefix . $this->armorLinks(
2496 $this->makeImage( $nt, $text, $holders ) ) . $trail;
2497 continue;
2498 }
2499 } elseif ( $ns == NS_CATEGORY ) {
2500 /**
2501 * Strip the whitespace Category links produce, see T2087
2502 */
2503 $s = rtrim( $s . $prefix ) . $trail; # T2087, T87753
2504
2505 if ( $wasblank ) {
2506 $sortkey = $this->getDefaultSort();
2507 } else {
2508 $sortkey = $text;
2509 }
2510 $sortkey = Sanitizer::decodeCharReferences( $sortkey );
2511 $sortkey = str_replace( "\n", '', $sortkey );
2512 $sortkey = $this->getTargetLanguage()->convertCategoryKey( $sortkey );
2513 $this->mOutput->addCategory( $nt->getDBkey(), $sortkey );
2514
2515 continue;
2516 }
2517 }
2518
2519 # Self-link checking. For some languages, variants of the title are checked in
2520 # LinkHolderArray::doVariants() to allow batching the existence checks necessary
2521 # for linking to a different variant.
2522 if ( $ns != NS_SPECIAL && $nt->equals( $this->mTitle ) && !$nt->hasFragment() ) {
2523 $s .= $prefix . Linker::makeSelfLinkObj( $nt, $text, '', $trail );
2524 continue;
2525 }
2526
2527 # NS_MEDIA is a pseudo-namespace for linking directly to a file
2528 # @todo FIXME: Should do batch file existence checks, see comment below
2529 if ( $ns == NS_MEDIA ) {
2530 # Give extensions a chance to select the file revision for us
2531 $options = [];
2532 $descQuery = false;
2533 Hooks::run( 'BeforeParserFetchFileAndTitle',
2534 [ $this, $nt, &$options, &$descQuery ] );
2535 # Fetch and register the file (file title may be different via hooks)
2536 list( $file, $nt ) = $this->fetchFileAndTitle( $nt, $options );
2537 # Cloak with NOPARSE to avoid replacement in replaceExternalLinks
2538 $s .= $prefix . $this->armorLinks(
2539 Linker::makeMediaLinkFile( $nt, $file, $text ) ) . $trail;
2540 continue;
2541 }
2542
2543 # Some titles, such as valid special pages or files in foreign repos, should
2544 # be shown as bluelinks even though they're not included in the page table
2545 # @todo FIXME: isAlwaysKnown() can be expensive for file links; we should really do
2546 # batch file existence checks for NS_FILE and NS_MEDIA
2547 if ( $iw == '' && $nt->isAlwaysKnown() ) {
2548 $this->mOutput->addLink( $nt );
2549 $s .= $this->makeKnownLinkHolder( $nt, $text, $trail, $prefix );
2550 } else {
2551 # Links will be added to the output link list after checking
2552 $s .= $holders->makeHolder( $nt, $text, [], $trail, $prefix );
2553 }
2554 }
2555 return $holders;
2556 }
2557
2558 /**
2559 * Render a forced-blue link inline; protect against double expansion of
2560 * URLs if we're in a mode that prepends full URL prefixes to internal links.
2561 * Since this little disaster has to split off the trail text to avoid
2562 * breaking URLs in the following text without breaking trails on the
2563 * wiki links, it's been made into a horrible function.
2564 *
2565 * @param Title $nt
2566 * @param string $text
2567 * @param string $trail
2568 * @param string $prefix
2569 * @return string HTML-wikitext mix oh yuck
2570 */
2571 protected function makeKnownLinkHolder( $nt, $text = '', $trail = '', $prefix = '' ) {
2572 list( $inside, $trail ) = Linker::splitTrail( $trail );
2573
2574 if ( $text == '' ) {
2575 $text = htmlspecialchars( $nt->getPrefixedText() );
2576 }
2577
2578 $link = $this->getLinkRenderer()->makeKnownLink(
2579 $nt, new HtmlArmor( "$prefix$text$inside" )
2580 );
2581
2582 return $this->armorLinks( $link ) . $trail;
2583 }
2584
2585 /**
2586 * Insert a NOPARSE hacky thing into any inline links in a chunk that's
2587 * going to go through further parsing steps before inline URL expansion.
2588 *
2589 * Not needed quite as much as it used to be since free links are a bit
2590 * more sensible these days. But bracketed links are still an issue.
2591 *
2592 * @param string $text More-or-less HTML
2593 * @return string Less-or-more HTML with NOPARSE bits
2594 */
2595 public function armorLinks( $text ) {
2596 return preg_replace( '/\b((?i)' . $this->mUrlProtocols . ')/',
2597 self::MARKER_PREFIX . "NOPARSE$1", $text );
2598 }
2599
2600 /**
2601 * Return true if subpage links should be expanded on this page.
2602 * @return bool
2603 */
2604 public function areSubpagesAllowed() {
2605 # Some namespaces don't allow subpages
2606 return $this->nsInfo->hasSubpages( $this->mTitle->getNamespace() );
2607 }
2608
2609 /**
2610 * Handle link to subpage if necessary
2611 *
2612 * @param string $target The source of the link
2613 * @param string &$text The link text, modified as necessary
2614 * @return string The full name of the link
2615 * @private
2616 */
2617 public function maybeDoSubpageLink( $target, &$text ) {
2618 return Linker::normalizeSubpageLink( $this->mTitle, $target, $text );
2619 }
2620
2621 /**
2622 * Make lists from lines starting with ':', '*', '#', etc. (DBL)
2623 *
2624 * @param string $text
2625 * @param bool $linestart Whether or not this is at the start of a line.
2626 * @private
2627 * @return string The lists rendered as HTML
2628 */
2629 public function doBlockLevels( $text, $linestart ) {
2630 return BlockLevelPass::doBlockLevels( $text, $linestart );
2631 }
2632
2633 /**
2634 * Return value of a magic variable (like PAGENAME)
2635 *
2636 * @private
2637 *
2638 * @param string $index Magic variable identifier as mapped in MagicWordFactory::$mVariableIDs
2639 * @param bool|PPFrame $frame
2640 *
2641 * @throws MWException
2642 * @return string
2643 */
2644 public function getVariableValue( $index, $frame = false ) {
2645 if ( is_null( $this->mTitle ) ) {
2646 // If no title set, bad things are going to happen
2647 // later. Title should always be set since this
2648 // should only be called in the middle of a parse
2649 // operation (but the unit-tests do funky stuff)
2650 throw new MWException( __METHOD__ . ' Should only be '
2651 . ' called while parsing (no title set)' );
2652 }
2653
2654 // Avoid PHP 7.1 warning from passing $this by reference
2655 $parser = $this;
2656
2657 /**
2658 * Some of these require message or data lookups and can be
2659 * expensive to check many times.
2660 */
2661 if (
2662 Hooks::run( 'ParserGetVariableValueVarCache', [ &$parser, &$this->mVarCache ] ) &&
2663 isset( $this->mVarCache[$index] )
2664 ) {
2665 return $this->mVarCache[$index];
2666 }
2667
2668 $ts = wfTimestamp( TS_UNIX, $this->mOptions->getTimestamp() );
2669 Hooks::run( 'ParserGetVariableValueTs', [ &$parser, &$ts ] );
2670
2671 $pageLang = $this->getFunctionLang();
2672
2673 switch ( $index ) {
2674 case '!':
2675 $value = '|';
2676 break;
2677 case 'currentmonth':
2678 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'm' ), true );
2679 break;
2680 case 'currentmonth1':
2681 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'n' ), true );
2682 break;
2683 case 'currentmonthname':
2684 $value = $pageLang->getMonthName( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2685 break;
2686 case 'currentmonthnamegen':
2687 $value = $pageLang->getMonthNameGen( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2688 break;
2689 case 'currentmonthabbrev':
2690 $value = $pageLang->getMonthAbbreviation( MWTimestamp::getInstance( $ts )->format( 'n' ) );
2691 break;
2692 case 'currentday':
2693 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'j' ), true );
2694 break;
2695 case 'currentday2':
2696 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'd' ), true );
2697 break;
2698 case 'localmonth':
2699 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'm' ), true );
2700 break;
2701 case 'localmonth1':
2702 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'n' ), true );
2703 break;
2704 case 'localmonthname':
2705 $value = $pageLang->getMonthName( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2706 break;
2707 case 'localmonthnamegen':
2708 $value = $pageLang->getMonthNameGen( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2709 break;
2710 case 'localmonthabbrev':
2711 $value = $pageLang->getMonthAbbreviation( MWTimestamp::getLocalInstance( $ts )->format( 'n' ) );
2712 break;
2713 case 'localday':
2714 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'j' ), true );
2715 break;
2716 case 'localday2':
2717 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'd' ), true );
2718 break;
2719 case 'pagename':
2720 $value = wfEscapeWikiText( $this->mTitle->getText() );
2721 break;
2722 case 'pagenamee':
2723 $value = wfEscapeWikiText( $this->mTitle->getPartialURL() );
2724 break;
2725 case 'fullpagename':
2726 $value = wfEscapeWikiText( $this->mTitle->getPrefixedText() );
2727 break;
2728 case 'fullpagenamee':
2729 $value = wfEscapeWikiText( $this->mTitle->getPrefixedURL() );
2730 break;
2731 case 'subpagename':
2732 $value = wfEscapeWikiText( $this->mTitle->getSubpageText() );
2733 break;
2734 case 'subpagenamee':
2735 $value = wfEscapeWikiText( $this->mTitle->getSubpageUrlForm() );
2736 break;
2737 case 'rootpagename':
2738 $value = wfEscapeWikiText( $this->mTitle->getRootText() );
2739 break;
2740 case 'rootpagenamee':
2741 $value = wfEscapeWikiText( wfUrlencode( str_replace(
2742 ' ',
2743 '_',
2744 $this->mTitle->getRootText()
2745 ) ) );
2746 break;
2747 case 'basepagename':
2748 $value = wfEscapeWikiText( $this->mTitle->getBaseText() );
2749 break;
2750 case 'basepagenamee':
2751 $value = wfEscapeWikiText( wfUrlencode( str_replace(
2752 ' ',
2753 '_',
2754 $this->mTitle->getBaseText()
2755 ) ) );
2756 break;
2757 case 'talkpagename':
2758 if ( $this->mTitle->canHaveTalkPage() ) {
2759 $talkPage = $this->mTitle->getTalkPage();
2760 $value = wfEscapeWikiText( $talkPage->getPrefixedText() );
2761 } else {
2762 $value = '';
2763 }
2764 break;
2765 case 'talkpagenamee':
2766 if ( $this->mTitle->canHaveTalkPage() ) {
2767 $talkPage = $this->mTitle->getTalkPage();
2768 $value = wfEscapeWikiText( $talkPage->getPrefixedURL() );
2769 } else {
2770 $value = '';
2771 }
2772 break;
2773 case 'subjectpagename':
2774 $subjPage = $this->mTitle->getSubjectPage();
2775 $value = wfEscapeWikiText( $subjPage->getPrefixedText() );
2776 break;
2777 case 'subjectpagenamee':
2778 $subjPage = $this->mTitle->getSubjectPage();
2779 $value = wfEscapeWikiText( $subjPage->getPrefixedURL() );
2780 break;
2781 case 'pageid': // requested in T25427
2782 $pageid = $this->getTitle()->getArticleID();
2783 if ( $pageid == 0 ) {
2784 # 0 means the page doesn't exist in the database,
2785 # which means the user is previewing a new page.
2786 # The vary-revision flag must be set, because the magic word
2787 # will have a different value once the page is saved.
2788 $this->mOutput->setFlag( 'vary-revision' );
2789 wfDebug( __METHOD__ . ": {{PAGEID}} used in a new page, setting vary-revision...\n" );
2790 }
2791 $value = $pageid ?: null;
2792 break;
2793 case 'revisionid':
2794 if (
2795 $this->svcOptions->get( 'MiserMode' ) &&
2796 !$this->mOptions->getInterfaceMessage() &&
2797 // @TODO: disallow this word on all namespaces
2798 $this->nsInfo->isContent( $this->mTitle->getNamespace() )
2799 ) {
2800 // Use a stub result instead of the actual revision ID in order to avoid
2801 // double parses on page save but still allow preview detection (T137900)
2802 if ( $this->getRevisionId() || $this->mOptions->getSpeculativeRevId() ) {
2803 $value = '-';
2804 } else {
2805 $this->mOutput->setFlag( '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...\n" );
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 # Let the edit saving system know we should parse the page
2843 # *after* a revision ID has been assigned. This is for null edits.
2844 $this->mOutput->setFlag( 'vary-revision' );
2845 wfDebug( __METHOD__ . ": {{REVISIONTIMESTAMP}} used, setting vary-revision...\n" );
2846 $value = $this->getRevisionTimestamp();
2847 break;
2848 case 'revisionuser':
2849 # Let the edit saving system know we should parse the page
2850 # *after* a revision ID has been assigned for null edits.
2851 $this->mOutput->setFlag( 'vary-user' );
2852 wfDebug( __METHOD__ . ": {{REVISIONUSER}} used, setting vary-user...\n" );
2853 $value = $this->getRevisionUser();
2854 break;
2855 case 'revisionsize':
2856 $value = $this->getRevisionSize();
2857 break;
2858 case 'namespace':
2859 $value = str_replace( '_', ' ',
2860 $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2861 break;
2862 case 'namespacee':
2863 $value = wfUrlencode( $this->contLang->getNsText( $this->mTitle->getNamespace() ) );
2864 break;
2865 case 'namespacenumber':
2866 $value = $this->mTitle->getNamespace();
2867 break;
2868 case 'talkspace':
2869 $value = $this->mTitle->canHaveTalkPage()
2870 ? str_replace( '_', ' ', $this->mTitle->getTalkNsText() )
2871 : '';
2872 break;
2873 case 'talkspacee':
2874 $value = $this->mTitle->canHaveTalkPage() ? wfUrlencode( $this->mTitle->getTalkNsText() ) : '';
2875 break;
2876 case 'subjectspace':
2877 $value = str_replace( '_', ' ', $this->mTitle->getSubjectNsText() );
2878 break;
2879 case 'subjectspacee':
2880 $value = ( wfUrlencode( $this->mTitle->getSubjectNsText() ) );
2881 break;
2882 case 'currentdayname':
2883 $value = $pageLang->getWeekdayName( (int)MWTimestamp::getInstance( $ts )->format( 'w' ) + 1 );
2884 break;
2885 case 'currentyear':
2886 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'Y' ), true );
2887 break;
2888 case 'currenttime':
2889 $value = $pageLang->time( wfTimestamp( TS_MW, $ts ), false, false );
2890 break;
2891 case 'currenthour':
2892 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'H' ), true );
2893 break;
2894 case 'currentweek':
2895 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2896 # int to remove the padding
2897 $value = $pageLang->formatNum( (int)MWTimestamp::getInstance( $ts )->format( 'W' ) );
2898 break;
2899 case 'currentdow':
2900 $value = $pageLang->formatNum( MWTimestamp::getInstance( $ts )->format( 'w' ) );
2901 break;
2902 case 'localdayname':
2903 $value = $pageLang->getWeekdayName(
2904 (int)MWTimestamp::getLocalInstance( $ts )->format( 'w' ) + 1
2905 );
2906 break;
2907 case 'localyear':
2908 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'Y' ), true );
2909 break;
2910 case 'localtime':
2911 $value = $pageLang->time(
2912 MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' ),
2913 false,
2914 false
2915 );
2916 break;
2917 case 'localhour':
2918 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'H' ), true );
2919 break;
2920 case 'localweek':
2921 # @bug T6594 PHP5 has it zero padded, PHP4 does not, cast to
2922 # int to remove the padding
2923 $value = $pageLang->formatNum( (int)MWTimestamp::getLocalInstance( $ts )->format( 'W' ) );
2924 break;
2925 case 'localdow':
2926 $value = $pageLang->formatNum( MWTimestamp::getLocalInstance( $ts )->format( 'w' ) );
2927 break;
2928 case 'numberofarticles':
2929 $value = $pageLang->formatNum( SiteStats::articles() );
2930 break;
2931 case 'numberoffiles':
2932 $value = $pageLang->formatNum( SiteStats::images() );
2933 break;
2934 case 'numberofusers':
2935 $value = $pageLang->formatNum( SiteStats::users() );
2936 break;
2937 case 'numberofactiveusers':
2938 $value = $pageLang->formatNum( SiteStats::activeUsers() );
2939 break;
2940 case 'numberofpages':
2941 $value = $pageLang->formatNum( SiteStats::pages() );
2942 break;
2943 case 'numberofadmins':
2944 $value = $pageLang->formatNum( SiteStats::numberingroup( 'sysop' ) );
2945 break;
2946 case 'numberofedits':
2947 $value = $pageLang->formatNum( SiteStats::edits() );
2948 break;
2949 case 'currenttimestamp':
2950 $value = wfTimestamp( TS_MW, $ts );
2951 break;
2952 case 'localtimestamp':
2953 $value = MWTimestamp::getLocalInstance( $ts )->format( 'YmdHis' );
2954 break;
2955 case 'currentversion':
2956 $value = SpecialVersion::getVersion();
2957 break;
2958 case 'articlepath':
2959 return $this->svcOptions->get( 'ArticlePath' );
2960 case 'sitename':
2961 return $this->svcOptions->get( 'Sitename' );
2962 case 'server':
2963 return $this->svcOptions->get( 'Server' );
2964 case 'servername':
2965 return $this->svcOptions->get( 'ServerName' );
2966 case 'scriptpath':
2967 return $this->svcOptions->get( 'ScriptPath' );
2968 case 'stylepath':
2969 return $this->svcOptions->get( 'StylePath' );
2970 case 'directionmark':
2971 return $pageLang->getDirMark();
2972 case 'contentlanguage':
2973 return $this->svcOptions->get( 'LanguageCode' );
2974 case 'pagelanguage':
2975 $value = $pageLang->getCode();
2976 break;
2977 case 'cascadingsources':
2978 $value = CoreParserFunctions::cascadingsources( $this );
2979 break;
2980 default:
2981 $ret = null;
2982 Hooks::run(
2983 'ParserGetVariableValueSwitch',
2984 [ &$parser, &$this->mVarCache, &$index, &$ret, &$frame ]
2985 );
2986
2987 return $ret;
2988 }
2989
2990 if ( $index ) {
2991 $this->mVarCache[$index] = $value;
2992 }
2993
2994 return $value;
2995 }
2996
2997 /**
2998 * @param int $start
2999 * @param int $len
3000 * @param int $mtts Max time-till-save; sets vary-revision if result might change by then
3001 * @param string $variable Parser variable name
3002 * @return string
3003 */
3004 private function getRevisionTimestampSubstring( $start, $len, $mtts, $variable ) {
3005 # Get the timezone-adjusted timestamp to be used for this revision
3006 $resNow = substr( $this->getRevisionTimestamp(), $start, $len );
3007 # Possibly set vary-revision if there is not yet an associated revision
3008 if ( !$this->getRevisionObject() ) {
3009 # Get the timezone-adjusted timestamp $mtts seconds in the future
3010 $resThen = substr(
3011 $this->contLang->userAdjust( wfTimestamp( TS_MW, time() + $mtts ), '' ),
3012 $start,
3013 $len
3014 );
3015
3016 if ( $resNow !== $resThen ) {
3017 # Let the edit saving system know we should parse the page
3018 # *after* a revision ID has been assigned. This is for null edits.
3019 $this->mOutput->setFlag( 'vary-revision' );
3020 wfDebug( __METHOD__ . ": $variable used, setting vary-revision...\n" );
3021 }
3022 }
3023
3024 return $resNow;
3025 }
3026
3027 /**
3028 * initialise the magic variables (like CURRENTMONTHNAME) and substitution modifiers
3029 *
3030 * @private
3031 */
3032 public function initialiseVariables() {
3033 $variableIDs = $this->magicWordFactory->getVariableIDs();
3034 $substIDs = $this->magicWordFactory->getSubstIDs();
3035
3036 $this->mVariables = $this->magicWordFactory->newArray( $variableIDs );
3037 $this->mSubstWords = $this->magicWordFactory->newArray( $substIDs );
3038 }
3039
3040 /**
3041 * Preprocess some wikitext and return the document tree.
3042 * This is the ghost of replace_variables().
3043 *
3044 * @param string $text The text to parse
3045 * @param int $flags Bitwise combination of:
3046 * - self::PTD_FOR_INCLUSION: Handle "<noinclude>" and "<includeonly>" as if the text is being
3047 * included. Default is to assume a direct page view.
3048 *
3049 * The generated DOM tree must depend only on the input text and the flags.
3050 * The DOM tree must be the same in OT_HTML and OT_WIKI mode, to avoid a regression of T6899.
3051 *
3052 * Any flag added to the $flags parameter here, or any other parameter liable to cause a
3053 * change in the DOM tree for a given text, must be passed through the section identifier
3054 * in the section edit link and thus back to extractSections().
3055 *
3056 * The output of this function is currently only cached in process memory, but a persistent
3057 * cache may be implemented at a later date which takes further advantage of these strict
3058 * dependency requirements.
3059 *
3060 * @return PPNode
3061 */
3062 public function preprocessToDom( $text, $flags = 0 ) {
3063 $dom = $this->getPreprocessor()->preprocessToObj( $text, $flags );
3064 return $dom;
3065 }
3066
3067 /**
3068 * Return a three-element array: leading whitespace, string contents, trailing whitespace
3069 *
3070 * @param string $s
3071 *
3072 * @return array
3073 */
3074 public static function splitWhitespace( $s ) {
3075 $ltrimmed = ltrim( $s );
3076 $w1 = substr( $s, 0, strlen( $s ) - strlen( $ltrimmed ) );
3077 $trimmed = rtrim( $ltrimmed );
3078 $diff = strlen( $ltrimmed ) - strlen( $trimmed );
3079 if ( $diff > 0 ) {
3080 $w2 = substr( $ltrimmed, -$diff );
3081 } else {
3082 $w2 = '';
3083 }
3084 return [ $w1, $trimmed, $w2 ];
3085 }
3086
3087 /**
3088 * Replace magic variables, templates, and template arguments
3089 * with the appropriate text. Templates are substituted recursively,
3090 * taking care to avoid infinite loops.
3091 *
3092 * Note that the substitution depends on value of $mOutputType:
3093 * self::OT_WIKI: only {{subst:}} templates
3094 * self::OT_PREPROCESS: templates but not extension tags
3095 * self::OT_HTML: all templates and extension tags
3096 *
3097 * @param string $text The text to transform
3098 * @param bool|PPFrame $frame Object describing the arguments passed to the
3099 * template. Arguments may also be provided as an associative array, as
3100 * was the usual case before MW1.12. Providing arguments this way may be
3101 * useful for extensions wishing to perform variable replacement
3102 * explicitly.
3103 * @param bool $argsOnly Only do argument (triple-brace) expansion, not
3104 * double-brace expansion.
3105 * @return string
3106 */
3107 public function replaceVariables( $text, $frame = false, $argsOnly = false ) {
3108 # Is there any text? Also, Prevent too big inclusions!
3109 $textSize = strlen( $text );
3110 if ( $textSize < 1 || $textSize > $this->mOptions->getMaxIncludeSize() ) {
3111 return $text;
3112 }
3113
3114 if ( $frame === false ) {
3115 $frame = $this->getPreprocessor()->newFrame();
3116 } elseif ( !( $frame instanceof PPFrame ) ) {
3117 wfDebug( __METHOD__ . " called using plain parameters instead of "
3118 . "a PPFrame instance. Creating custom frame.\n" );
3119 $frame = $this->getPreprocessor()->newCustomFrame( $frame );
3120 }
3121
3122 $dom = $this->preprocessToDom( $text );
3123 $flags = $argsOnly ? PPFrame::NO_TEMPLATES : 0;
3124 $text = $frame->expand( $dom, $flags );
3125
3126 return $text;
3127 }
3128
3129 /**
3130 * Clean up argument array - refactored in 1.9 so parserfunctions can use it, too.
3131 *
3132 * @param array $args
3133 *
3134 * @return array
3135 */
3136 public static function createAssocArgs( $args ) {
3137 $assocArgs = [];
3138 $index = 1;
3139 foreach ( $args as $arg ) {
3140 $eqpos = strpos( $arg, '=' );
3141 if ( $eqpos === false ) {
3142 $assocArgs[$index++] = $arg;
3143 } else {
3144 $name = trim( substr( $arg, 0, $eqpos ) );
3145 $value = trim( substr( $arg, $eqpos + 1 ) );
3146 if ( $value === false ) {
3147 $value = '';
3148 }
3149 if ( $name !== false ) {
3150 $assocArgs[$name] = $value;
3151 }
3152 }
3153 }
3154
3155 return $assocArgs;
3156 }
3157
3158 /**
3159 * Warn the user when a parser limitation is reached
3160 * Will warn at most once the user per limitation type
3161 *
3162 * The results are shown during preview and run through the Parser (See EditPage.php)
3163 *
3164 * @param string $limitationType Should be one of:
3165 * 'expensive-parserfunction' (corresponding messages:
3166 * 'expensive-parserfunction-warning',
3167 * 'expensive-parserfunction-category')
3168 * 'post-expand-template-argument' (corresponding messages:
3169 * 'post-expand-template-argument-warning',
3170 * 'post-expand-template-argument-category')
3171 * 'post-expand-template-inclusion' (corresponding messages:
3172 * 'post-expand-template-inclusion-warning',
3173 * 'post-expand-template-inclusion-category')
3174 * 'node-count-exceeded' (corresponding messages:
3175 * 'node-count-exceeded-warning',
3176 * 'node-count-exceeded-category')
3177 * 'expansion-depth-exceeded' (corresponding messages:
3178 * 'expansion-depth-exceeded-warning',
3179 * 'expansion-depth-exceeded-category')
3180 * @param string|int|null $current Current value
3181 * @param string|int|null $max Maximum allowed, when an explicit limit has been
3182 * exceeded, provide the values (optional)
3183 */
3184 public function limitationWarn( $limitationType, $current = '', $max = '' ) {
3185 # does no harm if $current and $max are present but are unnecessary for the message
3186 # Not doing ->inLanguage( $this->mOptions->getUserLangObj() ), since this is shown
3187 # only during preview, and that would split the parser cache unnecessarily.
3188 $warning = wfMessage( "$limitationType-warning" )->numParams( $current, $max )
3189 ->text();
3190 $this->mOutput->addWarning( $warning );
3191 $this->addTrackingCategory( "$limitationType-category" );
3192 }
3193
3194 /**
3195 * Return the text of a template, after recursively
3196 * replacing any variables or templates within the template.
3197 *
3198 * @param array $piece The parts of the template
3199 * $piece['title']: the title, i.e. the part before the |
3200 * $piece['parts']: the parameter array
3201 * $piece['lineStart']: whether the brace was at the start of a line
3202 * @param PPFrame $frame The current frame, contains template arguments
3203 * @throws Exception
3204 * @return string The text of the template
3205 */
3206 public function braceSubstitution( $piece, $frame ) {
3207 // Flags
3208
3209 // $text has been filled
3210 $found = false;
3211 // wiki markup in $text should be escaped
3212 $nowiki = false;
3213 // $text is HTML, armour it against wikitext transformation
3214 $isHTML = false;
3215 // Force interwiki transclusion to be done in raw mode not rendered
3216 $forceRawInterwiki = false;
3217 // $text is a DOM node needing expansion in a child frame
3218 $isChildObj = false;
3219 // $text is a DOM node needing expansion in the current frame
3220 $isLocalObj = false;
3221
3222 # Title object, where $text came from
3223 $title = false;
3224
3225 # $part1 is the bit before the first |, and must contain only title characters.
3226 # Various prefixes will be stripped from it later.
3227 $titleWithSpaces = $frame->expand( $piece['title'] );
3228 $part1 = trim( $titleWithSpaces );
3229 $titleText = false;
3230
3231 # Original title text preserved for various purposes
3232 $originalTitle = $part1;
3233
3234 # $args is a list of argument nodes, starting from index 0, not including $part1
3235 # @todo FIXME: If piece['parts'] is null then the call to getLength()
3236 # below won't work b/c this $args isn't an object
3237 $args = ( $piece['parts'] == null ) ? [] : $piece['parts'];
3238
3239 $profileSection = null; // profile templates
3240
3241 # SUBST
3242 if ( !$found ) {
3243 $substMatch = $this->mSubstWords->matchStartAndRemove( $part1 );
3244
3245 # Possibilities for substMatch: "subst", "safesubst" or FALSE
3246 # Decide whether to expand template or keep wikitext as-is.
3247 if ( $this->ot['wiki'] ) {
3248 if ( $substMatch === false ) {
3249 $literal = true; # literal when in PST with no prefix
3250 } else {
3251 $literal = false; # expand when in PST with subst: or safesubst:
3252 }
3253 } else {
3254 if ( $substMatch == 'subst' ) {
3255 $literal = true; # literal when not in PST with plain subst:
3256 } else {
3257 $literal = false; # expand when not in PST with safesubst: or no prefix
3258 }
3259 }
3260 if ( $literal ) {
3261 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3262 $isLocalObj = true;
3263 $found = true;
3264 }
3265 }
3266
3267 # Variables
3268 if ( !$found && $args->getLength() == 0 ) {
3269 $id = $this->mVariables->matchStartToEnd( $part1 );
3270 if ( $id !== false ) {
3271 $text = $this->getVariableValue( $id, $frame );
3272 if ( $this->magicWordFactory->getCacheTTL( $id ) > -1 ) {
3273 $this->mOutput->updateCacheExpiry(
3274 $this->magicWordFactory->getCacheTTL( $id ) );
3275 }
3276 $found = true;
3277 }
3278 }
3279
3280 # MSG, MSGNW and RAW
3281 if ( !$found ) {
3282 # Check for MSGNW:
3283 $mwMsgnw = $this->magicWordFactory->get( 'msgnw' );
3284 if ( $mwMsgnw->matchStartAndRemove( $part1 ) ) {
3285 $nowiki = true;
3286 } else {
3287 # Remove obsolete MSG:
3288 $mwMsg = $this->magicWordFactory->get( 'msg' );
3289 $mwMsg->matchStartAndRemove( $part1 );
3290 }
3291
3292 # Check for RAW:
3293 $mwRaw = $this->magicWordFactory->get( 'raw' );
3294 if ( $mwRaw->matchStartAndRemove( $part1 ) ) {
3295 $forceRawInterwiki = true;
3296 }
3297 }
3298
3299 # Parser functions
3300 if ( !$found ) {
3301 $colonPos = strpos( $part1, ':' );
3302 if ( $colonPos !== false ) {
3303 $func = substr( $part1, 0, $colonPos );
3304 $funcArgs = [ trim( substr( $part1, $colonPos + 1 ) ) ];
3305 $argsLength = $args->getLength();
3306 for ( $i = 0; $i < $argsLength; $i++ ) {
3307 $funcArgs[] = $args->item( $i );
3308 }
3309
3310 $result = $this->callParserFunction( $frame, $func, $funcArgs );
3311
3312 // Extract any forwarded flags
3313 if ( isset( $result['title'] ) ) {
3314 $title = $result['title'];
3315 }
3316 if ( isset( $result['found'] ) ) {
3317 $found = $result['found'];
3318 }
3319 if ( array_key_exists( 'text', $result ) ) {
3320 // a string or null
3321 $text = $result['text'];
3322 }
3323 if ( isset( $result['nowiki'] ) ) {
3324 $nowiki = $result['nowiki'];
3325 }
3326 if ( isset( $result['isHTML'] ) ) {
3327 $isHTML = $result['isHTML'];
3328 }
3329 if ( isset( $result['forceRawInterwiki'] ) ) {
3330 $forceRawInterwiki = $result['forceRawInterwiki'];
3331 }
3332 if ( isset( $result['isChildObj'] ) ) {
3333 $isChildObj = $result['isChildObj'];
3334 }
3335 if ( isset( $result['isLocalObj'] ) ) {
3336 $isLocalObj = $result['isLocalObj'];
3337 }
3338 }
3339 }
3340
3341 # Finish mangling title and then check for loops.
3342 # Set $title to a Title object and $titleText to the PDBK
3343 if ( !$found ) {
3344 $ns = NS_TEMPLATE;
3345 # Split the title into page and subpage
3346 $subpage = '';
3347 $relative = $this->maybeDoSubpageLink( $part1, $subpage );
3348 if ( $part1 !== $relative ) {
3349 $part1 = $relative;
3350 $ns = $this->mTitle->getNamespace();
3351 }
3352 $title = Title::newFromText( $part1, $ns );
3353 if ( $title ) {
3354 $titleText = $title->getPrefixedText();
3355 # Check for language variants if the template is not found
3356 if ( $this->getTargetLanguage()->hasVariants() && $title->getArticleID() == 0 ) {
3357 $this->getTargetLanguage()->findVariantLink( $part1, $title, true );
3358 }
3359 # Do recursion depth check
3360 $limit = $this->mOptions->getMaxTemplateDepth();
3361 if ( $frame->depth >= $limit ) {
3362 $found = true;
3363 $text = '<span class="error">'
3364 . wfMessage( 'parser-template-recursion-depth-warning' )
3365 ->numParams( $limit )->inContentLanguage()->text()
3366 . '</span>';
3367 }
3368 }
3369 }
3370
3371 # Load from database
3372 if ( !$found && $title ) {
3373 $profileSection = $this->mProfiler->scopedProfileIn( $title->getPrefixedDBkey() );
3374 if ( !$title->isExternal() ) {
3375 if ( $title->isSpecialPage()
3376 && $this->mOptions->getAllowSpecialInclusion()
3377 && $this->ot['html']
3378 ) {
3379 $specialPage = $this->specialPageFactory->getPage( $title->getDBkey() );
3380 // Pass the template arguments as URL parameters.
3381 // "uselang" will have no effect since the Language object
3382 // is forced to the one defined in ParserOptions.
3383 $pageArgs = [];
3384 $argsLength = $args->getLength();
3385 for ( $i = 0; $i < $argsLength; $i++ ) {
3386 $bits = $args->item( $i )->splitArg();
3387 if ( strval( $bits['index'] ) === '' ) {
3388 $name = trim( $frame->expand( $bits['name'], PPFrame::STRIP_COMMENTS ) );
3389 $value = trim( $frame->expand( $bits['value'] ) );
3390 $pageArgs[$name] = $value;
3391 }
3392 }
3393
3394 // Create a new context to execute the special page
3395 $context = new RequestContext;
3396 $context->setTitle( $title );
3397 $context->setRequest( new FauxRequest( $pageArgs ) );
3398 if ( $specialPage && $specialPage->maxIncludeCacheTime() === 0 ) {
3399 $context->setUser( $this->getUser() );
3400 } else {
3401 // If this page is cached, then we better not be per user.
3402 $context->setUser( User::newFromName( '127.0.0.1', false ) );
3403 }
3404 $context->setLanguage( $this->mOptions->getUserLangObj() );
3405 $ret = $this->specialPageFactory->capturePath( $title, $context, $this->getLinkRenderer() );
3406 if ( $ret ) {
3407 $text = $context->getOutput()->getHTML();
3408 $this->mOutput->addOutputPageMetadata( $context->getOutput() );
3409 $found = true;
3410 $isHTML = true;
3411 if ( $specialPage && $specialPage->maxIncludeCacheTime() !== false ) {
3412 $this->mOutput->updateRuntimeAdaptiveExpiry(
3413 $specialPage->maxIncludeCacheTime()
3414 );
3415 }
3416 }
3417 } elseif ( $this->nsInfo->isNonincludable( $title->getNamespace() ) ) {
3418 $found = false; # access denied
3419 wfDebug( __METHOD__ . ": template inclusion denied for " .
3420 $title->getPrefixedDBkey() . "\n" );
3421 } else {
3422 list( $text, $title ) = $this->getTemplateDom( $title );
3423 if ( $text !== false ) {
3424 $found = true;
3425 $isChildObj = true;
3426 }
3427 }
3428
3429 # If the title is valid but undisplayable, make a link to it
3430 if ( !$found && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3431 $text = "[[:$titleText]]";
3432 $found = true;
3433 }
3434 } elseif ( $title->isTrans() ) {
3435 # Interwiki transclusion
3436 if ( $this->ot['html'] && !$forceRawInterwiki ) {
3437 $text = $this->interwikiTransclude( $title, 'render' );
3438 $isHTML = true;
3439 } else {
3440 $text = $this->interwikiTransclude( $title, 'raw' );
3441 # Preprocess it like a template
3442 $text = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
3443 $isChildObj = true;
3444 }
3445 $found = true;
3446 }
3447
3448 # Do infinite loop check
3449 # This has to be done after redirect resolution to avoid infinite loops via redirects
3450 if ( !$frame->loopCheck( $title ) ) {
3451 $found = true;
3452 $text = '<span class="error">'
3453 . wfMessage( 'parser-template-loop-warning', $titleText )->inContentLanguage()->text()
3454 . '</span>';
3455 $this->addTrackingCategory( 'template-loop-category' );
3456 $this->mOutput->addWarning( wfMessage( 'template-loop-warning',
3457 wfEscapeWikiText( $titleText ) )->text() );
3458 wfDebug( __METHOD__ . ": template loop broken at '$titleText'\n" );
3459 }
3460 }
3461
3462 # If we haven't found text to substitute by now, we're done
3463 # Recover the source wikitext and return it
3464 if ( !$found ) {
3465 $text = $frame->virtualBracketedImplode( '{{', '|', '}}', $titleWithSpaces, $args );
3466 if ( $profileSection ) {
3467 $this->mProfiler->scopedProfileOut( $profileSection );
3468 }
3469 return [ 'object' => $text ];
3470 }
3471
3472 # Expand DOM-style return values in a child frame
3473 if ( $isChildObj ) {
3474 # Clean up argument array
3475 $newFrame = $frame->newChild( $args, $title );
3476
3477 if ( $nowiki ) {
3478 $text = $newFrame->expand( $text, PPFrame::RECOVER_ORIG );
3479 } elseif ( $titleText !== false && $newFrame->isEmpty() ) {
3480 # Expansion is eligible for the empty-frame cache
3481 $text = $newFrame->cachedExpand( $titleText, $text );
3482 } else {
3483 # Uncached expansion
3484 $text = $newFrame->expand( $text );
3485 }
3486 }
3487 if ( $isLocalObj && $nowiki ) {
3488 $text = $frame->expand( $text, PPFrame::RECOVER_ORIG );
3489 $isLocalObj = false;
3490 }
3491
3492 if ( $profileSection ) {
3493 $this->mProfiler->scopedProfileOut( $profileSection );
3494 }
3495
3496 # Replace raw HTML by a placeholder
3497 if ( $isHTML ) {
3498 $text = $this->insertStripItem( $text );
3499 } elseif ( $nowiki && ( $this->ot['html'] || $this->ot['pre'] ) ) {
3500 # Escape nowiki-style return values
3501 $text = wfEscapeWikiText( $text );
3502 } elseif ( is_string( $text )
3503 && !$piece['lineStart']
3504 && preg_match( '/^(?:{\\||:|;|#|\*)/', $text )
3505 ) {
3506 # T2529: if the template begins with a table or block-level
3507 # element, it should be treated as beginning a new line.
3508 # This behavior is somewhat controversial.
3509 $text = "\n" . $text;
3510 }
3511
3512 if ( is_string( $text ) && !$this->incrementIncludeSize( 'post-expand', strlen( $text ) ) ) {
3513 # Error, oversize inclusion
3514 if ( $titleText !== false ) {
3515 # Make a working, properly escaped link if possible (T25588)
3516 $text = "[[:$titleText]]";
3517 } else {
3518 # This will probably not be a working link, but at least it may
3519 # provide some hint of where the problem is
3520 preg_replace( '/^:/', '', $originalTitle );
3521 $text = "[[:$originalTitle]]";
3522 }
3523 $text .= $this->insertStripItem( '<!-- WARNING: template omitted, '
3524 . 'post-expand include size too large -->' );
3525 $this->limitationWarn( 'post-expand-template-inclusion' );
3526 }
3527
3528 if ( $isLocalObj ) {
3529 $ret = [ 'object' => $text ];
3530 } else {
3531 $ret = [ 'text' => $text ];
3532 }
3533
3534 return $ret;
3535 }
3536
3537 /**
3538 * Call a parser function and return an array with text and flags.
3539 *
3540 * The returned array will always contain a boolean 'found', indicating
3541 * whether the parser function was found or not. It may also contain the
3542 * following:
3543 * text: string|object, resulting wikitext or PP DOM object
3544 * isHTML: bool, $text is HTML, armour it against wikitext transformation
3545 * isChildObj: bool, $text is a DOM node needing expansion in a child frame
3546 * isLocalObj: bool, $text is a DOM node needing expansion in the current frame
3547 * nowiki: bool, wiki markup in $text should be escaped
3548 *
3549 * @since 1.21
3550 * @param PPFrame $frame The current frame, contains template arguments
3551 * @param string $function Function name
3552 * @param array $args Arguments to the function
3553 * @throws MWException
3554 * @return array
3555 */
3556 public function callParserFunction( $frame, $function, array $args = [] ) {
3557 # Case sensitive functions
3558 if ( isset( $this->mFunctionSynonyms[1][$function] ) ) {
3559 $function = $this->mFunctionSynonyms[1][$function];
3560 } else {
3561 # Case insensitive functions
3562 $function = $this->contLang->lc( $function );
3563 if ( isset( $this->mFunctionSynonyms[0][$function] ) ) {
3564 $function = $this->mFunctionSynonyms[0][$function];
3565 } else {
3566 return [ 'found' => false ];
3567 }
3568 }
3569
3570 list( $callback, $flags ) = $this->mFunctionHooks[$function];
3571
3572 // Avoid PHP 7.1 warning from passing $this by reference
3573 $parser = $this;
3574
3575 $allArgs = [ &$parser ];
3576 if ( $flags & self::SFH_OBJECT_ARGS ) {
3577 # Convert arguments to PPNodes and collect for appending to $allArgs
3578 $funcArgs = [];
3579 foreach ( $args as $k => $v ) {
3580 if ( $v instanceof PPNode || $k === 0 ) {
3581 $funcArgs[] = $v;
3582 } else {
3583 $funcArgs[] = $this->mPreprocessor->newPartNodeArray( [ $k => $v ] )->item( 0 );
3584 }
3585 }
3586
3587 # Add a frame parameter, and pass the arguments as an array
3588 $allArgs[] = $frame;
3589 $allArgs[] = $funcArgs;
3590 } else {
3591 # Convert arguments to plain text and append to $allArgs
3592 foreach ( $args as $k => $v ) {
3593 if ( $v instanceof PPNode ) {
3594 $allArgs[] = trim( $frame->expand( $v ) );
3595 } elseif ( is_int( $k ) && $k >= 0 ) {
3596 $allArgs[] = trim( $v );
3597 } else {
3598 $allArgs[] = trim( "$k=$v" );
3599 }
3600 }
3601 }
3602
3603 $result = $callback( ...$allArgs );
3604
3605 # The interface for function hooks allows them to return a wikitext
3606 # string or an array containing the string and any flags. This mungs
3607 # things around to match what this method should return.
3608 if ( !is_array( $result ) ) {
3609 $result = [
3610 'found' => true,
3611 'text' => $result,
3612 ];
3613 } else {
3614 if ( isset( $result[0] ) && !isset( $result['text'] ) ) {
3615 $result['text'] = $result[0];
3616 }
3617 unset( $result[0] );
3618 $result += [
3619 'found' => true,
3620 ];
3621 }
3622
3623 $noparse = true;
3624 $preprocessFlags = 0;
3625 if ( isset( $result['noparse'] ) ) {
3626 $noparse = $result['noparse'];
3627 }
3628 if ( isset( $result['preprocessFlags'] ) ) {
3629 $preprocessFlags = $result['preprocessFlags'];
3630 }
3631
3632 if ( !$noparse ) {
3633 $result['text'] = $this->preprocessToDom( $result['text'], $preprocessFlags );
3634 $result['isChildObj'] = true;
3635 }
3636
3637 return $result;
3638 }
3639
3640 /**
3641 * Get the semi-parsed DOM representation of a template with a given title,
3642 * and its redirect destination title. Cached.
3643 *
3644 * @param Title $title
3645 *
3646 * @return array
3647 */
3648 public function getTemplateDom( $title ) {
3649 $cacheTitle = $title;
3650 $titleText = $title->getPrefixedDBkey();
3651
3652 if ( isset( $this->mTplRedirCache[$titleText] ) ) {
3653 list( $ns, $dbk ) = $this->mTplRedirCache[$titleText];
3654 $title = Title::makeTitle( $ns, $dbk );
3655 $titleText = $title->getPrefixedDBkey();
3656 }
3657 if ( isset( $this->mTplDomCache[$titleText] ) ) {
3658 return [ $this->mTplDomCache[$titleText], $title ];
3659 }
3660
3661 # Cache miss, go to the database
3662 list( $text, $title ) = $this->fetchTemplateAndTitle( $title );
3663
3664 if ( $text === false ) {
3665 $this->mTplDomCache[$titleText] = false;
3666 return [ false, $title ];
3667 }
3668
3669 $dom = $this->preprocessToDom( $text, self::PTD_FOR_INCLUSION );
3670 $this->mTplDomCache[$titleText] = $dom;
3671
3672 if ( !$title->equals( $cacheTitle ) ) {
3673 $this->mTplRedirCache[$cacheTitle->getPrefixedDBkey()] =
3674 [ $title->getNamespace(), $title->getDBkey() ];
3675 }
3676
3677 return [ $dom, $title ];
3678 }
3679
3680 /**
3681 * Fetch the current revision of a given title. Note that the revision
3682 * (and even the title) may not exist in the database, so everything
3683 * contributing to the output of the parser should use this method
3684 * where possible, rather than getting the revisions themselves. This
3685 * method also caches its results, so using it benefits performance.
3686 *
3687 * @since 1.24
3688 * @param Title $title
3689 * @return Revision
3690 */
3691 public function fetchCurrentRevisionOfTitle( $title ) {
3692 $cacheKey = $title->getPrefixedDBkey();
3693 if ( !$this->currentRevisionCache ) {
3694 $this->currentRevisionCache = new MapCacheLRU( 100 );
3695 }
3696 if ( !$this->currentRevisionCache->has( $cacheKey ) ) {
3697 $this->currentRevisionCache->set( $cacheKey,
3698 // Defaults to Parser::statelessFetchRevision()
3699 call_user_func( $this->mOptions->getCurrentRevisionCallback(), $title, $this )
3700 );
3701 }
3702 return $this->currentRevisionCache->get( $cacheKey );
3703 }
3704
3705 /**
3706 * Wrapper around Revision::newFromTitle to allow passing additional parameters
3707 * without passing them on to it.
3708 *
3709 * @since 1.24
3710 * @param Title $title
3711 * @param Parser|bool $parser
3712 * @return Revision|bool False if missing
3713 */
3714 public static function statelessFetchRevision( Title $title, $parser = false ) {
3715 $rev = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title );
3716
3717 return $rev;
3718 }
3719
3720 /**
3721 * Fetch the unparsed text of a template and register a reference to it.
3722 * @param Title $title
3723 * @return array ( string or false, Title )
3724 */
3725 public function fetchTemplateAndTitle( $title ) {
3726 // Defaults to Parser::statelessFetchTemplate()
3727 $templateCb = $this->mOptions->getTemplateCallback();
3728 $stuff = call_user_func( $templateCb, $title, $this );
3729 // We use U+007F DELETE to distinguish strip markers from regular text.
3730 $text = $stuff['text'];
3731 if ( is_string( $stuff['text'] ) ) {
3732 $text = strtr( $text, "\x7f", "?" );
3733 }
3734 $finalTitle = $stuff['finalTitle'] ?? $title;
3735 if ( isset( $stuff['deps'] ) ) {
3736 foreach ( $stuff['deps'] as $dep ) {
3737 $this->mOutput->addTemplate( $dep['title'], $dep['page_id'], $dep['rev_id'] );
3738 if ( $dep['title']->equals( $this->getTitle() ) ) {
3739 // If we transclude ourselves, the final result
3740 // will change based on the new version of the page
3741 $this->mOutput->setFlag( '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 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
5975 */
5976 public function getRevisionTimestamp() {
5977 if ( is_null( $this->mRevisionTimestamp ) ) {
5978 $revObject = $this->getRevisionObject();
5979 $timestamp = $revObject ? $revObject->getTimestamp() : wfTimestampNow();
5980
5981 # The cryptic '' timezone parameter tells to use the site-default
5982 # timezone offset instead of the user settings.
5983 # Since this value will be saved into the parser cache, served
5984 # to other users, and potentially even used inside links and such,
5985 # it needs to be consistent for all visitors.
5986 $this->mRevisionTimestamp = $this->contLang->userAdjust( $timestamp, '' );
5987 }
5988 return $this->mRevisionTimestamp;
5989 }
5990
5991 /**
5992 * Get the name of the user that edited the last revision
5993 *
5994 * @return string User name
5995 */
5996 public function getRevisionUser() {
5997 if ( is_null( $this->mRevisionUser ) ) {
5998 $revObject = $this->getRevisionObject();
5999
6000 # if this template is subst: the revision id will be blank,
6001 # so just use the current user's name
6002 if ( $revObject ) {
6003 $this->mRevisionUser = $revObject->getUserText();
6004 } elseif ( $this->ot['wiki'] || $this->mOptions->getIsPreview() ) {
6005 $this->mRevisionUser = $this->getUser()->getName();
6006 }
6007 }
6008 return $this->mRevisionUser;
6009 }
6010
6011 /**
6012 * Get the size of the revision
6013 *
6014 * @return int|null Revision size
6015 */
6016 public function getRevisionSize() {
6017 if ( is_null( $this->mRevisionSize ) ) {
6018 $revObject = $this->getRevisionObject();
6019
6020 # if this variable is subst: the revision id will be blank,
6021 # so just use the parser input size, because the own substituation
6022 # will change the size.
6023 if ( $revObject ) {
6024 $this->mRevisionSize = $revObject->getSize();
6025 } else {
6026 $this->mRevisionSize = $this->mInputSize;
6027 }
6028 }
6029 return $this->mRevisionSize;
6030 }
6031
6032 /**
6033 * Mutator for $mDefaultSort
6034 *
6035 * @param string $sort New value
6036 */
6037 public function setDefaultSort( $sort ) {
6038 $this->mDefaultSort = $sort;
6039 $this->mOutput->setProperty( 'defaultsort', $sort );
6040 }
6041
6042 /**
6043 * Accessor for $mDefaultSort
6044 * Will use the empty string if none is set.
6045 *
6046 * This value is treated as a prefix, so the
6047 * empty string is equivalent to sorting by
6048 * page name.
6049 *
6050 * @return string
6051 */
6052 public function getDefaultSort() {
6053 if ( $this->mDefaultSort !== false ) {
6054 return $this->mDefaultSort;
6055 } else {
6056 return '';
6057 }
6058 }
6059
6060 /**
6061 * Accessor for $mDefaultSort
6062 * Unlike getDefaultSort(), will return false if none is set
6063 *
6064 * @return string|bool
6065 */
6066 public function getCustomDefaultSort() {
6067 return $this->mDefaultSort;
6068 }
6069
6070 private static function getSectionNameFromStrippedText( $text ) {
6071 $text = Sanitizer::normalizeSectionNameWhitespace( $text );
6072 $text = Sanitizer::decodeCharReferences( $text );
6073 $text = self::normalizeSectionName( $text );
6074 return $text;
6075 }
6076
6077 private static function makeAnchor( $sectionName ) {
6078 return '#' . Sanitizer::escapeIdForLink( $sectionName );
6079 }
6080
6081 private function makeLegacyAnchor( $sectionName ) {
6082 $fragmentMode = $this->svcOptions->get( 'FragmentMode' );
6083 if ( isset( $fragmentMode[1] ) && $fragmentMode[1] === 'legacy' ) {
6084 // ForAttribute() and ForLink() are the same for legacy encoding
6085 $id = Sanitizer::escapeIdForAttribute( $sectionName, Sanitizer::ID_FALLBACK );
6086 } else {
6087 $id = Sanitizer::escapeIdForLink( $sectionName );
6088 }
6089
6090 return "#$id";
6091 }
6092
6093 /**
6094 * Try to guess the section anchor name based on a wikitext fragment
6095 * presumably extracted from a heading, for example "Header" from
6096 * "== Header ==".
6097 *
6098 * @param string $text
6099 * @return string Anchor (starting with '#')
6100 */
6101 public function guessSectionNameFromWikiText( $text ) {
6102 # Strip out wikitext links(they break the anchor)
6103 $text = $this->stripSectionName( $text );
6104 $sectionName = self::getSectionNameFromStrippedText( $text );
6105 return self::makeAnchor( $sectionName );
6106 }
6107
6108 /**
6109 * Same as guessSectionNameFromWikiText(), but produces legacy anchors
6110 * instead, if possible. For use in redirects, since various versions
6111 * of Microsoft browsers interpret Location: headers as something other
6112 * than UTF-8, resulting in breakage.
6113 *
6114 * @param string $text The section name
6115 * @return string Anchor (starting with '#')
6116 */
6117 public function guessLegacySectionNameFromWikiText( $text ) {
6118 # Strip out wikitext links(they break the anchor)
6119 $text = $this->stripSectionName( $text );
6120 $sectionName = self::getSectionNameFromStrippedText( $text );
6121 return $this->makeLegacyAnchor( $sectionName );
6122 }
6123
6124 /**
6125 * Like guessSectionNameFromWikiText(), but takes already-stripped text as input.
6126 * @param string $text Section name (plain text)
6127 * @return string Anchor (starting with '#')
6128 */
6129 public static function guessSectionNameFromStrippedText( $text ) {
6130 $sectionName = self::getSectionNameFromStrippedText( $text );
6131 return self::makeAnchor( $sectionName );
6132 }
6133
6134 /**
6135 * Apply the same normalization as code making links to this section would
6136 *
6137 * @param string $text
6138 * @return string
6139 */
6140 private static function normalizeSectionName( $text ) {
6141 # T90902: ensure the same normalization is applied for IDs as to links
6142 $titleParser = MediaWikiServices::getInstance()->getTitleParser();
6143 try {
6144
6145 $parts = $titleParser->splitTitleString( "#$text" );
6146 } catch ( MalformedTitleException $ex ) {
6147 return $text;
6148 }
6149 return $parts['fragment'];
6150 }
6151
6152 /**
6153 * Strips a text string of wikitext for use in a section anchor
6154 *
6155 * Accepts a text string and then removes all wikitext from the
6156 * string and leaves only the resultant text (i.e. the result of
6157 * [[User:WikiSysop|Sysop]] would be "Sysop" and the result of
6158 * [[User:WikiSysop]] would be "User:WikiSysop") - this is intended
6159 * to create valid section anchors by mimicing the output of the
6160 * parser when headings are parsed.
6161 *
6162 * @param string $text Text string to be stripped of wikitext
6163 * for use in a Section anchor
6164 * @return string Filtered text string
6165 */
6166 public function stripSectionName( $text ) {
6167 # Strip internal link markup
6168 $text = preg_replace( '/\[\[:?([^[|]+)\|([^[]+)\]\]/', '$2', $text );
6169 $text = preg_replace( '/\[\[:?([^[]+)\|?\]\]/', '$1', $text );
6170
6171 # Strip external link markup
6172 # @todo FIXME: Not tolerant to blank link text
6173 # I.E. [https://www.mediawiki.org] will render as [1] or something depending
6174 # on how many empty links there are on the page - need to figure that out.
6175 $text = preg_replace( '/\[(?i:' . $this->mUrlProtocols . ')([^ ]+?) ([^[]+)\]/', '$2', $text );
6176
6177 # Parse wikitext quotes (italics & bold)
6178 $text = $this->doQuotes( $text );
6179
6180 # Strip HTML tags
6181 $text = StringUtils::delimiterReplace( '<', '>', '', $text );
6182 return $text;
6183 }
6184
6185 /**
6186 * strip/replaceVariables/unstrip for preprocessor regression testing
6187 *
6188 * @param string $text
6189 * @param Title $title
6190 * @param ParserOptions $options
6191 * @param int $outputType
6192 *
6193 * @return string
6194 */
6195 public function testSrvus( $text, Title $title, ParserOptions $options,
6196 $outputType = self::OT_HTML
6197 ) {
6198 $magicScopeVariable = $this->lock();
6199 $this->startParse( $title, $options, $outputType, true );
6200
6201 $text = $this->replaceVariables( $text );
6202 $text = $this->mStripState->unstripBoth( $text );
6203 $text = Sanitizer::removeHTMLtags( $text );
6204 return $text;
6205 }
6206
6207 /**
6208 * @param string $text
6209 * @param Title $title
6210 * @param ParserOptions $options
6211 * @return string
6212 */
6213 public function testPst( $text, Title $title, ParserOptions $options ) {
6214 return $this->preSaveTransform( $text, $title, $options->getUser(), $options );
6215 }
6216
6217 /**
6218 * @param string $text
6219 * @param Title $title
6220 * @param ParserOptions $options
6221 * @return string
6222 */
6223 public function testPreprocess( $text, Title $title, ParserOptions $options ) {
6224 return $this->testSrvus( $text, $title, $options, self::OT_PREPROCESS );
6225 }
6226
6227 /**
6228 * Call a callback function on all regions of the given text that are not
6229 * inside strip markers, and replace those regions with the return value
6230 * of the callback. For example, with input:
6231 *
6232 * aaa<MARKER>bbb
6233 *
6234 * This will call the callback function twice, with 'aaa' and 'bbb'. Those
6235 * two strings will be replaced with the value returned by the callback in
6236 * each case.
6237 *
6238 * @param string $s
6239 * @param callable $callback
6240 *
6241 * @return string
6242 */
6243 public function markerSkipCallback( $s, $callback ) {
6244 $i = 0;
6245 $out = '';
6246 while ( $i < strlen( $s ) ) {
6247 $markerStart = strpos( $s, self::MARKER_PREFIX, $i );
6248 if ( $markerStart === false ) {
6249 $out .= call_user_func( $callback, substr( $s, $i ) );
6250 break;
6251 } else {
6252 $out .= call_user_func( $callback, substr( $s, $i, $markerStart - $i ) );
6253 $markerEnd = strpos( $s, self::MARKER_SUFFIX, $markerStart );
6254 if ( $markerEnd === false ) {
6255 $out .= substr( $s, $markerStart );
6256 break;
6257 } else {
6258 $markerEnd += strlen( self::MARKER_SUFFIX );
6259 $out .= substr( $s, $markerStart, $markerEnd - $markerStart );
6260 $i = $markerEnd;
6261 }
6262 }
6263 }
6264 return $out;
6265 }
6266
6267 /**
6268 * Remove any strip markers found in the given text.
6269 *
6270 * @param string $text
6271 * @return string
6272 */
6273 public function killMarkers( $text ) {
6274 return $this->mStripState->killMarkers( $text );
6275 }
6276
6277 /**
6278 * Save the parser state required to convert the given half-parsed text to
6279 * HTML. "Half-parsed" in this context means the output of
6280 * recursiveTagParse() or internalParse(). This output has strip markers
6281 * from replaceVariables (extensionSubstitution() etc.), and link
6282 * placeholders from replaceLinkHolders().
6283 *
6284 * Returns an array which can be serialized and stored persistently. This
6285 * array can later be loaded into another parser instance with
6286 * unserializeHalfParsedText(). The text can then be safely incorporated into
6287 * the return value of a parser hook.
6288 *
6289 * @deprecated since 1.31
6290 * @param string $text
6291 *
6292 * @return array
6293 */
6294 public function serializeHalfParsedText( $text ) {
6295 wfDeprecated( __METHOD__, '1.31' );
6296 $data = [
6297 'text' => $text,
6298 'version' => self::HALF_PARSED_VERSION,
6299 'stripState' => $this->mStripState->getSubState( $text ),
6300 'linkHolders' => $this->mLinkHolders->getSubArray( $text )
6301 ];
6302 return $data;
6303 }
6304
6305 /**
6306 * Load the parser state given in the $data array, which is assumed to
6307 * have been generated by serializeHalfParsedText(). The text contents is
6308 * extracted from the array, and its markers are transformed into markers
6309 * appropriate for the current Parser instance. This transformed text is
6310 * returned, and can be safely included in the return value of a parser
6311 * hook.
6312 *
6313 * If the $data array has been stored persistently, the caller should first
6314 * check whether it is still valid, by calling isValidHalfParsedText().
6315 *
6316 * @deprecated since 1.31
6317 * @param array $data Serialized data
6318 * @throws MWException
6319 * @return string
6320 */
6321 public function unserializeHalfParsedText( $data ) {
6322 wfDeprecated( __METHOD__, '1.31' );
6323 if ( !isset( $data['version'] ) || $data['version'] != self::HALF_PARSED_VERSION ) {
6324 throw new MWException( __METHOD__ . ': invalid version' );
6325 }
6326
6327 # First, extract the strip state.
6328 $texts = [ $data['text'] ];
6329 $texts = $this->mStripState->merge( $data['stripState'], $texts );
6330
6331 # Now renumber links
6332 $texts = $this->mLinkHolders->mergeForeign( $data['linkHolders'], $texts );
6333
6334 # Should be good to go.
6335 return $texts[0];
6336 }
6337
6338 /**
6339 * Returns true if the given array, presumed to be generated by
6340 * serializeHalfParsedText(), is compatible with the current version of the
6341 * parser.
6342 *
6343 * @deprecated since 1.31
6344 * @param array $data
6345 *
6346 * @return bool
6347 */
6348 public function isValidHalfParsedText( $data ) {
6349 wfDeprecated( __METHOD__, '1.31' );
6350 return isset( $data['version'] ) && $data['version'] == self::HALF_PARSED_VERSION;
6351 }
6352
6353 /**
6354 * Parsed a width param of imagelink like 300px or 200x300px
6355 *
6356 * @param string $value
6357 * @param bool $parseHeight
6358 *
6359 * @return array
6360 * @since 1.20
6361 */
6362 public static function parseWidthParam( $value, $parseHeight = true ) {
6363 $parsedWidthParam = [];
6364 if ( $value === '' ) {
6365 return $parsedWidthParam;
6366 }
6367 $m = [];
6368 # (T15500) In both cases (width/height and width only),
6369 # permit trailing "px" for backward compatibility.
6370 if ( $parseHeight && preg_match( '/^([0-9]*)x([0-9]*)\s*(?:px)?\s*$/', $value, $m ) ) {
6371 $width = intval( $m[1] );
6372 $height = intval( $m[2] );
6373 $parsedWidthParam['width'] = $width;
6374 $parsedWidthParam['height'] = $height;
6375 } elseif ( preg_match( '/^[0-9]*\s*(?:px)?\s*$/', $value ) ) {
6376 $width = intval( $value );
6377 $parsedWidthParam['width'] = $width;
6378 }
6379 return $parsedWidthParam;
6380 }
6381
6382 /**
6383 * Lock the current instance of the parser.
6384 *
6385 * This is meant to stop someone from calling the parser
6386 * recursively and messing up all the strip state.
6387 *
6388 * @throws MWException If parser is in a parse
6389 * @return ScopedCallback The lock will be released once the return value goes out of scope.
6390 */
6391 protected function lock() {
6392 if ( $this->mInParse ) {
6393 throw new MWException( "Parser state cleared while parsing. "
6394 . "Did you call Parser::parse recursively? Lock is held by: " . $this->mInParse );
6395 }
6396
6397 // Save the backtrace when locking, so that if some code tries locking again,
6398 // we can print the lock owner's backtrace for easier debugging
6399 $e = new Exception;
6400 $this->mInParse = $e->getTraceAsString();
6401
6402 $recursiveCheck = new ScopedCallback( function () {
6403 $this->mInParse = false;
6404 } );
6405
6406 return $recursiveCheck;
6407 }
6408
6409 /**
6410 * Strip outer <p></p> tag from the HTML source of a single paragraph.
6411 *
6412 * Returns original HTML if the <p/> tag has any attributes, if there's no wrapping <p/> tag,
6413 * or if there is more than one <p/> tag in the input HTML.
6414 *
6415 * @param string $html
6416 * @return string
6417 * @since 1.24
6418 */
6419 public static function stripOuterParagraph( $html ) {
6420 $m = [];
6421 if ( preg_match( '/^<p>(.*)\n?<\/p>\n?$/sU', $html, $m ) && strpos( $m[1], '</p>' ) === false ) {
6422 $html = $m[1];
6423 }
6424
6425 return $html;
6426 }
6427
6428 /**
6429 * Return this parser if it is not doing anything, otherwise
6430 * get a fresh parser. You can use this method by doing
6431 * $newParser = $oldParser->getFreshParser(), or more simply
6432 * $oldParser->getFreshParser()->parse( ... );
6433 * if you're unsure if $oldParser is safe to use.
6434 *
6435 * @since 1.24
6436 * @return Parser A parser object that is not parsing anything
6437 */
6438 public function getFreshParser() {
6439 if ( $this->mInParse ) {
6440 return $this->factory->create();
6441 } else {
6442 return $this;
6443 }
6444 }
6445
6446 /**
6447 * Set's up the PHP implementation of OOUI for use in this request
6448 * and instructs OutputPage to enable OOUI for itself.
6449 *
6450 * @since 1.26
6451 */
6452 public function enableOOUI() {
6453 OutputPage::setupOOUI();
6454 $this->mOutput->setEnableOOUI( true );
6455 }
6456 }