Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.28% covered (warning)
51.28%
261 / 509
28.89% covered (danger)
28.89%
13 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageConverter
51.38% covered (warning)
51.38%
261 / 508
28.89% covered (danger)
28.89%
13 / 45
3904.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMainCode
n/a
0 / 0
n/a
0 / 0
0
 getStaticDefaultVariant
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLanguageVariants
n/a
0 / 0
n/a
0 / 0
0
 getVariantsFallbacks
n/a
0 / 0
n/a
0 / 0
0
 getFlags
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getAdditionalFlags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getManualLevel
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getAdditionalManualLevel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescCodeSeparator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescVarSeparator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVariantNames
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getVariants
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getVariantFallbacks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConvRuleTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPreferredVariant
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 getDefaultVariant
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 validateVariant
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getURLVariant
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 getUserVariant
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 getHeaderVariant
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
16.65
 autoConvert
55.84% covered (warning)
55.84%
43 / 77
0.00% covered (danger)
0.00%
0 / 1
50.08
 translate
33.33% covered (danger)
33.33%
5 / 15
0.00% covered (danger)
0.00%
0 / 1
5.67
 translateWithoutRomanNumbers
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
3.23
 autoConvertToAllVariants
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 applyManualConv
35.71% covered (danger)
35.71%
5 / 14
0.00% covered (danger)
0.00%
0 / 1
20.02
 convertSplitTitle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 convertTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 convertNamespace
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 computeNsVariantText
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 convert
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 convertTo
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 recursiveConvertTopLevel
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 recursiveConvertRule
51.28% covered (warning)
51.28%
20 / 39
0.00% covered (danger)
0.00%
0 / 1
24.99
 findVariantLink
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
420
 getExtraHashOptions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 guessVariant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadDefaultTables
n/a
0 / 0
n/a
0 / 0
0
 loadTables
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
4
 postLoadTables
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reloadTables
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parseCachedTable
17.19% covered (danger)
17.19%
11 / 64
0.00% covered (danger)
0.00%
0 / 1
296.87
 markNoConversion
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 convertCategoryKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateConversionTable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 getVarSeparatorPattern
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 hasVariants
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasVariant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 convertHtml
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @author Zhengzhu Feng <zhengzhu@gmail.com>
6 * @author fdcn <fdcn64@gmail.com>
7 * @author shinjiman <shinjiman@gmail.com>
8 * @author PhiLiP <philip.npc@gmail.com>
9 */
10
11namespace MediaWiki\Language;
12
13use InvalidArgumentException;
14use MediaWiki\Context\RequestContext;
15use MediaWiki\Debug\DeprecationHelper;
16use MediaWiki\HookContainer\HookRunner;
17use MediaWiki\Html\Html;
18use MediaWiki\Linker\LinkTarget;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MainConfigNames;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Page\PageIdentity;
23use MediaWiki\Parser\Parser;
24use MediaWiki\Parser\Sanitizer;
25use MediaWiki\Revision\RevisionRecord;
26use MediaWiki\Revision\SlotRecord;
27use MediaWiki\StubObject\StubUserLang;
28use MediaWiki\Title\Title;
29use MediaWiki\User\User;
30use RuntimeException;
31use UnexpectedValueException;
32use Wikimedia\ObjectCache\BagOStuff;
33use Wikimedia\ReplacementArray;
34use Wikimedia\StringUtils\StringUtils;
35
36/**
37 * Base class for multi-variant language conversion.
38 *
39 * @ingroup Language
40 */
41abstract class LanguageConverter implements ILanguageConverter {
42    use DeprecationHelper;
43
44    /**
45     * languages supporting variants
46     * @since 1.20
47     * @var string[]
48     * @phpcs-require-sorted-array
49     */
50    public static $languagesWithVariants = [
51        'ban',
52        'crh',
53        'en',
54        'gan',
55        'iu',
56        'ku',
57        'mni',
58        'sh',
59        'shi',
60        'sr',
61        'tg',
62        'tly',
63        'uz',
64        'wuu',
65        'zgh',
66        'zh',
67    ];
68
69    /**
70     * static default variant of languages supporting variants
71     * for use with DefaultOptionsLookup.php
72     * @since 1.40
73     * @var array<string,string>
74     * @phpcs-require-sorted-array
75     */
76    public static $languagesWithStaticDefaultVariant = [
77        'ban' => 'ban',
78        'crh' => 'crh',
79        'en' => 'en',
80        'gan' => 'gan',
81        'iu' => 'iu',
82        'ku' => 'ku',
83        'mni' => 'mni',
84        'sh' => 'sh-latn',
85        'shi' => 'shi',
86        'sr' => 'sr',
87        'tg' => 'tg',
88        'tly' => 'tly',
89        'uz' => 'uz',
90        'wuu' => 'wuu',
91        'zgh' => 'zgh',
92        'zh' => 'zh',
93    ];
94
95    /** @var bool */
96    private $mTablesLoaded = false;
97    /** @var ReplacementArray[] */
98    protected $mTables = [];
99    /** @var Language|StubUserLang */
100    private $mLangObj;
101    /** @var string|false */
102    private $mConvRuleTitle = false;
103    /** @var string|null */
104    private $mURLVariant;
105    /** @var string|null */
106    private $mUserVariant;
107    /** @var string|null */
108    private $mHeaderVariant;
109    /** @var int */
110    private $mMaxDepth = 10;
111    /** @var string|null */
112    private $mVarSeparatorPattern;
113
114    private const CACHE_VERSION_KEY = 'VERSION 8';
115
116    /**
117     * @param Language|StubUserLang $langobj
118     */
119    public function __construct( $langobj ) {
120        $this->mLangObj = $langobj;
121    }
122
123    /**
124     * Get the language code with converter (the "main" language code).
125     * Page language code would be the same of the language code with converter.
126     * Note that this code might not be included as one of the variant languages.
127     * @since 1.36
128     *
129     * @return string
130     */
131    abstract public function getMainCode(): string;
132
133    /**
134     * Get static default variant.
135     * For use of specify the default variant form when it different from the
136     *  default "unconverted/mixed-variant form".
137     * @since 1.40
138     *
139     * @return string
140     */
141    protected function getStaticDefaultVariant(): string {
142        $code = $this->getMainCode();
143        return self::$languagesWithStaticDefaultVariant[$code] ?? $code;
144    }
145
146    /**
147     * Get supported variants of the language.
148     * @since 1.36
149     *
150     * @return array
151     */
152    abstract protected function getLanguageVariants(): array;
153
154    /**
155     * Get language variants fallbacks.
156     * @since 1.36
157     *
158     * @return array
159     */
160    abstract public function getVariantsFallbacks(): array;
161
162    /**
163     * Get the strings that map to the flags.
164     * @since 1.36
165     *
166     * @return array
167     */
168    final public function getFlags(): array {
169        $defaultflags = [
170            // 'S' show the converted text
171            // '+' add rules for alltext
172            // 'E' the flags have an error
173            // these flags above are reserved for program
174            'A' => 'A', // add rule for convert code (all text converted)
175            'T' => 'T', // title convert
176            'R' => 'R', // raw content
177            'D' => 'D', // convert description (subclass implement)
178            '-' => '-', // remove convert (not implement)
179            'H' => 'H', // add rule for convert code (but no display in placed code)
180            'N' => 'N', // current variant name
181        ];
182        $flags = array_merge( $defaultflags, $this->getAdditionalFlags() );
183        foreach ( $this->getVariants() as $v ) {
184            $flags[$v] = $v;
185        }
186        return $flags;
187    }
188
189    /**
190     * Provides additional flags for converter. By default, it returns empty array and
191     * typically should be overridden by implementation of converter.
192     */
193    protected function getAdditionalFlags(): array {
194        return [];
195    }
196
197    /**
198     * Get manual level limit for supported variants.
199     * @since 1.36
200     *
201     * @return array
202     */
203    final public function getManualLevel() {
204        $manualLevel  = $this->getAdditionalManualLevel();
205        $result = [];
206        foreach ( $this->getVariants() as $v ) {
207            if ( array_key_exists( $v, $manualLevel ) ) {
208                $result[$v] = $manualLevel[$v];
209            } else {
210                $result[$v] = 'bidirectional';
211            }
212        }
213        return $result;
214    }
215
216    /**
217     * Provides additional flags for converter. By default, this function returns an empty array and
218     * typically should be overridden by the implementation of converter.
219     * @since 1.36
220     *
221     * @return array
222     */
223    protected function getAdditionalManualLevel(): array {
224        return [];
225    }
226
227    /**
228     * Get desc code separator. By default returns ":", can be overridden by
229     * implementation of converter.
230     * @since 1.36
231     *
232     * @return string
233     */
234    public function getDescCodeSeparator(): string {
235        return ':';
236    }
237
238    /**
239     * Get desc var separator. By default returns ";", can be overridden by
240     * implementation of converter.
241     * @since 1.36
242     *
243     * @return string
244     */
245    public function getDescVarSeparator(): string {
246        return ';';
247    }
248
249    public function getVariantNames(): array {
250        return MediaWikiServices::getInstance()
251            ->getLanguageNameUtils()
252            ->getLanguageNames();
253    }
254
255    /** @inheritDoc */
256    final public function getVariants() {
257        $disabledVariants = MediaWikiServices::getInstance()->getMainConfig()->get(
258            MainConfigNames::DisabledVariants );
259        return array_diff( $this->getLanguageVariants(), $disabledVariants );
260    }
261
262    /** @inheritDoc */
263    public function getVariantFallbacks( $variant ) {
264        return $this->getVariantsFallbacks()[$variant] ?? $this->getStaticDefaultVariant();
265    }
266
267    /** @inheritDoc */
268    public function getConvRuleTitle() {
269        return $this->mConvRuleTitle;
270    }
271
272    /** @inheritDoc */
273    public function getPreferredVariant() {
274        $req = $this->getURLVariant();
275
276        $services = MediaWikiServices::getInstance();
277        ( new HookRunner( $services->getHookContainer() ) )->onGetLangPreferredVariant( $req );
278
279        if ( !$req ) {
280            $user = RequestContext::getMain()->getUser();
281            // NOTE: For some calls there may not be a context user or session that is safe
282            // to use, see (T235360)
283            // Use case: During user autocreation, UserNameUtils::isUsable is called which uses interface
284            // messages for reserved usernames.
285            if ( $user->isSafeToLoad() && $user->isRegistered() ) {
286                $req = $this->getUserVariant( $user );
287            } else {
288                $req = $this->getHeaderVariant();
289            }
290        }
291
292        $defaultLanguageVariant = $services->getMainConfig()
293            ->get( MainConfigNames::DefaultLanguageVariant );
294        if ( !$req && $defaultLanguageVariant ) {
295            $req = $this->validateVariant( $defaultLanguageVariant );
296        }
297
298        $req = $this->validateVariant( $req );
299
300        // This function, unlike the other get*Variant functions, is
301        // not memoized (i.e., there return value is not cached) since
302        // new information might appear during processing after this
303        // is first called.
304        return $req ?? $this->getStaticDefaultVariant();
305    }
306
307    /** @inheritDoc */
308    public function getDefaultVariant() {
309        $defaultLanguageVariant = MediaWikiServices::getInstance()->getMainConfig()->get(
310            MainConfigNames::DefaultLanguageVariant );
311
312        $req = $this->getURLVariant() ?? $this->getHeaderVariant();
313
314        if ( !$req && $defaultLanguageVariant ) {
315            $req = $this->validateVariant( $defaultLanguageVariant );
316        }
317
318        return $req ?? $this->getStaticDefaultVariant();
319    }
320
321    /** @inheritDoc */
322    public function validateVariant( $variant = null ) {
323        if ( $variant === null ) {
324            return null;
325        }
326        // Our internal variants are always lower-case; the variant we
327        // are validating may have mixed cases.
328        $variant = LanguageCode::replaceDeprecatedCodes( strtolower( $variant ) );
329        if ( in_array( $variant, $this->getVariants() ) ) {
330            return $variant;
331        }
332        // Browsers are supposed to use BCP 47 standard in the
333        // Accept-Language header, but not all of our internal
334        // mediawiki variant codes are BCP 47.  Map BCP 47 code
335        // to our internal code.
336        foreach ( $this->getVariants() as $v ) {
337            // Case-insensitive match (BCP 47 is mixed-case)
338            if ( strtolower( LanguageCode::bcp47( $v ) ) === $variant ) {
339                return $v;
340            }
341        }
342        return null;
343    }
344
345    /** @inheritDoc */
346    public function getURLVariant() {
347        if ( $this->mURLVariant ) {
348            return $this->mURLVariant;
349        }
350
351        $request = RequestContext::getMain()->getRequest();
352        // see if the preference is set in the request
353        $ret = $request->getText( 'variant' );
354
355        if ( !$ret ) {
356            $ret = $request->getVal( 'uselang' );
357        }
358
359        $this->mURLVariant = $this->validateVariant( $ret );
360        return $this->mURLVariant;
361    }
362
363    /**
364     * Determine if the user has a variant set.
365     *
366     * @param User $user
367     * @return string|null Variant if one found, null otherwise
368     */
369    protected function getUserVariant( User $user ) {
370        // This should only be called within the class after the user is known to be
371        // safe to load and logged in, but check just in case.
372        if ( !$user->isSafeToLoad() ) {
373            return null;
374        }
375
376        if ( !$this->mUserVariant ) {
377            $services = MediaWikiServices::getInstance();
378            if ( $user->isRegistered() ) {
379                // Get language variant preference from logged in users
380                if (
381                    $this->getMainCode() ===
382                    $services->getContentLanguageCode()->toString()
383                ) {
384                    $optionName = 'variant';
385                } else {
386                    $optionName = 'variant-' . $this->getMainCode();
387                }
388            } else {
389                // figure out user lang without constructing wgLang to avoid
390                // infinite recursion
391                $optionName = 'language';
392            }
393            $ret = $services->getUserOptionsLookup()->getOption( $user, $optionName );
394
395            $this->mUserVariant = $this->validateVariant( $ret );
396        }
397
398        return $this->mUserVariant;
399    }
400
401    /**
402     * Determine the language variant from the Accept-Language header.
403     *
404     * @return string|null Variant if one found, null otherwise
405     */
406    protected function getHeaderVariant() {
407        if ( $this->mHeaderVariant ) {
408            return $this->mHeaderVariant;
409        }
410
411        $request = RequestContext::getMain()->getRequest();
412        // See if some supported language variant is set in the
413        // HTTP header.
414        $languages = array_keys( $request->getAcceptLang() );
415        if ( !$languages ) {
416            return null;
417        }
418
419        $fallbackLanguages = [];
420        foreach ( $languages as $language ) {
421            $this->mHeaderVariant = $this->validateVariant( $language );
422            if ( $this->mHeaderVariant ) {
423                break;
424            }
425
426            // To see if there are fallbacks of current language.
427            // We record these fallback variants, and process
428            // them later.
429            $fallbacks = $this->getVariantFallbacks( $language );
430            if (
431                is_string( $fallbacks ) &&
432                $fallbacks !== $this->getStaticDefaultVariant()
433            ) {
434                $fallbackLanguages[] = $fallbacks;
435            } elseif ( is_array( $fallbacks ) ) {
436                $fallbackLanguages =
437                    array_merge( $fallbackLanguages, $fallbacks );
438            }
439        }
440
441        if ( !$this->mHeaderVariant ) {
442            // process fallback languages now
443            $fallback_languages = array_unique( $fallbackLanguages );
444            foreach ( $fallback_languages as $language ) {
445                $this->mHeaderVariant = $this->validateVariant( $language );
446                if ( $this->mHeaderVariant ) {
447                    break;
448                }
449            }
450        }
451
452        return $this->mHeaderVariant;
453    }
454
455    /** @inheritDoc */
456    public function autoConvert( $text, $toVariant = false ) {
457        $this->loadTables();
458
459        if ( !$toVariant ) {
460            $toVariant = $this->getPreferredVariant();
461            if ( !$toVariant ) {
462                return $text;
463            }
464        }
465
466        if ( $this->guessVariant( $text, $toVariant ) ) {
467            return $text;
468        }
469        /**
470         * We convert everything except:
471         * 1. HTML markups (anything between < and >)
472         * 2. HTML entities
473         * 3. placeholders created by the parser
474         * IMPORTANT: Beware of failure from pcre.backtrack_limit (T124404).
475         * Minimize the use of backtracking where possible.
476         */
477        static $reg;
478        if ( $reg === null ) {
479            $marker = '|' . Parser::MARKER_PREFIX . '[^\x7f]++\x7f';
480
481            // this one is needed when the text is inside an HTML markup
482            $htmlfix = '|<[^>\004]++(?=\004$)|^[^<>]*+>';
483
484            // Optimize for the common case where these tags have
485            // few or no children. Thus try and possessively get as much as
486            // possible, and only engage in backtracking when we hit a '<'.
487
488            // disable convert to variants between <code> tags
489            $codefix = '<code>[^<]*+(?:(?:(?!<\/code>).)[^<]*+)*+<\/code>|';
490            // disable conversion of <script> tags
491            $scriptfix = '<script[^>]*+>[^<]*+(?:(?:(?!<\/script>).)[^<]*+)*+<\/script>|';
492            // disable conversion of <pre> tags
493            $prefix = '<pre[^>]*+>[^<]*+(?:(?:(?!<\/pre>).)[^<]*+)*+<\/pre>|';
494            // disable conversion of <math> tags
495            $mathfix = '<math[^>]*+>[^<]*+(?:(?:(?!<\/math>).)[^<]*+)*+<\/math>|';
496            // disable conversion of <svg> tags
497            $svgfix = '<svg[^>]*+>[^<]*+(?:(?:(?!<\/svg>).)[^<]*+)*+<\/svg>|';
498            // The "|.*+)" at the end, is in case we missed some part of html syntax,
499            // we will fail securely (hopefully) by matching the rest of the string.
500            $htmlFullTag = '<(?:[^>=]*+(?>[^>=]*+=\s*+(?:"[^"]*"|\'[^\']*\'|[^\'">\s]*+))*+[^>=]*+>|.*+)|';
501
502            $reg = '/' . $codefix . $scriptfix . $prefix . $mathfix . $svgfix .
503                $htmlFullTag .
504                '&[a-zA-Z#][a-z0-9]++;' . $marker . $htmlfix . '|\004$/s';
505        }
506        $startPos = 0;
507        $sourceBlob = '';
508        $literalBlob = '';
509
510        // Guard against delimiter nulls in the input
511        // (should never happen: see T159174)
512        $text = str_replace( "\000", '', $text );
513        $text = str_replace( "\004", '', $text );
514
515        $markupMatches = null;
516        $elementMatches = null;
517
518        // We add a marker (\004) at the end of text, to ensure we always match the
519        // entire text (Otherwise, pcre.backtrack_limit might cause silent failure)
520        $textWithMarker = $text . "\004";
521        while ( $startPos < strlen( $text ) ) {
522            if ( preg_match( $reg, $textWithMarker, $markupMatches, PREG_OFFSET_CAPTURE, $startPos ) ) {
523                $elementPos = $markupMatches[0][1];
524                $element = $markupMatches[0][0];
525                if ( $element === "\004" ) {
526                    // We hit the end.
527                    $elementPos = strlen( $text );
528                    $element = '';
529                } elseif ( substr( $element, -1 ) === "\004" ) {
530                    // This can sometimes happen if we have
531                    // unclosed html tags. For example,
532                    // when converting a title attribute
533                    // during a recursive call that contains
534                    // a &lt; e.g. <div title="&lt;">.
535                    $element = substr( $element, 0, -1 );
536                }
537            } else {
538                // If we hit here, then Language Converter could be tricked
539                // into doing an XSS, so we refuse to translate.
540                // If expected input manages to reach this code path,
541                // we should consider it a bug.
542                $log = LoggerFactory::getInstance( 'languageconverter' );
543                $log->error( "Hit pcre.backtrack_limit in " . __METHOD__
544                    . ". Disabling language conversion for this page.",
545                    [
546                        "method" => __METHOD__,
547                        "variant" => $toVariant,
548                        "startOfText" => substr( $text, 0, 500 )
549                    ]
550                );
551                return $text;
552            }
553            // Queue the part before the markup for translation in a batch
554            $sourceBlob .= substr( $text, $startPos, $elementPos - $startPos ) . "\000";
555
556            // Advance to the next position
557            $startPos = $elementPos + strlen( $element );
558
559            // Translate any alt or title attributes inside the matched element
560            if ( $element !== ''
561                && preg_match( '/^(<[^>\s]*+)\s([^>]*+)(.*+)$/', $element, $elementMatches )
562            ) {
563                // FIXME, this decodes entities, so if you have something
564                // like <div title="foo&lt;bar"> the bar won't get
565                // translated since after entity decoding it looks like
566                // unclosed html and we call this method recursively
567                // on attributes.
568                $attrs = Sanitizer::decodeTagAttributes( $elementMatches[2] );
569                // Ensure self-closing tags stay self-closing.
570                $close = substr( $elementMatches[2], -1 ) === '/' ? ' /' : '';
571                $changed = false;
572                foreach ( [ 'title', 'alt' ] as $attrName ) {
573                    if ( !isset( $attrs[$attrName] ) ) {
574                        continue;
575                    }
576                    $attr = $attrs[$attrName];
577                    // Don't convert URLs
578                    if ( !str_contains( $attr, '://' ) ) {
579                        $attr = $this->recursiveConvertTopLevel( $attr, $toVariant );
580                    }
581
582                    if ( $attr !== $attrs[$attrName] ) {
583                        $attrs[$attrName] = $attr;
584                        $changed = true;
585                    }
586                }
587                if ( $changed ) {
588                    // @phan-suppress-next-line SecurityCheck-DoubleEscaped Explained above with decodeTagAttributes
589                    $element = $elementMatches[1] . Html::expandAttributes( $attrs ) .
590                        $close . $elementMatches[3];
591                }
592            }
593            $literalBlob .= $element . "\000";
594        }
595
596        // Do the main translation batch
597        $translatedBlob = $this->translate( $sourceBlob, $toVariant );
598
599        // Put the output back together
600        $translatedIter = StringUtils::explode( "\000", $translatedBlob );
601        $literalIter = StringUtils::explode( "\000", $literalBlob );
602        $output = '';
603        while ( $translatedIter->valid() && $literalIter->valid() ) {
604            $output .= $translatedIter->current();
605            $output .= $literalIter->current();
606            $translatedIter->next();
607            $literalIter->next();
608        }
609
610        return $output;
611    }
612
613    /** @inheritDoc */
614    public function translate( $text, $variant ) {
615        // If $text is empty or only includes spaces, do nothing
616        // Otherwise translate it
617        if ( trim( $text ) ) {
618            $this->loadTables();
619            // (T337427) Debugging / note error state if mTables not initialised
620            if ( !$this->mTables[$variant] ) {
621                $log = LoggerFactory::getInstance( 'languageconverter' );
622                $log->error( "Tables not initialised for variant in " . __METHOD__
623                    . ". No language conversion made for this string.",
624                    [
625                        "method" => __METHOD__,
626                        "variant" => $variant,
627                        "startOfText" => substr( $text, 0, 500 )
628                    ]
629                );
630                return $text;
631            }
632            $text = $this->mTables[$variant]->replace( $text );
633        }
634        return $text;
635    }
636
637    /**
638     * @param string $text Text to convert
639     * @param string $variant Variant language code
640     * @return string Translated text
641     */
642    protected function translateWithoutRomanNumbers( $text, $variant ) {
643        $breaks = '[^\w\x80-\xff]';
644
645        // regexp for roman numbers
646        // Lookahead assertion ensures $roman doesn't match the empty string
647        $roman = '(?=[MDCLXVI])M{0,4}(C[DM]|D?C{0,3})(X[LC]|L?X{0,3})(I[VX]|V?I{0,3})';
648
649        $reg = '/^' . $roman . '$|^' . $roman . $breaks . '|' . $breaks
650            . $roman . '$|' . $breaks . $roman . $breaks . '/';
651
652        $matches = preg_split( $reg, $text, -1, PREG_SPLIT_OFFSET_CAPTURE );
653
654        $m = array_shift( $matches );
655        $this->loadTables();
656        if ( !isset( $this->mTables[$variant] ) ) {
657            throw new RuntimeException( "Broken variant table: "
658                . implode( ',', array_keys( $this->mTables ) ) );
659        }
660        $ret = $this->mTables[$variant]->replace( $m[0] );
661        $mstart = (int)$m[1] + strlen( $m[0] );
662        foreach ( $matches as $m ) {
663            $ret .= substr( $text, $mstart, (int)$m[1] - $mstart );
664            $ret .= $this->translate( $m[0], $variant );
665            $mstart = (int)$m[1] + strlen( $m[0] );
666        }
667
668        return $ret;
669    }
670
671    /** @inheritDoc */
672    public function autoConvertToAllVariants( $text ) {
673        $this->loadTables();
674
675        $ret = [];
676        foreach ( $this->getVariants() as $variant ) {
677            $ret[$variant] = $this->translate( $text, $variant );
678        }
679
680        return $ret;
681    }
682
683    /**
684     * Apply manual conversion rules.
685     */
686    protected function applyManualConv( ConverterRule $convRule ) {
687        // Use syntax -{T|zh-cn:TitleCN; zh-tw:TitleTw}- to custom
688        // title conversion.
689        // T26072: $mConvRuleTitle was overwritten by other manual
690        // rule(s) not for title, this breaks the title conversion.
691        $newConvRuleTitle = $convRule->getTitle();
692        if ( $newConvRuleTitle !== false ) {
693            // So I add an empty check for getTitle()
694            $this->mConvRuleTitle = $newConvRuleTitle;
695        }
696
697        // merge/remove manual conversion rules to/from global table
698        $convTable = $convRule->getConvTable();
699        $action = $convRule->getRulesAction();
700        foreach ( $convTable as $variant => $pair ) {
701            $v = $this->validateVariant( $variant );
702            if ( !$v ) {
703                continue;
704            }
705
706            if ( $action == 'add' ) {
707                // More efficient than array_merge(), about 2.5 times.
708                foreach ( $pair as $from => $to ) {
709                    $this->mTables[$v]->setPair( $from, $to );
710                }
711            } elseif ( $action == 'remove' ) {
712                $this->mTables[$v]->removeArray( $pair );
713            }
714        }
715    }
716
717    /** @inheritDoc */
718    public function convertSplitTitle( $title ) {
719        $variant = $this->getPreferredVariant();
720
721        $index = $title->getNamespace();
722        $nsText = $this->convertNamespace( $index, $variant );
723
724        $name = str_replace( '_', ' ', $title->getDBKey() );
725        $mainText = $this->translate( $name, $variant );
726
727        return [ $nsText, ':', $mainText ];
728    }
729
730    /** @inheritDoc */
731    public function convertTitle( $title ) {
732        [ $nsText, $nsSeparator, $mainText ] = $this->convertSplitTitle( $title );
733        return $nsText !== '' ?
734            $nsText . $nsSeparator . $mainText :
735            $mainText;
736    }
737
738    /** @inheritDoc */
739    public function convertNamespace( $index, $variant = null ) {
740        if ( $index === NS_MAIN ) {
741            return '';
742        }
743
744        $variant ??= $this->getPreferredVariant();
745
746        $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
747        $key = $cache->makeKey( 'languageconverter', 'namespace-text', $index, $variant );
748        return $cache->getWithSetCallback(
749            $key,
750            BagOStuff::TTL_MINUTE,
751            function () use ( $index, $variant ) {
752                return $this->computeNsVariantText( $index, $variant );
753            }
754        );
755    }
756
757    /**
758     * @param int $index
759     * @param string $variant
760     * @return string
761     */
762    private function computeNsVariantText( int $index, string $variant ): string {
763        $nsVariantText = false;
764
765        // Check if a message for this namespace exists. Note that this would follow
766        // the fallback chain, and the site's content language as the last resort.
767        $nsConvMsg = wfMessage( 'conversion-ns' . $index )->inLanguage( $variant );
768        if ( $nsConvMsg->exists() ) {
769            $nsVariantText = $nsConvMsg->plain();
770        }
771
772        if ( $nsVariantText === false ) {
773            // No message exists, retrieve it from the target variant's namespace names.
774            $langObj = MediaWikiServices::getInstance()
775                ->getLanguageFactory()
776                ->getLanguage( $variant );
777            $nsVariantText = $langObj->getFormattedNsText( $index );
778        }
779        return $nsVariantText;
780    }
781
782    /** @inheritDoc */
783    public function convert( $text ) {
784        $variant = $this->getPreferredVariant();
785        return $this->convertTo( $text, $variant );
786    }
787
788    /** @inheritDoc */
789    public function convertTo( $text, $variant, bool $clearState = true ) {
790        $languageConverterFactory = MediaWikiServices::getInstance()->getLanguageConverterFactory();
791        if ( $languageConverterFactory->isConversionDisabled() ) {
792            return $text;
793        }
794        // Reset converter state for a new converter run.
795        if ( $clearState ) {
796            $this->mConvRuleTitle = false;
797        }
798        return $this->recursiveConvertTopLevel( $text, $variant );
799    }
800
801    /**
802     * Recursively convert text on the outside. Allow to use nested
803     * markups to custom rules.
804     *
805     * @param string $text Text to be converted
806     * @param string $variant The target variant code
807     * @param int $depth Depth of recursion
808     * @return string Converted text
809     */
810    protected function recursiveConvertTopLevel( $text, $variant, $depth = 0 ) {
811        $startPos = 0;
812        $out = '';
813        $length = strlen( $text );
814        $shouldConvert = !$this->guessVariant( $text, $variant );
815        $continue = true;
816
817        $noScript = '<script.*?>.*?<\/script>(*SKIP)(*FAIL)';
818        $noStyle = '<style.*?>.*?<\/style>(*SKIP)(*FAIL)';
819        $noMath = '<math.*?>.*?<\/math>(*SKIP)(*FAIL)';
820        $noSvg = '<svg.*?>.*?<\/svg>(*SKIP)(*FAIL)';
821        // phpcs:ignore Generic.Files.LineLength
822        $noHtml = '<(?:[^>=]*+(?>[^>=]*+=\s*+(?:"[^"]*"|\'[^\']*\'|[^\'">\s]*+))*+[^>=]*+>|.*+)(*SKIP)(*FAIL)';
823        while ( $startPos < $length && $continue ) {
824            $continue = preg_match(
825                // Only match "-{" outside the html.
826                "/$noScript|$noStyle|$noMath|$noSvg|$noHtml|-\{/",
827                $text,
828                $m,
829                PREG_OFFSET_CAPTURE,
830                $startPos
831            );
832
833            if ( !$continue ) {
834                // No more markup, append final segment
835                $fragment = substr( $text, $startPos );
836                $out .= $shouldConvert ? $this->autoConvert( $fragment, $variant ) : $fragment;
837                return $out;
838            }
839
840            // Offset of the match of the regex pattern.
841            $pos = $m[0][1];
842
843            // Append initial segment
844            $fragment = substr( $text, $startPos, $pos - $startPos );
845            $out .= $shouldConvert ? $this->autoConvert( $fragment, $variant ) : $fragment;
846            // -{ marker found, not in attribute
847            // Advance position up to -{ marker.
848            $startPos = $pos;
849            // Do recursive conversion
850            // Note: This passes $startPos by reference, and advances it.
851            $out .= $this->recursiveConvertRule( $text, $variant, $startPos, $depth + 1 );
852        }
853        return $out;
854    }
855
856    /**
857     * Recursively convert text on the inside.
858     *
859     * @param string $text Text to be converted
860     * @param string $variant The target variant code
861     * @param int &$startPos
862     * @param int $depth Depth of recursion
863     * @return string Converted text
864     */
865    protected function recursiveConvertRule( $text, $variant, &$startPos, $depth = 0 ) {
866        // Quick check (no function calls)
867        if ( $text[$startPos] !== '-' || $text[$startPos + 1] !== '{' ) {
868            throw new InvalidArgumentException( __METHOD__ . ': invalid input string' );
869        }
870
871        $startPos += 2;
872        $inner = '';
873        $warningDone = false;
874        $length = strlen( $text );
875
876        while ( $startPos < $length ) {
877            $m = false;
878            preg_match( '/-\{|\}-/', $text, $m, PREG_OFFSET_CAPTURE, $startPos );
879            if ( !$m ) {
880                // Unclosed rule
881                break;
882            }
883
884            $token = $m[0][0];
885            $pos = $m[0][1];
886
887            // Markup found
888            // Append initial segment
889            $inner .= substr( $text, $startPos, $pos - $startPos );
890
891            // Advance position
892            $startPos = $pos;
893
894            switch ( $token ) {
895                case '-{':
896                    // Check max depth
897                    if ( $depth >= $this->mMaxDepth ) {
898                        $inner .= '-{';
899                        if ( !$warningDone ) {
900                            $inner .= '<span class="error">' .
901                                wfMessage( 'language-converter-depth-warning' )
902                                    ->numParams( $this->mMaxDepth )->inContentLanguage()->text() .
903                                '</span>';
904                            $warningDone = true;
905                        }
906                        $startPos += 2;
907                        break;
908                    }
909                    // Recursively parse another rule
910                    $inner .= $this->recursiveConvertRule( $text, $variant, $startPos, $depth + 1 );
911                    break;
912                case '}-':
913                    // Apply the rule
914                    $startPos += 2;
915                    $rule = new ConverterRule( $this );
916                    $rule->parse( $inner, $variant );
917                    $this->applyManualConv( $rule );
918                    return $rule->getDisplay();
919                default:
920                    throw new UnexpectedValueException( __METHOD__ . ': invalid regex match' );
921            }
922        }
923
924        // Unclosed rule
925        if ( $startPos < $length ) {
926            $inner .= substr( $text, $startPos );
927        }
928        $startPos = $length;
929        return '-{' . $this->autoConvert( $inner, $variant );
930    }
931
932    /** @inheritDoc */
933    public function findVariantLink( &$link, &$nt, $ignoreOtherCond = false ) {
934        # If the article has already existed, there is no need to
935        # check it again. Otherwise it may cause a fault.
936        if ( $nt instanceof LinkTarget ) {
937            $nt = Title::castFromLinkTarget( $nt );
938            if ( $nt->exists() ) {
939                return;
940            }
941        }
942
943        if ( $nt instanceof PageIdentity && $nt->exists() ) {
944            return;
945        }
946
947        $request = RequestContext::getMain()->getRequest();
948
949        $isredir = $request->getText( 'redirect', 'yes' );
950        $action = $request->getText( 'action' );
951        if ( $action == 'edit' && $request->getBool( 'redlink' ) ) {
952            $action = 'view';
953        }
954        $linkconvert = $request->getText( 'linkconvert', 'yes' );
955        $disableLinkConversion =
956            MediaWikiServices::getInstance()->getLanguageConverterFactory()
957            ->isLinkConversionDisabled();
958        $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
959        $linkBatch = $linkBatchFactory->newLinkBatch();
960
961        $ns = NS_MAIN;
962
963        if ( $disableLinkConversion ||
964            ( !$ignoreOtherCond &&
965                ( $isredir == 'no'
966                    || $action == 'edit'
967                    || $action == 'submit'
968                    || $linkconvert == 'no' )
969            )
970        ) {
971            return;
972        }
973
974        if ( is_object( $nt ) ) {
975            $ns = $nt->getNamespace();
976        }
977
978        $variants = $this->autoConvertToAllVariants( $link );
979        if ( !$variants ) { // give up
980            return;
981        }
982
983        $titles = [];
984
985        foreach ( $variants as $v ) {
986            if ( $v != $link ) {
987                $varnt = Title::newFromText( $v, $ns );
988                if ( $varnt !== null ) {
989                    $linkBatch->addObj( $varnt );
990                    $titles[] = $varnt;
991                }
992            }
993        }
994
995        // fetch all variants in single query
996        $linkBatch->execute();
997
998        foreach ( $titles as $varnt ) {
999            if ( $varnt->getArticleID() > 0 ) {
1000                $nt = $varnt;
1001                $link = $varnt->getText();
1002                break;
1003            }
1004        }
1005    }
1006
1007    /** @inheritDoc */
1008    public function getExtraHashOptions() {
1009        $variant = $this->getPreferredVariant();
1010
1011        return '!' . $variant;
1012    }
1013
1014    /** @inheritDoc */
1015    public function guessVariant( $text, $variant ) {
1016        return false;
1017    }
1018
1019    /**
1020     * Load default conversion tables.
1021     *
1022     * @return array
1023     */
1024    abstract protected function loadDefaultTables(): array;
1025
1026    /**
1027     * Load conversion tables either from the cache or the disk.
1028     * @private
1029     * @param bool $fromCache Whether to load from cache. Defaults to true.
1030     */
1031    protected function loadTables( $fromCache = true ) {
1032        $services = MediaWikiServices::getInstance();
1033        $languageConverterCacheType = $services
1034            ->getMainConfig()->get( MainConfigNames::LanguageConverterCacheType );
1035
1036        if ( $this->mTablesLoaded ) {
1037            return;
1038        }
1039
1040        $cache = $services->getObjectCacheFactory()->getInstance( $languageConverterCacheType );
1041        $cacheKey = $cache->makeKey(
1042            'conversiontables', $this->getMainCode(),
1043            md5( implode( ',', $this->getVariants() ) ), self::CACHE_VERSION_KEY
1044        );
1045        if ( !$fromCache ) {
1046            $cache->delete( $cacheKey );
1047        }
1048        $this->mTables = $cache->getWithSetCallback( $cacheKey, $cache::TTL_HOUR * 12, function () {
1049            // We will first load the default tables
1050            // then update them using things in MediaWiki:Conversiontable/*
1051            $tables = $this->loadDefaultTables();
1052            foreach ( $this->getVariants() as $var ) {
1053                $cached = $this->parseCachedTable( $var );
1054                $tables[$var]->mergeArray( $cached );
1055            }
1056
1057            $this->postLoadTables( $tables );
1058            return $tables;
1059        } );
1060        $this->mTablesLoaded = true;
1061    }
1062
1063    /**
1064     * Hook for post-processing after conversion tables are loaded.
1065     *
1066     * @param ReplacementArray[] &$tables
1067     */
1068    protected function postLoadTables( &$tables ) {
1069    }
1070
1071    /**
1072     * Reload the conversion tables.
1073     *
1074     * Also used by test suites which need to reset the converter state.
1075     *
1076     * Called by ParserTestRunner with the help of TestingAccessWrapper
1077     */
1078    private function reloadTables() {
1079        if ( $this->mTables ) {
1080            $this->mTables = [];
1081        }
1082
1083        $this->mTablesLoaded = false;
1084        $this->loadTables( false );
1085    }
1086
1087    /**
1088     * Parse the conversion table stored in the cache.
1089     *
1090     * The tables should be in blocks of the following form:
1091     *         -{
1092     *             word => word ;
1093     *             word => word ;
1094     *             ...
1095     *         }-
1096     *
1097     * To make the tables more manageable, subpages are allowed
1098     * and will be parsed recursively if $recursive == true.
1099     *
1100     * @param string $code Language code
1101     * @param string $subpage Subpage name
1102     * @param bool $recursive Parse subpages recursively? Defaults to true.
1103     *
1104     * @return array
1105     */
1106    private function parseCachedTable( $code, $subpage = '', $recursive = true ) {
1107        static $parsed = [];
1108
1109        $key = 'Conversiontable/' . $code;
1110        if ( $subpage ) {
1111            $key .= '/' . $subpage;
1112        }
1113        if ( array_key_exists( $key, $parsed ) ) {
1114            return [];
1115        }
1116
1117        $parsed[$key] = true;
1118
1119        if ( $subpage === '' ) {
1120            $messageCache = MediaWikiServices::getInstance()->getMessageCache();
1121            $txt = $messageCache->getMsgFromNamespace( $key, $code );
1122        } else {
1123            $txt = false;
1124            $title = Title::makeTitleSafe( NS_MEDIAWIKI, $key );
1125            if ( $title && $title->exists() ) {
1126                $revision = MediaWikiServices::getInstance()
1127                    ->getRevisionLookup()
1128                    ->getRevisionByTitle( $title );
1129                if ( $revision ) {
1130                    $model = $revision->getSlot(
1131                        SlotRecord::MAIN,
1132                        RevisionRecord::RAW
1133                    )->getModel();
1134                    if ( $model == CONTENT_MODEL_WIKITEXT ) {
1135                        // @phan-suppress-next-line PhanUndeclaredMethod
1136                        $txt = $revision->getContent(
1137                            SlotRecord::MAIN,
1138                            RevisionRecord::RAW
1139                        )->getText();
1140                    }
1141
1142                    // @todo in the future, use a specialized content model, perhaps based on json!
1143                }
1144            }
1145        }
1146
1147        # Nothing to parse if there's no text
1148        if ( $txt === false || $txt === null || $txt === '' ) {
1149            return [];
1150        }
1151
1152        // get all subpage links of the form
1153        // [[MediaWiki:Conversiontable/zh-xx/...|...]]
1154        $linkhead = $this->mLangObj->getNsText( NS_MEDIAWIKI ) .
1155            ':Conversiontable';
1156        $subs = StringUtils::explode( '[[', $txt );
1157        $sublinks = [];
1158        foreach ( $subs as $sub ) {
1159            $link = explode( ']]', $sub, 2 );
1160            if ( count( $link ) != 2 ) {
1161                continue;
1162            }
1163            $b = explode( '|', $link[0], 2 );
1164            $b = explode( '/', trim( $b[0] ), 3 );
1165            if ( count( $b ) == 3 ) {
1166                $sublink = $b[2];
1167            } else {
1168                $sublink = '';
1169            }
1170
1171            if ( $b[0] == $linkhead && $b[1] == $code ) {
1172                $sublinks[] = $sublink;
1173            }
1174        }
1175
1176        // parse the mappings in this page
1177        $blocks = StringUtils::explode( '-{', $txt );
1178        $ret = [];
1179        $first = true;
1180        foreach ( $blocks as $block ) {
1181            if ( $first ) {
1182                // Skip the part before the first -{
1183                $first = false;
1184                continue;
1185            }
1186            $mappings = explode( '}-', $block, 2 )[0];
1187            $stripped = str_replace( [ "'", '"', '*', '#' ], '', $mappings );
1188            $table = StringUtils::explode( ';', $stripped );
1189            foreach ( $table as $t ) {
1190                $m = explode( '=>', $t, 3 );
1191                if ( count( $m ) != 2 ) {
1192                    continue;
1193                }
1194                // trim any trailing comments starting with '//'
1195                $tt = explode( '//', $m[1], 2 );
1196                $ret[trim( $m[0] )] = trim( $tt[0] );
1197            }
1198        }
1199
1200        // recursively parse the subpages
1201        if ( $recursive ) {
1202            foreach ( $sublinks as $link ) {
1203                $s = $this->parseCachedTable( $code, $link, $recursive );
1204                $ret = $s + $ret;
1205            }
1206        }
1207        return $ret;
1208    }
1209
1210    /** @inheritDoc */
1211    public function markNoConversion( $text, $noParse = false ) {
1212        # don't mark if already marked
1213        if ( str_contains( $text, '-{' ) || str_contains( $text, '}-' ) ) {
1214            return $text;
1215        }
1216
1217        return "-{R|$text}-";
1218    }
1219
1220    /** @inheritDoc */
1221    public function convertCategoryKey( $key ) {
1222        return $key;
1223    }
1224
1225    /**
1226     * @param PageIdentity $page Message page
1227     *
1228     * @return void
1229     */
1230    public function updateConversionTable( PageIdentity $page ) {
1231        if ( $page->getNamespace() === NS_MEDIAWIKI ) {
1232            $t = explode( '/', $page->getDBkey(), 3 );
1233            $c = count( $t );
1234            if ( $c > 1 && $t[0] == 'Conversiontable' && $this->validateVariant( $t[1] ) ) {
1235                $this->reloadTables();
1236            }
1237        }
1238    }
1239
1240    /**
1241     * Get the cached separator pattern for ConverterRule::parseRules()
1242     * @return string
1243     */
1244    public function getVarSeparatorPattern() {
1245        if ( $this->mVarSeparatorPattern === null ) {
1246            // varsep_pattern for preg_split:
1247            // The text should be split by ";" only if a valid variant
1248            // name exists after the markup.
1249            // For example
1250            //  -{zh-hans:<span style="font-size:120%;">xxx</span>;zh-hant:\
1251            //  <span style="font-size:120%;">yyy</span>;}-
1252            // we should split it as:
1253            //  [
1254            //    [0] => 'zh-hans:<span style="font-size:120%;">xxx</span>'
1255            //    [1] => 'zh-hant:<span style="font-size:120%;">yyy</span>'
1256            //    [2] => ''
1257            //  ]
1258            $expandedVariants = [];
1259            foreach ( $this->getVariants() as $variant ) {
1260                $expandedVariants[ $variant ] = 1;
1261                // Accept standard BCP 47 names for variants as well.
1262                $expandedVariants[ LanguageCode::bcp47( $variant ) ] = 1;
1263            }
1264            // Accept old deprecated names for variants
1265            foreach ( LanguageCode::getDeprecatedCodeMapping() as $old => $new ) {
1266                if ( isset( $expandedVariants[ $new ] ) ) {
1267                    $expandedVariants[ $old ] = 1;
1268                }
1269            }
1270            $expandedVariants = implode( '|', array_keys( $expandedVariants ) );
1271
1272            $pat = '/;\s*(?=';
1273            // zh-hans:xxx;zh-hant:yyy
1274            $pat .= '(?:' . $expandedVariants . ')\s*:';
1275            // xxx=>zh-hans:yyy; xxx=>zh-hant:zzz
1276            $pat .= '|[^;]*?=>\s*(?:' . $expandedVariants . ')\s*:';
1277            $pat .= '|\s*$)/';
1278            $this->mVarSeparatorPattern = $pat;
1279        }
1280        return $this->mVarSeparatorPattern;
1281    }
1282
1283    /** @inheritDoc */
1284    public function hasVariants() {
1285        return count( $this->getVariants() ) > 1;
1286    }
1287
1288    /** @inheritDoc */
1289    public function hasVariant( $variant ) {
1290        return $variant && ( $variant === $this->validateVariant( $variant ) );
1291    }
1292
1293    /** @inheritDoc */
1294    public function convertHtml( $text ) {
1295        // @phan-suppress-next-line SecurityCheck-DoubleEscaped convert() is documented to return html
1296        return htmlspecialchars( $this->convert( $text ) );
1297    }
1298}
1299
1300/** @deprecated class alias since 1.43 */
1301class_alias( LanguageConverter::class, 'LanguageConverter' );