MediaWiki:Less/code.2.js

From Jurassic Outpost Encyclopedia
Jump to navigation Jump to search

Note: After saving, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Go to Menu → Settings (Opera → Preferences on a Mac) and then to Privacy & security → Clear browsing data → Cached images and files.
/**
 * 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 <https://dev.fandom.com/wiki/Less/less.js> less.js source (modified)
 *
 * @author Cqm
 * @version 2.4.0
 * @copyright (C) Cqm 2018 <cqm.fwd@gmail.com>
 * @license GPLv3 <https://www.gnu.org/licenses/gpl-3.0.html>
 * @link <https://dev.fandom.com/wiki/Less> Documentation
 *
 * @notes <https://phabricator.wikimedia.org/T56864> native support for this
 * @todo Add support for less files across wikia
 *       @example @import 'u:dev:MediaWiki:Foo.less';
 *       only support url syntax rather than w:c:foo...
 *       will require looking up files through api
 *       meaning a more substantial rewrite of less.js source
 */

/*jshint bitwise:true, camelcase:true, curly:true, eqeqeq:true, es3:false,
    forin:true, immed:true, indent:4, latedef:true, newcap:true,
    noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
    undef:true, unused:true, strict:true, trailing:true,
    browser:true, devel:false, jquery:true,
    onevar:true
*/

/*global less:true */

// disable indent warning
/*jshint -W015*/
;(function (window, location, $, mw, dev, undefined) {
/*jshint +W015*/

    'use strict';

    /*
    // example config used for testing
    window.lessOpts = [{
        target: 'MediaWiki:Common.css',
        source: 'MediaWiki:Common.less',
        load: [
            'MediaWiki:Common.css',
            'MediaWiki:Common.less'
        ],
        header: 'MediaWiki:Css-header/common'
    }];
    */

        /**
         * Cache mw.config values
         */
    var conf = mw.config.get([
            'debug',
            'skin',
            'wgAction',
            'wgArticlePath',
            'wgNamespaceIds',
            'wgPageName',
            'wgSassParams',
            'wgServer',
            'wgUserGroups',
            'wgUserName',
            'wgUserLanguage'
        ]),

        /**
         * Copy of script configuration
         */
        opts = window.lessOpts,

        config = $.extend({
            reload: true,
            wrap: true,
            allowed: [],
        }, window.lessConfig),

        /**
         *
         */
        i18n = null,

        /**
         * Boolean to check when adding event listeners via mw.hook
         *
         * If multiple event listeners are attached, it causes duplicate messages to
         * be output to the UI
         */
        attachListeners = false,

        /**
         * Reusable library functions
         */
        util = {
            /**
             *
             */
            loadMessages: function (callback) {
                mw.hook('dev.i18n').add(function (devI18n) {
                    devI18n.loadMessages('Less').done(function (i18n_) {
                        i18n = i18n_;
                        callback();
                    });
                });

                importArticles({type: 'script', articles: ['u:dev:I18n-js/code.js']});
            },

            /**
             * 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
             *        - 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'),
                    // '> text'
                    $p = $('<p>').html('&gt;&nbsp;' + ob.text);

                if (ob.error === true) {
                    // add error class
                    $p.addClass('error');
                }

                $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'));
                }
            }
        },

        /**
         * 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
                    allowed = ['sysop', 'vanguard', 'vstf', 'helper', 'staff']
                        .concat(config.allowed),
                    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 (conf.debug) {
                    mw.loader.using(['mediawiki.api'], function () {
                        util.loadMessages(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;
                    }
                }

                mw.loader.using(['mediawiki.api'], function () {
                    util.loadMessages(self.addUpdate);
                });
            },

            /**
             * Inserts update button
             */
            addUpdate: function () {
                $('#WikiaMainContent').prepend(
                    $('<a>')
                        .addClass('wds-is-squished wds-button')
                        .attr({
                            title: i18n.msg('update-css').escape(),
                            href: '#',
                            id: 'less-update-button'
                        })
                        .css({
                            'margin-top': '-5px',
                            'margin-bottom': '5px'
                        })
                        .text(i18n.msg('update-css').plain())
                        .click(self.modal)
                );
            },

            /**
             * Build the GUI
             */
            modal: function () {
                if (!$('#less-overlay').length) {
                    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;height:400px;width:70%;max-width:650px;margin:auto;border-radius:4px;background:white;box-shadow:0 10px 60px rgba(0,0,0,0.3);padding:10px 15px 20px;overflow:hidden;color:#3a3a3a}' +
                        '#less-header{height:50px;width:100%;position:relative;}' +
                        '#less-title{font-size:24px;font-family:"Helvetica Neue","Arial",sans-serif;color:#438ab5;line-height:55px;padding-left:10px}' +
                        '#less-close{background:url(//runescape.fandom.com/wiki/Special:FilePath/Close-x-white.svg) #438ab5 center no-repeat;height:10px;width:10px;padding:5px;display:block;top:12px;right:5px;position:absolute;cursor:pointer}' +
                        '#less-content{padding:10px;overflow-y:auto;background-color:#fff;height:330px;font-size:13px}' +
                        '#less-content>p{font-family:monospace;line-height:1.25em;margin:0}' +
                        '#less-content>p>a{color:#438ab5;}' +
                        '#less-content>.error{color:red;font-size:initial;}' +
                        '#less-content>.error>a{color:red;text-decoration:underline;}'
                    );

                    // createmodal
                    $('body').append(
                        '<div id="less-overlay">' +
                            '<div id="less-modal">' +
                                '<div id="less-header">' +
                                    '<span id="less-title">' + i18n.msg( 'less-title' ).escape() + '</span>' +
                                    '<span id="less-close" title="' + i18n.msg('less-close').escape() + '"></span>' +
                                '</div>' +
                                '<div id="less-content"></div>' +
                            '</div>' +
                        '</div>'
                    );

                    // add event listeners
                    $('#less-close, #less-overlay').click(self.closeModal);
                    $('#less-modal').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 && conf.wgPageName === opts.target) {
                    location.reload();
                }

                return false;
            },

            /**
             * Gets the .less source page
             */
            getSource: function () {
                if (conf.debug) {
                    util.addLine({
                        text: i18n.msg('debug-enabled').escape()
                    });
                }

                util.addLine({
                    text: i18n.msg('getting-source', opts.source).parse(),
                });

                // check if modules have been defined
                if (!mw.loader.getState('dev.less')) {
                    mw.loader.implement(
                        'dev.less',
                        [
                            '/load.php?debug=' + conf.debug +
                            '&lang=en&mode=articles&articles=u:dev:Less/less.js&only=scripts'
                        ],
                        // objects for styles and messages
                        // mw.loader doesn't handle these being undefined
                        {}, {}
                    );
                }

                // check if less module has been defined
                if (!mw.loader.getState('dev.colors')) {
                    mw.loader.implement(
                        'dev.colors',
                        [
                            '/load.php?debug=' + conf.debug +
                            '&lang=en&mode=articles&articles=u:dev:Colors/code.js&only=scripts'
                        ],
                        // objects for styles and messages
                        // mw.loader doesn't handle these being undefined
                        {}, {}
                    );
                }

                $.ajaxSetup({
                    dataType: 'text',
                    error: function (_, error, status) {
                        if (status === 'Not Found') {
                            util.addline({
                                text: i18n.msg('page-not-found').escape(),
                                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(['dev.less', 'dev.colors'], function () {
                            self.getMixins(data);
                        });
                    }
                });
            },

            /**
             * Gets some standard mixins for use in LESS files
             *
             * Standard mixins can be found at:
             * <https://dev.fandom.com/wiki/MediaWiki:Custom-Less/mixins.less>
             *
             * @param {string} data
             */
            getMixins: function (data) {
                util.addLine({
                    text: i18n.msg('getting-mixins').escape()
                });

                $.ajax({
                    crossDomain: 'true',
                    data: {
                        action: 'query',
                        format: 'json',
                        prop: 'revisions',
                        rvprop: 'content',
                        titles: 'MediaWiki:Custom-Less/mixins.less'
                    },
                    dataType: 'jsonp',
                    success: function (json) {
                        var content = json.query.pages['4441'].revisions[0]['*'],
                            res = content + '\n' + data;
                        self.sassParams(res);
                    },
                    url: 'https://dev.fandom.com/api.php'
                });
            },

            /**
             * Extract selected theme designer values for use in less files
             *
             * @param {string} mixins Mixins retrieved through getmixins method
             */
            sassParams: function (mixins) {
                var colors = dev.colors.wikia,
                    params = {
                        body: colors.body,
                        page: colors.page,
                        buttons: colors.menu,
                        header: colors.header,
                        links: colors.link,
                        contrast: colors.contrast,
                        text: colors.text,
                        border: colors.border,
                        gradient: colors.gradient,
                        nav: colors.nav
                    },
                    paramStr = '';

                $.each(params, function (k, v) {
                    paramStr += '@theme-' + k + ':' + v + ';\n';
                });

                paramStr += '@filepath:\'' + conf.wgServer + conf.wgArticlePath.replace('$1', 'Special:FilePath') + '\';\n';

                self.parseLess(paramStr + '\n' + mixins);
            },

            /**
             * Attempts to parse content of source file
             *
             * @param {string} toparse Content to parse
             */
            parseLess: function (toParse) {
                var parser = new less.Parser({}),
                    importErrs = 0;

                // attempt to parse less
                util.addLine({
                    text: i18n.msg('attempt-parse').escape()
                });

                mw.log(toParse);

                if (!attachListeners) {
                    // 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({
                            text: i18n.msg('import-success', path).escape(),
                        });
                    });

                    mw.hook( 'less.404' ).add(function (url) {
                        var uri = new mw.Uri(url),
                            path = uri.path.replace('/wiki/', '');

                        importErrs += 1;

                        util.addLine({
                            text: i18n.msg('import-error', path).escape(),
                            error: true
                        });
                    });

                    attachListeners = true;
                }

                parser.parse(toParse, function (err, root) {
                    var css,
                        lines,
                        i;

                    if (!err) {
                        css = root.toCSS();
                        self.formatCss(css);
                    } else {
                        if (err.filename === 'input') {
                            // replace filename with our source file
                            err.filename = opts.source;
                            // fix line number for sassparams and mixins
                            lines = toParse.split('\n');

                            for (i = 0; i < lines.length; i += 1) {
                                if (lines[i].trim().indexOf('// end of mixins') > -1) {
                                    break;
                                }
                            }

                            // add 1 here as i refers to the mixins still
                            // not the start of the source file
                            err.line = err.line - (i + 1);
                        } else {
                            err.filename = new mw.Uri(err.filename).path.replace('/wiki/', '');
                        }

                        if (importErrs > 0) {
                            // we have an import error
                            util.addLine({
                                text: i18n.msg('check-imports').escape(),
                                error: true
                            });
                        } else {
                            // we have a syntax error
                            util.addLine({
                                text: i18n.msg('parse-error-file', err.line, err.filename).parse(),
                                error: true
                            });
                            // output the problem text
                            util.addLine({
                                text: err.extract[1].trim(),
                                error: true
                            });
                            // LESS doesn't have i18n so this will have to be english
                            util.addLine({
                                text: err.message,
                                error: true
                            });
                        }
                    }
                });
            },
            
            /**
             * Formats resulting CSS so it's readable after parsing
             *
             * @param {string} css CSS to format
             */
            formatCss: function (css) {

                util.addLine({
                    text: i18n.msg('formatting-css').escape()
                });

                // be careful with these regexes
                // everything in them does something even if it's not obvious
                css = css
                    // strip block comments
                    // @source <https://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
                    // [\n\s]* at the start of this regex is to stop whitespace leftover
                    // from removing comments within rules
                    .replace(/[\n\s]*\/\*([\s\S]*?)\*\//g, '')

                    // add consistent newlines between rules
                    .replace(/(\})\n+/g, '$1\n\n')
                    
                    // 4 space indentation
                    // do it this way to account for rules inside media queries, keyframes, etc.
                    // the 8 space indent replace should never really be used
                    // but is there just in case
                    // the 6 space indent is for something like keyframes in media queries
                    .replace(/\n {8}([\s\S])/g, '\n                $1')
                    .replace(/\n {6}([\s\S])/g, '\n            $1')
                    .replace(/\n {4}([\s\S])/g, '\n        $1')
                    .replace(/\n {2}([\s\S])/g, '\n    $1')

                    // @font-face
                    // this just aligns each value for the src property
                    .replace(
                        /@font-face\s*\{([\s\S]*?\n)(\s*)src:\s*([\s\S]*?);([\s\S]*?\})/g,
                        function (_, p1, p2, p3, p4) {
                            return  '@font-face { ' +
                                p1 +
                                p2 +
                                'src: ' + p3.split(', ').join(',\n' + p2 + '     ') + ';' +
                                p4;
                        }
                    )

                    // trim outer whitespace
                    .trim();

                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) {
                    util.addLine({
                        text: i18n.msg('getting-header').escape()
                    });

                    $.ajax({
                        data: {
                            action: 'raw',
                            maxage: '0',
                            smaxage: '0',
                            title: opts.header
                        },
                        success: function (data) {
                            data.trim();
                            data += '\n\n' + css;
                            self.wrap(data);
                        }
                    });
                } else {
                    self.wrap(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) {
                    // you only need the opening pre tag to stop redlinks, etc.
                    css = '/* <pre> */\n' + css;
                }

                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 = i18n
                        .inContentLang()
                        .msg('edit-summary', opts.source)
                        .plain(),
                    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({
                                text: i18n.msg('edit-success', opts.target).parse()
                            });

                            window.setTimeout(function () {
                                self.closeModal(config.reload);
                            }, 2000);
                        } else if (data.error) {
                            util.addLine({
                                text: data.error.code + ': ' + data.error.info,
                                error: true
                            });
                            util.addLine({
                                text: i18n.msg('error-persist', 'w:c:dev:Talk:Less').parse(),
                                error: true
                            });
                        } else {
                            mw.log(data);
                            util.addLine({
                                text: i18n.msg('unknown-error').escape(),
                                error: true
                            });
                            util.addLine({
                                text: i18n.msg('error-persist', 'w:c:dev:Talk:Less').parse(),
                                error: true
                            });
                        }
                    });
            }
        };

    if (conf.debug) {
        dev.less = self;
    } else {
        dev.less = self.init;
    }

    mw.loader.using('jquery.client', function () {
        $(self.init);
    });
    
}(this, this.location, this.jQuery, this.mediaWiki, this.dev = this.dev || {}));