mirror of
https://github.com/StarCitizenTools/mediawiki-skins-Citizen.git
synced 2024-11-28 00:01:05 +00:00
commit
3806384e93
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
vendor
|
||||
composer.lock
|
6
.phpcs.xml
Normal file
6
.phpcs.xml
Normal 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>
|
|
@ -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
20
composer.json
Normal 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
7
i18n/de.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"@metadata": {
|
||||
"authors": [ "Octfx" ]
|
||||
},
|
||||
"skinname-citizen": "Citizen",
|
||||
"citizen-desc": "Ein responsive Skin entwickelt für das Star Citizen Wiki"
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
] );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
43
skin.json
43
skin.json
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue