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