Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.09% covered (danger)
7.09%
21 / 296
1.85% covered (danger)
1.85%
1 / 54
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParsoidExtensionAPI
7.09% covered (danger)
7.09%
21 / 296
1.85% covered (danger)
1.85%
1 / 54
10171.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 pushError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 createInterfaceI18nFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createPageContentI18nFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createLangI18nFragment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addInterfaceI18nAttribute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addPageContentI18nAttribute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addLangI18nAttribute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getErrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTopLevelDoc
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
 getSiteConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageConfig
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
 getTitleUri
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageUri
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 inTemplate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isPreview
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parentExtTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parentExtTagOpts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContentDOM
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 clearContentDOM
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wikitextToDOM
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
56
 extTagToDOM
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 setTempNodeData
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getTempNodeData
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 extArgToDOM
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 extArgsToArray
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 findAndUpdateArg
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 updateAllArgs
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 normalizeWhiteSpaceInArgs
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 addNewArg
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
 processAttributeEmbeddedHTML
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processAttributeEmbeddedDom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 migrateChildrenAndTransferWrapperDataAttribs
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessWikitext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 htmlToDom
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 domToHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setHtml2wtStateFlag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extStartTagToWikitext
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 domToWikitext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 htmlToWikitext
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getOrigSrc
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 domChildrenToWikitext
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 escapeWikitext
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 postProcessDOM
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderMedia
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 1
600
 serializeMedia
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 addModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addModuleStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalLinkAttribs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addTrackingCategory
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Ext;
5
6use Closure;
7use Wikimedia\Bcp47Code\Bcp47Code;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\Config\PageConfig;
10use Wikimedia\Parsoid\Config\SiteConfig;
11use Wikimedia\Parsoid\Core\ContentMetadataCollector;
12use Wikimedia\Parsoid\Core\ContentMetadataCollectorStringSets as CMCSS;
13use Wikimedia\Parsoid\Core\DomSourceRange;
14use Wikimedia\Parsoid\Core\MediaStructure;
15use Wikimedia\Parsoid\Core\Sanitizer;
16use Wikimedia\Parsoid\DOM\Document;
17use Wikimedia\Parsoid\DOM\DocumentFragment;
18use Wikimedia\Parsoid\DOM\Element;
19use Wikimedia\Parsoid\DOM\Node;
20use Wikimedia\Parsoid\Fragments\PFragment;
21use Wikimedia\Parsoid\Fragments\WikitextPFragment;
22use Wikimedia\Parsoid\Html2Wt\ConstrainedText\WikiLinkText;
23use Wikimedia\Parsoid\Html2Wt\LinkHandlerUtils;
24use Wikimedia\Parsoid\Html2Wt\SerializerState;
25use Wikimedia\Parsoid\NodeData\DataMwError;
26use Wikimedia\Parsoid\Tokens\KV;
27use Wikimedia\Parsoid\Tokens\SourceRange;
28use Wikimedia\Parsoid\Utils\ContentUtils;
29use Wikimedia\Parsoid\Utils\DOMCompat;
30use Wikimedia\Parsoid\Utils\DOMDataUtils;
31use Wikimedia\Parsoid\Utils\DOMUtils;
32use Wikimedia\Parsoid\Utils\PipelineUtils;
33use Wikimedia\Parsoid\Utils\Title;
34use Wikimedia\Parsoid\Utils\TokenUtils;
35use Wikimedia\Parsoid\Utils\Utils;
36use Wikimedia\Parsoid\Utils\WTUtils;
37use Wikimedia\Parsoid\Wikitext\Wikitext;
38use Wikimedia\Parsoid\Wt2Html\DOM\Processors\AddMetaData;
39use Wikimedia\Parsoid\Wt2Html\Frame;
40
41/**
42 * Extensions are expected to use only these interfaces and strongly discouraged from
43 * calling Parsoid code directly. Code review is expected to catch these discouraged
44 * code patterns. We'll have to finish grappling with the extension and hooks API
45 * to go down this path seriously. Till then, we'll have extensions leveraging existing
46 * code as in the native extension code in this repository.
47 */
48class ParsoidExtensionAPI {
49    /** @var Env */
50    private $env;
51
52    /** @var ?Frame */
53    private $frame;
54
55    /** @var ?ExtensionTag */
56    public $extTag;
57
58    /**
59     * @var ?array TokenHandler options
60     */
61    private $wt2htmlOpts;
62
63    /**
64     * @var ?array Serialiation options / state
65     */
66    private $html2wtOpts;
67
68    /**
69     * @var ?SerializerState
70     */
71    private $serializerState;
72
73    /**
74     * Errors collected while parsing.  This is used to indicate that, although
75     * an extension is returning a dom fragment, errors were encountered while
76     * generating it and should be marked up with the mw:Error typeof.
77     *
78     * @var list<DataMwError>
79     */
80    private $errors = [];
81
82    /**
83     * @param Env $env
84     * @param ?array $options
85     *  - wt2html: used in wt->html direction
86     *    - frame: (Frame)
87     *    - parseOpts: (array)
88     *      - extTag: (string)
89     *      - extTagOpts: (array)
90     *      - inTemplate: (bool)
91     *    - extTag: (ExtensionTag)
92     *  - html2wt: used in html->wt direction
93     *    - state: (SerializerState)
94     */
95    public function __construct(
96        Env $env, ?array $options = null
97    ) {
98        $this->env = $env;
99        $this->wt2htmlOpts = $options['wt2html'] ?? null;
100        $this->html2wtOpts = $options['html2wt'] ?? null;
101        $this->serializerState = $this->html2wtOpts['state'] ?? null;
102        $this->frame = $this->wt2htmlOpts['frame'] ?? $env->topFrame ?? null;
103        $this->extTag = $this->wt2htmlOpts['extTag'] ?? null;
104    }
105
106    /**
107     * Collect errors while parsing.  If processing can't continue, an
108     * ExtensionError should be thrown instead.
109     *
110     * $key and $params are basically the arguments to wfMessage, although they
111     * will be stored in the data-mw of the encapsulation wrapper.
112     *
113     * See https://www.mediawiki.org/wiki/Specs/HTML#Error_handling
114     *
115     * The returned fragment can be inserted in the dom and will be populated
116     * with the localized message.  See T266666
117     *
118     * @unstable
119     * @param string $key
120     * @param mixed ...$params
121     * @return DocumentFragment
122     */
123    public function pushError( string $key, ...$params ): DocumentFragment {
124        $this->errors[] = new DataMwError( $key, $params );
125        return WTUtils::createInterfaceI18nFragment( $this->getTopLevelDoc(), $key, $params );
126    }
127
128    /**
129     * Creates an internationalization (i18n) message that will be localized into the user
130     * interface language. The returned DocumentFragment contains, as a single child, a span
131     * element with the appropriate information for later localization.
132     * @param string $key message key for the message to be localized
133     * @param ?array $params parameters for localization
134     * @return DocumentFragment
135     */
136    public function createInterfaceI18nFragment( string $key, ?array $params ): DocumentFragment {
137        return WTUtils::createInterfaceI18nFragment( $this->getTopLevelDoc(), $key, $params );
138    }
139
140    /**
141     * Creates an internationalization (i18n) message that will be localized into the page content
142     * language. The returned DocumentFragment contains, as a single child, a span
143     * element with the appropriate information for later localization.
144     * @param string $key message key for the message to be localized
145     * @param ?array $params parameters for localization
146     * @return DocumentFragment
147     */
148    public function createPageContentI18nFragment( string $key, ?array $params ): DocumentFragment {
149        return WTUtils::createPageContentI18nFragment( $this->getTopLevelDoc(), $key, $params );
150    }
151
152    /**
153     * Creates an internationalization (i18n) message that will be localized into an arbitrary
154     * language. The returned DocumentFragment contains, as a single child, a span
155     * element with the appropriate information for later localization.
156     * The use of this method is discouraged; use ::createPageContentI18nFragment(...) and
157     * ::createInterfaceI18nFragment(...) where possible rather than, respectively,
158     * ::createLangI18nFragment($wgContLang, ...) and ::createLangI18nFragment($wgLang, ...).
159     * @param Bcp47Code $lang language in which the message will be localized
160     * @param string $key message key for the message to be localized
161     * @param ?array $params parameters for localization
162     * @return DocumentFragment
163     */
164    public function createLangI18nFragment( Bcp47Code $lang, string $key, ?array $params ): DocumentFragment {
165        return WTUtils::createLangI18nFragment( $this->getTopLevelDoc(), $lang, $key, $params );
166    }
167
168    /**
169     * Adds to $element the internationalization information needed for the attribute $name to be
170     * localized in a later pass into the user interface language.
171     * @param Element $element element on which to add internationalization information
172     * @param string $name name of the attribute whose value will be localized
173     * @param string $key message key used for the attribute value localization
174     * @param ?array $params parameters for localization
175     */
176    public function addInterfaceI18nAttribute(
177        Element $element, string $name, string $key, ?array $params
178    ): void {
179        WTUtils::addInterfaceI18nAttribute( $element, $name, $key, $params );
180    }
181
182    /**
183     * Adds to $element the internationalization information needed for the attribute $name to be
184     * localized in a later pass into the page content language.
185     * @param Element $element element on which to add internationalization information
186     * @param string $name name of the attribute whose value will be localized
187     * @param string $key message key used for the attribute value localization
188     * @param array $params parameters for localization
189     */
190    public function addPageContentI18nAttribute(
191        Element $element, string $name, string $key, array $params
192    ): void {
193        WTUtils::addPageContentI18nAttribute( $element, $name, $key, $params );
194    }
195
196    /**
197     * Adds to $element the internationalization information needed for the attribute $name to be
198     * localized in a later pass into the provided language.
199     * The use of this method is discouraged; use ::addPageContentI18nAttribute(...) and
200     * ::addInterfaceI18nAttribute(...) where possible rather than, respectively,
201     * ::addLangI18nAttribute(..., $wgContLang, ...) and ::addLangI18nAttribute(..., $wgLang, ...).
202     * @param Element $element element on which to add internationalization information
203     * @param Bcp47Code $lang language in which the  attribute will be localized
204     * @param string $name name of the attribute whose value will be localized
205     * @param string $key message key used for the attribute value localization
206     * @param array $params parameters for localization
207     */
208    public function addLangI18nAttribute(
209        Element $element, Bcp47Code $lang, string $name, string $key, array $params
210    ) {
211        WTUtils::addLangI18nAttribute( $element, $lang, $name, $key, $params );
212    }
213
214    /**
215     * @return list<DataMwError>
216     */
217    public function getErrors(): array {
218        return $this->errors;
219    }
220
221    /**
222     * Returns the main document we're parsing.  Extension content is parsed
223     * to fragments of this document.
224     *
225     * @return Document
226     */
227    public function getTopLevelDoc(): Document {
228        return $this->env->getTopLevelDoc();
229    }
230
231    /**
232     * Get a new about id for marking extension output
233     * FIXME: This should never really be needed since the extension API
234     * handles this on behalf of extensions, but Cite has one use case
235     * where implicit <references /> output is added.
236     *
237     * @return string
238     */
239    public function newAboutId(): string {
240        return $this->env->newAboutId();
241    }
242
243    /**
244     * Get the site configuration to let extensions customize
245     * their behavior based on how the wiki is configured.
246     *
247     * @return SiteConfig
248     */
249    public function getSiteConfig(): SiteConfig {
250        return $this->env->getSiteConfig();
251    }
252
253    /**
254     * FIXME: Unsure if we need to provide this access yet
255     * Get the page configuration
256     * @return PageConfig
257     */
258    public function getPageConfig(): PageConfig {
259        return $this->env->getPageConfig();
260    }
261
262    /**
263     * Get the ContentMetadataCollector corresponding to the top-level page.
264     * In Parsoid integrated mode this will typically be an instance of
265     * core's `ParserOutput` class.
266     *
267     * @return ContentMetadataCollector
268     */
269    public function getMetadata(): ContentMetadataCollector {
270        return $this->env->getMetadata();
271    }
272
273    /**
274     * Get the URI to link to a title
275     * @param Title $title
276     * @return string
277     */
278    public function getTitleUri( Title $title ): string {
279        return $this->env->makeLink( $title );
280    }
281
282    /**
283     * Get an URI for the current page
284     * @return string
285     */
286    public function getPageUri(): string {
287        return $this->getTitleUri( $this->env->getContextTitle() );
288    }
289
290    /**
291     * Make a title from an input string
292     * @param string $str
293     * @param int $namespaceId
294     * @return ?Title
295     */
296    public function makeTitle( string $str, int $namespaceId ): ?Title {
297        return $this->env->makeTitleFromText( $str, $namespaceId, true /* no exceptions */ );
298    }
299
300    /**
301     * Are we parsing in a template context?
302     * @return bool
303     */
304    public function inTemplate(): bool {
305        return $this->wt2htmlOpts['parseOpts']['inTemplate'] ?? false;
306    }
307
308    /**
309     * Are we parsing for a preview?
310     * FIXME: Right now, we never do; when we do, this needs to be modified to reflect reality
311     * @unstable
312     * @return bool
313     */
314    public function isPreview(): bool {
315        return false;
316    }
317
318    /**
319     * FIXME: Is this something that can come from the frame?
320     * If we are parsing in the context of a parent extension tag,
321     * return the name of that extension tag
322     * @return string|null
323     */
324    public function parentExtTag(): ?string {
325        return $this->wt2htmlOpts['parseOpts']['extTag'] ?? null;
326    }
327
328    /**
329     * FIXME: Is this something that can come from the frame?
330     * If we are parsing in the context of a parent extension tag,
331     * return the parsing options set by that tag.
332     * @return array
333     */
334    public function parentExtTagOpts(): array {
335        return $this->wt2htmlOpts['parseOpts']['extTagOpts'] ?? [];
336    }
337
338    /**
339     * Get the content DOM corresponding to an id
340     * @param string $contentId
341     * @return DocumentFragment
342     */
343    public function getContentDOM( string $contentId ): DocumentFragment {
344        return $this->env->getDOMFragment( $contentId );
345    }
346
347    public function clearContentDOM( string $contentId ): void {
348        $this->env->removeDOMFragment( $contentId );
349    }
350
351    /**
352     * Parse wikitext to DOM
353     *
354     * @param string|PFragment $wikitextOrPFragment
355     * @param array $opts
356     * - srcOffsets
357     * - processInNewFrame
358     * - clearDSROffsets
359     * - shiftDSRFn
360     * - parseOpts
361     *   - extTag
362     *   - extTagOpts
363     *   - context "inline", "block", etc. Currently, only "inline" is supported
364     * @param bool $sol Whether tokens should be processed in start-of-line context.
365     * @return DocumentFragment
366     */
367    public function wikitextToDOM(
368        $wikitextOrPFragment, array $opts, bool $sol
369    ): DocumentFragment {
370        if ( is_string( $wikitextOrPFragment ) ) {
371            $srcOffsets = $opts['srcOffsets'] ?? null;
372            if ( $srcOffsets !== null && !$srcOffsets instanceof DomSourceRange ) {
373                $srcOffsets = DomSourceRange::fromTsr( $srcOffsets );
374            }
375            $pFragment = WikitextPFragment::newFromWt(
376                $wikitextOrPFragment, $srcOffsets
377            );
378        } else {
379            $pFragment = $wikitextOrPFragment;
380        }
381        if ( $pFragment->isEmpty() ) {
382            $domFragment = $this->getTopLevelDoc()->createDocumentFragment();
383        } else {
384            // Parse content to DOM and pass DOM-fragment token back to the main pipeline.
385            // The DOM will get unwrapped and integrated  when processing the top level document.
386            [
387                'frame' => $frame,
388                'wikitext' => $wikitext,
389                'srcOffsets' => $srcOffsets,
390            ] = PipelineUtils::preparePFragment(
391                $this->env, $this->frame, $pFragment, $opts
392            );
393            $parseOpts = $opts['parseOpts'] ?? [];
394            $domFragment = PipelineUtils::processContentInPipeline(
395                $this->env, $frame, $wikitext,
396                [
397                    // Full pipeline for processing content
398                    'pipelineType' => 'wikitext-to-fragment',
399                    'pipelineOpts' => [
400                        'expandTemplates' => true,
401                        'extTag' => $parseOpts['extTag'] ?? null,
402                        'extTagOpts' => $parseOpts['extTagOpts'] ?? null,
403                        'inTemplate' => $this->inTemplate(),
404                        'inlineContext' => ( $parseOpts['context'] ?? '' ) === 'inline',
405                    ],
406                    'srcOffsets' => $srcOffsets,
407                    'sol' => $sol,
408                ]
409            );
410
411            if ( !empty( $opts['clearDSROffsets'] ) ) {
412                $dsrFn = static function ( DomSourceRange $dsr ) {
413                    return null;
414                };
415            } else {
416                $dsrFn = $opts['shiftDSRFn'] ?? null;
417            }
418
419            if ( $dsrFn ) {
420                ContentUtils::shiftDSR( $this->env, $domFragment, $dsrFn, $this );
421            }
422        }
423        return $domFragment;
424    }
425
426    /**
427     * Parse extension tag to DOM.
428     *
429     * If a wrapper tag is requested, beyond parsing the contents of the extension tag,
430     * this method wraps the contents in a custom wrapper element (ex: <div>), sanitizes
431     * the arguments of the extension args and sets some content flags on the wrapper.
432     *
433     * @param array $extArgs Args sanitized and applied to wrapper
434     * @param string $wikitext Wikitext content of the tag
435     * @param array $opts
436     * - srcOffsets
437     * - wrapperTag (skip OR pass null to not add any wrapper tag)
438     * - processInNewFrame
439     * - clearDSROffsets
440     * - shiftDSRFn
441     * - parseOpts
442     *   - extTag
443     *   - extTagOpts
444     *   - context
445     * @return DocumentFragment "prepared and loaded"
446     */
447    public function extTagToDOM(
448        array $extArgs, string $wikitext, array $opts
449    ): DocumentFragment {
450        $extTagOffsets = $this->extTag->getOffsets();
451        if ( !isset( $opts['srcOffsets'] ) ) {
452            $opts['srcOffsets'] = new SourceRange(
453                $extTagOffsets->innerStart(),
454                $extTagOffsets->innerEnd()
455            );
456        }
457
458        $domFragment = $this->wikitextToDOM( $wikitext, $opts, true /* sol */ );
459        if ( !empty( $opts['wrapperTag'] ) ) {
460            // Create a wrapper and migrate content into the wrapper
461            $wrapper = $domFragment->ownerDocument->createElement(
462                $opts['wrapperTag']
463            );
464            DOMUtils::migrateChildren( $domFragment, $wrapper );
465            $domFragment->appendChild( $wrapper );
466
467            // Sanitize args and set on the wrapper
468            Sanitizer::applySanitizedArgs( $this->env->getSiteConfig(), $wrapper, $extArgs );
469
470            // Mark empty content DOMs
471            if ( $wikitext === '' ) {
472                DOMDataUtils::getDataParsoid( $wrapper )->empty = true;
473            }
474
475            if ( !empty( $this->extTag->isSelfClosed() ) ) {
476                DOMDataUtils::getDataParsoid( $wrapper )->selfClose = true;
477            }
478        }
479
480        return $domFragment;
481    }
482
483    /**
484     * Set temporary data into the DOM node that will be discarded
485     * when DOM is serialized
486     *
487     * Use the tag name as the key for TempData management
488     *
489     * @param Element $node
490     * @param mixed $data
491     */
492    public function setTempNodeData( Element $node, $data ): void {
493        $dataParsoid = DOMDataUtils::getDataParsoid( $node );
494        $tmpData = $dataParsoid->getTemp();
495        $key = $this->extTag->getName();
496        $tmpData->setTagData( $key, $data );
497    }
498
499    /**
500     * Get temporary data into the DOM node that will be discarded
501     * when DOM is serialized.
502     *
503     * This should only be used when the ExtensionTag is not available; otherwise access the newly created data
504     * directly.
505     *
506     * @param Element $node
507     * @param string $key to access TmpData
508     * @return mixed
509     * @unstable
510     */
511    public function getTempNodeData( Element $node, string $key ) {
512        if ( $this->extTag ) {
513            throw new \RuntimeException(
514                'ExtensionTag is available. Data should be available directly through the DOM.'
515            );
516        }
517        $dataParsoid = DOMDataUtils::getDataParsoid( $node );
518        $tmpData = $dataParsoid->getTemp();
519        return $tmpData->getTagData( $key );
520    }
521
522    /**
523     * Process a specific extension arg as wikitext and return its DOM equivalent.
524     * By default, this method processes the argument value in inline context and normalizes
525     * every whitespace character to a single space.
526     * @param KV[] $extArgs
527     * @param string $key should be lower-case
528     * @param string $context
529     * @return ?DocumentFragment
530     */
531    public function extArgToDOM(
532        array $extArgs, string $key, string $context = "inline"
533    ): ?DocumentFragment {
534        $argKV = KV::lookupKV( $extArgs, strtolower( $key ) );
535        if ( $argKV === null || !$argKV->v ) {
536            return null;
537        }
538
539        if ( $context === "inline" ) {
540            // `normalizeExtOptions` can mess up source offsets as well as the string
541            // that ought to be processed as wikitext. So, we do our own whitespace
542            // normalization of the original source here.
543            //
544            // If 'context' is 'inline' below, it ensures indent-pre / p-wrapping is suppressed.
545            // So, the normalization is primarily for HTML string parity.
546            $argVal = preg_replace( '/[\t\r\n ]/', ' ', $argKV->vsrc );
547        } else {
548            $argVal = $argKV->vsrc;
549        }
550
551        return $this->wikitextToDOM(
552            $argVal,
553            [
554                'parseOpts' => [
555                    'extTag' => $this->extTag->getName(),
556                    'context' => $context,
557                ],
558                'srcOffsets' => $argKV->valueOffset(),
559            ],
560            false // inline context => no sol state
561        );
562    }
563
564    /**
565     * Convert the ext args representation from an array of KV objects
566     * to a plain associative array mapping arg name strings to arg value strings.
567     * @param array<KV> $extArgs
568     * @return array<string,string>
569     */
570    public function extArgsToArray( array $extArgs ): array {
571        return TokenUtils::kvToHash( $extArgs );
572    }
573
574    /**
575     * This method finds a requested arg by key name and return its current value.
576     * If a closure is passed in to update the current value, it is used to update the arg.
577     *
578     * @param KV[] &$extArgs Array of extension args
579     * @param string $key Argument key whose value needs an update
580     * @param ?Closure $updater $updater will get the existing string value
581     *   for the arg and is expected to return an updated value.
582     * @return ?string
583     */
584    public function findAndUpdateArg(
585        array &$extArgs, string $key, ?Closure $updater = null
586    ): ?string {
587        // FIXME: This code will get an overhaul when T250854 is resolved.
588        foreach ( $extArgs as $i => $kv ) {
589            $k = TokenUtils::tokensToString( $kv->k );
590            if ( strtolower( trim( $k ) ) === strtolower( $key ) ) {
591                $val = $kv->v;
592                if ( $updater ) {
593                    $kv = clone $kv;
594                    $kv->v = $updater( TokenUtils::tokensToString( $val ) );
595                    $extArgs[$i] = $kv;
596                }
597                return $val;
598            }
599        }
600
601        return null;
602    }
603
604    /**
605     * Updates all arguments, similar to findAndUpdateArg, but applies the closure to all arguments
606     * @param KV[] &$extArgs Array of extension args
607     * @param ?Closure $updater $updater will get the existing string value
608     *    for the key and value of the arg and is expected to return an updated value.
609     */
610    public function updateAllArgs( array &$extArgs, ?Closure $updater = null ): void {
611        if ( !$updater ) {
612            return;
613        }
614        foreach ( $extArgs as $i => $kv ) {
615            $k = TokenUtils::tokensToString( $kv->k );
616            $val = $kv->v;
617            $kv = clone $kv;
618            $kv->v = $updater( $k, TokenUtils::tokensToString( $val ) );
619            $extArgs[$i] = $kv;
620        }
621    }
622
623    /**
624     * Normalizes spaces from extension tag arguments, except for those keyed by values in $exceptions
625     * @param KV[] &$extArgs Array of extension args
626     * @param array[] $action array that is either empty or has one key, 'except' or 'only', which defines the
627     * attributes that should be respectively excluded or only included from the normalization
628     */
629    public function normalizeWhiteSpaceInArgs( array &$extArgs, array $action = [] ) {
630        $except = $action['except'] ?? null;
631        $only = $action['only'] ?? null;
632
633        if ( $except && $only ) {
634            $this->log( 'warn', 'normalizeWhiteSpaceInArgs should not have both except and only parameters' );
635            return;
636        }
637
638        if ( $except ) {
639            $closure = static function ( $key, $value ) use ( $except ) {
640                if ( in_array( strtolower( trim( $key ) ), $except, true ) ) {
641                    return $value;
642                } else {
643                    return trim( preg_replace( '/[\r\n\t ]+/', ' ', $value ) );
644                }
645            };
646        } elseif ( $only ) {
647            $closure = static function ( $key, $value ) use ( $only ) {
648                if ( in_array( strtolower( trim( $key ) ), $only, true ) ) {
649                    return trim( preg_replace( '/[\r\n\t ]+/', ' ', $value ) );
650                } else {
651                    return $value;
652                }
653            };
654        } else {
655            $closure = static function ( $key, $value ) {
656                return trim( preg_replace( '/[\r\n\t ]+/', ' ', $value ) );
657            };
658        }
659
660        $this->updateAllArgs( $extArgs, $closure );
661    }
662
663    /**
664     * This method adds a new argument to the extension args array
665     * @param KV[] &$extArgs
666     * @param string $key
667     * @param string $value
668     */
669    public function addNewArg( array &$extArgs, string $key, string $value ): void {
670        $extArgs[] = new KV( $key, $value );
671    }
672
673    // TODO: Provide support for extensions to register lints
674    // from their customized lint handlers.
675
676    /**
677     * Forwards the logging request to the underlying logger
678     * @param string $prefix
679     * @param mixed ...$args
680     */
681    public function log( string $prefix, ...$args ): void {
682        $this->env->log( $prefix, ...$args );
683    }
684
685    /**
686     * Extensions might be interested in examining (their) content embedded
687     * in attributes that don't otherwise show up in the DOM.
688     *
689     * Ex: inline media captions that aren't rendered, language variant markup,
690     *     attributes that are transcluded. More scenarios might be added later.
691     * @deprecated
692     * Don't use this directly: use ::processAttributeEmbeddedDom().
693     * This method may omit content which is embedded natively as
694     * DocumentFragments instead of as HTML strings.
695     *
696     * @param Element $elt The node whose data attributes need to be examined
697     * @param Closure $proc The processor that will process the embedded HTML
698     *        Signature: (string) -> string
699     *        This processor will be provided the HTML string as input
700     *        and is expected to return a possibly modified string.
701     */
702    public function processAttributeEmbeddedHTML( Element $elt, Closure $proc ): void {
703        // @phan-suppress-next-line PhanDeprecatedFunction
704        ContentUtils::processAttributeEmbeddedHTML( $this, $elt, $proc );
705    }
706
707    /**
708     * Extensions might be interested in examining (their) content embedded
709     * in attributes that don't otherwise show up in the DOM.
710     *
711     * Ex: inline media captions that aren't rendered, language variant markup,
712     *     attributes that are transcluded. More scenarios might be added later.
713     *
714     * @param Element $elt The node whose data attributes need to be examined
715     * @param callable(DocumentFragment):bool $proc
716     *        The processor that will process the embedded HTML.
717     *        This processor will be provided a DocumentFragment
718     *        and is expected to return true if that fragment was modified.
719     */
720    public function processAttributeEmbeddedDom( Element $elt, callable $proc ): void {
721        ContentUtils::processAttributeEmbeddedDom( $this, $elt, $proc );
722    }
723
724    /**
725     * Copy $from->childNodes to $to and clone the data attributes of $from
726     * to $to.
727     *
728     * @param Element $from
729     * @param Element $to
730     */
731    public static function migrateChildrenAndTransferWrapperDataAttribs(
732        Element $from, Element $to
733    ): void {
734        DOMUtils::migrateChildren( $from, $to );
735        DOMDataUtils::setDataParsoid(
736            $to, clone DOMDataUtils::getDataParsoid( $from )
737        );
738        DOMDataUtils::setDataMw(
739            $to, clone DOMDataUtils::getDataMw( $from )
740        );
741    }
742
743    /**
744     * Equivalent of 'preprocess' from Parser.php in core.
745     * - expands templates
746     * - replaces magic variables
747     * This does not run any hooks however since that would be unexpected.
748     * This also doesn't support replacing template args from a frame.
749     *
750     * @param string $wikitext
751     * @return array{error:bool,src?:string,fragment?:PFragment}
752     *  - 'error' did we hit resource limits?
753     *  - 'src' expanded wikitext OR error message to print
754     *     FIXME: Maybe error message should be localizable
755     *  - 'fragment' Optional fragment (wikitext plus strip state)
756     */
757    public function preprocessWikitext( string $wikitext ) {
758        return Wikitext::preprocess( $this->env, $wikitext );
759    }
760
761    /**
762     * Parse input string into DOM.
763     * NOTE: This leaves the DOM in Parsoid-canonical state and is the preferred method
764     * to convert HTML to DOM that will be passed into Parsoid's processing code.
765     *
766     * @param string $html
767     * @param ?Document $doc XXX You probably don't want to be doing this
768     * @param ?array $options
769     * @return DocumentFragment
770     */
771    public function htmlToDom(
772        string $html, ?Document $doc = null, ?array $options = []
773    ): DocumentFragment {
774        return ContentUtils::createAndLoadDocumentFragment(
775            $doc ?? $this->getTopLevelDoc(), $html, $options
776        );
777    }
778
779    /**
780     * Serialize DOM element to string (inner/outer HTML is controlled by flag).
781     * If $releaseDom is set to true, the DOM will not be "prepared and loaded"
782     * and is not safe to use after this call. This is primarily a performance optimization.
783     *
784     * @param Node $node
785     * @param bool $innerHTML if true, inner HTML of the element will be returned
786     *    This flag defaults to false
787     * @param bool $releaseDom if true, the DOM will not be in canonical form after this call
788     *    This flag defaults to false
789     * @return string
790     */
791    public function domToHtml(
792        Node $node, bool $innerHTML = false, bool $releaseDom = false
793    ): string {
794        // FIXME: This is going to drop any diff markers but since
795        // the dom differ doesn't traverse into extension content (right now),
796        // none should exist anyways.
797        // FIXME: This roundtrip discards data-parsoid.tmp data from $node.
798        // Maybe it meant to set keepTmp, but that flag is currently broken.
799        $html = ContentUtils::ppToXML( $node, [ 'innerXML' => $innerHTML, 'fragment' => true ] );
800        if ( !$releaseDom ) {
801            DOMDataUtils::visitAndLoadDataAttribs( $node );
802        }
803        return $html;
804    }
805
806    /**
807     * Bit flags describing escaping / serializing context in html -> wt mode
808     */
809    public const IN_SOL = 1;
810    public const IN_MEDIA = 2;
811    public const IN_LINK = 4;
812    public const IN_IMG_CAPTION = 8;
813    public const IN_OPTION = 16;
814
815    /**
816     * FIXME: This is a bit broken - shouldn't be needed ideally
817     * @param string $flag
818     */
819    public function setHtml2wtStateFlag( string $flag ) {
820        $this->serializerState->{$flag} = true;
821    }
822
823    /**
824     * Emit the opening tag (including attributes) for the extension
825     * represented by this node.
826     *
827     * @param Element $node
828     * @return string
829     */
830    public function extStartTagToWikitext( Element $node ): string {
831        $state = $this->serializerState;
832        return $state->serializer->serializeExtensionStartTag( $node, $state );
833    }
834
835    /**
836     * Convert the input DOM to wikitext.
837     *
838     * @param array $opts
839     *  - extName: (string) Name of the extension whose body we are serializing
840     *  - inPHPBlock: (bool) FIXME: This needs to be removed
841     * @param Element $node DOM to serialize
842     * @param bool $releaseDom If $releaseDom is set to true, the DOM will be left in
843     *  non-canonical form and is not safe to use after this call. This is primarily a
844     *  performance optimization.  This flag defaults to false.
845     * @return mixed
846     */
847    public function domToWikitext( array $opts, Element $node, bool $releaseDom = false ) {
848        // FIXME: WTS expects the input DOM to be a <body> element!
849        // Till that is fixed, we have to go through this round-trip!
850        // TODO: Move $node children to a fragment and call `$serializer->domToWikitext`
851        return $this->htmlToWikitext( $opts, $this->domToHtml( $node, $releaseDom ) );
852    }
853
854    /**
855     * Convert the HTML body of an extension to wikitext
856     *
857     * @param array $opts
858     *  - extName: (string) Name of the extension whose body we are serializing
859     *  - inPHPBlock: (bool) FIXME: This needs to be removed
860     * @param string $html HTML for the extension's body
861     * @return string
862     */
863    public function htmlToWikitext( array $opts, string $html ): string {
864        // Type cast so phan has more information to ensure type safety
865        $state = $this->serializerState;
866        $opts['env'] = $this->env;
867        return $state->serializer->htmlToWikitext( $opts, $html );
868    }
869
870    /**
871     * Get the original source for an element.
872     *
873     * The callable, $checkIfOrigSrcReusable, is used to determine if the $elt
874     * is unedited and therefore valid to reuse source.  This is assumed to be
875     * pretty specific to the callsite so no default is provided.
876     *
877     * @param Element $elt
878     * @param bool $inner
879     * @param callable $checkIfOrigSrcReusable
880     * @return string|null
881     */
882    public function getOrigSrc(
883        Element $elt, bool $inner, callable $checkIfOrigSrcReusable
884    ): ?string {
885        $state = $this->serializerState;
886        if ( !$state->selserMode || $state->inInsertedContent ) {
887            return null;
888        }
889        $dsr = DOMDataUtils::getDataParsoid( $elt )->dsr ?? null;
890        if ( !Utils::isValidDSR( $dsr, $inner ) ) {
891            return null;
892        }
893        if ( $checkIfOrigSrcReusable( $elt ) ) {
894            return $state->getOrigSrc(
895                $inner ? $dsr->innerRange() : $dsr
896            );
897        } else {
898            return null;
899        }
900    }
901
902    /**
903     * @param Element $elt
904     * @param int $context OR-ed bit flags specifying escaping / serialization context
905     * @return string
906     */
907    public function domChildrenToWikitext( Element $elt, int $context ): string {
908        $state = $this->serializerState;
909        if ( $context & self::IN_IMG_CAPTION ) {
910            if ( $context & self::IN_OPTION ) {
911                $escapeHandler = 'mediaOptionHandler'; // Escapes "|" as well
912            } else {
913                $escapeHandler = 'wikilinkHandler'; // image captions show up in wikilink syntax
914            }
915            $out = $state->serializeCaptionChildrenToString( $elt,
916                [ $state->serializer->wteHandlers, $escapeHandler ] );
917        } else {
918            throw new \RuntimeException( 'Not yet supported!' );
919        }
920        return $out;
921    }
922
923    /**
924     * Escape any wikitext like constructs in a string so that when the output
925     * is parsed, it renders as a string. The escaping is sensitive to the context
926     * in which the string is embedded. For example, a "*" is not safe at the start
927     * of a line (since it will parse as a list item), but is safe if it is not in
928     * a start of line context. Similarly the "|" character is safe outside tables,
929     * links, and transclusions.
930     *
931     * @param string $str
932     * @param Node $node
933     * @param int $context OR-ed bit flags specifying escaping / serialization context
934     * @return string
935     */
936    public function escapeWikitext( string $str, Node $node, int $context ): string {
937        if ( $context & ( self::IN_MEDIA | self::IN_LINK ) ) {
938            $state = $this->serializerState;
939            return $state->serializer->wteHandlers->escapeLinkContent(
940                $state, $str,
941                (bool)( $context & self::IN_SOL ),
942                $node,
943                (bool)( $context & self::IN_MEDIA )
944            );
945        } else {
946            throw new \RuntimeException( 'Not yet supported!' );
947        }
948    }
949
950    /**
951     * EXTAPI-FIXME: We have to figure out what it means to run a DOM pass
952     * (and what processors and what handlers apply) on content models that are
953     * not wikitext. For now, we are only storing data attribs back to the DOM
954     * and adding metadata to the page.
955     *
956     * @param Document $doc
957     */
958    public function postProcessDOM( Document $doc ): void {
959        // Ugh! But, this whole method needs to go away anyway
960        ( new AddMetaData( null ) )->run( $this->env, DOMCompat::getBody( $doc ) );
961    }
962
963    /**
964     * Produce the HTML rendering of a title string and media options as the
965     * wikitext parser would for a wikilink in the file namespace
966     *
967     * @param string $titleStr Image title string
968     * @param array $imageOpts Array of a mix of strings or arrays,
969     *   the latter of which can signify that the value came from source.
970     *   Where,
971     *     [0] is the fully-constructed image option
972     *     [1] is the full wikitext source offset for it
973     * @param ?string &$error Error string is set when the return is null.
974     * @param ?bool $forceBlock Forces the media to be rendered in a figure as
975     *   opposed to a span.
976     * @param ?bool $suppressMediaFormats If any media format is present in
977     *   $imageOpts, it won't be applied and will result in a linting error.
978     * @return ?Element
979     */
980    public function renderMedia(
981        string $titleStr, array $imageOpts, ?string &$error = null,
982        ?bool $forceBlock = false, ?bool $suppressMediaFormats = false
983    ): ?Element {
984        $extTagName = $this->extTag->getName();
985        $extTagOpts = [ 'suppressMediaFormats' => $suppressMediaFormats ];
986
987        $fileNs = $this->getSiteConfig()->canonicalNamespaceId( 'file' );
988
989        $title = $this->makeTitle( $titleStr, 0 );
990        if ( $title === null || $title->getNamespace() !== $fileNs ) {
991            $error = "{$extTagName}_no_image";
992            return null;
993        }
994
995        $pieces = [ '[[' ];
996        // Since the above two chars aren't from source, the resulting figure
997        // won't have any dsr info, so we can omit an offset for the title as
998        // well.  In any case, $titleStr may not necessarily be from source,
999        // see the special case in the gallery extension.
1000        $pieces[] = $titleStr;
1001        $pieces = array_merge( $pieces, $imageOpts );
1002
1003        if ( $forceBlock ) {
1004            // We add "none" here so that this renders in the block form
1005            // (ie. figure).  It's a valid media option, so shouldn't turn into
1006            // a caption.  And since it's first wins, it shouldn't interfere
1007            // with another horizontal alignment defined in $imageOpts.
1008            // We just have to remember to strip the class below.
1009            // NOTE: This will have to be adjusted with T305628
1010            $pieces[] = '|none';
1011        }
1012
1013        $pieces[] = ']]';
1014
1015        $shiftOffset = static function ( int $offset ) use ( $pieces ): ?int {
1016            foreach ( $pieces as $p ) {
1017                if ( is_string( $p ) ) {
1018                    $offset -= strlen( $p );
1019                    if ( $offset <= 0 ) {
1020                        return null;
1021                    }
1022                } else {
1023                    if ( $offset <= strlen( $p[0] ) && isset( $p[1] ) ) {
1024                        return $p[1] + $offset;
1025                    }
1026                    $offset -= strlen( $p[0] );
1027                    if ( $offset <= 0 ) {
1028                        return null;
1029                    }
1030                }
1031            }
1032            return null;
1033        };
1034
1035        $imageWt = '';
1036        foreach ( $pieces as $p ) {
1037            $imageWt .= ( is_string( $p ) ? $p : $p[0] );
1038        }
1039
1040        $domFragment = $this->wikitextToDOM(
1041            $imageWt,
1042            [
1043                'parseOpts' => [
1044                    'extTag' => $extTagName,
1045                    'extTagOpts' => $extTagOpts,
1046                    'context' => 'inline',
1047                ],
1048                // Create new frame, because $pieces doesn't literally appear
1049                // on the page, it has been hand-crafted here
1050                'processInNewFrame' => true,
1051                // Shift the DSRs in the DOM by startOffset, and strip DSRs
1052                // for bits which aren't the caption or file, since they
1053                // don't refer to actual source wikitext
1054                'shiftDSRFn' => static function ( DomSourceRange $dsr ) use ( $shiftOffset ) {
1055                    $start = $dsr->start === null ? null : $shiftOffset( $dsr->start );
1056                    $end = $dsr->end === null ? null : $shiftOffset( $dsr->end );
1057                    // If either offset is newly-invalid, remove entire DSR
1058                    if (
1059                        ( $dsr->start !== null && $start === null ) ||
1060                        ( $dsr->end !== null && $end === null )
1061                    ) {
1062                        return null;
1063                    }
1064                    return new DomSourceRange(
1065                        $start, $end, $dsr->openWidth, $dsr->closeWidth
1066                    );
1067                },
1068            ],
1069            true  // sol
1070        );
1071
1072        $thumb = $domFragment->firstChild;
1073        $validWrappers = [ 'figure' ];
1074        // Downstream code expects a figcaption if we're forcing a block so we
1075        // validate that we did indeed parse a figure.  It might not have
1076        // happened because $imageOpts has an unbalanced `]]` which closes
1077        // the wikilink syntax before we get in our `|none`.
1078        if ( !$forceBlock ) {
1079            $validWrappers[] = 'span';
1080        }
1081        if ( !in_array( DOMCompat::nodeName( $thumb ), $validWrappers, true ) ) {
1082            $error = "{$extTagName}_invalid_image";
1083            return null;
1084        }
1085        DOMUtils::assertElt( $thumb );
1086
1087        // Detach the $thumb since the $domFragment is going out of scope
1088        // See https://bugs.php.net/bug.php?id=39593
1089        DOMCompat::remove( $thumb );
1090
1091        if ( $forceBlock ) {
1092            $dp = DOMDataUtils::getDataParsoid( $thumb );
1093            array_pop( $dp->optList );
1094            $explicitNone = false;
1095            foreach ( $dp->optList as $opt ) {
1096                if ( $opt['ck'] === 'none' ) {
1097                    $explicitNone = true;
1098                }
1099            }
1100            if ( !$explicitNone ) {
1101                // FIXME: Should we worry about someone adding this with the
1102                // "class=" option?
1103                DOMCompat::getClassList( $thumb )->remove( 'mw-halign-none' );
1104            }
1105        }
1106
1107        return $thumb;
1108    }
1109
1110    /**
1111     * Serialize a MediaStructure to a title and media options string.
1112     * The converse to ::renderMedia.
1113     *
1114     * @param MediaStructure $ms
1115     * @return array Where,
1116     *   [0] is the media title string
1117     *   [1] is the string of media options
1118     */
1119    public function serializeMedia( MediaStructure $ms ): array {
1120        $ct = LinkHandlerUtils::figureToConstrainedText( $this->serializerState, $ms );
1121        if ( $ct instanceof WikiLinkText ) {
1122            // Remove the opening and closing square brackets
1123            $text = substr( $ct->text, 2, -2 );
1124            return array_pad( explode( '|', $text, 2 ), 2, '' );
1125        } else {
1126            // Note that $ct could be an AutoURLLinkText, not just null
1127            return [ '', '' ];
1128        }
1129    }
1130
1131    /**
1132     * @param array $modules
1133     * @deprecated Use ::getMetadata()->appendOutputStrings( MODULE, ...) instead.
1134     */
1135    public function addModules( array $modules ) {
1136        $this->getMetadata()->appendOutputStrings( CMCSS::MODULE, $modules );
1137    }
1138
1139    /**
1140     * @param array $modulestyles
1141     * @deprecated Use ::getMetadata()->appendOutputStrings(MODULE_STYLE, ...) instead.
1142     */
1143    public function addModuleStyles( array $modulestyles ) {
1144        $this->getMetadata()->appendOutputStrings( CMCSS::MODULE_STYLE, $modulestyles );
1145    }
1146
1147    /**
1148     * Get an array of attributes to apply to an anchor linking to $url
1149     */
1150    public function getExternalLinkAttribs( string $url ): array {
1151        return $this->env->getExternalLinkAttribs( $url );
1152    }
1153
1154    /**
1155     * Add a tracking category to the current page.
1156     * @param string $key Message key (not localized)
1157     */
1158    public function addTrackingCategory( string $key ): void {
1159        $this->env->getDataAccess()->addTrackingCategory(
1160            $this->env->getPageConfig(),
1161            $this->env->getMetadata(),
1162            $key
1163        );
1164    }
1165}