Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 275
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
AttributeExpander
0.00% covered (danger)
0.00%
0 / 275
0.00% covered (danger)
0.00%
0 / 10
10506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 nlTkIndex
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 splitTokens
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
132
 stripMetaTags
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
210
 tplToksToString
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 buildExpandedAttrs
0.00% covered (danger)
0.00%
0 / 139
0.00% covered (danger)
0.00%
0 / 1
2352
 processComplexAttributes
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 expandFirstAttribute
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 onCompoundTk
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 onAny
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\TT;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\NodeData\DataMw;
10use Wikimedia\Parsoid\NodeData\DataMwAttrib;
11use Wikimedia\Parsoid\Tokens\CompoundTk;
12use Wikimedia\Parsoid\Tokens\EmptyLineTk;
13use Wikimedia\Parsoid\Tokens\KV;
14use Wikimedia\Parsoid\Tokens\NlTk;
15use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
16use Wikimedia\Parsoid\Tokens\TagTk;
17use Wikimedia\Parsoid\Tokens\Token;
18use Wikimedia\Parsoid\Tokens\XMLTagTk;
19use Wikimedia\Parsoid\Utils\PHPUtils;
20use Wikimedia\Parsoid\Utils\PipelineUtils;
21use Wikimedia\Parsoid\Utils\TokenUtils;
22use Wikimedia\Parsoid\Utils\Utils;
23use Wikimedia\Parsoid\Utils\WTUtils;
24use Wikimedia\Parsoid\Wt2Html\Frame;
25use Wikimedia\Parsoid\Wt2Html\PegTokenizer;
26use Wikimedia\Parsoid\Wt2Html\TokenHandlerPipeline;
27
28/**
29 * Generic attribute expansion handler.
30 */
31class AttributeExpander extends UniversalTokenHandler {
32    private const META_TYPE_MATCHER = '#(mw:(LanguageVariant|Transclusion|Param|Includes|Annotation/)(.*)$)#D';
33
34    /**
35     * Used for re-tokenizing attribute strings that need to be re-expanded
36     * @var PegTokenizer
37     */
38    private $tokenizer;
39
40    /**
41     * @param TokenHandlerPipeline $manager
42     * @param array $options
43     *  - bool inTemplate Is this being invoked while processing a template?
44     *  - bool expandTemplates Should we expand templates encountered here?
45     *  - bool standalone Is this AttributeExpander used as part of a pipeline
46     *                    or is it being used standalone as an utility class?
47     */
48    public function __construct( TokenHandlerPipeline $manager, array $options ) {
49        parent::__construct( $manager, $options );
50        $this->tokenizer = new PegTokenizer( $manager->getEnv() );
51    }
52
53    private static function nlTkIndex(
54        bool $nlTkOkay, array $tokens, bool $atTopLevel
55    ): int {
56        // Moving this check here since it makes the
57        // callsite cleaner and simpler.
58        if ( $nlTkOkay ) {
59            return -1;
60        }
61
62        // Check if we have a newline token in the attribute key/value token stream.
63        // However, newlines are acceptable inside a <*include*>..</*include*> directive
64        // since they are stripped out.
65        //
66        // let includeRE = !atTopLevel ?
67        //     /(?:^|\s)mw:Includes\/NoInclude(\/.*)?(?:\s|$)/ :
68        //     /(?:^|\s)mw:Includes\/(?:Only)?Include(?:Only)?(\/.*)?(?:\s|$)/;
69        //
70        // SSS FIXME: We cannot support this usage for <*include*> directives currently
71        // since they don't go through template encapsulation and don't have a data-mw
72        // format with "wt" and "transclusion" parts that we can use to just track bits
73        // of wikitext that don't have a DOM representation.
74        //
75        // So, for now, we just suppress all newlines contained within these directives.
76        $includeRE = '#(?:^|\s)mw:Includes/(?:No|Only)?Include(?:Only)?(/.*)?(?:\s|$)#D';
77        $inInclude = false;
78        foreach ( $tokens as $i => $t ) {
79            if ( $t instanceof SelfclosingTagTk ) {
80                $type = $t->getAttributeV( 'typeof' );
81                $typeMatch = [];
82                if ( $type && preg_match( $includeRE, $type, $typeMatch, PREG_UNMATCHED_AS_NULL ) ) {
83                    $inInclude = !str_ends_with( $typeMatch[1] ?? '', '/End' );
84                }
85            } elseif ( !$inInclude && $t instanceof NlTk ) {
86                // newline token outside <*include*>
87                return $i;
88            }
89        }
90
91        return -1;
92    }
93
94    /**
95     * @phpcs:ignore Generic.Files.LineLength.TooLong
96     * @return array{metaTokens: list{0?: SelfclosingTagTk}, preNLBuf: list<string|Token>, postNLBuf: list<string|Token>}
97     */
98    private static function splitTokens(
99        Frame $frame, XMLTagTk $token, int $nlTkPos, array $tokens, bool $wrapTemplates
100    ): array {
101        $preNLBuf = [];
102        $postNLBuf = [];
103        $startMeta = null;
104
105        // Split the token array around the first newline token.
106        $startMetaIndex = null;
107        foreach ( $tokens as $i => $t ) {
108            if ( $i === $nlTkPos ) {
109                // split here!
110                $postNLBuf = array_slice( $tokens, $i );
111                break;
112            } else {
113                if ( $wrapTemplates && $t instanceof SelfclosingTagTk ) {
114                    $type = $t->getAttributeV( 'typeof' );
115                    // We are interested in the last start meta tag.
116                    // Everything before it is assumed to be closed.
117                    $typeMatch = [];
118                    if ( $type &&
119                        preg_match( self::META_TYPE_MATCHER, $type, $typeMatch ) &&
120                        !str_ends_with( $typeMatch[1], '/End' )
121                    ) {
122                        $startMeta = $t;
123                        $startMetaIndex = $i;
124                    }
125                }
126
127                // Use $i to make code robust if $tokens were not continugous
128                $preNLBuf[$i] = $t;
129            }
130        }
131
132        // We split the token into pieces.
133        // Since we no longer know where this token now ends tsr-wise,
134        // set tsr->end to null
135        $token->dataParsoid->tsr->end = null;
136        $token->dataParsoid->getTemp()->attrSrc = '';
137
138        if ( $startMeta ) {
139            if ( count( $preNLBuf ) === 1 ) {
140                // Nothing to do since all real content (except the meta token)
141                // is after the newline.
142                return [ 'metaTokens' => [], 'preNLBuf' => [], 'postNLBuf' => $tokens ];
143            } else {
144                // Clear $startMeta from $preNLBuf - setting to '' is sufficient.
145                $preNLBuf[$startMetaIndex] = '';
146
147                // Support template wrapping with the following steps:
148                // - Hoist the transclusion start-meta from the first line
149                //   to before the token.
150                // - Update the start-meta tsr to that of the token.
151                // - Record the wikitext between the token and the transclusion
152                //   as an unwrappedWT data-parsoid attribute of the start-meta.
153                $dp = $startMeta->dataParsoid;
154                $source = $dp->tsr->source ?? $frame->getSource();
155                $dp->unwrappedWT = PHPUtils::safeSubstr(
156                    $source->getSrcText(), $token->dataParsoid->tsr->start,
157                    $dp->tsr->start - $token->dataParsoid->tsr->start );
158
159                // unwrappedWT will be added to the data-mw.parts array which makes
160                // this a multi-template-content-block.
161                // Record the first wikitext node of this block (required by html->wt serialization)
162
163                // FIXME spec-compliant values would be upper-case, this is just a workaround
164                // for current PHP DOM implementation and could be removed in the future
165                $tokenName = mb_strtoupper( $token->getName() );
166
167                $dp->firstWikitextNode = isset( $token->dataParsoid->stx ) ?
168                    $tokenName . '_' . $token->dataParsoid->stx : $tokenName;
169
170                // Update tsr->start only. Unless the end-meta token is moved as well,
171                // updating tsr->end can introduce bugs in cases like:
172                //
173                //   {|
174                //   |{{singlechart|Australia|93|artist=Madonna|album=Girls Gone Wild}}|x
175                //   |}
176                //
177                // which can then cause dirty diffs (the "|" before the x gets dropped).
178                $dp->tsr->start = $token->dataParsoid->tsr->start;
179                $metaTokens = [ $startMeta ];
180
181                return [ 'metaTokens' => $metaTokens, 'preNLBuf' => $preNLBuf, 'postNLBuf' => $postNLBuf ];
182            }
183        } else {
184            return [ 'metaTokens' => [], 'preNLBuf' => $tokens, 'postNLBuf' => [] ];
185        }
186    }
187
188    /**
189     * This helper method strips all meta tags introduced by
190     * transclusions, etc. and returns the content.
191     *
192     * @param Env $env
193     * @param array $tokens
194     * @param bool $wrapTemplates
195     *
196     * @return array{hasGeneratedContent: bool, annotationType: list<string>, value: list<Token|string>}
197     */
198    private static function stripMetaTags(
199        Env $env, array $tokens, bool $wrapTemplates, bool $inTableCellContext = false
200    ): array {
201        $buf = [];
202        $hasGeneratedContent = false;
203        $cellAttrTerminatorSeen = false;
204        $annotationType = [];
205
206        foreach ( $tokens as $t ) {
207            if ( $t instanceof TagTk || $t instanceof SelfclosingTagTk ) {
208                // Take advantage of this iteration of `tokens` to seek out
209                // document fragments.  They're an indication that an attribute
210                // value wasn't present as literal text in the input and the
211                // token should be annotated with "mw:ExpandedAttrs".
212                if ( TokenUtils::hasDOMFragmentType( $t ) ) {
213                    $hasGeneratedContent = true;
214                }
215
216                if ( $inTableCellContext ) {
217                    // Images, links, etc. terminate table cell attribute processing.
218                    // If so, record info so TableFixups can cleanly convert this attribute
219                    // back to content and handle any additional fixups resulting from that.
220                    $rel = $t->getAttributeV( 'rel' ) ?? '';
221                    $typeof = $t->getAttributeV( 'typeof' ) ?? '';
222                    if ( preg_match( '#\bmw:File($|/)\b#', $typeof ) ||
223                        preg_match( WTUtils::WIKILINK_SYNTAX_CONSTRUCTS_REGEXP, $rel )
224                    ) {
225                        $cellAttrTerminatorSeen = true;
226                    }
227                }
228
229                if ( $wrapTemplates ) {
230                    // Strip all meta tags.
231                    $type = $t->getAttributeV( 'typeof' );
232                    $typeMatch = [];
233                    if ( $type && preg_match( self::META_TYPE_MATCHER, $type, $typeMatch ) ) {
234                        if ( !str_ends_with( $typeMatch[1], '/End' ) ) {
235                            $hasGeneratedContent = true;
236                        }
237                        $groups = [];
238                        if ( preg_match( WTUtils::ANNOTATION_META_TYPE_REGEXP, $type, $groups ) ) {
239                            $annotationType[] = $groups[1];
240                        }
241                    } else {
242                        $buf[] = $t;
243                        continue;
244                    }
245                }
246
247                if ( $t->getName() !== 'meta' ) {
248                    // Dont strip token if it is not a meta-tag
249                    $buf[] = $t;
250                }
251            } else {
252                $buf[] = $t;
253            }
254        }
255
256        return [
257            'hasGeneratedContent' => $hasGeneratedContent,
258            'cellAttrTerminatorSeen' => $cellAttrTerminatorSeen,
259            'annotationType' => $annotationType,
260            'value' => $buf
261        ];
262    }
263
264    /**
265     * This receives tokens from the PegTokenizer whose input itself
266     * is preprocessed wikitext. So, templates and include directives
267     * have all been processed and comments have been dropped.
268     * Anything left behind (directives, template tokens, template args)
269     * should just be converted to a plain string.
270     *
271     * @param mixed $a
272     */
273    private static function tplToksToString( $a ): string {
274        if ( !is_array( $a ) ) {
275            return $a;
276        }
277        $buf = "";
278        foreach ( $a as $t ) {
279            if ( is_string( $t ) ) {
280                $buf .= $t;
281            } else {
282                $buf .= $t->dataParsoid->src ?? ''; /* drop it if dp->src is missing */
283            }
284        }
285        return $buf;
286    }
287
288    /**
289     * Callback for attribute expansion in AttributeTransformManager
290     * @param XMLTagTk $token
291     * @param KV[] $expandedAttrs
292     * @return array<string|Token>
293     */
294    private function buildExpandedAttrs( XMLTagTk $token, array $expandedAttrs ): array {
295        // If we're not in a template, we'll be doing template wrapping in dom
296        // post-processing (same conditional there), so take care of meta markers
297        // found while processing tokens.
298        $wrapTemplates = !$this->options['inTemplate'];
299        $env = $this->manager->getEnv();
300        $metaTokens = [];
301        $postNLToks = [];
302        $tmpDataMW = null;
303        $oldAttrs = $token->attribs;
304        $tokenName = $token->getName();
305        // Build newAttrs lazily (on-demand) to avoid creating
306        // objects in the common case where nothing of significance
307        // happens in this code.
308        $newAttrs = null;
309        // FIXME: td/th/caption need different handling.
310        // For now, we are limiting this to table & tr tags because
311        // the code below uses 'table_attributes' to reparse the string
312        // which is only valid for table & tr tokens. The fix for td/th/caption
313        // may be as simple as using the 'row_syntax_table_args' rule. To be
314        // investigated and fixed.
315        $nlTkOkay = TokenUtils::isHTMLTag( $token ) ||
316            ( $tokenName !== 'table' && $tokenName !== 'tr' );
317        $annotationTypes = [];
318
319        // Identify attributes that were generated in full or in part using templates
320        foreach ( $oldAttrs as $i => $oldA ) {
321            $expandedA = $expandedAttrs[$i];
322
323            // Preserve the key and value source, if available.
324            // But, if 'oldA' wasn't cloned, expandedA will be the same as 'oldA'.
325            if ( $oldA !== $expandedA ) {
326                $expandedA->ksrc = $oldA->ksrc;
327                $expandedA->vsrc = $oldA->vsrc;
328                $expandedA->srcOffsets = $oldA->srcOffsets;
329            }
330
331            // Deal with two template-expansion scenarios for the attribute key (not value)
332            //
333            // 1. We have a template that generates multiple attributes of this token
334            //    as well as content after the token.
335            //    Ex: infobox templates from aircraft, ship, and other pages
336            //        See enwiki:Boeing_757
337            //
338            //    - Split the expanded tokens into multiple lines.
339            //    - Expanded attributes associated with the token are retained in the
340            //      first line before a NlTk.
341            //    - Content tokens after the NlTk are moved to subsequent lines.
342            //    - The meta tags are hoisted before the original token to make sure
343            //      that the entire token and following content is encapsulated as a unit.
344            //
345            // 2. We have a template that only generates multiple attributes of this
346            //    token. In that case, we strip all template meta tags from the expanded
347            //    tokens and assign it a mw:ExpandedAttrs type with orig/expanded
348            //    values in data-mw.
349            //
350            // Reparse-KV-string scenario with templated attributes:
351            // -----------------------------------------------------
352            // In either scenario above, we need additional special handling if the
353            // template generates one or more k=v style strings:
354            //    <div {{1x|1=style='color:red''}}></div>
355            //    <div {{1x|1=style='color:red' title='boo'}}></div>
356            //
357            // Real use case: Template {{ligne grise}} on frwp.
358            //
359            // To support this, we utilize the following hack. If we got a string of the
360            // form "k=v" and our orig-v was "", we convert the token array to a string
361            // and retokenize it to extract one or more attributes.
362            //
363            // But, we won't support scenarios like this:
364            //   {| title={{1x|1='name' style='color:red;'\n|-\n|foo}}\n|}
365            // Here, part of one attribute and additional complete attribute strings
366            // need reparsing, and that isn't a use case that is worth more complexity here.
367            //
368            // FIXME:
369            // ------
370            // 1. It is not possible for multiple instances of scenario 1 to be triggered
371            //    for the same token. So, I am not bothering trying to test and deal with it.
372            //
373            // 2. We trigger the Reparse-KV-string scenario only for attribute keys,
374            //    since it isn't possible for attribute values to require this reparsing.
375            //    However, it is possible to come up with scenarios where a template
376            //    returns the value for one attribute and additional k=v strings for newer
377            //    attributes. We don't support that scenario, but don't even test for it.
378            //
379            // Reparse-KV-string scenario with non-string attributes:
380            // ------------------------------------------------------
381            // This is only going to be the case with table wikitext that has special syntax
382            // for attribute strings.
383            //
384            // {| <div>a</div> style='border:1px solid black;'
385            // |- <div>b</div> style='border:1px dotted blue;'
386            // | <div>c</div> style='color:red;'
387            // |}
388            //
389            // In wikitext like the above, the PEG tokenizer doesn't recognize these as
390            // valid attributes (the templated attribute scenario is a special case) and
391            // orig-v will be "". So, the same strategy as above is applied here as well.
392
393            $expandedK = $origK = $expandedA->k;
394            $expandedV = $origV = $expandedA->v;
395            $updatedK = null;
396            $updatedV = null;
397            $reparsedKV = false;
398            $keyUsesMixedAttrContentTpl = false;
399            $valUsesMixedAttrContentTpl = false;
400
401            if ( $expandedK ) {
402                // FIXME: We should get rid of these array/string/non-string checks
403                // and probably use appropriately-named flags to convey type information.
404                if ( is_array( $oldA->k ) ) {
405                    if ( !is_array( $expandedK ) ) {
406                        throw new UnreachableException( "expandedK: expected array. Found: " .
407                            PHPUtils::jsonEncode( $expandedK ) );
408                    }
409
410                    $nlTkPos = self::nlTkIndex( $nlTkOkay, $expandedK, $wrapTemplates );
411                    if ( $nlTkPos !== -1 ) {
412                        // Scenario 1 from the documentation comment above.
413                        $keyUsesMixedAttrContentTpl = true;
414                        $updatedK = self::splitTokens(
415                            $this->manager->getFrame(), $token, $nlTkPos, $expandedK, $wrapTemplates
416                        );
417                        $expandedK = $updatedK['preNLBuf'];
418                        $postNLToks = $updatedK['postNLBuf'];
419                        $metaTokens = $updatedK['metaTokens'];
420                        // We split up this attribute's key into pieces.
421                        if ( $expandedA->srcOffsets->key ) {
422                            $expandedA->srcOffsets->key->end = null;
423                            $expandedA->ksrc = null;
424                        }
425                    } else {
426                        // Maybe scenario 2 from the documentation comment above.
427                        $updatedK = self::stripMetaTags(
428                            $env, $expandedK, $wrapTemplates,
429                            ( $tokenName === 'td' || $tokenName === 'th' )
430                        );
431                        PHPUtils::pushArray( $annotationTypes, $updatedK['annotationType'] );
432                        $expandedK = $updatedK['value'];
433                        // @phan-suppress-next-line PhanTypeInvalidDimOffset
434                        if ( $updatedK['cellAttrTerminatorSeen'] ) {
435                            $token->dataParsoid->getTemp()->cellAttrTerminatorSeen = true;
436                        }
437                    }
438
439                    $expandedA->k = $expandedK;
440
441                    // Check if we need to deal with the Reparse-KV-string scenario.
442                    // (See documentation comment above.)
443                    //
444                    // Don't incorrectly reparse the kv string for parser functions.
445                    // Ex: "#ifexpr" parser function expects the "=" equality operator.
446                    // We encounter those in "standalone" mode (used to expand
447                    // templated template targets).
448                    if ( $expandedA->v === '' && empty( $this->options['standalone'] ) ) {
449                        // Extract a parsable string from the token array.
450                        // Trim whitespace to ensure tokenizer isn't tripped up
451                        // by the presence of unnecessary whitespace.
452                        $kStr = trim( TokenUtils::tokensToString( $expandedK, false, [
453                            // These tokens haven't been expanded to DOM yet
454                            // so unpacking them here is justifiable
455                            'unpackDOMFragments' => true,
456                        ] ) );
457                        $rule = $nlTkOkay ? 'generic_newline_attributes' : 'table_attributes';
458                        $kvs = str_contains( $kStr, '=' ) ?
459                            $this->tokenizer->tokenizeAs( $kStr, $rule, /* sol */true ) : null;
460                        if ( $kvs ) {
461                            // At this point, templates should have been expanded.
462                            // Returning a template token here probably means that
463                            // when we just converted to string and reparsed, we failed
464                            // to expand the template. This can be particularly bad
465                            // when we make iterative calls to expand template names.
466                            // So, give up template expansion and convert them to strings.
467                            foreach ( $kvs as $kv ) {
468                                $kv->k = self::tplToksToString( $kv->k );
469                                $kv->v = self::tplToksToString( $kv->v );
470
471                                // $kStr is based on running tokensToString on $expandedK.
472                                // So, $kStr might have dropped HTML tags, etc. Given that,
473                                // we can no longer reliably compute offsets for these
474                                // new key/value pairs. We could try to be more smart here,
475                                // but it is not worth the complexity.
476                                $kv->srcOffsets = null;
477                            }
478                            // SSS FIXME: Collect all keys here, not just the first key
479                            // i.e. in a string like {{1x|1=id='v1' title='foo' style='..'}}
480                            // that string is setting attributes for [id, title, style], not just id.
481                            //
482                            // That requires the ability for the data-mw.attribs[i].txt to be an array.
483                            // However, the spec at [[mw:Specs/HTML#Generated_attributes_of_HTML_tags]]
484                            // says:
485                            //
486                            //    "This spec also assumes that a template can only
487                            //     generate one attribute rather than multiple attributes."
488                            //
489                            // So, revision of the spec is another FIXME at which point this code can
490                            // be updated to reflect the revised spec.
491                            $expandedK = $kvs[0]->k;
492                            $reparsedKV = true;
493                            if ( !$newAttrs ) {
494                                $newAttrs = $i === 0 ? [] : array_slice( $expandedAttrs, 0, $i );
495                            }
496                            PHPUtils::pushArray( $newAttrs, $kvs );
497                        }
498                    }
499                }
500
501                // We have a potentially expanded value.
502                // Check if the value came from a template/extension expansion.
503                if ( is_string( $expandedK ) && !str_starts_with( $expandedK, 'mw:' )
504                    && is_array( $oldA->v )
505                ) {
506                    $nlTkPos = self::nlTkIndex( $nlTkOkay, $expandedV, $wrapTemplates );
507                    if ( $nlTkPos !== -1 ) {
508                        // Scenario 1 from the documentation comment above.
509                        $valUsesMixedAttrContentTpl = true;
510                        $updatedV = self::splitTokens(
511                            $this->manager->getFrame(), $token, $nlTkPos,
512                            $expandedV, $wrapTemplates
513                        );
514                        $expandedV = $updatedV['preNLBuf'];
515                        $postNLToks = $updatedV['postNLBuf'];
516                        $metaTokens = $updatedV['metaTokens'];
517                        // We split up this attribute's value into pieces.
518                        if ( $expandedA->srcOffsets->value ) {
519                            $expandedA->srcOffsets->value->end = null;
520                            $expandedA->vsrc = null;
521                        }
522                    } else {
523                        // Maybe scenario 2 from the documentation comment above.
524                        $updatedV = self::stripMetaTags(
525                            $env, $expandedV, $wrapTemplates,
526                            ( $tokenName === 'td' || $tokenName === 'th' )
527                        );
528                        PHPUtils::pushArray( $annotationTypes, $updatedV['annotationType'] );
529                        $expandedV = $updatedV['value'];
530                        // @phan-suppress-next-line PhanTypeInvalidDimOffset
531                        if ( $updatedV['cellAttrTerminatorSeen'] ) {
532                            $token->dataParsoid->getTemp()->cellAttrTerminatorSeen = true;
533                        }
534                    }
535                    $expandedA->v = $expandedV;
536                }
537
538                // Update data-mw to account for templated attributes.
539                // For editability, set HTML property.
540                if ( !empty( $updatedK['hasGeneratedContent'] ) ||
541                    !empty( $updatedV['hasGeneratedContent'] ) ||
542                    ( $reparsedKV && count( $metaTokens ) > 0 )
543                ) {
544                    $key = TokenUtils::tokensToString( $expandedK );
545                    if ( !$tmpDataMW ) {
546                        $tmpDataMW = [];
547                    }
548
549                    // For the $(key|val)UsesMixedAttrContentTpl checks below,
550                    // it is incorrect to assign the HTML for the original wikitext
551                    // string since the content part will get duplicated in both
552                    // this data-mw and in the actual body of the table (for example)
553                    // and cause bugs like T249740.
554                    //
555                    // So, in this case, we assign just the key/value part of the HTML
556                    // ($expandedA->k or $expandedA->v), but we mark it uneditable
557                    // because we cannot really edit just the key/value of the attribute
558                    // on its own because it is only a part of the template's output.
559                    if ( $reparsedKV ) {
560                        // If we encountered a reparse-KV-string scenario,
561                        // we set the value's HTML to [] since we can edit
562                        // the transclusion either via the key's HTML or the
563                        // value's HTML, but not both.
564                        $keyHTML = $keyUsesMixedAttrContentTpl ? $expandedA->k : $origK;
565                        $valHTML = [];
566                    } else {
567                        Assert::invariant( !$keyUsesMixedAttrContentTpl,
568                            "If reparseKV was false, and we had a mixed attr-content template, " .
569                            "we should have landed in the valUsesMixedAttrContentTpl codepath." );
570                        $keyHTML = empty( $updatedK['hasGeneratedContent'] ) ? null : $origK;
571                        $valHTML = $valUsesMixedAttrContentTpl ? $expandedA->v : $origV;
572                    }
573
574                    // FIXME: Ideally we would have called them ktext, khtml, vhtml
575                    // since in the serialized data-mw, the "k" and "v" key strings are dropped.
576                    //    [{ "ktxt":..., "khtml":... }, { "vhtml":... }]
577                    //         is clearer and less confusing than
578                    //    [{ "txt":..., "html":... }, { "html":... }]
579                    $tmpDataMW[$key] = [
580                        // @phan-suppress-next-line PhanCoalescingNeverNullInLoop $expandedA is nullable
581                        'k' => [ 'txt' => $key, 'srcOffsets' => $expandedA->srcOffsets->key ?? null ],
582                        // FIXME: Why is 'txt' missing? Why are we not checking for [] ?
583                        // @phan-suppress-next-line PhanCoalescingNeverNullInLoop $expandedA is nullable
584                        'v' => [ 'html' => $valHTML, 'srcOffsets' => $expandedA->srcOffsets->value ?? null ]
585                    ];
586
587                    if ( $keyHTML !== null ) {
588                        $tmpDataMW[$key]['k']['html'] = $keyHTML;
589                    }
590                    if ( $keyUsesMixedAttrContentTpl ) {
591                        $tmpDataMW[$key]['k']['uneditable'] = true;
592                    }
593                    if ( $valUsesMixedAttrContentTpl ) {
594                        $tmpDataMW[$key]['v']['uneditable'] = true;
595                    }
596                }
597            }
598
599            // Update newAttrs
600            if ( $newAttrs && !$reparsedKV ) {
601                $newAttrs[] = $expandedA;
602            }
603        }
604
605        $token->attribs = $newAttrs ?? $expandedAttrs;
606
607        // If the token already has an about, it already has transclusion/extension
608        // wrapping. No need to record information about templated attributes in addition.
609        //
610        // FIXME: If there is a real use case for extension attributes getting templated,
611        // this check can be relaxed to allow that.
612        // https://gerrit.wikimedia.org/r/#/c/65575 has some reference code that can be used then.
613
614        if ( !$token->getAttributeV( 'about' ) && $tmpDataMW && count( $tmpDataMW ) > 0 ) {
615            // Flatten k-v pairs.
616            $vals = [];
617            foreach ( $tmpDataMW as $obj ) {
618                $vals[] = $obj['k'];
619                $vals[] = $obj['v'];
620            }
621
622            // Clone the vals since they'll be passed to another pipeline
623            // for expanding, which may destructively mutate them in the process.
624            //
625            // This is a problem since subsequent handlers to the
626            // AttributeExpander may interact with the original tokens still
627            // present as attributes of `token`.
628            //
629            // For example, while treebuilding, the object holding dataParsoid
630            // of a token is reused as the data-parsoid attribute of the
631            // corresonding node.  Thus, when we get to the DOM cleanup pass,
632            // unsetting properties changes the token as well.  This was
633            // the issue when an "href" was expanded and then the
634            // ExternalLinkHandler tried to call tokensToString on it,
635            // resulting in a transcluded entity missing its src (which, by the way,
636            // had already been clobered by WrapTemplates, similar to T214241).
637            //
638            // The general principle here being, don't share tokens between
639            // pipelines.
640            $vals = Utils::cloneArray( $vals );
641
642            // Expand all token arrays to DOM.
643            $eVals = PipelineUtils::expandAttrValuesToDOM(
644                $this->env, $this->manager->getFrame(), $vals,
645                $this->options['expandTemplates'],
646                $this->options['inTemplate']
647            );
648
649            // Rebuild flattened k-v pairs.
650            $expAttrs = [];
651            for ( $j = 0;  $j < count( $eVals );  $j += 2 ) {
652                $expAttrs[] = new DataMwAttrib( $eVals[$j], $eVals[$j + 1] );
653            }
654
655            // Mark token as having expanded attrs.
656            //
657            // Template tokens are omitted because the attribute expander is
658            // just being used to resolve the template target.
659            if ( $tokenName !== 'template' ) {
660                $token->addAttribute( 'about', $this->env->newAboutId() );
661                $token->addSpaceSeparatedAttribute( 'typeof', 'mw:ExpandedAttrs' );
662                foreach ( $annotationTypes as $annotationType ) {
663                    $token->addSpaceSeparatedAttribute( 'typeof', 'mw:Annotation/' . $annotationType );
664                }
665                $token->dataMw = new DataMw( [ 'attribs' => $expAttrs ] );
666            }
667        }
668
669        return array_merge( $metaTokens, [ $token ], $postNLToks );
670    }
671
672    /**
673     * Processes any attribute keys and values that are not simple strings.
674     * (Ex: Templated styles)
675     *
676     * @param XMLTagTk $token Token whose attrs being expanded.
677     * @return ?array<string|Token>
678     */
679    private function processComplexAttributes( XMLTagTk $token ): ?array {
680        $expandedAttrs = AttributeTransformManager::process(
681            $this->manager->getFrame(),
682            [
683                'expandTemplates' => $this->options['expandTemplates'],
684                'inTemplate' => $this->options['inTemplate']
685            ],
686            $token->attribs
687        );
688
689        // detection for linting (
690        if ( $token->getName() === 'urllink' || $token->getName() === 'extlink' ) {
691            /** @var null|array<Token> $hrefAttr */
692            $hrefAttr = $token->getAttributeV( "href" );
693            if ( TokenUtils::hasTemplateToken( $hrefAttr ) ) {
694                $token->dataParsoid->getTemp()->linkContainsTemplate = true;
695            }
696        }
697
698        // null signifies unmodified token
699        return $expandedAttrs ? $this->buildExpandedAttrs( $token, $expandedAttrs ) : null;
700    }
701
702    /**
703     * Expand the first attribute of the token -- usually needed to support
704     * tempate tokens where the template target itself is a complex attribute.
705     *
706     * @param XMLTagTK $token Token whose first attribute is being expanded.
707     * @return ?array<string|Token>
708     */
709    public function expandFirstAttribute( XMLTagTk $token ): ?array {
710        $expandedAttrs = AttributeTransformManager::process(
711            $this->manager->getFrame(),
712            [
713                'expandTemplates' => $this->options['expandTemplates'],
714                'inTemplate' => $this->options['inTemplate']
715            ],
716            [ $token->attribs[0] ]
717        );
718        if ( $expandedAttrs ) {
719            return $this->buildExpandedAttrs(
720                $token,
721                array_replace( $token->attribs, [ 0 => $expandedAttrs[0] ] )
722            );
723        } else {
724            // null signifies unmodified token
725            return null;
726        }
727    }
728
729    /** @inheritDoc */
730    public function onCompoundTk( CompoundTk $ctk, TokenHandler $tokensHandler ): ?array {
731        if ( $ctk instanceof EmptyLineTk ) {
732            return null;
733        } else {
734            // default handling: in this case throws exception!
735            return parent::onCompoundTk( $ctk, $tokensHandler );
736        }
737    }
738
739    /**
740     * For tokens that might have complex attributes, this handler processes / expands them.
741     * (Ex: Templated styles)
742     *
743     * @inheritDoc
744     */
745    public function onAny( $token ): ?array {
746        if (
747            !( $token instanceof TagTk || $token instanceof SelfclosingTagTk ) ||
748            !count( $token->attribs )
749        ) {
750            return null;
751        }
752
753        $name = $token->getName();
754        $typeOf = $token->getAttributeV( 'typeof' ) ?? '';
755
756        if (
757            // Do not process dom-fragment tokens: a separate handler deals with them.
758            $name === 'mw:dom-fragment-token' ||
759            // Parsoid generated metas don't need expansion
760            ( $name === 'meta' &&
761                preg_match( '/mw:(Placeholder|Transclusion|Param|Includes)/', $typeOf ) )
762        ) {
763            return null;
764        }
765
766        Assert::invariant(
767            !str_contains( $typeOf, 'mw:ExpandedAttrs' ),
768            "Expanding an already expanded token, that's a no-no."
769        );
770
771        return $this->processComplexAttributes( $token );
772    }
773}