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