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