'vector-more-actions', 'tb' => 'toolbox', 'personal' => 'personaltools', 'lang' => 'otherlanguages', ]; /** @var int */ private const MENU_TYPE_DEFAULT = 0; /** @var int */ private const MENU_TYPE_TABS = 1; /** @var int */ private const MENU_TYPE_DROPDOWN = 2; private const MENU_TYPE_PORTAL = 3; /** * 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(); 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() . '', '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(), 'title' => $this->msg( 'vector-opt-out-tooltip' )->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->getMenuData( 'tb', $this->getToolbox(), self::MENU_TYPE_PORTAL ); // Run deprecated hooks. $vectorTemplate = $this; ob_start(); // Use SidebarBeforeOutput instead. Hooks::run( 'SkinTemplateToolboxEnd', [ &$vectorTemplate, true ] ); $htmlhookitems = ob_get_clean(); $portal['html-items'] .= $htmlhookitems; ob_start(); Hooks::run( 'VectorAfterToolbox', [], '1.35' ); $props[] = $portal + [ 'html-hook-vector-after-toolbox' => ob_get_clean(), ]; break; case 'LANGUAGES': $languages = $skin->getLanguages(); $portal = $this->getMenuData( 'lang', $languages, self::MENU_TYPE_PORTAL ); // The language portal will be added provided either // languages exist or there is a value in html-after-portal // for example to show the add language wikidata link (T252800) if ( count( $languages ) || $portal['html-after-portal'] ) { $props[] = $portal; } break; default: // Historically some portals have been defined using HTML rather than arrays. // Let's move away from that to a uniform definition. if ( !is_array( $content ) ) { $html = $content; $content = []; wfDeprecated( "`content` field in portal $name must be array." . "Previously it could be a string but this is no longer supported.", '1.35.0' ); } else { $html = false; } $portal = $this->getMenuData( $name, $content, self::MENU_TYPE_PORTAL ); if ( $html ) { $portal['html-items'] .= $html; } $props[] = $portal; 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 ), 'data-portals-first' => $firstPortal, 'msg-toggle-sidebar-button-label' => $this->msg( 'vector-action-toggle-sidebar' )->text(), // [todo] fetch user preference when logged in (T246427). 'sidebar-visible' => false ]; } /** * @param string $label to be used to derive the id and human readable label of the menu * If the key has an entry in the constant MENU_LABEL_KEYS then that message will be used for the * human readable text instead. * @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 { $extraClasses = [ self::MENU_TYPE_DROPDOWN => 'vector-menu vector-menu-dropdown vectorMenu', self::MENU_TYPE_TABS => 'vector-menu vector-menu-tabs vectorTabs', self::MENU_TYPE_PORTAL => 'vector-menu vector-menu-portal portal', self::MENU_TYPE_DEFAULT => 'vector-menu', ]; $isPortal = self::MENU_TYPE_PORTAL === $type; // For some menu items, there is no language key corresponding with its menu key. // These inconsitencies are captured in MENU_LABEL_KEYS $msgObj = $this->msg( self::MENU_LABEL_KEYS[ $label ] ?? $label ); $props = [ 'id' => "p-$label", 'label-id' => "p-{$label}-label", // If no message exists fallback to plain text (T252727) 'label' => $msgObj->exists() ? $msgObj->text() : $label, 'html-userlangattributes' => $this->get( 'userlangattributes', '' ), 'html-items' => '', 'is-dropdown' => self::MENU_TYPE_DROPDOWN === $type, 'html-tooltip' => Linker::tooltip( 'p-' . $label ), ]; foreach ( $urls as $key => $item ) { // Add CSS class 'collapsible' to all links EXCEPT watchstar. if ( $key !== 'watch' && $key !== 'unwatch' && isset( $options['vector-collapsible'] ) && $options['vector-collapsible'] ) { if ( !isset( $item['class'] ) ) { $item['class'] = ''; } $item['class'] = rtrim( 'collapsible ' . $item['class'], ' ' ); } $props['html-items'] .= $this->getSkin()->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']; } } } $props['html-after-portal'] = $isPortal ? $this->getAfterPortlet( $label ) : ''; // Mark the portal as empty if it has no content $class = ( count( $urls ) == 0 && !$props['html-after-portal'] ) ? 'vector-menu-empty emptyPortlet' : ''; $props['class'] = trim( "$class $extraClasses[$type]" ); 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 = $skin->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; } }