Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
BannerRenderer
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 10
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 linkToBanner
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getPreviewLink
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 toHtml
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 getPreloadJs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getPreloadJsRaw
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getResourceLoaderHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 substituteMagicWords
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getMagicWords
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 renderMagicWord
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2
3use MediaWiki\Context\IContextSource;
4use MediaWiki\Html\Html;
5use MediaWiki\MediaWikiServices;
6use MediaWiki\ResourceLoader\ResourceLoader;
7use MediaWiki\SpecialPage\SpecialPage;
8
9/**
10 * Produce HTML and JSON output for a given banner and context
11 */
12class BannerRenderer {
13
14    /** @var MixinController|null */
15    private $mixinController = null;
16
17    /**
18     * Creates a new renderer for a given banner and context
19     *
20     * @param IContextSource $context UI context, including language.
21     * @param Banner $banner Banner to be rendered.
22     * @param string|null $campaignName Which campaign we're serving.  This is
23     *   substituted in for {{{campaign}}} magic word.
24     * @param string|null $previewContent Unsaved raw banner content to use for rendering
25     *   an unsaved preview.
26     * @param array|null $previewMessages Associative array of banner message names and
27     *   and values, for rendering unsaved preview.
28     * @param bool $debug If false, minify the output.
29     */
30    public function __construct(
31        private readonly IContextSource $context,
32        private readonly Banner $banner,
33        private readonly ?string $campaignName = null,
34        private readonly ?string $previewContent = null,
35        private readonly ?array $previewMessages = null,
36        private readonly bool $debug = false,
37    ) {
38        $this->mixinController = new MixinController( $this->context, $this->banner->getMixins() );
39
40        // FIXME: it should make sense to do this:
41        // $this->mixinController->registerMagicWord( 'campaign', array( $this, 'getCampaign' ) );
42        // $this->mixinController->registerMagicWord( 'banner', array( $this, 'getBanner' ) );
43    }
44
45    /**
46     * Get the edit link for a banner (static version).
47     *
48     * @param string $name Banner name.
49     * @return string Edit URL.
50     *
51     * TODO Move the following method somewhere more appropriate.
52     */
53    public static function linkToBanner( $name ) {
54        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
55        return $linkRenderer->makeLink(
56            SpecialPage::getTitleFor( 'CentralNoticeBanners', "edit/{$name}" ),
57            $name,
58            [ 'class' => 'cn-banner-title' ]
59        );
60    }
61
62    /**
63     * Return a rendered link to the Special:Random banner preview.
64     *
65     * @param string $name Banner name
66     * @return string HTML anchor tag
67     *
68     * TODO Move this method somewhere more appropriate.
69     */
70    public static function getPreviewLink( $name ) {
71        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
72
73        // FIXME: Need a reusable way to get a known target language.  We want
74        // to set uselang= so that the banner is rendered using an available
75        // translation.  banner->getPriorityLanguages isn't reliable.
76
77        return $linkRenderer->makeKnownLink(
78            SpecialPage::getTitleFor( 'Randompage' ),
79            wfMessage( 'centralnotice-live-preview' )->text(),
80            [ 'class' => 'cn-banner-list-element-label-text' ],
81            [
82                'banner' => $name,
83                // TODO: 'uselang' => $language,
84                'force' => '1',
85            ]
86        );
87    }
88
89    /**
90     * Get the body of the banner, with all transformations applied.
91     *
92     * FIXME: "->inLanguage( $context->getLanguage() )" is necessary due to a bug
93     *   in DerivativeContext
94     *
95     * @return string HTML fragment for the banner body.
96     */
97    public function toHtml() {
98        global $wgNoticeUseLanguageConversion;
99
100        $parentLang = $lang = $this->context->getLanguage();
101        if ( $wgNoticeUseLanguageConversion ) {
102            $parentLang = MediaWikiServices::getInstance()->getLanguageFactory()
103                ->getParentLanguage( $lang->getCode() ) ?? $lang;
104        }
105
106        if ( $this->previewContent !== null ) {
107            // Preview mode, banner content is ephemeral
108            // TODO Double-check that this is the correct way to get process as a i18n
109            // message, and add documentation to the core method.
110            $bannerHtml = MediaWikiServices::getInstance()->getMessageCache()->transform(
111                $this->previewContent,
112                false,
113                $parentLang
114            );
115
116        } else {
117            // Normal mode, banner content is stored as message
118            $bannerKey = $this->banner->getDbKey();
119            $bannerContentMessage = $this->context->msg( $bannerKey )->inLanguage( $parentLang );
120            if ( !$bannerContentMessage->exists() ) {
121                // Translation subsystem failure
122                throw new RuntimeException(
123                    "Banner message key $bannerKey could not be found in {$parentLang->getCode()}" );
124            }
125            $bannerHtml = $bannerContentMessage->text();
126        }
127
128        $bannerHtml .= $this->getResourceLoaderHtml();
129        $bannerHtml = $this->substituteMagicWords( $bannerHtml );
130
131        if ( $wgNoticeUseLanguageConversion ) {
132            $converter = MediaWikiServices::getInstance()->getLanguageConverterFactory()
133                ->getLanguageConverter( $parentLang );
134            $variant = $lang->getCode();
135            if ( $converter->hasVariant( $variant ) ) {
136                $bannerHtml = $converter->convertTo( $bannerHtml, $variant );
137            }
138        }
139        return $bannerHtml;
140    }
141
142    /**
143     * Render any preload javascript for this banner
144     *
145     * TODO: Remove/refactor. See T225831.
146     *
147     * @return string JavaScript code
148     */
149    public function getPreloadJs() {
150        $code = $this->substituteMagicWords( $this->getPreloadJsRaw() );
151
152        // Minify the code, if any.
153        if ( !$this->debug && $code ) {
154            $code = ResourceLoader::filter( 'minify-js', $code, [ 'cache' => false ] );
155        }
156        return $code;
157    }
158
159    /**
160     * Unrendered blob of preload javascript snippets
161     *
162     * This is only used internally, and will be parsed for magic words
163     * before use.
164     *
165     * TODO: Remove/refactor. See T225831.
166     *
167     * @return string JavaScript code
168     */
169    public function getPreloadJsRaw() {
170        $snippets = $this->mixinController->getPreloadJsSnippets();
171
172        return implode( "\n\n", $snippets );
173    }
174
175    /**
176     * Render any ResourceLoader modules
177     *
178     * If the banner includes RL mixins, render the JS (TODO: and CSS) and
179     * return here.
180     *
181     * @return string HTML snippet.
182     */
183    public function getResourceLoaderHtml() {
184        $modules = $this->mixinController->getResourceLoaderModules();
185        if ( $modules ) {
186            // FIXME: Does the RL library already include a helper to do this?
187            $html = "<!-- " . implode( ", ", array_keys( $modules ) ) . " -->";
188            $html .= ResourceLoader::makeInlineScript(
189                Html::encodeJsCall( 'mw.loader.load', array_values( $modules ) )
190            );
191            return $html;
192        }
193        return "";
194    }
195
196    /**
197     * Replace magic word placeholders with their value
198     *
199     * We rely on $this->renderMagicWord to do the heavy lifting.
200     *
201     * @param string $contents Raw contents to be processed.
202     *
203     * @return string Rendered contents.
204     */
205    public function substituteMagicWords( $contents ) {
206        // FIXME The syntax {{{magicword:param1|param2}}} for magic words does not work,
207        // since it is munged by core before we get it here. It was part of the in-banner
208        // mixin system, currently unused.
209        return preg_replace_callback(
210            '/{{{([^}:]+)(?:[:]([^}]*))?}}}/',
211            [ $this, 'renderMagicWord' ],
212            $contents
213        );
214    }
215
216    /**
217     * Get a list of magic words provided or dependened upon by this banner
218     *
219     * @return array List of magic word names.
220     */
221    public function getMagicWords() {
222        $words = [ 'banner', 'campaign' ];
223        $words = array_merge( $words, $this->mixinController->getMagicWords() );
224        return $words;
225    }
226
227    /**
228     * Get the value for a magic word
229     *
230     * @param array $re_matches Funky PCRE callback param having the form,
231     *     array(
232     *         0 => full match, ignored,
233     *         1 => magic word name,
234     *         2 => optional arguments to the magic word replacement function
235     *              FIXME Doesn't work, unused
236     *     );
237     *
238     * @return string HTML fragment with the resulting value.
239     */
240    private function renderMagicWord( $re_matches ) {
241        $field = $re_matches[1];
242        if ( $field === 'banner' ) {
243            return $this->banner->getName();
244        } elseif ( $field === 'campaign' ) {
245            return $this->campaignName;
246        }
247
248        // FIXME This doesn't work; part of the unused in-banner mixin system.
249        $params = [];
250        if ( isset( $re_matches[2] ) ) {
251            $params = explode( "|", $re_matches[2] );
252        }
253
254        $value = $this->mixinController->renderMagicWord( $field, $params );
255        if ( $value !== null ) {
256            return $value;
257        }
258
259        // Treat anything else as a translatable message
260        $messageFields = explode( ',', $field, 2 );
261        if ( isset( $messageFields[ 1 ] ) ) {
262            // A translatable message from a named banner. String before the comma is the
263            // banner that defines the message, and the rest is the name of the
264            // translatable message from that banner.
265            $bannerMessage = Banner::getMessageFieldForBanner(
266                trim( $messageFields[ 0 ] ),
267                trim( $messageFields[ 1 ] )
268            );
269
270        } elseif ( $this->previewMessages !== null &&
271            array_key_exists( $field, $this->previewMessages ) ) {
272            // If we're rendering an unsaved preview and the field is provided as an
273            // unsaved preview message, transform as a messages, sanitize and return that.
274
275            // TODO As above, double-check that this is the correct way to get process as
276            // a i18n message.
277            return MediaWikiServices::getInstance()->getMessageCache()->transform(
278                BannerMessage::sanitize( $this->previewMessages[ $field ] ) );
279
280        } else {
281            $bannerMessage = $this->banner->getMessageField( $field );
282        }
283
284        return $bannerMessage->toHtml( $this->context );
285    }
286
287}