2014-04-20

v2 (still needs some work)

New page

// __NOWYSIWYG__ <syntaxhighlight lang="javascript">

/**

* LESS GUI for Wikia wikis

*

* Adds support for using LESS on MediaWiki and an interface for compiling LESS to CSS

*

* This script uses a modified version of less.js

* @link <https://github.com/less/less.js> less.js source (original)

* @link <http://lesscss.org/> less.js documentation

* @link <http://dev.wikia.com/wiki/Less/less.js> less.js source (modified)

*

* @author Cqm

* @version 2.0.0

* @copyright (C) Cqm 2014 <cqm.fwd@gmail.com>

* @license GPLv3 <http://www.gnu.org/licenses/gpl-3.0.html>

* @link <http://dev.wikia.com/wiki/Less> Documentation

*

* @notes This (hopefully) will eventually be superseded by something server side

* see <https://bugzilla.wikimedia.org/show_bug.cgi?id=54864>

* Though probably not for a couple of years at minimum

*/

/*jshint devel:true */

/*global less:true */

;( function ( window, document, $, mw, undefined ) {

'use strict';

// Example config used for testing

window.lessOptions = [ {

target: 'MediaWiki:Common.css',

source: 'MediaWiki:Common.less',

load: [

'MediaWiki:Common.css',

'MediaWiki:Common.less'

],

header: 'MediaWiki:Css-header/common'

} ];

window.lessConfig = {

// indentation

// - 2 spaces (no space replacement)

// - 4 spaces (default)

// - 8 spaces

// - tabs

indent: 4,

// if to reload at the end if no errors are encountered

// defaults to true

reload: true,

// wrap in pre tags

wrap: true

};

/**

* Messages

*

* @todo explore using mw.messages

* mw.messages.set( {} );

* // need to check if each key is defined in the users language

* // if not sub in english

* new mw.Message( mw.messages, 'message', param1, param2 ).parse();

* new mw.Message( mw.messages, 'message', param1, param2 ).escaped();

* etc.

*/

var i18n = {

// English (English)

en: {

// ui

'update-css': 'Update CSS',

'less-title': 'LESS Interface',

'less-close': 'Close',

// status

'debug-enabled': 'Debug mode enabled',

'getting-source': 'Getting source file: [[$1]]',

'getting-mixins': 'Getting standard mixins',

'attempt-parse': 'Attempting to parse less',

'import-success': 'Imported $1 successfully',

'import-error': 'Failed to import $1',

'formatting-css': 'Formatting CSS',

'edit-success': 'Successfully updated [[$1]]',

// edit summary

'edit-summary': 'Updating CSS',

// errors

// thrown during util.addline

'internal-error': 'Internal error',

// hit when looking up source or mixins page

// or looking for header page

'page-not-found': 'Page not found, please check your configuration',

// hit when one or more files could not be imported

'check-imports': '',

// parse errors

'parse-error-file': 'Parse error on line $1 in [[$2]]',

// an error that hasn't been accounted for

// used with error-persist (below)

'unknown-error': 'An unknown error has occurred',

// if said unknown error continues, please report it

'error-persist': 'If this error persists, please report it [[$1|here]]'

}

},

/**

* Cache mw.config values

*/

conf = mw.config.get( [

'skin',

'wgAction',

'wgNamespaceIds',

'wgPageName',

'wgScript',

'wgScriptPath',

'wgUserGroups',

'wgUserName',

'wgUserLanguage'

] ),

/**

* Copy of script configuration

*/

opts = window.lessOptions,

config = window.lessConfig || {},

/**

* Reusable library functions

*/

util = {

/**

* Attempts to return a message in the users language

* and substitutes any parameters into the message

*

* @param {string} msg

* @param {array} args

* @returns {string}

*/

msg: function ( msg, args ) {

var message = false,

i;

args = args || [];

if ( i18n[conf.wgUserLanguage] && i18n[conf.wgUserLanguage][msg] ) {

// we have the translated message

message = i18n[conf.wgUserLanguage][msg];

}

if ( i18n.en[msg] ) {

// we have the english message

message = i18n.en[msg];

}

if ( !message ) {

// the message key does not exist

return mw.html.escape( msg );

}

// replace params in message

for ( i = 0; i < args.length; i += 1 ) {

message = message.replace( '$' + ( i + 1 ), args[i] );

}

return util.parselinks( message );

},

/**

* Parses wikitext links with regex

*

* @param {string} linktext Wikitext to parse

* @returns {string} Parsed wikitext

* @todo bold/italics?

*/

parselinks: function ( linktext ) {

var text = mw.html.escape( linktext ),

match = text.match( /\[\[[\s\S]+?\]\]/g ),

replace,

i;

if ( !match ) {

return text;

}

for ( i = 0; i < match.length; i += 1 ) {

// strip brackets

replace = match[i].replace( /(\[\[|\]\])/g, '' );

if ( replace.indexOf( '|' ) > -1 ) {

// href is different to text of anchor tag

replace = replace.split( '|' );

text = text.replace(

match[i],

'<a href="/wiki/' + encodeURIComponent( replace[0].replace( / /g, '_' ) ) + '" title="' + replace[1] + '" target="_blank">' + replace[1] + '</a>'

);

} else {

// href and text are the same

text = text.replace(

match[i],

'<a href="/wiki/' + encodeURIComponent( replace.replace( / /g, '_' ) ) + '" title="' + replace + '" target="_blank">' + replace + '</a>'

);

}

}

return text;

},

/**

* Inserts a line into the interface content area

*

* If there is an overflow in the content area

* this will also scroll the content down

*

* @param {object} ob An object with the following keys:

* - text {string} A text string to be inserted to the interface

* - msg {string} A translatable message to be inserted into the interface

* - args {array} Any arguments for msg

* - error {boolean} True if the message is for an error

* @notes text and msg are mutually exclusive

* they should not both exist in ob

* text takes precedence over msg

*/

addline: function ( ob ) {

var $content = $( '#less-content' ),

$p = $( '<p>' ),

text;

if ( !!ob.text ) {

// plain text

text = mw.html.escape( ob.text );

} else if ( !!ob.msg ) {

// translatable message

// pass an empty array to .msg()

// to stop errors when args does not exist

text = util.msg( ob.msg, ob.args || [] );

} else {

// neither are defined

// and we need to fix our code

text = util.msg( 'internal-error' );

ob.error = true;

}

if ( ob.error === true ) {

// add error class

$p.attr( 'class', 'error' );

}

$p.html( text );

$content.append( $p );

if ( $content.prop( 'scrollHeight' ) > $content.prop( 'clientHeight' ) ) {

// the text is longer than the content

// so scroll down to the bottom

$content.scrollTop( $content.prop( 'scrollHeight' ) );

}

},

/**

* Checks for debug mode enabled by user

*

* This won't catch debug mode set server side

* but that should never be set on a live wiki anyway

*

* Debug mode can be set by adding ?debug=true to the url

* or by setting the cookie "resourceLoaderDebug=true"

*

* @returns {boolean} true if debug mode is enabled

* false if not enabled

*/

debug: function () {

if (

mw.util.getParamValue( 'debug' ) === 'true' ||

$.cookie( 'resourceLoaderDebug' ) === 'true'

) {

return true;

}

return false;

}

},

/**

* Functions for parsing the LESS files and updating the target CSS file

*

* These are typically used once per 'cycle'

* Reusable functions are under util

*/

self = {

/**

* Loading function

*

* - Validates configuration and check for correct environment to load in

* - Checks if the user can edit MediaWiki pages if applicable

* - Checks for debug mode (skips user checks)

*/

init: function () {

var profile = $.client.profile(),

run = false,

// usergroups that can edit mediawiki pages

// @todo make this configurable

allowed = ['sysop', 'vstf', 'helper', 'staff'],

ns,

mwi,

i;

if ( profile.name === 'msie' && profile.versionNumber < 9 ) {

// we're not going to support anything below ie9

// so stop here rather than cause any errors

// by using stuff ie8 doesn't support

return;

}

if ( conf.wgAction !== 'view' ) {

return;

}

if ( opts === undefined || !Array.isArray( opts ) ) {

// incorrect configuration

return;

}

// check if this page is added to the options.load array

for ( i = 0; i < opts.length; i += 1 ) {

if ( opts[i].load.indexOf( conf.wgPageName ) > -1 ) {

run = true;

opts = opts[i];

break;

}

}

if ( !run ) {

return;

}

if ( util.debug() ) {

self.addupdate();

return;

}

// get localised name for mediawiki namespace

for ( ns in conf.wgNamespaceIds ) {

if ( conf.wgNamespaceIds.hasOwnProperty( ns ) ) {

if ( conf.wgNamespaceIds[ns] === 8 ) {

mwi = ns;

}

}

}

// if we're trying to update a mediawiki page

// check the user can edit them

if ( opts.target.toLowerCase().indexOf( mwi ) === 0 ) {

run = false;

for ( i = 0; i < allowed.length; i += 1 ) {

if ( conf.wgUserGroups.indexOf( allowed[i] ) > -1 ) {

run = true;

break;

}

}

if ( !run ) {

return;

}

}

self.addupdate();

},

/**

* Inserts update button

*/

addupdate: function () {

var text = util.msg( 'update-css' ),

$a = $( '<a>' )

.attr( {

title: text,

href: '#'

} )

.text( text )

.on( 'click', self.modal ),

$append,

nbsp = '';

if ( ['oasis', 'wikia'].indexOf( conf.skin ) > -1 ) {

$append = $( '#WikiaPageHeader' );

$a.addClass( 'wikia-button' );

nbsp = ' ';

} else if ( ['monobook', 'uncyclopedia', 'wowwiki'].indexOf( conf.skin ) > -1 ) {

$a = $( '<li>' ).attr( 'id', 't-updateless' ).append( $a );

$append = $( '#p-tb .pBody ul' );

} else {

mw.log( 'Unknown skin' );

$a = undefined;

return;

}

$append.append( $a, nbsp );

},

/**

* Build the GUI

*/

modal: function () {

var modal;

if ( !$( '#less-overlay' ).length ) {

// create modal

modal = '<div id="less-overlay"><div id="less-modal">' +

'<div id="less-header">' +

'<span id="less-title">' + util.msg( 'less-interface' ) + '</span>' +

'<span id="less-close" title="' + util.msg( 'less-close' ) + '"></span>' +

'</div>' +

'<div id="less-content"></div>' +

'</div></div>';

// insert CSS

mw.util.addCSS(

'#less-overlay{position:fixed;height:1000px;background-color:rgba(255,255,255,0.6);width:100%;top:0;left:0;z-index:20000002;}' +

'#less-modal{position:relative;background-color:#87ceeb;height:400px;width:60%;margin:auto;border:5px solid #87ceeb;border-radius:15px;overflow:hidden;color:#3a3a3a;}' +

'#less-header{height:50px;width:100%;position:relative;}' +

'#less-title{font-size:25px;font-family:"Lucida Console",monospace;font-weight:bold;line-height:53px;padding-left:10px;}' +

'#less-close{background:url("/resources/wikia/ui_components/modal/images/close-dark.svg");height:25px;width:25px;display:block;top:10px;right:10px;position:absolute;cursor:pointer;}' +

'#less-content{padding:10px;overflow-y:auto;background-color:#fff;height:330px;font-size:14px;}' +

'#less-content>p{font-family:monospace;margin:0}' +

'#less-content>p>a{color:#87ceeb;}' +

'#less-content>.error{color:red;font-size:initial;}' +

'#less-content>.error>a{color:red;text-decoration:underline;}'

);

// insert into DOM

$( 'body' ).append( modal );

// add event listeners

$( '#less-close, #less-overlay' ).on( 'click', self.closemodal );

$( '#less-modal' ).on( 'click', function ( e ) {

// stop click events bubbling down to overlay

e.stopPropagation();

} );

} else {

$( '#less-content' ).empty();

$( '#less-overlay' ).show();

}

// set modal height

$( '#less-modal' ).css( 'margin-top', ( ( $( window ).height() - 400 ) / 3 ) );

self.getsource();

return false;

},

/**

* Closes the GUI

*

* @param {boolean} refresh (optional) Reload the page if true

*/

closemodal: function ( refresh ) {

$( '#less-overlay' ).hide();

// refresh the page on close

if ( refresh === true ) {

document.location.reload();

}

return false;

},

/**

* Gets the .less source page

*/

getsource: function () {

if ( util.debug() ) {

util.addline( {

msg: 'debug-enabled'

} );

}

util.addline( {

msg: 'getting-source',

args: [opts.source]

} );

// check if less module has been defined

if ( !mw.loader.getState( 'less' ) ) {

mw.loader.implement(

'less',

['http://dev.wikia.com/wiki/Less/less.js?action=raw&ctype=text/javascript'],

// objects for styles and messages

// mw.loader doesn't handle these being undefined

{}, {}

);

}

$.ajaxSetup( {

dataType: 'text',

error: function ( xhr, error, status ) {

if ( status === 'Not Found' ) {

util.addline( {

msg: 'page-not-found',

error: true

} );

} else {

// @todo output error to gui

mw.log( error, status );

}

},

type: 'GET',

url: mw.util.wikiScript()

} );

$.ajax( {

data: {

action: 'raw',

maxage: '0',

smaxage: '0',

title: opts.source.replace( / /g, '_' )

},

success: function ( data ) {

mw.loader.using( 'less', function () {

self.getmixins( data );

} );

}

} );

},

/**

* Gets some standard mixins for use in LESS files

*

* Standard mixins can be found at:

* <http://dev.wikia.com/wiki/Less/mixins.less>

*

* @param {string} data

*/

getmixins: function ( data ) {

util.addline( {

msg: 'getting-mixins'

} );

$.ajax( {

crossDomain: 'true',

data: {

action: 'query',

format: 'json',

prop: 'revisions',

rvprop: 'content',

titles: 'Less/mixins.less'

},

dataType: 'jsonp',

success: function ( json ) {

var content = json.query.pages['4441'].revisions[0]['*'],

res = content + '\n' + data;

mw.loader.using( 'less', function () {

self.parseless( res );

} );

},

url: 'http://dev.wikia.com' + mw.util.wikiScript( 'api' )

} );

},

/**

* Attempts to parse content of source file

*

* @param {string} toparse Content to parse

*/

parseless: function ( toparse ) {

var parser = new less.Parser( {} ),

uri,

path;

// attempt to parse less

util.addline( {

msg: 'attempt-parse'

} );

// attach listeners for ajax requests here

// so we can react to imports independent of if they're successful or not

// if there's an import error, less.js will throw an error at the end parsing

// not as soon as it encounters them

mw.hook( 'less.200' ).add( function ( url ) {

var uri = new mw.Uri( url ),

path = uri.path.replace( '/wiki/', '' );

util.addline( {

msg: 'import-success',

args: [path]

} );

} );

mw.hook( 'less.404' ).add( function ( url ) {

var uri = new mw.Uri( url ),

path = uri.path.replace( '/wiki/', '' );

util.addline( {

msg: 'import-error',

args: [path],

error: true

} );

} );

try {

parser.parse( toparse, function ( error, root ) {

// error isn't much use here

// as if we do find any errors

// this throws them, rather than allowing us to react to them

var css = root.toCSS();

self.formatcss( css );

} );

} catch ( e ) {

mw.log( e );

if ( e.name === 'TypeError' && e.message === "Cannot call method 'toCSS' of undefined" ) {

util.addline( {

msg: 'check-imports',

error: true

} );

} else if ( e.line ) {

uri = new mw.Uri( e.filename );

path = uri.path.replace( '/wiki/', '' );

// we can access the file and line number containing the error

util.addline( {

msg: 'parse-error-file',

args: [e.line, path],

error: true

} );

// output the content of the problem line

util.addline( {

text: e.extract[1].trim(),

error: true

} );

// less.js isn't localised

// so we'll have to output the message it gives us unchanged

util.addline( {

text: e.message,

error: true

} );

} else {

util.addline( {

msg: 'unknown-error',

error: true

} );

util.addline( {

msg: 'error-persist',

args: ['w:c:dev:Talk:Less'],

error: true

} );

mw.log( e );

}

}

},

/**

* Formats resulting CSS so it's readable after parsing

*

* @param {string} css CSS to format

*/

formatcss: function ( css ) {

util.addline( {

msg: 'formatting-css'

} );

css = css

// strip block comments

// @source <http://stackoverflow.com/a/2458830/1942596>

// after parsing, block comments are unlikely to be anywhere near

// the code they're commenting, so remove them to prevent confusion

// inline comments are stripped during parsing

.replace( /\/\*([\s\S]*?)\*\//g, '' )

// remove multiple newlines

// clean up from gaps between imported files

// and stripping comments

.replace( /\n\s*\n/g, '\n' )

// add consistent newlines between rules

.replace( /(\})\n(.)/g, '$1\n\n$2' );

// need to account for at-rules

// <https://developer.mozilla.org/en-US/docs/Web/CSS/At-rule>

// indent with 4 spaces

// perhaps make this configurable?

// 2 spaces (default)

// tabs

// 8 spaces

// if there's an id in a selector

// cut out everything before it

// as there's no need for specificity before ids in a selector

// bad practice

self.addheader( css );

},

/**

* Prepends content of header file if defined

*

* @param {string} css CSS to prepend header too

*/

addheader: function ( css ) {

// check opts.header is defined

if ( !!opts.header ) {

$.ajax( {

data: {

action: 'raw',

maxage: '0',

smaxage: '0',

title: opts.header

},

success: function ( data ) {

data += '\n' + css;

self.postcss( data );

}

} );

} else {

self.postcss( css );

}

},

/**

* If set in config, wraps the css in pre tags

*

* @param {string} css CSS to wrap in pre tags

*/

wrap: function ( css ) {

if ( config.wrap !== false ) {

css = '/* <pre> */\n' + css + '\n/* </pre> */';

}

self.postcss( css );

},

/**

* Edits the target page with the new CSS

*

* @param {string} text Content to update the target page with

*/

postcss: function ( text ) {

var token = mw.user.tokens.get( 'editToken' ),

summary = util.msg( 'edit-summary', [opts.source] ),

params = {

action: 'edit',

summary: summary,

token: token,

title: opts.target,

text: text

},

api;

// safe guard for debugging

// as mw.Api isn't loaded for anons

if ( !conf.wgUserName ) {

mw.log( 'User is not logged in' );

return;

}

// use mw.Api as it escapes all out params for us as required

api = new mw.Api();

api.post( params )

.done( function ( data ) {

if ( data.edit && data.edit.result === 'Success' ) {

util.addline( {

msg: 'edit-success',

args: [opts.target]

} );

window.setTimeout( function () {

self.closemodal( config.reload || true );

}, 2000 );

} else if ( data.error ) {

util.addline( {

text: data.error.code + ': ' + data.error.info,

error: true

} );

util.addline( {

msg: 'error-persist',

args: ['w:c:dev:Talk:Less'],

error: true

} );

} else {

util.addline( {

msg: 'unknown-error',

error: true

} );

util.addline( {

msg: 'error-persist',

args: ['w:c:dev:Talk:Less'],

error: true

} );

}

} );

}

};

window.dev = window.dev || {};

if ( util.debug() ) {

window.dev.less = self;

window.dev.less.debug = util.debug;

} else {

window.dev.less = self.init;

$( self.init );

}

}( this, this.document, this.jQuery, this.mediaWiki ) );

// </syntaxhighlight>

Show more