Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.18% covered (danger)
1.18%
2 / 169
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MockDataAccess
1.18% covered (danger)
1.18%
2 / 169
10.00% covered (danger)
10.00%
1 / 10
2973.87
0.00% covered (danger)
0.00%
0 / 1
 normTitle
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPageInfo
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getFileInfo
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
756
 parseWikitext
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessWikitext
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
156
 fetchTemplateSource
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 fetchTemplateData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logLinterData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 addTrackingCategory
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Mocks;
5
6use Error;
7use Wikimedia\Parsoid\Config\DataAccess;
8use Wikimedia\Parsoid\Config\PageConfig;
9use Wikimedia\Parsoid\Config\PageContent;
10use Wikimedia\Parsoid\Config\SiteConfig;
11use Wikimedia\Parsoid\Core\ContentMetadataCollector;
12use Wikimedia\Parsoid\Core\LinkTarget;
13use Wikimedia\Parsoid\Fragments\LiteralStringPFragment;
14use Wikimedia\Parsoid\Fragments\WikitextPFragment;
15use Wikimedia\Parsoid\ParserTests\MockApiHelper;
16use Wikimedia\Parsoid\Utils\PHPUtils;
17use Wikimedia\Parsoid\Utils\Title;
18use Wikimedia\Parsoid\Utils\TitleValue;
19
20/**
21 * This implements some of the functionality that the tests/ParserTests/MockAPIHelper.php
22 * provides. While originally implemented to support ParserTests, this is no longer used
23 * by parser tests.
24 */
25class MockDataAccess extends DataAccess {
26    private SiteConfig $siteConfig;
27    private array $opts;
28
29    private const PAGE_DATA = [
30        "Main_Page" => [
31            "title" => "Main Page",
32            "pageid" => 1,
33            "ns" => 0,
34            "revid" => 1,
35            "parentid" => 0,
36            'slots' => [
37                'main' => [
38                    'contentmodel' => 'wikitext',
39                    'contentformat' => 'text/x-wiki',
40                    // phpcs:ignore Generic.Files.LineLength.TooLong
41                    '*' => "<strong>MediaWiki has been successfully installed.</strong>\n\nConsult the [//meta.wikimedia.org/wiki/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]"
42                ]
43            ]
44        ],
45        "Junk_Page" => [
46            "title" => "Junk Page",
47            "pageid" => 2,
48            "ns" => 0,
49            "revid" => 2,
50            "parentid" => 0,
51            'slots' => [
52                'main' => [
53                    'contentmodel' => 'wikitext',
54                    'contentformat' => 'text/x-wiki',
55                    '*' => '2. This is just some junk. See the comment above.'
56                ]
57            ]
58        ],
59        "Large_Page" => [
60            "title" => "Large_Page",
61            "pageid" => 3,
62            "ns" => 0,
63            "revid" => 3,
64            "parentid" => 0,
65            'slots' => [
66                'main' => [
67                    'contentmodel' => 'wikitext',
68                    'contentformat' => 'text/x-wiki',
69                    '*' => '', // Will be fixed up in the constructor
70                ]
71            ]
72        ],
73        "Reuse_Page" => [
74            "title" => "Reuse_Page",
75            "pageid" => 100,
76            "ns" => 0,
77            "revid" => 100,
78            "parentid" => 0,
79            'slots' => [
80                'main' => [
81                    'contentmodel' => 'wikitext',
82                    'contentformat' => 'text/x-wiki',
83                    '*' => '{{colours of the rainbow}}'
84                ]
85            ]
86        ],
87        "JSON_page" => [
88            "title" => "JSON_Page",
89            "pageid" => 101,
90            "ns" => 0,
91            "revid" => 101,
92            "parentid" => 0,
93            'slots' => [
94                'main' => [
95                    'contentmodel' => 'json',
96                    'contentformat' => 'text/json',
97                    '*' => '[1]'
98                ]
99            ]
100        ],
101        "Lint_Page" => [
102            "title" => "Lint Page",
103            "pageid" => 102,
104            "ns" => 0,
105            "revid" => 102,
106            "parentid" => 0,
107            'slots' => [
108                'main' => [
109                    'contentmodel' => 'wikitext',
110                    'contentformat' => 'text/x-wiki',
111                    '*' => "{|\nhi\n|ho\n|}"
112                ]
113            ]
114        ],
115        "Redlinks_Page" => [
116            "title" => "Redlinks Page",
117            "pageid" => 103,
118            "ns" => 0,
119            "revid" => 103,
120            "parentid" => 0,
121            'slots' => [
122                'main' => [
123                    'contentmodel' => 'wikitext',
124                    'contentformat' => 'text/x-wiki',
125                    '*' => '[[Special:Version]] [[Doesnotexist]] [[Redirected]]'
126                ]
127            ]
128        ],
129        "Variant_Page" => [
130            "title" => "Variant Page",
131            "pageid" => 104,
132            "ns" => 0,
133            "revid" => 104,
134            "parentid" => 0,
135            'pagelanguage' => 'sr',
136            'pagelanguagedir' => 'ltr',
137            'slots' => [
138                'main' => [
139                    'contentmodel' => 'wikitext',
140                    'contentformat' => 'text/x-wiki',
141                    '*' => "абвг abcd"
142                ]
143            ]
144        ],
145        "No_Variant_Page" => [
146            "title" => "No Variant Page",
147            "pageid" => 105,
148            "ns" => 0,
149            "revid" => 105,
150            "parentid" => 0,
151            'pagelanguage' => 'sr',
152            'pagelanguagedir' => 'ltr',
153            'slots' => [
154                'main' => [
155                    'contentmodel' => 'wikitext',
156                    'contentformat' => 'text/x-wiki',
157                    '*' => "абвг abcd\n__NOCONTENTCONVERT__"
158                ]
159            ]
160        ],
161        "Revision_ID" => [
162            "title" => "Revision ID",
163            "pageid" => 63,
164            "ns" => 0,
165            "revid" => 63,
166            "parentid" => 0,
167            'pagelanguage' => 'sr',
168            'pagelanguagedir' => 'ltr',
169            'slots' => [
170                'main' => [
171                    'contentmodel' => 'wikitext',
172                    'contentformat' => 'text/x-wiki',
173                    '*' => '{{REVISIONID}}'
174                ]
175            ]
176        ],
177        "Redirected" => [
178            "title" => "Revision ID",
179            "pageid" => 63,
180            "ns" => 0,
181            "revid" => 64,
182            "parentid" => 0,
183            "redirect" => true,
184        ],
185        "Disambiguation" => [
186            "title" => "Disambiguation Page",
187            "pageid" => 106,
188            "ns" => 0,
189            "revid" => 106,
190            "parentid" => 0,
191            'slots' => [
192                'main' => [
193                    'contentmodel' => 'wikitext',
194                    'contentformat' => 'text/x-wiki',
195                    '*' => "This is a mock disambiguation page with no more info!"
196                ]
197            ],
198            "linkclasses" => [
199                "mw-disambig",
200            ]
201        ],
202        "User:~2025-1" => [
203            "title" => "~2025-1",
204            "pageid" => 42,
205            "ns" => 0,
206            "revid" => 42,
207            "parentid" => 0,
208            'slots' => [
209                'main' => [
210                    'contentmodel' => 'wikitext',
211                    'contentformat' => 'text/x-wiki',
212                    '*' => "This is a mock temp user page."
213                ]
214            ],
215            "linkclasses" => [
216                "mw-userlink",
217            ],
218            "linkclasses-default" => [
219                "mw-tempuserlink",
220            ]
221        ],
222        "Special:Version" => [
223            "title" => "Version",
224            "pageid" => 107,
225            "ns" => -1,
226            "revid" => 107,
227            "parentid" => 0,
228            'slots' => [
229                'main' => [
230                    'contentmodel' => 'wikitext',
231                    'contentformat' => 'text/x-wiki',
232                    '*' => "This is a mock special page."
233                ]
234            ],
235        ]
236    ];
237
238    // This templatedata description only provides a subset of fields
239    // that mediawiki API returns. Parsoid only uses the format and
240    // paramOrder fields at this point, so keeping these lean.
241    private const TEMPLATE_DATA = [
242        'Template:NoFormatWithParamOrder' => [
243            'paramOrder' => [ 'f0', 'f1', 'unused2', 'f2', 'unused3' ]
244        ],
245        'Template:InlineTplNoParamOrder' => [
246            'format' => 'inline'
247        ],
248        'Template:BlockTplNoParamOrder' => [
249            'format' => 'block'
250        ],
251        'Template:InlineTplWithParamOrder' => [
252            'format' => 'inline',
253            'paramOrder' => [ 'f1', 'f2' ]
254        ],
255        'Template:BlockTplWithParamOrder' => [
256            'format' => 'block',
257            'paramOrder' => [ 'f1', 'f2' ]
258        ],
259        'Template:WithParamOrderAndAliases' => [
260            'params' => [
261                'f1' => [ 'aliases' => [ 'f4', 'f3' ] ]
262            ],
263            'paramOrder' => [ 'f1', 'f2' ]
264        ],
265        'Template:InlineFormattedTpl_1' => [
266            'format' => '{{_|_=_}}'
267        ],
268        'Template:InlineFormattedTpl_2' => [
269            'format' => "\n{{_ | _ = _}}"
270        ],
271        'Template:InlineFormattedTpl_3' => [
272            'format' => '{{_| _____ = _}}'
273        ],
274        'Template:BlockFormattedTpl_1' => [
275            'format' => "{{_\n| _ = _\n}}"
276        ],
277        'Template:BlockFormattedTpl_2' => [
278            'format' => "\n{{_\n| _ = _\n}}\n"
279        ],
280        'Template:BlockFormattedTpl_3' => [
281            'format' => "{{_|\n _____ = _}}"
282        ]
283    ];
284
285    private const FNAMES = [
286        'Image:Foobar.jpg' => 'Foobar.jpg',
287        'File:Foobar.jpg' => 'Foobar.jpg',
288        'Archivo:Foobar.jpg' => 'Foobar.jpg',
289        'Mynd:Foobar.jpg' => 'Foobar.jpg',
290        "Датотека:Foobar.jpg" => 'Foobar.jpg',
291        'Image:Foobar.svg' => 'Foobar.svg',
292        'File:Foobar.svg' => 'Foobar.svg',
293        'File:File_&_file.jpg' => 'File_&_file.jpg',
294        'Image:Thumb.png' => 'Thumb.png',
295        'File:Thumb.png' => 'Thumb.png',
296        'File:LoremIpsum.djvu' => 'LoremIpsum.djvu',
297        'File:Video.ogv' => 'Video.ogv',
298        'File:Audio.oga' => 'Audio.oga',
299        'File:Bad.jpg' => 'Bad.jpg',
300    ];
301
302    private const PNAMES = [
303        'Image:Foobar.jpg' => 'File:Foobar.jpg',
304        'Image:Foobar.svg' => 'File:Foobar.svg',
305        'Image:Thumb.png' => 'File:Thumb.png'
306    ];
307
308    // configuration to match PHP parserTests
309    // Note that parserTests use a MockLocalRepo with
310    // url=>'http://example.com/images' although $wgServer="http://example.org"
311    private const IMAGE_BASE_URL = 'http://example.com/images';
312    private const IMAGE_DESC_URL = self::IMAGE_BASE_URL;
313    private const FILE_PROPS = [
314        'Foobar.jpg' => [
315            'size' => 7881,
316            'width' => 1941,
317            'height' => 220,
318            'bits' => 8,
319            'mime' => 'image/jpeg',
320            'sha1' => '0000000000000000000000000000001', // Wikimedia\base_convert( '1', 16, 36, 31 )
321            'timestamp' => '20010115123500',
322        ],
323        'File_&_file.jpg' => [
324            'size' => 7881,
325            'width' => 1941,
326            'height' => 220,
327            'bits' => 8,
328            'mime' => 'image/jpeg',
329            'sha1' => '0000000000000000000000000000004', // Wikimedia\base_convert( '4', 16, 36, 31 )
330            'timestamp' => '20010115123500',
331        ],
332        'Thumb.png' => [
333            'size' => 22589,
334            'width' => 135,
335            'height' => 135,
336            'bits' => 8,
337            'mime' => 'image/png',
338            'sha1' => '0000000000000000000000000000002', // Wikimedia\base_convert( '2', 16, 36, 31 )
339            'timestamp' => '20130225203040',
340        ],
341        'Foobar.svg' => [
342            'size' => 12345,
343            'width' => 240,
344            'height' => 180,
345            'bits' => 24,
346            'mime' => 'image/svg+xml',
347            'sha1' => null, // Wikimedia\base_convert( '', 16, 36, 31 ) returns false
348            'timestamp' => '20010115123500',
349        ],
350        'Bad.jpg' => [
351            'size' => 12345,
352            'width' => 320,
353            'height' => 240,
354            'bits' => 24,
355            'mime' => 'image/jpeg',
356            'sha1' => '0000000000000000000000000000003', // Wikimedia\base_convert( '3', 16, 36, 31 )
357            'timestamp' => '20010115123500',
358        ],
359        'LoremIpsum.djvu' => [
360            'size' => 3249,
361            'width' => 2480,
362            'height' => 3508,
363            'bits' => 8,
364            'mime' => 'image/vnd.djvu',
365            'sha1' => null, // Wikimedia\base_convert( '', 16, 36, 31 ) returns false
366            'timestamp' => '20010115123600',
367        ],
368        'Video.ogv' => [
369            'size' => 12345,
370            'width' => 320,
371            'height' => 240,
372            'bits' => 0,
373            # duration comes from
374            # TimedMediaHandler/tests/phpunit/mocks/MockOggHandler::getLength()
375            'duration' => 4.3666666666667,
376            'mime' => 'video/ogg; codecs="theora"',
377            'mediatype' => 'VIDEO',
378            'thumbtimes' => [
379                '1.2' => 'seek%3D1.2',
380                '85' => 'seek%3D3.3666666666667', # hard limited by duration
381            ],
382            'sha1' => null, // Wikimedia\base_convert( '', 16, 36, 31 ) returns false
383            'timestamp' => '20010115123500',
384        ],
385        'Audio.oga' => [
386            'size' => 12345,
387            'width' => 0,
388            'height' => 0,
389            'bits' => 0,
390            # duration comes from
391            # TimedMediaHandler/tests/phpunit/mocks/MockOggHandler::getLength()
392            'duration' => 0.99875,
393            'mime' => 'audio/ogg; codecs="vorbis"',
394            'mediatype' => 'AUDIO',
395            'sha1' => null, // Wikimedia\base_convert( '', 16, 36, 31 ) returns false
396            'timestamp' => '20010115123500',
397        ]
398    ];
399
400    /**
401     * @param string|LinkTarget $title
402     * @return string
403     */
404    private function normTitle( $title ): string {
405        if ( !is_string( $title ) ) {
406            $title = Title::newFromLinkTarget(
407                $title, $this->siteConfig
408            );
409            return $title->getPrefixedDBKey();
410        }
411        return strtr( $title, ' ', '_' );
412    }
413
414    /**
415     * @param SiteConfig $siteConfig
416     * @param array $opts
417     */
418    public function __construct( SiteConfig $siteConfig, array $opts ) {
419        $this->siteConfig = $siteConfig;
420        $this->opts = $opts;
421    }
422
423    /** @inheritDoc */
424    public function getPageInfo( $pageConfigOrTitle, array $titles, bool $defaultLinkCaption = false ): array {
425        $ret = [];
426        foreach ( $titles as $title ) {
427            $normTitle = $this->normTitle( $title );
428            $pageData = self::PAGE_DATA[$normTitle] ?? null;
429            if ( $normTitle === 'Large_Page' ) {
430                // Update data of the large page
431                $pageData['slots']['main']['*'] = str_repeat( 'a', $this->opts['maxWikitextSize'] ?? 1000000 );
432            }
433            $linkClasses = $pageData['linkclasses'] ?? [];
434            if ( $defaultLinkCaption ) {
435                $linkClasses = array_merge( $linkClasses, $pageData['linkclasses-default'] ?? [] );
436            }
437            $ret[$title] = [
438                'pageId' => $pageData['pageid'] ?? null,
439                'revId' => $pageData['revid'] ?? null,
440                'missing' => $pageData === null,
441                'known' => $pageData !== null,
442                'redirect' => $pageData['redirect'] ?? false,
443                'linkclasses' => $linkClasses,
444            ];
445        }
446
447        return $ret;
448    }
449
450    /** @inheritDoc */
451    public function getFileInfo( PageConfig $pageConfig, array $files ): array {
452        $ret = [];
453        foreach ( $files as $f ) {
454            $name = $f[0];
455            $dims = $f[1];
456
457            // From mockAPI.js
458            $normFileName = self::FNAMES[$name] ?? $name;
459            $props = self::FILE_PROPS[$normFileName] ?? null;
460            if ( $props === null ) {
461                // We don't have info for this file
462                $ret[] = null;
463                continue;
464            }
465
466            $md5 = md5( $normFileName );
467            $md5prefix = $md5[0] . '/' . $md5[0] . $md5[1] . '/';
468            $baseurl = self::IMAGE_BASE_URL . '/' . $md5prefix . $normFileName;
469            $height = $props['height'] ?? 220;
470            $width = $props['width'] ?? 1941;
471            $turl = self::IMAGE_BASE_URL . '/thumb/' . $md5prefix . $normFileName;
472            $durl = self::IMAGE_DESC_URL . '/' . $normFileName;
473            $mediatype = $props['mediatype'] ??
474                ( $props['mime'] === 'image/svg+xml' ? 'DRAWING' : 'BITMAP' );
475
476            $info = [
477                'size' => $props['size'] ?? 12345,
478                'height' => $height,
479                'width' => $width,
480                'url' => $baseurl,
481                'descriptionurl' => $durl,
482                'mediatype' => $mediatype,
483                'mime' => $props['mime'],
484                'badFile' => ( $normFileName === 'Bad.jpg' ),
485                'sha1' => $props['sha1'],
486                'timestamp' => $props['timestamp'],
487            ];
488
489            if ( isset( $props['duration'] ) ) {
490                $info['duration'] = $props['duration'];
491            }
492
493            // See Config/Api/DataAccess.php
494            $txopts = [
495                'width' => null,
496                'height' => null,
497            ];
498            if ( isset( $dims['width'] ) && $dims['width'] !== null ) {
499                $txopts['width'] = $dims['width'];
500                if ( isset( $dims['page'] ) ) {
501                    $txopts['page'] = $dims['page'];
502                }
503                if ( isset( $dims['lang'] ) ) {
504                    $txopts['lang'] = $dims['lang'];
505                }
506            }
507            if ( isset( $dims['height'] ) && $dims['height'] !== null ) {
508                $txopts['height'] = $dims['height'];
509            }
510            if ( isset( $dims['seek'] ) ) {
511                $txopts['thumbtime'] = $dims['seek'];
512            }
513
514            // From mockAPI.js
515            if ( $mediatype === 'VIDEO' && empty( $txopts['height'] ) && empty( $txopts['width'] ) ) {
516                $txopts['width'] = $width;
517                $txopts['height'] = $height;
518            }
519
520            if ( !empty( $txopts['height'] ) || !empty( $txopts['width'] ) ) {
521
522                // Set $txopts['width'] and $txopts['height']
523                $rtwidth = &$txopts['width'];
524                $rtheight = &$txopts['height'];
525                MockApiHelper::transformHelper( $width, $height, $rtwidth, $rtheight );
526
527                $urlWidth = $txopts['width'];
528                if ( $txopts['width'] > $width ) {
529                    // The PHP api won't enlarge a bitmap ... but the batch api will.
530                    // But, to match the PHP sections, don't scale.
531                    if ( $mediatype !== 'DRAWING' ) {
532                        $urlWidth = $width;
533                    }
534                }
535                if ( $urlWidth !== $width || $mediatype === 'AUDIO' || $mediatype === 'VIDEO' ) {
536                    $turl .= '/' . $urlWidth . 'px-';
537                    if ( $mediatype === 'VIDEO' ) {
538                        // Hack in a 'seek' option, if provided (T258767)
539                        if ( isset( $txopts['thumbtime'] ) ) {
540                            $turl .= $props['thumbtimes'][strval( $txopts['thumbtime'] )] ?? '';
541                        }
542                        $turl .= '-';
543                    }
544                    $turl .= $normFileName;
545                    switch ( $mediatype ) {
546                        case 'AUDIO':
547                            // No thumbs are generated for audio
548                            $turl = self::IMAGE_BASE_URL . '/w/resources/assets/file-type-icons/fileicon-ogg.png';
549                            break;
550                        case 'VIDEO':
551                            $turl .= '.jpg';
552                            break;
553                        case 'DRAWING':
554                            $turl .= '.png';
555                            break;
556                    }
557                } else {
558                    $turl = $baseurl;
559                }
560                $info['thumbwidth'] = $txopts['width'];
561                $info['thumbheight'] = $txopts['height'];
562                $info['thumburl'] = $turl;
563            }
564
565            $ret[] = $info;
566        }
567
568        return $ret;
569    }
570
571    /** @inheritDoc */
572    public function parseWikitext(
573        PageConfig $pageConfig,
574        ContentMetadataCollector $metadata,
575        string $wikitext
576    ): string {
577        // Render to html the contents of known extension tags
578        preg_match( '#<([A-Za-z][^\t\n\v />\0]*)#', $wikitext, $match );
579        return match ( $match[1] ) {
580            'templatestyles' => "<style data-mw-deduplicate='TemplateStyles:r123456'>" .
581                'small { font-size: 120% } big { font-size: 80% }</style>',
582            'translate' => $wikitext,
583            'indicator',
584            'section' => '',
585            'math' => '<math xmlns="http://www.w3.org/1998/Math/MathML">' .
586                '<mrow data-mjx-texclass="ORD"><mstyle displaystyle="true" scriptlevel="0">' .
587                '<mi>x</mi></mstyle></mrow></math>',
588            default => throw new Error( 'Unhandled extension type encountered in: ' . $wikitext )
589        };
590    }
591
592    /** @inheritDoc */
593    public function preprocessWikitext(
594        PageConfig $pageConfig,
595        ContentMetadataCollector $metadata,
596        $wikitext
597    ) {
598        $revid = $pageConfig->getRevisionId();
599
600        if ( !is_string( $wikitext ) ) {
601            // Flatten fragments into wikitext
602            $wikitext = $wikitext->killMarkers();
603        }
604        $expanded = str_replace( '{{!}}', '|', $wikitext );
605        preg_match( '/{{1x\|(.*?)}}/s', $expanded, $match1 );
606        preg_match( '/{{#tag:(.*?)\|(.*?)(?:\|(.*?))?}}/s', $expanded, $match2 );
607
608        if ( $match1 ) {
609            $ret = $match1[1];
610        } elseif ( $match2 ) {
611            $attrs = isset( $match2[3] ) ? ' ' . $match2[3] : '';
612            $ret = "<{$match2[1]}{$attrs}>{$match2[2]}</{$match2[1]}>";
613        } elseif ( $wikitext === '{{colours of the rainbow}}' ) {
614            $ret = 'purple';
615        } elseif ( $wikitext === '{{REVISIONID}}' ) {
616            $ret = (string)$revid;
617        } elseif ( $wikitext === '{{pretest}}' ) {
618            $ret = '<pre format="wikitext">{{1x|test}}</pre>';
619        } elseif ( $wikitext === '{{pretest2}}' ) {
620            $ret = '<pre format="wikitext">{{<div>test</div>|123}}</pre>';
621        } elseif ( $wikitext === '{{mangle}}' ) {
622            $ret = 'hi';
623            $metadata->addCategory(
624                Title::newFromText( 'Category:Mangle', $this->siteConfig ),
625                'ho'
626            );
627        } elseif ( $wikitext === '{{template with ext tag}}' ) {
628            $ret = '<gallery>File:{{{1|some}}}-{{{2|name}}}.jpg</gallery>';
629        } elseif ( $wikitext === '{{loop}}' ) {
630            $lit = LiteralStringPFragment::newFromLiteral( 'meh', null );
631            $wt = '{{loop}}';
632            return WikitextPFragment::newFromSplitWt( [ $lit, $wt ] );
633        } else {
634            $ret = '';
635        }
636
637        return WikitextPFragment::newFromWt( $ret, null );
638    }
639
640    /** @inheritDoc */
641    public function fetchTemplateSource(
642        PageConfig $pageConfig, LinkTarget $title
643    ): ?PageContent {
644        $normTitle = $this->normTitle( $title );
645        $pageData = self::PAGE_DATA[$normTitle] ?? null;
646        if ( $pageData ) {
647            $content = [];
648            foreach ( $pageData['slots'] as $role => $data ) {
649                $content['role'] = $data['*'];
650            }
651            $revid = $pageData['revid'];
652            return new MockPageContent( $content, $title, $revid );
653        } else {
654            return null;
655        }
656    }
657
658    /** @inheritDoc */
659    public function fetchTemplateData( PageConfig $pageConfig, LinkTarget $title ): ?array {
660        return self::TEMPLATE_DATA[$this->normTitle( $title )] ?? null;
661    }
662
663    /** @inheritDoc */
664    public function logLinterData(
665        PageConfig $pageConfig, array $lints
666    ): void {
667        foreach ( $lints as $l ) {
668            error_log( PHPUtils::jsonEncode( $l ) );
669        }
670    }
671
672    private const TRACKING_CATEGORIES = [
673        'broken-file-category' => 'Pages with broken file links',
674        'magiclink-tracking-rfc' => 'Pages using RFC magic links',
675        'magiclink-tracking-isbn' => 'Pages using ISBN magic links',
676        'magiclink-tracking-pmid' => 'Pages using PMID magic links',
677        'hidden-category-category' => 'Hidden categories',
678        'media-limit-reached' => 'Pages where media limit was reached',
679    ];
680
681    /** @inheritDoc */
682    public function addTrackingCategory(
683        PageConfig $pageConfig,
684        ContentMetadataCollector $metadata,
685        string $key
686    ): void {
687        if ( !isset( self::TRACKING_CATEGORIES[$key] ) ) {
688            throw new Error( 'Unknown tracking category: ' . $key );
689        }
690        $tv = TitleValue::tryNew(
691            14, // NS_CATEGORY,
692            self::TRACKING_CATEGORIES[$key]
693        );
694        $metadata->addCategory( $tv );
695    }
696}