Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.33% covered (danger)
4.33%
34 / 785
0.00% covered (danger)
0.00%
0 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiLinkHandler
4.33% covered (danger)
4.33%
34 / 785
0.00% covered (danger)
0.00%
0 / 25
48590.58
0.00% covered (danger)
0.00%
0 / 1
 hrefParts
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getWikiLinkTargetInfo
55.74% covered (warning)
55.74%
34 / 61
0.00% covered (danger)
0.00%
0 / 1
38.20
 onRedirect
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 bailTokens
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
6
 onWikiLink
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
 wikiLinkHandler
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
156
 buildLinkAttrs
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
156
 addLinkAttributesAndGetContent
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
930
 renderWikiLink
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 renderCategory
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
 renderLanguageLink
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 renderInterwikiLink
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 getWrapperInfo
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 getOptionInfo
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 isWikitextOpt
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 stringifyOptionTokens
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
702
 getFormat
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getUsed
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 hasTransclusion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 renderFile
0.00% covered (danger)
0.00%
0 / 241
0.00% covered (danger)
0.00%
0 / 1
3906
 specialFilePath
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 linkToMedia
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 renderMedia
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 onTag
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4/**
5 * Simple link handler.
6 *
7 * TODO: keep round-trip information in meta tag or the like
8 */
9
10namespace Wikimedia\Parsoid\Wt2Html\TT;
11
12use stdClass;
13use Wikimedia\Assert\Assert;
14use Wikimedia\Parsoid\Config\Env;
15use Wikimedia\Parsoid\Core\DOMCompat;
16use Wikimedia\Parsoid\Core\DomSourceRange;
17use Wikimedia\Parsoid\Core\InternalException;
18use Wikimedia\Parsoid\Core\Sanitizer;
19use Wikimedia\Parsoid\Core\SourceRange;
20use Wikimedia\Parsoid\Language\Language;
21use Wikimedia\Parsoid\NodeData\DataMw;
22use Wikimedia\Parsoid\NodeData\DataMwAttrib;
23use Wikimedia\Parsoid\NodeData\DataMwError;
24use Wikimedia\Parsoid\NodeData\DataParsoid;
25use Wikimedia\Parsoid\NodeData\TempData;
26use Wikimedia\Parsoid\Tokens\EndTagTk;
27use Wikimedia\Parsoid\Tokens\EOFTk;
28use Wikimedia\Parsoid\Tokens\KV;
29use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
30use Wikimedia\Parsoid\Tokens\TagTk;
31use Wikimedia\Parsoid\Tokens\Token;
32use Wikimedia\Parsoid\Tokens\XMLTagTk;
33use Wikimedia\Parsoid\Utils\DOMUtils;
34use Wikimedia\Parsoid\Utils\PHPUtils;
35use Wikimedia\Parsoid\Utils\PipelineUtils;
36use Wikimedia\Parsoid\Utils\Title;
37use Wikimedia\Parsoid\Utils\TitleException;
38use Wikimedia\Parsoid\Utils\TokenUtils;
39use Wikimedia\Parsoid\Utils\Utils;
40use Wikimedia\Parsoid\Wikitext\Consts;
41use Wikimedia\Parsoid\Wt2Html\PipelineContentCache;
42use Wikimedia\Parsoid\Wt2Html\TokenHandlerPipeline;
43
44class WikiLinkHandler extends XMLTagBasedHandler {
45    private static bool $cachingEnabled = true;
46
47    /**
48     * @return ?array{prefix: string, title: string}
49     */
50    private static function hrefParts( string $str ): ?array {
51        if ( preg_match( '/^([^:]+):(.*)$/D', $str, $matches ) ) {
52            return [ 'prefix' => $matches[1], 'title' => $matches[2] ];
53        } else {
54            return null;
55        }
56    }
57
58    private ?PipelineContentCache $wikilinkCache = null;
59
60    /** @inheritDoc */
61    public function __construct( TokenHandlerPipeline $manager, array $options ) {
62        parent::__construct( $manager, $options );
63
64        // Cache only on seeing the same source the fourth time.
65        // This minimizes cache bloat & token cloning penalties
66        // and reserving benefits for links seen at least 5 times.
67        if ( self::$cachingEnabled ) {
68            $this->wikilinkCache = $this->manager->getEnv()->getCache(
69                "wikilink",
70                [ "repeatThreshold" => 3, "cloneValue" => true ]
71            );
72        }
73    }
74
75    /**
76     * Normalize and analyze a wikilink target.
77     *
78     * Returns an object containing
79     * - href: The expanded target string
80     * - hrefSrc: The original target wikitext
81     * - title: A title object *or*
82     * - language: An interwikiInfo object *or*
83     * - interwiki: An interwikiInfo object.
84     * - localprefix: Set if the link had a localinterwiki prefix (or prefixes)
85     * - fromColonEscapedText: Target was colon-escaped ([[:en:foo]])
86     * - prefix: The original namespace or language/interwiki prefix without a
87     *   colon escape.
88     *
89     * @param Token $token
90     * @param string $href
91     * @param string $hrefSrc
92     * @return stdClass The target info.
93     * @throws InternalException
94     */
95    private function getWikiLinkTargetInfo( Token $token, string $href, string $hrefSrc ): stdClass {
96        $env = $this->env;
97        $siteConfig = $env->getSiteConfig();
98        $info = (object)[
99            'href' => $href,
100            'hrefSrc' => $hrefSrc,
101            // Initialize these properties to avoid isset checks
102            'interwiki' => null,
103            'language' => null,
104            'localprefix' => null,
105            'fromColonEscapedText' => null
106        ];
107
108        if ( ( ltrim( $info->href )[0] ?? '' ) === ':' ) {
109            $info->fromColonEscapedText = true;
110            // Remove the colon escape
111            $info->href = substr( ltrim( $info->href ), 1 );
112        }
113        if ( ( $info->href[0] ?? '' ) === ':' ) {
114            if ( $env->linting( 'multi-colon-escape' ) ) {
115                $lint = [
116                    'dsr' => DomSourceRange::fromTsr( $token->dataParsoid->tsr ),
117                    'params' => [ 'href' => ':' . $info->href ],
118                    'templateInfo' => null
119                ];
120                if ( $this->options['inTemplate'] ) {
121                    // Match Linter.findEnclosingTemplateName(), by first
122                    // converting the title to an href using env.makeLink
123                    $name = PHPUtils::stripPrefix(
124                        $env->makeLink( $this->manager->getFrame()->getTitle() ),
125                        './'
126                    );
127                    $lint['templateInfo'] = [ 'name' => $name ];
128                    // TODO(arlolra): Pass tsr info to the frame (T405759)
129                    $lint['dsr'] = new DomSourceRange( 0, 0, null, null, source: null );
130                }
131                $env->recordLint( 'multi-colon-escape', $lint );
132            }
133            // This will get caught by the caller, and mark the target as invalid
134            throw new InternalException( 'Multiple colons prefixing href.' );
135        }
136
137        $title = $env->resolveTitle( Utils::decodeURIComponent( $info->href ) );
138        $hrefBits = self::hrefParts( $info->href );
139        if ( $hrefBits ) {
140            $nsPrefix = $hrefBits['prefix'];
141            $info->prefix = $nsPrefix;
142            $nnn = Utils::normalizeNamespaceName( trim( $nsPrefix ) );
143            $interwikiInfo = $siteConfig->interwikiMapNoNamespaces()[$nnn] ?? null;
144            // check for interwiki / language links
145            $ns = $siteConfig->namespaceId( $nnn );
146            // also check for url to protect against [[constructor:foo]]
147            if ( $ns !== null ) {
148                $info->title = $env->makeTitleFromURLDecodedStr( $title );
149            } elseif ( isset( $interwikiInfo['localinterwiki'] ) ) {
150                if ( $hrefBits['title'] === '' ) {
151                    // Empty title => main page (T66167)
152                    $info->title = Title::newFromLinkTarget(
153                        $siteConfig->mainPageLinkTarget(), $siteConfig
154                    );
155                } else {
156                    $info->href = str_contains( $hrefBits['title'], ':' )
157                        ? ':' . $hrefBits['title'] : $hrefBits['title'];
158                    // Recurse!
159                    $info = $this->getWikiLinkTargetInfo( $token, $info->href, $info->hrefSrc );
160                    $info->localprefix = $nsPrefix .
161                        ( $info->localprefix ? ( ':' . $info->localprefix ) : '' );
162                }
163            } elseif ( !empty( $interwikiInfo['url'] ) ) {
164                $info->href = $hrefBits['title'];
165                // Ensure a valid title and store it for later use.
166                // (don't store as $info->title because that signals a wikilink)
167                $interwikiInfo['title'] = $env->makeTitleFromURLDecodedStr( $title );
168                // Interwiki or language link? If no language info, or if it starts
169                // with an explicit ':' (like [[:en:Foo]]), it's not a language link.
170                if ( $info->fromColonEscapedText ||
171                    ( !isset( $interwikiInfo['language'] ) && !isset( $interwikiInfo['extralanglink'] ) )
172                ) {
173                    // An interwiki link.
174                    $info->interwiki = $interwikiInfo;
175                    // Remove the colon escape after an interwiki prefix
176                    if ( ( ltrim( $info->href )[0] ?? '' ) === ':' ) {
177                        $info->href = substr( ltrim( $info->href ), 1 );
178                    }
179                } else {
180                    // A language link.
181                    $info->language = $interwikiInfo;
182                }
183            } else {
184                $info->title = $env->makeTitleFromURLDecodedStr( $title );
185            }
186        } else {
187            $info->title = $env->makeTitleFromURLDecodedStr( $title );
188        }
189
190        return $info;
191    }
192
193    /**
194     * Handle mw:redirect tokens
195     *
196     * @param Token $token
197     * @return array<string|Token>
198     * @throws InternalException
199     */
200    private function onRedirect( Token $token ): array {
201        // Avoid duplicating the link-processing code by invoking the
202        // standard onWikiLink handler on the embedded link, intercepting
203        // the generated tokens using the callback mechanism, reading
204        // the href from the result, and then creating a
205        // <link rel="mw:PageProp/redirect"> token from it.
206
207        $rlink = new SelfclosingTagTk( 'link',
208            Utils::cloneArray( $token->attribs ?? [] ),
209            clone $token->dataParsoid,
210            $token->dataMw ? clone $token->dataMw : null );
211        $wikiLinkTk = $rlink->dataParsoid->linkTk;
212        $rlink->setAttribute( 'rel', 'mw:PageProp/redirect' );
213
214        // Remove the nested wikiLinkTk token and the cloned href attribute
215        unset( $rlink->dataParsoid->linkTk );
216        $rlink->removeAttribute( 'href' );
217
218        // Transfer href attribute back to wikiLinkTk, since it may have been
219        // template-expanded in the pipeline prior to this point.
220        $wikiLinkTk->attribs = Utils::cloneArray( $token->attribs ?? [] );
221
222        // Set "redirect" attribute on the wikilink token to indicate that
223        // image and category links should be handled as plain links.
224        $wikiLinkTk->setAttribute( 'redirect', 'true' );
225
226        // Render the wikilink (including interwiki links, etc) then collect
227        // the resulting href and transfer it to rlink.
228        $r = $this->onWikiLink( $wikiLinkTk );
229        $firstToken = ( $r[0] ?? null );
230        $isValid = $firstToken instanceof XMLTagTk &&
231            in_array( $firstToken->getName(), [ 'a', 'link' ], true );
232        if ( $isValid ) {
233            $da = $r[0]->dataParsoid;
234            $rlink->addNormalizedAttribute( 'href', $da->a['href'], $da->sa['href'] );
235            return [ $rlink ];
236        } else {
237            // Bail!  Emit tokens as if they were parsed as a list item:
238            // #REDIRECT....
239            $src = $rlink->dataParsoid->src;
240            $tsr = $rlink->dataParsoid->tsr;
241            preg_match( '/^([^#]*)(#)/', $src, $srcMatch );
242            $ntokens = strlen( $srcMatch[1] ) ? [ $srcMatch[1] ] : [];
243            $hashPos = $tsr->start + strlen( $srcMatch[1] );
244            $tsr0 = new SourceRange( $hashPos, $hashPos + 1, $tsr->source );
245            $dp = new DataParsoid;
246            $dp->tsr = $tsr0;
247            $li = new TagTk(
248                'listItem',
249                [ new KV( 'bullets', [ '#' ], $tsr0->expandTsrV() ) ],
250                $dp );
251            $ntokens[] = $li;
252            $ntokens[] = substr( $src, strlen( $srcMatch[0] ) );
253            PHPUtils::pushArray( $ntokens, $r );
254            return $ntokens;
255        }
256    }
257
258    /**
259     * @return array<string|Token>
260     */
261    public static function bailTokens( TokenHandlerPipeline $manager, Token $token ): array {
262        $frame = $manager->getFrame();
263        $tsr = $token->dataParsoid->tsr;
264        $frameSrc = $frame->getSource();
265        $linkSrc = $tsr->substr( $frameSrc );
266        $src = substr( $linkSrc, 1 );
267        if ( $src === '' ) {
268            $manager->getEnv()->log(
269                'error', 'Unable to determine link source.',
270                "frame: ", $frameSrc->getSrcText(),
271                'tsr: ', $tsr,
272                "link: $linkSrc"
273            );
274            return [ $linkSrc ];  // Forget about trying to tokenize this
275        }
276        $startOffset = $tsr->start + 1;
277        $toks = PipelineUtils::processContentInPipeline(
278            $manager->getEnv(), $frame, $src, [
279                // FIXME: Set toplevel when bailing
280                // 'toplevel' => $atTopLevel ?? false,
281                'sol' => false,
282                'pipelineType' => 'wikitext-to-expanded-tokens',
283                'srcOffsets' => new SourceRange( $startOffset, $startOffset + strlen( $src ), $tsr->source ),
284                'pipelineOpts' => [
285                    'expandTemplates' => $manager->getOptions()['expandTemplates'],
286                    'inTemplate' => $manager->getOptions()['inTemplate'],
287                ],
288            ]
289        );
290        TokenUtils::stripEOFTkFromTokens( $toks );
291        return array_merge( [ '[' ], $toks );
292    }
293
294    /**
295     * Handle a mw:WikiLink token.
296     *
297     * @param Token $token
298     * @return array<string|Token>
299     * @throws InternalException
300     */
301    private function onWikiLink( Token $token ): array {
302        $env = $this->env;
303        $tsr = $token->dataParsoid->tsr ?? null;
304        $tsrSource = $tsr->source ?? null;
305        $tsrStart = $tsr->start ?? null;
306
307        // Check if we have cached output for this wikilink source.
308        // Given wikilink-syntax source, token output is deterministic
309        // and so we can benefit from caching.
310        $src = $token->dataParsoid->src ?? '';
311        $isCacheable = ( $this->wikilinkCache && $tsrStart !== null && strlen( $src ) > 0 );
312        if ( $isCacheable ) {
313            $cachedOutput = $this->wikilinkCache->lookup( $src );
314            if ( $cachedOutput !== null ) {
315                $toks = $cachedOutput['value']['tokens'];
316                TokenUtils::shiftTokenTSR(
317                    $toks,
318                    $tsrStart - $cachedOutput['value']['start'],
319                    $tsrSource === $cachedOutput['source'] ? null : $tsrSource
320                );
321                TokenUtils::dedupeAboutIds( $env, $toks );
322                return $toks;
323            }
324        }
325
326        $hrefKV = $token->getAttributeKV( 'href' );
327        $hrefTokenStr = TokenUtils::tokensToString( $hrefKV->v );
328
329        // Don't allow internal links to pages containing PROTO:
330        // See Parser::handleInternalLinks2()
331        if ( $env->getSiteConfig()->hasValidProtocol( $hrefTokenStr ) ) {
332            return self::bailTokens( $this->manager, $token );
333        }
334
335        // Xmlish tags in title position are invalid.  Not according to the
336        // preprocessor ABNF but at later stages in the legacy parser,
337        // namely handleInternalLinks.
338        if ( is_array( $hrefKV->v ) ) {
339            // Use the expanded attr instead of trying to unpackDOMFragments
340            // since the fragment will have been released when expanding to DOM
341            $expandedDom = $token->fetchExpandedAttrValue( 'href' )
342                ?? $env->getTopLevelDoc()->createDocumentFragment();
343            foreach ( DOMCompat::querySelectorAll( $expandedDom, '[typeof]' ) as $el ) {
344                if ( DOMUtils::matchTypeOf( $el, '#^mw:(Nowiki|Extension|DOMFragment/sealed)#' ) !== null ) {
345                    return self::bailTokens( $this->manager, $token );
346                }
347            }
348        }
349
350        // First check if the expanded href contains a pipe.
351        if ( str_contains( $hrefTokenStr, '|' ) ) {
352            // It does. This 'href' was templated and also returned other
353            // parameters separated by a pipe. We don't have any sensible way to
354            // handle such a construct currently, so prevent people from editing
355            // it.  See T226523
356            // TODO: add useful debugging info for editors ('if you would like to
357            // make this content editable, then fix template X..')
358            // TODO: also check other parameters for pipes!
359            // NOTE: We'd need to clear firstPipeSrc if this case gets supported
360            return self::bailTokens( $this->manager, $token );
361        }
362
363        try {
364            $target = $this->getWikiLinkTargetInfo( $token, $hrefTokenStr, $hrefKV->vsrc );
365        } catch ( TitleException | InternalException ) {
366            // Invalid title
367            return self::bailTokens( $this->manager, $token );
368        }
369
370        // Ok, it looks like we have a sensible href. Figure out which handler to use.
371        $isRedirect = (bool)$token->getAttributeV( 'redirect' );
372        $toks = $this->wikiLinkHandler( $token, $target, $isRedirect );
373        if ( $isCacheable ) {
374            $this->wikilinkCache->cache( $src, [ 'start' => $tsrStart, 'tokens' => $toks ], $tsrSource );
375        }
376
377        return $toks;
378    }
379
380    /**
381     * Figure out which handler to use to render a given WikiLink token. Override
382     * this method to add new handlers or swap out existing handlers based on the
383     * target structure.
384     *
385     * @param Token $token
386     * @param stdClass $target
387     * @param bool $isRedirect
388     * @return array<string|Token>
389     * @throws InternalException
390     */
391    private function wikiLinkHandler(
392        Token $token, stdClass $target, bool $isRedirect
393    ): array {
394        $title = $target->title ?? null;
395        if ( $title ) {
396            if ( $isRedirect ) {
397                return $this->renderWikiLink( $token, $target );
398            }
399            $siteConfig = $this->env->getSiteConfig();
400            $nsId = $title->getNamespace();
401            if ( $nsId === $siteConfig->canonicalNamespaceId( 'media' ) ) {
402                // Render as a media link.
403                return $this->renderMedia( $token, $target );
404            }
405            if (
406                !$target->fromColonEscapedText &&
407                // Protect from purely fragment links on pages in these namespaces
408                ( $target->href[0] ?? '' ) !== '#'
409            ) {
410                if ( $nsId === $siteConfig->canonicalNamespaceId( 'file' ) ) {
411                    // Render as a file.
412                    return $this->renderFile( $token, $target );
413                }
414                if ( $nsId === $siteConfig->canonicalNamespaceId( 'category' ) ) {
415                    // Render as a category membership.
416                    return $this->renderCategory( $token, $target );
417                }
418            }
419
420            // Render as plain wiki links.
421            return $this->renderWikiLink( $token, $target );
422        }
423
424        // language and interwiki links
425        if ( $target->interwiki ) {
426            return $this->renderInterwikiLink( $token, $target );
427        }
428        if ( $target->language ) {
429            $ns = $this->env->getContextTitle()->getNamespace();
430            $noLanguageLinks = $this->env->getSiteConfig()->namespaceIsTalk( $ns ) ||
431                !$this->env->getSiteConfig()->interwikiMagic();
432            if ( $noLanguageLinks ) {
433                $target->interwiki = $target->language;
434                return $this->renderInterwikiLink( $token, $target );
435            }
436
437            return $this->renderLanguageLink( $token, $target );
438        }
439
440        // Neither a title, nor a language or interwiki. Should not happen.
441        throw new InternalException( 'Unknown link type' );
442    }
443
444    /** ------------------------------------------------------------
445     * This (overloaded) function does three different things:
446     * - Extracts link text from attrs (when k === "mw:maybeContent").
447     *   As a performance micro-opt, only does if asked to (getLinkText)
448     * - Updates existing rdfa type with an additional rdf-type,
449     *   if one is provided (rdfaType)
450     * - Collates about, typeof, and linkAttrs into a new attr. array
451     *
452     * @param list<KV> $attrs
453     * @param bool $getLinkText
454     * @param ?string $rdfaType
455     * @param ?list<KV> $linkAttrs
456     *
457     * @return array{attribs: list<KV>, contentKVs: list<KV>, hasRdfaType: bool}
458     */
459    public static function buildLinkAttrs(
460        array $attrs, bool $getLinkText, ?string $rdfaType,
461        ?array $linkAttrs
462    ): array {
463        $newAttrs = [];
464        $linkTextKVs = [];
465        $about = null;
466
467        // In one pass through the attribute array, fetch about, typeof, and linkText
468        //
469        // about && typeof are usually at the end of the array if at all present
470        foreach ( $attrs as $kv ) {
471            $k = $kv->k;
472            $v = $kv->v;
473
474            // link-text attrs have the key "maybeContent"
475            if ( $getLinkText && $k === 'mw:maybeContent' ) {
476                $linkTextKVs[] = $kv;
477            } elseif ( is_string( $k ) && $k ) {
478                if ( trim( $k ) === 'typeof' ) {
479                    $rdfaType = $rdfaType ? $rdfaType . ' ' . $v : $v;
480                } elseif ( trim( $k ) === 'about' ) {
481                    $about = $v;
482                }
483            }
484        }
485
486        if ( $rdfaType ) {
487            $newAttrs[] = new KV( 'typeof', $rdfaType );
488        }
489
490        if ( $about ) {
491            $newAttrs[] = new KV( 'about', $about );
492        }
493
494        if ( $linkAttrs ) {
495            PHPUtils::pushArray( $newAttrs, $linkAttrs );
496        }
497
498        return [
499            'attribs' => $newAttrs,
500            'contentKVs' => $linkTextKVs,
501            'hasRdfaType' => $rdfaType !== null
502        ];
503    }
504
505    /**
506     * Generic wiki link attribute setup on a passed-in new token based on the
507     * wikilink token and target. As a side effect, this method also extracts the
508     * link content tokens and returns them.
509     *
510     * @param Token $newTk
511     * @param Token $token
512     * @param stdClass $target
513     * @param bool $buildDOMFragment
514     * @return array<string|Token>
515     * @throws InternalException
516     */
517    private function addLinkAttributesAndGetContent(
518        Token $newTk, Token $token, stdClass $target, bool $buildDOMFragment = false
519    ): array {
520        $attribs = $token->attribs;
521        $dataParsoid = $token->dataParsoid;
522        $dataMw = $token->dataMw;
523        $newAttrData = self::buildLinkAttrs( $attribs, true, null, [ new KV( 'rel', 'mw:WikiLink' ) ] );
524        $content = $newAttrData['contentKVs'];
525        $env = $this->env;
526
527        // Set attribs and dataParsoid
528        $newTk->attribs = $newAttrData['attribs'];
529        $newTk->dataParsoid = clone $dataParsoid;
530        $newTk->dataMw = $dataMw !== null ? clone $dataMw : null;
531        unset( $newTk->dataParsoid->src ); // clear src string since we can serialize this
532
533        // Note: Link tails are handled on the DOM in handleLinkNeighbours, so no
534        // need to handle them here.
535        $l = count( $content );
536        if ( $l > 0 ) {
537            $newTk->dataParsoid->stx = 'piped';
538            $out = [];
539            // re-join content bits
540            foreach ( $content as $i => $kv ) {
541                $toks = $kv->v;
542                // since this is already a link, strip autolinks from content
543                // FIXME: Maybe add a stop in the grammar so that autolinks
544                // aren't tokenized in link content to begin with?
545                if ( !is_array( $toks ) ) {
546                    $toks = [ $toks ];
547                }
548
549                $toks = array_values( array_filter( $toks, static function ( $t ) {
550                    return $t !== '';
551                } ) );
552                $n = count( $toks );
553                foreach ( $toks as $j => $t ) {
554                    // Bail on media-syntax in wikilink-syntax scenarios,
555                    // since the legacy parser explodes on [[, last one wins.
556                    // Note that without this, anchors tags in media output
557                    // will be stripped and we won't have the right structure
558                    // when we get to the dom pass to add media info.
559                    if (
560                        $t instanceof TagTk &&
561                        ( $t->getName() === 'figure' || $t->getName() === 'span' ) &&
562                        TokenUtils::matchTypeOf( $t, '#^mw:File($|/)#D' ) !== null
563                    ) {
564                        throw new InternalException( 'Media-in-link' );
565                    }
566
567                    if ( $t instanceof TagTk && $t->getName() === 'a' ) {
568                        // Bail on wikilink-syntax in wiklink-syntax scenarios,
569                        // since the legacy parser explodes on [[, last one wins
570                        if (
571                            preg_match(
572                                '#^mw:WikiLink(/Interwiki)?$#D',
573                                $t->getAttributeV( 'rel' ) ?? ''
574                            ) &&
575                            // ISBN links don't use wikilink-syntax but still
576                            // get the same "rel", so should be ignored
577                            ( $t->dataParsoid->stx ?? '' ) !== 'magiclink'
578                        ) {
579                            throw new InternalException( 'Link-in-link' );
580                        }
581                        if ( $j + 1 < $n && $toks[$j + 1] instanceof EndTagTk &&
582                            $toks[$j + 1]->getName() === 'a'
583                        ) {
584                            // autonumbered links in the stream get rendered
585                            // as an <a> tag with no content -- but these ought
586                            // to be treated as plaintext since we don't allow
587                            // nested links.
588                            $out[] = '[' . $t->getAttributeV( 'href' ) . ']';
589                        }
590                        // suppress <a>
591                        continue;
592                    }
593
594                    // Categories also use wikilink syntax so we bail to match
595                    // legacy output.  However, this isn't an a-in-a scenario
596                    // so maybe should be permitted in the future.
597                    if (
598                        $t instanceof SelfclosingTagTk && $t->getName() === 'link' &&
599                        preg_match(
600                            '#^mw:PageProp/Category$#D',
601                            $t->getAttributeV( 'rel' ) ?? ''
602                        )
603                    ) {
604                        throw new InternalException( 'Category-in-link' );
605                    }
606
607                    if ( $t instanceof EndTagTk && $t->getName() === 'a' ) {
608                        continue; // suppress </a>
609                    }
610
611                    $out[] = $t;
612                }
613                if ( $i < $l - 1 ) {
614                    $out[] = '|';
615                }
616            }
617
618            if ( $buildDOMFragment ) {
619                // content = [part 0, .. part l-1]
620                // offsets = [start(part-0), end(part l-1)]
621                $offsets = isset( $dataParsoid->tsr ) ?
622                    new SourceRange(
623                        $content[0]->srcOffsets->value->start,
624                        $content[$l - 1]->srcOffsets->value->end,
625                        $dataParsoid->tsr->source ) :
626                    null;
627                $content = [ PipelineUtils::getDOMFragmentToken( $out, $offsets,
628                    [ 'inlineContext' => true, 'token' => $token ] ) ];
629            } else {
630                $content = $out;
631            }
632        } else {
633            $newTk->dataParsoid->stx = 'simple';
634            $morecontent = Utils::decodeURIComponent( $target->href );
635
636            // Try to match labeling in core
637            if ( $env->getSiteConfig()->namespaceHasSubpages(
638                $env->getContextTitle()->getNamespace()
639            ) ) {
640                // subpage links with a trailing slash get the trailing slashes stripped.
641                // See https://gerrit.wikimedia.org/r/173431
642                if ( preg_match( '#^((\.\./)+|/)(?!\.\./)(.*?[^/])/+$#D', $morecontent, $match ) ) {
643                    $morecontent = $match[3];
644                } elseif ( str_starts_with( $morecontent, '../' ) ) {
645                    // Subpages on interwiki / language links aren't valid,
646                    // so $target->title should always be present here
647                    $morecontent = $target->title->getPrefixedText();
648                }
649            }
650
651            // for interwiki links, include the interwiki prefix in the link text
652            if ( $target->interwiki ) {
653                $morecontent = $target->prefix . ':' . $morecontent;
654            }
655
656            // for local links, include the local prefix in the link text
657            if ( $target->localprefix ) {
658                $morecontent = $target->localprefix . ':' . $morecontent;
659            }
660
661            $content = [ $morecontent ];
662        }
663        return $content;
664    }
665
666    /**
667     * Render a plain wiki link.
668     *
669     * @param Token $token
670     * @param stdClass $target
671     * @return array<string|Token>
672     */
673    private function renderWikiLink( Token $token, stdClass $target ): array {
674        $newTk = new TagTk( 'a' );
675        try {
676            $content = $this->addLinkAttributesAndGetContent( $newTk, $token, $target, true );
677        } catch ( InternalException ) {
678            return self::bailTokens( $this->manager, $token );
679        }
680
681        $newTk->addNormalizedAttribute( 'href', $this->env->makeLink( $target->title ),
682            $target->hrefSrc );
683
684        $newTk->setAttribute( 'title', $target->title->getPrefixedText() );
685
686        return array_merge( [ $newTk ], $content, [ new EndTagTk( 'a' ) ] );
687    }
688
689    /**
690     * Render a category 'link'. Categories are really page properties, and are
691     * normally rendered in a box at the bottom of an article.
692     *
693     * @param Token $token
694     * @param stdClass $target
695     * @return array<string|Token>
696     */
697    private function renderCategory( Token $token, stdClass $target ): array {
698        $newTk = new SelfclosingTagTk( 'link' );
699        try {
700            $content = $this->addLinkAttributesAndGetContent( $newTk, $token, $target );
701        } catch ( InternalException ) {
702            return self::bailTokens( $this->manager, $token );
703        }
704        $env = $this->env;
705
706        // Change the rel to be mw:PageProp/Category
707        $newTk->getAttributeKV( 'rel' )->v = 'mw:PageProp/Category';
708
709        $newTk->addNormalizedAttribute( 'href', $env->makeLink( $target->title ), $target->hrefSrc );
710
711        // Change the href to include the sort key, if any (but don't update the rt info)
712        // Fallback to empty string for default sorting
713        $categorySort = '';
714        $strContent = str_replace( "\n", '', TokenUtils::tokensToString( $content ) );
715        if ( $strContent !== '' && $strContent !== $target->href ) {
716            $categorySort = $strContent;
717            $hrefkv = $newTk->getAttributeKV( 'href' );
718            $hrefkv->v .= '#';
719            $hrefkv->v .= str_replace( '#', '%23', Sanitizer::sanitizeTitleURI( $categorySort, false ) );
720        }
721
722        if ( count( $content ) !== 1 ) {
723            // Deal with sort keys that come from generated content (transclusions, etc.)
724            $key = [ 'txt' => 'mw:sortKey' ];
725            $contentKV = $token->getAttributeKV( 'mw:maybeContent' );
726            $so = $contentKV->valueOffset();
727            $val = PipelineUtils::expandAttrValueToDOM(
728                $this->env,
729                $this->manager->getFrame(),
730                [ 'html' => $content, 'srcOffsets' => $so ],
731                $this->options['expandTemplates'],
732                $this->options['inTemplate']
733            );
734            $attr = new DataMwAttrib( $key, $val );
735            $dataMw = $newTk->dataMw;
736            if ( $dataMw ) {
737                $dataMw->attribs[] = $attr;
738            } else {
739                $dataMw = new DataMw( [ 'attribs' => [ $attr ] ] );
740            }
741
742            // Mark token as having expanded attrs
743            $newTk->addAttribute( 'about', $env->newAboutId() );
744            $newTk->addSpaceSeparatedAttribute( 'typeof', 'mw:ExpandedAttrs' );
745            $newTk->dataMw = $dataMw;
746        }
747        $this->env->getMetadata()->addCategory( $target->title, $categorySort );
748        return [ $newTk ];
749    }
750
751    /**
752     * Render a language link. Those normally appear in the list of alternate
753     * languages for an article in the sidebar, so are really a page property.
754     *
755     * @param Token $token
756     * @param stdClass $target
757     * @return array<string|Token>
758     */
759    private function renderLanguageLink( Token $token, stdClass $target ): array {
760        // The prefix is listed in the interwiki map
761
762        // TODO: If $target->language['deprecated'] is set and
763        // $target->language['extralanglink'] is *not* set, then we
764        // should use the normalized language name/prefix (from
765        // 'deprecated') when calling
766        // ContentMetadataCollector::addLanguageLink() here (which
767        // we should eventualy be doing)
768
769        // TODO: might also want to add the language *code* here,
770        // which would be the language['bcp47'] property (added in
771        // change I82465261bc66f0b0cd30d361c299f08066494762) for an
772        // extralanglink, or the interwiki prefix otherwise; the
773        // latter is mediawiki-internal and maybe not BCP-47 compliant.
774        // This is for clients of the MediaWiki DOM spec HTML: the
775        // WMF domain prefix, the MediaWiki internal language code,
776        // and the actual *language* (ie bcp-47 code) can all differ
777        // from each other, due to various historical infelicities.
778        // Perhaps a `lang` attribute on the `link` would be appropriate.
779
780        $newTk = new SelfclosingTagTk( 'link', [], $token->dataParsoid );
781        try {
782            $this->addLinkAttributesAndGetContent( $newTk, $token, $target );
783        } catch ( InternalException ) {
784            return self::bailTokens( $this->manager, $token );
785        }
786
787        // add title attribute giving the presentation name of the
788        // "extra language link"
789        // T329303: the 'linktext' comes from the system message
790        // `interlanguage-link-$prefix` and should be set in integrated mode
791        // using the localization features; the integrated-mode SiteConfig
792        // currently never sets the `linktext` property in
793        // SiteConfig::interwikiMap().
794        // I52d50e2f75942a849908c6be7fc5169f00a5983a has some partial work
795        // on this.
796        if ( isset( $target->language['extralanglink'] ) &&
797            !empty( $target->language['linktext'] )
798        ) {
799            // XXX in standalone mode, this is user-interface-language text,
800            // not "content language" text.
801            $newTk->addNormalizedAttribute( 'title', $target->language['linktext'], null );
802        }
803
804        // We set an absolute link to the article in the other wiki/language
805        $title = Sanitizer::sanitizeTitleURI( Utils::decodeURIComponent( $target->href ), false );
806        $absHref = str_replace( '$1', $title, $target->language['url'] );
807        if ( isset( $target->language['protorel'] ) ) {
808            $absHref = preg_replace( '/^https?:/', '', $absHref, 1 );
809        }
810        $newTk->addNormalizedAttribute( 'href', $absHref, $target->hrefSrc );
811
812        // Change the rel to be mw:PageProp/Language
813        $newTk->getAttributeKV( 'rel' )->v = 'mw:PageProp/Language';
814
815        // Add language link(s) to metadata
816        $this->env->getMetadata()->addLanguageLink( $target->language['title'] );
817
818        return [ $newTk ];
819    }
820
821    /**
822     * Render an interwiki link.
823     *
824     * @param Token $token
825     * @param stdClass $target
826     * @return array<string|Token>
827     */
828    private function renderInterwikiLink( Token $token, stdClass $target ): array {
829        // The prefix is listed in the interwiki map
830
831        $tokens = [];
832        $newTk = new TagTk( 'a', [], $token->dataParsoid );
833        try {
834            $content = $this->addLinkAttributesAndGetContent( $newTk, $token, $target, true );
835        } catch ( InternalException ) {
836            return self::bailTokens( $this->manager, $token );
837        }
838
839        // We set an absolute link to the article in the other wiki/language
840        $isLocal = !empty( $target->interwiki['local'] );
841        $trimmedHref = trim( $target->href );
842        $title = Sanitizer::sanitizeTitleURI(
843            Utils::decodeURIComponent( $trimmedHref ),
844            !$isLocal
845        );
846        $absHref = str_replace( '$1', $title, $target->interwiki['url'] );
847        if ( isset( $target->interwiki['protorel'] ) ) {
848            $absHref = preg_replace( '/^https?:/', '', $absHref, 1 );
849        }
850        $newTk->addNormalizedAttribute( 'href', $absHref, $target->hrefSrc );
851
852        $newTk->getAttributeKV( 'rel' )->v = 'mw:WikiLink/Interwiki';
853
854        // Add title unless it's just a fragment (and trim off fragment)
855        // (The normalization here is similar to what Title#getPrefixedDBKey() does.)
856        if ( $target->href === '' || $target->href[0] !== '#' ) {
857            $titleAttr = $target->interwiki['prefix'] . ':' .
858                Utils::decodeURIComponent( str_replace( '_', ' ',
859                    preg_replace( '/#.*/s', '', $trimmedHref, 1 ) ) );
860            $newTk->setAttribute( 'title', $titleAttr );
861        }
862        $tokens[] = $newTk;
863
864        PHPUtils::pushArray( $tokens, $content );
865        $tokens[] = new EndTagTk( 'a' );
866        return $tokens;
867    }
868
869    private const HORIZONTAL_ALIGNS = [
870        // PHP parser wraps in <div class="floatnone">
871        'left',
872        // PHP parser wraps in <div class="center"><div class="floatnone">
873        'right',
874        // PHP parser wraps in <div class="floatleft">
875        'center',
876        // PHP parser wraps in <div class="floatright">
877        'none',
878    ];
879    private const VERTICAL_ALIGNS = [ 'baseline', 'sub', 'super', 'top', 'text_top', 'middle',
880        'bottom', 'text_bottom' ];
881
882    /**
883     * Get the style and class lists for an image's wrapper element.
884     *
885     * @param array $opts The option hash from renderFile.
886     *
887     * @return array{classes: list<string>, isInline: bool}
888     *  - isInline: Whether the image is inline after handling options
889     *  - classes: The list of classes for the wrapper.
890     */
891    private static function getWrapperInfo( array $opts ): array {
892        $format = self::getFormat( $opts );
893        $isInline = !in_array( $format, [ 'thumbnail', 'manualthumb', 'framed' ], true );
894        $classes = [];
895
896        if (
897            !isset( $opts['size']['src'] ) &&
898            // Framed and manualthumb images aren't scaled
899            !in_array( $format, [ 'manualthumb', 'framed' ], true )
900        ) {
901            $classes[] = 'mw-default-size';
902        }
903
904        // Border isn't applicable to 'thumbnail', 'manualthumb', or 'framed' formats
905        // Using $isInline as a shorthand for that here (see above),
906        // but this isn't about being *inline* per se
907        if ( $isInline && isset( $opts['border'] ) ) {
908            $classes[] = 'mw-image-border';
909        }
910
911        $halign = $opts['halign']['v'] ?? null;
912        if ( in_array( $halign, self::HORIZONTAL_ALIGNS, true ) ) {
913            $isInline = false;
914            $classes[] = "mw-halign-$halign";
915        }
916
917        if ( $isInline ) {
918            $valignOpt = $opts['valign']['v'] ?? null;
919            if ( in_array( $valignOpt, self::VERTICAL_ALIGNS, true ) ) {
920                $classes[] = str_replace( '_', '-', "mw-valign-$valignOpt" );
921            }
922        }
923
924        return [ 'classes' => $classes, 'isInline' => $isInline ];
925    }
926
927    /**
928     * Determine the name of an option.
929     *
930     * @param string $optStr
931     * @param Env $env
932     * @return ?array{ck: string, v: string, ak: string, s: bool}
933     * - ck: Canonical key for the image option.
934     * - v: Value of the option.
935     * - ak: Aliased key for the image option; includes `"$1"` for placeholder.
936     * - s: Whether it's a simple option or one with a value.
937     */
938    private static function getOptionInfo( string $optStr, Env $env ): ?array {
939        $oText = trim( $optStr );
940        $siteConfig = $env->getSiteConfig();
941        $getOption = $siteConfig->getMediaPrefixParameterizedAliasMatcher();
942        // oText contains the localized name of this option.  the
943        // canonical option names (from mediawiki upstream) are in
944        // English and contain an '(img|timedmedia)_' prefix.  We drop the
945        // prefix before stuffing them in data-parsoid in order to
946        // save space (that's shortCanonicalOption)
947        $canonicalOption = $siteConfig->getMagicWordForMediaOption( $oText ) ?? '';
948        $shortCanonicalOption = preg_replace( '/^(img|timedmedia)_/', '', $canonicalOption, 1 );
949        // 'imgOption' is the key we'd put in opts; it names the 'group'
950        // for the option, and doesn't have an img_ prefix.
951        $imgOption = Consts::$Media['SimpleOptions'][$canonicalOption] ?? null;
952        if ( $imgOption !== null ) {
953            return [
954                'ck' => $imgOption,
955                'v' => $shortCanonicalOption,
956                'ak' => $optStr,
957                's' => true
958            ];
959        }
960        // If there isn't a literal match for the option, look for a
961        // prefix match (ie, img_width => `$1px`)
962
963        // *Note* that the legacy parser doesn't have a "principled"
964        // precedence here (T372935), it just so happens that members
965        // of Consts::PrefixOptions like
966        // img_width/img_page/img_lang/timedmedia_* are added last (as
967        // handler parameters), and other prefixed options like
968        // img_link/img_alt/img_class *happen* to be last in the
969        // $internalParamMap.  But the possibility for conflicts
970        // between prefixed parameters and literal options still
971        // exists in the legacy parser.
972        $bits = $getOption( $oText );
973        $normalizedBit0 = $bits ? mb_strtolower( trim( $bits['k'] ) ) : null;
974        $key = $bits ? ( Consts::$Media['PrefixOptions'][$normalizedBit0] ?? null ) : null;
975
976        // bits.a *used to have* the localized name for the prefix option
977        // (see SiteConfig::getMediaPrefixParameterizedAliasMatcher, this was
978        // dropped in the port from JS.)
979        // with $1 as a placeholder for the value, which is in bits.v
980        // 'normalizedBit0' is the canonical English option name
981        // (from mediawiki upstream) with a prefix.
982        // 'key' is the parsoid 'group' for the option; it doesn't
983        // have a prefix (it's the key we'd put in opts)
984        if ( $bits && $key ) {
985            $shortCanonicalOption = preg_replace( '/^(img|timedmedia)_/', '', $normalizedBit0, 1 );
986            // map short canonical name to the localized version used
987
988            // Note that we deliberately do entity decoding
989            // *after* splitting so that HTML-encoded pipes don't
990            // separate options.  This matches PHP, whether or
991            // not it's a good idea.
992            return [
993                'ck' => $shortCanonicalOption,
994                'v' => Utils::decodeWtEntities( $bits['v'] ),
995                'ak' => $optStr,
996                's' => false
997            ];
998        }
999
1000        return null;
1001    }
1002
1003    private static function isWikitextOpt(
1004        Env $env, ?array &$optInfo, string $prefix, string $resultStr
1005    ): bool {
1006        // link and alt options are allowed to contain arbitrary
1007        // wikitext (even though only strings are supported in reality)
1008        // FIXME(SSS): Is this actually true of all options rather than
1009        // just link and alt?
1010        if ( $optInfo === null ) {
1011            $optInfo = self::getOptionInfo( $prefix . $resultStr, $env );
1012        }
1013        return $optInfo !== null && in_array( $optInfo['ck'], [ 'link', 'alt' ], true );
1014    }
1015
1016    /**
1017     * Make option token streams into a stringy thing that we can recognize.
1018     *
1019     * @param array $tstream
1020     * @param string $prefix Anything that came before this part of the recursive call stack.
1021     * @param Env $env
1022     * @return ?string
1023     */
1024    private static function stringifyOptionTokens( array $tstream, string $prefix, Env $env ): ?string {
1025        // Seems like this should be a more general "stripTags"-like function?
1026        $skipToEndOf = null;
1027        $optInfo = null;
1028        $resultStr = '';
1029
1030        for ( $i = 0;  $i < count( $tstream );  $i++ ) {
1031            $currentToken = $tstream[$i];
1032
1033            if ( $skipToEndOf ) {
1034                if ( $currentToken instanceof EndTagTk && $currentToken->getName() === $skipToEndOf ) {
1035                    $skipToEndOf = null;
1036                }
1037                continue;
1038            }
1039
1040            if ( is_string( $currentToken ) ) {
1041                $resultStr .= $currentToken;
1042            } elseif ( is_array( $currentToken ) ) {
1043                $nextResult = self::stringifyOptionTokens( $currentToken, $prefix . $resultStr, $env );
1044
1045                if ( $nextResult === null ) {
1046                    return null;
1047                }
1048
1049                $resultStr .= $nextResult;
1050            } elseif (
1051                ( $currentToken instanceof XMLTagTk ) &&
1052                !( $currentToken instanceof EndTagTk )
1053            ) {
1054                // This is actually a token
1055                if ( TokenUtils::hasDOMFragmentType( $currentToken ) ) {
1056                    if ( self::isWikitextOpt( $env, $optInfo, $prefix, $resultStr ) ) {
1057                        $str = TokenUtils::tokensToString( [ $currentToken ], false, [
1058                                // These tokens haven't been expanded to DOM yet
1059                                // so unpacking them here is justifiable
1060                                // FIXME: It's a little convoluted to figure out
1061                                // that this is actually the case in the
1062                                // AttributeExpander, but it seems like only
1063                                // target/href ever gets expanded to DOM and
1064                                // the rest of the wikilink_content/options
1065                                // become mw:maybeContent that gets expanded
1066                                // below where $hasExpandableOpt is set.
1067                                'unpackDOMFragments' => true,
1068                            ]
1069                        );
1070                        // Entity encode pipes since we wouldn't have split on
1071                        // them from fragments and we're about to attempt to
1072                        // when this function returns.
1073                        $resultStr .= str_replace( '|', '&vert;', $str );
1074                        $optInfo = null; // might change the nature of opt
1075                        continue;
1076                    } else {
1077                        // if this is a nowiki, we must be in a caption
1078                        return null;
1079                    }
1080                }
1081                if ( $currentToken->getName() === 'mw-quote' ) {
1082                    if ( self::isWikitextOpt( $env, $optInfo, $prefix, $resultStr ) ) {
1083                        // just recurse inside
1084                        $optInfo = null; // might change the nature of opt
1085                        continue;
1086                    }
1087                    return null;
1088                }
1089                // Similar to TokenUtils.tokensToString()'s includeEntities
1090                if ( TokenUtils::isEntitySpanToken( $currentToken ) ) {
1091                    $resultStr .= $currentToken->dataParsoid->src;
1092                    $skipToEndOf = 'span';
1093                    continue;
1094                }
1095                if ( $currentToken->getName() === 'a' ) {
1096                    if ( $optInfo === null ) {
1097                        $optInfo = self::getOptionInfo( $prefix . $resultStr, $env );
1098                        if ( $optInfo === null ) {
1099                            // An <a> tag before a valid option?
1100                            // This is most likely a caption.
1101                            return null;
1102                        }
1103                    }
1104
1105                    if ( self::isWikitextOpt( $env, $optInfo, $prefix, $resultStr ) ) {
1106                        $tokenType = $currentToken->getAttributeV( 'rel' );
1107                        $isLink = $optInfo && $optInfo['ck'] === 'link';
1108                        // Reset the optInfo since we're changing the nature of it
1109                        $optInfo = null;
1110                        // Figure out the proper string to put here and break.
1111                        if (
1112                            $tokenType === 'mw:ExtLink' &&
1113                            ( $currentToken->dataParsoid->stx ?? '' ) === 'url'
1114                        ) {
1115                            // Add the URL and entity encode any pipes
1116                            // If the pipes are from entity decoding in the href,
1117                            // we don't want to split media options on them
1118                            // If the pipes are from template expansion, legacy
1119                            // would have considered them delineating a media option
1120                            // but let's not support that combination with the url
1121                            $resultStr .= str_replace(
1122                                '|', '&vert;', $currentToken->getAttributeV( 'href' )
1123                            );
1124                            // Tell our loop to skip to the end of this tag
1125                            $skipToEndOf = 'a';
1126                        } elseif ( $tokenType === 'mw:WikiLink/Interwiki' ) {
1127                            if ( $isLink ) {
1128                                $resultStr .= $currentToken->getAttributeV( 'href' );
1129                                $i += 2;
1130                                continue;
1131                            }
1132                            // Nothing to do -- the link content will be
1133                            // captured by walking the rest of the tokens.
1134                        } elseif ( $tokenType === 'mw:WikiLink' || $tokenType === 'mw:MediaLink' ) {
1135
1136                            // Nothing to do -- the link content will be
1137                            // captured by walking the rest of the tokens.
1138                        } else {
1139                            // There shouldn't be any other kind of link...
1140                            // This is likely a caption.
1141                            return null;
1142                        }
1143                    } else {
1144                        // Why would there be an a tag without a link?
1145                        return null;
1146                    }
1147                }
1148            }
1149        }
1150
1151        return $resultStr;
1152    }
1153
1154    /**
1155     * Get the format for media.
1156     *
1157     * @param array $opts
1158     * @return string|null
1159     */
1160    private static function getFormat( array $opts ): ?string {
1161        if ( $opts['manualthumb'] ) {
1162            return 'manualthumb';
1163        }
1164        return $opts['format']['v'] ?? null;
1165    }
1166
1167    private array $used = [];
1168
1169    /**
1170     * This is the set of file options that apply to the container, rather
1171     * than the media element itself (or, apply generically to a span).
1172     * Other options depend on the fetched media type and won't necessary be
1173     * applied.
1174     *
1175     * @return array
1176     */
1177    private function getUsed(): array {
1178        if ( $this->used ) {
1179            return $this->used;
1180        }
1181        $this->used = PHPUtils::makeSet(
1182            array_merge(
1183                [
1184                    'lang', 'width', 'class', 'upright',
1185                    'border', 'frameless', 'framed', 'thumbnail',
1186                ],
1187                self::HORIZONTAL_ALIGNS,
1188                self::VERTICAL_ALIGNS
1189            )
1190        );
1191        return $this->used;
1192    }
1193
1194    private function hasTransclusion( array $toks ): bool {
1195        foreach ( $toks as $t ) {
1196            if (
1197                $t instanceof SelfclosingTagTk &&
1198                TokenUtils::hasTypeOf( $t, 'mw:Transclusion' )
1199            ) {
1200                return true;
1201            }
1202        }
1203        return false;
1204    }
1205
1206    /**
1207     * Render a file. This can be an image, a sound, a PDF etc.
1208     *
1209     * @param Token $token
1210     * @param stdClass $target
1211     * @return array<string|Token>
1212     */
1213    private function renderFile( Token $token, stdClass $target ): array {
1214        $manager = $this->manager;
1215        $env = $this->env;
1216
1217        // FIXME: Re-enable use of media cache and figure out how that fits
1218        // into this new processing model. See T98995
1219
1220        $dataParsoid = clone $token->dataParsoid;
1221        $dataParsoid->optList = [];
1222
1223        // Account for the possibility of an expanded target
1224        $dataMw = $token->dataMw ?? new DataMw();
1225
1226        $opts = [
1227            'title' => [
1228                'v' => $env->makeLink( $target->title ),
1229                'src' => $token->getAttributeKV( 'href' )->vsrc
1230            ],
1231            'size' => [
1232                'v' => [
1233                    'height' => null,
1234                    'width' => null
1235                ]
1236            ],
1237            // Initialize these properties to avoid isset checks
1238            'caption' => null,
1239            'format' => null,
1240            'manualthumb' => null,
1241            'class' => null
1242        ];
1243
1244        $hasExpandableOpt = false;
1245
1246        $optKVs = self::buildLinkAttrs( $token->attribs, true, null, null )['contentKVs'];
1247        while ( count( $optKVs ) > 0 ) {
1248            $oContent = array_shift( $optKVs );
1249            Assert::invariant( $oContent instanceof KV, 'bad type' );
1250
1251            $origOptSrc = $oContent->v;
1252            if ( is_array( $origOptSrc ) && count( $origOptSrc ) === 1 ) {
1253                $origOptSrc = $origOptSrc[0];
1254            }
1255
1256            $oText = TokenUtils::tokensToString( $origOptSrc, true, [ 'includeEntities' => true ] );
1257
1258            if ( !is_string( $oText ) ) {
1259                // Might be that this is a valid option whose value is just
1260                // complicated. Try to figure it out, step through all tokens.
1261                $maybeOText = self::stringifyOptionTokens( $oText, '', $env );
1262                if ( $maybeOText !== null ) {
1263                    $oText = $maybeOText;
1264                }
1265            }
1266
1267            $optInfo = null;
1268            if ( is_string( $oText ) ) {
1269                if ( str_contains( $oText, '|' ) ) {
1270                    // Split the pipe-separated string into pieces
1271                    // and convert each one into a KV obj and add them
1272                    // to the beginning of the array. Note that this is
1273                    // a hack to support templates that provide multiple
1274                    // image options as a pipe-separated string. We aren't
1275                    // really providing editing support for this yet, or
1276                    // ever, maybe.
1277                    //
1278                    // TODO(arlolra): Tables in captions suppress breaking on
1279                    // "linkdesc" pipes so `stringifyOptionTokens` should account
1280                    // for pipes in table cell content.  For the moment, breaking
1281                    // here is acceptable since it matches the php implementation
1282                    // bug for bug.
1283                    $pieces = array_map( static function ( $s ) {
1284                        return new KV( 'mw:maybeContent', $s );
1285                    }, explode( '|', $oText ) );
1286                    $optKVs = array_merge( $pieces, $optKVs );
1287
1288                    // Record the fact that we won't provide editing support for this.
1289                    $dataParsoid->uneditable = true;
1290                    continue;
1291                } else {
1292                    // We're being overly accepting of media options at this point,
1293                    // since we don't know the type yet.  After the info request,
1294                    // we'll filter out those that aren't appropriate.
1295                    $optInfo = self::getOptionInfo( $oText, $env );
1296                }
1297            }
1298
1299            $recordCaption = static function () use ( $oContent, $oText, $dataParsoid, &$opts ): void {
1300                $optsCaption = [
1301                    'v' => $oContent->v,
1302                    'src' => $oContent->vsrc ?? $oText,
1303                    'srcOffsets' => $oContent->valueOffset(),
1304                    // remember the position
1305                    'pos' => count( $dataParsoid->optList )
1306                ];
1307                // if there was a 'caption' previously, round-trip it as a
1308                // "bogus option".
1309                if ( !empty( $opts['caption'] ) ) {
1310                    // Wrap the caption opt in an array since the option itself is an array!
1311                    // Without the wrapping, the splicing will flatten the value.
1312                    array_splice( $dataParsoid->optList, $opts['caption']['pos'], 0, [ [
1313                            'ck' => 'bogus',
1314                            'ak' => $opts['caption']['src']
1315                        ] ]
1316                    );
1317                    $optsCaption['pos']++;
1318                }
1319                $opts['caption'] = $optsCaption;
1320            };
1321
1322            // For the values of the caption and options, see
1323            // getOptionInfo's documentation above.
1324            //
1325            // If there are multiple captions, this code always
1326            // picks the last entry. This is the spec; see
1327            // "Image with multiple captions" parserTest.
1328            if ( !is_string( $oText ) || $optInfo === null ||
1329                // Deprecated options
1330                in_array( $optInfo['ck'], [ 'disablecontrols' ], true )
1331            ) {
1332                // No valid option found!?
1333                // Record for RT-ing
1334                $recordCaption();
1335                continue;
1336            }
1337
1338            // First option wins, the rest are 'bogus'
1339            // FIXME: For now, see T305628
1340            if ( isset( $opts[$optInfo['ck']] ) || (
1341                // All the formats are simple options with the key "format"
1342                // except for "manualthumb", so check if the format has been set
1343                in_array( $optInfo['ck'], [ 'format', 'manualthumb' ], true ) && (
1344                    self::getFormat( $opts ) ||
1345                    ( $this->options['extTagOpts']['suppressMediaFormats'] ?? false )
1346                )
1347            ) ) {
1348                $dataParsoid->optList[] = [
1349                    'ck' => 'bogus',
1350                    'ak' => $optInfo['ak']
1351                ];
1352                continue;
1353            }
1354
1355            $opt = [
1356                'ck' => $optInfo['v'],
1357                'ak' => $oContent->vsrc ?? $optInfo['ak']
1358            ];
1359
1360            if ( $optInfo['s'] === true ) {
1361                // Default: Simple image option
1362                $opts[$optInfo['ck']] = [ 'v' => $optInfo['v'] ];
1363            } else {
1364                // Map short canonical name to the localized version used.
1365                $opt['ck'] = $optInfo['ck'];
1366
1367                // The MediaWiki magic word for image dimensions is called 'width'
1368                // for historical reasons
1369                // Unlike other options, use last-specified width.
1370                if ( $optInfo['ck'] === 'width' ) {
1371                    // We support a trailing 'px' here for historical reasons
1372                    // (T15500, T53628, T207032)
1373                    $maybeDim = Utils::parseMediaDimensions(
1374                        $env->getSiteConfig(), $optInfo['v'], false, true
1375                    );
1376                    if ( $maybeDim !== null ) {
1377                        if ( $maybeDim['bogusPx'] ) {
1378                            // Lint away redundant unit (T207032)
1379                            $dataParsoid->setTempFlag( TempData::BOGUS_PX );
1380                        }
1381                        $opts['size']['v'] = [
1382                            'width' => Utils::validateMediaParam( $maybeDim['x'] ) ? $maybeDim['x'] : null,
1383                            'height' => array_key_exists( 'y', $maybeDim ) &&
1384                                Utils::validateMediaParam( $maybeDim['y'] ) ? $maybeDim['y'] : null
1385                        ];
1386                        // Only round-trip a valid size
1387                        $opts['size']['src'] = $oContent->vsrc ?? $optInfo['ak'];
1388                        // check for duplicated options
1389                        foreach ( $dataParsoid->optList as &$value ) {
1390                            if ( $value['ck'] === 'width' ) {
1391                                $value['ck'] = 'bogus'; // mark the previous definition as bogus, last one wins
1392                                break;
1393                            }
1394                        }
1395                    } else {
1396                        $recordCaption();
1397                        continue;
1398                    }
1399                // Lang is a global attribute and can be applied to all media elements
1400                // for editing and roundtripping.  However, not all file handlers will
1401                // make use of it.  This param validation is from the SVG handler but
1402                // seems generally applicable.
1403                } elseif ( $optInfo['ck'] === 'lang' && !Language::isValidInternalCode( $optInfo['v'] ) ) {
1404                    $opt['ck'] = 'bogus';
1405                } elseif (
1406                    $optInfo['ck'] === 'upright' &&
1407                    ( !is_numeric( $optInfo['v'] ) || $optInfo['v'] <= 0 )
1408                ) {
1409                    $opt['ck'] = 'bogus';
1410                } else {
1411                    $opts[$optInfo['ck']] = [
1412                        'v' => $optInfo['v'],
1413                        'src' => $oContent->vsrc ?? $optInfo['ak'],
1414                        'srcOffsets' => $oContent->valueOffset(),
1415                    ];
1416                }
1417            }
1418
1419            // Collect option in dataParsoid (becomes data-parsoid later on)
1420            // for faithful serialization.
1421            $dataParsoid->optList[] = $opt;
1422
1423            // Collect source wikitext for image options for possible template expansion.
1424            $maybeOpt = !isset( self::getUsed()[$opt['ck']] );
1425            // Links more often than not show up as arrays here because they're
1426            // tokenized as `autourl`.  To avoid unnecessarily considering them
1427            // expanded, we'll use a more restrictive test, at the cost of
1428            // perhaps missing some edgy behaviour.
1429            if ( $opt['ck'] === 'link' ) {
1430                $expOpt = is_array( $origOptSrc ) &&
1431                    $this->hasTransclusion( $origOptSrc );
1432            } else {
1433                $expOpt = is_array( $origOptSrc );
1434            }
1435            if ( $maybeOpt || $expOpt ) {
1436                $val = [];
1437                if ( $expOpt ) {
1438                    $hasExpandableOpt = true;
1439                    $val['html'] = $origOptSrc;
1440                    $val['srcOffsets'] = $oContent->valueOffset();
1441                    $val = PipelineUtils::expandAttrValueToDOM(
1442                        $env, $manager->getFrame(), $val,
1443                        $this->options['expandTemplates'],
1444                        $this->options['inTemplate']
1445                    );
1446                }
1447
1448                // This is a bit of an abuse of the "txt" property since
1449                // `optInfo.v` isn't necessarily wikitext from source.
1450                // It's a result of the specialized stringifying above, which
1451                // if interpreted as wikitext upon serialization will result
1452                // in some (acceptable) normalization.
1453                //
1454                // We're storing these options in data-mw because they aren't
1455                // guaranteed to apply to all media types and we'd like to
1456                // avoid the need to back them out later.
1457                //
1458                // Note that the caption in the legacy parser depends on the
1459                // exact set of options parsed, which we aren't attempting to
1460                // try and replicate after fetching the media info, since we
1461                // consider that more of bug than a feature.  It prevent anyone
1462                // from ever safely adding media options in the future.
1463                //
1464                // See T163582
1465                if ( $maybeOpt ) {
1466                    $val['txt'] = $optInfo['v'];
1467                }
1468                $dataMw->attribs ??= [];
1469                $dataMw->attribs[] = new DataMwAttrib( $opt['ck'], $val );
1470            }
1471        }
1472
1473        // Add the last caption in the right position if there is one
1474        if ( isset( $opts['caption'] ) ) {
1475            // Wrap the caption opt in an array since the option itself is an array!
1476            // Without the wrapping, the splicing will flatten the value.
1477            array_splice( $dataParsoid->optList, $opts['caption']['pos'], 0, [ [
1478                    'ck' => 'caption',
1479                    'ak' => $opts['caption']['src']
1480                ] ]
1481            );
1482        }
1483
1484        $format = self::getFormat( $opts );
1485
1486        // Handle image default sizes and upright option after extracting all
1487        // options
1488        $uprightFactor = null;
1489        if ( $format === 'framed' || $format === 'manualthumb' ) {
1490            // width and height is ignored for framed and manualthumb images
1491            // https://phabricator.wikimedia.org/T64258
1492            $opts['size']['v'] = [ 'width' => null, 'height' => null ];
1493            // Mark any definitions as bogus
1494            foreach ( $dataParsoid->optList as &$value ) {
1495                if ( $value['ck'] === 'width' ) {
1496                    $value['ck'] = 'bogus';
1497                }
1498            }
1499        } elseif ( $format ) {
1500            if ( !$opts['size']['v']['height'] && !$opts['size']['v']['width'] ) {
1501                $defaultWidth = $env->getSiteConfig()->widthOption();
1502                if ( isset( $opts['upright'] ) ) {
1503                    if ( $opts['upright']['v'] === 'upright' ) {  // Simple option
1504                        $uprightFactor = 0.75;
1505                    } else {
1506                        $uprightFactor = (float)$opts['upright']['v'];
1507                    }
1508                    $defaultWidth *= $uprightFactor;
1509                    // round to nearest 10 pixels
1510                    $defaultWidth = 10 * round( $defaultWidth / 10 );
1511                }
1512                $opts['size']['v']['width'] = $defaultWidth;
1513            }
1514        }
1515
1516        // If the format is something we *recognize*, add the subtype
1517        $rdfaType = 'mw:File' . match ( $format ) {
1518            'manualthumb', // FIXME(T305759): Does it deserve its own type?
1519            'thumbnail' => '/Thumb',
1520            'framed' => '/Frame',
1521            'frameless' => '/Frameless',
1522            default => ''
1523        };
1524
1525        // Tell VE that it shouldn't try to edit this
1526        if ( !empty( $dataParsoid->uneditable ) ) {
1527            $rdfaType .= ' mw:Placeholder';
1528        } else {
1529            unset( $dataParsoid->src );
1530        }
1531
1532        $wrapperInfo = self::getWrapperInfo( $opts );
1533
1534        $isInline = $wrapperInfo['isInline'];
1535        $containerName = $isInline ? 'span' : 'figure';
1536
1537        $classes = $wrapperInfo['classes'];
1538        if ( !empty( $opts['class'] ) ) {
1539            PHPUtils::pushArray( $classes, explode( ' ', $opts['class']['v'] ) );
1540        }
1541
1542        $attribs = [ new KV( 'typeof', $rdfaType ) ];
1543        if ( count( $classes ) > 0 ) {
1544            array_unshift( $attribs, new KV( 'class', implode( ' ', $classes ) ) );
1545        }
1546
1547        $container = new TagTk( $containerName, $attribs, $dataParsoid );
1548        $containerClose = new EndTagTk( $containerName );
1549
1550        if ( $hasExpandableOpt ) {
1551            $container->addAttribute( 'about', $env->newAboutId() );
1552            $container->addSpaceSeparatedAttribute( 'typeof', 'mw:ExpandedAttrs' );
1553        } elseif ( preg_match( '/\bmw:ExpandedAttrs\b/', $token->getAttributeV( 'typeof' ) ?? '' ) ) {
1554            $container->addSpaceSeparatedAttribute( 'typeof', 'mw:ExpandedAttrs' );
1555        }
1556
1557        // Start off as broken media since we don't know if the file exists.
1558        // In the AddMediaInfo pass, we'll replace the node after calling getFileInfo
1559        $span = new TagTk( 'span', [ new KV( 'class', 'mw-file-element mw-broken-media' ) ] );
1560
1561        // "resource" and "lang" are allowed attributes on spans
1562        $span->addNormalizedAttribute( 'resource', $opts['title']['v'], $opts['title']['src'] );
1563        if ( isset( $opts['lang'] ) ) {
1564            $span->addNormalizedAttribute( 'lang', $opts['lang']['v'], $opts['lang']['src'] );
1565        }
1566
1567        // Token's KV attributes only accept strings, Tokens or arrays of those.
1568        $size = $opts['size']['v'];
1569        if ( !empty( $size['width'] ) ) {
1570            $span->addAttribute( 'data-width', (string)$size['width'] );
1571        }
1572        if ( !empty( $size['height'] ) ) {
1573            $span->addAttribute( 'data-height', (string)$size['height'] );
1574        }
1575        if ( $uprightFactor !== null ) {
1576            $span->addAttribute( 'data-upright', (string)$uprightFactor );
1577        }
1578
1579        $anchor = new TagTk( 'a' );
1580        $anchor->setAttribute( 'href', $this->specialFilePath( $target->title ) );
1581        $anchor->setAttribute( 'class', 'new' );
1582        $anchor->setAttribute( 'title', $target->title->getPrefixedText() );
1583
1584        $tokens = [
1585            $container,
1586            $anchor,
1587            $span,
1588            $target->title->getPrefixedText(),
1589            new EndTagTk( 'span' ),
1590            new EndTagTk( 'a' )
1591        ];
1592
1593        $optsCaption = $opts['caption'] ?? null;
1594        if ( $isInline ) {
1595            if ( $optsCaption ) {
1596                if ( !is_array( $optsCaption['v'] ) ) {
1597                    $opts['caption']['v'] = $optsCaption['v'] = [ $optsCaption['v'] ];
1598                }
1599                // Parse the caption
1600                $captionDOM = PipelineUtils::processContentInPipeline(
1601                    $this->env,
1602                    $this->manager->getFrame(),
1603                    array_merge( $optsCaption['v'], [ new EOFTk() ] ),
1604                    [
1605                        'pipelineType' => 'expanded-tokens-to-fragment',
1606                        'pipelineOpts' => [
1607                            'inlineContext' => true,
1608                            'expandTemplates' => $this->options['expandTemplates'],
1609                            'inTemplate' => $this->options['inTemplate']
1610                        ],
1611                        'srcOffsets' => $optsCaption['srcOffsets'] ?? null,
1612                        'sol' => true
1613                    ]
1614                );
1615
1616                // Use parsed DOM given in `captionDOM`
1617                // FIXME: Does this belong in `dataMw.attribs`?
1618                $dataMw->caption = $captionDOM;
1619            }
1620        } else {
1621            // We always add a figcaption for blocks
1622            $tsr = $optsCaption['srcOffsets'] ?? null;
1623            $dp = new DataParsoid;
1624            $dp->tsr = $tsr;
1625            $tokens[] = new TagTk( 'figcaption', [], $dp );
1626            if ( $optsCaption ) {
1627                if ( is_string( $optsCaption['v'] ) ) {
1628                    $tokens[] = $optsCaption['v'];
1629                } else {
1630                    $tokens[] = PipelineUtils::getDOMFragmentToken(
1631                        $optsCaption['v'],
1632                        $tsr,
1633                        [ 'inlineContext' => true, 'token' => $token ]
1634                    );
1635                }
1636            }
1637            $tokens[] = new EndTagTk( 'figcaption' );
1638        }
1639
1640        if ( !$dataMw->isEmpty() ) {
1641            $container->dataMw = $dataMw;
1642        }
1643
1644        $tokens[] = $containerClose;
1645        return $tokens;
1646    }
1647
1648    private function specialFilePath( Title $title ): string {
1649        $filePath = Sanitizer::sanitizeTitleURI( $title->getDBkey(), false );
1650        return "./Special:FilePath/{$filePath}";
1651    }
1652
1653    /**
1654     * @param Token $token
1655     * @param stdClass $target
1656     * @param list<DataMwError> $errs
1657     * @param ?array{url?:string} $info
1658     * @return array<string|Token>
1659     */
1660    private function linkToMedia( Token $token, stdClass $target, array $errs, ?array $info ): array {
1661        // Only pass in the url, since media links should not link to the thumburl
1662        $imgHref = $info['url'] ?? $this->specialFilePath( $target->title );  // Copied from getPath
1663        $imgHrefFileName = preg_replace( '#.*/#', '', $imgHref, 1 );
1664
1665        $link = new TagTk( 'a' );
1666
1667        try {
1668            $content = $this->addLinkAttributesAndGetContent( $link, $token, $target );
1669        } catch ( InternalException ) {
1670            return self::bailTokens( $this->manager, $token );
1671        }
1672
1673        // Change the rel to be mw:MediaLink
1674        $link->getAttributeKV( 'rel' )->v = 'mw:MediaLink';
1675
1676        $link->addNormalizedAttribute( 'href', $imgHref, $target->hrefSrc );
1677
1678        // html2wt will use the resource rather than try to parse the href.
1679        $link->addNormalizedAttribute(
1680            'resource',
1681            $this->env->makeLink( $target->title ),
1682            $target->hrefSrc
1683        );
1684
1685        // Normalize title according to how PHP parser does it currently
1686        $link->setAttribute( 'title', str_replace( '_', ' ', $imgHrefFileName ) );
1687
1688        if ( count( $errs ) > 0 ) {
1689            // Set RDFa type to mw:Error so VE and other clients
1690            // can use this to do client-specific action on these.
1691            if ( !TokenUtils::hasTypeOf( $link, 'mw:Error' ) ) {
1692                $link->addSpaceSeparatedAttribute( 'typeof', 'mw:Error' );
1693            }
1694
1695            // Update data-mw
1696            $dataMw = $token->dataMw ?? new DataMw;
1697            if ( is_array( $dataMw->errors ?? null ) ) {
1698                array_push( $dataMw->errors, ...$errs );
1699            } else {
1700                $dataMw->errors = $errs;
1701            }
1702            $link->dataMw = $dataMw;
1703        }
1704
1705        return array_merge( [ $link ], $content, [ new EndTagTk( 'a' ) ] );
1706    }
1707
1708    /**
1709     * FIXME: The media request here is only used to determine if this is a
1710     * redlink and deserves to be handling in the redlink post-processing pass.
1711     * @return array<string|Token>
1712     */
1713    private function renderMedia( Token $token, stdClass $target ): array {
1714        $env = $this->env;
1715        $title = $target->title;
1716        $errs = [];
1717        $info = $env->getDataAccess()->getFileInfo(
1718            $env->getPageConfig(),
1719            [ [ $title->getDBkey(), [ 'height' => null, 'width' => null ] ] ]
1720        )[0];
1721        if ( !$info ) {
1722            $errs[] = new DataMwError( 'apierror-filedoesnotexist', [], 'This image does not exist.' );
1723        } elseif ( isset( $info['thumberror'] ) ) {
1724            $errs[] = new DataMwError( 'apierror-unknownerror', [], $info['thumberror'] );
1725        }
1726        return $this->linkToMedia( $token, $target, $errs, $info );
1727    }
1728
1729    /** @inheritDoc */
1730    public function onTag( XMLTagTk $token ): ?array {
1731        return match ( $token->getName() ) {
1732            'wikilink' => $this->onWikiLink( $token ),
1733            'mw:redirect' => $this->onRedirect( $token ),
1734            default => null
1735        };
1736    }
1737}