Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.27% covered (danger)
36.27%
103 / 284
7.04% covered (danger)
7.04%
5 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
Env
36.27% covered (danger)
36.27%
103 / 284
7.04% covered (danger)
7.04%
5 / 71
4169.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
88.00% covered (warning)
88.00%
44 / 50
0.00% covered (danger)
0.00%
0 / 1
6.06
 checkPlatform
12.50% covered (danger)
12.50%
2 / 16
0.00% covered (danger)
0.00%
0 / 1
9.03
 profiling
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCurrentProfile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pushNewProfile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 popProfile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasTraceFlags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasTraceFlag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDumpFlags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasDumpFlag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 writeDump
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSiteConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDataAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMetadata
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTOCData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 nativeTemplateExpansionEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUID
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFID
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWrapSections
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPipelineFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRequestOffsetType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCurrentOffsetType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setCurrentOffsetType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContextTitle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 resolveTitle
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
15
 normalizedTitleKey
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 makeTitle
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 makeTitleFromText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeTitleFromURLDecodedStr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeLink
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 isValidLinkTarget
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 generateUID
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateAnnotationUID
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newAnnotationId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newAboutId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDOMDiff
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDOMDiff
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFragmentId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setupTopLevelDoc
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
2.15
 getTopLevelDoc
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fetchRemexPipeline
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 setBehaviorSwitch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBehaviorSwitch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDOMFragmentMap
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDOMFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setDOMFragment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 removeDOMFragment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getPFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addToPFragmentMap
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pFragmentMapToString
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 recordLint
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getLints
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setLints
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 log
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 trace
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 bumpWt2HtmlResourceUse
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 compareWt2HtmlLimit
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 bumpHtml2WtResourceUse
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getContentHandler
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 langConverterEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getInputContentVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOutputContentVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHtmlVariantLanguageBcp47
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getWtVariantLanguageBcp47
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSkipLanguageConversionPass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 htmlVary
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 htmlContentLanguageBcp47
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalLinkAttribs
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 getLinterConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 linting
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Config;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Bcp47Code\Bcp47Code;
8use Wikimedia\Parsoid\Core\ContentMetadataCollector;
9use Wikimedia\Parsoid\Core\ContentModelHandler;
10use Wikimedia\Parsoid\Core\DomPageBundle;
11use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
12use Wikimedia\Parsoid\Core\Sanitizer;
13use Wikimedia\Parsoid\Core\TOCData;
14use Wikimedia\Parsoid\DOM\Document;
15use Wikimedia\Parsoid\DOM\DocumentFragment;
16use Wikimedia\Parsoid\Fragments\PFragment;
17use Wikimedia\Parsoid\Logger\ParsoidLogger;
18use Wikimedia\Parsoid\Parsoid;
19use Wikimedia\Parsoid\Tokens\Token;
20use Wikimedia\Parsoid\Utils\DOMCompat;
21use Wikimedia\Parsoid\Utils\DOMDataUtils;
22use Wikimedia\Parsoid\Utils\PHPUtils;
23use Wikimedia\Parsoid\Utils\Title;
24use Wikimedia\Parsoid\Utils\TitleException;
25use Wikimedia\Parsoid\Utils\TokenUtils;
26use Wikimedia\Parsoid\Utils\UrlUtils;
27use Wikimedia\Parsoid\Utils\Utils;
28use Wikimedia\Parsoid\Wikitext\ContentModelHandler as WikitextContentModelHandler;
29use Wikimedia\Parsoid\Wt2Html\Frame;
30use Wikimedia\Parsoid\Wt2Html\PageConfigFrame;
31use Wikimedia\Parsoid\Wt2Html\ParserPipelineFactory;
32use Wikimedia\Parsoid\Wt2Html\TreeBuilder\RemexPipeline;
33
34/**
35 * Environment/Envelope class for Parsoid
36 *
37 * Carries around the SiteConfig and PageConfig during an operation
38 * and provides certain other services.
39 */
40class Env {
41    private SiteConfig $siteConfig;
42    private PageConfig $pageConfig;
43    private DataAccess $dataAccess;
44    private ContentMetadataCollector $metadata;
45
46    /** Table of Contents metadata for the article */
47    private TOCData $tocData;
48
49    /**
50     * The top-level frame for this conversion.
51     * This largely wraps the PageConfig.
52     * In the future we may replace PageConfig with the Frame
53     */
54    public Frame $topFrame;
55    // XXX In the future, perhaps replace PageConfig with the Frame, and
56    // add $this->currentFrame (relocated from TokenHandlerPipeline) if/when
57    // we've removed async parsing.
58
59    /**
60     * Are we using native template expansion?
61     *
62     * Parsoid implements native template expansion, which is currently
63     * only used during parser tests; in production, template expansion
64     * is done via MediaWiki's legacy preprocessor.
65     *
66     * FIXME: Hopefully this distinction can be removed when we're entirely
67     * in PHP land.
68     */
69    private bool $nativeTemplateExpansion;
70
71    /** @var array<string,int> */
72    private array $wt2htmlUsage = [];
73
74    /** @var array<string,int> */
75    private array $html2wtUsage = [];
76    private bool $profiling = false;
77
78    /** @var array<Profile> */
79    private array $profileStack = [];
80    private bool $wrapSections;
81
82    /** @var ('byte'|'ucs2'|'char') */
83    private string $requestOffsetType = 'byte';
84
85    /** @var ('byte'|'ucs2'|'char') */
86    private string $currentOffsetType = 'byte';
87    private bool $skipLanguageConversionPass = false;
88
89    /** @var array<string,mixed> */
90    private array $behaviorSwitches = [];
91
92    /**
93     * Maps fragment id to the fragment forest (array of Nodes).
94     * @var array<string,DocumentFragment>
95     */
96    private array $fragmentMap = [];
97
98    /**
99     * Maps pfragment id to a PFragment.
100     * @var array<string,PFragment>
101     */
102    private array $pFragmentMap = [];
103
104    /**
105     * Used to generate fragment ids as needed during parse
106     */
107    private int $fid = 1;
108
109    /** Used to generate uids as needed during this parse */
110    private int $uid = 1;
111
112    /** Used to generate annotation uids as needed during this parse */
113    private int $annUid = 0;
114
115    /** Lints recorded */
116    private array $lints = [];
117    public bool $logLinterData = false;
118    private array $linterOverrides = [];
119
120    /** @var bool[] */
121    private array $traceFlags;
122
123    /** @var bool[] */
124    private array $dumpFlags;
125
126    /** @var bool[] */
127    private array $debugFlags;
128    private ParsoidLogger $parsoidLogger;
129
130    /**
131     * The default content version that Parsoid assumes it's serializing or
132     * updating in the pb2pb endpoints
133     */
134    private string $inputContentVersion;
135
136    /**
137     * The default content version that Parsoid will generate.
138     */
139    private string $outputContentVersion;
140
141    /**
142     * If non-null, the language variant used for Parsoid HTML;
143     * we convert to this if wt2html, or from this if html2wt.
144     */
145    private ?Bcp47Code $htmlVariantLanguage;
146
147    /**
148     * If non-null, the language variant to be used for wikitext.
149     * If null, heuristics will be used to identify the original wikitext variant
150     * in wt2html mode, and in html2wt mode new or edited HTML will be left unconverted.
151     */
152    private ?Bcp47Code $wtVariantLanguage;
153    private ParserPipelineFactory $pipelineFactory;
154
155    /**
156     * FIXME Used in DedupeStyles::dedupe()
157     */
158    public array $styleTagKeys = [];
159
160    /**
161     * The DomPageBundle holding the JSON data for data-parsoid and data-mw
162     * attributes, or `null` if these are to be encoded as inline HTML
163     * attributes.
164     */
165    public ?DomPageBundle $pageBundle = null;
166    private ?Document $domDiff = null;
167    public bool $hasAnnotations = false;
168
169    /**
170     * Cache of wikitext source for a title; only used for ParserTests.
171     */
172    public array $pageCache = [];
173
174    /**
175     * The current top-level document. During wt2html, this will be the document
176     * associated with the RemexPipeline. During html2wt, this will be the
177     * input document, typically passed as a constructor option.
178     *
179     * This document should be prepared and loaded; see
180     * ContentUtils::createAndLoadDocument().
181     */
182    private Document $topLevelDoc;
183
184    /**
185     * The RemexPipeline used during a wt2html operation.
186     */
187    private ?RemexPipeline $remexPipeline;
188    private WikitextContentModelHandler $wikitextContentModelHandler;
189    private ?Title $cachedContextTitle = null;
190
191    /**
192     * @param SiteConfig $siteConfig
193     * @param PageConfig $pageConfig
194     * @param DataAccess $dataAccess
195     * @param ContentMetadataCollector $metadata
196     * @param ?array $options
197     *  - wrapSections: (bool) Whether `<section>` wrappers should be added.
198     *  - pageBundle: (bool) When true, sets ids on nodes and stores
199     *      data-* attributes in a JSON blob in Env::$pageBundle
200     *  - traceFlags: (array) Flags indicating which components need to be traced
201     *  - dumpFlags: (bool[]) Dump flags
202     *  - debugFlags: (bool[]) Debug flags
203     *  - nativeTemplateExpansion: boolean
204     *  - offsetType: 'byte' (default), 'ucs2', 'char'
205     *                See `Parsoid\Wt2Html\DOM\Processors\ConvertOffsets`.
206     *  - logLinterData: (bool) Should we log linter data if linting is enabled?
207     *  - linterOverrides: (array) Override the site linting configs.
208     *  - skipLanguageConversionPass: (bool) Should we skip the language
209     *      conversion pass? (defaults to false)
210     *  - htmlVariantLanguage: Bcp47Code|null
211     *      If non-null, the language variant used for Parsoid HTML
212     *      as a BCP 47 object.
213     *      We convert to this if wt2html, or from this if html2wt.
214     *  - wtVariantLanguage: Bcp47Code|null
215     *      If non-null, the language variant to be used for wikitext
216     *      as a BCP 47 object.
217     *      If null, heuristics will be used to identify the original
218     *      wikitext variant in wt2html mode, and in html2wt mode new
219     *      or edited HTML will be left unconverted.
220     *  - logLevels: (string[]) Levels to log
221     *  - topLevelDoc: Document Set explicitly
222     *      when serializing otherwise it gets initialized for parsing.
223     *      This should be a "prepared & loaded" document.
224     */
225    public function __construct(
226        SiteConfig $siteConfig,
227        PageConfig $pageConfig,
228        DataAccess $dataAccess,
229        ContentMetadataCollector $metadata,
230        ?array $options = null
231    ) {
232        self::checkPlatform();
233        $options ??= [];
234        $this->siteConfig = $siteConfig;
235        $this->pageConfig = $pageConfig;
236        $this->dataAccess = $dataAccess;
237        $this->metadata = $metadata;
238        $this->tocData = new TOCData();
239        $this->topFrame = new PageConfigFrame( $this, $pageConfig, $siteConfig );
240        $this->wrapSections = (bool)( $options['wrapSections'] ?? true );
241        $this->pipelineFactory = new ParserPipelineFactory( $this );
242        $defaultContentVersion = Parsoid::defaultHTMLVersion();
243        $this->inputContentVersion = $options['inputContentVersion'] ?? $defaultContentVersion;
244        // FIXME: We should have a check for the supported input content versions as well.
245        // That will require a separate constant.
246        $this->outputContentVersion = $options['outputContentVersion'] ?? $defaultContentVersion;
247        if ( !in_array( $this->outputContentVersion, Parsoid::AVAILABLE_VERSIONS, true ) ) {
248            throw new \UnexpectedValueException(
249                $this->outputContentVersion . ' is not an available content version.' );
250        }
251        $this->skipLanguageConversionPass =
252            $options['skipLanguageConversionPass'] ?? false;
253        $this->htmlVariantLanguage = !empty( $options['htmlVariantLanguage'] ) ?
254            Utils::mwCodeToBcp47(
255                $options['htmlVariantLanguage'],
256                // Be strict in what we accept here.
257                true, $this->siteConfig->getLogger()
258            ) : null;
259        $this->wtVariantLanguage = !empty( $options['wtVariantLanguage'] ) ?
260            Utils::mwCodeToBcp47(
261                $options['wtVariantLanguage'],
262                // Be strict in what we accept here.
263                true, $this->siteConfig->getLogger()
264            ) : null;
265        $this->nativeTemplateExpansion = !empty( $options['nativeTemplateExpansion'] );
266        $this->requestOffsetType = $options['offsetType'] ?? 'byte';
267        $this->logLinterData = !empty( $options['logLinterData'] );
268        $this->linterOverrides = $options['linterOverrides'] ?? [];
269        $this->traceFlags = $options['traceFlags'] ?? [];
270        $this->dumpFlags = $options['dumpFlags'] ?? [];
271        $this->debugFlags = $options['debugFlags'] ?? [];
272        $this->parsoidLogger = new ParsoidLogger( $this->siteConfig->getLogger(), [
273            'logLevels' => $options['logLevels'] ?? [ 'fatal', 'error', 'warn', 'info' ],
274            'debugFlags' => $this->debugFlags,
275            'dumpFlags' => $this->dumpFlags,
276            'traceFlags' => $this->traceFlags
277        ] );
278        if ( $this->hasTraceFlag( 'time' ) ) {
279            $this->profiling = true;
280        }
281        $this->setupTopLevelDoc( $options['topLevelDoc'] ?? null );
282        if ( $options['pageBundle'] ?? false ) {
283            $this->pageBundle = DomPageBundle::newEmpty(
284                $this->topLevelDoc
285            );
286        }
287        // NOTE:
288        // Don't try to do this in setupTopLevelDoc since it is called on existing Env objects
289        // in a couple of places. That then leads to a multiple-write to tocdata property on
290        // the metadata object.
291        //
292        // setupTopLevelDoc is called outside Env in these couple cases:
293        // 1. html2wt in ContentModelHandler for dealing with
294        //    missing original HTML.
295        // 2. ParserTestRunner's html2html tests
296        //
297        // That is done to either reuse an existing Env object (as in 1.)
298        // OR to refresh the attached DOC (html2html as in 2.).
299        // Constructing a new Env in both cases could eliminate this issue.
300        $this->metadata->setTOCData( $this->tocData );
301
302        $this->wikitextContentModelHandler = new WikitextContentModelHandler( $this );
303    }
304
305    /**
306     * Check to see if the PHP platform is sensible
307     */
308    private static function checkPlatform() {
309        static $checked;
310        if ( !$checked ) {
311            $highBytes =
312                "\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f" .
313                "\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f" .
314                "\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf" .
315                "\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf" .
316                "\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf" .
317                "\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf" .
318                "\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef" .
319                "\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff";
320            if ( strtolower( 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' . $highBytes )
321                !== 'abcdefghijklmnopqrstuvwxyz' . $highBytes
322            ) {
323                throw new \RuntimeException( 'strtolower() doesn\'t work -- ' .
324                    'please set the locale to C or a UTF-8 variant such as C.UTF-8' );
325            }
326            $checked = true;
327        }
328    }
329
330    /**
331     * Is profiling enabled?
332     * @return bool
333     */
334    public function profiling(): bool {
335        return $this->profiling;
336    }
337
338    /**
339     * Get the profile at the top of the stack
340     *
341     * FIXME: This implicitly assumes sequential in-order processing
342     * This wouldn't have worked in Parsoid/JS and may not work in the future
343     * depending on how / if we restructure the pipeline for concurrency, etc.
344     *
345     * @return Profile
346     */
347    public function getCurrentProfile(): Profile {
348        return PHPUtils::lastItem( $this->profileStack );
349    }
350
351    /**
352     * New pipeline started. Push profile.
353     * @return Profile
354     */
355    public function pushNewProfile(): Profile {
356        $currProfile = count( $this->profileStack ) > 0 ? $this->getCurrentProfile() : null;
357        $profile = new Profile();
358        $this->profileStack[] = $profile;
359        if ( $currProfile !== null ) {
360            $currProfile->pushNestedProfile( $profile );
361        }
362        return $profile;
363    }
364
365    /**
366     * Pipeline ended. Pop profile.
367     * @return Profile
368     */
369    public function popProfile(): Profile {
370        return array_pop( $this->profileStack );
371    }
372
373    public function hasTraceFlags(): bool {
374        return !empty( $this->traceFlags );
375    }
376
377    /**
378     * Test which trace information to log
379     *
380     * @param string $flag Flag name.
381     * @return bool
382     */
383    public function hasTraceFlag( string $flag ): bool {
384        return isset( $this->traceFlags[$flag] );
385    }
386
387    public function hasDumpFlags(): bool {
388        return !empty( $this->dumpFlags );
389    }
390
391    /**
392     * Test which state to dump
393     *
394     * @param string $flag Flag name.
395     * @return bool
396     */
397    public function hasDumpFlag( string $flag ): bool {
398        return isset( $this->dumpFlags[$flag] );
399    }
400
401    /**
402     * Write out a string (because it was requested by dumpFlags)
403     * @param string $str
404     */
405    public function writeDump( string $str ) {
406        $this->log( 'dump', $str );
407    }
408
409    /**
410     * Get the site config
411     * @return SiteConfig
412     */
413    public function getSiteConfig(): SiteConfig {
414        return $this->siteConfig;
415    }
416
417    /**
418     * Get the page config
419     * @return PageConfig
420     */
421    public function getPageConfig(): PageConfig {
422        return $this->pageConfig;
423    }
424
425    /**
426     * Get the data access object
427     * @return DataAccess
428     */
429    public function getDataAccess(): DataAccess {
430        return $this->dataAccess;
431    }
432
433    /**
434     * Return the ContentMetadataCollector.
435     * @return ContentMetadataCollector
436     */
437    public function getMetadata(): ContentMetadataCollector {
438        return $this->metadata;
439    }
440
441    /**
442     * Return the Table of Contents information for the article.
443     * @return TOCData
444     */
445    public function getTOCData(): TOCData {
446        return $this->tocData;
447    }
448
449    public function nativeTemplateExpansionEnabled(): bool {
450        return $this->nativeTemplateExpansion;
451    }
452
453    /**
454     * Get the current uid counter value
455     * @return int
456     */
457    public function getUID(): int {
458        return $this->uid;
459    }
460
461    /**
462     * Get the current fragment id counter value
463     * @return int
464     */
465    public function getFID(): int {
466        return $this->fid;
467    }
468
469    /**
470     * Whether `<section>` wrappers should be added.
471     * @todo Does this actually belong here? Should it be a behavior switch?
472     * @return bool
473     */
474    public function getWrapSections(): bool {
475        return $this->wrapSections;
476    }
477
478    /**
479     * Get the pipeline factory.
480     * @return ParserPipelineFactory
481     */
482    public function getPipelineFactory(): ParserPipelineFactory {
483        return $this->pipelineFactory;
484    }
485
486    /**
487     * Return the external format of character offsets in source ranges.
488     * Internally we always keep DomSourceRange and SourceRange information
489     * as UTF-8 byte offsets for efficiency (matches the native string
490     * representation), but for external use we can convert these to
491     * other formats when we output wt2html or input for html2wt.
492     *
493     * @see Parsoid\Wt2Html\DOM\Processors\ConvertOffsets
494     * @return ('byte'|'ucs2'|'char')
495     */
496    public function getRequestOffsetType(): string {
497        return $this->requestOffsetType;
498    }
499
500    /**
501     * Return the current format of character offsets in source ranges.
502     * This allows us to track whether the internal byte offsets have
503     * been converted to the external format (as returned by
504     * `getRequestOffsetType`) yet.
505     *
506     * @see Parsoid\Wt2Html\DOM\Processors\ConvertOffsets
507     * @return ('byte'|'ucs2'|'char')
508     */
509    public function getCurrentOffsetType(): string {
510        return $this->currentOffsetType;
511    }
512
513    /**
514     * Update the current offset type. Only
515     * Parsoid\Wt2Html\DOM\Processors\ConvertOffsets should be doing this.
516     * @param ('byte'|'ucs2'|'char') $offsetType 'byte', 'ucs2', or 'char'
517     */
518    public function setCurrentOffsetType( string $offsetType ) {
519        $this->currentOffsetType = $offsetType;
520    }
521
522    /**
523     * Return the title from the PageConfig, as a Parsoid title.
524     * @return Title
525     */
526    public function getContextTitle(): Title {
527        if ( $this->cachedContextTitle === null ) {
528            $this->cachedContextTitle = Title::newFromLinkTarget(
529                $this->pageConfig->getLinkTarget(), $this->siteConfig
530            );
531        }
532        return $this->cachedContextTitle;
533    }
534
535    /**
536     * Resolve strings that are page-fragments or subpage references with
537     * respect to the current page name.
538     *
539     * @param string $str Page fragment or subpage reference. Not URL encoded.
540     * @param bool $resolveOnly If true, only trim and add the current title to
541     *  lone fragments. TODO: This parameter seems poorly named.
542     * @return string Resolved title
543     */
544    public function resolveTitle( string $str, bool $resolveOnly = false ): string {
545        $origName = $str;
546        $str = trim( $str );
547
548        $pageConfig = $this->getPageConfig();
549        $title = $this->getContextTitle();
550
551        // Resolve lonely fragments (important if the current page is a subpage,
552        // otherwise the relative link will be wrong)
553        if ( $str !== '' && $str[0] === '#' ) {
554            return $title->getPrefixedText() . $str;
555        }
556
557        // Default return value
558        $titleKey = $str;
559        if ( $this->getSiteConfig()->namespaceHasSubpages( $title->getNamespace() ) ) {
560            // Resolve subpages
561            $reNormalize = false;
562            if ( preg_match( '!^(?:\.\./)+!', $str, $relUp ) ) {
563                $levels = strlen( $relUp[0] ) / 3;  // Levels are indicated by '../'.
564                $titleBits = explode( '/', $title->getPrefixedText() );
565                if ( $titleBits[0] === '' ) {
566                    // FIXME: Punt on subpages of titles starting with "/" for now
567                    return $origName;
568                }
569                if ( count( $titleBits ) <= $levels ) {
570                    // Too many levels -- invalid relative link
571                    return $origName;
572                }
573                $newBits = array_slice( $titleBits, 0, -$levels );
574                if ( $str !== $relUp[0] ) {
575                    $newBits[] = substr( $str, $levels * 3 );
576                }
577                $str = implode( '/', $newBits );
578                $reNormalize = true;
579            } elseif ( $str !== '' && $str[0] === '/' ) {
580                // Resolve absolute subpage links
581                $str = $title->getPrefixedText() . $str;
582                $reNormalize = true;
583            }
584
585            if ( $reNormalize && !$resolveOnly ) {
586                // Remove final slashes if present.
587                // See https://gerrit.wikimedia.org/r/173431
588                $str = rtrim( $str, '/' );
589                $titleKey = (string)$this->normalizedTitleKey( $str );
590            }
591        }
592
593        // Strip leading ':'
594        if ( $titleKey !== '' && $titleKey[0] === ':' && !$resolveOnly ) {
595            $titleKey = substr( $titleKey, 1 );
596        }
597        return $titleKey;
598    }
599
600    /**
601     * Get normalized title key for a title string.
602     *
603     * @param string $str Should be in url-decoded format.
604     * @param bool $noExceptions Return null instead of throwing exceptions.
605     * @param bool $ignoreFragment Ignore the fragment, if any.
606     * @return string|null Normalized title key for a title string (or null for invalid titles).
607     */
608    public function normalizedTitleKey(
609        string $str, bool $noExceptions = false, bool $ignoreFragment = false
610    ): ?string {
611        $title = $this->makeTitleFromURLDecodedStr( $str, 0, $noExceptions );
612        if ( !$title ) {
613            return null;
614        }
615        return $ignoreFragment ?
616            $title->getPrefixedDBKey() :
617            $title->getFullDBKey();
618    }
619
620    /**
621     * Create a Title object
622     * @param string $text URL-decoded text
623     * @param ?int $defaultNs
624     * @param bool $noExceptions
625     * @return Title|null
626     */
627    private function makeTitle( string $text, ?int $defaultNs = null, bool $noExceptions = false ): ?Title {
628        try {
629            if ( preg_match( '!^(?:[#/]|\.\./)!', $text ) ) {
630                $defaultNs = $this->getContextTitle()->getNamespace();
631            }
632            $text = $this->resolveTitle( $text );
633            return Title::newFromText( $text, $this->getSiteConfig(), $defaultNs );
634        } catch ( TitleException $e ) {
635            if ( $noExceptions ) {
636                return null;
637            }
638            throw $e;
639        }
640    }
641
642    /**
643     * Create a Title object
644     * @see Title::newFromURL in MediaWiki
645     * @param string $str URL-encoded text
646     * @param ?int $defaultNs
647     * @param bool $noExceptions
648     * @return Title|null
649     */
650    public function makeTitleFromText(
651        string $str, ?int $defaultNs = null, bool $noExceptions = false
652    ): ?Title {
653        return $this->makeTitle( Utils::decodeURIComponent( $str ), $defaultNs, $noExceptions );
654    }
655
656    /**
657     * Create a Title object
658     * @see Title::newFromText in MediaWiki
659     * @param string $str URL-decoded text
660     * @param ?int $defaultNs
661     * @param bool $noExceptions
662     * @return Title|null
663     */
664    public function makeTitleFromURLDecodedStr(
665        string $str, ?int $defaultNs = null, bool $noExceptions = false
666    ): ?Title {
667        return $this->makeTitle( $str, $defaultNs, $noExceptions );
668    }
669
670    /**
671     * Make a link to a local Title
672     * @param Title $title
673     * @return string
674     */
675    public function makeLink( Title $title ): string {
676        // T380676: This method *should* be used only for local titles,
677        // (ie $title->getInterwiki() should be '') but apparently we
678        // are using it for interwiki/interlanguage links as well.
679        return $this->getSiteConfig()->relativeLinkPrefix() . Sanitizer::sanitizeTitleURI(
680            $title->getFullDBKey(),
681            false
682        );
683    }
684
685    /**
686     * Test if an href attribute value could be a valid link target
687     * @param string|(Token|string)[] $href
688     * @return bool
689     */
690    public function isValidLinkTarget( $href ): bool {
691        $href = TokenUtils::tokensToString( $href );
692
693        // decode percent-encoding so that we can reliably detect
694        // bad page title characters
695        $hrefToken = Utils::decodeURIComponent( $href );
696        return $this->normalizedTitleKey( $this->resolveTitle( $hrefToken, true ), true ) !== null;
697    }
698
699    /**
700     * Generate a new uid
701     * @return int
702     */
703    public function generateUID(): int {
704        return $this->uid++;
705    }
706
707    /**
708     * Generate a new annotation uid
709     * @return int
710     */
711    public function generateAnnotationUID(): int {
712        return $this->annUid++;
713    }
714
715    /**
716     * Generate a new annotation id
717     * @return string
718     */
719    public function newAnnotationId(): string {
720        return "mwa" . $this->generateAnnotationUID();
721    }
722
723    /**
724     * Generate a new about id
725     * @return string
726     */
727    public function newAboutId(): string {
728        return '#mwt' . $this->generateUID();
729    }
730
731    /**
732     * Store reference to DOM diff document
733     * @param Document $doc
734     */
735    public function setDOMDiff( $doc ): void {
736        $this->domDiff = $doc;
737    }
738
739    /**
740     * Return reference to DOM diff document
741     * @return Document|null
742     */
743    public function getDOMDiff(): ?Document {
744        return $this->domDiff;
745    }
746
747    /**
748     * Generate a new fragment id
749     * @return string
750     */
751    public function newFragmentId(): string {
752        return "mwf" . (string)$this->fid++;
753    }
754
755    /**
756     * When an environment is constructed, we initialize a document (and
757     * RemexPipeline) to be used throughout the parse.
758     *
759     * @param ?Document $topLevelDoc if non-null,
760     *  the document should be prepared and loaded.
761     */
762    public function setupTopLevelDoc( ?Document $topLevelDoc = null ): void {
763        if ( $topLevelDoc ) {
764            $this->remexPipeline = null;
765            // This is a prepared & loaded Document.
766            Assert::invariant(
767                DOMDataUtils::isPreparedAndLoaded( $topLevelDoc ),
768                "toplevelDoc should be prepared and loaded already"
769            );
770            $this->topLevelDoc = $topLevelDoc;
771        } else {
772            $this->remexPipeline = new RemexPipeline( $this );
773            $this->topLevelDoc = $this->remexPipeline->doc;
774            // Prepare and load.
775            // (Loading should be easy since the doc is expected to be empty.)
776            $options = [
777                'validateXMLNames' => true,
778                 // Don't mark the <body> tag as new!
779                'markNew' => false,
780            ];
781            DOMDataUtils::prepareDoc( $this->topLevelDoc );
782            DOMDataUtils::visitAndLoadDataAttribs(
783                DOMCompat::getBody( $this->topLevelDoc ), $options
784            );
785            // Mark the document as loaded so we can try to catch errors which
786            // might try to reload this again later.
787            DOMDataUtils::getBag( $this->topLevelDoc )->loaded = true;
788        }
789    }
790
791    /**
792     * Return the current top-level document. During wt2html, this
793     * will be the document associated with the RemexPipeline. During
794     * html2wt, this will be the input document, typically passed as a
795     * constructor option.
796     *
797     * This document will be prepared and loaded; see
798     * ContentUtils::createAndLoadDocument().
799     */
800    public function getTopLevelDoc(): Document {
801        return $this->topLevelDoc;
802    }
803
804    public function fetchRemexPipeline( bool $toFragment ): RemexPipeline {
805        if ( !$toFragment ) {
806            return $this->remexPipeline;
807        } else {
808            $pipeline = new RemexPipeline( $this );
809            // Attach the top-level bag to the document, for the convenience
810            // of code that modifies the data within the RemexHtml TreeBuilder
811            // pipeline, prior to the migration of nodes to the top-level
812            // document.
813            DOMDataUtils::prepareChildDoc( $this->topLevelDoc, $pipeline->doc );
814            return $pipeline;
815        }
816    }
817
818    /**
819     * Record a behavior switch.
820     *
821     * @param string $switch Switch name
822     * @param mixed $state Relevant state data to record
823     */
824    public function setBehaviorSwitch( string $switch, $state ): void {
825        $this->behaviorSwitches[$switch] = $state;
826    }
827
828    /**
829     * Fetch the state of a previously-recorded behavior switch.
830     *
831     * @param string $switch Switch name
832     * @param mixed $default Default value if the switch was never set
833     * @return mixed State data that was previously passed to setBehaviorSwitch(), or $default
834     */
835    public function getBehaviorSwitch( string $switch, $default = null ) {
836        return $this->behaviorSwitches[$switch] ?? $default;
837    }
838
839    /**
840     * @return array<string,DocumentFragment>
841     */
842    public function getDOMFragmentMap(): array {
843        return $this->fragmentMap;
844    }
845
846    /**
847     * @param string $id Fragment id
848     * @return DocumentFragment
849     */
850    public function getDOMFragment( string $id ): DocumentFragment {
851        return $this->fragmentMap[$id];
852    }
853
854    /**
855     * @param string $id Fragment id
856     * @param DocumentFragment $forest DOM forest
857     *   to store against the fragment id
858     */
859    public function setDOMFragment(
860        string $id, DocumentFragment $forest
861    ): void {
862        Assert::invariant(
863            $forest->ownerDocument === $this->topLevelDoc,
864            "fragment should belong to the top level document"
865        );
866        $this->fragmentMap[$id] = $forest;
867    }
868
869    public function removeDOMFragment( string $id ): void {
870        $domFragment = $this->fragmentMap[$id];
871        Assert::invariant(
872            !$domFragment->hasChildNodes(), 'Fragment should be empty.'
873        );
874        unset( $this->fragmentMap[$id] );
875    }
876
877    public function getPFragment( string $id ): PFragment {
878        return $this->pFragmentMap[$id];
879    }
880
881    /** @param array<string,PFragment> $mapping */
882    public function addToPFragmentMap( array $mapping ): void {
883        $this->pFragmentMap += $mapping;
884    }
885
886    /**
887     * @internal
888     * Serialize pfragment map to string for debugging dumps
889     */
890    public function pFragmentMapToString(): string {
891        $codec = DOMDataUtils::getCodec( $this->getTopLevelDoc() );
892        $buf = '';
893        foreach ( $this->pFragmentMap as $k => $v ) {
894            $buf .= "$k = " . $codec->toJsonString( $v, PFragment::hint() );
895        }
896        return $buf;
897    }
898
899    /**
900     * Record a lint
901     * @param string $type Lint type key
902     * @param array $lintData Data for the lint.
903     *  - dsr: (SourceRange)
904     *  - params: (array)
905     *  - templateInfo: (array|null)
906     */
907    public function recordLint( string $type, array $lintData ): void {
908        if ( !$this->linting( $type ) ) {
909            return;
910        }
911
912        if ( empty( $lintData['dsr'] ) ) {
913            $this->log( 'error/lint', "Missing DSR; msg=", $lintData );
914            return;
915        }
916
917        // This will always be recorded as a native 'byte' offset
918        $lintData['dsr'] = $lintData['dsr']->toJsonArray();
919        $lintData['params'] ??= [];
920
921        $this->lints[] = [ 'type' => $type ] + $lintData;
922    }
923
924    /**
925     * Retrieve recorded lints
926     * @return array[]
927     */
928    public function getLints(): array {
929        return $this->lints;
930    }
931
932    /**
933     * Init lints to the passed array.
934     *
935     * FIXME: This is currently needed to reset lints after converting
936     * DSR offsets because of ordering of DOM passes. So, in reality,
937     * there should be no real use case for setting this anywhere else
938     * but from that single callsite.
939     *
940     * @param array $lints
941     */
942    public function setLints( array $lints ): void {
943        $this->lints = $lints;
944    }
945
946    /**
947     * @param string $prefix
948     * @param mixed ...$args
949     */
950    public function log( string $prefix, ...$args ): void {
951        $this->parsoidLogger->log( $prefix, ...$args );
952    }
953
954    /**
955     * Shortcut helper that also allows early exit if tracing is not enabled.
956     * @param string $prefix
957     * @param mixed ...$args
958     */
959    public function trace( string $prefix, ...$args ): void {
960        if ( $this->traceFlags ) {
961            $this->parsoidLogger->log( $prefix ? "trace/$prefix" : "trace", ...$args );
962        }
963    }
964
965    /**
966     * Bump usage of some limited parser resource
967     * (ex: tokens, # transclusions, # list items, etc.)
968     *
969     * @param string $resource
970     * @param int $count How much of the resource is used?
971     * @return ?bool Returns `null` if the limit was already reached, `false` when exceeded
972     */
973    public function bumpWt2HtmlResourceUse( string $resource, int $count = 1 ): ?bool {
974        $n = $this->wt2htmlUsage[$resource] ?? 0;
975        if ( !$this->compareWt2HtmlLimit( $resource, $n ) ) {
976            return null;
977        }
978        $n += $count;
979        $this->wt2htmlUsage[$resource] = $n;
980        return $this->compareWt2HtmlLimit( $resource, $n );
981    }
982
983    /**
984     * @param string $resource
985     * @param int $n
986     * @return bool Return `false` when exceeded
987     */
988    public function compareWt2HtmlLimit( string $resource, int $n ): bool {
989        $wt2htmlLimits = $this->siteConfig->getWt2HtmlLimits();
990        return !( isset( $wt2htmlLimits[$resource] ) && $n > $wt2htmlLimits[$resource] );
991    }
992
993    /**
994     * Bump usage of some limited serializer resource
995     * (ex: html size)
996     *
997     * @param string $resource
998     * @param int $count How much of the resource is used? (defaults to 1)
999     * @throws ResourceLimitExceededException
1000     */
1001    public function bumpHtml2WtResourceUse( string $resource, int $count = 1 ): void {
1002        $n = $this->html2wtUsage[$resource] ?? 0;
1003        $n += $count;
1004        $this->html2wtUsage[$resource] = $n;
1005        $html2wtLimits = $this->siteConfig->getHtml2WtLimits();
1006        if (
1007            isset( $html2wtLimits[$resource] ) &&
1008            $n > $html2wtLimits[$resource]
1009        ) {
1010            throw new ResourceLimitExceededException( "html2wt: $resource limit exceeded: $n" );
1011        }
1012    }
1013
1014    /**
1015     * Get an appropriate content handler, given a contentmodel.
1016     *
1017     * @param ?string &$contentmodel An optional content model which
1018     *   will override whatever the source specifies.  It gets set to the
1019     *   handler which is used.
1020     * @return ContentModelHandler An appropriate content handler
1021     */
1022    public function getContentHandler(
1023        ?string &$contentmodel = null
1024    ): ContentModelHandler {
1025        $contentmodel ??= $this->pageConfig->getContentModel();
1026        $handler = $this->siteConfig->getContentModelHandler( $contentmodel );
1027        if ( !$handler && $contentmodel !== 'wikitext' ) {
1028            // For now, fallback to 'wikitext' as the default handler
1029            // FIXME: This is bogus, but this is just so suppress noise in our
1030            // logs till we get around to handling all these other content models.
1031            // $this->log( 'warn', "Unknown contentmodel $contentmodel" );
1032        }
1033        return $handler ?? $this->wikitextContentModelHandler;
1034    }
1035
1036    /**
1037     * Is the language converter enabled on this page?
1038     *
1039     * @return bool
1040     */
1041    public function langConverterEnabled(): bool {
1042        return $this->siteConfig->langConverterEnabledBcp47(
1043            $this->pageConfig->getPageLanguageBcp47()
1044        );
1045    }
1046
1047    /**
1048     * The HTML content version of the input document (for html2wt and html2html conversions).
1049     * @see https://www.mediawiki.org/wiki/Parsoid/API#Content_Negotiation
1050     * @see https://www.mediawiki.org/wiki/Specs/HTML#Versioning
1051     * @return string A semver version number
1052     */
1053    public function getInputContentVersion(): string {
1054        return $this->inputContentVersion;
1055    }
1056
1057    /**
1058     * The HTML content version of the input document (for html2wt and html2html conversions).
1059     * @see https://www.mediawiki.org/wiki/Parsoid/API#Content_Negotiation
1060     * @see https://www.mediawiki.org/wiki/Specs/HTML#Versioning
1061     * @return string A semver version number
1062     */
1063    public function getOutputContentVersion(): string {
1064        return $this->outputContentVersion;
1065    }
1066
1067    /**
1068     * If non-null, the language variant used for Parsoid HTML; we convert
1069     * to this if wt2html, or from this (if html2wt).
1070     *
1071     * @return ?Bcp47Code a BCP-47 language code
1072     */
1073    public function getHtmlVariantLanguageBcp47(): ?Bcp47Code {
1074        return $this->htmlVariantLanguage; // Stored as BCP-47
1075    }
1076
1077    /**
1078     * If non-null, the language variant to be used for wikitext.  If null,
1079     * heuristics will be used to identify the original wikitext variant
1080     * in wt2html mode, and in html2wt mode new or edited HTML will be left
1081     * unconverted.
1082     *
1083     * @return ?Bcp47Code a BCP-47 language code
1084     */
1085    public function getWtVariantLanguageBcp47(): ?Bcp47Code {
1086        return $this->wtVariantLanguage;
1087    }
1088
1089    public function getSkipLanguageConversionPass(): bool {
1090        return $this->skipLanguageConversionPass;
1091    }
1092
1093    /**
1094     * Determine appropriate vary headers for the HTML form of this page.
1095     * @return string
1096     */
1097    public function htmlVary(): string {
1098        $varies = [ 'Accept' ]; // varies on Content-Type
1099        if ( $this->langConverterEnabled() ) {
1100            $varies[] = 'Accept-Language';
1101        }
1102
1103        sort( $varies );
1104        return implode( ', ', $varies );
1105    }
1106
1107    /**
1108     * Determine an appropriate content-language for the HTML form of this page.
1109     * @return Bcp47Code a BCP-47 language code.
1110     */
1111    public function htmlContentLanguageBcp47(): Bcp47Code {
1112        // PageConfig::htmlVariant is set iff we do variant conversion on the
1113        // HTML
1114        return $this->pageConfig->getVariantBcp47() ??
1115            $this->pageConfig->getPageLanguageBcp47();
1116    }
1117
1118    /**
1119     * Get an array of attributes to apply to an anchor linking to $url
1120     */
1121    public function getExternalLinkAttribs( string $url ): array {
1122        $siteConfig = $this->getSiteConfig();
1123        $noFollowConfig = $siteConfig->getNoFollowConfig();
1124        $attribs = [];
1125        $ns = $this->getContextTitle()->getNamespace();
1126        if (
1127            $noFollowConfig['nofollow'] &&
1128            !in_array( $ns, $noFollowConfig['nsexceptions'], true ) &&
1129            !UrlUtils::matchesDomainList(
1130                $url,
1131                // Cast to an array because parserTests sets it as a string
1132                (array)$noFollowConfig['domainexceptions']
1133            )
1134        ) {
1135            $attribs['rel'] = [ 'nofollow' ];
1136        }
1137        $target = $siteConfig->getExternalLinkTarget();
1138        if ( $target ) {
1139            $attribs['target'] = $target;
1140            if ( !in_array( $target, [ '_self', '_parent', '_top' ], true ) ) {
1141                // T133507. New windows can navigate parent cross-origin.
1142                // Including noreferrer due to lacking browser
1143                // support of noopener. Eventually noreferrer should be removed.
1144                if ( !isset( $attribs['rel'] ) ) {
1145                    $attribs['rel'] = [];
1146                }
1147                array_push( $attribs['rel'], 'noreferrer', 'noopener' );
1148            }
1149        }
1150        return $attribs;
1151    }
1152
1153    /**
1154     * @return array
1155     */
1156    public function getLinterConfig(): array {
1157        return $this->linterOverrides + $this->getSiteConfig()->getLinterSiteConfig();
1158    }
1159
1160    /**
1161     * Whether to enable linter Backend.
1162     * Consults the allow list and block list from ::getLinterConfig().
1163     *
1164     * @param string|null $type If $type is null or omitted, returns true if *any* linting
1165     *   type is enabled; otherwise returns true only if the specified
1166     *   linting type is enabled.
1167     * @return bool If $type is null or omitted, returns true if *any* linting
1168     *   type is enabled; otherwise returns true only if the specified
1169     *   linting type is enabled.
1170     */
1171    public function linting( ?string $type = null ) {
1172        if ( !$this->getSiteConfig()->linterEnabled() ) {
1173            return false;
1174        }
1175        $lintConfig = $this->getLinterConfig();
1176        // Allow list
1177        $allowList = $lintConfig['enabled'] ?? null;
1178        if ( is_array( $allowList ) ) {
1179            if ( $type === null ) {
1180                return count( $allowList ) > 0;
1181            }
1182            return in_array( $type, $allowList, true );
1183        }
1184        // Block list
1185        if ( $type === null ) {
1186            return true;
1187        }
1188        $blockList = $lintConfig['disabled'] ?? null;
1189        if ( is_array( $blockList ) ) {
1190            return !in_array( $type, $blockList, true );
1191        }
1192        // No specific configuration
1193        return true;
1194    }
1195}