Merge pull request #1 from octfx/feature/refactoring

Refactor Code
This commit is contained in:
alistair3149 2019-12-26 01:16:19 -05:00 committed by GitHub
commit 3806384e93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1353 additions and 1029 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
vendor
composer.lock

3
.jshintrc Normal file
View file

@ -0,0 +1,3 @@
{
"esversion": 6
}

6
.phpcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0"?>
<ruleset>
<rule ref="./vendor/mediawiki/mediawiki-codesniffer/MediaWiki" />
<file>.</file>
<exclude-pattern>vendor</exclude-pattern>
</ruleset>

View file

@ -4,11 +4,8 @@ if ( function_exists( 'wfLoadSkin' ) ) {
wfLoadSkin( 'Citizen' );
// Keep i18n globals so mergeMessageFileList.php doesn't break
$wgMessagesDirs['Citizen'] = __DIR__ . '/i18n';
/* wfWarn(
'Deprecated PHP entry point used for Citizen skin. Please use wfLoadSkin instead, ' .
'see https://www.mediawiki.org/wiki/Extension_registration for more details.'
); */
return true;
} else {
die( 'This version of the Citizen skin requires MediaWiki 1.31+' );
}
die( 'This version of the Citizen skin requires MediaWiki 1.31+' );

20
composer.json Normal file
View file

@ -0,0 +1,20 @@
{
"require-dev": {
"jakub-onderka/php-parallel-lint": "1.0.0",
"mediawiki/mediawiki-codesniffer": "28.0.0",
"jakub-onderka/php-console-highlighter": "0.4.0",
"mediawiki/minus-x": "0.3.2",
"mediawiki/mediawiki-phan-config": "0.9.0"
},
"scripts": {
"test": [
"parallel-lint . --exclude vendor --exclude node_modules",
"phpcs -p -s",
"minus-x check ."
],
"fix": [
"minus-x fix .",
"phpcbf"
]
}
}

7
i18n/de.json Normal file
View file

@ -0,0 +1,7 @@
{
"@metadata": {
"authors": [ "Octfx" ]
},
"skinname-citizen": "Citizen",
"citizen-desc": "Ein responsive Skin entwickelt für das Star Citizen Wiki"
}

View file

@ -18,18 +18,31 @@
* @file
*/
namespace Citizen;
use ConfigException;
use Exception;
use MediaWiki\MediaWikiServices;
use OutputPage;
use RequestContext;
use Skin;
use ThumbnailImage;
/**
* Hook handlers for Citizen skin.
*
* Hook handler method names should be in the form of:
* on<HookName>()
* on<HookName>()
*/
class CitizenHooks {
public static function BeforePageDisplay($out, $skin) {
/**
* @param OutputPage $out
* @param Skin $skin
* @return bool
*/
public static function onBeforePageDisplay( $out, $skin ) {
$out->addModules( 'skins.citizen.bpd' );
return true;
}
@ -41,11 +54,25 @@ class CitizenHooks {
* @return bool
*/
public static function onResourceLoaderGetConfigVars( &$vars ) {
$config = MediaWikiServices::getInstance()->getConfigFactory()
->makeConfig( 'Citizen' );
try {
$config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'Citizen' );
} catch ( Exception $e ) {
return false;
}
$vars['wgCitizenMaxSearchResults'] = $config->get( 'CitizenMaxSearchResults' );
$vars['wgCitizenSearchExchars'] = $config->get( 'CitizenSearchExchars' );
try {
$vars['wgCitizenMaxSearchResults'] = $config->get( 'CitizenMaxSearchResults' );
} catch ( ConfigException $e ) {
// Should not happen
$vars['wgCitizenMaxSearchResults'] = 6;
}
try {
$vars['wgCitizenSearchExchars'] = $config->get( 'CitizenSearchExchars' );
} catch ( ConfigException $e ) {
// Should not happen
$vars['wgCitizenSearchExchars'] = 60;
}
return true;
}
@ -54,18 +81,28 @@ class CitizenHooks {
* Lazyload images
* Modified from the Lazyload extension
* Looks for thumbnail and swap src to data-src
*
* @param ThumbnailImage $thumb
* @param array &$attribs
* @param array &$linkAttribs
* @return bool
*/
public static function ThumbnailBeforeProduceHTML($thumb, &$attribs, &$linkAttribs) {
public static function onThumbnailBeforeProduceHTML( $thumb, &$attribs, &$linkAttribs ) {
$file = $thumb->getFile();
if ( $file ) {
global $wgRequest, $wgTitle;
if (defined('MW_API') && $wgRequest->getVal('action') === 'parse') return true;
if (isset($wgTitle) && $wgTitle->getNamespace() === NS_FILE) return true;
if ( $file !== null ) {
$request = RequestContext::getMain()->getRequest();
if ( defined( 'MW_API' ) && $request->getVal( 'action' ) === 'parse' ) {
return true;
}
// Set lazy class for the img
$attribs['class'] .= ' lazy';
if ( isset( $attribs['class'] ) ) {
$attribs['class'] .= 'lazy';
} else {
$attribs['class'] = 'lazy';
}
// Native API
$attribs['loading'] = 'lazy';
@ -75,22 +112,21 @@ class CitizenHooks {
$attribs['data-height'] = $attribs['height'];
// Replace src with small size image
$attribs['src'] = preg_replace('#/\d+px-#', '/10px-', $attribs['src']);
// $attribs['src'] = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
$attribs['src'] = preg_replace( '#/\d+px-#', '/10px-', $attribs['src'] );
// So that the 10px thumbnail is enlarged to the right size
$attribs['width'] = $attribs['data-width'];
$attribs['height'] = $attribs['data-height'];
// Clean up
unset($attribs['data-width']);
unset($attribs['data-height']);
unset( $attribs['data-width'], $attribs['data-height'] );
if (isset($attribs['srcset'])) {
$attribs['data-srcset'] = $attribs['srcset'];
unset($attribs['srcset']);
if ( isset( $attribs['srcset'] ) ) {
$attribs['data-srcset'] = $attribs['srcset'];
unset( $attribs['srcset'] );
}
}
return true;
}
}

View file

@ -1,86 +1,33 @@
<?php
use MediaWiki\MediaWikiServices;
/**
* BaseTemplate class for the Citizen skin
*
* TODO: Add missing title to buttons
* @ingroup Skins
*/
//TODO: Add missing title to buttons
class CitizenTemplate extends BaseTemplate {
/**
* Outputs the entire contents of the page
*/
public function execute() {
$html = '';
$html .= $this->get( 'headelement' );
$loggedinclass = 'not-logged';
$html = $this->get( 'headelement' );
$loggedInClass = 'not-logged';
// Add class if logged in
if ( $this->getSkin()->getUser()->isLoggedIn() ) {
$loggedinclass .= 'logged-in';
$loggedInClass = 'logged-in';
}
$html .= Html::rawElement( 'div', [ 'class' => $loggedinclass, 'id' => 'mw-wrapper' ],
// Header
Html::rawElement( 'header', [ 'class' => 'mw-header-container', 'id' => 'mw-navigation' ],
Html::rawElement( 'div', [ 'class' => 'mw-header-icons'],
// Site navigation menu
$this->getHamburgerMenu()
) .
Html::rawElement( 'div', [ 'class' => 'mw-header-icons'],
// User icons
Html::rawElement( 'div', [ 'class' => 'mw-header', 'id' => 'user-icons' ],
$this->getUserIcons()
) .
// Search bar
$this->getSearchToggle()
)
) .
// Main body
Html::rawElement( 'main', [ 'class' => 'mw-body', 'id' => 'content', 'role' => 'main' ],
// Container for compatiblity with extensions
Html::rawElement( 'section', [ 'id' => 'mw-body-container' ],
$this->getSiteNotice() .
$this->getNewTalk() .
$this->getIndicators() .
// Page editing and tools
$this->getPageTools() .
Html::rawElement( 'h1',
[
'class' => 'firstHeading',
'lang' => $this->get( 'pageLanguage' )
],
$this->get( 'title' )
) .
Html::rawElement( 'div', [ 'id' => 'siteSub' ],
$this->getMsg( 'tagline' )->parse()
) .
Html::rawElement( 'div', [ 'class' => 'mw-body-content' ],
Html::rawElement( 'div', [ 'id' => 'contentSub' ],
$this->getPageSubtitle() .
Html::rawElement(
'p',
[],
$this->get( 'undelete' )
)
) .
$this->get( 'bodycontent' ) .
$this->getClear() .
Html::rawElement( 'div', [ 'class' => 'printfooter' ],
$this->get( 'printfooter' )
) .
$this->getPageLinks() .
$this->getCategoryLinks()
) .
$this->getDataAfterContent() .
$this->get( 'debughtml' )
)
) .
$this->getFooterBlock() .
// Site title for sidebar
Html::rawElement( 'div', [ 'id' => 'mw-sidebar-sitename', 'role' => 'banner' ],
$this->getSiteTitle('link')
) .
$this->getBottomBar()
$html .= Html::rawElement(
'div',
[ 'class' => $loggedInClass, 'id' => 'mw-wrapper' ],
$this->getHeader() .
$this->getMainBody() .
$this->getFooterBlock() .
$this->getSideTitle() .
$this->getBottomBar()
);
$html .= $this->getTrail();
@ -90,88 +37,229 @@ class CitizenTemplate extends BaseTemplate {
echo $html;
}
/**
* The header containing the mobile site navigation and user icons + search
*
* @return string Header
*/
protected function getHeader() {
$header =
Html::openElement( 'header',
[ 'class' => 'mw-header-container', 'id' => 'mw-navigation' ] );
// Site navigation menu
$navigation =
Html::rawElement(
'div',
[ 'class' => 'mw-header-icons' ],
$this->getHamburgerMenu()
);
// User icons and Search bar
$userIconsSearchBar =
Html::rawElement(
'div',
[ 'class' => 'mw-header-icons' ],
Html::rawElement(
'div',
[ 'class' => 'mw-header', 'id' => 'user-icons' ],
$this->getUserIcons()
) .
$this->getSearchToggle()
);
return $header . $navigation . $userIconsSearchBar . Html::closeElement( 'header' );
}
/**
* The main body holding all content
*
* @return string Main Body
*/
protected function getMainBody() {
return Html::rawElement(
'main',
[ 'class' => 'mw-body', 'id' => 'content', 'role' => 'main' ],
// Container for compatibility with extensions
Html::rawElement(
'section',
[ 'id' => 'mw-body-container' ],
$this->getSiteNotice() .
$this->getNewTalk() .
$this->getIndicators() .
$this->getPageTools() .
Html::rawElement(
'h1',
[ 'class' => 'firstHeading', 'lang' => $this->get( 'pageLanguage' ) ],
$this->get( 'title' )
) .
Html::rawElement(
'div',
[ 'id' => 'siteSub' ],
$this->getMsg( 'tagline' )->parse()
) .
Html::rawElement(
'div',
[ 'class' => 'mw-body-content' ],
Html::rawElement(
'div',
[ 'id' => 'contentSub' ],
$this->getPageSubtitle() .
Html::rawElement(
'p',
[],
$this->get( 'undelete' )
)
) .
$this->get( 'bodycontent' ) .
$this->getClear() .
Html::rawElement(
'div',
[ 'class' => 'printfooter' ],
$this->get( 'printfooter' )
) .
$this->getPageLinks() .
$this->getCategoryLinks()
) .
$this->getDataAfterContent() .
$this->get( 'debughtml' )
)
);
}
/**
* The rotated site title
*
* @return string
*/
protected function getSideTitle() {
return Html::rawElement(
'div',
[ 'id' => 'mw-sidebar-sitename', 'role' => 'banner' ],
$this->getSiteTitle( 'link' )
);
}
/**
* Generates the bottom bar
* @return string html
*/
protected function getBottomBar() {
try {
$buttonEnabled = $this->config->get( 'CitizenEnableButton' );
$buttonLink = $this->config->get( 'CitizenButtonLink' );
$buttonTitle = $this->config->get( 'CitizenButtonTitle' );
$buttonText = $this->config->get( 'CitizenButtonText' );
} catch ( ConfigException $e ) {
return '';
}
$linkButton = 'https://discord.gg/3kjftWK';
$titleButton = 'Contact Us on Discord';
$textButton = 'Discord';
if ( $buttonEnabled === false ) {
return '';
}
/*
$linkButton = $this->getConfig()->get( 'CitizenButtonLink' );
$titleButton = $this->getConfig()->get( 'CitizenButtonTitle' );
$textButton = $this->getConfig()->get( 'CitizenButtonText' );
*/
$html = Html::openElement( 'div', [ 'id' => 'mw-bottombar' ] );
$html .= Html::rawElement( 'div', [ 'id' => 'mw-bottombar-buttons' ],
Html::rawElement( 'div', [ 'class' => 'citizen-ui-icon', 'id' => 'citizen-ui-button' ],
Html::rawElement( 'a', [ 'href' => $linkButton, 'title' => $titleButton, 'rel' => 'noopener noreferrer', 'target' => '_blank' ], $textButton )
return Html::rawElement(
'div',
[ 'id' => 'mw-bottombar' ],
Html::rawElement(
'div',
[ 'id' => 'mw-bottombar-buttons' ],
Html::rawElement(
'div',
[ 'class' => 'citizen-ui-icon', 'id' => 'citizen-ui-button' ],
Html::rawElement(
'a',
[
'href' => $buttonLink,
'title' => $buttonTitle,
'rel' => 'noopener noreferrer',
'target' => '_blank',
],
$buttonText
)
)
)
);
$html .= Html::closeElement( 'div' );
return $html;
}
/**
* Generates the search button
* @return string html
*/
protected function getSearchToggle() {
$titleButton = 'Toggle Search';
$html = Html::rawElement( 'div', [ 'class' => 'mw-header-end', 'id' => 'site-search' ],
Html::rawElement( 'input', [ 'type' => 'checkbox', 'role' => 'button', 'title' => $titleButton, 'id' => 'search-toggle' ]) .
// Search button
Html::rawElement( 'div', [ 'id' => 'search-toggle-icon-container' ],
Html::rawElement( 'div', [ 'id' => 'search-toggle-icon' ] )
protected function getSearchToggle() {
return Html::rawElement(
'div',
[ 'class' => 'mw-header-end', 'id' => 'site-search' ],
Html::rawElement(
'input',
[
'type' => 'checkbox',
'role' => 'button',
'title' => 'Toggle Search',
'id' => 'search-toggle',
]
) .
// Search button
Html::rawElement(
'div',
[ 'id' => 'search-toggle-icon-container' ],
Html::rawElement(
'div',
[ 'id' => 'search-toggle-icon' ]
)
) .
// Search form
$this->getSearch()
);
return $html;
}
}
/**
* Generates the hamburger menu
* @return string html
*/
protected function getHamburgerMenu() {
$titleButton = 'Toggle Menu';
protected function getHamburgerMenu() {
$html = Html::openElement(
'div',
[ 'class' => 'mw-header-end', 'id' => 'mw-header-menu' ]
);
$html = Html::openElement( 'div', [ 'class' => 'mw-header-end', 'id' => 'mw-header-menu' ]);
$html .= Html::rawElement( 'input', [ 'type' => 'checkbox', 'role' => 'button', 'title' => $titleButton ]);
$html .= Html::rawElement(
'input',
[ 'type' => 'checkbox', 'role' => 'button', 'title' => 'Toggle Menu' ]
);
// Actual hamburger
$html .= Html::openElement( 'div', [ 'id' => 'mw-header-menu-toggle' ]);
for ($i = 1; $i <= 3; $i++) {
$html .= Html::openElement( 'div', [ 'id' => 'mw-header-menu-toggle' ] );
for ( $i = 1; $i <= 3; $i++ ) {
$html .= Html::rawElement( 'span' );
}
$html .= Html::closeElement( 'div' );
// Get sidebar links
$html .= Html::rawElement( 'div', [ 'id' => 'mw-header-menu-drawer' ],
Html::rawElement( 'div', [ 'id' => 'mw-header-menu-drawer-container' ],
$this->getSiteTitle('text') .
$html .= Html::rawElement(
'div',
[ 'id' => 'mw-header-menu-drawer' ],
Html::rawElement(
'div',
[ 'id' => 'mw-header-menu-drawer-container' ],
$this->getSiteTitle( 'text' ) .
// Container for navigation and tools
Html::rawElement( 'div', [ 'id' => 'p-nt-container' ],
Html::rawElement(
'div',
[ 'id' => 'p-nt-container' ],
$this->getSiteNavigation()
) .
$this->getUserLinks()
)
);
$html .= Html::closeElement( 'div' );
return $html;
}
return $html . Html::closeElement( 'div' );
}
/**
* Generates the sitetitle
* @param string $option
* @return string html
*/
protected function getSiteTitle( $option ) {
@ -179,28 +267,25 @@ class CitizenTemplate extends BaseTemplate {
$language = $this->getSkin()->getLanguage();
$siteTitle = $language->convert( $this->getMsg( 'sitetitle' )->escaped() );
switch ( $option ) {
case 'link':
$html .= Html::rawElement(
'a',
[
'id' => 'p-banner',
'class' => 'mw-wiki-title',
'href' => $this->data['nav_urls']['mainpage']['href']
] + Linker::tooltipAndAccesskeyAttribs( 'p-logo' ),
$siteTitle
);
break;
case 'text':
$html .= Html::rawElement(
'span',
[
'id' => 'p-banner',
'class' => 'mw-wiki-title',
],
$siteTitle
);
break;
if ( $option === 'link' ) {
$html .= Html::rawElement(
'a',
[
'id' => 'p-banner',
'class' => 'mw-wiki-title',
'href' => $this->data['nav_urls']['mainpage']['href'],
] + Linker::tooltipAndAccesskeyAttribs( 'p-logo' ),
$siteTitle
);
} elseif ( $option === 'text' ) {
$html .= Html::rawElement(
'span',
[
'id' => 'p-banner',
'class' => 'mw-wiki-title',
],
$siteTitle
);
}
return $html;
@ -215,15 +300,9 @@ class CitizenTemplate extends BaseTemplate {
$language = $this->getSkin()->getLanguage();
$siteDesc = $language->convert( $this->getMsg( 'citizen-footer-desc' )->escaped() );
$html .= Html::rawElement(
'span',
[
'id' => 'mw-footer-desc'
],
$siteDesc
);
return $html;
return $html . Html::rawElement( 'span', [
'id' => 'mw-footer-desc',
], $siteDesc );
}
/**
@ -235,15 +314,9 @@ class CitizenTemplate extends BaseTemplate {
$language = $this->getSkin()->getLanguage();
$siteTagline = $language->convert( $this->getMsg( 'citizen-footer-tagline' )->escaped() );
$html .= Html::rawElement(
'span',
[
'id' => 'mw-footer-tagline'
],
$siteTagline
);
return $html;
return $html . Html::rawElement( 'span', [
'id' => 'mw-footer-tagline',
], $siteTagline );
}
/**
@ -253,24 +326,17 @@ class CitizenTemplate extends BaseTemplate {
* @return string html
*/
protected function getLogo( $id = 'p-logo' ) {
$html = Html::openElement(
'div',
[
'id' => $id,
'class' => 'mw-portlet',
'role' => 'banner'
]
);
$html .= Html::element(
'a',
[
$html = Html::openElement( 'div', [
'id' => $id,
'class' => 'mw-portlet',
'role' => 'banner',
] );
$html .= Html::element( 'a', [
'href' => $this->data['nav_urls']['mainpage']['href'],
'class' => 'mw-wiki-logo',
] + Linker::tooltipAndAccesskeyAttribs( 'p-logo' )
);
$html .= Html::closeElement( 'div' );
] + Linker::tooltipAndAccesskeyAttribs( 'p-logo' ) );
return $html;
return $html . Html::closeElement( 'div' );
}
/**
@ -280,22 +346,21 @@ class CitizenTemplate extends BaseTemplate {
* @return string html
*/
protected function getSearch() {
$html = Html::openElement(
'form',
[
'action' => $this->get( 'wgScript' ),
'role' => 'search',
'id' => 'search-form'
]
);
$html = Html::openElement( 'form', [
'action' => $this->get( 'wgScript' ),
'role' => 'search',
'id' => 'search-form',
] );
$html .= Html::hidden( 'title', $this->get( 'searchtitle' ) );
$html .= Html::label( $this->getMsg( 'search' )->text(), 'search-input', [ 'class' => 'screen-reader-text' ] );
$html .= Html::label( $this->getMsg( 'search' )->text(), 'search-input',
[ 'class' => 'screen-reader-text' ] );
$html .= $this->makeSearchInput( [ 'id' => 'search-input', 'type' => 'search' ] );
$html .= $this->makeSearchButton( 'image', [ 'id' => 'search-button', 'src' => $this->getSkin()->getSkinStylePath( 'resources/images/icons/search.svg') ] );
// $html .= $this->makeSearchButton( 'go', [ 'id' => 'search-button' ] );
$html .= Html::closeElement( 'form' );
$html .= $this->makeSearchButton( 'image', [
'id' => 'search-button',
'src' => $this->getSkin()->getSkinStylePath( 'resources/images/icons/search.svg' ),
] );
return $html;
return $html . Html::closeElement( 'form' );
}
/**
@ -304,8 +369,8 @@ class CitizenTemplate extends BaseTemplate {
* Or get rid of this entirely, and take the specific bits to use wherever you actually want them
* * Toolbox is the page/site tools that appears under the sidebar in vector
* * Languages is the interlanguage links on the page via en:... es:... etc
* * Default is each user-specified box as defined on MediaWiki:Sidebar; you will still need a foreach loop
* to parse these.
* * Default is each user-specified box as defined on MediaWiki:Sidebar;
* you will still need a foreach loop to parse these.
* @return string html
*/
protected function getSiteNavigation() {
@ -338,14 +403,15 @@ class CitizenTemplate extends BaseTemplate {
break;
}
}
return $html;
}
/**
* Generates user icon bar
* @return string html
*/
protected function getUserIcons() {
$personalTools = $this->getPersonalTools();
$html = '';
@ -368,12 +434,10 @@ class CitizenTemplate extends BaseTemplate {
$iconList .= $this->makeListItem( $key, $item );
}
$html .= Html::rawElement(
'div',
[ 'id' => 'p-personal-extra', 'class' => 'p-body' ],
Html::rawElement( 'ul', [], $iconList )
);
$html .= Html::rawElement( 'div', [ 'id' => 'p-personal-extra', 'class' => 'p-body' ],
Html::rawElement( 'ul', [], $iconList ) );
}
return $html;
}
@ -382,13 +446,11 @@ class CitizenTemplate extends BaseTemplate {
* @return string html
*/
protected function getUserLinks() {
$personalTools = $this->getPersonalTools();
$html = '';
// Move the Echo badges and ULS out of default list
$extraTools = [];
if ( isset( $personalTools['notifications-alert'] ) ) {
unset( $personalTools['notifications-alert'] );
}
@ -401,9 +463,8 @@ class CitizenTemplate extends BaseTemplate {
$html .= Html::openElement( 'div', [ 'id' => 'mw-user-links' ] );
$html .= $this->getPortlet( 'personal', $personalTools, 'personaltools' );
$html .= Html::closeElement( 'div' );
return $html;
return $html . Html::closeElement( 'div' );
}
/**
@ -429,10 +490,7 @@ class CitizenTemplate extends BaseTemplate {
protected function getVariants() {
$html = '';
if ( count( $this->data['content_navigation']['variants'] ) > 0 ) {
$html .= $this->getPortlet(
'variants',
$this->data['content_navigation']['variants']
);
$html .= $this->getPortlet( 'variants', $this->data['content_navigation']['variants'] );
}
return $html;
@ -440,27 +498,47 @@ class CitizenTemplate extends BaseTemplate {
/**
* Generates page-related tools
* Possible visibility conditions:
* * true: always visible (bool)
* * false: never visible (bool)
* * 'login': only visible if logged in (string)
* * 'permission-*': only visible if user has permission
* e.g. permission-edit = only visible if user can edit pages
* @return string html
*/
protected function getPageTools() {
$html = '';
// Only display if user is logged in
// TODO: Make it check for EDIT permission instead
if ( $this->getSkin()->getUser()->isLoggedIn() ) {
$html .= Html::openElement( 'div', [ 'class' => 'mw-side', 'id' => 'page-tools' ]);
try {
$condition = $this->config->get( 'CitizenShowPageTools' );
} catch ( ConfigException $e ) {
$condition = false;
}
if ( $condition === 'login' ) {
$condition = $this->getSkin()->getUser()->isLoggedIn();
}
if ( is_string( $condition ) && strpos( $condition, 'permission' ) === 0 ) {
$permission = substr( $condition, 11 );
try {
$condition = MediaWikiServices::getInstance()->getPermissionManager()->userCan(
$permission, $this->getSkin()->getUser(), $this->getSkin()->getTitle() );
} catch ( Exception $e ) {
$condition = false;
}
}
// Only display if user is logged in
if ( $condition === true ) {
$html .= Html::openElement( 'div', [ 'class' => 'mw-side', 'id' => 'page-tools' ] );
// 'View' actions for the page: view, edit, view history, etc
$html .= $this->getPortlet(
'views',
$this->data['content_navigation']['views']
);
$html .= $this->getPortlet( 'views', $this->data['content_navigation']['views'] );
// Other actions for the page: move, delete, protect, everything else
$html .= $this->getPortlet(
'actions',
$this->data['content_navigation']['actions']
);
$html .= $this->getPortlet( 'actions', $this->data['content_navigation']['actions'] );
$html .= Html::closeElement( 'div' );
}
return $html;
}
@ -469,16 +547,14 @@ class CitizenTemplate extends BaseTemplate {
* @return string html
*/
protected function getPageLinks() {
// Namespaces: links for 'content' and 'talk' for namespaces with talkpages. Otherwise is just the content.
// Namespaces: links for 'content' and 'talk' for namespaces with talkpages.
// Otherwise is just the content.
// Usually rendered as tabs on the top of the page.
$html = $this->getPortlet(
'namespaces',
$this->data['content_navigation']['namespaces']
);
// Language variant options
$html .= $this->getVariants();
$html = $this->getPortlet( 'namespaces', $this->data['content_navigation']['namespaces'] );
return $html;
// Language variant options
return $html . $this->getVariants();
}
/**
@ -488,7 +564,7 @@ class CitizenTemplate extends BaseTemplate {
protected function getSiteNotice() {
return $this->getIfExists( 'sitenotice', [
'wrapper' => 'div',
'parameters' => [ 'id' => 'siteNotice' ]
'parameters' => [ 'id' => 'siteNotice' ],
] );
}
@ -499,7 +575,7 @@ class CitizenTemplate extends BaseTemplate {
protected function getNewTalk() {
return $this->getIfExists( 'newtalk', [
'wrapper' => 'div',
'parameters' => [ 'class' => 'usermessage' ]
'parameters' => [ 'class' => 'usermessage' ],
] );
}
@ -537,21 +613,18 @@ class CitizenTemplate extends BaseTemplate {
*/
protected function getIfExists( $object, $setOptions = [] ) {
$options = $setOptions + [
'wrapper' => 'none',
'parameters' => []
];
'wrapper' => 'none',
'parameters' => [],
];
$html = '';
if ( $this->data[$object] ) {
if ( $options['wrapper'] == 'none' ) {
if ( $options['wrapper'] === 'none' ) {
$html .= $this->get( $object );
} else {
$html .= Html::rawElement(
$options['wrapper'],
$options['parameters'],
$this->get( $object )
);
$html .= Html::rawElement( $options['wrapper'], $options['parameters'],
$this->get( $object ) );
}
}
@ -571,21 +644,21 @@ class CitizenTemplate extends BaseTemplate {
protected function getPortlet( $name, $content, $msg = null, $setOptions = [] ) {
// random stuff to override with any provided options
$options = $setOptions + [
// extra classes/ids
'id' => 'p-' . $name,
'class' => 'mw-portlet',
'extra-classes' => '',
// what to wrap the body list in, if anything
'body-wrapper' => 'nav',
'body-id' => null,
'body-class' => 'mw-portlet-body',
// makeListItem options
'list-item' => [ 'text-wrapper' => [ 'tag' => 'span' ] ],
// option to stick arbitrary stuff at the beginning of the ul
'list-prepend' => '',
// old toolbox hook support (use: [ 'SkinTemplateToolboxEnd' => [ &$skin, true ] ])
'hooks' => ''
];
// extra classes/ids
'id' => 'p-' . $name,
'class' => 'mw-portlet',
'extra-classes' => '',
// what to wrap the body list in, if anything
'body-wrapper' => 'nav',
'body-id' => null,
'body-class' => 'mw-portlet-body',
// makeListItem options
'list-item' => [ 'text-wrapper' => [ 'tag' => 'span' ] ],
// option to stick arbitrary stuff at the beginning of the ul
'list-prepend' => '',
// old toolbox hook support (use: [ 'SkinTemplateToolboxEnd' => [ &$skin, true ] ])
'hooks' => '',
];
// Handle the different $msg possibilities
if ( $msg === null ) {
@ -609,9 +682,9 @@ class CitizenTemplate extends BaseTemplate {
$labelId = Sanitizer::escapeIdForAttribute( "p-$name-label" );
if ( is_array( $content ) ) {
$contentText = Html::openElement( 'ul',
[ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ]
);
$contentText =
Html::openElement( 'ul',
[ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ] );
$contentText .= $options['list-prepend'];
foreach ( $content as $key => $item ) {
$contentText .= $this->makeListItem( $key, $item, $options['list-item'] );
@ -640,7 +713,7 @@ class CitizenTemplate extends BaseTemplate {
'role' => 'navigation',
'id' => Sanitizer::escapeIdForAttribute( $options['id'] ),
'title' => Linker::titleAttrib( $options['id'] ),
'aria-labelledby' => $labelId
'aria-labelledby' => $labelId,
];
if ( !is_array( $options['class'] ) ) {
$class = [ $options['class'] ];
@ -648,12 +721,13 @@ class CitizenTemplate extends BaseTemplate {
if ( !is_array( $options['extra-classes'] ) ) {
$extraClasses = [ $options['extra-classes'] ];
}
$divOptions['class'] = array_merge( $class, $extraClasses );
$divOptions['class'] =
array_merge( $class ?? $options['class'], $extraClasses ?? $options['extra-classes'] );
$labelOptions = [
'id' => $labelId,
'lang' => $this->get( 'userlang' ),
'dir' => $this->get( 'dir' )
'dir' => $this->get( 'dir' ),
];
if ( $options['body-wrapper'] !== 'none' ) {
@ -661,20 +735,15 @@ class CitizenTemplate extends BaseTemplate {
if ( is_string( $options['body-id'] ) ) {
$bodyDivOptions['id'] = $options['body-id'];
}
$body = Html::rawElement( $options['body-wrapper'], $bodyDivOptions,
$contentText .
$this->getAfterPortlet( $name )
);
$body =
Html::rawElement( $options['body-wrapper'], $bodyDivOptions,
$contentText . $this->getAfterPortlet( $name ) );
} else {
$body = $contentText . $this->getAfterPortlet( $name );
}
$html = Html::rawElement( 'div', $divOptions,
Html::rawElement( 'h3', $labelOptions, $msgString ) .
$body
);
return $html;
return Html::rawElement( 'div', $divOptions,
Html::rawElement( 'h3', $labelOptions, $msgString ) . $body );
}
/**
@ -687,11 +756,14 @@ class CitizenTemplate extends BaseTemplate {
* @return string html
*/
protected function deprecatedHookHack( $hook, $hookOptions = [] ) {
$hookContents = '';
ob_start();
Hooks::run( $hook, $hookOptions );
$hookContents = ob_get_contents();
ob_end_clean();
try {
Hooks::run( $hook, $hookOptions );
} catch ( Exception $e ) {
// Do nothing
}
$hookContents = ob_get_clean();
if ( !trim( $hookContents ) ) {
$hookContents = '';
}
@ -717,12 +789,12 @@ class CitizenTemplate extends BaseTemplate {
protected function getFooterBlock( $setOptions = [] ) {
// Set options and fill in defaults
$options = $setOptions + [
'id' => 'footer',
'order' => 'linksfirst',
'link-prefix' => 'footer',
'icon-style' => 'icononly',
'link-style' => 'flat'
];
'id' => 'footer',
'order' => 'linksfirst',
'link-prefix' => 'footer',
'icon-style' => 'icononly',
'link-style' => 'flat',
];
$validFooterIcons = $this->getFooterIcons( $options['icon-style'] );
$validFooterLinks = $this->getFooterLinks( $options['link-style'] );
@ -732,24 +804,23 @@ class CitizenTemplate extends BaseTemplate {
'id' => $options['id'],
'role' => 'contentinfo',
'lang' => $this->get( 'userlang' ),
'dir' => $this->get( 'dir' )
'dir' => $this->get( 'dir' ),
] );
$iconsHTML = '';
if ( count( $validFooterIcons ) > 0 ) {
$iconsHTML .= Html::openElement( 'div', [ 'id' => "{$options['link-prefix']}-container-icons"] );
$iconsHTML .= Html::openElement( 'div', [ 'id' => "footer-bottom-container"] );
$iconsHTML .= Html::openElement( 'div',
[ 'id' => "{$options['link-prefix']}-container-icons" ] );
$iconsHTML .= Html::openElement( 'div', [ 'id' => 'footer-bottom-container' ] );
// Get tagline
$iconsHTML .= $this -> getFooterTagline();
$iconsHTML .= $this->getFooterTagline();
$iconsHTML .= Html::openElement( 'ul', [ 'id' => "{$options['link-prefix']}-icons" ] );
foreach ( $validFooterIcons as $blockName => $footerIcons ) {
$iconsHTML .= Html::openElement( 'li', [
'id' => Sanitizer::escapeIdForAttribute(
"{$options['link-prefix']}-{$blockName}ico"
),
'class' => 'footer-icons'
'id' => Sanitizer::escapeIdForAttribute( "{$options['link-prefix']}-{$blockName}ico" ),
'class' => 'footer-icons',
] );
foreach ( $footerIcons as $icon ) {
$iconsHTML .= $this->getSkin()->makeFooterIcon( $icon );
@ -763,75 +834,57 @@ class CitizenTemplate extends BaseTemplate {
$linksHTML = '';
if ( count( $validFooterLinks ) > 0 ) {
$linksHTML .= Html::openElement( 'div', [ 'id' => "{$options['link-prefix']}-container-list" ] );
if ( $options['link-style'] == 'flat' ) {
$linksHTML .= Html::openElement( 'div',
[ 'id' => "{$options['link-prefix']}-container-list" ] );
if ( $options['link-style'] === 'flat' ) {
$linksHTML .= Html::openElement( 'ul', [
'id' => "{$options['link-prefix']}-list",
'class' => 'footer-places'
'class' => 'footer-places',
] );
// Site logo
// $linksHTML .= Html::rawElement( 'li', [ 'id' => 'sitelogo' ],
// $this->getLogo()
// );
// Site title
$linksHTML .= Html::rawElement( 'li', [ 'id' => 'sitetitle' ],
$this->getSiteTitle('text')
);
$this->getSiteTitle( 'text' ) );
// Site description
$linksHTML .= Html::rawElement( 'li', [ 'id' => 'sitedesc' ],
$this->getFooterDesc()
);
$this->getFooterDesc() );
foreach ( $validFooterLinks as $link ) {
$linksHTML .= Html::rawElement(
'li',
[ 'id' => Sanitizer::escapeIdForAttribute( $link ) ],
$this->get( $link )
);
$linksHTML .= Html::rawElement( 'li',
[ 'id' => Sanitizer::escapeIdForAttribute( $link ) ], $this->get( $link ) );
}
$linksHTML .= Html::closeElement( 'ul' );
} else {
$linksHTML .= Html::openElement( 'div', [ 'id' => "{$options['link-prefix']}-list" ] );
$linksHTML .= Html::openElement( 'div',
[ 'id' => "{$options['link-prefix']}-list" ] );
foreach ( $validFooterLinks as $category => $links ) {
$linksHTML .= Html::openElement( 'ul',
[ 'id' => Sanitizer::escapeIdForAttribute(
"{$options['link-prefix']}-{$category}"
) ]
);
$linksHTML .= Html::openElement( 'ul', [
'id' => Sanitizer::escapeIdForAttribute( "{$options['link-prefix']}-{$category}" ),
] );
foreach ( $links as $link ) {
$linksHTML .= Html::rawElement(
'li',
[ 'id' => Sanitizer::escapeIdForAttribute(
"{$options['link-prefix']}-{$category}-{$link}"
) ],
$this->get( $link )
);
$linksHTML .= Html::rawElement( 'li', [
'id' => Sanitizer::escapeIdForAttribute( "{$options['link-prefix']}-{$category}-{$link}" ),
], $this->get( $link ) );
}
$linksHTML .= Html::closeElement( 'ul' );
}
// Site title
$linksHTML .= Html::rawElement( 'li', [ 'id' => 'footer-sitetitle' ],
$this->getSiteTitle('text')
);
$this->getSiteTitle( 'text' ) );
// Site logo
$linksHTML .= Html::rawElement( 'li', [ 'id' => 'footer-sitelogo' ],
$this->getLogo()
);
$this->getLogo() );
$linksHTML .= Html::closeElement( 'div' );
}
$linksHTML .= Html::closeElement( 'div' );
}
if ( $options['order'] == 'iconsfirst' ) {
if ( $options['order'] === 'iconsfirst' ) {
$html .= $iconsHTML . $linksHTML;
} else {
$html .= $linksHTML . $iconsHTML;
}
$html .= $this->getClear() . Html::closeElement( 'footer' );
return $html;
return $html . $this->getClear() . Html::closeElement( 'footer' );
}
}

View file

@ -1,107 +1,205 @@
<?php
/**
* SkinTemplate class for the Citizen skin
*
* @ingroup Skins
*/
class SkinCitizen extends SkinTemplate {
public $skinname = 'citizen',
$stylename = 'Citizen',
$template = 'CitizenTemplate';
public $skinName = 'citizen';
public $styleName = 'Citizen';
public $template = 'CitizenTemplate';
/**
* @var OutputPage
*/
private $out;
/**
* ResourceLoader
*
* @param $out OutputPage
* @param OutputPage $out
*/
public function initPage( OutputPage $out ) {
$this->out = $out;
// Responsive layout
$out->addMeta( 'viewport',
'width=device-width, initial-scale=1.0'
);
$out->addMeta( 'viewport', 'width=device-width, initial-scale=1.0' );
// Theme color
$out->addMeta( 'theme-color',
$this->getConfig()->get( 'CitizenThemeColor' )
);
$out->addMeta( 'theme-color', $this->getConfigValue( 'CitizenThemeColor' ) ?? '' );
// Preconnect origin
if ( $this->getConfig()->get( 'CitizenEnablePreconnect' ) ) {
$out->addLink(
[
'rel' => 'preconnect',
'href' => $this->getConfig()->get( 'CitizenPreconnectURL' )
]
);
}
$this->addPreConnect();
// Generate manifest
if ( $this->getConfig()->get( 'CitizenEnableManifest' ) ) {
$out->addLink(
[
'rel' => 'manifest',
'href' => wfExpandUrl(
wfAppendQuery(
wfScript( 'api' ),
[ 'action' => 'webapp-manifest' ]
),
PROTO_RELATIVE
)
]
);
}
$this->addManifest();
// HTTP headers
// CSP
if ( $this->getConfig()->get( 'CitizenEnableCSP' ) ) {
$this->addCSP();
$cspdirective = $this->getConfig()->get( 'CitizenCSPDirective' );
// HSTS
$this->addHSTS();
// Deny X-Frame-Options
$this->addXFrameOptions();
// Strict referrer policy
$this->addStrictReferrerPolicy();
// Feature policy
$this->addFeaturePolicy();
$this->addModules();
}
/**
* getConfig() wrapper to catch exceptions.
* Returns null on exception
*
* @param string $key
* @return mixed|null
* @see SkinTemplate::getConfig()
*/
private function getConfigValue( $key ) {
try {
$value = $this->getConfig()->get( $key );
} catch ( ConfigException $e ) {
$value = null;
}
return $value;
}
/**
* Adds a preconnect header if enabled in 'CitizenEnablePreconnect'
*/
private function addPreConnect() {
if ( $this->getConfigValue( 'CitizenEnablePreconnect' ) === true ) {
$this->out->addLink( [
'rel' => 'preconnect',
'href' => $this->getConfigValue( 'CitizenPreconnectURL' ),
] );
}
}
/**
* Adds the manifest if enabled in 'CitizenEnableManifest'.
* Manifest link will be empty if wfExpandUrl throws an exception.
*/
private function addManifest() {
if ( $this->getConfigValue( 'CitizenEnableManifest' ) === true ) {
try {
$href =
wfExpandUrl( wfAppendQuery( wfScript( 'api' ),
[ 'action' => 'webapp-manifest' ] ), PROTO_RELATIVE );
} catch ( Exception $e ) {
$href = '';
}
$this->out->addLink( [
'rel' => 'manifest',
'href' => $href,
] );
}
}
/**
* Adds the csp directive if enabled in 'CitizenEnableCSP'.
* Directive holds the content of 'CitizenCSPDirective'.
*/
private function addCSP() {
if ( $this->getConfigValue( 'CitizenEnableCSP' ) === true ) {
$cspDirective = $this->getConfigValue( 'CitizenCSPDirective' ) ?? '';
$cspMode = 'Content-Security-Policy';
// Check if report mode is enabled
if ( $this->getConfig()->get( 'CitizenEnableCSPReportMode' ) ) {
$out->getRequest()->response()->header( 'Content-Security-Policy-Report-Only: ' . $cspdirective );
} else {
$out->getRequest()->response()->header( 'Content-Security-Policy: ' . $cspdirective );
if ( $this->getConfigValue( 'CitizenEnableCSPReportMode' ) !== null ) {
$cspMode = 'Content-Security-Policy-Report-Only';
}
}
// HSTS
if ( $this->getConfig()->get( 'CitizenEnableHSTS' ) ) {
$hstsmaxage = $this->getConfig()->get( 'CitizenHSTSMaxAge' );
$hstsincludesubdomains = $this->getConfig()->get( 'CitizenHSTSIncludeSubdomains' );
$hstspreload = $this->getConfig()->get( 'CitizenHSTSPreload' );
$this->out->getRequest()->response()->header( sprintf( '%s: %s', $cspMode,
$cspDirective ) );
}
}
/**
* Adds the HSTS Header. If no max age or an invalid max age is set a default of 300 will be
* applied.
* Preload and Include Subdomains can be enabled by setting 'CitizenHSTSIncludeSubdomains'
* and/or 'CitizenHSTSPreload' to true.
*/
private function addHSTS() {
if ( $this->getConfigValue( 'CitizenEnableHSTS' ) === true ) {
$maxAge = $this->getConfigValue( 'CitizenHSTSMaxAge' );
$includeSubdomains = $this->getConfigValue( 'CitizenHSTSIncludeSubdomains' ) ?? false;
$preload = $this->getConfigValue( 'CitizenHSTSPreload' ) ?? false;
// HSTS max age
if ( is_int( $hstsmaxage ) ) {
$hstsmaxage = max($hstsmaxage, 0);
if ( is_int( $maxAge ) ) {
$maxAge = max( $maxAge, 0 );
} else {
// Default to 5 mins if input is invalid
$hstsmaxage = 300;
$maxAge = 300;
}
$out->getRequest()->response()->header( 'Strict-Transport-Security: max-age=' . $hstsmaxage . ( $hstsincludesubdomains ? '; includeSubDomains' : '' ) . ( $hstspreload ? '; preload' : '' ) );
$hstsHeader = 'Strict-Transport-Security: max-age=' . $maxAge;
if ( $includeSubdomains ) {
$hstsHeader .= '; includeSubDomains';
}
if ( $preload ) {
$hstsHeader .= '; preload';
}
$this->out->getRequest()->response()->header( $hstsHeader );
}
// Deny X-Frame-Options
if ( $this->getConfig()->get( 'CitizenEnableDenyXFrameOptions' ) ) {
$out->getRequest()->response()->header( 'X-Frame-Options: deny' );
}
/**
* Adds the X-Frame-Options header if set in 'CitizenEnableDenyXFrameOptions'
*/
private function addXFrameOptions() {
if ( $this->getConfigValue( 'CitizenEnableDenyXFrameOptions' ) === true ) {
$this->out->getRequest()->response()->header( 'X-Frame-Options: deny' );
}
// Strict referrer policy
if ( $this->getConfig()->get( 'CitizenEnableStrictReferrerPolicy' ) ) {
}
/**
* Adds the referrer header if enabled in 'CitizenEnableStrictReferrerPolicy'
*/
private function addStrictReferrerPolicy() {
if ( $this->getConfigValue( 'CitizenEnableStrictReferrerPolicy' ) === true ) {
// iOS Safari, IE, Edge compatiblity
$out->addMeta( 'referrer',
'strict-origin'
);
$out->addMeta( 'referrer',
'strict-origin-when-cross-origin'
);
$out->getRequest()->response()->header( 'Referrer-Policy: strict-origin-when-cross-origin' );
$this->out->addMeta( 'referrer', 'strict-origin' );
$this->out->addMeta( 'referrer', 'strict-origin-when-cross-origin' );
$this->out->getRequest()
->response()
->header( 'Referrer-Policy: strict-origin-when-cross-origin' );
}
// Feature policy
if ( $this->getConfig()->get( 'CitizenEnableFeaturePolicy' ) ) {
}
$fpdirective = $this->getConfig()->get( 'CitizenFeaturePolicyDirective' );
/**
* Adds the Feature policy header to the response if enabled in 'CitizenFeaturePolicyDirective'
*/
private function addFeaturePolicy() {
if ( $this->getConfigValue( 'CitizenEnableFeaturePolicy' ) === true ) {
$out->getRequest()->response()->header( 'Feature-Policy: ' . $fpdirective );
$featurePolicy = $this->getConfigValue( 'CitizenFeaturePolicyDirective' ) ?? '';
$this->out->getRequest()->response()->header( sprintf( 'Feature-Policy: %s',
$featurePolicy ) );
}
}
$out->addModuleStyles( [
/**
* Adds all needed skin modules
*/
private function addModules() {
$this->out->addModuleStyles( [
'mediawiki.skinning.content.externallinks',
'skins.citizen',
'skins.citizen.icons',
@ -114,18 +212,12 @@ class SkinCitizen extends SkinTemplate {
'skins.citizen.icons.pt',
'skins.citizen.icons.footer',
'skins.citizen.icons.badges',
'skins.citizen.icons.search'
'skins.citizen.icons.search',
] );
$out->addModules( [
'skins.citizen.js',
'skins.citizen.search'
] );
}
/**
* @param $out OutputPage
*/
function setupSkinUserCss( OutputPage $out ) {
parent::setupSkinUserCss( $out );
$this->out->addModules( [
'skins.citizen.js',
'skins.citizen.search',
] );
}
}

View file

@ -1,55 +1,91 @@
<?php
namespace Citizen;
use ApiBase;
use ApiFormatJson;
use ApiResult;
use ConfigException;
use Exception;
use MWHttpRequest;
use Title;
/**
* Extract and modified from MobileFrontend extension
* Return the webapp manifest for this wiki
*/
class ApiWebappManifest extends ApiBase {
/**
* Execute the requested Api actions.
*/
public function execute() {
$config = $this->getConfig();
$resultObj = $this->getResult();
$resultObj->addValue( null, 'name', $config->get( 'Sitename' ) );
$resultObj->addValue( null, 'short_name', $config->get( 'Sitename' ) ); // Might as well add shortname
$resultObj->addValue( null, 'name', $this->getConfigSafe( 'Sitename' ) );
// Might as well add shortname
$resultObj->addValue( null, 'short_name', $this->getConfigSafe( 'Sitename' ) );
$resultObj->addValue( null, 'orientation', 'portrait' );
$resultObj->addValue( null, 'dir', $config->get( 'ContLang' )->getDir() );
$resultObj->addValue( null, 'lang', $config->get( 'LanguageCode' ) );
$resultObj->addValue( null, 'display', 'standalone' ); // Changed to standalone to provide better experience
$resultObj->addValue( null, 'theme_color', $config->get( 'CitizenManifestThemeColor' ) );
$resultObj->addValue( null, 'background_color', $config->get( 'CitizenManifestBackgroundColor' ) );
if ( $this->getConfigSafe( 'ContLang', false ) !== false ) {
$resultObj->addValue( null, 'dir', $this->getConfigSafe( 'ContLang' )->getDir() );
}
$resultObj->addValue( null, 'lang', $this->getConfigSafe( 'LanguageCode' ) );
// Changed to standalone to provide better experience
$resultObj->addValue( null, 'display', 'standalone' );
$resultObj->addValue( null, 'theme_color',
$this->getConfigSafe( 'CitizenManifestThemeColor' ) );
$resultObj->addValue( null, 'background_color',
$this->getConfigSafe( 'CitizenManifestBackgroundColor' ) );
$resultObj->addValue( null, 'start_url', Title::newMainPage()->getLocalUrl() );
$icons = [];
$appleTouchIcon = $config->get( 'AppleTouchIcon' );
if ( $appleTouchIcon !== false ) {
$appleTouchIconUrl = wfExpandUrl( $appleTouchIcon, PROTO_CURRENT );
$request = MWHttpRequest::factory( $appleTouchIconUrl );
$request->execute();
$appleTouchIconContent = $request->getContent();
if ( !empty( $appleTouchIconContent ) ) {
$appleTouchIconSize = getimagesizefromstring( $appleTouchIconContent );
}
$icon = [
'src' => $appleTouchIcon
];
if ( isset( $appleTouchIconSize ) && $appleTouchIconSize !== false ) {
$icon['sizes'] = $appleTouchIconSize[0].'x'.$appleTouchIconSize[1];
$icon['type'] = $appleTouchIconSize['mime'];
}
$icons[] = $icon;
}
$resultObj->addValue( null, 'icons', $icons );
$this->addIcons( $resultObj );
$main = $this->getMain();
$main->setCacheControl( [ 's-maxage' => 86400, 'max-age' => 86400 ] );
$main->setCacheMode( 'public' );
}
/**
* @param ApiResult $result
*/
private function addIcons( $result ) {
$icons = [];
$appleTouchIcon = $this->getConfigSafe( 'AppleTouchIcon', false );
if ( $appleTouchIcon !== false ) {
try {
$appleTouchIconUrl = wfExpandUrl( $appleTouchIcon, PROTO_CURRENT );
} catch ( Exception $e ) {
return;
}
$request = MWHttpRequest::factory( $appleTouchIconUrl );
$request->execute();
$appleTouchIconContent = $request->getContent();
if ( !empty( $appleTouchIconContent ) ) {
$appleTouchIconSize = getimagesizefromstring( $appleTouchIconContent );
}
$icon = [
'src' => $appleTouchIcon,
];
if ( isset( $appleTouchIconSize ) && $appleTouchIconSize !== false ) {
$icon['sizes'] = $appleTouchIconSize[0] . 'x' . $appleTouchIconSize[1];
$icon['type'] = $appleTouchIconSize['mime'];
}
$icons[] = $icon;
}
$result->addValue( null, 'icons', $icons );
}
/**
* Get the JSON printer
*
@ -59,4 +95,19 @@ class ApiWebappManifest extends ApiBase {
return new ApiFormatJson( $this->getMain(), 'json' );
}
/**
* Calls getConfig. Returns empty string on exception or $default;
*
* @param string $key
* @param string|integer $default
* @return mixed|string
* @see Config::get()
*/
private function getConfigSafe( $key, $default = null ) {
try {
return $this->getConfig()->get( $key );
} catch ( ConfigException $e ) {
return $default ?? '';
}
}
}

View file

@ -1,43 +1,58 @@
/*
* Scroll up Header
* Modified from https://codepen.io/sajjad/pen/vgEZNy
* TODO: Convert to Vanilla JS
*/
// Hide header on scroll down
var didScroll;
var lastScrollTop = 0;
var delta = 0;
var navbarHeight = $('.mw-header-container').outerHeight();
(function () {
// Hide header on scroll down
let didScroll;
let lastScrollTop = 0;
const delta = 0;
const header = document.getElementsByTagName('header')[0];
let navbarHeight = 0;
const headerContainer = document.querySelector('.mw-header-container');
if (headerContainer !== null) {
navbarHeight = headerContainer.offsetHeight;
}
$(window).scroll(function(event) {
didScroll = true;
});
window.addEventListener('scroll', () => {
didScroll = true;
});
setInterval(function() {
if (didScroll) {
hasScrolled();
didScroll = false;
}
}, 250);
setInterval(function () {
if (didScroll) {
hasScrolled();
didScroll = false;
}
}, 250);
function hasScrolled() {
var st = $(this).scrollTop();
function hasScrolled() {
const st = window.scrollY;
// Make scroll more than delta
if (Math.abs(lastScrollTop - st) <= delta)
return;
// Make scroll more than delta
if (Math.abs(lastScrollTop - st) <= delta) {
return;
}
if (st > lastScrollTop && st > navbarHeight) {
// If scrolled down and past the navbar, add class .nav-up.
// Scroll Down
$('header').removeClass('nav-down').addClass('nav-up');
} else {
// Scroll Up
if (st + $(window).height() < $(document).height()) {
$('header').removeClass('nav-up').addClass('nav-down');
}
}
if (st > lastScrollTop && st > navbarHeight) {
// If scrolled down and past the navbar, add class .nav-up.
// Scroll Down
header.classList.remove('nav-down');
header.classList.add('nav-up');
} else {
// Scroll Up
const body = document.body;
const html = document.documentElement;
lastScrollTop = st;
}
const documentHeight = Math.max(body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight);
if (st + window.innerHeight < documentHeight) {
header.classList.remove('nav-up');
header.classList.add('nav-down');
}
}
lastScrollTop = st;
}
})();

View file

@ -7,33 +7,33 @@
// Native API
if ("loading" in HTMLImageElement.prototype) {
document.querySelectorAll("img.lazy").forEach(function(img) {
img.setAttribute("src", img.getAttribute("data-src"));
if (img.hasAttribute("data-srcset")) {
img.setAttribute("srcset", img.getAttribute("data-srcset"));
}
img.classList.remove("lazy");
});
document.querySelectorAll("img.lazy").forEach(function(img) {
img.setAttribute("src", img.getAttribute("data-src"));
if (img.hasAttribute("data-srcset")) {
img.setAttribute("srcset", img.getAttribute("data-srcset"));
}
img.classList.remove("lazy");
});
} else {
// IntersectionObserver API
if (typeof IntersectionObserver !== "undefined" && "forEach" in NodeList.prototype) {
var observer = new IntersectionObserver(function(changes) {
if ("connection" in navigator && navigator.connection.saveData === true) {
return;
}
changes.forEach(function(change) {
if (change.isIntersecting) {
change.target.setAttribute("src", change.target.getAttribute("data-src"));
if (change.target.hasAttribute("data-srcset")) {
change.target.setAttribute("srcset", change.target.getAttribute("data-srcset"));
}
change.target.classList.remove("lazy");
observer.unobserve(change.target);
}
});
});
document.querySelectorAll("img.lazy").forEach(function(img) {
observer.observe(img);
});
}
// IntersectionObserver API
if (typeof IntersectionObserver !== "undefined" && "forEach" in NodeList.prototype) {
const observer = new IntersectionObserver(function (changes) {
if ("connection" in navigator && navigator.connection.saveData === true) {
return;
}
changes.forEach(function (change) {
if (change.isIntersecting) {
change.target.setAttribute("src", change.target.getAttribute("data-src"));
if (change.target.hasAttribute("data-srcset")) {
change.target.setAttribute("srcset", change.target.getAttribute("data-srcset"));
}
change.target.classList.remove("lazy");
observer.unobserve(change.target);
}
});
});
document.querySelectorAll("img.lazy").forEach(function(img) {
observer.observe(img);
});
}
}

View file

@ -6,55 +6,62 @@
*/
const SmoothScroll = () => {
if (!'scrollBehavior' in document.documentElement.style) {
const navLinks = document.querySelectorAll('#toc a');
if (!('scrollBehavior' in document.documentElement.style)) {
const navLinks = document.querySelectorAll('#toc a');
for (let n in navLinks) {
if (navLinks.hasOwnProperty(n)) {
navLinks[n].addEventListener('click', e => {
e.preventDefault();
document.querySelector(navLinks[n].hash)
.scrollIntoView({
behavior: "smooth"
});
});
}
}
}
}
const eventListener = e => {
e.preventDefault();
document.querySelector(navLinks[n].hash)
.scrollIntoView({
behavior: "smooth"
});
};
for (let n in navLinks) {
if (navLinks.hasOwnProperty(n)) {
navLinks[n].addEventListener('click', eventListener);
}
}
}
};
const ScrollSpy = () => {
const sections = document.querySelectorAll('.mw-headline');
const sections = document.querySelectorAll('.mw-headline');
window.onscroll = () => {
const scrollPos = document.documentElement.scrollTop || document.body.scrollTop;
window.addEventListener('scroll', () => {
const scrollPos = document.documentElement.scrollTop || document.body.scrollTop;
for (let s in sections)
if (sections.hasOwnProperty(s) && sections[s].offsetTop <= scrollPos) {
const id = mw.util.escapeIdForAttribute(sections[s].id);
document.querySelector('.active').classList.remove('active');
document.querySelector(`a[href*="${ id }"]`).parentNode.classList.add('active');
}
}
}
for (let s in sections) {
if (sections.hasOwnProperty(s) && sections[s].offsetTop <= scrollPos) {
const id = mw.util.escapeIdForAttribute(sections[s].id);
document.querySelector('.active').classList.remove('active');
const node = document.querySelector(`a[href * = "${id}"]`).parentNode;
if (node !== null) {
node.classList.add('active');
}
}
}
});
};
const CheckToC = () => {
if (document.getElementById("toc")) {
SmoothScroll();
ScrollSpy();
}
}
if (document.getElementById('toc')) {
SmoothScroll();
ScrollSpy();
}
};
if (document.readyState !== 'loading') {
CheckToC();
CheckToC();
} else if (document.addEventListener) {
// All modern browsers to register DOMContentLoaded
document.addEventListener('DOMContentLoaded', CheckToC);
// All modern browsers to register DOMContentLoaded
document.addEventListener('DOMContentLoaded', CheckToC);
} else {
// Old IE browsers
document.attachEvent('onreadystatechange', function() {
if (document.readyState === 'complete') {
CheckToC();
}
});
// Old IE browsers
document.attachEvent('onreadystatechange', function () {
if (document.readyState === 'complete') {
CheckToC();
}
});
}

View file

@ -7,9 +7,9 @@
( function ( WMTypeAhead ) {
var inputEvent,
searchInput = document.getElementById( 'search-input' ),
typeAhead = new WMTypeAhead( 'search-form', 'search-input' );
let inputEvent,
searchInput = document.getElementById('search-input'),
typeAhead = new WMTypeAhead('search-form', 'search-input');
/**
* Testing for 'input' event and falling back to 'propertychange' event for IE.

View file

@ -38,16 +38,19 @@ _.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
if (!options) { options = {};
}
var later = function() {
previous = options.leading === false ? 0 : _.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
if (!timeout) { context = args = null;
}
};
return function() {
var now = _.now();
if (!previous && options.leading === false) previous = now;
if (!previous && options.leading === false) { previous = now;
}
var remaining = wait - (now - previous);
context = this;
args = arguments;
@ -58,7 +61,8 @@ _.throttle = function(func, wait, options) {
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
if (!timeout) { context = args = null;
}
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
@ -78,7 +82,8 @@ _.debounce = function(func, wait, immediate) {
timeout = null;
if (!immediate) {
result = func.apply(context, args);
if (!timeout) context = args = null;
if (!timeout) { context = args = null;
}
}
}
};
@ -88,7 +93,8 @@ _.debounce = function(func, wait, immediate) {
args = arguments;
timestamp = _.now();
var callNow = immediate && !timeout;
if (!timeout) timeout = setTimeout(later, wait);
if (!timeout) { timeout = setTimeout(later, wait);
}
if (callNow) {
result = func.apply(context, args);
context = args = null;

View file

@ -27,476 +27,476 @@
window.WMTypeAhead = function(appendTo, searchInput) { // eslint-disable-line no-unused-vars
var typeAheadID = 'typeahead-suggestions',
typeAheadEl = document.getElementById(typeAheadID), // Type-ahead DOM element.
appendEl = document.getElementById(appendTo),
searchEl = document.getElementById(searchInput),
thumbnailSize = getDevicePixelRatio() * 80,
maxSearchResults = mw.config.get( 'wgCitizenMaxSearchResults' ),
searchLang,
searchString,
typeAheadItems,
activeItem,
ssActiveIndex;
// Only create typeAheadEl once on page.
if (!typeAheadEl) {
typeAheadEl = document.createElement('div');
typeAheadEl.id = typeAheadID;
appendEl.appendChild(typeAheadEl);
}
/**
* Serializes a JS object into a URL parameter string.
*
* @param {Object} obj - object whose properties will be serialized
* @return {string}
*/
function serialize(obj) {
var serialized = [],
prop;
for (prop in obj) {
if (obj.hasOwnProperty(prop)) { // eslint-disable-line no-prototype-builtins
serialized.push(prop + '=' + encodeURIComponent(obj[prop]));
}
}
return serialized.join('&');
}
/**
* Keeps track of the search query callbacks. Consists of an array of
* callback functions and an index that keeps track of the order of requests.
* Callbacks are deleted by replacing the callback function with a no-op.
*/
window.callbackStack = {
queue: {},
index: -1,
incrementIndex: function() {
this.index += 1;
return this.index;
},
addCallback: function(func) {
var index = this.incrementIndex();
this.queue[index] = func(index);
return index;
},
deleteSelfFromQueue: function(i) {
delete this.queue[i];
},
deletePrevCallbacks: function(j) {
var callback;
this.deleteSelfFromQueue(j);
for (callback in this.queue) {
if (callback < j) {
this.queue[callback] = this.deleteSelfFromQueue.bind(
window.callbackStack, callback
);
}
}
}
};
/**
* Maintains the 'active' state on search suggestions.
* Makes sure the 'active' element is synchronized between mouse and keyboard usage,
* and cleared when new search suggestions appear.
*/
ssActiveIndex = {
index: -1,
max: maxSearchResults,
setMax: function(x) {
this.max = x;
},
increment: function(i) {
this.index += i;
if (this.index < 0) {
this.setIndex(this.max - 1);
} // Index reaches top
if (this.index === this.max) {
this.setIndex(0);
} // Index reaches bottom
return this.index;
},
setIndex: function(i) {
if (i <= this.max - 1) {
this.index = i;
}
return this.index;
},
clear: function() {
this.setIndex(-1);
}
};
/**
* Removes the type-ahead suggestions from the DOM.
* Reason for timeout: The typeahead is set to clear on input blur.
* When a user clicks on a search suggestion, they triggers the input blur
* and remove the typeahead before a click event is registered.
* The timeout makes it so a click on the search suggestion is registered before
* an input blur.
* 300ms is used to account for the click delay on mobile devices.
*
*/
function clearTypeAhead() {
setTimeout(function() {
var searchScript = document.getElementById('api_opensearch');
typeAheadEl.innerHTML = '';
if (searchScript) {
searchScript.src = false;
}
ssActiveIndex.clear();
}, 300);
}
/**
* Manually redirects the page to the href of a given element.
*
* For Chrome on Android to solve T221628.
* When search suggestions below the fold are clicked, the blur event
* on the search input is triggered and the page scrolls the search input
* into view. However, the originating click event does not redirect
* the page.
*
* @param {Event} e
*/
function forceLinkFollow(e) {
var el = e.relatedTarget;
if (el && /suggestion-link/.test(el.className)) {
window.location = el.href;
}
}
/**
* Inserts script element containing the Search API results into document head.
* The script itself calls the 'portalOpensearchCallback' callback function,
*
* @param {string} string - query string to search.
* @param {string} lang - ISO code of language to search in.
*/
function loadQueryScript(string, lang) {
var script = document.getElementById('api_opensearch'),
docHead = document.getElementsByTagName('head')[0],
hostname,
callbackIndex,
searchQuery;
// Variables declared in parent function.
searchLang = encodeURIComponent(lang) || 'en';
searchString = encodeURIComponent(string);
if (searchString.length === 0) {
clearTypeAhead();
return;
}
// Change sitename here
// TODO: Make it configurable from the skin
hostname = mw.config.get('wgServer') + mw.config.get('wgScriptPath')+ '/api.php?';
// If script already exists, remove it.
if (script) {
docHead.removeChild(script);
}
script = document.createElement('script');
script.id = 'api_opensearch';
callbackIndex = window.callbackStack.addCallback(window.portalOpensearchCallback);
// Removed description prop
// TODO: Use text extract or PCS for description
searchQuery = {
action: 'query',
format: 'json',
generator: 'prefixsearch',
prop: 'pageprops|pageimages|description|extracts',
exlimit: mw.config.get( 'wgCitizenMaxSearchResults' ),
exintro: 1,
exchars: mw.config.get( 'wgCitizenSearchExchars' ),
explaintext: 1,
redirects: '',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pithumbsize: thumbnailSize,
pilimit: maxSearchResults,
gpssearch: string,
gpsnamespace: 0,
gpslimit: maxSearchResults,
callback: 'callbackStack.queue[' + callbackIndex + ']'
};
script.src = hostname + serialize(searchQuery);
docHead.appendChild(script);
}
// END loadQueryScript
/**
* Highlights the part of the suggestion title that matches the search query.
* Used inside the generateTemplateString function.
*
* @param {string} title - The title of the search suggestion.
* @param {string} searchString - The string to highlight.
* @return {string} The title with highlighted part in an <em> tag.
*/
function highlightTitle(title, searchString) {
var sanitizedSearchString = mw.html.escape(mw.RegExp.escape(searchString)),
searchRegex = new RegExp(sanitizedSearchString, 'i'),
startHighlightIndex = title.search(searchRegex),
formattedTitle = mw.html.escape(title),
endHighlightIndex,
strong,
beforeHighlight,
aferHighlight;
if (startHighlightIndex >= 0) {
endHighlightIndex = startHighlightIndex + sanitizedSearchString.length;
strong = title.substring(startHighlightIndex, endHighlightIndex);
beforeHighlight = title.substring(0, startHighlightIndex);
aferHighlight = title.substring(endHighlightIndex, title.length);
formattedTitle = beforeHighlight + mw.html.element('em', { 'class': 'suggestion-highlight' }, strong) + aferHighlight;
}
return formattedTitle;
} // END highlightTitle
/**
* Generates a template string based on an array of search suggestions.
*
* @param {Array} suggestions - An array of search suggestion results.
* @return {string} A string representing the search suggestions DOM
*/
function generateTemplateString(suggestions) {
var string = '<div class="suggestions-dropdown">',
suggestionLink,
suggestionThumbnail,
suggestionText,
suggestionTitle,
suggestionDescription,
page,
sanitizedThumbURL = false,
descriptionText = '',
pageDescription = '',
i;
for (i = 0; i < suggestions.length; i++) {
if (!suggestions[i]) {
continue;
}
page = suggestions[i];
// Description > TextExtracts
pageDescription = page.description || page.extract || '';
// Ensure that the value from the previous iteration isn't used
sanitizedThumbURL = false;
if (page.thumbnail && page.thumbnail.source) {
sanitizedThumbURL = page.thumbnail.source.replace(/"/g, '%22');
sanitizedThumbURL = sanitizedThumbURL.replace(/'/g, '%27');
}
// Ensure that the value from the previous iteration isn't used
descriptionText = '';
// Check if description exists
if (pageDescription) {
// If the description is an array, use the first item
if (typeof pageDescription === 'object' && pageDescription[0]) {
descriptionText = pageDescription[0].toString();
} else {
// Otherwise, use the description as is.
descriptionText = pageDescription.toString();
}
}
// Filter out no text from TextExtracts
if (descriptionText == '...') {
descriptionText = '';
}
suggestionDescription = mw.html.element('p', { 'class': 'suggestion-description' }, descriptionText);
suggestionTitle = mw.html.element('h3', { 'class': 'suggestion-title' }, new mw.html.Raw(highlightTitle(page.title, searchString)));
suggestionText = mw.html.element('div', { 'class': 'suggestion-text' }, new mw.html.Raw(suggestionTitle + suggestionDescription));
suggestionThumbnail = mw.html.element('div', {
'class': 'suggestion-thumbnail',
style: (sanitizedThumbURL) ? 'background-image:url(' + sanitizedThumbURL + ')' : false
}, '');
// TODO: Make it configurable from the skin
suggestionLink = mw.html.element('a', {
'class': 'suggestion-link',
href: mw.config.get('wgServer') + '/' + encodeURIComponent(page.title.replace(/ /gi, '_'))
}, new mw.html.Raw(suggestionText + suggestionThumbnail));
string += suggestionLink;
}
string += '</div>';
return string;
} // END generateTemplateString
/**
* - Removes 'active' class from a collection of elements.
* - Adds 'active' class to an item if missing.
* - Removes 'active' class from item if present.
*
* @param {HTMLElement} item Item to add active class to.
* @param {NodeList} collection Sibling items.
*/
function toggleActiveClass(item, collection) {
var activeClass = ' active', // Prefixed with space.
colItem,
i;
for (i = 0; i < collection.length; i++) {
colItem = collection[i];
// Remove the class name from everything except item.
if (colItem !== item) {
colItem.className = colItem.className.replace(activeClass, '');
} else {
// If item has class name, remove it
if (/ active/.test(item.className)) {
item.className = item.className.replace(activeClass, '');
} else {
// It item doesn't have class name, add it.
item.className += activeClass;
ssActiveIndex.setIndex(i);
}
}
}
}
/**
* Search API callback. Returns a closure that holds the index of the request.
* Deletes previous callbacks based on this index. This prevents callbacks for old
* requests from executing. Then:
* - parses the search results
* - generates the template String
* - inserts the template string into the DOM
* - attaches event listeners on each suggestion item.
*
* @param {number} i
* @return {Function}
*/
window.portalOpensearchCallback = function(i) {
var callbackIndex = i,
orderedResults = [],
suggestions,
item,
result,
templateDOMString,
listEl;
return function(xhrResults) {
window.callbackStack.deletePrevCallbacks(callbackIndex);
if (document.activeElement !== searchEl) {
return;
}
suggestions = (xhrResults.query && xhrResults.query.pages) ?
xhrResults.query.pages : [];
for (item in suggestions) {
result = suggestions[item];
orderedResults[result.index - 1] = result;
}
templateDOMString = generateTemplateString(orderedResults);
ssActiveIndex.setMax(orderedResults.length);
ssActiveIndex.clear();
typeAheadEl.innerHTML = templateDOMString;
typeAheadItems = typeAheadEl.childNodes[0].childNodes;
// Attaching hover events
for (i = 0; i < typeAheadItems.length; i++) {
listEl = typeAheadItems[i];
// Requires the addEvent global polyfill
addEvent(listEl, 'mouseenter', toggleActiveClass.bind(this, listEl, typeAheadItems));
addEvent(listEl, 'mouseleave', toggleActiveClass.bind(this, listEl, typeAheadItems));
}
};
};
/**
* Keyboard events: up arrow, down arrow and enter.
* moves the 'active' suggestion up and down.
*
* @param {event} event
*/
function keyboardEvents(event) {
var e = event || window.event,
keycode = e.which || e.keyCode,
suggestionItems,
searchSuggestionIndex;
if (!typeAheadEl.firstChild) {
return;
}
if (keycode === 40 || keycode === 38) {
suggestionItems = typeAheadEl.firstChild.childNodes;
if (keycode === 40) {
searchSuggestionIndex = ssActiveIndex.increment(1);
} else {
searchSuggestionIndex = ssActiveIndex.increment(-1);
}
activeItem = (suggestionItems) ? suggestionItems[searchSuggestionIndex] : false;
toggleActiveClass(activeItem, suggestionItems);
}
if (keycode === 13 && activeItem) {
if (e.preventDefault) {
e.preventDefault();
} else {
(e.returnValue = false);
}
activeItem.children[0].click();
}
}
addEvent(searchEl, 'keydown', keyboardEvents);
addEvent(searchEl, 'blur', function(e) {
clearTypeAhead();
forceLinkFollow(e);
});
return {
typeAheadEl: typeAheadEl,
query: loadQueryScript
};
var typeAheadID = 'typeahead-suggestions',
typeAheadEl = document.getElementById(typeAheadID), // Type-ahead DOM element.
appendEl = document.getElementById(appendTo),
searchEl = document.getElementById(searchInput),
thumbnailSize = getDevicePixelRatio() * 80,
maxSearchResults = mw.config.get( 'wgCitizenMaxSearchResults' ),
searchLang,
searchString,
typeAheadItems,
activeItem,
ssActiveIndex;
// Only create typeAheadEl once on page.
if (!typeAheadEl) {
typeAheadEl = document.createElement('div');
typeAheadEl.id = typeAheadID;
appendEl.appendChild(typeAheadEl);
}
/**
* Serializes a JS object into a URL parameter string.
*
* @param {Object} obj - object whose properties will be serialized
* @return {string}
*/
function serialize(obj) {
var serialized = [],
prop;
for (prop in obj) {
if (obj.hasOwnProperty(prop)) { // eslint-disable-line no-prototype-builtins
serialized.push(prop + '=' + encodeURIComponent(obj[prop]));
}
}
return serialized.join('&');
}
/**
* Keeps track of the search query callbacks. Consists of an array of
* callback functions and an index that keeps track of the order of requests.
* Callbacks are deleted by replacing the callback function with a no-op.
*/
window.callbackStack = {
queue: {},
index: -1,
incrementIndex: function() {
this.index += 1;
return this.index;
},
addCallback: function(func) {
var index = this.incrementIndex();
this.queue[index] = func(index);
return index;
},
deleteSelfFromQueue: function(i) {
delete this.queue[i];
},
deletePrevCallbacks: function(j) {
var callback;
this.deleteSelfFromQueue(j);
for (callback in this.queue) {
if (callback < j) {
this.queue[callback] = this.deleteSelfFromQueue.bind(
window.callbackStack, callback
);
}
}
}
};
/**
* Maintains the 'active' state on search suggestions.
* Makes sure the 'active' element is synchronized between mouse and keyboard usage,
* and cleared when new search suggestions appear.
*/
ssActiveIndex = {
index: -1,
max: maxSearchResults,
setMax: function(x) {
this.max = x;
},
increment: function(i) {
this.index += i;
if (this.index < 0) {
this.setIndex(this.max - 1);
} // Index reaches top
if (this.index === this.max) {
this.setIndex(0);
} // Index reaches bottom
return this.index;
},
setIndex: function(i) {
if (i <= this.max - 1) {
this.index = i;
}
return this.index;
},
clear: function() {
this.setIndex(-1);
}
};
/**
* Removes the type-ahead suggestions from the DOM.
* Reason for timeout: The typeahead is set to clear on input blur.
* When a user clicks on a search suggestion, they triggers the input blur
* and remove the typeahead before a click event is registered.
* The timeout makes it so a click on the search suggestion is registered before
* an input blur.
* 300ms is used to account for the click delay on mobile devices.
*
*/
function clearTypeAhead() {
setTimeout(function() {
var searchScript = document.getElementById('api_opensearch');
typeAheadEl.innerHTML = '';
if (searchScript) {
searchScript.src = false;
}
ssActiveIndex.clear();
}, 300);
}
/**
* Manually redirects the page to the href of a given element.
*
* For Chrome on Android to solve T221628.
* When search suggestions below the fold are clicked, the blur event
* on the search input is triggered and the page scrolls the search input
* into view. However, the originating click event does not redirect
* the page.
*
* @param {Event} e
*/
function forceLinkFollow(e) {
var el = e.relatedTarget;
if (el && /suggestion-link/.test(el.className)) {
window.location = el.href;
}
}
/**
* Inserts script element containing the Search API results into document head.
* The script itself calls the 'portalOpensearchCallback' callback function,
*
* @param {string} string - query string to search.
* @param {string} lang - ISO code of language to search in.
*/
function loadQueryScript(string, lang) {
var script = document.getElementById('api_opensearch'),
docHead = document.getElementsByTagName('head')[0],
hostname,
callbackIndex,
searchQuery;
// Variables declared in parent function.
searchLang = encodeURIComponent(lang) || 'en';
searchString = encodeURIComponent(string);
if (searchString.length === 0) {
clearTypeAhead();
return;
}
// Change sitename here
// TODO: Make it configurable from the skin
hostname = mw.config.get('wgServer') + mw.config.get('wgScriptPath') + '/api.php?';
// If script already exists, remove it.
if (script) {
docHead.removeChild(script);
}
script = document.createElement('script');
script.id = 'api_opensearch';
callbackIndex = window.callbackStack.addCallback(window.portalOpensearchCallback);
// Removed description prop
// TODO: Use text extract or PCS for description
searchQuery = {
action: 'query',
format: 'json',
generator: 'prefixsearch',
prop: 'pageprops|pageimages|description|extracts',
exlimit: mw.config.get( 'wgCitizenMaxSearchResults' ),
exintro: 1,
exchars: mw.config.get( 'wgCitizenSearchExchars' ),
explaintext: 1,
redirects: '',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pithumbsize: thumbnailSize,
pilimit: maxSearchResults,
gpssearch: string,
gpsnamespace: 0,
gpslimit: maxSearchResults,
callback: 'callbackStack.queue[' + callbackIndex + ']'
};
script.src = hostname + serialize(searchQuery);
docHead.appendChild(script);
}
// END loadQueryScript
/**
* Highlights the part of the suggestion title that matches the search query.
* Used inside the generateTemplateString function.
*
* @param {string} title - The title of the search suggestion.
* @param {string} searchString - The string to highlight.
* @return {string} The title with highlighted part in an <em> tag.
*/
function highlightTitle(title, searchString) {
var sanitizedSearchString = mw.html.escape(mw.RegExp.escape(searchString)),
searchRegex = new RegExp(sanitizedSearchString, 'i'),
startHighlightIndex = title.search(searchRegex),
formattedTitle = mw.html.escape(title),
endHighlightIndex,
strong,
beforeHighlight,
aferHighlight;
if (startHighlightIndex >= 0) {
endHighlightIndex = startHighlightIndex + sanitizedSearchString.length;
strong = title.substring(startHighlightIndex, endHighlightIndex);
beforeHighlight = title.substring(0, startHighlightIndex);
aferHighlight = title.substring(endHighlightIndex, title.length);
formattedTitle = beforeHighlight + mw.html.element('em', { 'class': 'suggestion-highlight' }, strong) + aferHighlight;
}
return formattedTitle;
} // END highlightTitle
/**
* Generates a template string based on an array of search suggestions.
*
* @param {Array} suggestions - An array of search suggestion results.
* @return {string} A string representing the search suggestions DOM
*/
function generateTemplateString(suggestions) {
var string = '<div class="suggestions-dropdown">',
suggestionLink,
suggestionThumbnail,
suggestionText,
suggestionTitle,
suggestionDescription,
page,
sanitizedThumbURL = false,
descriptionText = '',
pageDescription = '',
i;
for (i = 0; i < suggestions.length; i++) {
if (!suggestions[i]) {
continue;
}
page = suggestions[i];
// Description > TextExtracts
pageDescription = page.description || page.extract || '';
// Ensure that the value from the previous iteration isn't used
sanitizedThumbURL = false;
if (page.thumbnail && page.thumbnail.source) {
sanitizedThumbURL = page.thumbnail.source.replace(/"/g, '%22');
sanitizedThumbURL = sanitizedThumbURL.replace(/'/g, '%27');
}
// Ensure that the value from the previous iteration isn't used
descriptionText = '';
// Check if description exists
if (pageDescription) {
// If the description is an array, use the first item
if (typeof pageDescription === 'object' && pageDescription[0]) {
descriptionText = pageDescription[0].toString();
} else {
// Otherwise, use the description as is.
descriptionText = pageDescription.toString();
}
}
// Filter out no text from TextExtracts
if (descriptionText == '...') {
descriptionText = '';
}
suggestionDescription = mw.html.element('p', { 'class': 'suggestion-description' }, descriptionText);
suggestionTitle = mw.html.element('h3', { 'class': 'suggestion-title' }, new mw.html.Raw(highlightTitle(page.title, searchString)));
suggestionText = mw.html.element('div', { 'class': 'suggestion-text' }, new mw.html.Raw(suggestionTitle + suggestionDescription));
suggestionThumbnail = mw.html.element('div', {
'class': 'suggestion-thumbnail',
style: (sanitizedThumbURL) ? 'background-image:url(' + sanitizedThumbURL + ')' : false
}, '');
// TODO: Make it configurable from the skin
suggestionLink = mw.html.element('a', {
'class': 'suggestion-link',
href: mw.config.get('wgServer') + '/' + encodeURIComponent(page.title.replace(/ /gi, '_'))
}, new mw.html.Raw(suggestionText + suggestionThumbnail));
string += suggestionLink;
}
string += '</div>';
return string;
} // END generateTemplateString
/**
* - Removes 'active' class from a collection of elements.
* - Adds 'active' class to an item if missing.
* - Removes 'active' class from item if present.
*
* @param {HTMLElement} item Item to add active class to.
* @param {NodeList} collection Sibling items.
*/
function toggleActiveClass(item, collection) {
var activeClass = ' active', // Prefixed with space.
colItem,
i;
for (i = 0; i < collection.length; i++) {
colItem = collection[i];
// Remove the class name from everything except item.
if (colItem !== item) {
colItem.className = colItem.className.replace(activeClass, '');
} else {
// If item has class name, remove it
if (/ active/.test(item.className)) {
item.className = item.className.replace(activeClass, '');
} else {
// It item doesn't have class name, add it.
item.className += activeClass;
ssActiveIndex.setIndex(i);
}
}
}
}
/**
* Search API callback. Returns a closure that holds the index of the request.
* Deletes previous callbacks based on this index. This prevents callbacks for old
* requests from executing. Then:
* - parses the search results
* - generates the template String
* - inserts the template string into the DOM
* - attaches event listeners on each suggestion item.
*
* @param {number} i
* @return {Function}
*/
window.portalOpensearchCallback = function(i) {
var callbackIndex = i,
orderedResults = [],
suggestions,
item,
result,
templateDOMString,
listEl;
return function(xhrResults) {
window.callbackStack.deletePrevCallbacks(callbackIndex);
if (document.activeElement !== searchEl) {
return;
}
suggestions = (xhrResults.query && xhrResults.query.pages) ?
xhrResults.query.pages : [];
for (item in suggestions) {
result = suggestions[item];
orderedResults[result.index - 1] = result;
}
templateDOMString = generateTemplateString(orderedResults);
ssActiveIndex.setMax(orderedResults.length);
ssActiveIndex.clear();
typeAheadEl.innerHTML = templateDOMString;
typeAheadItems = typeAheadEl.childNodes[0].childNodes;
// Attaching hover events
for (i = 0; i < typeAheadItems.length; i++) {
listEl = typeAheadItems[i];
// Requires the addEvent global polyfill
addEvent(listEl, 'mouseenter', toggleActiveClass.bind(this, listEl, typeAheadItems));
addEvent(listEl, 'mouseleave', toggleActiveClass.bind(this, listEl, typeAheadItems));
}
};
};
/**
* Keyboard events: up arrow, down arrow and enter.
* moves the 'active' suggestion up and down.
*
* @param {event} event
*/
function keyboardEvents(event) {
var e = event || window.event,
keycode = e.which || e.keyCode,
suggestionItems,
searchSuggestionIndex;
if (!typeAheadEl.firstChild) {
return;
}
if (keycode === 40 || keycode === 38) {
suggestionItems = typeAheadEl.firstChild.childNodes;
if (keycode === 40) {
searchSuggestionIndex = ssActiveIndex.increment(1);
} else {
searchSuggestionIndex = ssActiveIndex.increment(-1);
}
activeItem = (suggestionItems) ? suggestionItems[searchSuggestionIndex] : false;
toggleActiveClass(activeItem, suggestionItems);
}
if (keycode === 13 && activeItem) {
if (e.preventDefault) {
e.preventDefault();
} else {
(e.returnValue = false);
}
activeItem.children[0].click();
}
}
addEvent(searchEl, 'keydown', keyboardEvents);
addEvent(searchEl, 'blur', function(e) {
clearTypeAhead();
forceLinkFollow(e);
});
return {
typeAheadEl: typeAheadEl,
query: loadQueryScript
};
};
/**
/**
* Below are additional dependency extracted from polyfills.js
* TODO: Optimize and clear unneeded code
*/

View file

@ -5,7 +5,6 @@
"author": "alistair3149",
"url": "https://starcitizen.tools",
"descriptionmsg": "citizen-desc",
"namemsg": "skinname-citizen",
"license-name": "GPL-3.0",
"type": "skin",
"requires": {
@ -126,6 +125,36 @@
"description": "The character limit for the description in search suggestion",
"descriptionmsg": "citizen-config-searchexchars",
"public": true
},
"EnableButton": {
"value": false,
"description": "Enable or disable the bottom left button",
"descriptionmsg": "citizen-config-enablebutton",
"public": true
},
"ButtonLink": {
"value": "",
"description": "Link the button leads to",
"descriptionmsg": "citizen-config-buttonlink",
"public": true
},
"ButtonTitle": {
"value": "",
"description": "Button title on hover",
"descriptionmsg": "citizen-config-buttontitle",
"public": true
},
"ButtonText": {
"value": "",
"description": "Button link text",
"descriptionmsg": "citizen-config-buttontext",
"public": true
},
"ShowPageTools": {
"value": true,
"description": "Page tools visibility condition",
"descriptionmsg": "citizen-config-showpagetools",
"public": true
}
},
"ConfigRegistry": {
@ -346,21 +375,21 @@
"AutoloadClasses": {
"SkinCitizen": "includes/SkinCitizen.php",
"CitizenTemplate": "includes/CitizenTemplate.php",
"CitizenHooks": "includes/CitizenHooks.php",
"ApiWebappManifest": "includes/api/ApiWebappManifest.php"
"Citizen\\CitizenHooks": "includes/CitizenHooks.php",
"Citizen\\ApiWebappManifest": "includes/api/ApiWebappManifest.php"
},
"APIModules": {
"webapp-manifest": "ApiWebappManifest"
"webapp-manifest": "Citizen\\ApiWebappManifest"
},
"Hooks": {
"BeforePageDisplay": [
"CitizenHooks::BeforePageDisplay"
"Citizen\\CitizenHooks::onBeforePageDisplay"
],
"ResourceLoaderGetConfigVars": [
"CitizenHooks::onResourceLoaderGetConfigVars"
"Citizen\\CitizenHooks::onResourceLoaderGetConfigVars"
],
"ThumbnailBeforeProduceHTML": [
"CitizenHooks::ThumbnailBeforeProduceHTML"
"Citizen\\CitizenHooks::onThumbnailBeforeProduceHTML"
]
},
"manifest_version": 2