MediaWiki:Gadget-Less-core.js
Jump to navigation
Jump to search
Note: After publishing, 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: Press Ctrl-F5.
1 /**
2 * Adds support for using LESS on MediaWiki and an interface for compiling LESS to CSS
3 *
4 * This script uses a modified version of less.js
5 * @link <https://github.com/less/less.js> less.js source
6 * @link <http://lesscss.org/> less.js documentation
7 *
8 * @author Cqm
9 * @version 2.6.0
10 * @copyright © Cqm 2019 <cqm.fwd@gmail.com>
11 * @license GPLv3 <http://www.gnu.org/licenses/gpl-3.0.html>
12 *
13 * @notes <https://phabricator.wikimedia.org/T56864> native support for this
14 * @todo Move docs to meta and update
15 */
16
17
18
19 mw.loader.implement("ext.less.messages",null,{},{"less-dialog-attempt-parse":"Attempting to parse LESS","less-dialog-check-imports":"One or more files could not be imported, please ensure all @import statements reference existing pages","less-dialog-close":"Close","less-dialog-debug-enabled":"Debug mode enabled","less-dialog-edit-success":"Successfully updated [[$1]]","less-dialog-edit-summary":"Updating CSS from [[$1]]","less-dialog-error-persist":"If this error persists, please report it [[$1|here]]","less-dialog-formatting-css":"Formatting CSS","less-dialog-getting-header":"Getting header comment","less-dialog-getting-mixins":"Getting standard mixins","less-dialog-getting-source":"Getting source file: [[$1]]","less-dialog-import-error":"Failed to import [[$1]]","less-dialog-import-success":"Imported [[$1]] successfully","less-dialog-internal-error":"Internal error","less-dialog-page-not-found":"Page not found, please check your configuration","less-dialog-parse-error-file":
20 "Parse error on line $1 in [[$2]]","less-dialog-title":"Compiling LESS","less-dialog-unknown-error":"An unknown error has occurred","less-update-css":"Update CSS"});
21
22 /*jshint bitwise:true, camelcase:true, curly:true, eqeqeq:true, es3:false,
23 forin:true, immed:true, indent:4, latedef:true, newcap:true,
24 noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
25 undef:true, unused:true, strict:true, trailing:true,
26 browser:true, devel:false, jquery:true,
27 onevar:true
28 */
29
30 /*global less:true */
31
32 // disable indent warning
33 /*jshint -W015*/
34 ;(function (window, location, $, mw, wgl, undefined) {
35 /*jshint +W015*/
36
37 'use strict';
38
39 /**
40 * Cache mw.config values
41 */
42 var conf = mw.config.get([
43 'debug',
44 'wgAction',
45 'wgArticlePath',
46 'wgPageName',
47 'wgUserName',
48 ]),
49
50 /**
51 * Copy of script configuration
52 */
53 source,
54 target,
55 targets = window.lessTargets,
56 config = $.extend({
57 reload: true,
58 wrap: true,
59 allowed: [],
60 }, window.lessConfig),
61
62 /**
63 * Boolean to check when adding event listeners via mw.hook
64 *
65 * If multiple event listeners are attached, it causes duplicate messages to
66 * be output to the UI
67 */
68 attachListeners = false,
69
70 /**
71 * Reusable library functions
72 */
73 util = {
74 /**
75 * Inserts a line into the interface content area
76 *
77 * If there is an overflow in the content area
78 * this will also scroll the content down
79 *
80 * @param {text} The text to add to the GUI,
81 * @param {isError} If the message is an error message or not.
82 */
83 addLine: function (text, isError) {
84 var $content = $('#less-content'),
85 $p = $('<p>');
86
87 if (isError === true) {
88 // add error class
89 $p.addClass('error');
90 }
91
92 // '> text'
93 $p.html('> ' + text);
94 $content.append($p);
95
96 if ($content.prop('scrollHeight' ) > $content.prop('clientHeight')) {
97 // the text is longer than the content
98 // so scroll down to the bottom
99 $content.scrollTop($content.prop('scrollHeight'));
100 }
101 }
102 },
103
104 /**
105 * Functions for parsing the LESS files and updating the target CSS file
106 *
107 * These are typically used once per 'cycle'
108 * Reusable functions are under util
109 */
110 self = {
111 /**
112 * Loading function
113 *
114 * - Validates configuration and check for correct environment to load in
115 * - Checks if the user can edit MediaWiki pages if applicable
116 * - Checks for debug mode (skips user checks)
117 */
118 init: function () {
119 if (conf.wgAction !== 'view') {
120 return;
121 }
122
123 if (targets === undefined || !Array.isArray(targets)) {
124 // incorrect configuration
125 return;
126 }
127
128 // check if this page is CSS generated from LESS or is LESS.
129 target = targets.find( function (e) { return e === conf.wgPageName.replace(new RegExp('\.less$'),'.css' ) } );
130 if ( target !== undefined ) {
131 source = target.replace(new RegExp('\.css$'),'.less' );
132 } else {
133 return;
134 }
135
136 mw.loader.using(['mediawiki.jqueryMsg'], function () {
137 self.addUpdate();
138 });
139 },
140
141 /**
142 * Inserts update button
143 */
144 addUpdate: function () {
145 var text = mw.message('less-update-css').escaped();
146
147 $('#p-views ul')
148 .prepend(
149 $('<li>')
150 .attr('id', 't-updateless')
151 .append(
152 $('<span>')
153 .append(
154 $('<a>')
155 .attr({
156 title: text,
157 href: '#',
158 id: 'less-update-button'
159 })
160 .on('click', self.modal)
161 .text(text)
162 )
163 )
164 );
165 },
166
167 /**
168 * Build the GUI
169 */
170 modal: function () {
171 // TODO: move this to extension assets
172 var closeImg = conf.wgArticlePath.replace('$1', 'Special:FilePath/Close-x-white.svg'),
173 modal;
174
175 if (!$('#less-overlay' ).length) {
176 // create modal
177 modal = '<div id="less-overlay"><div id="less-modal">' +
178 '<div id="less-header">' +
179 '<span id="less-title">' + mw.message('less-dialog-title').escaped() + '</span>' +
180 '<span id="less-close" title="' + mw.message('less-dialog-close').escaped() + '"></span>' +
181 '</div>' +
182 '<div id="less-content"></div>' +
183 '</div></div>';
184
185 // insert CSS
186 mw.util.addCSS(
187 '#less-overlay { display:flex; justify-content:center; align-items:center; position:fixed; height:100vh; background-color:rgba(255,255,255,0.6); width:100%; top:0; left:0; z-index:20000002 }' +
188 '#less-modal { height:400px; width:650px; border-radius:4px; background:#fff; box-shadow:0 10px 60px rgba(0,0,0,0.3); padding:10px 15px; overflow:hidden; color:#3a3a3a }' +
189 '#less-header { border-bottom:1px solid #e4eaee; height:50px; width:100%; position:relative; }' +
190 '#less-title { font-size:24px; line-height:50px; padding-left:10px }' +
191 '#less-close { background:url(' + closeImg + ') #bdc5cd center no-repeat; height:10px; width:10px; padding:5px; display:block; top:12px; right:5px; position:absolute; cursor:pointer }' +
192 '#less-content { margin:0 10px 10px 10px; padding-top:10px; overflow:auto; height:330px; }' +
193 '#less-content p { font-family:monospace; line-height:1.5em; margin:0 }' +
194 '#less-content p a { color: #327ba7; }' +
195 '#less-content .error { color:#d22313; font-size:initial; }' +
196 '#less-content .error a { color:#d22313; text-decoration:underline; }'
197 );
198
199 // insert into DOM
200 $('body').append(modal);
201
202 // add event listeners
203 $('#less-close, #less-overlay').click(self.closeModal);
204 $('#less-modal').click(function (e) {
205 // stop click events bubbling down to overlay
206 e.stopPropagation();
207 });
208 } else {
209 $('#less-content').empty();
210 $('#less-overlay').show();
211 }
212
213 self.getSource();
214
215 return false;
216 },
217
218 /**
219 * Closes the GUI
220 *
221 * @param {boolean} refresh (optional) Reload the page if true
222 */
223 closeModal: function (refresh) {
224 $('#less-overlay').hide();
225
226 // refresh the page on close
227 if (refresh === true && conf.wgPageName === target) {
228 location.reload();
229 }
230
231 return false;
232 },
233
234 /**
235 * Gets the .less source page
236 */
237 getSource: function () {
238 if (conf.debug) {
239 util.addLine(mw.message('less-dialog-debug-enabled').escaped());
240 }
241
242 if (!mw.loader.getState('wgl.less')) {
243 // @todo: move this to extension/gadget
244 mw.loader.implement(
245 'wgl.less',
246 [
247 'https://meta.weirdgloop.org/w/MediaWiki:Gadget-LessSrc.js?action=raw&ctype=text/javascript'
248 ],
249 {}, {}
250 );
251 }
252
253 util.addLine(mw.message('less-dialog-getting-source', source).parse());
254
255 $.ajaxSetup({
256 dataType: 'text',
257 error: function (_, error, status) {
258 // TODO: can we not inspect the HTTP status code?
259 if (status === 'Not Found') {
260 util.addLine(mw.message('less-dialog-page-not-found').escaped(), true);
261 } else {
262 // TODO: output error to gui
263 console.log(error, status);
264 }
265 },
266 type: 'GET',
267 url: mw.util.wikiScript()
268 });
269
270 $.ajax({
271 data: {
272 action: 'raw',
273 maxage: '0',
274 smaxage: '0',
275 title: source.replace(/ /g, '_')
276 },
277 success: function (data) {
278 self.getMixins(data);
279 }
280 });
281 },
282
283 /**
284 * Gets some standard mixins for use in LESS files
285 *
286 * @param {string} data
287 */
288 getMixins: function (data) {
289 util.addLine(mw.message('less-dialog-getting-mixins').escaped());
290
291 $.ajax({
292 data: {
293 action: 'raw',
294 maxage: '0',
295 smaxage: '0',
296 title: 'MediaWiki:Gadget-LessMixins.less'
297 },
298 url: 'https://meta.weirdgloop.org/index.php',
299 success: function (content) {
300 mw.log('getMixins::content', content);
301
302 mw.loader.using( ['wgl.less'], function () {
303 // Monkey patch in a filepath function that takes a wiki file name and generates the url to it.
304 less.tree.Filepath = function ( fileName, width ) {
305 var f = fileName.value.replace(' ', '_'),
306 url = '/images/';
307
308 if ( arguments.length < 2 ) {
309 url += f;
310 } else {
311 url += width.value + 'px-' + f;
312 }
313 url += "?11111"
314
315 return new(less.tree.URL)(new(less.tree.Anonymous)(url));
316 };
317
318 self.parseLess(content + '\n' + data);
319 });
320 },
321 });
322 },
323
324 /**
325 * Attempts to parse content of source file
326 *
327 * @param {string} toparse Content to parse
328 */
329 parseLess: function (toParse) {
330 var importErrs = 0;
331
332 // attempt to parse less
333 util.addLine(mw.message('less-dialog-attempt-parse').escaped());
334 mw.log(toParse);
335
336 if (!attachListeners) {
337 // attach listeners for ajax requests here
338 // so we can react to imports independent of if they're successful or not
339 // if there's an import error, less.js will throw an error at the end parsing
340 // not as soon as it encounters them
341 mw.hook('less.200').add(function (url) {
342 var uri = new mw.Uri( url ),
343 path = uri.path.replace('/w/', '');
344
345 util.addLine(mw.message('less-dialog-import-success', path).parse());
346 });
347
348 mw.hook( 'less.404' ).add(function (url) {
349 var uri = new mw.Uri(url),
350 path = uri.path.replace('/w/', '');
351
352 importErrs += 1;
353
354 util.addLine(mw.message('less-dialog-import-error', path).parse(), true);
355 });
356
357 attachListeners = true;
358 }
359
360 less.render(toParse, {}, function (err, root) {
361 var css,
362 lines,
363 i;
364
365 if (!err) {
366 try {
367 css = root.css;
368 self.formatCss(css);
369 } catch (exc) {
370 self.handleSyntaxError(exc);
371 }
372 } else {
373 if (err.filename === 'input') {
374 // replace filename with our source file
375 err.filename = source;
376 // fix line number for sassparams and mixins
377 lines = toParse.split('\n');
378
379 for (i = 0; i < lines.length; i += 1) {
380 if (lines[i].trim().indexOf('// end of mixins') > -1) {
381 break;
382 }
383 }
384
385 // add 1 here as i refers to the mixins still
386 // not the start of the source file
387 err.line = err.line - (i + 1);
388 } else {
389 err.filename = new mw.Uri(err.filename).path.replace('/w/', '');
390 }
391
392 if (importErrs > 0) {
393 // we have an import error
394 util.addLine(mw.message('less-dialog-check-imports').escaped(), true);
395 } else {
396 self.handleSyntaxError(err);
397 }
398 }
399 });
400 },
401
402 /**
403 * Handle a syntax error.
404 *
405 * @param {Exception} exc Exception to handle.
406 */
407 handleSyntaxError: function (exc) {
408 // log the raw error as well
409 mw.log.error(exc);
410
411 // convert URI to pagename
412 var uri = new mw.Uri(exc.filename),
413 path = uri.path.replace('/w/', '');
414
415 util.addLine(mw.message('less-dialog-parse-error-file', exc.line, path).parse(), true);
416 // output the problem text
417 util.addLine(exc.extract[1].trim(), true);
418 // LESS doesn't have i18n so this will have to be english
419 util.addLine(exc.message, true);
420 },
421
422 /**
423 * Formats resulting CSS so it's readable after parsing
424 *
425 * @param {string} css CSS to format
426 */
427 formatCss: function (css) {
428
429 util.addLine(mw.message('less-dialog-formatting-css').escaped());
430
431 // be careful with these regexes
432 // everything in them does something even if it's not obvious
433 css = css
434 // strip block comments
435 // @source <http://stackoverflow.com/a/2458830/1942596>
436 // after parsing, block comments are unlikely to be anywhere near
437 // the code they're commenting, so remove them to prevent confusion
438 // inline comments are stripped during parsing
439 // [\n\s]* at the start of this regex is to stop whitespace leftover
440 // from removing comments within rules
441 .replace(/[\n\s]*\/\*([\s\S]*?)\*\//g, '')
442
443 // add consistent newlines between rules
444 .replace(/(\})\n+/g, '$1\n\n')
445
446 // 4 space indentation
447 // do it this way to account for rules inside media queries, keyframes, etc.
448 // the 8 space indent replace should never really be used
449 // but is there just in case
450 // the 6 space indent is for something like keyframes in media queries
451 .replace(/\n {8}([\s\S])/g, '\n $1')
452 .replace(/\n {6}([\s\S])/g, '\n $1')
453 .replace(/\n {4}([\s\S])/g, '\n $1')
454 .replace(/\n {2}([\s\S])/g, '\n $1')
455
456 // @font-face
457 // this just aligns each value for the src property
458 .replace(
459 /@font-face\s*\{([\s\S]*?\n)(\s*)src:\s*([\s\S]*?);([\s\S]*?\})/g,
460 function (_, p1, p2, p3, p4) {
461 return '@font-face { ' +
462 p1 +
463 p2 +
464 'src: ' + p3.split(', ').join(',\n' + p2 + ' ') + ';' +
465 p4;
466 }
467 )
468
469 // trim outer whitespace
470 .trim();
471
472 self.wrap(css);
473
474 },
475
476 /**
477 * If set in config, wraps the css in pre tags
478 *
479 * @param {string} css CSS to wrap in pre tags
480 */
481 wrap: function (css) {
482 if (config.wrap) {
483 // you only need the opening pre tag to stop redlinks, etc.
484 css = '/* <pre> */\n' + css;
485 }
486
487 self.postCss(css);
488 },
489
490 /**
491 * Edits the target page with the new CSS
492 *
493 * @param {string} text Content to update the target page with
494 */
495 postCss: function (text) {
496 var token = mw.user.tokens.get('csrfToken'),
497 summary = mw.message('less-dialog-edit-summary', source).plain(),
498 params = {
499 action: 'edit',
500 summary: summary,
501 token: token,
502 title: target,
503 text: text
504 },
505 api;
506
507 // safe guard for debugging
508 // as mw.Api isn't loaded for anons
509 if (!conf.wgUserName) {
510 mw.log('User is not logged in');
511 return;
512 }
513
514 // use mw.Api as it escapes all out params for us as required
515 api = new mw.Api();
516 api.post(params)
517 .done(function (data) {
518 if (data.edit && data.edit.result === 'Success') {
519 util.addLine(mw.message('less-dialog-edit-success', target).parse());
520
521 /*window.setTimeout(function () {
522 self.closeModal(config.reload);
523 }, 2000);*/
524 } else if (data.error) {
525 util.addLine(data.error.code + ': ' + data.error.info, true);
526 util.addLine(
527 mw.message('error-persist', 'meta:MediaWiki talk:Gadget-Less-core.js').parse(),
528 true
529 );
530 } else {
531 mw.log(data);
532 util.addLine(mw.message('less-dialog-unknown-error').escaped(), true);
533 util.addLine(mw.message('less-dialog-error-persist').escaped(), true);
534 }
535 });
536 }
537 };
538
539 if (conf.debug) {
540 wgl.less = self;
541 } else {
542 wgl.less = self.init;
543 }
544
545 $(self.init);
546
547 }(this, this.location, this.jQuery, this.mediaWiki, this.wgl = this.wgl || {}));