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