Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.18% covered (warning)
68.18%
195 / 286
42.11% covered (danger)
42.11%
8 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageRenderingHandler
68.18% covered (warning)
68.18%
195 / 286
42.11% covered (danger)
42.11%
8 / 19
392.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onSkinTemplateNavigation__Universal
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
20
 appendOrReplaceQueryParams
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 onHtmlPageLinkRendererEnd
84.13% covered (warning)
84.13%
53 / 63
0.00% covered (danger)
0.00%
0 / 1
28.70
 matchRepoAndAbstractRoutes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 onWebRequestPathInfoRouter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforePageDisplay
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 onBeforeDisplayNoArticleText
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 showMissingObject
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 onGetMagicVariableIDs
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 onParserGetVariableValueSwitch
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 onSpecialStatsAddExtra
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 onParserFirstCallInit
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 parserFunctionWikifunctionLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parserFunctionWikifunctionLabelAndDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderParserFunctionWikifunction
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
8
 fetchAbstractModeLabel
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 fetchRepoModeLabel
47.06% covered (danger)
47.06%
8 / 17
0.00% covered (danger)
0.00%
0 / 1
11.34
 onSkinPageReadyConfig
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * WikiLambda handler for hooks which alter page rendering
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\HookHandler;
12
13use MediaWiki\Config\Config;
14use MediaWiki\Context\IContextSource;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractContentUtils;
17use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
18use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
19use MediaWiki\Extension\WikiLambda\ZObjectContent;
20use MediaWiki\Extension\WikiLambda\ZObjectStore;
21use MediaWiki\Extension\WikiLambda\ZObjectUtils;
22use MediaWiki\Html\Html;
23use MediaWiki\Language\LanguageFactory;
24use MediaWiki\Language\LanguageNameUtils;
25use MediaWiki\Linker\LinkRenderer;
26use MediaWiki\Linker\LinkTarget;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Output\OutputPage;
29use MediaWiki\Page\Article;
30use MediaWiki\Parser\Parser;
31use MediaWiki\Parser\PPFrame;
32use MediaWiki\Registration\ExtensionRegistry;
33use MediaWiki\ResourceLoader\Context as ResourceLoaderContext;
34use MediaWiki\Skin\Skin;
35use MediaWiki\Title\Title;
36use MediaWiki\User\Options\UserOptionsLookup;
37use OutOfBoundsException;
38use Wikimedia\HtmlArmor\HtmlArmor;
39
40class PageRenderingHandler implements
41    \MediaWiki\Skin\Hook\SkinTemplateNavigation__UniversalHook,
42    \MediaWiki\Hook\WebRequestPathInfoRouterHook,
43    \MediaWiki\Output\Hook\BeforePageDisplayHook,
44    \MediaWiki\Page\Hook\BeforeDisplayNoArticleTextHook,
45    \MediaWiki\Parser\Hook\GetMagicVariableIDsHook,
46    \MediaWiki\Parser\Hook\ParserFirstCallInitHook,
47    \MediaWiki\Parser\Hook\ParserGetVariableValueSwitchHook,
48    \MediaWiki\Specials\Hook\SpecialStatsAddExtraHook,
49    \MediaWiki\Skin\Hook\SkinPageReadyConfigHook
50{
51    private UserOptionsLookup $userOptionsLookup;
52    private LanguageNameUtils $languageNameUtils;
53    private LanguageFactory $languageFactory;
54
55    public function __construct(
56        private readonly Config $config,
57        UserOptionsLookup $userOptionsLookup,
58        LanguageNameUtils $languageNameUtils,
59        LanguageFactory $languageFactory,
60        private readonly ZObjectStore $zObjectStore
61    ) {
62        $this->userOptionsLookup = $userOptionsLookup;
63        $this->languageNameUtils = $languageNameUtils;
64        $this->languageFactory = $languageFactory;
65    }
66
67    /**
68     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinTemplateNavigation::Universal
69     *
70     * @inheritDoc
71     */
72    public function onSkinTemplateNavigation__Universal( $skinTemplate, &$links ): void {
73        // We only do this in repo or abstract mode
74        if (
75            !$this->config->get( 'WikiLambdaEnableRepoMode' ) &&
76            !$this->config->get( 'WikiLambdaEnableAbstractMode' )
77        ) {
78            return;
79        }
80
81        // For any page in repo or abstract mode: Add a language control, for users to navigate to another language.
82        // TODO (T362235): This only works for browsers with Javascript. The button is invisible
83        // until the ext.wikilambda.languageselector module creates the Vue component to replace
84        // it; instead, render this Codex component properly server-side somehow.
85        $ourButton = [ 'wikifunctions-language' => [
86            'button' => true,
87            'id' => 'ext-wikilambda-language-selector',
88            'text' => '',
89            'active' => false,
90            'link-class' => [ 'wikifunctions-trigger' ],
91            'href' => '#'
92        ] ];
93        $links['user-interface-preferences'] = $ourButton + $links['user-interface-preferences'];
94
95        // The rest of this function is about rewriting skin links on ZObject or Abstract pages
96        $targetTitle = $skinTemplate->getRelevantTitle();
97        if ( !$targetTitle ) {
98            // Nothing to do, exit.
99            return;
100        }
101
102        // Get current langugae from the Skin, which already returns the language set
103        // by the Request Context, which prioritizes language settings like this:
104        // * if there's presence of uselang, that takes priority
105        // * if no language is specified by the url, falls to the user logged in language
106        // * if nothing specified, falls back to the site content language
107        $lang = $skinTemplate->getLanguage()->getCode();
108
109        // If the page is a Talk page of a ZObject or Abstract content
110        // rewrite the view page with the canonical url
111        if ( $targetTitle->isTalkPage() ) {
112            $subjectPage = $targetTitle->getSubjectPage();
113
114            if (
115                $subjectPage->hasContentModel( CONTENT_MODEL_ZOBJECT ) ||
116                $subjectPage->hasContentModel( CONTENT_MODEL_ABSTRACT )
117            ) {
118                $subjectId = $subjectPage->getNamespaceKey( '' );
119
120                if ( isset( $links['associated-pages' ] ) && isset( $links['associated-pages'][$subjectId] ) ) {
121                    $subjectPrefixedTitle = $subjectPage->getPrefixedDBkey();
122                    $subjectCanonicalViewLink = '/view/' . $lang . '/' . $subjectPrefixedTitle;
123                    $links['associated-pages'][$subjectId]['href'] = $subjectCanonicalViewLink;
124                }
125            }
126
127            // Nothing else to do, exit.
128            return;
129        }
130
131        // Determine the content model
132        $isAbstractContent = $targetTitle->exists() &&
133            $targetTitle->hasContentModel( CONTENT_MODEL_ABSTRACT );
134
135        $isZObjectContent = $targetTitle->exists() &&
136            $targetTitle->hasContentModel( CONTENT_MODEL_ZOBJECT );
137
138        if ( !$isZObjectContent && !$isAbstractContent ) {
139            // Nothing to do, exit.
140            return;
141        }
142
143        // Don't show a "View source" link, it's meaningless for our content type
144        unset( $links['views']['viewsource'] );
145
146        // Don't show a "Variants" selector even though we're futzing with lang, we have our own control
147        $links['variants'] = [];
148
149        // Determine the page title based on the content model.
150        // ZObject content uses, 'ZID', while Abstract uses 'Abstract_Wikipedia:QID'.
151        $prefixedTitle = $targetTitle->getPrefixedDBkey();
152
153        // (T360229) Build GET parameters using an array and append them with
154        // `wfArrayToCgi` rather than hacking inline
155        $langParams = [ 'uselang' => $lang ];
156
157        // Append uselang to the 'history' link:
158        $links['views']['history']['href'] = $this->appendOrReplaceQueryParams(
159            $links['views']['history']['href'],
160            $langParams
161        );
162
163        // Append uselang to the 'edit' link (if it exists):
164        if ( array_key_exists( 'edit', $links['views'] ) ) {
165            $links['views']['edit']['href'] = $this->appendOrReplaceQueryParams(
166                $links['views']['edit']['href'],
167                $langParams
168            );
169        }
170
171        $isMain = $targetTitle->inNamespace( NS_MAIN );
172        $pageKey = mb_strtolower( $prefixedTitle );
173
174        // Rewrite the 'view' link to the canonical syntax: /view/<lang>/<title>:
175        // * View link in the right navigation bar
176        // * View link in the associated pages navigaton bar (if any)
177        $viewPageKey = $isMain ? 'main' : explode( ':', $pageKey, 2 )[0];
178        $canonicalViewLink = '/view/' . $lang . '/' . $prefixedTitle;
179        $links['views']['view']['href'] = $canonicalViewLink;
180        if ( isset( $links['associated-pages' ] ) && isset( $links['associated-pages'][$viewPageKey] ) ) {
181            $links['associated-pages'][$viewPageKey]['href'] = $canonicalViewLink;
182        }
183
184        // Append uselang to the 'talk' link
185        $talkPageKey = $isMain ? 'talk' : $viewPageKey . '_talk';
186        if ( isset( $links[ 'associated-pages'] ) && isset( $links['associated-pages'][$talkPageKey] ) ) {
187            $links['associated-pages'][$talkPageKey]['href'] = $this->appendOrReplaceQueryParams(
188                $links['associated-pages'][$talkPageKey]['href'],
189                $langParams
190            );
191        }
192    }
193
194    /**
195     * Given a url with or without query parameters, append or replace them with
196     * the given set of parameters and their new values and return the new url.
197     *
198     * E.g. given an url like title?action=edit&uselang=fr and the parameters
199     * [ uselang => es, foo => bar ], it appends foo and overwrites uselang,
200     * returning the url title?action=edit&uselang=es&foo=bar
201     *
202     * @param string $url
203     * @param array $queryParams
204     * @return string
205     */
206    private function appendOrReplaceQueryParams( string $url, array $queryParams ): string {
207        if ( str_contains( $url, '?' ) ) {
208            [ $url, $queryString ] = explode( '?', $url, 2 );
209            $query = wfCgiToArray( $queryString );
210        } else {
211            $query = [];
212        }
213        foreach ( $queryParams as $paramName => $paramValue ) {
214            $query[ $paramName ] = $paramValue;
215        }
216        return wfAppendQuery( $url, $query );
217    }
218
219    /**
220     * @see https://www.mediawiki.org/wiki/Manual:Hooks/HtmlPageLinkRendererEnd
221     *
222     * @param LinkRenderer $linkRenderer
223     * @param LinkTarget $linkTarget
224     * @param bool $isKnown
225     * @param string|HtmlArmor &$text
226     * @param string[] &$attribs
227     * @param string &$ret
228     * @return bool|void
229     */
230    public function onHtmlPageLinkRendererEnd(
231        $linkRenderer, $linkTarget, $isKnown, &$text, &$attribs, &$ret
232    ) {
233        if (
234            !$this->config->get( 'WikiLambdaEnableRepoMode' ) &&
235            !$this->config->get( 'WikiLambdaEnableAbstractMode' )
236        ) {
237            return;
238        }
239
240        // (T343483) We only do this work on special pages, like Special:Watchlist; we don't want to mess with the
241        // wikitext content, partially because Parsoid-rendered HTML is incompatible with this hook.
242        $context = RequestContext::getMain();
243        if ( !$context->hasTitle() ) {
244            return;
245        }
246
247        // Convert the slimline LinkTarget into a full-fat Title so we can ask deeper questions
248        $targetTitle = Title::newFromLinkTarget( $linkTarget );
249
250        $entityId = $targetTitle->getBaseText();
251
252        if (
253            // Do nothing if the target isn't one of ours
254        !(
255            // Is this a ZObject?
256            (
257                $targetTitle->inNamespace( NS_MAIN ) &&
258                $targetTitle->hasContentModel( CONTENT_MODEL_ZOBJECT ) &&
259                ZObjectUtils::isValidZObjectReference( $entityId )
260            )
261            // Is this an Abstract Article?
262            || (
263                array_key_exists( $targetTitle->getNamespace(),
264                    $this->config->get( 'WikiLambdaAbstractNamespaces' ) ) &&
265                $targetTitle->hasContentModel( CONTENT_MODEL_ABSTRACT ) &&
266                AbstractContentUtils::isValidAbstractWikiTitle( $entityId )
267            )
268        )
269        ) {
270            return;
271        }
272
273        $context = RequestContext::getMain();
274        $currentPageContentLanguageCode = $context->getLanguage()->getCode();
275        // (T357702) If we don't know the language code, fall back to Z1002/'en'
276        if (
277            // If we're in repo mode, is this a known ZLanguage?
278            (
279                // (T423515) LangRegistry can only be use in repo mode, as it relies on the ZObjectStore / DB
280                $this->config->get( 'WikiLambdaEnableRepoMode' ) &&
281                !ZLangRegistry::singleton()->isLanguageKnownGivenCode( $currentPageContentLanguageCode )
282            ) ||
283            // If we're not, just use MediaWiki's language support check
284            !MediaWikiServices::getInstance()->getLanguageNameUtils()
285                ->isSupportedLanguage( $currentPageContentLanguageCode )
286        ) {
287            $currentPageContentLanguageCode = 'en';
288        }
289
290        // Re-write our path to include the content language ($attribs['href']) where appropriate
291        // HACK (T358789): Our hook doesn't tell us properly that the target is an action, so we have to pull it from
292        // the href — we could get $query from HtmlPageLinkRendererBefore, but then we don't have access to the href
293        $queryPos = strpos( $attribs['href'], '?' );
294        $query = $queryPos ? wfCgiToArray( substr( $attribs['href'], $queryPos + 1 ) ) : [];
295
296        $action = $query['action'] ?? 'view';
297        if ( $action !== 'view' ) {
298            $attribs['href'] = '/wiki/' . $entityId . '?action=' . $action . '&uselang='
299                . $currentPageContentLanguageCode . '&';
300        } elseif ( !isset( $query[ 'diff'] ) && !isset( $query['oldid'] ) ) {
301            if ( $targetTitle->getNamespace() !== NS_MAIN ) {
302                $attribs['href'] = '/view/' . $currentPageContentLanguageCode . '/'
303                    . $targetTitle->getNsText() . ':' . $entityId . '?';
304            } else {
305                $attribs['href'] = '/view/' . $currentPageContentLanguageCode . '/' . $entityId . '?';
306            }
307        } else {
308            $attribs['href'] = '/wiki/' . $entityId . '?uselang=' . $currentPageContentLanguageCode . '&';
309        }
310
311        unset( $query['action'] );
312        unset( $query['title'] );
313
314        foreach ( $query as $key => $value ) {
315            $attribs['href'] .= $key . '=' . $value . '&';
316        }
317        $attribs['href'] = substr( $attribs['href'], 0, -1 );
318
319        // **After this point, the only changes we're making are to the label ($text)**
320
321        // (T342212) Wrap our ZID in an LTR-enforced <span> so it works OK in RTL environments
322        $bidiWrappedEntityId = '<span dir="ltr">' . $entityId . '</span>';
323
324        // Special handling for unknown (red) links; we want to add the wrapped ZID but don't want to try to fetch
325        // the label, which will fail
326        if ( !$isKnown && $text === $entityId ) {
327            $text = new HtmlArmor( $bidiWrappedEntityId );
328            return;
329        }
330
331        // We don't re-write the label if the label is already set (e.g. for "prev" and "cur" and revision links on
332        // history pages, or inline links like [[Z1|this]]); we do however continue for
333        if ( $text !== null && $targetTitle->getFullText() !== HtmlArmor::getHtml( $text ) ) {
334            return;
335        }
336
337        if ( $targetTitle->hasContentModel( CONTENT_MODEL_ABSTRACT ) ) {
338            $label = $this->fetchAbstractModeLabel( $entityId, $currentPageContentLanguageCode );
339        } elseif ( $targetTitle->hasContentModel( CONTENT_MODEL_ZOBJECT ) ) {
340            $label = $this->fetchRepoModeLabel( $entityId, $currentPageContentLanguageCode, $targetTitle, $context );
341        } else {
342            // If neither mode is enabled, we shouldn't be here, but just in case, don't do anything
343            return;
344        }
345        // If we've got got a label back for any reason, just fall back to the ZID or QID.
346        if ( $label === null ) {
347            return;
348        }
349
350        // Finally, set the label of the link to the *un*escaped user-supplied label, see
351        // https://www.mediawiki.org/wiki/Manual:Hooks/HtmlPageLinkRendererEnd
352        //
353        // &$text: the contents that the <a> tag should have; either a *plain, unescaped string* or a HtmlArmor object.
354        //
355        $text = new HtmlArmor(
356            htmlspecialchars( $label )
357                . $context->msg( 'word-separator' )->escaped()
358                . $context->msg( 'parentheses', [ $bidiWrappedEntityId ] )
359        );
360    }
361
362    /**
363     * Uses both Repo/Abstract flag configuration and $1 matching
364     * to determine the route.
365     * * E.g. /view/en/Z111 and Repo enabled takes to Special:ViewObject/en/Z111
366     * * E.g. /view/en/Q222 and Abstract enabled takes to Special:ViewAbstract/en/Q222
367     * * E.g. /view/en/Namespace:Q222 and Abstract enabled takes to Special:ViewAbstract/en/Namespace:Q222
368     *
369     * @param array &$matches
370     * @param array $data
371     * @return bool
372     */
373    public function matchRepoAndAbstractRoutes( array &$matches, array $data ): bool {
374        $lang = $data[ '$2' ];
375        $id = $data[ '$1' ];
376
377        if (
378            $this->config->get( 'WikiLambdaEnableRepoMode' ) &&
379            ZObjectUtils::isValidZObjectReference( $id )
380        ) {
381            $matches['title'] = "Special:ViewObject/$lang/$id";
382            return true;
383        }
384
385        if (
386            $this->config->get( 'WikiLambdaEnableAbstractMode' ) &&
387            AbstractContentUtils::isValidAbstractWikiTitle( $id )
388        ) {
389            $matches['title'] = "Special:ViewAbstract/$lang/$id";
390            return true;
391        }
392
393        return false;
394    }
395
396    /**
397     * @inheritDoc
398     */
399    public function onWebRequestPathInfoRouter( $router ) {
400        $router->add(
401            '/view/$2/$1',
402            [ 'title' => false ],
403            [ 'callback' => [ $this, 'matchRepoAndAbstractRoutes' ] ],
404        );
405    }
406
407    /**
408     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
409     *
410     * @param OutputPage $out
411     * @param Skin $skin
412     * @return void
413     */
414    public function onBeforePageDisplay( $out, $skin ): void {
415        // We only do the rest in repo mode
416        // Note: Search client is registered via SkinPageReadyConfig hook, not here
417        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
418            return;
419        }
420
421        // Save language name in global variables, needed for language selector module
422        $userLang = $out->getLanguage();
423        $userLangName = $this->languageNameUtils->getLanguageName( $userLang->getCode() );
424        $out->addJsConfigVars( 'wgUserLanguageName', $userLangName );
425
426        // Add language selector module to all pages
427        $out->addModules( 'ext.wikilambda.languageselector' );
428
429        // Add references module to all pages to enable references on all pages
430        $out->addModuleStyles( 'ext.wikilambda.references.styles' );
431        $out->addModules( 'ext.wikilambda.references' );
432    }
433
434    /**
435     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeDisplayNoArticleText
436     *
437     * @param Article $article
438     * @return bool
439     */
440    public function onBeforeDisplayNoArticleText( $article ): bool {
441        // We only do this in repo mode
442        // TODO (T411705): Add this for AbstractContent pages too, once we have an edit page for them
443        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
444            return true;
445        }
446
447        $title = $article->getTitle();
448        $zid = $title->getBaseText();
449
450        // ignore if the article is not a z object
451        if (
452            !$title->inNamespace( NS_MAIN )
453            || !ZObjectUtils::isValidZObjectReference( $zid )
454        ) {
455            return true;
456        }
457
458        $context = $article->getContext();
459
460        $this->showMissingObject( $context );
461
462        return false;
463    }
464
465    /**
466     * T342965: Show a message on object pages that don't have a result.
467     * @param IContextSource $context
468     *
469     * @return void
470     */
471    private function showMissingObject( $context ): void {
472        $text = wfMessage( 'wikilambda-noobject' )->setContext( $context )->plain();
473
474        $dir = $context->getLanguage()->getDir();
475        $lang = $context->getLanguage()->getHtmlCode();
476
477        $outputPage = $context->getOutput();
478        $outputPage->addWikiTextAsInterface( Html::rawElement( 'div', [
479            'class' => "noarticletext mw-content-$dir",
480            'dir' => $dir,
481            'lang' => $lang,
482        ], "\n$text\n" ) );
483    }
484
485    /**
486     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetMagicVariableIDs
487     *
488     * @param string[] &$variableIDs
489     * @return void
490     */
491    public function onGetMagicVariableIDs( &$variableIDs ): void {
492        // We only do this in repo mode
493        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
494            return;
495        }
496
497        $variableIDs[] = 'magic_count_all';
498        $variableIDs[] = 'magic_count_functions';
499        $variableIDs[] = 'magic_count_implementations';
500        $variableIDs[] = 'magic_count_testers';
501        $variableIDs[] = 'magic_count_types';
502        $variableIDs[] = 'magic_count_languages';
503    }
504
505    /**
506     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ParserGetVariableValueSwitch
507     *
508     * @param Parser $parser
509     * @param array &$variableCache
510     * @param string $magicWordId
511     * @param ?string &$ret
512     * @param PPFrame|false $frame
513     * @return bool|void
514     */
515    public function onParserGetVariableValueSwitch( $parser, &$variableCache, $magicWordId, &$ret, $frame ) {
516        // We only do this in repo mode
517        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
518            return true;
519        }
520
521        $matchedType = match ( $magicWordId ) {
522            'magic_count_all' => ZTypeRegistry::Z_OBJECT,
523            'magic_count_functions' => ZTypeRegistry::Z_FUNCTION,
524            'magic_count_implementations' => ZTypeRegistry::Z_IMPLEMENTATION,
525            'magic_count_testers' => ZTypeRegistry::Z_TESTER,
526            'magic_count_types' => ZTypeRegistry::Z_TYPE,
527            'magic_count_languages' => ZTypeRegistry::Z_LANGUAGE,
528            default => null,
529        };
530
531        if ( $matchedType === null ) {
532            // Unknown magic word, do nothing
533            return;
534        }
535
536        $ret = $this->zObjectStore->getCountOfTypeInstances( $matchedType );
537
538        // Speed-optimisation: cache the value for calls to the same variable in the same request
539        $variableCache[$magicWordId] = $ret;
540
541        // Permit future callbacks to run for this hook (e.g. other extensions' code).
542        return true;
543    }
544
545    /**
546     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SpecialStatsAddExtra
547     *
548     * @param array &$extraStats
549     * @param IContextSource $context
550     */
551    public function onSpecialStatsAddExtra( &$extraStats, $context ) {
552        // We only do this in repo mode
553        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
554            return;
555        }
556
557        $contentLanguage = MediaWikiServices::getInstance()->getContentLanguage();
558
559        $extraStats['wikilambda-statistics-header'] = [
560            'wikilambda-statistics-label-allobjects' => $contentLanguage->formatNum(
561                $this->zObjectStore->getCountOfTypeInstances( ZTypeRegistry::Z_OBJECT )
562            ),
563            'wikilambda-statistics-label-types' => $contentLanguage->formatNum(
564                $this->zObjectStore->getCountOfTypeInstances( ZTypeRegistry::Z_TYPE )
565            ),
566            'wikilambda-statistics-label-languages' => $contentLanguage->formatNum(
567                $this->zObjectStore->getCountOfTypeInstances( ZTypeRegistry::Z_LANGUAGE )
568            ),
569            'wikilambda-statistics-label-functions' => $contentLanguage->formatNum(
570                $this->zObjectStore->getCountOfTypeInstances( ZTypeRegistry::Z_FUNCTION )
571            ),
572            'wikilambda-statistics-label-implementations' => $contentLanguage->formatNum(
573                $this->zObjectStore->getCountOfTypeInstances( ZTypeRegistry::Z_IMPLEMENTATION )
574            ),
575            'wikilambda-statistics-label-testers' => $contentLanguage->formatNum(
576                $this->zObjectStore->getCountOfTypeInstances( ZTypeRegistry::Z_TESTER )
577            ),
578        ];
579    }
580
581    /** @inheritDoc */
582    public function onParserFirstCallInit( $parser ) {
583        // We only do this in repo mode
584        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
585            return;
586        }
587        // Provide {{#wikifunctionlabel:Z1234|en}} that will render a link to the ZObject with its label
588        $parser->setFunctionHook( 'wikifunctionlabel', [ $this, 'parserFunctionWikifunctionLabel' ] );
589        $parser->setFunctionHook( 'wikifunctionlabeldesc', [ $this, 'parserFunctionWikifunctionLabelAndDescription' ] );
590    }
591
592    /**
593     * Renders the output of our repo-mode parser function to show the label of a ZObject.
594     *
595     * Example:
596     *   {{#wikifunctionlabel:Z1234|en}}
597     * ->
598     *   [[Z1234|English label of Z1234 (Z1234)]]
599     *
600     * @param Parser $parser
601     * @param string $zid ZID of the ZObject to link to, defaults to 'Z1'
602     * @param string $langCode Language code to use for the label, defaults to and will fall back to English
603     * @return string
604     */
605    public function parserFunctionWikifunctionLabel( $parser, $zid = 'Z1', $langCode = 'en' ): string {
606        return $this->renderParserFunctionWikifunction( $parser, $zid, $langCode, false );
607    }
608
609    /**
610     * Renders the output of our repo-mode parser function to show the label and description of a ZObject.
611     *
612     * Example:
613     *   {{#wikifunctionlabeldesc:Z1234|en}}
614     * ->
615     *   [[Z1234|English label of Z1234 (Z1234): English description of Z1234]]
616     *
617     * @param Parser $parser
618     * @param string $zid ZID of the ZObject to link to, defaults to 'Z1'
619     * @param string $langCode Language code to use for the label, defaults to and will fall back to English
620     * @return string
621     */
622    public function parserFunctionWikifunctionLabelAndDescription( $parser, $zid = 'Z1', $langCode = 'en' ): string {
623        return $this->renderParserFunctionWikifunction( $parser, $zid, $langCode, true );
624    }
625
626    /**
627     * Renders the output of our repo-mode parser functions
628     *
629     * @param Parser $parser
630     * @param string $zid ZID of the ZObject to link to, defaults to 'Z1'
631     * @param string $langCode Language code to use for the label, defaults to and will fall back to English
632     * @param bool $includeDescription Whether to include the description in the output
633     * @return string
634     */
635    private function renderParserFunctionWikifunction(
636        $parser, string $zid = 'Z1', string $langCode = 'en', bool $includeDescription = false
637    ): string {
638        if ( !ZObjectUtils::isValidZObjectReference( $zid ) ) {
639            // If the ZID is not valid, return just the supplied false-ZID as plain text, escaped.
640            return htmlspecialchars( $zid );
641        }
642
643        // Start the response with a link to the ZObject.
644        $output = '[[' . $zid . '|';
645
646        $label = $this->zObjectStore->fetchZObjectLabel( $zid, $langCode, true );
647        if ( $label === null ) {
648            // If we don't have a label, we can't link to it, so just show the 'Unlabelled' ZID
649            $label = $parser->msg( 'wikilambda-repoparserfunction-unlabelled' )->text();
650        }
651        $output .= $label;
652
653        $output .= $parser->msg( 'word-separator' )->text()
654            . $parser->msg( 'parentheses' )->params(
655                '<span dir="ltr" class="ext-wikilambda-inline-zid">' . $zid . '</span>'
656            )->text();
657
658        if ( $includeDescription ) {
659            // This is an expensive operation; flag it as such.
660            $parser->incrementExpensiveFunctionCount();
661
662            $zobject = $this->zObjectStore->fetchZObject( $zid );
663
664            if ( $zobject === null || !( $zobject instanceof ZObjectContent ) || !$zobject->isValid() ) {
665                // If we can't load the ZObject, just return the label and ZID
666                $description = $parser->msg( 'wikilambda-repoparserfunction-unknown' )->text();
667            } else {
668                $language = $this->languageFactory->getLanguage( $langCode );
669
670                $description = $zobject->getZObject()->getDescription( $language, true );
671
672                if ( !$description ) {
673                    // If we don't have a description, use the default one
674                    $description = $parser->msg( 'wikilambda-repoparserfunction-undescribed' )->text();
675                }
676            }
677
678            $output .= $parser->msg( 'colon-separator' )->text()
679                . '<span class="ext-wikilambda-inline-description">'
680                . trim( strip_tags( $parser->recursiveTagParseFully( $description ) ) )
681                . '</span>';
682
683        }
684
685        $output .= ']]';
686
687        return $output;
688    }
689
690    /**
691     * Fetch the label for a Wikidata entity in abstract mode via the Wikidata API.
692     *
693     * @param string $entityId The Wikidata QID (e.g. Q715040)
694     * @param string $currentPageContentLanguageCode The language code to fetch the label in
695     * @return ?string The label, or null if not found
696     */
697    private function fetchAbstractModeLabel( string $entityId, string $currentPageContentLanguageCode ): ?string {
698        if ( !ExtensionRegistry::getInstance()->isLoaded( 'WikibaseClient' ) ) {
699            return null;
700        }
701        try {
702            $wbEntityParser = \Wikibase\Client\WikibaseClient::getEntityIdParser();
703            $itemId = $wbEntityParser->parse( $entityId );
704        } catch ( \Wikibase\DataModel\Entity\EntityIdParsingException ) {
705            return null;
706        }
707
708        $wbEntityLookup = \Wikibase\Client\WikibaseClient::getStore()->getEntityLookup();
709        $wbEntity = $wbEntityLookup->getEntity( $itemId );
710
711        if ( !( $wbEntity instanceof \Wikibase\DataModel\Entity\Item ) ) {
712            return null;
713        }
714
715        // return label
716        try {
717            return $wbEntity->getLabels()->getByLanguage( $currentPageContentLanguageCode )?->getText();
718        } catch ( OutOfBoundsException ) {
719            // This means Wikibase doesn't have a label; return null
720            return null;
721        }
722    }
723
724    /**
725     * Fetch the label for a ZObject in repo mode via the ZObjectStore.
726     *
727     * @param string $entityId The ZObject ID (e.g. Z42)
728     * @param string $currentPageContentLanguageCode The language code to fetch the label in
729     * @param Title $targetTitle The title of the target page
730     * @param IContextSource $context The current request context
731     * @return ?string The label, or null if not found
732     */
733    private function fetchRepoModeLabel(
734        string $entityId, string $currentPageContentLanguageCode, $targetTitle, $context
735    ): ?string {
736        if ( !$this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
737            // (T423515) Don't do this on a non-repo, as it will explode the DB
738            return null;
739        }
740
741        // Rather than (rather expensively) fetching the whole object from the ZObjectStore, see if the labels are in
742        // the labels table already, which is very much faster:
743        $label = $this->zObjectStore->fetchZObjectLabel(
744            $entityId,
745            $currentPageContentLanguageCode,
746            true
747        );
748
749        // Just in case the database has no entry (e.g. the table is a millisecond behind or so), load the full object.
750        if ( $label === null ) {
751            $targetZObject = $this->zObjectStore->fetchZObjectByTitle( $targetTitle );
752            // Do nothing if somehow after all that it's not loadable
753            if ( !$targetZObject || !( $targetZObject instanceof ZObjectContent ) || !$targetZObject->isValid() ) {
754                return null;
755            }
756
757            // At this point, we know they're linking to a ZObject page, so show a label, falling back
758            // to English even if that's not in the language's fall-back chain.
759            // return label
760            return $targetZObject->getLabels()
761                ->buildStringForLanguage( $context->getLanguage() )
762                ->fallbackWithEnglish()
763                ->placeholderForTitle()
764                ->getString() ?? '';
765        }
766
767        return $label;
768    }
769
770    /**
771     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SkinPageReadyConfig
772     *
773     * @param ResourceLoaderContext $context
774     * @param array &$config
775     * @return void
776     */
777    public function onSkinPageReadyConfig( ResourceLoaderContext $context, array &$config ): void {
778        // Register our custom search module for Vector 2022 typeahead search.
779        // We only register search if WikiLambda is in repo or abstract mode.
780        // This replaces the deprecated mw.config.get( 'wgVectorSearchClient' ) approach (T395641).
781        // The client decides which underlying search implementation to use based on configuration.
782        if (
783            $this->config->get( 'WikiLambdaEnableAbstractMode' ) ||
784            $this->config->get( 'WikiLambdaEnableRepoMode' )
785        ) {
786            $config['searchModule'] = 'ext.wikilambda.search';
787        }
788    }
789}