MediaWiki:Gadget-Less-core.js

From Old School Near-Reality Wiki
Revision as of 21:38, 4 October 2022 by Bawolff (talk | contribs) (change msg implementation)
(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 
 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('&gt;&nbsp;' + 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 || {}));