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