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