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