mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/TemplateStyles
synced 2024-11-27 17:50:29 +00:00
TemplateStyles extension prototype
This extension adds a <templatestyles> tag that, when placed on a template, allows specifying CSS for pages where that template is transcluded. Unlike inline styles, the per-template CSS supports rules with proper selectors, and @media blocks. THIS VERSION DOES NOT CURRENTLY FILTER DECLARATIONS and is therefore unsuitable for wikis where unprivileged users should not be allowed to influcence the pagewide CSS in unrestricted ways! Bug: T483 Change-Id: Ibc1cae3079d164f7ac7bcc7c4ded3f02bb048614
This commit is contained in:
parent
f063b69870
commit
31743445bd
24
.jshintrc
Normal file
24
.jshintrc
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
// Enforcing
|
||||
"bitwise": true,
|
||||
"eqeqeq": true,
|
||||
"freeze": true,
|
||||
"latedef": true,
|
||||
"noarg": true,
|
||||
"nonew": true,
|
||||
"undef": true,
|
||||
"unused": true,
|
||||
"strict": false,
|
||||
|
||||
// Relaxing
|
||||
"es5": false,
|
||||
|
||||
// Environment
|
||||
"browser": true,
|
||||
"jquery": true,
|
||||
|
||||
"globals": {
|
||||
"mediaWiki": false,
|
||||
"OO": false
|
||||
}
|
||||
}
|
242
CSSParser.php
Normal file
242
CSSParser.php
Normal file
|
@ -0,0 +1,242 @@
|
|||
<?php
|
||||
/**
|
||||
* @file
|
||||
* @ingroup Extensions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a style sheet as a structured tree, organized
|
||||
* in rule blocks nested in at-rule blocks.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
class CSSParser {
|
||||
|
||||
private $tokens;
|
||||
private $index;
|
||||
|
||||
/**
|
||||
* Parse and (minimally) validate the passed string as a CSS, and
|
||||
* constructs an array of tokens for parsing, as well as an index
|
||||
* into that array.
|
||||
*
|
||||
* Internally, the class behaves as a lexer.
|
||||
*
|
||||
* @param string $css
|
||||
*/
|
||||
function __construct( $css ) {
|
||||
preg_match_all( '/(
|
||||
[ \n\t]+
|
||||
| \/\* (?: [^*]+ | \*[^\/] )* \*\/ [ \n\t]*
|
||||
| " (?: [^"\\\\\n]+ | \\\\\. )* ["\n]
|
||||
| \' (?: [^\'\\\\\n]+ | \\\\\. )* [\'\n]
|
||||
| [+-]? (?: [0-9]* \. )? [0-9]+ (?: [_a-z][_a-z0-9-]* | % )?
|
||||
| url [ \n\t]* \(
|
||||
| @? -? (?: [_a-z] | \\\\[0-9a-f]{1,6} [ \n\t]? )
|
||||
(?: [_a-z0-9-]+ | \\\\[0-9a-f]{1,6} [ \n\t]? | [^\0-\177] )*
|
||||
| \# (?: [_a-z0-9-]+ | \\\\[0-9a-f]{1,6} [ \n\t]? | [^\0-\177] )*
|
||||
| u\+ [0-9a-f]{1,6} (?: - [0-9a-f]{1,6} )?
|
||||
| u\+ [0-9a-f?]{1,6}
|
||||
| <!--
|
||||
| -->
|
||||
| .)/xis', $css, $match );
|
||||
|
||||
$space = false;
|
||||
foreach ( $match[0] as $t ) {
|
||||
if ( preg_match( '/^(?:[ \n\t]|\/\*|<!--|-->)/', $t ) ) {
|
||||
if ( !$space ) {
|
||||
$space = true;
|
||||
$this->tokens[] = ' ';
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$space = false;
|
||||
$this->tokens[] = $t;
|
||||
}
|
||||
}
|
||||
$this->index = 0;
|
||||
}
|
||||
|
||||
private function peek( $i ) {
|
||||
if ( $this->index+$i >= count( $this->tokens ) )
|
||||
return null;
|
||||
return $this->tokens[$this->index+$i];
|
||||
}
|
||||
|
||||
private function consume( $num = 1 ) {
|
||||
if ( $num > 0 ) {
|
||||
if ( $this->index+$num >= count( $this->tokens ) )
|
||||
$num = count( $this->tokens ) - $this->index;
|
||||
$text = implode( array_slice( $this->tokens, $this->index, $num ) );
|
||||
$this->index += $num;
|
||||
return $text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function consumeTo( $delim ) {
|
||||
$consume = 0;
|
||||
while ( !in_array( $this->peek( $consume ), $delim ) )
|
||||
$consume++;
|
||||
return $this->consume( $consume );
|
||||
}
|
||||
|
||||
private function consumeWS() {
|
||||
$consume = 0;
|
||||
while ( $this->peek( $consume ) === ' ' )
|
||||
$consume++;
|
||||
return $this->consume( $consume );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses:
|
||||
* decl : WS* IDENT WS* ':' TOKEN* ';'
|
||||
* | WS* IDENT <error> ';' -> skip
|
||||
* ;
|
||||
*
|
||||
* Returns:
|
||||
* [ name => value ]
|
||||
*/
|
||||
private function parseDecl() {
|
||||
$this->consumeWS();
|
||||
$name = $this->consume();
|
||||
$this->consumeWS();
|
||||
if ( $this->peek( 0 )!=':' ) {
|
||||
$this->consumeTo( [';', '}', null] );
|
||||
if ( $this->peek( 0 ) == ';' ) {
|
||||
$this->consume();
|
||||
$this->consumeWS();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
$this->consume();
|
||||
$this->consumeWS();
|
||||
$value = $this->consumeTo( [';', '}', null] );
|
||||
if ( $this->peek( 0 ) == ';' ) {
|
||||
$value .= $this->consume();
|
||||
$this->consumeWS();
|
||||
}
|
||||
return [ $name => $value ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses:
|
||||
* decls : '}'
|
||||
* | decl decls
|
||||
* ;
|
||||
*
|
||||
* Returns:
|
||||
* [ decl* ]
|
||||
*/
|
||||
private function parseDecls() {
|
||||
$decls = [];
|
||||
while ( $this->peek( 0 ) !== null and $this->peek( 0 ) != '}' ) {
|
||||
$decl = $this->parseDecl();
|
||||
if ( $decl )
|
||||
foreach ( $decl as $k => $d )
|
||||
$decls[$k] = $d;
|
||||
}
|
||||
if ( $this->peek( 0 ) == '}' )
|
||||
$this->consume();
|
||||
return $decls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses:
|
||||
* rule : WS* selectors ';'
|
||||
* | WS* selectors '{' decls
|
||||
* ;
|
||||
* selectors : TOKEN*
|
||||
* | selectors ',' TOKEN*
|
||||
* ;
|
||||
*
|
||||
* Returns:
|
||||
* [ selectors => [ selector* ], decls => [ decl* ] ]
|
||||
*/
|
||||
public function parseRule() {
|
||||
$selectors = [];
|
||||
$text = '';
|
||||
$this->consumeWS();
|
||||
while ( !in_array( $this->peek( 0 ), ['{', ';', null] ) ) {
|
||||
if ( $this->peek( 0 ) == ',' ) {
|
||||
$selectors[] = $text;
|
||||
$this->consume();
|
||||
$this->consumeWS();
|
||||
$text = '';
|
||||
} else
|
||||
$text .= $this->consume();
|
||||
}
|
||||
$selectors[] = $text;
|
||||
if ( $this->peek( 0 ) == '{' ) {
|
||||
$this->consume();
|
||||
return [ "selectors"=>$selectors, "decls"=>$this->parseDecls() ];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the token array, and returns a tree representing the CSS suitable
|
||||
* for feeding CSSRenderer objects.
|
||||
*
|
||||
* @param array $end An array of string representing tokens that can end the parse. Defaults
|
||||
* to ending only at the end of the string.
|
||||
* @return array A tree describing the CSS rule blocks.
|
||||
*
|
||||
* Parses:
|
||||
* anyrule : ATIDENT='@media' WS* TOKEN* '{' rules '}'
|
||||
* | ATIDENT WS* TOKEN* ';'
|
||||
* | ATIDENT WS* TOKEN* '{' decls '}'
|
||||
* | rule
|
||||
* ;
|
||||
* rules : anyrule
|
||||
* | rules anyrule
|
||||
* ;
|
||||
*
|
||||
* Returns:
|
||||
* [ [ name=>ATIDENT? , text=>body? , rules=>rules? ]* ]
|
||||
*/
|
||||
public function rules( $end = [ null ] ) {
|
||||
$atrules = [];
|
||||
$rules = [];
|
||||
$this->consumeWS();
|
||||
while ( !in_array( $this->peek( 0 ), $end ) ) {
|
||||
if ( in_array( $this->peek( 0 ), [ '@media' ] ) ) {
|
||||
$at = $this->consume();
|
||||
$this->consumeWS();
|
||||
$text = '';
|
||||
while ( !in_array( $this->peek( 0 ), ['{', ';', null] ) )
|
||||
$text .= $this->consume();
|
||||
if ( $this->peek( 0 ) == '{' ) {
|
||||
$this->consume();
|
||||
$r = $this->rules( [ '}', null ] );
|
||||
if ( $r )
|
||||
$atrules[] = [ "name"=>$at, "text"=>$text, "rules"=>$r ];
|
||||
} else {
|
||||
$atrules[] = [ "name"=>$at, "text"=>$text ];
|
||||
}
|
||||
} elseif ( $this->peek( 0 )[0] == '@' ) {
|
||||
$at = $this->consume();
|
||||
$text = '';
|
||||
while ( !in_array( $this->peek( 0 ), ['{', ';', null] ) )
|
||||
$text .= $this->consume();
|
||||
if ( $this->peek( 0 ) == '{' ) {
|
||||
$this->consume();
|
||||
$decl = $this->parseDecls();
|
||||
if ( $decl )
|
||||
$atrules[] = [ "name"=>$at, "text"=>$text, "rules"=>[ "selectors"=>'', "decls"=>$decl ] ];
|
||||
} else {
|
||||
$atrules[] = [ "name"=>$at, "text"=>$text ];
|
||||
}
|
||||
} else
|
||||
$rules[] = $this->parseRule();
|
||||
$this->consumeWS();
|
||||
}
|
||||
if ( $rules )
|
||||
$atrules[] = [ "name"=>'', "rules"=>$rules ];
|
||||
if ( $this->peek( 0 ) !== null )
|
||||
$this->consume();
|
||||
return $atrules;
|
||||
}
|
||||
|
||||
}
|
||||
|
75
CSSRenderer.php
Normal file
75
CSSRenderer.php
Normal file
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
|
||||
/**
|
||||
* @file
|
||||
* @ingroup Extensions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects parsed CSS trees, and merges them for rendering into text.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
class CSSRenderer {
|
||||
|
||||
private $bymedia;
|
||||
|
||||
function __construct() {
|
||||
$this->bymedia = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds (and merge) a parsed CSS tree to the render list.
|
||||
*
|
||||
* @param array $rules The parsed tree as created by CSSParser::rules()
|
||||
* @param string $media Forcibly specified @media block selector. Normally unspecified
|
||||
* and defaults to the empty string.
|
||||
*/
|
||||
function add( $rules, $media = '' ) {
|
||||
if ( !array_key_exists( $media, $this->bymedia ) )
|
||||
$this->bymedia[$media] = [];
|
||||
|
||||
foreach ( $rules as $at ) {
|
||||
switch ( $at['name'] ) {
|
||||
case '@media':
|
||||
if ( $media == '' )
|
||||
$this->add( $at['rules'], "@media ".$at['text'] );
|
||||
break;
|
||||
case '':
|
||||
$this->bymedia[$media] = array_merge( $this->bymedia[$media], $at['rules'] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the collected CSS trees into a string suitable for inclusion
|
||||
* in a <style> tag.
|
||||
*
|
||||
* @return string Rendered CSS
|
||||
*/
|
||||
function render() {
|
||||
|
||||
$css = '';
|
||||
|
||||
foreach ( $this->bymedia as $at => $rules ) {
|
||||
if ( $at != '' )
|
||||
$css .= "$at {\n";
|
||||
foreach ( $rules as $rule ) {
|
||||
$css .= implode( ',', $rule['selectors'] ) . "{";
|
||||
foreach ( $rule['decls'] as $key => $value ) {
|
||||
$css .= "$key:$value";
|
||||
}
|
||||
$css .= "} ";
|
||||
}
|
||||
if ( $at != '' )
|
||||
$css .= "} ";
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
35
Gruntfile.js
Normal file
35
Gruntfile.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*jshint node:true */
|
||||
module.exports = function ( grunt ) {
|
||||
grunt.loadNpmTasks( 'grunt-contrib-jshint' );
|
||||
grunt.loadNpmTasks( 'grunt-jsonlint' );
|
||||
grunt.loadNpmTasks( 'grunt-banana-checker' );
|
||||
grunt.loadNpmTasks( 'grunt-jscs' );
|
||||
|
||||
grunt.initConfig( {
|
||||
jshint: {
|
||||
options: {
|
||||
jshintrc: true
|
||||
},
|
||||
all: [
|
||||
'**/*.js',
|
||||
'!node_modules/**'
|
||||
]
|
||||
},
|
||||
jscs: {
|
||||
src: '<%= jshint.all %>'
|
||||
},
|
||||
banana: {
|
||||
all: 'i18n/'
|
||||
},
|
||||
jsonlint: {
|
||||
all: [
|
||||
'*.json',
|
||||
'**/*.json',
|
||||
'!node_modules/**'
|
||||
]
|
||||
}
|
||||
} );
|
||||
|
||||
grunt.registerTask( 'test', [ 'jshint', 'jscs', 'jsonlint', 'banana' ] );
|
||||
grunt.registerTask( 'default', 'test' );
|
||||
};
|
98
TemplateStyles.hooks.php
Normal file
98
TemplateStyles.hooks.php
Normal file
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
/**
|
||||
* TemplateStyles extension hooks
|
||||
*
|
||||
* @file
|
||||
* @ingroup Extensions
|
||||
* @license LGPL-2.0+
|
||||
*/
|
||||
class TemplateStylesHooks {
|
||||
/**
|
||||
* Register parser hooks
|
||||
*/
|
||||
public static function onParserFirstCallInit( &$parser ) {
|
||||
$parser->setHook( 'templatestyles', array( 'TemplateStylesHooks', 'render' ) );
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function onOutputPageParserOutput( &$out, $parseroutput ) {
|
||||
global $wgTemplateStylesNamespaces;
|
||||
if ( $wgTemplateStylesNamespaces )
|
||||
$namespaces = $wgTemplateStylesNamespaces;
|
||||
else
|
||||
$namespaces = [ NS_TEMPLATE ];
|
||||
|
||||
$renderer = new CSSRenderer();
|
||||
$pages = [];
|
||||
|
||||
if ( $out->canUseWikiPage() )
|
||||
$pages[$out->getWikiPage()->getID()] = 'self';
|
||||
|
||||
foreach ( $namespaces as $ns )
|
||||
if ( array_key_exists( $ns, $parseroutput->getTemplates() ) )
|
||||
foreach ( $parseroutput->getTemplates()[$ns] as $title => $pageid )
|
||||
$pages[$pageid] = $title;
|
||||
|
||||
if ( count( $pages ) ) {
|
||||
$db = wfGetDB( DB_SLAVE );
|
||||
$res = $db->select( 'page_props', [ 'pp_page', 'pp_value' ], [
|
||||
'pp_page' => array_keys( $pages ),
|
||||
'pp_propname' => 'templatestyles'
|
||||
],
|
||||
__METHOD__,
|
||||
[ 'ORDER BY', 'pp_page' ]
|
||||
);
|
||||
foreach ( $res as $row ) {
|
||||
$css = unserialize( gzdecode( $row->pp_value ) );
|
||||
$renderer->add( $css );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$selfcss = $out->getProperty( 'templatestyles' );
|
||||
if ( $selfcss ) {
|
||||
$selfcss = unserialize( gzdecode( $selfcss ) );
|
||||
$renderer->add( $selfcss );
|
||||
}
|
||||
|
||||
$css = $renderer->render();
|
||||
if ( $css )
|
||||
$out->addInlineStyle( $css );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser hook for <templatestyles>.
|
||||
* If there is a CSS provided, render its source on the page and attach the
|
||||
* parsed stylesheet to the page as a Property.
|
||||
*
|
||||
* @param string $input: The content of the tag.
|
||||
* @param array $args: The attributes of the tag.
|
||||
* @param Parser $parser: Parser instance available to render
|
||||
* wikitext into html, or parser methods.
|
||||
* @param PPFrame $frame: Can be used to see what template parameters ("{{{1}}}", etc.)
|
||||
* this hook was used with.
|
||||
*
|
||||
* @return string: HTML to insert in the page.
|
||||
*/
|
||||
public static function render( $input, $args, $parser, $frame ) {
|
||||
$css = new CSSParser( $input );
|
||||
|
||||
if ( $css )
|
||||
$parser->getOutput()->setProperty( 'templatestyles', gzencode( serialize( $css->rules() ) ) );
|
||||
|
||||
$html =
|
||||
Html::openElement( 'div', [ 'class' => 'mw-templatestyles-doc' ] )
|
||||
. Html::rawElement(
|
||||
'p',
|
||||
[ 'class' => 'mw-templatestyles-caption' ],
|
||||
wfMessage( 'templatedata-doc-title' ) )
|
||||
. Html::element(
|
||||
'pre',
|
||||
[ 'class' => 'mw-templatestyles-stylesheet' ],
|
||||
$input )
|
||||
. Html::closeElement( 'div' );
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
}
|
13
composer.json
Normal file
13
composer.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"license": "LGPL-2.1+",
|
||||
"require-dev": {
|
||||
"jakub-onderka/php-parallel-lint": "0.9",
|
||||
"mediawiki/mediawiki-codesniffer": "0.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": [
|
||||
"parallel-lint . --exclude vendor",
|
||||
"phpcs -p -s"
|
||||
]
|
||||
}
|
||||
}
|
42
extension.json
Normal file
42
extension.json
Normal file
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "TemplateStyles",
|
||||
"version": "0.9",
|
||||
"author": [
|
||||
"Marc A. Pelletier"
|
||||
],
|
||||
"url": "https://www.mediawiki.org/wiki/Extension:TemplateStyles",
|
||||
"namemsg": "templateStyles",
|
||||
"descriptionmsg": "templateStyles-desc",
|
||||
"license-name": "LGPL-2.0+",
|
||||
"type": "other",
|
||||
"manifest_version": 1,
|
||||
"MessageDirs": {
|
||||
"TemplateStyles": [
|
||||
"i18n"
|
||||
]
|
||||
},
|
||||
"AutoloadClasses": {
|
||||
"TemplateStylesHooks": "TemplateStyles.hooks.php",
|
||||
"CSSParser": "CSSParser.php",
|
||||
"CSSRenderer": "CSSRenderer.php"
|
||||
},
|
||||
"ext.templateStyles": {
|
||||
"scripts": [
|
||||
],
|
||||
"styles": [
|
||||
],
|
||||
"messages": [
|
||||
],
|
||||
"dependencies": [
|
||||
]
|
||||
},
|
||||
"Hooks": {
|
||||
"ParserFirstCallInit": [
|
||||
"TemplateStylesHooks::onParserFirstCallInit"
|
||||
],
|
||||
"OutputPageParserOutput": [
|
||||
"TemplateStylesHooks::onOutputPageParserOutput"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
10
i18n/en.json
Normal file
10
i18n/en.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"@metadata": {
|
||||
"authors": [
|
||||
"Marc A. Pelletier"
|
||||
]
|
||||
},
|
||||
"templateStyles": "TemplateStyles",
|
||||
"templateStyles-desc": "Implement per-template style sheets",
|
||||
"templatestyles-doc-header": "Template-specific style sheet:"
|
||||
}
|
10
i18n/qqq.json
Normal file
10
i18n/qqq.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"@metadata": {
|
||||
"authors": [
|
||||
"Marc A. Pelletier"
|
||||
]
|
||||
},
|
||||
"templateStyles": "The name of the extension",
|
||||
"templateStyles-desc": "{{desc|name=TemplateStyles|url=https://www.mediawiki.org/wiki/Extension:TemplateStyles}}",
|
||||
"templatestyles-doc-header": "Used as caption for the display of the style sheet of the current template."
|
||||
}
|
20
package.json
Normal file
20
package.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "TemplateStyles",
|
||||
"version": "0.9.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gerrit.wikimedia.org/r/mediawiki/extensions/TemplateStyles"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "grunt test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"grunt": "0.4.5",
|
||||
"grunt-cli": "0.1.13",
|
||||
"grunt-contrib-jshint": "1.0.0",
|
||||
"grunt-banana-checker": "0.5.0",
|
||||
"grunt-jscs": "2.8.0",
|
||||
"grunt-jsonlint": "1.0.7"
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue