mediawiki-skins-Vector/includes/VectorTemplate.php
jdlrobson 7efe7c3468 Refactor: Remove indirection (where alternatives exist)
The get method while well intended, adds an unnecessary level of
indirection to our code and can't be easily typechecked. Using
the methods directly (which if you review core code, these all
directly map to those methods) makes the code much more of a pleasure
to read.

Bug: T251212
Change-Id: Ibb2a69801d3b004a0a174308d3ac04d1d228807c
2020-05-05 13:07:14 -07:00

582 lines
18 KiB
PHP

<?php
/**
* Vector - Modern version of MonoBook with fresh look and many usability
* improvements.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*
* @file
* @ingroup Skins
*/
use MediaWiki\MediaWikiServices;
/**
* QuickTemplate subclass for Vector
* @ingroup Skins
*/
class VectorTemplate extends BaseTemplate {
/** @var array of alternate message keys for menu labels */
private const MENU_LABEL_KEYS = [
'cactions' => 'vector-more-actions',
'personal' => 'personaltools',
];
/** @var int */
private const MENU_TYPE_DEFAULT = 0;
/** @var int */
private const MENU_TYPE_TABS = 1;
/** @var int */
private const MENU_TYPE_DROPDOWN = 2;
/**
* T243281: Code used to track clicks to opt-out link.
*
* The "vct" substring is used to describe the newest "Vector" (non-legacy)
* feature. The "w" describes the web platform. The "1" describes the version
* of the feature.
*
* @see https://wikitech.wikimedia.org/wiki/Provenance
* @var string
*/
private const OPT_OUT_LINK_TRACKING_CODE = 'vctw1';
/** @var TemplateParser */
private $templateParser;
/** @var string File name of the root (master) template without folder path and extension */
private $templateRoot;
/** @var bool */
private $isLegacy;
/**
* @param Config $config
* @param TemplateParser $templateParser
* @param bool $isLegacy
*/
public function __construct(
Config $config,
TemplateParser $templateParser,
bool $isLegacy
) {
parent::__construct( $config );
$this->templateParser = $templateParser;
$this->isLegacy = $isLegacy;
$this->templateRoot = $isLegacy ? 'legacy' : 'index';
}
/**
* Amends the default behavior of BaseTemplate to return rather
* than echo.
* @param string $key
* @return Message
*/
public function msg( $key ) {
return $this->getMsg( $key );
}
/**
* @return Config
*/
private function getConfig() {
return $this->config;
}
/**
* The template parser might be undefined. This function will check if it set first
*
* @return TemplateParser
*/
protected function getTemplateParser() {
if ( $this->templateParser === null ) {
throw new \LogicException(
'TemplateParser has to be set first via setTemplateParser method'
);
}
return $this->templateParser;
}
/**
* @return array Returns an array of data shared between Vector and legacy
* Vector.
*/
private function getSkinData() : array {
$contentNavigation = $this->get( 'content_navigation', [] );
$skin = $this->getSkin();
$out = $skin->getOutput();
$title = $out->getTitle();
// Move the watch/unwatch star outside of the collapsed "actions" menu to the main "views" menu
if ( $this->getConfig()->get( 'VectorUseIconWatch' ) ) {
$mode = ( $skin->getRelevantTitle()->isWatchable() &&
MediaWikiServices::getInstance()->getPermissionManager()->userHasRight(
$skin->getUser(),
'viewmywatchlist'
) &&
MediaWikiServices::getInstance()->getWatchedItemStore()->isWatched(
$skin->getUser(),
$skin->getRelevantTitle()
)
) ? 'unwatch' : 'watch';
$actionUrls = $contentNavigation[ 'actions' ] ?? [];
if ( array_key_exists( $mode, $actionUrls ) ) {
$viewUrls = $contentNavigation[ 'views' ] ?? [];
$viewUrls[ $mode ] = $actionUrls[ $mode ];
unset( $actionUrls[ $mode ] );
$contentNavigation['actions'] = $actionUrls;
$contentNavigation['views'] = $viewUrls;
$this->set( 'content_navigation', $contentNavigation );
}
}
ob_start();
Hooks::run( 'VectorBeforeFooter', [], '1.35' );
$htmlHookVectorBeforeFooter = ob_get_contents();
ob_end_clean();
// Naming conventions for Mustache parameters.
//
// Value type (first segment):
// - Prefix "is" or "has" for boolean values.
// - Prefix "msg-" for interface message text.
// - Prefix "html-" for raw HTML.
// - Prefix "data-" for an array of template parameters that should be passed directly
// to a template partial.
// - Prefix "array-" for lists of any values.
//
// Source of value (first or second segment)
// - Segment "page-" for data relating to the current page (e.g. Title, WikiPage, or OutputPage).
// - Segment "hook-" for any thing generated from a hook.
// It should be followed by the name of the hook in hyphenated lowercase.
//
// Conditionally used values must use null to indicate absence (not false or '').
$mainPageHref = Skin::makeMainPageUrl();
$commonSkinData = [
'html-headelement' => $out->headElement( $skin ),
'html-sitenotice' => $this->get( 'sitenotice', null ),
'html-indicators' => $this->getIndicators(),
'page-langcode' => $title->getPageViewLanguage()->getHtmlCode(),
'page-isarticle' => (bool)$out->isArticle(),
// Remember that the string '0' is a valid title.
// From OutputPage::getPageTitle, via ::setPageTitle().
'html-title' => $out->getPageTitle(),
'html-prebodyhtml' => $this->get( 'prebodyhtml', '' ),
'msg-tagline' => $this->msg( 'tagline' )->text(),
// TODO: Use Skin::prepareUserLanguageAttributes() when available.
'html-userlangattributes' => $this->get( 'userlangattributes', '' ),
// From OutputPage::getSubtitle()
'html-subtitle' => $this->get( 'subtitle', '' ),
// TODO: Use Skin::prepareUndeleteLink() when available
// Always returns string, cast to null if empty.
'html-undelete' => $this->get( 'undelete', null ) ?: null,
// From Skin::getNewtalks(). Always returns string, cast to null if empty.
'html-newtalk' => $skin->getNewtalks() ?: null,
'msg-jumptonavigation' => $this->msg( 'vector-jumptonavigation' )->text(),
'msg-jumptosearch' => $this->msg( 'vector-jumptosearch' )->text(),
// Result of OutputPage::addHTML calls
'html-bodycontent' => $this->get( 'bodycontent' ),
'html-printfooter' => $skin->printSource(),
'html-catlinks' => $skin->getCategories(),
'html-dataAfterContent' => $this->get( 'dataAfterContent', '' ),
// From MWDebug::getHTMLDebugLog (when $wgShowDebug is enabled)
'html-debuglog' => $this->get( 'debughtml', '' ),
// From BaseTemplate::getTrail (handles bottom JavaScript)
'html-printtail' => $this->getTrail() . '</body></html>',
'data-footer' => [
'html-userlangattributes' => $this->get( 'userlangattributes', '' ),
'html-hook-vector-before-footer' => $htmlHookVectorBeforeFooter,
'array-footer-rows' => $this->getTemplateFooterRows(),
],
'html-navigation-heading' => $this->msg( 'navigation-heading' ),
'data-search-box' => $this->buildSearchProps(),
// Header
'data-logos' => ResourceLoaderSkinModule::getAvailableLogos( $this->getConfig() ),
'msg-sitetitle' => $this->msg( 'sitetitle' )->text(),
'msg-sitesubtitle' => $this->msg( 'sitesubtitle' )->text(),
'main-page-href' => $mainPageHref,
'data-sidebar' => $this->buildSidebar(),
] + $this->getMenuProps();
// The following logic is unqiue to Vector (not used by legacy Vector) and
// is planned to be moved in a follow-up patch.
if ( !$this->isLegacy && $skin->getUser()->isLoggedIn() ) {
$commonSkinData['data-sidebar']['data-emphasized-sidebar-action'] = [
'href' => SpecialPage::getTitleFor(
'Preferences',
false,
'mw-prefsection-rendering-skin-skin-prefs'
)->getLinkURL( 'wprov=' . self::OPT_OUT_LINK_TRACKING_CODE ),
'text' => $this->msg( 'vector-opt-out' )->text()
];
}
return $commonSkinData;
}
/**
* Renders the entire contents of the HTML page.
*/
public function execute() {
$tp = $this->getTemplateParser();
echo $tp->processTemplate( $this->templateRoot, $this->getSkinData() );
}
/**
* Get rows that make up the footer
* @return array for use in Mustache template describing the footer elements.
*/
private function getTemplateFooterRows() : array {
$skin = $this->getSkin();
$footerRows = [];
foreach ( $this->getFooterLinks() as $category => $links ) {
$items = [];
$rowId = "footer-$category";
foreach ( $links as $link ) {
$items[] = [
'id' => "$rowId-$link",
'html' => $this->get( $link, '' ),
];
}
$footerRows[] = [
'id' => $rowId,
'className' => null,
'array-items' => $items
];
}
// If footer icons are enabled append to the end of the rows
$footerIcons = $this->getFooterIcons( 'icononly' );
if ( count( $footerIcons ) > 0 ) {
$items = [];
foreach ( $footerIcons as $blockName => $blockIcons ) {
$html = '';
foreach ( $blockIcons as $icon ) {
$html .= $skin->makeFooterIcon( $icon );
}
$items[] = [
'id' => 'footer-' . htmlspecialchars( $blockName ) . 'ico',
'html' => $html,
];
}
$footerRows[] = [
'id' => 'footer-icons',
'className' => 'noprint',
'array-items' => $items,
];
}
return $footerRows;
}
/**
* Render a series of portals
*
* @return array
*/
private function buildSidebar() : array {
$skin = $this->getSkin();
$portals = $this->get( 'sidebar', [] );
$props = [];
// Force the rendering of the following portals
if ( !isset( $portals['TOOLBOX'] ) ) {
$portals['TOOLBOX'] = true;
}
if ( !isset( $portals['LANGUAGES'] ) ) {
$portals['LANGUAGES'] = true;
}
// Render portals
foreach ( $portals as $name => $content ) {
if ( $content === false ) {
continue;
}
// Numeric strings gets an integer when set as key, cast back - T73639
$name = (string)$name;
switch ( $name ) {
case 'SEARCH':
break;
case 'TOOLBOX':
$portal = $this->buildPortalProps( 'tb', $this->getToolbox(), 'toolbox',
'SkinTemplateToolboxEnd' );
ob_start();
Hooks::run( 'VectorAfterToolbox', [], '1.35' );
$props[] = $portal + [
'html-hook-vector-after-toolbox' => ob_get_clean(),
];
break;
case 'LANGUAGES':
// @phan-suppress-next-line PhanUndeclaredMethod
$languages = $skin->getLanguages();
if ( count( $languages ) ) {
$props[] = $this->buildPortalProps(
'lang',
$languages,
'otherlanguages'
);
}
break;
default:
$props[] = $this->buildPortalProps( $name, $content );
break;
}
}
$firstPortal = $props[0] ?? null;
if ( $firstPortal ) {
$firstPortal[ 'class' ] .= ' portal-first';
}
return [
'has-logo' => $this->isLegacy,
'html-logo-attributes' => Xml::expandAttributes(
Linker::tooltipAndAccesskeyAttribs( 'p-logo' ) + [
'class' => 'mw-wiki-logo',
'href' => Skin::makeMainPageUrl(),
]
),
'array-portals-rest' => array_slice( $props, 1 ),
'array-portals-first' => $firstPortal,
];
}
/**
* @param string $name
* @param array|string $content
* @param null|string $msg
* @param null|string|array $hook
* @return array
*/
private function buildPortalProps( $name, $content, $msg = null, $hook = null ) : array {
if ( $msg === null ) {
$msg = $name;
}
$msgObj = $this->getMsg( $msg );
$props = [
'portal-id' => "p-$name",
'class' => 'portal',
'html-tooltip' => Linker::tooltip( 'p-' . $name ),
'msg-label' => $msgObj->exists() ? $msgObj->text() : $msg,
'msg-label-id' => "p-$name-label",
'html-userlangattributes' => $this->get( 'userlangattributes', '' ),
'html-portal-content' => '',
'html-after-portal' => $this->getAfterPortlet( $name ),
];
if ( is_array( $content ) ) {
$props['html-portal-content'] .= '<ul>';
foreach ( $content as $key => $val ) {
$props['html-portal-content'] .= $this->makeListItem( $key, $val );
}
if ( $hook !== null ) {
// Avoid PHP 7.1 warning
$skin = $this;
ob_start();
Hooks::run( $hook, [ &$skin, true ] );
$props['html-portal-content'] .= ob_get_contents();
ob_end_clean();
}
$props['html-portal-content'] .= '</ul>';
} else {
// Allow raw HTML block to be defined by extensions
$props['html-portal-content'] = $content;
}
return $props;
}
/**
* @inheritDoc
*/
public function makeListItem( $key, $item, $options = [] ) {
// For fancy styling of watch/unwatch star
if (
$this->getConfig()->get( 'VectorUseIconWatch' )
&& ( $key === 'watch' || $key === 'unwatch' )
) {
if ( !isset( $item['class'] ) ) {
$item['class'] = '';
}
$item['class'] = rtrim( 'icon ' . $item['class'], ' ' );
$item['primary'] = true;
}
// Add CSS class 'collapsible' to links which are not marked as "primary"
if (
isset( $options['vector-collapsible'] ) && $options['vector-collapsible'] ) {
if ( !isset( $item['class'] ) ) {
$item['class'] = '';
}
$item['class'] = rtrim( 'collapsible ' . $item['class'], ' ' );
}
return parent::makeListItem( $key, $item, $options );
}
/**
* @param string $label to be used to derive the id and human readable label of the menu
* @param array $urls to convert to list items stored as string in html-items key
* @param int $type of menu (optional) - a plain list (MENU_TYPE_DEFAULT),
* a tab (MENU_TYPE_TABS) or a dropdown (MENU_TYPE_DROPDOWN)
* @param array $options (optional) to be passed to makeListItem
* @param bool $setLabelToSelected (optional) the menu label will take the value of the
* selected item if found.
* @return array
*/
private function getMenuData(
string $label,
array $urls = [],
int $type = self::MENU_TYPE_DEFAULT,
array $options = [],
bool $setLabelToSelected = false
) : array {
$class = ( count( $urls ) == 0 ) ? 'emptyPortlet' : '';
$extraClasses = [
self::MENU_TYPE_DROPDOWN => 'vectorMenu',
self::MENU_TYPE_TABS => 'vectorTabs',
self::MENU_TYPE_DEFAULT => '',
];
$props = [
'id' => "p-$label",
'class' => trim( "$class $extraClasses[$type]" ),
'label-id' => "p-{$label}-label",
// For some menu items, there is no language key corresponding with its menu key.
// These inconsitencies are captured in MENU_LABEL_KEYS
'label' => $this->msg( self::MENU_LABEL_KEYS[ $label ] ?? $label )->text(),
'html-userlangattributes' => $this->get( 'userlangattributes', '' ),
'html-items' => '',
];
foreach ( $urls as $key => $item ) {
$props['html-items'] .= $this->makeListItem( $key, $item, $options );
// Check the class of the item for a `selected` class and if so, propagate the items
// label to the main label.
if ( $setLabelToSelected ) {
if ( isset( $item['class'] ) && stripos( $item['class'], 'selected' ) !== false ) {
$props['label'] = $item['text'];
break;
}
}
}
return $props;
}
/**
* @return array
*/
private function getMenuProps() : array {
$contentNavigation = $this->get( 'content_navigation', [] );
$personalTools = $this->getPersonalTools();
$skin = $this->getSkin();
// For logged out users Vector shows a "Not logged in message"
// This should be upstreamed to core, with instructions for how to hide it for skins
// that do not want it.
// For now we create a dedicated list item to avoid having to sync the API internals
// of makeListItem.
if ( !$skin->getUser()->isLoggedIn() && User::groupHasPermission( '*', 'edit' ) ) {
$loggedIn =
Html::element( 'li',
[ 'id' => 'pt-anonuserpage' ],
$this->msg( 'notloggedin' )->text()
);
} else {
$loggedIn = '';
}
// This code doesn't belong here, it belongs in the UniversalLanguageSelector
// It is here to workaround the fact that it wants to be the first item in the personal menus.
if ( array_key_exists( 'uls', $personalTools ) ) {
$uls = $this->makeListItem( 'uls', $personalTools[ 'uls' ] );
unset( $personalTools[ 'uls' ] );
} else {
$uls = '';
}
$ptools = $this->getMenuData( 'personal', $personalTools );
// Append additional link items if present.
$ptools['html-items'] = $uls . $loggedIn . $ptools['html-items'];
return [
'data-personal-menu' => $ptools,
'data-namespace-tabs' => $this->getMenuData(
'namespaces',
$contentNavigation[ 'namespaces' ] ?? [],
self::MENU_TYPE_TABS
),
'data-variants' => $this->getMenuData(
'variants',
$contentNavigation[ 'variants' ] ?? [],
self::MENU_TYPE_DROPDOWN,
[], true
),
'data-page-actions' => $this->getMenuData(
'views',
$contentNavigation[ 'views' ] ?? [],
self::MENU_TYPE_TABS, [
'vector-collapsible' => true,
]
),
'data-page-actions-more' => $this->getMenuData(
'cactions',
$contentNavigation[ 'actions' ] ?? [],
self::MENU_TYPE_DROPDOWN
),
];
}
/**
* @return array
*/
private function buildSearchProps() : array {
global $wgScript;
$props = [
'searchHeaderAttrsHTML' => $this->get( 'userlangattributes', '' ),
'searchActionURL' => $wgScript,
'searchDivID' => $this->getConfig()->get( 'VectorUseSimpleSearch' ) ? 'simpleSearch' : '',
'searchInputHTML' => $this->makeSearchInput( [ 'id' => 'searchInput' ] ),
'titleHTML' => Html::hidden( 'title', SpecialPage::getTitleFor( 'Search' )->getPrefixedDBkey() ),
'fallbackSearchButtonHTML' => $this->makeSearchButton(
'fulltext',
[ 'id' => 'mw-searchButton', 'class' => 'searchButton mw-fallbackSearchButton' ]
),
'searchButtonHTML' => $this->makeSearchButton(
'go',
[ 'id' => 'searchButton', 'class' => 'searchButton' ]
),
'searchInputLabel' => $this->msg( 'search' )
];
return $props;
}
}