MediaWiki:Gadget-Less-core.js

From Old School Near-Reality Wiki
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...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
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 /*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('&gt;&nbsp;' + 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 || {}));