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