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