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:
Coren 2016-04-07 09:08:44 -04:00
parent f063b69870
commit 31743445bd
12 changed files with 580 additions and 0 deletions

3
.jscsrc Normal file
View file

@ -0,0 +1,3 @@
{
"preset": "wikimedia"
}

24
.jshintrc Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

8
phpcs.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0"?>
<ruleset>
<rule ref="vendor/mediawiki/mediawiki-codesniffer/MediaWiki"/>
<file>.</file>
<arg name="extensions" value="php,php5,inc"/>
<arg name="encoding" value="utf8"/>
<exclude-pattern>vendor</exclude-pattern>
</ruleset>