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