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