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>