Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.53% covered (success)
91.53%
173 / 189
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SkinComponentFooter
91.53% covered (success)
91.53%
173 / 189
50.00% covered (danger)
50.00%
6 / 12
57.90
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
 getTemplateDataFooter
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
3.14
 getTemplateData
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 getFooterInfoData
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
11.24
 getCopyright
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 formatFooterInfoData
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 getSiteFooterLinks
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 makeFooterIconHTML
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
8
 getFooterIconsData
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
10.24
 getFooterIcons
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
7
 formatFooterDataForCurrentSpec
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 lastModified
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3namespace MediaWiki\Skin;
4
5use Action;
6use Article;
7use CreditsAction;
8use MediaWiki\Config\Config;
9use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
10use MediaWiki\Html\Html;
11use MediaWiki\MainConfigNames;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\Title\Title;
14
15class SkinComponentFooter implements SkinComponent {
16    use ProtectedHookAccessorTrait;
17
18    /** @var SkinComponentRegistryContext */
19    private $skinContext;
20
21    public function __construct( SkinComponentRegistryContext $skinContext ) {
22        $this->skinContext = $skinContext;
23    }
24
25    /**
26     * Run SkinAddFooterLinks hook on menu data to insert additional menu items specifically in footer.
27     */
28    private function getTemplateDataFooter(): array {
29        $data = [
30            'info' => $this->formatFooterInfoData(
31                $this->getFooterInfoData()
32            ),
33            'places' => $this->getSiteFooterLinks(),
34        ];
35        $skin = $this->skinContext->getContextSource()->getSkin();
36        foreach ( $data as $key => $existingItems ) {
37            $newItems = [];
38            $this->getHookRunner()->onSkinAddFooterLinks( $skin, $key, $newItems );
39            foreach ( $newItems as $index => $linkHTML ) {
40                $data[ $key ][ $index ] = [
41                    'id' => 'footer-' . $key . '-' . $index,
42                    'html' => $linkHTML,
43                ];
44            }
45        }
46        return $data;
47    }
48
49    /**
50     * @inheritDoc
51     */
52    public function getTemplateData(): array {
53        $footerData = $this->getTemplateDataFooter();
54
55        // Create the menu components from the footer data.
56        $footerInfoMenuData = new SkinComponentMenu(
57            'footer-info',
58            $footerData['info'],
59            $this->skinContext->getMessageLocalizer()
60        );
61        $footerSiteMenuData = new SkinComponentMenu(
62            'footer-places',
63            $footerData['places'],
64            $this->skinContext->getMessageLocalizer()
65        );
66
67        // To conform the footer menu data to the current SkinMustache specification,
68        // run the derived data through a cleanup function to unset unexpected data properties
69        // until the spec is updated to reflect the new properties introduced by the menu component.
70        // See https://www.mediawiki.org/wiki/Manual:SkinMustache.php#DataFooter
71        $footerMenuData = [];
72        $footerMenuData['data-info'] = $footerInfoMenuData->getTemplateData();
73        $footerMenuData['data-places'] = $footerSiteMenuData->getTemplateData();
74        $footerMenuData['data-icons'] = $this->getFooterIcons();
75        $footerMenuData = $this->formatFooterDataForCurrentSpec( $footerMenuData );
76
77        return [
78            'data-info' => $footerMenuData['data-info'],
79            'data-places' => $footerMenuData['data-places'],
80            'data-icons' => $footerMenuData['data-icons']
81        ];
82    }
83
84    /**
85     * Get the footer data containing standard footer links.
86     *
87     * All values are resolved and can be added to by the
88     * SkinAddFooterLinks hook.
89     *
90     * @since 1.40
91     * @internal
92     * @return array
93     */
94    private function getFooterInfoData(): array {
95        $action = null;
96        $skinContext = $this->skinContext;
97        $out = $skinContext->getOutput();
98        $ctx = $skinContext->getContextSource();
99        // This needs to be the relevant Title rather than just the raw Title for e.g. special pages that render content
100        $title = $skinContext->getRelevantTitle();
101        $titleExists = $title && $title->exists();
102        $config = $skinContext->getConfig();
103        $maxCredits = $config->get( MainConfigNames::MaxCredits );
104        $showCreditsIfMax = $config->get( MainConfigNames::ShowCreditsIfMax );
105        $useCredits = $titleExists
106            && $out->isArticle()
107            && $out->isRevisionCurrent()
108            && $maxCredits !== 0;
109
110        /** @var CreditsAction $action */
111        if ( $useCredits ) {
112            $article = Article::newFromWikiPage( $skinContext->getWikiPage(), $ctx );
113            $action = Action::factory( 'credits', $article, $ctx );
114        }
115
116        '@phan-var CreditsAction $action';
117        return [
118            'lastmod' => !$useCredits ? $this->lastModified() : null,
119            'numberofwatchingusers' => null,
120            'credits' => $useCredits && $action ?
121                $action->getCredits( $maxCredits, $showCreditsIfMax ) : null,
122            'copyright' => $titleExists &&
123            $out->showsCopyright() ? $this->getCopyright() : null,
124        ];
125    }
126
127    /**
128     * @return string
129     */
130    private function getCopyright() {
131        $copyright = new SkinComponentCopyright( $this->skinContext );
132        return $copyright->getTemplateData()[ 'html' ];
133    }
134
135    /**
136     * Format the footer data containing standard footer links for passing
137     * into SkinComponentMenu.
138     *
139     * @since 1.40
140     * @internal
141     * @param array $data raw footer data
142     * @return array
143     */
144    private function formatFooterInfoData( array $data ): array {
145        $formattedData = [];
146        foreach ( $data as $key => $item ) {
147            if ( $item ) {
148                $formattedData[ $key ] = [
149                    'id' => 'footer-info-' . $key,
150                    'html' => $item
151                ];
152            }
153        }
154        return $formattedData;
155    }
156
157    /**
158     * Gets the link to the wiki's privacy policy, about page, and disclaimer page
159     *
160     * @internal
161     * @return array data array for 'privacy', 'about', 'disclaimer'
162     */
163    private function getSiteFooterLinks(): array {
164        $siteLinksData = [];
165        $siteLinks = [
166            'privacy' => [ 'privacy', 'privacypage' ],
167            'about' => [ 'aboutsite', 'aboutpage' ],
168            'disclaimers' => [ 'disclaimers', 'disclaimerpage' ]
169        ];
170        $localizer = $this->skinContext->getMessageLocalizer();
171
172        foreach ( $siteLinks as $key => $siteLink ) {
173            // Check if the link description has been disabled in the default language.
174            // If disabled, it is disabled for all languages.
175            if ( !$localizer->msg( $siteLink[0] )->inContentLanguage()->isDisabled() ) {
176                // Display the link for the user, described in their language (which may or may not be the same as the
177                // default language), but make the link target be the one site-wide page.
178                $title = Title::newFromText( $localizer->msg( $siteLink[1] )->inContentLanguage()->text() );
179                if ( $title !== null ) {
180                    $siteLinksData[$key] = [
181                        'id' => "footer-places-$key",
182                        'text' => $localizer->msg( $siteLink[0] )->text(),
183                        'href' => $title->fixSpecialName()->getLinkURL()
184                    ];
185                }
186            }
187        }
188        return $siteLinksData;
189    }
190
191    /**
192     * Renders a $wgFooterIcons icon according to the method's arguments
193     *
194     * @param Config $config
195     * @param array|string $icon The icon to build the html for, see $wgFooterIcons
196     *   for the format of this array.
197     * @param string $withImage Whether to use the icon's image or output
198     *   a text-only footer icon.
199     * @return string HTML
200     * @internal for use in Skin only
201     */
202    public static function makeFooterIconHTML( Config $config, $icon, string $withImage = 'withImage' ): string {
203        if ( is_string( $icon ) ) {
204            $html = $icon;
205        } else { // Assuming array
206            $url = $icon['url'] ?? null;
207            unset( $icon['url'] );
208
209            $sources = '';
210            if ( isset( $icon['sources'] ) ) {
211                foreach ( $icon['sources'] as $source ) {
212                    $sources .= Html::element( 'source', $source );
213                }
214                unset( $icon['sources'] );
215            }
216
217            if ( isset( $icon['src'] ) && $withImage === 'withImage' ) {
218                // Lazy-load footer icons, since they're not part of the printed view.
219                $icon['loading'] = 'lazy';
220                // do this the lazy way, just pass icon data as an attribute array
221                $html = Html::element( 'img', $icon );
222                if ( $sources ) {
223                    $html = Html::openElement( 'picture' ) . $sources . $html . Html::closeElement( 'picture' );
224                }
225            } else {
226                $html = htmlspecialchars( $icon['alt'] ?? '' );
227            }
228            if ( $url ) {
229                $html = Html::rawElement(
230                    'a',
231                    [
232                        'href' => $url,
233                        // Using a fake Codex link button, as this is the long-expected UX; our apologies.
234                        'class' => [
235                            'cdx-button', 'cdx-button--fake-button',
236                            'cdx-button--size-large', 'cdx-button--fake-button--enabled'
237                        ],
238                        'target' => $config->get( MainConfigNames::ExternalLinkTarget ),
239                    ],
240                    $html
241                );
242            }
243        }
244        return $html;
245    }
246
247    /**
248     * Get data representation of icons
249     *
250     * @internal for use in Skin only
251     * @param Config $config
252     * @return array
253     */
254    public static function getFooterIconsData( Config $config ) {
255        $footericons = [];
256        foreach (
257            $config->get( MainConfigNames::FooterIcons ) as $footerIconsKey => &$footerIconsBlock
258        ) {
259            if ( count( $footerIconsBlock ) > 0 ) {
260                $footericons[$footerIconsKey] = [];
261                foreach ( $footerIconsBlock as &$footerIcon ) {
262                    if ( isset( $footerIcon['src'] ) ) {
263                        if ( !isset( $footerIcon['width'] ) ) {
264                            $footerIcon['width'] = 88;
265                        }
266                        if ( !isset( $footerIcon['height'] ) ) {
267                            $footerIcon['height'] = 31;
268                        }
269                    }
270
271                    // Only output icons which have an image.
272                    // For historic reasons this mimics the `icononly` option
273                    // for BaseTemplate::getFooterIcons.
274                    // In some cases the icon may be an empty array.
275                    // Filter these out. (See T269776)
276                    if ( is_string( $footerIcon ) || isset( $footerIcon['src'] ) ) {
277                        $footericons[$footerIconsKey][] = $footerIcon;
278                    }
279                }
280
281                // If no valid icons with images were added, unset the parent array
282                // Should also prevent empty arrays from when no copyright is set.
283                if ( !count( $footericons[$footerIconsKey] ) ) {
284                    unset( $footericons[$footerIconsKey] );
285                }
286            }
287        }
288        return $footericons;
289    }
290
291    /**
292     * Gets the link to the wiki's privacy policy, about page, and disclaimer page
293     *
294     * @internal
295     * @return array data array for 'privacy', 'about', 'disclaimer'
296     * @suppress SecurityCheck-DoubleEscaped
297     */
298    private function getFooterIcons(): array {
299        $dataIcons = [];
300        $skinContext = $this->skinContext;
301        $config = $skinContext->getConfig();
302        // If footer icons are enabled append to the end of the rows
303        $footerIcons = self::getFooterIconsData(
304            $config
305        );
306
307        if ( count( $footerIcons ) > 0 ) {
308            $icons = [];
309            foreach ( $footerIcons as $blockName => $blockIcons ) {
310                $html = '';
311                foreach ( $blockIcons as $icon ) {
312                    $html .= self::makeFooterIconHTML(
313                        $config, $icon
314                    );
315                }
316                // For historic reasons this mimics the `icononly` option
317                // for BaseTemplate::getFooterIcons. Empty rows should not be output.
318                if ( $html ) {
319                    $block = htmlspecialchars( $blockName );
320                    $icons[$block] = [
321                        'name' => $block,
322                        'id' => 'footer-' . $block . 'ico',
323                        'html' => $html,
324                        'class' => [ 'noprint' ],
325                    ];
326                }
327            }
328
329            // Empty rows should not be output.
330            // This is how Vector has behaved historically but we can revisit later if necessary.
331            if ( count( $icons ) > 0 ) {
332                $dataIcons = new SkinComponentMenu(
333                    'footer-icons',
334                    $icons,
335                    $this->skinContext->getMessageLocalizer(),
336                    '',
337                    []
338                );
339            }
340        }
341
342        return $dataIcons ? $dataIcons->getTemplateData() : [];
343    }
344
345    /**
346     * Get finalized footer menu data and reformat to fit current specification.
347     *
348     * See https://www.mediawiki.org/wiki/Manual:SkinMustache.php#DataFooter
349     * This method should be removed once the specification is updated and
350     * new data properties provided by the menu component are ok to output.
351     *
352     * @internal
353     * @param array $data
354     * @return array
355     */
356    private function formatFooterDataForCurrentSpec( array $data ): array {
357        $formattedData = [];
358        foreach ( $data as $key => $item ) {
359            unset( $item['html-tooltip'] );
360            unset( $item['html-items'] );
361            unset( $item['html-after-portal'] );
362            unset( $item['html-before-portal'] );
363            unset( $item['label'] );
364            unset( $item['class'] );
365            foreach ( $item['array-items'] ?? [] as $index => $arrayItem ) {
366                unset( $item['array-items'][$index]['html-item'] );
367            }
368            $formattedData[$key] = $item;
369            $formattedData[$key]['className'] = $key === 'data-icons' ? 'noprint' : null;
370        }
371        return $formattedData;
372    }
373
374    /**
375     * Get the timestamp of the latest revision, formatted in user language
376     *
377     * @internal for use in Skin.php only
378     * @return string
379     */
380    private function lastModified() {
381        $skinContext = $this->skinContext;
382        $out = $skinContext->getOutput();
383        $timestamp = $out->getRevisionTimestamp();
384
385        // No cached timestamp, load it from the database
386        // TODO: This code shouldn't be necessary, revision ID should always be available
387        // Move this logic to OutputPage::getRevisionTimestamp if needed.
388        if ( $timestamp === null ) {
389            $revId = $out->getRevisionId();
390            if ( $revId !== null ) {
391                $timestamp = MediaWikiServices::getInstance()->getRevisionLookup()->getTimestampFromId( $revId );
392            }
393        }
394
395        $lastModified = new SkinComponentLastModified(
396            $skinContext,
397            $timestamp
398        );
399
400        return $lastModified->getTemplateData()['text'];
401    }
402}