From a1a1e8da18e38d89a7bff967075e056baeefcb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Mon, 14 Feb 2022 21:35:33 -0500 Subject: [PATCH] feat: webpack 5 part 1 --- Gruntfile.js | 5 +- install/package.json | 7 +- public/src/admin/admin.js | 6 - public/src/admin/advanced/errors.js | 2 +- public/src/admin/appearance/customise.js | 2 +- public/src/admin/dashboard.js | 2 +- public/src/admin/extend/plugins.js | 2 +- public/src/admin/extend/widgets.js | 8 +- public/src/admin/manage/categories.js | 2 +- public/src/admin/manage/category-analytics.js | 2 +- .../src/admin/modules/dashboard-line-graph.js | 2 +- public/src/admin/modules/selectable.js | 2 +- public/src/admin/settings/email.js | 2 +- public/src/admin/settings/navigation.js | 6 +- public/src/ajaxify.js | 73 +- public/src/app.js | 43 +- public/src/client/account/header.js | 4 +- public/src/client/flags/list.js | 2 +- public/src/client/ip-blacklist.js | 2 +- public/src/client/test.js | 101 +++ public/src/modules/ace-editor.js | 20 + public/src/modules/helpers.common.js | 368 ++++++++ public/src/modules/helpers.js | 378 +------- public/src/modules/pictureCropper.js | 5 +- public/src/modules/settings.js | 22 +- public/src/modules/settings/sorted-list.js | 2 +- public/src/modules/taskbar.js | 6 +- public/src/modules/topicThumbs.js | 2 +- public/src/modules/translator.common.js | 629 +++++++++++++ public/src/modules/translator.js | 671 +------------- public/src/overrides.js | 158 ++-- public/src/require-config.js | 13 - public/src/sockets.js | 5 +- public/src/utils.common.js | 776 ++++++++++++++++ public/src/utils.js | 833 ++---------------- public/src/widgets.js | 91 +- src/cli/index.js | 1 + src/controllers/admin/settings.js | 2 +- src/meta/build.js | 50 ++ src/meta/js.js | 317 ++----- src/routes/debug.js | 16 +- src/routes/index.js | 1 + src/translator.js | 11 +- src/utils.js | 16 +- src/views/500-embed.tpl | 23 +- src/views/partials/footer/js.tpl | 2 +- src/views/test.tpl | 59 ++ src/webserver.js | 2 +- test/template-helpers.js | 3 +- webpack.common.js | 59 ++ webpack.dev.js | 9 + webpack.installer.js | 25 + webpack.prod.js | 8 + 53 files changed, 2550 insertions(+), 2308 deletions(-) create mode 100644 public/src/client/test.js create mode 100644 public/src/modules/ace-editor.js create mode 100644 public/src/modules/helpers.common.js create mode 100644 public/src/modules/translator.common.js delete mode 100644 public/src/require-config.js create mode 100644 public/src/utils.common.js create mode 100644 src/views/test.tpl create mode 100644 webpack.common.js create mode 100644 webpack.dev.js create mode 100644 webpack.installer.js create mode 100644 webpack.prod.js diff --git a/Gruntfile.js b/Gruntfile.js index d95d14f7fd..d7201d1b1b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -137,9 +137,10 @@ module.exports = function (grunt) { }); const build = require('./src/meta/build'); if (!grunt.option('skip')) { - await build.build(true); + await build.build(true, { webpack: false }); } run(); + await build.webpack({ watch: true }); done(); }); @@ -183,7 +184,7 @@ module.exports = function (grunt) { return run(); } - require('./src/meta/build').build([compiling], (err) => { + require('./src/meta/build').build([compiling], { webpack: false }, (err) => { if (err) { winston.error(err.stack); } diff --git a/install/package.json b/install/package.json index 992af9563c..2344c516f5 100644 --- a/install/package.json +++ b/install/package.json @@ -60,6 +60,7 @@ "express": "4.17.2", "express-session": "1.17.2", "express-useragent": "1.0.15", + "file-loader": "6.2.0", "graceful-fs": "4.2.9", "helmet": "5.0.2", "html-to-text": "8.1.0", @@ -112,7 +113,6 @@ "ioredis": "4.28.5", "request": "2.88.2", "request-promise-native": "1.0.9", - "requirejs": "2.3.6", "rimraf": "3.0.2", "rss": "1.2.2", "sanitize-html": "2.7.0", @@ -136,6 +136,7 @@ "uglify-es": "3.3.9", "validator": "13.7.0", "visibilityjs": "2.0.2", + "webpack": "^5.68.0", "winston": "3.6.0", "xml": "1.0.1", "xregexp": "5.1.0", @@ -159,7 +160,9 @@ "mocha-lcov-reporter": "1.3.0", "mockdate": "3.0.5", "nyc": "15.1.0", - "smtp-server": "3.9.0" + "smtp-server": "3.9.0", + "webpack-merge": "5.8.0", + "clean-webpack-plugin": "4.0.0" }, "bugs": { "url": "https://github.com/NodeBB/NodeBB/issues" diff --git a/public/src/admin/admin.js b/public/src/admin/admin.js index a91572c009..2e8f661d4e 100644 --- a/public/src/admin/admin.js +++ b/public/src/admin/admin.js @@ -227,10 +227,4 @@ }); }); } - - // tell ace to use the right paths when requiring modules - require(['ace/ace'], function (ace) { - ace.config.set('packaged', true); - ace.config.set('basePath', config.relative_path + '/assets/src/modules/ace/'); - }); }()); diff --git a/public/src/admin/advanced/errors.js b/public/src/admin/advanced/errors.js index b64f110b2a..9f904e2e14 100644 --- a/public/src/admin/advanced/errors.js +++ b/public/src/admin/advanced/errors.js @@ -1,7 +1,7 @@ 'use strict'; -define('admin/advanced/errors', ['bootbox', 'alerts', 'Chart'], function (bootbox, alerts, Chart) { +define('admin/advanced/errors', ['bootbox', 'alerts', 'chart.js'], function (bootbox, alerts, Chart) { const Errors = {}; Errors.init = function () { diff --git a/public/src/admin/appearance/customise.js b/public/src/admin/appearance/customise.js index d0a95050fd..5744eeaf02 100644 --- a/public/src/admin/appearance/customise.js +++ b/public/src/admin/appearance/customise.js @@ -1,6 +1,6 @@ 'use strict'; -define('admin/appearance/customise', ['admin/settings', 'ace/ace'], function (Settings, ace) { +define('admin/appearance/customise', ['admin/settings', 'ace-editor'], function (Settings, ace) { const Customise = {}; Customise.init = function () { diff --git a/public/src/admin/dashboard.js b/public/src/admin/dashboard.js index dc20cb9c41..026aba91e6 100644 --- a/public/src/admin/dashboard.js +++ b/public/src/admin/dashboard.js @@ -2,7 +2,7 @@ define('admin/dashboard', [ - 'Chart', 'translator', 'benchpress', 'bootbox', 'alerts', + 'chart.js', 'translator', 'benchpress', 'bootbox', 'alerts', ], function (Chart, translator, Benchpress, bootbox, alerts) { const Admin = {}; const intervals = { diff --git a/public/src/admin/extend/plugins.js b/public/src/admin/extend/plugins.js index 142323c870..cd91dc0fb3 100644 --- a/public/src/admin/extend/plugins.js +++ b/public/src/admin/extend/plugins.js @@ -6,7 +6,7 @@ define('admin/extend/plugins', [ 'benchpress', 'bootbox', 'alerts', - 'jquery-ui/widgets/sortable', + 'jquery-ui/ui/widgets/sortable', ], function (translator, Benchpress, bootbox, alerts) { const Plugins = {}; Plugins.init = function () { diff --git a/public/src/admin/extend/widgets.js b/public/src/admin/extend/widgets.js index 029bdc4a85..76d73d4d95 100644 --- a/public/src/admin/extend/widgets.js +++ b/public/src/admin/extend/widgets.js @@ -4,10 +4,10 @@ define('admin/extend/widgets', [ 'bootbox', 'alerts', - 'jquery-ui/widgets/sortable', - 'jquery-ui/widgets/draggable', - 'jquery-ui/widgets/droppable', - 'jquery-ui/widgets/datepicker', + 'jquery-ui/ui/widgets/sortable', + 'jquery-ui/ui/widgets/draggable', + 'jquery-ui/ui/widgets/droppable', + 'jquery-ui/ui/widgets/datepicker', ], function (bootbox, alerts) { const Widgets = {}; diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js index 6c2664a09a..c3b682e96f 100644 --- a/public/src/admin/manage/categories.js +++ b/public/src/admin/manage/categories.js @@ -5,7 +5,7 @@ define('admin/manage/categories', [ 'benchpress', 'categorySelector', 'api', - 'Sortable', + 'sortablejs', 'bootbox', 'alerts', ], function (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) { diff --git a/public/src/admin/manage/category-analytics.js b/public/src/admin/manage/category-analytics.js index a4366d07d1..1768feabee 100644 --- a/public/src/admin/manage/category-analytics.js +++ b/public/src/admin/manage/category-analytics.js @@ -1,7 +1,7 @@ 'use strict'; -define('admin/manage/category-analytics', ['Chart'], function (Chart) { +define('admin/manage/category-analytics', ['chart.js'], function (Chart) { const CategoryAnalytics = {}; CategoryAnalytics.init = function () { diff --git a/public/src/admin/modules/dashboard-line-graph.js b/public/src/admin/modules/dashboard-line-graph.js index 1e11b82e5b..dcfa1db80a 100644 --- a/public/src/admin/modules/dashboard-line-graph.js +++ b/public/src/admin/modules/dashboard-line-graph.js @@ -1,6 +1,6 @@ 'use strict'; -define('admin/modules/dashboard-line-graph', ['Chart', 'translator', 'benchpress', 'api', 'hooks', 'bootbox'], function (Chart, translator, Benchpress, api, hooks, bootbox) { +define('admin/modules/dashboard-line-graph', ['chart.js', 'translator', 'benchpress', 'api', 'hooks', 'bootbox'], function (Chart, translator, Benchpress, api, hooks, bootbox) { const Graph = { _current: null, }; diff --git a/public/src/admin/modules/selectable.js b/public/src/admin/modules/selectable.js index 470462d558..22dee352bc 100644 --- a/public/src/admin/modules/selectable.js +++ b/public/src/admin/modules/selectable.js @@ -2,7 +2,7 @@ define('admin/modules/selectable', [ - 'jquery-ui/widgets/selectable', + 'jquery-ui/ui/widgets/selectable', ], function () { const selectable = {}; diff --git a/public/src/admin/settings/email.js b/public/src/admin/settings/email.js index 20f5d8587d..663b564e83 100644 --- a/public/src/admin/settings/email.js +++ b/public/src/admin/settings/email.js @@ -1,7 +1,7 @@ 'use strict'; -define('admin/settings/email', ['ace/ace', 'alerts', 'admin/settings'], function (ace, alerts) { +define('admin/settings/email', ['alerts', 'admin/settings', 'ace-editor'], function (ace, alerts) { const module = {}; let emailEditor; diff --git a/public/src/admin/settings/navigation.js b/public/src/admin/settings/navigation.js index bcd2bccc0d..aca3d530a8 100644 --- a/public/src/admin/settings/navigation.js +++ b/public/src/admin/settings/navigation.js @@ -6,9 +6,9 @@ define('admin/settings/navigation', [ 'iconSelect', 'benchpress', 'alerts', - 'jquery-ui/widgets/draggable', - 'jquery-ui/widgets/droppable', - 'jquery-ui/widgets/sortable', + 'jquery-ui/ui/widgets/draggable', + 'jquery-ui/ui/widgets/droppable', + 'jquery-ui/ui/widgets/sortable', ], function (translator, iconSelect, Benchpress, alerts) { const navigation = {}; let available; diff --git a/public/src/ajaxify.js b/public/src/ajaxify.js index 04bcb6949e..e6ec0cf0d7 100644 --- a/public/src/ajaxify.js +++ b/public/src/ajaxify.js @@ -1,8 +1,14 @@ 'use strict'; +import $ from 'jquery'; +import Benchpress from 'benchpressjs'; +import render from './widgets'; +import translator from './modules/translator'; -ajaxify = window.ajaxify || {}; +translator.translate('[[error:no-connection]]'); +window.ajaxify = window.ajaxify || {}; +ajaxify.widgets = { render: render }; (function () { let apiXHR = null; let ajaxifyTimer; @@ -328,14 +334,9 @@ ajaxify = window.ajaxify || {}; }; ajaxify.loadScript = function (tpl_url, callback) { - let location = !app.inAdmin ? 'forum/' : ''; - - if (tpl_url.startsWith('admin')) { - location = ''; - } const data = { tpl_url: tpl_url, - scripts: [location + tpl_url], + scripts: [tpl_url], }; // Hint: useful if you want to load a module on a specific page (append module name to `scripts`) @@ -352,18 +353,14 @@ ajaxify = window.ajaxify || {}; }; } if (typeof script === 'string') { - return function (next) { - require([script], function (module) { - // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), - // or call a method other than .init() - hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => { - if (module && module.init) { - module.init(); - } - next(); - }); - }, function () { - // ignore 404 error + return async function (next) { + const module = await importScript(script); + // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), + // or call a method other than .init() + hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => { + if (module && module.init) { + module.init(); + } next(); }); }; @@ -386,6 +383,25 @@ ajaxify = window.ajaxify || {}; }); }; + async function importScript(scriptName) { + let pageScript; + try { + if (scriptName.startsWith('admin/plugins')) { + pageScript = await import(/* webpackChunkName: "admin/plugins/[request]" */ 'admin/plugins/' + scriptName.replace(/^admin\/plugins\//, '')); + } else if (scriptName.startsWith('admin')) { + pageScript = await import(/* webpackChunkName: "admin/[request]" */ 'admin/' + scriptName.replace(/^admin\//, '')); + } else if (scriptName.startsWith('forum/plugins')) { + pageScript = await import(/* webpackChunkName: "forum/plugins/[request]" */ 'forum/plugins/' + scriptName.replace(/^forum\/plugins\//, '')); + } else { + pageScript = await import(/* webpackChunkName: "forum/[request]" */ 'forum/' + scriptName); + } + } catch (err) { + console.warn('error loading script' + err.stack); + } + return pageScript; + } + + ajaxify.loadData = function (url, callback) { url = ajaxify.removeRelativePath(url); @@ -434,9 +450,24 @@ ajaxify = window.ajaxify || {}; }; ajaxify.loadTemplate = function (template, callback) { - require([config.assetBaseUrl + '/templates/' + template + '.js'], callback, function (err) { + $.ajax({ + url: `${config.assetBaseUrl}/templates/${template}.js`, + dataType: 'text', + success: function (script) { + var context = { + module: { + exports: {}, + }, + }; + + // eslint-disable-next-line no-new-func + const renderFunction = new Function('module', script); + renderFunction(context.module); + callback(context.module.exports); + }, + }).fail(function () { console.error('Unable to load template: ' + template); - throw err; + callback(new Error('[[error:unable-to-load-template]]')); }); }; diff --git a/public/src/app.js b/public/src/app.js index 92bdccc1bc..716514b72e 100644 --- a/public/src/app.js +++ b/public/src/app.js @@ -1,5 +1,24 @@ 'use strict'; +window.$ = require('jquery'); + +window.jQuery = window.$; +require('bootstrap'); +window.bootbox = require('bootbox'); +require('jquery-form'); +window.utils = require('./utils'); +require('timeago'); + +const Visibility = require('visibilityjs'); +const Benchpress = require('benchpressjs'); + +Benchpress.setGlobal('config', config); + +require('../../build/public/client-scripts.min'); + +require('./sockets'); +require('./overrides'); +require('./ajaxify'); app = window.app || {}; @@ -8,6 +27,7 @@ app.currentRoom = null; app.widgets = {}; app.flags = {}; + (function () { let appLoaded = false; const isTouchDevice = utils.isTouchDevice(); @@ -114,19 +134,6 @@ app.flags = {}; }); }; - app.require = async (modules) => { // allows you to await require.js modules - const single = !Array.isArray(modules); - if (single) { - modules = [modules]; - } - - return new Promise((resolve, reject) => { - require(modules, (...exports) => { - resolve(single ? exports.pop() : exports); - }, reject); - }); - }; - app.logout = function (redirect) { console.warn('[deprecated] app.logout is deprecated, please use logout module directly'); require(['logout'], function (logout) { @@ -322,11 +329,11 @@ app.flags = {}; return callback(); } require([ - 'jquery-ui/widgets/datepicker', - 'jquery-ui/widgets/autocomplete', - 'jquery-ui/widgets/sortable', - 'jquery-ui/widgets/resizable', - 'jquery-ui/widgets/draggable', + 'jquery-ui/ui/widgets/datepicker', + 'jquery-ui/ui/widgets/autocomplete', + 'jquery-ui/ui/widgets/sortable', + 'jquery-ui/ui/widgets/resizable', + 'jquery-ui/ui/widgets/draggable', ], function () { callback(); }); diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js index c7880123c9..206760072b 100644 --- a/public/src/client/account/header.js +++ b/public/src/client/account/header.js @@ -35,7 +35,7 @@ define('forum/account/header', [ components.get('account/chat').on('click', async function () { const roomId = await socket.emit('modules.chats.hasPrivateChat', ajaxify.data.uid); - const chat = await app.require('chat'); + const chat = await import('chat'); if (roomId) { chat.openChat(roomId); } else { @@ -44,7 +44,7 @@ define('forum/account/header', [ }); components.get('account/new-chat').on('click', async function () { - const chat = await app.require('chat'); + const chat = await import('chat'); chat.newChat(ajaxify.data.uid, function () { components.get('account/chat').parent().removeClass('hidden'); }); diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index f2e15cedab..5633a8df0f 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -1,7 +1,7 @@ 'use strict'; define('forum/flags/list', [ - 'components', 'Chart', 'categoryFilter', 'autocomplete', 'api', 'alerts', + 'components', 'chart.js', 'categoryFilter', 'autocomplete', 'api', 'alerts', ], function (components, Chart, categoryFilter, autocomplete, api, alerts) { const Flags = {}; diff --git a/public/src/client/ip-blacklist.js b/public/src/client/ip-blacklist.js index 5fccd07a32..6eb95fed74 100644 --- a/public/src/client/ip-blacklist.js +++ b/public/src/client/ip-blacklist.js @@ -1,7 +1,7 @@ 'use strict'; -define('forum/ip-blacklist', ['Chart', 'benchpress', 'bootbox', 'alerts'], function (Chart, Benchpress, bootbox, alerts) { +define('forum/ip-blacklist', ['chart.js', 'benchpress', 'bootbox', 'alerts'], function (Chart, Benchpress, bootbox, alerts) { const Blacklist = {}; Blacklist.init = function () { diff --git a/public/src/client/test.js b/public/src/client/test.js new file mode 100644 index 0000000000..1eaf384423 --- /dev/null +++ b/public/src/client/test.js @@ -0,0 +1,101 @@ +import 'jquery-ui/ui/widgets/datepicker'; +import Sortable from 'sortablejs'; +import semver from 'semver'; +import * as autocomplete from 'autocomplete'; +// we are using browser colorpicker +// import { enable as colorpickerEnable } from '../admin/modules/colorpicker'; +import 'jquery-deserialize'; +import * as api from 'api'; +import * as alerts from 'alerts'; +export function init() { + console.log('should be true semver.gt("1.1.1", "1.0.0")', semver.gt('1.1.1', '1.0.0')); + + $('#change-skin').val(config.bootswatchSkin); + + $('#inputTags').tagsinput({ + confirmKeys: [13, 44], + trimValue: true, + }); + + $('#inputBirthday').datepicker({ + changeMonth: true, + changeYear: true, + yearRange: '1900:-5y', + defaultDate: '-13y', + }); + + $('#change-language').on('click', function () { + config.userLang = 'tr'; + var languageCode = utils.userLangToTimeagoCode(config.userLang); + import(/* webpackChunkName: "timeago/[request]" */ 'timeago/locales/jquery.timeago.' + languageCode).then(function () { + overrides.overrideTimeago(); + ajaxify.refresh(); + }); + }); + + // colorpickerEnable($('#colorpicker')); + + autocomplete.user($('#autocomplete')); + + Sortable.create($('#sortable-list')[0], {}); + + var data = $('#form-serialize').serializeObject(); + $('#json-form-data').text(JSON.stringify(data, null, 2)); + + $('#form-deserialize').deserialize({ + foo: [1, 2], + moo: 'it works', + }); + + $('#change-skin').change(async function () { + var newSkin = $(this).val(); + api.put(`/users/${app.user.uid}/settings`, { + settings: { + postsPerPage: 20, + topicsPerPage: 20, + bootswatchSkin: newSkin, + } + }).then((newSettings) => { + config.bootswatchSkin = newSkin; + reskin(newSkin); + }).catch(alerts.error); + }); + + // copied from account/settings + async function reskin(skinName) { + const clientEl = Array.prototype.filter.call(document.querySelectorAll('link[rel="stylesheet"]'), function (el) { + return el.href.indexOf(config.relative_path + '/assets/client') !== -1; + })[0] || null; + if (!clientEl) { + return; + } + + const currentSkinClassName = $('body').attr('class').split(/\s+/).filter(function (className) { + return className.startsWith('skin-'); + }); + if (!currentSkinClassName[0]) { + return; + } + let currentSkin = currentSkinClassName[0].slice(5); + currentSkin = currentSkin !== 'noskin' ? currentSkin : ''; + + // Stop execution if skin didn't change + if (skinName === currentSkin) { + return; + } + + const linkEl = document.createElement('link'); + linkEl.rel = 'stylesheet'; + linkEl.type = 'text/css'; + linkEl.href = config.relative_path + '/assets/client' + (skinName ? '-' + skinName : '') + '.css'; + linkEl.onload = function () { + clientEl.parentNode.removeChild(clientEl); + + // Update body class with proper skin name + $('body').removeClass(currentSkinClassName.join(' ')); + $('body').addClass('skin-' + (skinName || 'noskin')); + }; + + document.head.appendChild(linkEl); + } +} \ No newline at end of file diff --git a/public/src/modules/ace-editor.js b/public/src/modules/ace-editor.js new file mode 100644 index 0000000000..322878fe44 --- /dev/null +++ b/public/src/modules/ace-editor.js @@ -0,0 +1,20 @@ +import ace from 'ace-builds'; + +// only import the modes and theme we use +import 'ace-builds/src-noconflict/mode-javascript'; +import 'ace-builds/src-noconflict/mode-less'; +import 'ace-builds/src-noconflict/mode-html'; +import 'ace-builds/src-noconflict/ext-searchbox'; +import 'ace-builds/src-noconflict/theme-twilight'; + +/* eslint-disable import/no-webpack-loader-syntax */ +/* eslint-disable import/no-unresolved */ +import htmlWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-html'; +import javascriptWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-javascript'; +import cssWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-css'; + +ace.config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl); +ace.config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl); +ace.config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl); + + diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js new file mode 100644 index 0000000000..ae40828ead --- /dev/null +++ b/public/src/modules/helpers.common.js @@ -0,0 +1,368 @@ +'use strict'; + +module.exports = function (utils, Benchpress, relative_path) { + Benchpress.setGlobal('true', true); + Benchpress.setGlobal('false', false); + + const helpers = { + displayMenuItem, + buildMetaTag, + buildLinkTag, + stringify, + escape, + stripTags, + generateCategoryBackground, + generateChildrenCategories, + generateTopicClass, + membershipBtn, + spawnPrivilegeStates, + localeToHTML, + renderTopicImage, + renderTopicEvents, + renderEvents, + renderDigestAvatar, + userAgentIcons, + buildAvatar, + register, + __escape: identity, + }; + + function identity(str) { + return str; + } + + function displayMenuItem(data, index) { + const item = data.navigation[index]; + if (!item) { + return false; + } + + if (item.route.match('/users') && data.user && !data.user.privileges['view:users']) { + return false; + } + + if (item.route.match('/tags') && data.user && !data.user.privileges['view:tags']) { + return false; + } + + if (item.route.match('/groups') && data.user && !data.user.privileges['view:groups']) { + return false; + } + + return true; + } + + function buildMetaTag(tag) { + const name = tag.name ? 'name="' + tag.name + '" ' : ''; + const property = tag.property ? 'property="' + tag.property + '" ' : ''; + const content = tag.content ? 'content="' + tag.content.replace(/\n/g, ' ') + '" ' : ''; + + return '\n\t'; + } + + function buildLinkTag(tag) { + const attributes = ['link', 'rel', 'as', 'type', 'href', 'sizes', 'title', 'crossorigin']; + const [link, rel, as, type, href, sizes, title, crossorigin] = attributes.map(attr => (tag[attr] ? `${attr}="${tag[attr]}" ` : '')); + + return '\n\t'; + } + + function stringify(obj) { + // Turns the incoming object into a JSON string + return JSON.stringify(obj).replace(/&/gm, '&').replace(//gm, '>') + .replace(/"/g, '"'); + } + + function escape(str) { + return utils.escapeHTML(str); + } + + function stripTags(str) { + return utils.stripHTMLTags(str); + } + + function generateCategoryBackground(category) { + if (!category) { + return ''; + } + const style = []; + + if (category.bgColor) { + style.push('background-color: ' + category.bgColor); + } + + if (category.color) { + style.push('color: ' + category.color); + } + + if (category.backgroundImage) { + style.push('background-image: url(' + category.backgroundImage + ')'); + if (category.imageClass) { + style.push('background-size: ' + category.imageClass); + } + } + + return style.join('; ') + ';'; + } + + function generateChildrenCategories(category) { + let html = ''; + if (!category || !category.children || !category.children.length) { + return html; + } + category.children.forEach(function (child) { + if (child && !child.isSection) { + const link = child.link ? child.link : (relative_path + '/category/' + child.slug); + html += '' + + '' + + '' + child.name + ''; + } + }); + html = html ? ('' + html + '') : html; + return html; + } + + function generateTopicClass(topic) { + const style = []; + + if (topic.locked) { + style.push('locked'); + } + + if (topic.pinned) { + style.push('pinned'); + } + + if (topic.deleted) { + style.push('deleted'); + } + + if (topic.unread) { + style.push('unread'); + } + + if (topic.scheduled) { + style.push('scheduled'); + } + + return style.join(' '); + } + + // Groups helpers + function membershipBtn(groupObj) { + if (groupObj.isMember && groupObj.name !== 'administrators') { + return ''; + } + + if (groupObj.isPending && groupObj.name !== 'administrators') { + return ''; + } else if (groupObj.isInvited) { + return ''; + } else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') { + return ''; + } + return ''; + } + + function spawnPrivilegeStates(member, privileges) { + const states = []; + for (const priv in privileges) { + if (privileges.hasOwnProperty(priv)) { + states.push({ + name: priv, + state: privileges[priv], + }); + } + } + return states.map(function (priv) { + const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; + const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; + const globalModDisabled = ['groups:moderate']; + const disabled = + (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) || + (member === 'spiders' && !spidersEnabled.includes(priv.name)) || + (member === 'Global Moderators' && globalModDisabled.includes(priv.name)); + + return ''; + }).join(''); + } + + function localeToHTML(locale, fallback) { + locale = locale || fallback || 'en-GB'; + return locale.replace('_', '-'); + } + + function renderTopicImage(topicObj) { + if (topicObj.thumb) { + return ''; + } + return ''; + } + + function renderTopicEvents(index, sort) { + if (sort === 'most_votes') { + return ''; + } + const start = this.posts[index].eventStart; + const end = this.posts[index].eventEnd; + const events = this.events.filter(event => event.timestamp >= start && event.timestamp < end); + if (!events.length) { + return ''; + } + + return renderEvents.call(this, events); + } + + function renderEvents(events) { + return events.reduce((html, event) => { + html += `
  • +
    + +
    + + ${event.href ? `${event.text}` : event.text}  + + `; + + if (event.user) { + if (!event.user.system) { + html += `${buildAvatar(event.user, 'xs', true)} ${event.user.username} `; + } else { + html += `[[global:system-user]] `; + } + } + + html += ``; + + if (this.privileges.isAdminOrMod) { + html += ` `; + } + + return html; + }, ''); + } + + function renderDigestAvatar(block) { + if (block.teaser) { + if (block.teaser.user.picture) { + return ''; + } + return '
    ' + block.teaser.user['icon:text'] + '
    '; + } + if (block.user.picture) { + return ''; + } + return '
    ' + block.user['icon:text'] + '
    '; + } + + function userAgentIcons(data) { + let icons = ''; + + switch (data.platform) { + case 'Linux': + icons += ''; + break; + case 'Microsoft Windows': + icons += ''; + break; + case 'Apple Mac': + icons += ''; + break; + case 'Android': + icons += ''; + break; + case 'iPad': + icons += ''; + break; + case 'iPod': // intentional fall-through + case 'iPhone': + icons += ''; + break; + default: + icons += ''; + break; + } + + switch (data.browser) { + case 'Chrome': + icons += ''; + break; + case 'Firefox': + icons += ''; + break; + case 'Safari': + icons += ''; + break; + case 'IE': + icons += ''; + break; + case 'Edge': + icons += ''; + break; + default: + icons += ''; + break; + } + + return icons; + } + + function buildAvatar(userObj, size, rounded, classNames, component) { + /** + * userObj requires: + * - uid, picture, icon:bgColor, icon:text (getUserField w/ "picture" should return all 4), username + * size: one of "xs", "sm", "md", "lg", or "xl" (required), or an integer + * rounded: true or false (optional, default false) + * classNames: additional class names to prepend (optional, default none) + * component: overrides the default component (optional, default none) + */ + + // Try to use root context if passed-in userObj is undefined + if (!userObj) { + userObj = this; + } + + const attributes = [ + 'alt="' + userObj.username + '"', + 'title="' + userObj.username + '"', + 'data-uid="' + userObj.uid + '"', + 'loading="lazy"', + ]; + const styles = []; + classNames = classNames || ''; + + // Validate sizes, handle integers, otherwise fall back to `avatar-sm` + if (['xs', 'sm', 'sm2x', 'md', 'lg', 'xl'].includes(size)) { + classNames += ' avatar-' + size; + } else if (!isNaN(parseInt(size, 10))) { + styles.push('width: ' + size + 'px;', 'height: ' + size + 'px;', 'line-height: ' + size + 'px;', 'font-size: ' + (parseInt(size, 10) / 16) + 'rem;'); + } else { + classNames += ' avatar-sm'; + } + attributes.unshift('class="avatar ' + classNames + (rounded ? ' avatar-rounded' : '') + '"'); + + // Component override + if (component) { + attributes.push('component="' + component + '"'); + } else { + attributes.push('component="avatar/' + (userObj.picture ? 'picture' : 'icon') + '"'); + } + + if (userObj.picture) { + return ''; + } + + styles.push('background-color: ' + userObj['icon:bgColor'] + ';'); + return '' + userObj['icon:text'] + ''; + } + + function register() { + Object.keys(helpers).forEach(function (helperName) { + Benchpress.registerHelper(helperName, helpers[helperName]); + }); + } + + return helpers; +}; diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js index a73c251100..f471d869b6 100644 --- a/public/src/modules/helpers.js +++ b/public/src/modules/helpers.js @@ -1,377 +1,7 @@ 'use strict'; -(function (factory) { - if (typeof module === 'object' && module.exports) { - const relative_path = require('nconf').get('relative_path'); - module.exports = factory(require('../utils'), require('benchpressjs'), relative_path); - } else if (typeof define === 'function' && define.amd) { - define('helpers', ['benchpress'], function (Benchpress) { - return factory(utils, Benchpress, config.relative_path); - }); - } -}(function (utils, Benchpress, relative_path) { - Benchpress.setGlobal('true', true); - Benchpress.setGlobal('false', false); +const factory = require('./helpers.common'); - const helpers = { - displayMenuItem, - buildMetaTag, - buildLinkTag, - stringify, - escape, - stripTags, - generateCategoryBackground, - generateChildrenCategories, - generateTopicClass, - membershipBtn, - spawnPrivilegeStates, - localeToHTML, - renderTopicImage, - renderTopicEvents, - renderEvents, - renderDigestAvatar, - userAgentIcons, - buildAvatar, - register, - __escape: identity, - }; - - function identity(str) { - return str; - } - - function displayMenuItem(data, index) { - const item = data.navigation[index]; - if (!item) { - return false; - } - - if (item.route.match('/users') && data.user && !data.user.privileges['view:users']) { - return false; - } - - if (item.route.match('/tags') && data.user && !data.user.privileges['view:tags']) { - return false; - } - - if (item.route.match('/groups') && data.user && !data.user.privileges['view:groups']) { - return false; - } - - return true; - } - - function buildMetaTag(tag) { - const name = tag.name ? 'name="' + tag.name + '" ' : ''; - const property = tag.property ? 'property="' + tag.property + '" ' : ''; - const content = tag.content ? 'content="' + tag.content.replace(/\n/g, ' ') + '" ' : ''; - - return '\n\t'; - } - - function buildLinkTag(tag) { - const attributes = ['link', 'rel', 'as', 'type', 'href', 'sizes', 'title', 'crossorigin']; - const [link, rel, as, type, href, sizes, title, crossorigin] = attributes.map(attr => (tag[attr] ? `${attr}="${tag[attr]}" ` : '')); - - return '\n\t'; - } - - function stringify(obj) { - // Turns the incoming object into a JSON string - return JSON.stringify(obj).replace(/&/gm, '&').replace(//gm, '>') - .replace(/"/g, '"'); - } - - function escape(str) { - return utils.escapeHTML(str); - } - - function stripTags(str) { - return utils.stripHTMLTags(str); - } - - function generateCategoryBackground(category) { - if (!category) { - return ''; - } - const style = []; - - if (category.bgColor) { - style.push('background-color: ' + category.bgColor); - } - - if (category.color) { - style.push('color: ' + category.color); - } - - if (category.backgroundImage) { - style.push('background-image: url(' + category.backgroundImage + ')'); - if (category.imageClass) { - style.push('background-size: ' + category.imageClass); - } - } - - return style.join('; ') + ';'; - } - - function generateChildrenCategories(category) { - let html = ''; - if (!category || !category.children || !category.children.length) { - return html; - } - category.children.forEach(function (child) { - if (child && !child.isSection) { - const link = child.link ? child.link : (relative_path + '/category/' + child.slug); - html += '' + - '' + - '' + child.name + ''; - } - }); - html = html ? ('' + html + '') : html; - return html; - } - - function generateTopicClass(topic) { - const style = []; - - if (topic.locked) { - style.push('locked'); - } - - if (topic.pinned) { - style.push('pinned'); - } - - if (topic.deleted) { - style.push('deleted'); - } - - if (topic.unread) { - style.push('unread'); - } - - if (topic.scheduled) { - style.push('scheduled'); - } - - return style.join(' '); - } - - // Groups helpers - function membershipBtn(groupObj) { - if (groupObj.isMember && groupObj.name !== 'administrators') { - return ''; - } - - if (groupObj.isPending && groupObj.name !== 'administrators') { - return ''; - } else if (groupObj.isInvited) { - return ''; - } else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') { - return ''; - } - return ''; - } - - function spawnPrivilegeStates(member, privileges) { - const states = []; - for (const priv in privileges) { - if (privileges.hasOwnProperty(priv)) { - states.push({ - name: priv, - state: privileges[priv], - }); - } - } - return states.map(function (priv) { - const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; - const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; - const globalModDisabled = ['groups:moderate']; - const disabled = - (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) || - (member === 'spiders' && !spidersEnabled.includes(priv.name)) || - (member === 'Global Moderators' && globalModDisabled.includes(priv.name)); - - return ''; - }).join(''); - } - - function localeToHTML(locale, fallback) { - locale = locale || fallback || 'en-GB'; - return locale.replace('_', '-'); - } - - function renderTopicImage(topicObj) { - if (topicObj.thumb) { - return ''; - } - return ''; - } - - function renderTopicEvents(index, sort) { - if (sort === 'most_votes') { - return ''; - } - const start = this.posts[index].eventStart; - const end = this.posts[index].eventEnd; - const events = this.events.filter(event => event.timestamp >= start && event.timestamp < end); - if (!events.length) { - return ''; - } - - return renderEvents.call(this, events); - } - - function renderEvents(events) { - return events.reduce((html, event) => { - html += `
  • -
    - -
    - - ${event.href ? `${event.text}` : event.text}  - - `; - - if (event.user) { - if (!event.user.system) { - html += `${buildAvatar(event.user, 'xs', true)} ${event.user.username} `; - } else { - html += `[[global:system-user]] `; - } - } - - html += ``; - - if (this.privileges.isAdminOrMod) { - html += ` `; - } - - return html; - }, ''); - } - - function renderDigestAvatar(block) { - if (block.teaser) { - if (block.teaser.user.picture) { - return ''; - } - return '
    ' + block.teaser.user['icon:text'] + '
    '; - } - if (block.user.picture) { - return ''; - } - return '
    ' + block.user['icon:text'] + '
    '; - } - - function userAgentIcons(data) { - let icons = ''; - - switch (data.platform) { - case 'Linux': - icons += ''; - break; - case 'Microsoft Windows': - icons += ''; - break; - case 'Apple Mac': - icons += ''; - break; - case 'Android': - icons += ''; - break; - case 'iPad': - icons += ''; - break; - case 'iPod': // intentional fall-through - case 'iPhone': - icons += ''; - break; - default: - icons += ''; - break; - } - - switch (data.browser) { - case 'Chrome': - icons += ''; - break; - case 'Firefox': - icons += ''; - break; - case 'Safari': - icons += ''; - break; - case 'IE': - icons += ''; - break; - case 'Edge': - icons += ''; - break; - default: - icons += ''; - break; - } - - return icons; - } - - function buildAvatar(userObj, size, rounded, classNames, component) { - /** - * userObj requires: - * - uid, picture, icon:bgColor, icon:text (getUserField w/ "picture" should return all 4), username - * size: one of "xs", "sm", "md", "lg", or "xl" (required), or an integer - * rounded: true or false (optional, default false) - * classNames: additional class names to prepend (optional, default none) - * component: overrides the default component (optional, default none) - */ - - // Try to use root context if passed-in userObj is undefined - if (!userObj) { - userObj = this; - } - - const attributes = [ - 'alt="' + userObj.username + '"', - 'title="' + userObj.username + '"', - 'data-uid="' + userObj.uid + '"', - 'loading="lazy"', - ]; - const styles = []; - classNames = classNames || ''; - - // Validate sizes, handle integers, otherwise fall back to `avatar-sm` - if (['xs', 'sm', 'sm2x', 'md', 'lg', 'xl'].includes(size)) { - classNames += ' avatar-' + size; - } else if (!isNaN(parseInt(size, 10))) { - styles.push('width: ' + size + 'px;', 'height: ' + size + 'px;', 'line-height: ' + size + 'px;', 'font-size: ' + (parseInt(size, 10) / 16) + 'rem;'); - } else { - classNames += ' avatar-sm'; - } - attributes.unshift('class="avatar ' + classNames + (rounded ? ' avatar-rounded' : '') + '"'); - - // Component override - if (component) { - attributes.push('component="' + component + '"'); - } else { - attributes.push('component="avatar/' + (userObj.picture ? 'picture' : 'icon') + '"'); - } - - if (userObj.picture) { - return ''; - } - - styles.push('background-color: ' + userObj['icon:bgColor'] + ';'); - return '' + userObj['icon:text'] + ''; - } - - function register() { - Object.keys(helpers).forEach(function (helperName) { - Benchpress.registerHelper(helperName, helpers[helperName]); - }); - } - - return helpers; -})); +define('helpers', ['utils', 'benchpressjs'], function (utils, Benchpressjs) { + return factory(utils, Benchpressjs, config.relative_path); +}); diff --git a/public/src/modules/pictureCropper.js b/public/src/modules/pictureCropper.js index f095ebd760..1d55d38bf5 100644 --- a/public/src/modules/pictureCropper.js +++ b/public/src/modules/pictureCropper.js @@ -1,6 +1,6 @@ 'use strict'; -define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { +define('pictureCropper', ['alerts'], function (alerts) { const module = {}; module.show = function (data, callback) { @@ -36,7 +36,7 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { $('#crop-picture-modal').remove(); app.parseAndTranslate('modals/crop_picture', { url: utils.escapeHTML(data.url), - }, function (cropperModal) { + }, async function (cropperModal) { cropperModal.modal({ backdrop: 'static', }).modal('show'); @@ -45,6 +45,7 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { const cropBoxHeight = parseInt($(window).height() / 2, 10); const img = document.getElementById('cropped-image'); $(img).css('max-height', cropBoxHeight); + const Cropper = await import(/* webpackChunkName: "cropperjs" */ 'cropperjs'); let cropperTool = new Cropper(img, { aspectRatio: data.aspectRatio, diff --git a/public/src/modules/settings.js b/public/src/modules/settings.js index a3f570dc7a..57dfa2abee 100644 --- a/public/src/modules/settings.js +++ b/public/src/modules/settings.js @@ -2,17 +2,6 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) { - const DEFAULT_PLUGINS = [ - 'settings/checkbox', - 'settings/number', - 'settings/textarea', - 'settings/select', - 'settings/array', - 'settings/key', - 'settings/object', - 'settings/sorted-list', - ]; - // eslint-disable-next-line prefer-const let Settings; let onReady = []; @@ -574,7 +563,16 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) { helper.registerReadyJobs(1); - require(DEFAULT_PLUGINS, function () { + require([ + 'settings/checkbox', + 'settings/number', + 'settings/textarea', + 'settings/select', + 'settings/array', + 'settings/key', + 'settings/object', + 'settings/sorted-list', + ], function () { for (let i = 0; i < arguments.length; i += 1) { Settings.registerPlugin(arguments[i]); } diff --git a/public/src/modules/settings/sorted-list.js b/public/src/modules/settings/sorted-list.js index dba5da9bc0..fa3d4e4c81 100644 --- a/public/src/modules/settings/sorted-list.js +++ b/public/src/modules/settings/sorted-list.js @@ -4,7 +4,7 @@ define('settings/sorted-list', [ 'benchpress', 'bootbox', 'hooks', - 'jquery-ui/widgets/sortable', + 'jquery-ui/ui/widgets/sortable', ], function (benchpress, bootbox, hooks) { let Settings; diff --git a/public/src/modules/taskbar.js b/public/src/modules/taskbar.js index 87fc2ad1c6..8242692745 100644 --- a/public/src/modules/taskbar.js +++ b/public/src/modules/taskbar.js @@ -14,10 +14,12 @@ define('taskbar', ['benchpress', 'translator', 'hooks'], function (Benchpress, t self.taskbar.on('click', 'li', function () { const $btn = $(this); - const module = $btn.attr('data-module'); + const moduleName = $btn.attr('data-module'); const uuid = $btn.attr('data-uuid'); - require([module], function (module) { + // TODO: throws warning in webpack + // https://webpack.js.org/api/module-methods/#dynamic-expressions-in-import + require([moduleName], function (module) { if (!$btn.hasClass('active')) { minimizeAll(); module.load(uuid); diff --git a/public/src/modules/topicThumbs.js b/public/src/modules/topicThumbs.js index 904a366d52..55b84ec92a 100644 --- a/public/src/modules/topicThumbs.js +++ b/public/src/modules/topicThumbs.js @@ -1,7 +1,7 @@ 'use strict'; define('topicThumbs', [ - 'api', 'bootbox', 'alerts', 'uploader', 'benchpress', 'translator', 'jquery-ui/widgets/sortable', + 'api', 'bootbox', 'alerts', 'uploader', 'benchpress', 'translator', 'jquery-ui/ui/widgets/sortable', ], function (api, bootbox, alerts, uploader, Benchpress, translator) { const Thumbs = {}; diff --git a/public/src/modules/translator.common.js b/public/src/modules/translator.common.js new file mode 100644 index 0000000000..684c014341 --- /dev/null +++ b/public/src/modules/translator.common.js @@ -0,0 +1,629 @@ +'use strict'; + +module.exports = function (utils, load, warn) { + const assign = Object.assign || jQuery.extend; + + function escapeHTML(str) { + return utils.escapeHTML(utils.decodeHTMLEntities( + String(str) + .replace(/[\s\xa0]+/g, ' ') + .replace(/^\s+|\s+$/g, '') + )); + } + + const Translator = (function () { + /** + * Construct a new Translator object + * @param {string} language - Language code for this translator instance + * @exports translator.Translator + */ + function Translator(language) { + const self = this; + + if (!language) { + throw new TypeError('Parameter `language` must be a language string. Received ' + language + (language === '' ? '(empty string)' : '')); + } + + self.modules = Object.keys(Translator.moduleFactories).map(function (namespace) { + const factory = Translator.moduleFactories[namespace]; + return [namespace, factory(language)]; + }).reduce(function (prev, elem) { + const namespace = elem[0]; + const module = elem[1]; + prev[namespace] = module; + + return prev; + }, {}); + + self.lang = language; + self.translations = {}; + } + + Translator.prototype.load = load; + + /** + * Parse the translation instructions into the language of the Translator instance + * @param {string} str - Source string + * @returns {Promise} + */ + Translator.prototype.translate = function translate(str) { + // regex for valid text in namespace / key + const validText = 'a-zA-Z0-9\\-_.\\/'; + const validTextRegex = new RegExp('[' + validText + ']'); + const invalidTextRegex = new RegExp('[^' + validText + '\\]]'); + + // current cursor position + let cursor = 0; + // last break of the input string + let lastBreak = 0; + // length of the input string + const len = str.length; + // array to hold the promises for the translations + // and the strings of untranslated text in between + const toTranslate = []; + + // to store the state of if we're currently in a top-level token for later + let inToken = false; + + // split a translator string into an array of tokens + // but don't split by commas inside other translator strings + function split(text) { + const len = text.length; + const arr = []; + let i = 0; + let brk = 0; + let level = 0; + + while (i + 2 <= len) { + if (text[i] === '[' && text[i + 1] === '[') { + level += 1; + i += 1; + } else if (text[i] === ']' && text[i + 1] === ']') { + level -= 1; + i += 1; + } else if (level === 0 && text[i] === ',' && text[i - 1] !== '\\') { + arr.push(text.slice(brk, i).trim()); + i += 1; + brk = i; + } + i += 1; + } + arr.push(text.slice(brk, i + 1).trim()); + return arr; + } + + // move to the first [[ + cursor = str.indexOf('[[', cursor); + + // the loooop, we'll go to where the cursor + // is equal to the length of the string since + // slice doesn't include the ending index + while (cursor + 2 <= len && cursor !== -1) { + // split the string from the last break + // to the character before the cursor + // add that to the result array + toTranslate.push(str.slice(lastBreak, cursor)); + // set the cursor position past the beginning + // brackets of the translation string + cursor += 2; + // set the last break to our current + // spot since we just broke the string + lastBreak = cursor; + // we're in a token now + inToken = true; + + // the current level of nesting of the translation strings + let level = 0; + let char0; + let char1; + // validating the current string is actually a translation + let textBeforeColonFound = false; + let colonFound = false; + let textAfterColonFound = false; + let commaAfterNameFound = false; + + while (cursor + 2 <= len) { + char0 = str[cursor]; + char1 = str[cursor + 1]; + // found some text after the double bracket, + // so this is probably a translation string + if (!textBeforeColonFound && validTextRegex.test(char0)) { + textBeforeColonFound = true; + cursor += 1; + // found a colon, so this is probably a translation string + } else if (textBeforeColonFound && !colonFound && char0 === ':') { + colonFound = true; + cursor += 1; + // found some text after the colon, + // so this is probably a translation string + } else if (colonFound && !textAfterColonFound && validTextRegex.test(char0)) { + textAfterColonFound = true; + cursor += 1; + } else if (textAfterColonFound && !commaAfterNameFound && char0 === ',') { + commaAfterNameFound = true; + cursor += 1; + // a space or comma was found before the name + // this isn't a translation string, so back out + } else if (!(textBeforeColonFound && colonFound && textAfterColonFound && commaAfterNameFound) && + invalidTextRegex.test(char0)) { + cursor += 1; + lastBreak -= 2; + // no longer in a token + inToken = false; + if (level > 0) { + level -= 1; + } else { + break; + } + // if we're at the beginning of another translation string, + // we're nested, so add to our level + } else if (char0 === '[' && char1 === '[') { + level += 1; + cursor += 2; + // if we're at the end of a translation string + } else if (char0 === ']' && char1 === ']') { + // if we're at the base level, then this is the end + if (level === 0) { + // so grab the name and args + const currentSlice = str.slice(lastBreak, cursor); + const result = split(currentSlice); + const name = result[0]; + const args = result.slice(1); + + // make a backup based on the raw string of the token + // if there are arguments to the token + let backup = ''; + if (args && args.length) { + backup = this.translate(currentSlice); + } + // add the translation promise to the array + toTranslate.push(this.translateKey(name, args, backup)); + // skip past the ending brackets + cursor += 2; + // set this as our last break + lastBreak = cursor; + // and we're no longer in a translation string, + // so continue with the main loop + inToken = false; + break; + } + // otherwise we lower the level + level -= 1; + // and skip past the ending brackets + cursor += 2; + } else { + // otherwise just move to the next character + cursor += 1; + } + } + + // skip to the next [[ + cursor = str.indexOf('[[', cursor); + } + + // ending string of source + let last = str.slice(lastBreak); + + // if we were mid-token, treat it as invalid + if (inToken) { + last = this.translate(last); + } + + // add the remaining text after the last translation string + toTranslate.push(last); + + // and return a promise for the concatenated translated string + return Promise.all(toTranslate).then(function (translated) { + return translated.join(''); + }); + }; + + /** + * Translates a specific key and array of arguments + * @param {string} name - Translation key (ex. 'global:home') + * @param {string[]} args - Arguments for `%1`, `%2`, etc + * @param {string|Promise} backup - Text to use in case the key can't be found + * @returns {Promise} + */ + Translator.prototype.translateKey = function translateKey(name, args, backup) { + const self = this; + + const result = name.split(':', 2); + const namespace = result[0]; + const key = result[1]; + + if (self.modules[namespace]) { + return Promise.resolve(self.modules[namespace](key, args)); + } + + if (namespace && result.length === 1) { + return Promise.resolve('[[' + namespace + ']]'); + } + + if (namespace && !key) { + warn('Missing key in translation token "' + name + '" for language "' + self.lang + '"'); + return Promise.resolve('[[' + namespace + ']]'); + } + + const translation = this.getTranslation(namespace, key); + return translation.then(function (translated) { + // check if the translation is missing first + if (!translated) { + warn('Missing translation "' + name + '" for language "' + self.lang + '"'); + return backup || key; + } + + const argsToTranslate = args.map(function (arg) { + return self.translate(escapeHTML(arg)); + }); + + return Promise.all(argsToTranslate).then(function (translatedArgs) { + let out = translated; + translatedArgs.forEach(function (arg, i) { + let escaped = arg.replace(/%(?=\d)/g, '%').replace(/\\,/g, ','); + // fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206 + escaped = escaped.replace(/&lsqb;/g, '[') + .replace(/&rsqb;/g, ']'); + out = out.replace(new RegExp('%' + (i + 1), 'g'), escaped); + }); + return out; + }); + }); + }; + + /** + * Load translation file (or use a cached version), and optionally return the translation of a certain key + * @param {string} namespace - The file name of the translation namespace + * @param {string} [key] - The key of the specific translation to getJSON + * @returns {Promise<{ [key: string]: string } | string>} + */ + Translator.prototype.getTranslation = function getTranslation(namespace, key) { + let translation; + if (!namespace) { + warn('[translator] Parameter `namespace` is ' + namespace + (namespace === '' ? '(empty string)' : '')); + translation = Promise.resolve({}); + } else { + this.translations[namespace] = this.translations[namespace] || + this.load(this.lang, namespace).catch(function () { return {}; }); + translation = this.translations[namespace]; + } + + if (key) { + return translation.then(function (x) { + if (typeof x[key] === 'string') return x[key]; + const keyParts = key.split('.'); + for (let i = 0; i <= keyParts.length; i++) { + if (i === keyParts.length) { + // default to trying to find key with the same name as parent or equal to empty string + return x[keyParts[i - 1]] !== undefined ? x[keyParts[i - 1]] : x['']; + } + switch (typeof x[keyParts[i]]) { + case 'object': + x = x[keyParts[i]]; + break; + case 'string': + if (i === keyParts.length - 1) { + return x[keyParts[i]]; + } + + return false; + + default: + return false; + } + } + }); + } + return translation; + }; + + /** + * @param {Node} node + * @returns {Node[]} + */ + function descendantTextNodes(node) { + const textNodes = []; + + function helper(node) { + if (node.nodeType === 3) { + textNodes.push(node); + } else { + for (let i = 0, c = node.childNodes, l = c.length; i < l; i += 1) { + helper(c[i]); + } + } + } + + helper(node); + return textNodes; + } + + /** + * Recursively translate a DOM element in place + * @param {Element} element - Root element to translate + * @param {string[]} [attributes] - Array of node attributes to translate + * @returns {Promise} + */ + Translator.prototype.translateInPlace = function translateInPlace(element, attributes) { + attributes = attributes || ['placeholder', 'title']; + + const nodes = descendantTextNodes(element); + const text = nodes.map(function (node) { + return utils.escapeHTML(node.nodeValue); + }).join(' || '); + + const attrNodes = attributes.reduce(function (prev, attr) { + const tuples = Array.prototype.map.call(element.querySelectorAll('[' + attr + '*="[["]'), function (el) { + return [attr, el]; + }); + return prev.concat(tuples); + }, []); + const attrText = attrNodes.map(function (node) { + return node[1].getAttribute(node[0]); + }).join(' || '); + + return Promise.all([ + this.translate(text), + this.translate(attrText), + ]).then(function (ref) { + const translated = ref[0]; + const translatedAttrs = ref[1]; + if (translated) { + translated.split(' || ').forEach(function (html, i) { + $(nodes[i]).replaceWith(html); + }); + } + if (translatedAttrs) { + translatedAttrs.split(' || ').forEach(function (text, i) { + attrNodes[i][1].setAttribute(attrNodes[i][0], text); + }); + } + }); + }; + + /** + * Get the language of the current environment, falling back to defaults + * @returns {string} + */ + Translator.getLanguage = function getLanguage() { + return utils.getLanguage(); + }; + + /** + * Create and cache a new Translator instance, or return a cached one + * @param {string} [language] - ('en-GB') Language string + * @returns {Translator} + */ + Translator.create = function create(language) { + if (!language) { + language = Translator.getLanguage(); + } + + Translator.cache[language] = Translator.cache[language] || new Translator(language); + + return Translator.cache[language]; + }; + + Translator.cache = {}; + + /** + * Register a custom module to handle translations + * @param {string} namespace - Namespace to handle translation for + * @param {Function} factory - Function to return the translation function for this namespace + */ + Translator.registerModule = function registerModule(namespace, factory) { + Translator.moduleFactories[namespace] = factory; + + Object.keys(Translator.cache).forEach(function (key) { + const translator = Translator.cache[key]; + translator.modules[namespace] = factory(translator.lang); + }); + }; + + Translator.moduleFactories = {}; + + /** + * Remove the translator patterns from text + * @param {string} text + * @returns {string} + */ + Translator.removePatterns = function removePatterns(text) { + const len = text.length; + let cursor = 0; + let lastBreak = 0; + let level = 0; + let out = ''; + let sub; + + while (cursor < len) { + sub = text.slice(cursor, cursor + 2); + if (sub === '[[') { + if (level === 0) { + out += text.slice(lastBreak, cursor); + } + level += 1; + cursor += 2; + } else if (sub === ']]') { + level -= 1; + cursor += 2; + if (level === 0) { + lastBreak = cursor; + } + } else { + cursor += 1; + } + } + out += text.slice(lastBreak, cursor); + return out; + }; + + /** + * Escape translator patterns in text + * @param {string} text + * @returns {string} + */ + Translator.escape = function escape(text) { + return typeof text === 'string' ? text.replace(/\[\[/g, '[[').replace(/\]\]/g, ']]') : text; + }; + + /** + * Unescape escaped translator patterns in text + * @param {string} text + * @returns {string} + */ + Translator.unescape = function unescape(text) { + return typeof text === 'string' ? + text.replace(/[/g, '[').replace(/\\\[/g, '[') + .replace(/]/g, ']').replace(/\\\]/g, ']') : + text; + }; + + /** + * Construct a translator pattern + * @param {string} name - Translation name + * @param {...string} arg - Optional argument for the pattern + */ + Translator.compile = function compile() { + const args = Array.prototype.slice.call(arguments, 0).map(function (text) { + // escape commas and percent signs in arguments + return String(text).replace(/%/g, '%').replace(/,/g, ','); + }); + + return '[[' + args.join(', ') + ']]'; + }; + + return Translator; + }()); + + /** + * @exports translator + */ + const adaptor = { + /** + * The Translator class + */ + Translator: Translator, + + compile: Translator.compile, + escape: Translator.escape, + unescape: Translator.unescape, + getLanguage: Translator.getLanguage, + + flush: function () { + Object.keys(Translator.cache).forEach(function (code) { + Translator.cache[code].translations = {}; + }); + }, + + flushNamespace: function (namespace) { + Object.keys(Translator.cache).forEach(function (code) { + if (Translator.cache[code] && + Translator.cache[code].translations && + Translator.cache[code].translations[namespace] + ) { + Translator.cache[code].translations[namespace] = null; + } + }); + }, + + + /** + * Legacy translator function for backwards compatibility + */ + translate: function translate(text, language, callback) { + // TODO: deprecate? + + let cb = callback; + let lang = language; + if (typeof language === 'function') { + cb = language; + lang = null; + } + + if (!(typeof text === 'string' || text instanceof String) || text === '') { + if (cb) { + return setTimeout(cb, 0, ''); + } + return ''; + } + + return Translator.create(lang).translate(text).then(function (output) { + if (cb) { + setTimeout(cb, 0, output); + } + return output; + }, function (err) { + warn('Translation failed: ' + err.stack); + }); + }, + + /** + * Add translations to the cache + */ + addTranslation: function addTranslation(language, namespace, translation) { + Translator.create(language).getTranslation(namespace).then(function (translations) { + assign(translations, translation); + }); + }, + + /** + * Get the translations object + */ + getTranslations: function getTranslations(language, namespace, callback) { + callback = callback || function () {}; + Translator.create(language).getTranslation(namespace).then(callback); + }, + + /** + * Alias of getTranslations + */ + load: function load(language, namespace, callback) { + adaptor.getTranslations(language, namespace, callback); + }, + + toggleTimeagoShorthand: function toggleTimeagoShorthand(callback) { + /* eslint "prefer-object-spread": "off" */ + function toggle() { + const tmp = assign({}, jQuery.timeago.settings.strings); + jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort); + adaptor.timeagoShort = assign({}, tmp); + if (typeof callback === 'function') { + callback(); + } + } + + if (!adaptor.timeagoShort) { + let languageCode = utils.userLangToTimeagoCode(config.userLang); + if (!config.timeagoCodes.includes(languageCode + '-short')) { + languageCode = 'en'; + } + + const originalSettings = assign({}, jQuery.timeago.settings.strings); + adaptor.switchTimeagoLanguage(languageCode + '-short', function () { + adaptor.timeagoShort = assign({}, jQuery.timeago.settings.strings); + jQuery.timeago.settings.strings = assign({}, originalSettings); + toggle(); + }); + } else { + toggle(); + } + }, + + switchTimeagoLanguage: function switchTimeagoLanguage(langCode, callback) { + // Delete the cached shorthand strings if present + delete adaptor.timeagoShort; + import(/* webpackChunkName: "timeago/[request]" */ 'timeago/locales/jquery.timeago.' + langCode).then(callback); + }, + + prepareDOM: function prepareDOM() { + // Add directional code if necessary + adaptor.translate('[[language:dir]]', function (value) { + if (value && !$('html').attr('data-dir')) { + jQuery('html').css('direction', value).attr('data-dir', value); + } + }); + }, + }; + + return adaptor; +}; diff --git a/public/src/modules/translator.js b/public/src/modules/translator.js index 00572536d5..d146df51f8 100644 --- a/public/src/modules/translator.js +++ b/public/src/modules/translator.js @@ -1,6 +1,8 @@ 'use strict'; -(function (factory) { +const factory = require('./translator.common'); + +define('translator', ['jquery', 'utils'], function (jQuery, utils) { function loadClient(language, namespace) { return new Promise(function (resolve, reject) { jQuery.getJSON([config.assetBaseUrl, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) { @@ -18,667 +20,6 @@ }); }); } - let warn = function () { console.warn.apply(console, arguments); }; - if (typeof define === 'function' && define.amd) { - // AMD. Register as a named module - define('translator', [], function () { - return factory(utils, loadClient, warn); - }); - } else if (typeof module === 'object' && module.exports) { - // Node - (function () { - if (global.env === 'development') { - const winston = require('winston'); - warn = function (a) { - winston.warn(a); - }; - } - - module.exports = factory(require('../utils'), function (lang, namespace) { - const languages = require('../../../src/languages'); - return languages.get(lang, namespace); - }, warn); - }()); - } -}(function (utils, load, warn) { - const assign = Object.assign || jQuery.extend; - - function escapeHTML(str) { - return utils.escapeHTML(utils.decodeHTMLEntities( - String(str) - .replace(/[\s\xa0]+/g, ' ') - .replace(/^\s+|\s+$/g, '') - )); - } - - const Translator = (function () { - /** - * Construct a new Translator object - * @param {string} language - Language code for this translator instance - * @exports translator.Translator - */ - function Translator(language) { - const self = this; - - if (!language) { - throw new TypeError('Parameter `language` must be a language string. Received ' + language + (language === '' ? '(empty string)' : '')); - } - - self.modules = Object.keys(Translator.moduleFactories).map(function (namespace) { - const factory = Translator.moduleFactories[namespace]; - return [namespace, factory(language)]; - }).reduce(function (prev, elem) { - const namespace = elem[0]; - const module = elem[1]; - prev[namespace] = module; - - return prev; - }, {}); - - self.lang = language; - self.translations = {}; - } - - Translator.prototype.load = load; - - /** - * Parse the translation instructions into the language of the Translator instance - * @param {string} str - Source string - * @returns {Promise} - */ - Translator.prototype.translate = function translate(str) { - // regex for valid text in namespace / key - const validText = 'a-zA-Z0-9\\-_.\\/'; - const validTextRegex = new RegExp('[' + validText + ']'); - const invalidTextRegex = new RegExp('[^' + validText + '\\]]'); - - // current cursor position - let cursor = 0; - // last break of the input string - let lastBreak = 0; - // length of the input string - const len = str.length; - // array to hold the promises for the translations - // and the strings of untranslated text in between - const toTranslate = []; - - // to store the state of if we're currently in a top-level token for later - let inToken = false; - - // split a translator string into an array of tokens - // but don't split by commas inside other translator strings - function split(text) { - const len = text.length; - const arr = []; - let i = 0; - let brk = 0; - let level = 0; - - while (i + 2 <= len) { - if (text[i] === '[' && text[i + 1] === '[') { - level += 1; - i += 1; - } else if (text[i] === ']' && text[i + 1] === ']') { - level -= 1; - i += 1; - } else if (level === 0 && text[i] === ',' && text[i - 1] !== '\\') { - arr.push(text.slice(brk, i).trim()); - i += 1; - brk = i; - } - i += 1; - } - arr.push(text.slice(brk, i + 1).trim()); - return arr; - } - - // move to the first [[ - cursor = str.indexOf('[[', cursor); - - // the loooop, we'll go to where the cursor - // is equal to the length of the string since - // slice doesn't include the ending index - while (cursor + 2 <= len && cursor !== -1) { - // split the string from the last break - // to the character before the cursor - // add that to the result array - toTranslate.push(str.slice(lastBreak, cursor)); - // set the cursor position past the beginning - // brackets of the translation string - cursor += 2; - // set the last break to our current - // spot since we just broke the string - lastBreak = cursor; - // we're in a token now - inToken = true; - - // the current level of nesting of the translation strings - let level = 0; - let char0; - let char1; - // validating the current string is actually a translation - let textBeforeColonFound = false; - let colonFound = false; - let textAfterColonFound = false; - let commaAfterNameFound = false; - - while (cursor + 2 <= len) { - char0 = str[cursor]; - char1 = str[cursor + 1]; - // found some text after the double bracket, - // so this is probably a translation string - if (!textBeforeColonFound && validTextRegex.test(char0)) { - textBeforeColonFound = true; - cursor += 1; - // found a colon, so this is probably a translation string - } else if (textBeforeColonFound && !colonFound && char0 === ':') { - colonFound = true; - cursor += 1; - // found some text after the colon, - // so this is probably a translation string - } else if (colonFound && !textAfterColonFound && validTextRegex.test(char0)) { - textAfterColonFound = true; - cursor += 1; - } else if (textAfterColonFound && !commaAfterNameFound && char0 === ',') { - commaAfterNameFound = true; - cursor += 1; - // a space or comma was found before the name - // this isn't a translation string, so back out - } else if (!(textBeforeColonFound && colonFound && textAfterColonFound && commaAfterNameFound) && - invalidTextRegex.test(char0)) { - cursor += 1; - lastBreak -= 2; - // no longer in a token - inToken = false; - if (level > 0) { - level -= 1; - } else { - break; - } - // if we're at the beginning of another translation string, - // we're nested, so add to our level - } else if (char0 === '[' && char1 === '[') { - level += 1; - cursor += 2; - // if we're at the end of a translation string - } else if (char0 === ']' && char1 === ']') { - // if we're at the base level, then this is the end - if (level === 0) { - // so grab the name and args - const currentSlice = str.slice(lastBreak, cursor); - const result = split(currentSlice); - const name = result[0]; - const args = result.slice(1); - - // make a backup based on the raw string of the token - // if there are arguments to the token - let backup = ''; - if (args && args.length) { - backup = this.translate(currentSlice); - } - // add the translation promise to the array - toTranslate.push(this.translateKey(name, args, backup)); - // skip past the ending brackets - cursor += 2; - // set this as our last break - lastBreak = cursor; - // and we're no longer in a translation string, - // so continue with the main loop - inToken = false; - break; - } - // otherwise we lower the level - level -= 1; - // and skip past the ending brackets - cursor += 2; - } else { - // otherwise just move to the next character - cursor += 1; - } - } - - // skip to the next [[ - cursor = str.indexOf('[[', cursor); - } - - // ending string of source - let last = str.slice(lastBreak); - - // if we were mid-token, treat it as invalid - if (inToken) { - last = this.translate(last); - } - - // add the remaining text after the last translation string - toTranslate.push(last); - - // and return a promise for the concatenated translated string - return Promise.all(toTranslate).then(function (translated) { - return translated.join(''); - }); - }; - - /** - * Translates a specific key and array of arguments - * @param {string} name - Translation key (ex. 'global:home') - * @param {string[]} args - Arguments for `%1`, `%2`, etc - * @param {string|Promise} backup - Text to use in case the key can't be found - * @returns {Promise} - */ - Translator.prototype.translateKey = function translateKey(name, args, backup) { - const self = this; - - const result = name.split(':', 2); - const namespace = result[0]; - const key = result[1]; - - if (self.modules[namespace]) { - return Promise.resolve(self.modules[namespace](key, args)); - } - - if (namespace && result.length === 1) { - return Promise.resolve('[[' + namespace + ']]'); - } - - if (namespace && !key) { - warn('Missing key in translation token "' + name + '" for language "' + self.lang + '"'); - return Promise.resolve('[[' + namespace + ']]'); - } - - const translation = this.getTranslation(namespace, key); - return translation.then(function (translated) { - // check if the translation is missing first - if (!translated) { - warn('Missing translation "' + name + '" for language "' + self.lang + '"'); - return backup || key; - } - - const argsToTranslate = args.map(function (arg) { - return self.translate(escapeHTML(arg)); - }); - - return Promise.all(argsToTranslate).then(function (translatedArgs) { - let out = translated; - translatedArgs.forEach(function (arg, i) { - let escaped = arg.replace(/%(?=\d)/g, '%').replace(/\\,/g, ','); - // fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206 - escaped = escaped.replace(/&lsqb;/g, '[') - .replace(/&rsqb;/g, ']'); - out = out.replace(new RegExp('%' + (i + 1), 'g'), escaped); - }); - return out; - }); - }); - }; - - /** - * Load translation file (or use a cached version), and optionally return the translation of a certain key - * @param {string} namespace - The file name of the translation namespace - * @param {string} [key] - The key of the specific translation to getJSON - * @returns {Promise<{ [key: string]: string } | string>} - */ - Translator.prototype.getTranslation = function getTranslation(namespace, key) { - let translation; - if (!namespace) { - warn('[translator] Parameter `namespace` is ' + namespace + (namespace === '' ? '(empty string)' : '')); - translation = Promise.resolve({}); - } else { - this.translations[namespace] = this.translations[namespace] || - this.load(this.lang, namespace).catch(function () { return {}; }); - translation = this.translations[namespace]; - } - - if (key) { - return translation.then(function (x) { - if (typeof x[key] === 'string') return x[key]; - const keyParts = key.split('.'); - for (let i = 0; i <= keyParts.length; i++) { - if (i === keyParts.length) { - // default to trying to find key with the same name as parent or equal to empty string - return x[keyParts[i - 1]] !== undefined ? x[keyParts[i - 1]] : x['']; - } - switch (typeof x[keyParts[i]]) { - case 'object': - x = x[keyParts[i]]; - break; - case 'string': - if (i === keyParts.length - 1) { - return x[keyParts[i]]; - } - - return false; - - default: - return false; - } - } - }); - } - return translation; - }; - - /** - * @param {Node} node - * @returns {Node[]} - */ - function descendantTextNodes(node) { - const textNodes = []; - - function helper(node) { - if (node.nodeType === 3) { - textNodes.push(node); - } else { - for (let i = 0, c = node.childNodes, l = c.length; i < l; i += 1) { - helper(c[i]); - } - } - } - - helper(node); - return textNodes; - } - - /** - * Recursively translate a DOM element in place - * @param {Element} element - Root element to translate - * @param {string[]} [attributes] - Array of node attributes to translate - * @returns {Promise} - */ - Translator.prototype.translateInPlace = function translateInPlace(element, attributes) { - attributes = attributes || ['placeholder', 'title']; - - const nodes = descendantTextNodes(element); - const text = nodes.map(function (node) { - return utils.escapeHTML(node.nodeValue); - }).join(' || '); - - const attrNodes = attributes.reduce(function (prev, attr) { - const tuples = Array.prototype.map.call(element.querySelectorAll('[' + attr + '*="[["]'), function (el) { - return [attr, el]; - }); - return prev.concat(tuples); - }, []); - const attrText = attrNodes.map(function (node) { - return node[1].getAttribute(node[0]); - }).join(' || '); - - return Promise.all([ - this.translate(text), - this.translate(attrText), - ]).then(function (ref) { - const translated = ref[0]; - const translatedAttrs = ref[1]; - if (translated) { - translated.split(' || ').forEach(function (html, i) { - $(nodes[i]).replaceWith(html); - }); - } - if (translatedAttrs) { - translatedAttrs.split(' || ').forEach(function (text, i) { - attrNodes[i][1].setAttribute(attrNodes[i][0], text); - }); - } - }); - }; - - /** - * Get the language of the current environment, falling back to defaults - * @returns {string} - */ - Translator.getLanguage = function getLanguage() { - let lang; - - if (typeof window === 'object' && window.config && window.utils) { - lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB'; - } else { - const meta = require('../../../src/meta'); - lang = meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB'; - } - - return lang; - }; - - /** - * Create and cache a new Translator instance, or return a cached one - * @param {string} [language] - ('en-GB') Language string - * @returns {Translator} - */ - Translator.create = function create(language) { - if (!language) { - language = Translator.getLanguage(); - } - - Translator.cache[language] = Translator.cache[language] || new Translator(language); - - return Translator.cache[language]; - }; - - Translator.cache = {}; - - /** - * Register a custom module to handle translations - * @param {string} namespace - Namespace to handle translation for - * @param {Function} factory - Function to return the translation function for this namespace - */ - Translator.registerModule = function registerModule(namespace, factory) { - Translator.moduleFactories[namespace] = factory; - - Object.keys(Translator.cache).forEach(function (key) { - const translator = Translator.cache[key]; - translator.modules[namespace] = factory(translator.lang); - }); - }; - - Translator.moduleFactories = {}; - - /** - * Remove the translator patterns from text - * @param {string} text - * @returns {string} - */ - Translator.removePatterns = function removePatterns(text) { - const len = text.length; - let cursor = 0; - let lastBreak = 0; - let level = 0; - let out = ''; - let sub; - - while (cursor < len) { - sub = text.slice(cursor, cursor + 2); - if (sub === '[[') { - if (level === 0) { - out += text.slice(lastBreak, cursor); - } - level += 1; - cursor += 2; - } else if (sub === ']]') { - level -= 1; - cursor += 2; - if (level === 0) { - lastBreak = cursor; - } - } else { - cursor += 1; - } - } - out += text.slice(lastBreak, cursor); - return out; - }; - - /** - * Escape translator patterns in text - * @param {string} text - * @returns {string} - */ - Translator.escape = function escape(text) { - return typeof text === 'string' ? text.replace(/\[\[/g, '[[').replace(/\]\]/g, ']]') : text; - }; - - /** - * Unescape escaped translator patterns in text - * @param {string} text - * @returns {string} - */ - Translator.unescape = function unescape(text) { - return typeof text === 'string' ? - text.replace(/[/g, '[').replace(/\\\[/g, '[') - .replace(/]/g, ']').replace(/\\\]/g, ']') : - text; - }; - - /** - * Construct a translator pattern - * @param {string} name - Translation name - * @param {...string} arg - Optional argument for the pattern - */ - Translator.compile = function compile() { - const args = Array.prototype.slice.call(arguments, 0).map(function (text) { - // escape commas and percent signs in arguments - return String(text).replace(/%/g, '%').replace(/,/g, ','); - }); - - return '[[' + args.join(', ') + ']]'; - }; - - return Translator; - }()); - - /** - * @exports translator - */ - const adaptor = { - /** - * The Translator class - */ - Translator: Translator, - - compile: Translator.compile, - escape: Translator.escape, - unescape: Translator.unescape, - getLanguage: Translator.getLanguage, - - flush: function () { - Object.keys(Translator.cache).forEach(function (code) { - Translator.cache[code].translations = {}; - }); - }, - - flushNamespace: function (namespace) { - Object.keys(Translator.cache).forEach(function (code) { - if (Translator.cache[code] && - Translator.cache[code].translations && - Translator.cache[code].translations[namespace] - ) { - Translator.cache[code].translations[namespace] = null; - } - }); - }, - - - /** - * Legacy translator function for backwards compatibility - */ - translate: function translate(text, language, callback) { - // TODO: deprecate? - - let cb = callback; - let lang = language; - if (typeof language === 'function') { - cb = language; - lang = null; - } - - if (!(typeof text === 'string' || text instanceof String) || text === '') { - if (cb) { - return setTimeout(cb, 0, ''); - } - return ''; - } - - return Translator.create(lang).translate(text).then(function (output) { - if (cb) { - setTimeout(cb, 0, output); - } - return output; - }, function (err) { - warn('Translation failed: ' + err.stack); - }); - }, - - /** - * Add translations to the cache - */ - addTranslation: function addTranslation(language, namespace, translation) { - Translator.create(language).getTranslation(namespace).then(function (translations) { - assign(translations, translation); - }); - }, - - /** - * Get the translations object - */ - getTranslations: function getTranslations(language, namespace, callback) { - callback = callback || function () {}; - Translator.create(language).getTranslation(namespace).then(callback); - }, - - /** - * Alias of getTranslations - */ - load: function load(language, namespace, callback) { - adaptor.getTranslations(language, namespace, callback); - }, - - toggleTimeagoShorthand: function toggleTimeagoShorthand(callback) { - /* eslint "prefer-object-spread": "off" */ - function toggle() { - const tmp = assign({}, jQuery.timeago.settings.strings); - jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort); - adaptor.timeagoShort = assign({}, tmp); - if (typeof callback === 'function') { - callback(); - } - } - - if (!adaptor.timeagoShort) { - let languageCode = utils.userLangToTimeagoCode(config.userLang); - if (!config.timeagoCodes.includes(languageCode + '-short')) { - languageCode = 'en'; - } - - const originalSettings = assign({}, jQuery.timeago.settings.strings); - adaptor.switchTimeagoLanguage(languageCode + '-short', function () { - adaptor.timeagoShort = assign({}, jQuery.timeago.settings.strings); - jQuery.timeago.settings.strings = assign({}, originalSettings); - toggle(); - }); - } else { - toggle(); - } - }, - - switchTimeagoLanguage: function switchTimeagoLanguage(langCode, callback) { - // Delete the cached shorthand strings if present - delete adaptor.timeagoShort; - - const stringsModule = 'timeago/locales/jquery.timeago.' + langCode; - // without undef, requirejs won't load the strings a second time - require.undef(stringsModule); - require([stringsModule], function () { - callback(); - }); - }, - - prepareDOM: function prepareDOM() { - // Add directional code if necessary - adaptor.translate('[[language:dir]]', function (value) { - if (value && !$('html').attr('data-dir')) { - jQuery('html').css('direction', value).attr('data-dir', value); - } - }); - }, - }; - - return adaptor; -})); + const warn = function () { console.warn.apply(console, arguments); }; + return factory(utils, loadClient, warn); +}); diff --git a/public/src/overrides.js b/public/src/overrides.js index 123d814059..14067fbf0f 100644 --- a/public/src/overrides.js +++ b/public/src/overrides.js @@ -1,89 +1,89 @@ 'use strict'; +import $ from 'jquery'; +import translator from './modules/translator'; -overrides = window.overrides || {}; +window.overrides = window.overrides || {}; + +function translate(elements, type, str) { + return elements.each(function () { + var el = $(this); + translator.translate(str, function (translated) { + el[type](translated); + }); + }); +} if (typeof window !== 'undefined') { (function ($) { - require(['translator'], function (translator) { - $.fn.getCursorPosition = function () { - const el = $(this).get(0); - let pos = 0; - if ('selectionStart' in el) { - pos = el.selectionStart; - } else if ('selection' in document) { - el.focus(); - const Sel = document.selection.createRange(); - const SelLength = document.selection.createRange().text.length; - Sel.moveStart('character', -el.value.length); - pos = Sel.text.length - SelLength; - } - return pos; - }; - - $.fn.selectRange = function (start, end) { - if (!end) { - end = start; - } - return this.each(function () { - if (this.setSelectionRange) { - this.focus(); - this.setSelectionRange(start, end); - } else if (this.createTextRange) { - const range = this.createTextRange(); - range.collapse(true); - range.moveEnd('character', end); - range.moveStart('character', start); - range.select(); - } - }); - }; - - // http://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element - $.fn.putCursorAtEnd = function () { - return this.each(function () { - $(this).focus(); - - if (this.setSelectionRange) { - const len = $(this).val().length * 2; - this.setSelectionRange(len, len); - } else { - $(this).val($(this).val()); - } - this.scrollTop = 999999; - }); - }; - - $.fn.translateHtml = function (str) { - return translate(this, 'html', str); - }; - - $.fn.translateText = function (str) { - return translate(this, 'text', str); - }; - - $.fn.translateVal = function (str) { - return translate(this, 'val', str); - }; - - $.fn.translateAttr = function (attr, str) { - return this.each(function () { - const el = $(this); - translator.translate(str, function (translated) { - el.attr(attr, translated); - }); - }); - }; - - function translate(elements, type, str) { - return elements.each(function () { - const el = $(this); - translator.translate(str, function (translated) { - el[type](translated); - }); - }); + $.fn.getCursorPosition = function () { + const el = $(this).get(0); + let pos = 0; + if ('selectionStart' in el) { + pos = el.selectionStart; + } else if ('selection' in document) { + el.focus(); + const Sel = document.selection.createRange(); + const SelLength = document.selection.createRange().text.length; + Sel.moveStart('character', -el.value.length); + pos = Sel.text.length - SelLength; } - }); + return pos; + }; + + $.fn.selectRange = function (start, end) { + if (!end) { + end = start; + } + return this.each(function () { + if (this.setSelectionRange) { + this.focus(); + this.setSelectionRange(start, end); + } else if (this.createTextRange) { + const range = this.createTextRange(); + range.collapse(true); + range.moveEnd('character', end); + range.moveStart('character', start); + range.select(); + } + }); + }; + + // http://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element + $.fn.putCursorAtEnd = function () { + return this.each(function () { + $(this).focus(); + + if (this.setSelectionRange) { + const len = $(this).val().length * 2; + this.setSelectionRange(len, len); + } else { + $(this).val($(this).val()); + } + this.scrollTop = 999999; + }); + }; + + $.fn.translateHtml = function (str) { + return translate(this, 'html', str); + }; + + $.fn.translateText = function (str) { + return translate(this, 'text', str); + }; + + $.fn.translateVal = function (str) { + return translate(this, 'val', str); + }; + + $.fn.translateAttr = function (attr, str) { + return this.each(function () { + const el = $(this); + translator.translate(str, function (translated) { + el.attr(attr, translated); + }); + }); + }; }(jQuery || { fn: {} })); (function () { diff --git a/public/src/require-config.js b/public/src/require-config.js deleted file mode 100644 index 00607597e2..0000000000 --- a/public/src/require-config.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -require.config({ - baseUrl: config.assetBaseUrl + '/src/modules', - waitSeconds: 0, - urlArgs: config['cache-buster'], - paths: { - forum: '../client', - admin: '../admin', - vendor: '../../vendor', - plugins: '../../plugins', - }, -}); diff --git a/public/src/sockets.js b/public/src/sockets.js index 37698f1a4e..f2494088d5 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -1,8 +1,9 @@ 'use strict'; +import io from 'socket.io-client'; +import $ from 'jquery'; app = window.app || {}; -socket = window.socket; (function () { let reconnecting = false; @@ -14,7 +15,7 @@ socket = window.socket; path: config.relative_path + '/socket.io', }; - socket = io(config.websocketAddress, ioParams); + window.socket = io(config.websocketAddress, ioParams); const oEmit = socket.emit; socket.emit = function (event, data, callback) { diff --git a/public/src/utils.common.js b/public/src/utils.common.js new file mode 100644 index 0000000000..71b2196562 --- /dev/null +++ b/public/src/utils.common.js @@ -0,0 +1,776 @@ +'use strict'; + + +// add default escape function for escaping HTML entities +const escapeCharMap = Object.freeze({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`', + '=': '=', +}); +function replaceChar(c) { + return escapeCharMap[c]; +} +const escapeChars = /[&<>"'`=]/g; + +const HTMLEntities = Object.freeze({ + amp: '&', + gt: '>', + lt: '<', + quot: '"', + apos: "'", + AElig: 198, + Aacute: 193, + Acirc: 194, + Agrave: 192, + Aring: 197, + Atilde: 195, + Auml: 196, + Ccedil: 199, + ETH: 208, + Eacute: 201, + Ecirc: 202, + Egrave: 200, + Euml: 203, + Iacute: 205, + Icirc: 206, + Igrave: 204, + Iuml: 207, + Ntilde: 209, + Oacute: 211, + Ocirc: 212, + Ograve: 210, + Oslash: 216, + Otilde: 213, + Ouml: 214, + THORN: 222, + Uacute: 218, + Ucirc: 219, + Ugrave: 217, + Uuml: 220, + Yacute: 221, + aacute: 225, + acirc: 226, + aelig: 230, + agrave: 224, + aring: 229, + atilde: 227, + auml: 228, + ccedil: 231, + eacute: 233, + ecirc: 234, + egrave: 232, + eth: 240, + euml: 235, + iacute: 237, + icirc: 238, + igrave: 236, + iuml: 239, + ntilde: 241, + oacute: 243, + ocirc: 244, + ograve: 242, + oslash: 248, + otilde: 245, + ouml: 246, + szlig: 223, + thorn: 254, + uacute: 250, + ucirc: 251, + ugrave: 249, + uuml: 252, + yacute: 253, + yuml: 255, + copy: 169, + reg: 174, + nbsp: 160, + iexcl: 161, + cent: 162, + pound: 163, + curren: 164, + yen: 165, + brvbar: 166, + sect: 167, + uml: 168, + ordf: 170, + laquo: 171, + not: 172, + shy: 173, + macr: 175, + deg: 176, + plusmn: 177, + sup1: 185, + sup2: 178, + sup3: 179, + acute: 180, + micro: 181, + para: 182, + middot: 183, + cedil: 184, + ordm: 186, + raquo: 187, + frac14: 188, + frac12: 189, + frac34: 190, + iquest: 191, + times: 215, + divide: 247, + 'OElig;': 338, + 'oelig;': 339, + 'Scaron;': 352, + 'scaron;': 353, + 'Yuml;': 376, + 'fnof;': 402, + 'circ;': 710, + 'tilde;': 732, + 'Alpha;': 913, + 'Beta;': 914, + 'Gamma;': 915, + 'Delta;': 916, + 'Epsilon;': 917, + 'Zeta;': 918, + 'Eta;': 919, + 'Theta;': 920, + 'Iota;': 921, + 'Kappa;': 922, + 'Lambda;': 923, + 'Mu;': 924, + 'Nu;': 925, + 'Xi;': 926, + 'Omicron;': 927, + 'Pi;': 928, + 'Rho;': 929, + 'Sigma;': 931, + 'Tau;': 932, + 'Upsilon;': 933, + 'Phi;': 934, + 'Chi;': 935, + 'Psi;': 936, + 'Omega;': 937, + 'alpha;': 945, + 'beta;': 946, + 'gamma;': 947, + 'delta;': 948, + 'epsilon;': 949, + 'zeta;': 950, + 'eta;': 951, + 'theta;': 952, + 'iota;': 953, + 'kappa;': 954, + 'lambda;': 955, + 'mu;': 956, + 'nu;': 957, + 'xi;': 958, + 'omicron;': 959, + 'pi;': 960, + 'rho;': 961, + 'sigmaf;': 962, + 'sigma;': 963, + 'tau;': 964, + 'upsilon;': 965, + 'phi;': 966, + 'chi;': 967, + 'psi;': 968, + 'omega;': 969, + 'thetasym;': 977, + 'upsih;': 978, + 'piv;': 982, + 'ensp;': 8194, + 'emsp;': 8195, + 'thinsp;': 8201, + 'zwnj;': 8204, + 'zwj;': 8205, + 'lrm;': 8206, + 'rlm;': 8207, + 'ndash;': 8211, + 'mdash;': 8212, + 'lsquo;': 8216, + 'rsquo;': 8217, + 'sbquo;': 8218, + 'ldquo;': 8220, + 'rdquo;': 8221, + 'bdquo;': 8222, + 'dagger;': 8224, + 'Dagger;': 8225, + 'bull;': 8226, + 'hellip;': 8230, + 'permil;': 8240, + 'prime;': 8242, + 'Prime;': 8243, + 'lsaquo;': 8249, + 'rsaquo;': 8250, + 'oline;': 8254, + 'frasl;': 8260, + 'euro;': 8364, + 'image;': 8465, + 'weierp;': 8472, + 'real;': 8476, + 'trade;': 8482, + 'alefsym;': 8501, + 'larr;': 8592, + 'uarr;': 8593, + 'rarr;': 8594, + 'darr;': 8595, + 'harr;': 8596, + 'crarr;': 8629, + 'lArr;': 8656, + 'uArr;': 8657, + 'rArr;': 8658, + 'dArr;': 8659, + 'hArr;': 8660, + 'forall;': 8704, + 'part;': 8706, + 'exist;': 8707, + 'empty;': 8709, + 'nabla;': 8711, + 'isin;': 8712, + 'notin;': 8713, + 'ni;': 8715, + 'prod;': 8719, + 'sum;': 8721, + 'minus;': 8722, + 'lowast;': 8727, + 'radic;': 8730, + 'prop;': 8733, + 'infin;': 8734, + 'ang;': 8736, + 'and;': 8743, + 'or;': 8744, + 'cap;': 8745, + 'cup;': 8746, + 'int;': 8747, + 'there4;': 8756, + 'sim;': 8764, + 'cong;': 8773, + 'asymp;': 8776, + 'ne;': 8800, + 'equiv;': 8801, + 'le;': 8804, + 'ge;': 8805, + 'sub;': 8834, + 'sup;': 8835, + 'nsub;': 8836, + 'sube;': 8838, + 'supe;': 8839, + 'oplus;': 8853, + 'otimes;': 8855, + 'perp;': 8869, + 'sdot;': 8901, + 'lceil;': 8968, + 'rceil;': 8969, + 'lfloor;': 8970, + 'rfloor;': 8971, + 'lang;': 9001, + 'rang;': 9002, + 'loz;': 9674, + 'spades;': 9824, + 'clubs;': 9827, + 'hearts;': 9829, + 'diams;': 9830, +}); + +const utils = { + generateUUID: function () { + /* eslint-disable no-bitwise */ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : ((r & 0x3) | 0x8); + return v.toString(16); + }); + /* eslint-enable no-bitwise */ + }, + // https://github.com/substack/node-ent/blob/master/index.js + decodeHTMLEntities: function (html) { + return String(html) + .replace(/&#(\d+);?/g, function (_, code) { + return String.fromCharCode(code); + }) + .replace(/&#[xX]([A-Fa-f0-9]+);?/g, function (_, hex) { + return String.fromCharCode(parseInt(hex, 16)); + }) + .replace(/&([^;\W]+;?)/g, function (m, e) { + const ee = e.replace(/;$/, ''); + const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]); + + if (typeof target === 'number') { + return String.fromCharCode(target); + } else if (typeof target === 'string') { + return target; + } + + return m; + }); + }, + // https://github.com/jprichardson/string.js/blob/master/lib/string.js + stripHTMLTags: function (str, tags) { + const pattern = (tags || ['']).join('|'); + return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); + }, + + cleanUpTag: function (tag, maxLength) { + if (typeof tag !== 'string' || !tag.length) { + return ''; + } + + tag = tag.trim().toLowerCase(); + // see https://github.com/NodeBB/NodeBB/issues/4378 + tag = tag.replace(/\u202E/gi, ''); + tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, ''); + tag = tag.substr(0, maxLength || 15).trim(); + const matches = tag.match(/^[.-]*(.+?)[.-]*$/); + if (matches && matches.length > 1) { + tag = matches[1]; + } + return tag; + }, + + removePunctuation: function (str) { + return str.replace(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, ''); + }, + + isEmailValid: function (email) { + return typeof email === 'string' && email.length && email.indexOf('@') !== -1 && email.indexOf(',') === -1 && email.indexOf(';') === -1; + }, + + isUserNameValid: function (name) { + return (name && name !== '' && (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name))); + }, + + isPasswordValid: function (password) { + return typeof password === 'string' && password.length; + }, + + isNumber: function (n) { + // `isFinite('') === true` so isNan parseFloat check is necessary + return !isNaN(parseFloat(n)) && isFinite(n); + }, + + languageKeyRegex: /\[\[[\w]+:.+\]\]/, + hasLanguageKey: function (input) { + return utils.languageKeyRegex.test(input); + }, + userLangToTimeagoCode: function (userLang) { + const mapping = { + 'en-GB': 'en', + 'en-US': 'en', + 'fa-IR': 'fa', + 'pt-BR': 'pt-br', + nb: 'no', + }; + return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang; + }, + // shallow objects merge + merge: function () { + const result = {}; + let obj; + let keys; + for (let i = 0; i < arguments.length; i += 1) { + obj = arguments[i] || {}; + keys = Object.keys(obj); + for (let j = 0; j < keys.length; j += 1) { + result[keys[j]] = obj[keys[j]]; + } + } + return result; + }, + + fileExtension: function (path) { + return ('' + path).split('.').pop(); + }, + + extensionMimeTypeMap: { + bmp: 'image/bmp', + cmx: 'image/x-cmx', + cod: 'image/cis-cod', + gif: 'image/gif', + ico: 'image/x-icon', + ief: 'image/ief', + jfif: 'image/pipeg', + jpe: 'image/jpeg', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + pbm: 'image/x-portable-bitmap', + pgm: 'image/x-portable-graymap', + pnm: 'image/x-portable-anymap', + ppm: 'image/x-portable-pixmap', + ras: 'image/x-cmu-raster', + rgb: 'image/x-rgb', + svg: 'image/svg+xml', + tif: 'image/tiff', + tiff: 'image/tiff', + xbm: 'image/x-xbitmap', + xpm: 'image/x-xpixmap', + xwd: 'image/x-xwindowdump', + }, + + fileMimeType: function (path) { + return utils.extensionToMimeType(utils.fileExtension(path)); + }, + + extensionToMimeType: function (extension) { + return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*'; + }, + + isPromise: function (object) { + // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324 + return object && typeof object.then === 'function'; + }, + + promiseParallel: function (obj) { + const keys = Object.keys(obj); + return Promise.all( + keys.map(function (k) { return obj[k]; }) + ).then(function (results) { + const data = {}; + keys.forEach(function (k, i) { + data[k] = results[i]; + }); + return data; + }); + }, + + // https://github.com/sindresorhus/is-absolute-url + isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/, + isWinPathRE: /^[a-zA-Z]:\\/, + isAbsoluteUrl: function (url) { + if (utils.isWinPathRE.test(url)) { + return false; + } + return utils.isAbsoluteUrlRE.test(url); + }, + + isRelativeUrl: function (url) { + return !utils.isAbsoluteUrl(url); + }, + + makeNumbersHumanReadable: function (elements) { + elements.each(function () { + $(this) + .html(utils.makeNumberHumanReadable($(this).attr('title'))) + .removeClass('hidden'); + }); + }, + + makeNumberHumanReadable: function (num) { + const n = parseInt(num, 10); + if (!n) { + return num; + } + if (n > 999999) { + return (n / 1000000).toFixed(1) + 'm'; + } else if (n > 999) { + return (n / 1000).toFixed(1) + 'k'; + } + return n; + }, + + addCommasToNumbers: function (elements) { + elements.each(function (index, element) { + $(element) + .html(utils.addCommas($(element).html())) + .removeClass('hidden'); + }); + }, + + // takes a string like 1000 and returns 1,000 + addCommas: function (text) { + return String(text).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,'); + }, + + toISOString: function (timestamp) { + if (!timestamp || !Date.prototype.toISOString) { + return ''; + } + + // Prevent too-high values to be passed to Date object + timestamp = Math.min(timestamp, 8640000000000000); + + try { + return new Date(parseInt(timestamp, 10)).toISOString(); + } catch (e) { + return timestamp; + } + }, + + tags: ['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', + 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', + 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', + 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', + 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', + 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', + 'small', 'source', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', + 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], + + stripTags: ['abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'base', 'basefont', + 'bdi', 'bdo', 'big', 'blink', 'body', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', + 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', + 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'head', 'header', 'hr', 'html', 'iframe', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', + 'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', + 'output', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', + 'source', 'span', 'strike', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', + 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], + + escapeRegexChars: function (text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + }, + + escapeHTML: function (str) { + if (str == null) { + return ''; + } + if (!str) { + return String(str); + } + + return str.toString().replace(escapeChars, replaceChar); + }, + + isAndroidBrowser: function () { + // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser + const nua = navigator.userAgent; + return ((nua.indexOf('Mozilla/5.0') > -1 && nua.indexOf('Android ') > -1 && nua.indexOf('AppleWebKit') > -1) && !(nua.indexOf('Chrome') > -1)); + }, + + isTouchDevice: function () { + return 'ontouchstart' in document.documentElement; + }, + + findBootstrapEnvironment: function () { + // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api + const envs = ['xs', 'sm', 'md', 'lg']; + const $el = $('
    '); + + $el.appendTo($('body')); + + for (let i = envs.length - 1; i >= 0; i -= 1) { + const env = envs[i]; + + $el.addClass('hidden-' + env); + if ($el.is(':hidden')) { + $el.remove(); + return env; + } + } + }, + + isMobile: function () { + const env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].some(function (targetEnv) { + return targetEnv === env; + }); + }, + + getHoursArray: function () { + const currentHour = new Date().getHours(); + const labels = []; + + for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { + const hour = i < 0 ? 24 + i : i; + labels.push(hour + ':00'); + } + + return labels.reverse(); + }, + + getDaysArray: function (from, amount) { + const currentDay = new Date(parseInt(from, 10) || Date.now()).getTime(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const labels = []; + let tmpDate; + + for (let x = (amount || 30) - 1; x >= 0; x -= 1) { + tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); + labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); + } + + return labels; + }, + + /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */ + isElementInViewport: function (el) { + // special bonus for those using jQuery + if (typeof jQuery === 'function' && el instanceof jQuery) { + el = el[0]; + } + + const rect = el.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ + rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */ + ); + }, + + // get all the url params in a single key/value hash + params: function (options) { + const hash = {}; + + options = options || {}; + options.skipToType = options.skipToType || {}; + + let searchStr = window.location.search; + if (options.hasOwnProperty('url')) { + if (options.url) { + const a = utils.urlToLocation(options.url); + searchStr = a ? a.search : ''; + } else { + searchStr = ''; + } + } + const params = searchStr.substring(1).split('&'); + + params.forEach(function (param) { + const val = param.split('='); + let key = decodeURIComponent(val[0]); + const value = ( + options.disableToType || + options.skipToType[key] ? decodeURIComponent(val[1]) : utils.toType(decodeURIComponent(val[1])) + ); + + if (key) { + if (key.substr(-2, 2) === '[]') { + key = key.slice(0, -2); + } + if (!hash[key]) { + hash[key] = value; + } else { + if (!Array.isArray(hash[key])) { + hash[key] = [hash[key]]; + } + hash[key].push(value); + } + } + }); + return hash; + }, + + param: function (key) { + return this.params()[key]; + }, + + urlToLocation: function (url) { + const a = document.createElement('a'); + a.href = url; + return a; + }, + + // return boolean if string 'true' or string 'false', or if a parsable string which is a number + // also supports JSON object and/or arrays parsing + toType: function (str) { + const type = typeof str; + if (type !== 'string') { + return str; + } + const nb = parseFloat(str); + if (!isNaN(nb) && isFinite(str)) { + return nb; + } + if (str === 'false') { + return false; + } + if (str === 'true') { + return true; + } + + try { + str = JSON.parse(str); + } catch (e) {} + + return str; + }, + + // Safely get/set chained properties on an object + // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10 + // get example: utils.props(A, 'a.b.c') // returns {d: 10} + // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError + // credits to github.com/gkindel + props: function (obj, props, value) { + if (obj === undefined) { + obj = window; + } + if (props == null) { + return undefined; + } + const i = props.indexOf('.'); + if (i === -1) { + if (value !== undefined) { + obj[props] = value; + } + return obj[props]; + } + const prop = props.slice(0, i); + const newProps = props.slice(i + 1); + + if (props !== undefined && !(obj[prop] instanceof Object)) { + obj[prop] = {}; + } + + return utils.props(obj[prop], newProps, value); + }, + + isInternalURI: function (targetLocation, referenceLocation, relative_path) { + return targetLocation.host === '' || // Relative paths are always internal links + ( + targetLocation.host === referenceLocation.host && + // Otherwise need to check if protocol and host match + targetLocation.protocol === referenceLocation.protocol && + // Subfolder installs need this additional check + (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true) + ); + }, + + rtrim: function (str) { + return str.replace(/\s+$/g, ''); + }, + + debounce: function (func, wait, immediate) { + // modified from https://davidwalsh.name/javascript-debounce-function + let timeout; + return function () { + const context = this; + const args = arguments; + const later = function () { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, args); + } + }; + }, + throttle: function (func, wait, immediate) { + let timeout; + return function () { + const context = this; + const args = arguments; + const later = function () { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + const callNow = immediate && !timeout; + if (!timeout) { + timeout = setTimeout(later, wait); + } + if (callNow) { + func.apply(context, args); + } + }; + }, +}; + +module.exports = utils; diff --git a/public/src/utils.js b/public/src/utils.js index f2f63e219c..5a33542fe0 100644 --- a/public/src/utils.js +++ b/public/src/utils.js @@ -1,793 +1,58 @@ +/* eslint-disable no-redeclare */ + 'use strict'; -(function (factory) { - if (typeof module === 'object' && module.exports) { - module.exports = factory(); +const $ = require('jquery'); +const utils = require('./utils.common'); - process.profile = function (operation, start) { - console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); - }; - - process.elapsedTimeSince = function (start) { - const diff = process.hrtime(start); - return (diff[0] * 1e3) + (diff[1] / 1e6); - }; - } else { - window.utils = factory(); +utils.getLanguage = function () { + let lang = 'en-GB'; + if (typeof window === 'object' && window.config && window.utils) { + lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB'; } - // eslint-disable-next-line -}(function () { - // add default escape function for escaping HTML entities - const escapeCharMap = Object.freeze({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '`': '`', - '=': '=', + return lang; +}; + + +utils.makeNumbersHumanReadable = function (elements) { + elements.each(function () { + $(this) + .html(utils.makeNumberHumanReadable($(this).attr('title'))) + .removeClass('hidden'); }); - function replaceChar(c) { - return escapeCharMap[c]; +}; + +utils.addCommasToNumbers = function (elements) { + elements.each(function (index, element) { + $(element) + .html(utils.addCommas($(element).html())) + .removeClass('hidden'); + }); +}; + +utils.findBootstrapEnvironment = function () { + // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api + const envs = ['xs', 'sm', 'md', 'lg']; + const $el = $('
    '); + + $el.appendTo($('body')); + + for (let i = envs.length - 1; i >= 0; i -= 1) { + const env = envs[i]; + + $el.addClass('hidden-' + env); + if ($el.is(':hidden')) { + $el.remove(); + return env; + } } - const escapeChars = /[&<>"'`=]/g; +}; - const HTMLEntities = Object.freeze({ - amp: '&', - gt: '>', - lt: '<', - quot: '"', - apos: "'", - AElig: 198, - Aacute: 193, - Acirc: 194, - Agrave: 192, - Aring: 197, - Atilde: 195, - Auml: 196, - Ccedil: 199, - ETH: 208, - Eacute: 201, - Ecirc: 202, - Egrave: 200, - Euml: 203, - Iacute: 205, - Icirc: 206, - Igrave: 204, - Iuml: 207, - Ntilde: 209, - Oacute: 211, - Ocirc: 212, - Ograve: 210, - Oslash: 216, - Otilde: 213, - Ouml: 214, - THORN: 222, - Uacute: 218, - Ucirc: 219, - Ugrave: 217, - Uuml: 220, - Yacute: 221, - aacute: 225, - acirc: 226, - aelig: 230, - agrave: 224, - aring: 229, - atilde: 227, - auml: 228, - ccedil: 231, - eacute: 233, - ecirc: 234, - egrave: 232, - eth: 240, - euml: 235, - iacute: 237, - icirc: 238, - igrave: 236, - iuml: 239, - ntilde: 241, - oacute: 243, - ocirc: 244, - ograve: 242, - oslash: 248, - otilde: 245, - ouml: 246, - szlig: 223, - thorn: 254, - uacute: 250, - ucirc: 251, - ugrave: 249, - uuml: 252, - yacute: 253, - yuml: 255, - copy: 169, - reg: 174, - nbsp: 160, - iexcl: 161, - cent: 162, - pound: 163, - curren: 164, - yen: 165, - brvbar: 166, - sect: 167, - uml: 168, - ordf: 170, - laquo: 171, - not: 172, - shy: 173, - macr: 175, - deg: 176, - plusmn: 177, - sup1: 185, - sup2: 178, - sup3: 179, - acute: 180, - micro: 181, - para: 182, - middot: 183, - cedil: 184, - ordm: 186, - raquo: 187, - frac14: 188, - frac12: 189, - frac34: 190, - iquest: 191, - times: 215, - divide: 247, - 'OElig;': 338, - 'oelig;': 339, - 'Scaron;': 352, - 'scaron;': 353, - 'Yuml;': 376, - 'fnof;': 402, - 'circ;': 710, - 'tilde;': 732, - 'Alpha;': 913, - 'Beta;': 914, - 'Gamma;': 915, - 'Delta;': 916, - 'Epsilon;': 917, - 'Zeta;': 918, - 'Eta;': 919, - 'Theta;': 920, - 'Iota;': 921, - 'Kappa;': 922, - 'Lambda;': 923, - 'Mu;': 924, - 'Nu;': 925, - 'Xi;': 926, - 'Omicron;': 927, - 'Pi;': 928, - 'Rho;': 929, - 'Sigma;': 931, - 'Tau;': 932, - 'Upsilon;': 933, - 'Phi;': 934, - 'Chi;': 935, - 'Psi;': 936, - 'Omega;': 937, - 'alpha;': 945, - 'beta;': 946, - 'gamma;': 947, - 'delta;': 948, - 'epsilon;': 949, - 'zeta;': 950, - 'eta;': 951, - 'theta;': 952, - 'iota;': 953, - 'kappa;': 954, - 'lambda;': 955, - 'mu;': 956, - 'nu;': 957, - 'xi;': 958, - 'omicron;': 959, - 'pi;': 960, - 'rho;': 961, - 'sigmaf;': 962, - 'sigma;': 963, - 'tau;': 964, - 'upsilon;': 965, - 'phi;': 966, - 'chi;': 967, - 'psi;': 968, - 'omega;': 969, - 'thetasym;': 977, - 'upsih;': 978, - 'piv;': 982, - 'ensp;': 8194, - 'emsp;': 8195, - 'thinsp;': 8201, - 'zwnj;': 8204, - 'zwj;': 8205, - 'lrm;': 8206, - 'rlm;': 8207, - 'ndash;': 8211, - 'mdash;': 8212, - 'lsquo;': 8216, - 'rsquo;': 8217, - 'sbquo;': 8218, - 'ldquo;': 8220, - 'rdquo;': 8221, - 'bdquo;': 8222, - 'dagger;': 8224, - 'Dagger;': 8225, - 'bull;': 8226, - 'hellip;': 8230, - 'permil;': 8240, - 'prime;': 8242, - 'Prime;': 8243, - 'lsaquo;': 8249, - 'rsaquo;': 8250, - 'oline;': 8254, - 'frasl;': 8260, - 'euro;': 8364, - 'image;': 8465, - 'weierp;': 8472, - 'real;': 8476, - 'trade;': 8482, - 'alefsym;': 8501, - 'larr;': 8592, - 'uarr;': 8593, - 'rarr;': 8594, - 'darr;': 8595, - 'harr;': 8596, - 'crarr;': 8629, - 'lArr;': 8656, - 'uArr;': 8657, - 'rArr;': 8658, - 'dArr;': 8659, - 'hArr;': 8660, - 'forall;': 8704, - 'part;': 8706, - 'exist;': 8707, - 'empty;': 8709, - 'nabla;': 8711, - 'isin;': 8712, - 'notin;': 8713, - 'ni;': 8715, - 'prod;': 8719, - 'sum;': 8721, - 'minus;': 8722, - 'lowast;': 8727, - 'radic;': 8730, - 'prop;': 8733, - 'infin;': 8734, - 'ang;': 8736, - 'and;': 8743, - 'or;': 8744, - 'cap;': 8745, - 'cup;': 8746, - 'int;': 8747, - 'there4;': 8756, - 'sim;': 8764, - 'cong;': 8773, - 'asymp;': 8776, - 'ne;': 8800, - 'equiv;': 8801, - 'le;': 8804, - 'ge;': 8805, - 'sub;': 8834, - 'sup;': 8835, - 'nsub;': 8836, - 'sube;': 8838, - 'supe;': 8839, - 'oplus;': 8853, - 'otimes;': 8855, - 'perp;': 8869, - 'sdot;': 8901, - 'lceil;': 8968, - 'rceil;': 8969, - 'lfloor;': 8970, - 'rfloor;': 8971, - 'lang;': 9001, - 'rang;': 9002, - 'loz;': 9674, - 'spades;': 9824, - 'clubs;': 9827, - 'hearts;': 9829, - 'diams;': 9830, +utils.isMobile = function () { + const env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].some(function (targetEnv) { + return targetEnv === env; }); +}; - const utils = { - generateUUID: function () { - /* eslint-disable no-bitwise */ - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : ((r & 0x3) | 0x8); - return v.toString(16); - }); - /* eslint-enable no-bitwise */ - }, - // https://github.com/substack/node-ent/blob/master/index.js - decodeHTMLEntities: function (html) { - return String(html) - .replace(/&#(\d+);?/g, function (_, code) { - return String.fromCharCode(code); - }) - .replace(/&#[xX]([A-Fa-f0-9]+);?/g, function (_, hex) { - return String.fromCharCode(parseInt(hex, 16)); - }) - .replace(/&([^;\W]+;?)/g, function (m, e) { - const ee = e.replace(/;$/, ''); - const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]); - - if (typeof target === 'number') { - return String.fromCharCode(target); - } else if (typeof target === 'string') { - return target; - } - - return m; - }); - }, - // https://github.com/jprichardson/string.js/blob/master/lib/string.js - stripHTMLTags: function (str, tags) { - const pattern = (tags || ['']).join('|'); - return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); - }, - - cleanUpTag: function (tag, maxLength) { - if (typeof tag !== 'string' || !tag.length) { - return ''; - } - - tag = tag.trim().toLowerCase(); - // see https://github.com/NodeBB/NodeBB/issues/4378 - tag = tag.replace(/\u202E/gi, ''); - tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, ''); - tag = tag.substr(0, maxLength || 15).trim(); - const matches = tag.match(/^[.-]*(.+?)[.-]*$/); - if (matches && matches.length > 1) { - tag = matches[1]; - } - return tag; - }, - - removePunctuation: function (str) { - return str.replace(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, ''); - }, - - isEmailValid: function (email) { - return typeof email === 'string' && email.length && email.indexOf('@') !== -1 && email.indexOf(',') === -1 && email.indexOf(';') === -1; - }, - - isUserNameValid: function (name) { - return (name && name !== '' && (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name))); - }, - - isPasswordValid: function (password) { - return typeof password === 'string' && password.length; - }, - - isNumber: function (n) { - // `isFinite('') === true` so isNan parseFloat check is necessary - return !isNaN(parseFloat(n)) && isFinite(n); - }, - - languageKeyRegex: /\[\[[\w]+:.+\]\]/, - hasLanguageKey: function (input) { - return utils.languageKeyRegex.test(input); - }, - userLangToTimeagoCode: function (userLang) { - const mapping = { - 'en-GB': 'en', - 'en-US': 'en', - 'fa-IR': 'fa', - 'pt-BR': 'pt-br', - nb: 'no', - }; - return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang; - }, - // shallow objects merge - merge: function () { - const result = {}; - let obj; - let keys; - for (let i = 0; i < arguments.length; i += 1) { - obj = arguments[i] || {}; - keys = Object.keys(obj); - for (let j = 0; j < keys.length; j += 1) { - result[keys[j]] = obj[keys[j]]; - } - } - return result; - }, - - fileExtension: function (path) { - return ('' + path).split('.').pop(); - }, - - extensionMimeTypeMap: { - bmp: 'image/bmp', - cmx: 'image/x-cmx', - cod: 'image/cis-cod', - gif: 'image/gif', - ico: 'image/x-icon', - ief: 'image/ief', - jfif: 'image/pipeg', - jpe: 'image/jpeg', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - png: 'image/png', - pbm: 'image/x-portable-bitmap', - pgm: 'image/x-portable-graymap', - pnm: 'image/x-portable-anymap', - ppm: 'image/x-portable-pixmap', - ras: 'image/x-cmu-raster', - rgb: 'image/x-rgb', - svg: 'image/svg+xml', - tif: 'image/tiff', - tiff: 'image/tiff', - xbm: 'image/x-xbitmap', - xpm: 'image/x-xpixmap', - xwd: 'image/x-xwindowdump', - }, - - fileMimeType: function (path) { - return utils.extensionToMimeType(utils.fileExtension(path)); - }, - - extensionToMimeType: function (extension) { - return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*'; - }, - - isPromise: function (object) { - // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324 - return object && typeof object.then === 'function'; - }, - - promiseParallel: function (obj) { - const keys = Object.keys(obj); - return Promise.all( - keys.map(function (k) { return obj[k]; }) - ).then(function (results) { - const data = {}; - keys.forEach(function (k, i) { - data[k] = results[i]; - }); - return data; - }); - }, - - // https://github.com/sindresorhus/is-absolute-url - isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/, - isWinPathRE: /^[a-zA-Z]:\\/, - isAbsoluteUrl: function (url) { - if (utils.isWinPathRE.test(url)) { - return false; - } - return utils.isAbsoluteUrlRE.test(url); - }, - - isRelativeUrl: function (url) { - return !utils.isAbsoluteUrl(url); - }, - - makeNumbersHumanReadable: function (elements) { - elements.each(function () { - $(this) - .html(utils.makeNumberHumanReadable($(this).attr('title'))) - .removeClass('hidden'); - }); - }, - - makeNumberHumanReadable: function (num) { - const n = parseInt(num, 10); - if (!n) { - return num; - } - if (n > 999999) { - return (n / 1000000).toFixed(1) + 'm'; - } else if (n > 999) { - return (n / 1000).toFixed(1) + 'k'; - } - return n; - }, - - addCommasToNumbers: function (elements) { - elements.each(function (index, element) { - $(element) - .html(utils.addCommas($(element).html())) - .removeClass('hidden'); - }); - }, - - // takes a string like 1000 and returns 1,000 - addCommas: function (text) { - return String(text).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,'); - }, - - toISOString: function (timestamp) { - if (!timestamp || !Date.prototype.toISOString) { - return ''; - } - - // Prevent too-high values to be passed to Date object - timestamp = Math.min(timestamp, 8640000000000000); - - try { - return new Date(parseInt(timestamp, 10)).toISOString(); - } catch (e) { - return timestamp; - } - }, - - tags: ['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', - 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', - 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', - 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', - 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', - 'small', 'source', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', - 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], - - stripTags: ['abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'base', 'basefont', - 'bdi', 'bdo', 'big', 'blink', 'body', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', - 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'head', 'header', 'hr', 'html', 'iframe', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', - 'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', - 'output', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', - 'source', 'span', 'strike', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', - 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], - - escapeRegexChars: function (text) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - }, - - escapeHTML: function (str) { - if (str == null) { - return ''; - } - if (!str) { - return String(str); - } - - return str.toString().replace(escapeChars, replaceChar); - }, - - isAndroidBrowser: function () { - // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser - const nua = navigator.userAgent; - return ((nua.indexOf('Mozilla/5.0') > -1 && nua.indexOf('Android ') > -1 && nua.indexOf('AppleWebKit') > -1) && !(nua.indexOf('Chrome') > -1)); - }, - - isTouchDevice: function () { - return 'ontouchstart' in document.documentElement; - }, - - findBootstrapEnvironment: function () { - // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api - const envs = ['xs', 'sm', 'md', 'lg']; - const $el = $('
    '); - - $el.appendTo($('body')); - - for (let i = envs.length - 1; i >= 0; i -= 1) { - const env = envs[i]; - - $el.addClass('hidden-' + env); - if ($el.is(':hidden')) { - $el.remove(); - return env; - } - } - }, - - isMobile: function () { - const env = utils.findBootstrapEnvironment(); - return ['xs', 'sm'].some(function (targetEnv) { - return targetEnv === env; - }); - }, - - getHoursArray: function () { - const currentHour = new Date().getHours(); - const labels = []; - - for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { - const hour = i < 0 ? 24 + i : i; - labels.push(hour + ':00'); - } - - return labels.reverse(); - }, - - getDaysArray: function (from, amount) { - const currentDay = new Date(parseInt(from, 10) || Date.now()).getTime(); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const labels = []; - let tmpDate; - - for (let x = (amount || 30) - 1; x >= 0; x -= 1) { - tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); - labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); - } - - return labels; - }, - - /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */ - isElementInViewport: function (el) { - // special bonus for those using jQuery - if (typeof jQuery === 'function' && el instanceof jQuery) { - el = el[0]; - } - - const rect = el.getBoundingClientRect(); - - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ - rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */ - ); - }, - - // get all the url params in a single key/value hash - params: function (options) { - const hash = {}; - - options = options || {}; - options.skipToType = options.skipToType || {}; - - let searchStr = window.location.search; - if (options.hasOwnProperty('url')) { - if (options.url) { - const a = utils.urlToLocation(options.url); - searchStr = a ? a.search : ''; - } else { - searchStr = ''; - } - } - const params = searchStr.substring(1).split('&'); - - params.forEach(function (param) { - const val = param.split('='); - let key = decodeURIComponent(val[0]); - const value = ( - options.disableToType || - options.skipToType[key] ? decodeURIComponent(val[1]) : utils.toType(decodeURIComponent(val[1])) - ); - - if (key) { - if (key.substr(-2, 2) === '[]') { - key = key.slice(0, -2); - } - if (!hash[key]) { - hash[key] = value; - } else { - if (!Array.isArray(hash[key])) { - hash[key] = [hash[key]]; - } - hash[key].push(value); - } - } - }); - return hash; - }, - - param: function (key) { - return this.params()[key]; - }, - - urlToLocation: function (url) { - const a = document.createElement('a'); - a.href = url; - return a; - }, - - // return boolean if string 'true' or string 'false', or if a parsable string which is a number - // also supports JSON object and/or arrays parsing - toType: function (str) { - const type = typeof str; - if (type !== 'string') { - return str; - } - const nb = parseFloat(str); - if (!isNaN(nb) && isFinite(str)) { - return nb; - } - if (str === 'false') { - return false; - } - if (str === 'true') { - return true; - } - - try { - str = JSON.parse(str); - } catch (e) {} - - return str; - }, - - // Safely get/set chained properties on an object - // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10 - // get example: utils.props(A, 'a.b.c') // returns {d: 10} - // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError - // credits to github.com/gkindel - props: function (obj, props, value) { - if (obj === undefined) { - obj = window; - } - if (props == null) { - return undefined; - } - const i = props.indexOf('.'); - if (i === -1) { - if (value !== undefined) { - obj[props] = value; - } - return obj[props]; - } - const prop = props.slice(0, i); - const newProps = props.slice(i + 1); - - if (props !== undefined && !(obj[prop] instanceof Object)) { - obj[prop] = {}; - } - - return utils.props(obj[prop], newProps, value); - }, - - isInternalURI: function (targetLocation, referenceLocation, relative_path) { - return targetLocation.host === '' || // Relative paths are always internal links - ( - targetLocation.host === referenceLocation.host && - // Otherwise need to check if protocol and host match - targetLocation.protocol === referenceLocation.protocol && - // Subfolder installs need this additional check - (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true) - ); - }, - - rtrim: function (str) { - return str.replace(/\s+$/g, ''); - }, - - debounce: function (func, wait, immediate) { - // modified from https://davidwalsh.name/javascript-debounce-function - let timeout; - return function () { - const context = this; - const args = arguments; - const later = function () { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - }; - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) { - func.apply(context, args); - } - }; - }, - throttle: function (func, wait, immediate) { - let timeout; - return function () { - const context = this; - const args = arguments; - const later = function () { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - }; - const callNow = immediate && !timeout; - if (!timeout) { - timeout = setTimeout(later, wait); - } - if (callNow) { - func.apply(context, args); - } - }; - }, - }; - - return utils; -})); +module.exports = utils; diff --git a/public/src/widgets.js b/public/src/widgets.js index 95eaf2f888..fdb515e488 100644 --- a/public/src/widgets.js +++ b/public/src/widgets.js @@ -1,55 +1,54 @@ 'use strict'; -(function (ajaxify) { - ajaxify.widgets = {}; +// import $ from 'jquery'; - ajaxify.widgets.render = function (template) { - if (template.match(/^admin/)) { +export default function render(template) { + if (template.match(/^admin/)) { + return; + } + + const locations = Object.keys(ajaxify.data.widgets); + + locations.forEach(function (location) { + let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); + if (area.length) { return; } - const locations = Object.keys(ajaxify.data.widgets); + const widgetsAtLocation = ajaxify.data.widgets[location] || []; + let html = ''; - locations.forEach(function (location) { - let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); - if (area.length) { - return; - } - - const widgetsAtLocation = ajaxify.data.widgets[location] || []; - let html = ''; - - widgetsAtLocation.forEach(function (widget) { - html += widget.html; - }); - - if (location === 'footer' && !$('#content [widget-area="footer"],#content [data-widget-area="footer"]').length) { - $('#content').append($('
    ')); - } else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) { - if ($('[component="account/cover"]').length) { - $('[component="account/cover"]').nextAll().wrapAll($('
    ')); - } else if ($('[component="groups/cover"]').length) { - $('[component="groups/cover"]').nextAll().wrapAll($('
    ')); - } else { - $('#content > *').wrapAll($('
    ')); - } - } else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) { - $('#content').prepend($('
    ')); - } - - area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); - if (html && area.length) { - area.html(html); - area.find('img:not(.not-responsive)').addClass('img-responsive'); - } - - if (widgetsAtLocation.length) { - area.removeClass('hidden'); - } + widgetsAtLocation.forEach(function (widget) { + html += widget.html; }); - require(['hooks'], function (hooks) { - hooks.fire('action:widgets.loaded', {}); - }); - }; -}(ajaxify || {})); + if (location === 'footer' && !$('#content [widget-area="footer"],#content [data-widget-area="footer"]').length) { + $('#content').append($('
    ')); + } else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) { + if ($('[component="account/cover"]').length) { + $('[component="account/cover"]').nextAll().wrapAll($('
    ')); + } else if ($('[component="groups/cover"]').length) { + $('[component="groups/cover"]').nextAll().wrapAll($('
    ')); + } else { + $('#content > *').wrapAll($('
    ')); + } + } else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) { + $('#content').prepend($('
    ')); + } + + area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); + if (html && area.length) { + area.html(html); + area.find('img:not(.not-responsive)').addClass('img-responsive'); + } + + if (widgetsAtLocation.length) { + area.removeClass('hidden'); + } + }); + + require(['hooks'], function (hooks) { + hooks.fire('action:widgets.loaded', {}); + }); +}; + diff --git a/src/cli/index.js b/src/cli/index.js index 0ed6b449fd..386866d912 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -192,6 +192,7 @@ program .command('build [targets...]') .description(`Compile static assets ${chalk.red('(JS, CSS, templates, languages)')}`) .option('-s, --series', 'Run builds in series without extra processes') + .option('-w, --webpack', 'Bundle assets with webpack', true) .action((targets, options) => { if (program.opts().dev) { process.env.NODE_ENV = 'development'; diff --git a/src/controllers/admin/settings.js b/src/controllers/admin/settings.js index d4abecd479..719f6e206a 100644 --- a/src/controllers/admin/settings.js +++ b/src/controllers/admin/settings.js @@ -11,7 +11,7 @@ const navigationAdmin = require('../../navigation/admin'); const social = require('../../social'); const helpers = require('../helpers'); -const translator = require('../../../public/src/modules/translator'); +const translator = require('../../translator'); const settingsController = module.exports; diff --git a/src/meta/build.js b/src/meta/build.js index 207318fe56..b59e6b3213 100644 --- a/src/meta/build.js +++ b/src/meta/build.js @@ -8,6 +8,7 @@ const path = require('path'); const mkdirp = require('mkdirp'); const chalk = require('chalk'); +const db = require('../database'); const cacheBuster = require('./cacheBuster'); const { aliases } = require('./aliases'); @@ -176,6 +177,11 @@ exports.build = async function (targets, options) { const startTime = Date.now(); await buildTargets(targets, !series); + + if (options.webpack) { + await exports.webpack(options); + } + const totalTime = (Date.now() - startTime) / 1000; await cacheBuster.write(); winston.info(`[build] Asset compilation successful. Completed in ${totalTime}sec.`); @@ -185,6 +191,50 @@ exports.build = async function (targets, options) { } }; +function getWebpackConfig() { + return require(process.env.NODE_ENV !== 'development' ? '../../webpack.prod' : '../../webpack.dev'); +} + +exports.webpack = async function (options) { + winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} with Webpack.`); + const webpack = require('webpack'); + const fs = require('fs'); + const util = require('util'); + const activePlugins = await db.getSortedSetRange('plugins:active', 0, -1); + if (!activePlugins.includes('nodebb-plugin-composer-default')) { + activePlugins.push('nodebb-plugin-composer-default'); + } + await fs.promises.writeFile(path.resolve(__dirname, '../../build/active_plugins.json'), JSON.stringify(activePlugins)); + + const webpackCfg = getWebpackConfig(); + const compiler = webpack(webpackCfg); + const webpackRun = util.promisify(compiler.run).bind(compiler); + const webpackWatch = util.promisify(compiler.watch).bind(compiler); + try { + let stats; + if (options.watch) { + stats = await webpackWatch(webpackCfg.watchOptions); + compiler.hooks.assetEmitted.tap('nbbWatchPlugin', (file) => { + console.log(`webpack:assetEmitted > ${webpackCfg.output.publicPath} ${file}`); + }); + } else { + stats = await webpackRun(); + } + + if (stats.hasErrors() || stats.hasWarnings()) { + console.log(stats.toString('minimal')); + } else { + const statsJson = stats.toJson(); + winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} took ${statsJson.time} ms`); + } + } catch (err) { + console.error(err.stack || err); + if (err.details) { + console.error(err.details); + } + } +}; + exports.buildAll = async function () { await exports.build(allTargets); }; diff --git a/src/meta/js.js b/src/meta/js.js index 44aee1bea4..b8432a73ad 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -14,106 +14,80 @@ const minifier = require('./minifier'); const JS = module.exports; + JS.scripts = { base: [ - 'node_modules/socket.io-client/dist/socket.io.js', - 'node_modules/requirejs/require.js', - 'public/src/require-config.js', - 'node_modules/jquery/dist/jquery.js', - 'node_modules/textcomplete/dist/textcomplete.min.js', - 'node_modules/textcomplete.contenteditable/dist/textcomplete.codemirror.min.js', - 'node_modules/visibilityjs/lib/visibility.core.js', - 'node_modules/bootstrap/dist/js/bootstrap.js', + // 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.min.js', 'node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.js', - 'node_modules/benchpressjs/build/benchpress.js', 'node_modules/jquery-serializeobject/jquery.serializeObject.js', - 'node_modules/jquery-deserialize/src/jquery.deserialize.js', - 'public/vendor/bootbox/wrapper.js', - - 'public/src/utils.js', - 'public/src/sockets.js', - 'public/src/app.js', - 'public/src/ajaxify.js', - 'public/src/overrides.js', - 'public/src/widgets.js', ], - // files listed below are only available client-side, or are bundled in to reduce # of network requests on cold load - rjs: [ - 'public/src/client/header.js', - 'public/src/client/header/chat.js', - 'public/src/client/header/notifications.js', - 'public/src/client/infinitescroll.js', - 'public/src/client/pagination.js', - 'public/src/client/recent.js', - 'public/src/client/unread.js', - 'public/src/client/topic.js', - 'public/src/client/topic/events.js', - 'public/src/client/topic/posts.js', - 'public/src/client/topic/images.js', - 'public/src/client/topic/votes.js', - 'public/src/client/topic/postTools.js', - 'public/src/client/topic/threadTools.js', - 'public/src/client/categories.js', - 'public/src/client/category.js', - 'public/src/client/category/tools.js', - - 'public/src/modules/translator.js', - 'public/src/modules/components.js', - 'public/src/modules/hooks.js', - 'public/src/modules/sort.js', - 'public/src/modules/navigator.js', - 'public/src/modules/topicSelect.js', - 'public/src/modules/topicList.js', - 'public/src/modules/categoryFilter.js', - 'public/src/modules/categorySelector.js', - 'public/src/modules/categorySearch.js', - 'public/src/modules/share.js', - 'public/src/modules/alerts.js', - 'public/src/modules/taskbar.js', - 'public/src/modules/helpers.js', - 'public/src/modules/storage.js', - 'public/src/modules/handleBack.js', - 'public/src/modules/messages.js', - 'public/src/modules/search.js', - ], - - admin: [ - 'node_modules/material-design-lite/material.js', - 'public/src/admin/admin.js', - 'node_modules/jquery-deserialize/src/jquery.deserialize.js', - ], - - // modules listed below are built (/src/modules) so they can be defined anonymously - modules: { - 'Chart.js': 'node_modules/chart.js/dist/Chart.min.js', - 'mousetrap.js': 'node_modules/mousetrap/mousetrap.min.js', - 'cropper.js': 'node_modules/cropperjs/dist/cropper.min.js', - 'jquery-ui': 'node_modules/jquery-ui/ui', - 'zxcvbn.js': 'node_modules/zxcvbn/dist/zxcvbn.js', - - // only get ace files required by acp - 'ace/ace.js': 'node_modules/ace-builds/src-min/ace.js', - 'ace/mode-less.js': 'node_modules/ace-builds/src-min/mode-less.js', - 'ace/mode-javascript.js': 'node_modules/ace-builds/src-min/mode-javascript.js', - 'ace/mode-html.js': 'node_modules/ace-builds/src-min/mode-html.js', - 'ace/theme-twilight.js': 'node_modules/ace-builds/src-min/theme-twilight.js', - 'ace/worker-css.js': 'node_modules/ace-builds/src-min/worker-css.js', - 'ace/worker-javascript.js': 'node_modules/ace-builds/src-min/worker-javascript.js', - 'ace/worker-html.js': 'node_modules/ace-builds/src-min/worker-html.js', - 'ace/ext-searchbox.js': 'node_modules/ace-builds/src-min/ext-searchbox.js', - - 'clipboard.js': 'node_modules/clipboard/dist/clipboard.min.js', - 'tinycon.js': 'node_modules/tinycon/tinycon.js', - 'slideout.js': 'node_modules/slideout/dist/slideout.min.js', - 'compare-versions.js': 'node_modules/compare-versions/index.js', - 'timeago/locales': 'node_modules/timeago/locales', - 'jquery-form.js': 'node_modules/jquery-form/dist/jquery.form.min.js', - 'xregexp.js': 'node_modules/xregexp/xregexp-all.js', - }, + // plugins add entries into this object, + // they get linked into /build/public/src/modules + modules: { }, }; +// JS.scripts = { +// base: [ +// 'node_modules/socket.io-client/dist/socket.io.js', +// 'node_modules/requirejs/require.js', +// 'public/src/require-config.js', +// 'node_modules/jquery/dist/jquery.js', +// 'node_modules/textcomplete/dist/textcomplete.min.js', +// 'node_modules/textcomplete.contenteditable/dist/textcomplete.codemirror.min.js', +// 'node_modules/visibilityjs/lib/visibility.core.js', +// 'node_modules/bootstrap/dist/js/bootstrap.js', +// 'node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.js', +// 'node_modules/benchpressjs/build/benchpress.js', +// 'node_modules/jquery-serializeobject/jquery.serializeObject.js', +// 'node_modules/jquery-deserialize/src/jquery.deserialize.js', + +// 'public/vendor/bootbox/wrapper.js', + +// 'public/src/utils.js', +// 'public/src/sockets.js', +// 'public/src/app.js', +// 'public/src/ajaxify.js', +// 'public/src/overrides.js', +// 'public/src/widgets.js', +// ], + +// admin: [ +// 'node_modules/material-design-lite/material.js', +// 'public/src/admin/admin.js', +// 'node_modules/jquery-deserialize/src/jquery.deserialize.js', +// ], + +// // modules listed below are built (/src/modules) so they can be defined anonymously +// modules: { +// 'Chart.js': 'node_modules/chart.js/dist/Chart.min.js', +// 'mousetrap.js': 'node_modules/mousetrap/mousetrap.min.js', +// 'cropper.js': 'node_modules/cropperjs/dist/cropper.min.js', +// 'jquery-ui': 'node_modules/jquery-ui/ui', +// 'zxcvbn.js': 'node_modules/zxcvbn/dist/zxcvbn.js', + +// // only get ace files required by acp +// 'ace/ace.js': 'node_modules/ace-builds/src-min/ace.js', +// 'ace/mode-less.js': 'node_modules/ace-builds/src-min/mode-less.js', +// 'ace/mode-javascript.js': 'node_modules/ace-builds/src-min/mode-javascript.js', +// 'ace/mode-html.js': 'node_modules/ace-builds/src-min/mode-html.js', +// 'ace/theme-twilight.js': 'node_modules/ace-builds/src-min/theme-twilight.js', +// 'ace/worker-css.js': 'node_modules/ace-builds/src-min/worker-css.js', +// 'ace/worker-javascript.js': 'node_modules/ace-builds/src-min/worker-javascript.js', +// 'ace/worker-html.js': 'node_modules/ace-builds/src-min/worker-html.js', +// 'ace/ext-searchbox.js': 'node_modules/ace-builds/src-min/ext-searchbox.js', + +// 'clipboard.js': 'node_modules/clipboard/dist/clipboard.min.js', +// 'tinycon.js': 'node_modules/tinycon/tinycon.js', +// 'slideout.js': 'node_modules/slideout/dist/slideout.min.js', +// 'compare-versions.js': 'node_modules/compare-versions/index.js', +// 'timeago/locales': 'node_modules/timeago/locales', +// 'jquery-form.js': 'node_modules/jquery-form/dist/jquery.form.min.js', +// 'xregexp.js': 'node_modules/xregexp/xregexp-all.js', +// }, +// }; + async function linkIfLinux(srcPath, destPath) { if (process.platform === 'win32') { await fs.promises.copyFile(srcPath, destPath); @@ -124,36 +98,14 @@ async function linkIfLinux(srcPath, destPath) { const basePath = path.resolve(__dirname, '../..'); -async function minifyModules(modules, fork) { - const moduleDirs = modules.reduce((prev, mod) => { - const dir = path.resolve(path.dirname(mod.destPath)); - if (!prev.includes(dir)) { - prev.push(dir); - } - return prev; - }, []); - - await Promise.all(moduleDirs.map(dir => mkdirp(dir))); - - const filtered = modules.reduce((prev, mod) => { - if (mod.srcPath.endsWith('.min.js') || path.dirname(mod.srcPath).endsWith('min')) { - prev.skip.push(mod); - } else { - prev.minify.push(mod); - } - - return prev; - }, { minify: [], skip: [] }); - - await Promise.all([ - minifier.js.minifyBatch(filtered.minify, fork), - ...filtered.skip.map(mod => linkIfLinux(mod.srcPath, mod.destPath)), - ]); -} - async function linkModules() { const { modules } = JS.scripts; + await Promise.all([ + mkdirp(path.join(__dirname, '../../build/public/src/modules/admin/plugins')), + mkdirp(path.join(__dirname, '../../build/public/src/modules/forum/plugins')), + ]); + await Promise.all(Object.keys(modules).map(async (relPath) => { const srcPath = path.join(__dirname, '../../', modules[relPath]); const destPath = path.join(__dirname, '../../build/public/src/modules', relPath); @@ -163,56 +115,14 @@ async function linkModules() { ]); if (stats.isDirectory()) { await file.linkDirs(srcPath, destPath, true); - return; + } else { + await linkIfLinux(srcPath, destPath); } - - await linkIfLinux(srcPath, destPath); })); } const moduleDirs = ['modules', 'admin', 'client']; -async function getModuleList() { - let modules = Object.keys(JS.scripts.modules).map(relPath => ({ - srcPath: path.join(__dirname, '../../', JS.scripts.modules[relPath]), - destPath: path.join(__dirname, '../../build/public/src/modules', relPath), - })); - - const coreDirs = moduleDirs.map(dir => ({ - srcPath: path.join(__dirname, '../../public/src', dir), - destPath: path.join(__dirname, '../../build/public/src', dir), - })); - - modules = modules.concat(coreDirs); - - const moduleFiles = []; - await Promise.all(modules.map(async (module) => { - const { srcPath } = module; - const { destPath } = module; - - const stats = await fs.promises.stat(srcPath); - if (!stats.isDirectory()) { - moduleFiles.push(module); - return; - } - - const files = await file.walk(srcPath); - - const mods = files.filter( - filePath => path.extname(filePath) === '.js' - ).map(filePath => ({ - srcPath: path.normalize(filePath), - destPath: path.join(destPath, path.relative(srcPath, filePath)), - })); - - moduleFiles.push(...mods); - })); - moduleFiles.forEach((mod) => { - mod.filename = path.relative(basePath, mod.srcPath).replace(/\\/g, '/'); - }); - return moduleFiles; -} - async function clearModules() { const builtPaths = moduleDirs.map( p => path.join(__dirname, '../../build/public/src', p) @@ -222,65 +132,11 @@ async function clearModules() { ); } -JS.buildModules = async function (fork) { +JS.buildModules = async function () { await clearModules(); - if (process.env.NODE_ENV === 'development') { - await linkModules(); - return; - } - const modules = await getModuleList(); - await minifyModules(modules, fork); + await linkModules(); }; -async function requirejsOptimize(target) { - const requirejs = require('requirejs'); - let scriptText = ''; - const sharedCfg = { - paths: { - jquery: 'empty:', - }, - optimize: 'none', - out: function (text) { - scriptText += text; - }, - }; - const bundledModules = [ - { - baseUrl: path.join(basePath, 'node_modules'), - name: 'timeago/jquery.timeago', - }, - { - baseUrl: path.join(basePath, 'node_modules/nprogress'), - name: 'nprogress', - }, - { - baseUrl: path.join(basePath, 'node_modules/bootbox'), - name: 'bootbox', - }, - ]; - const targetModules = { - admin: [ - { - baseUrl: path.join(basePath, 'node_modules/sortablejs'), - name: 'Sortable', - }, - ], - client: [], - }; - const optimizeAsync = util.promisify((config, cb) => { - requirejs.optimize(config, () => cb(), err => cb(err)); - }); - - const allModules = bundledModules.concat(targetModules[target]); - - for (const moduleCfg of allModules) { - // eslint-disable-next-line no-await-in-loop - await optimizeAsync({ ...sharedCfg, ...moduleCfg }); - } - const filePath = path.join(__dirname, `../../build/public/rjs-bundle-${target}.js`); - await fs.promises.writeFile(filePath, scriptText); -} - JS.linkStatics = async function () { await rimrafAsync(path.join(__dirname, '../../build/public/plugins')); @@ -313,15 +169,7 @@ async function getBundleScriptList(target) { pluginScripts = pluginScripts.concat(scripts); })); - let scripts = JS.scripts.base; - - if (target === 'client') { - scripts = scripts.concat(JS.scripts.rjs); - } else if (target === 'acp') { - scripts = scripts.concat(JS.scripts.admin); - } - - scripts = scripts.concat(pluginScripts).map((script) => { + pluginScripts = JS.scripts.base.concat(pluginScripts).map((script) => { const srcPath = path.resolve(basePath, script).replace(/\\/g, '/'); return { srcPath: srcPath, @@ -329,23 +177,16 @@ async function getBundleScriptList(target) { }; }); - return scripts; + return pluginScripts; } JS.buildBundle = async function (target, fork) { const fileNames = { - client: 'nodebb.min.js', - admin: 'acp.min.js', + client: 'client-scripts.min.js', + admin: 'acp-scripts.min.js', }; - await requirejsOptimize(target); + const files = await getBundleScriptList(target); - - const srcPath = path.join(__dirname, `../../build/public/rjs-bundle-${target}.js`); - files.push({ - srcPath: srcPath, - filename: path.relative(basePath, srcPath).replace(/\\/g, '/'), - }); - const minify = process.env.NODE_ENV !== 'development'; const filePath = path.join(__dirname, '../../build/public', fileNames[target]); diff --git a/src/routes/debug.js b/src/routes/debug.js index b4ad76721f..0c6873e18d 100644 --- a/src/routes/debug.js +++ b/src/routes/debug.js @@ -6,11 +6,21 @@ const nconf = require('nconf'); const fs = require('fs').promises; const path = require('path'); -module.exports = function (app) { +module.exports = function (app, middleware) { const router = express.Router(); - router.get('/test', async (req, res) => { - res.redirect(404); + // router.get('/test', async (req, res) => { + // res.redirect(404); + // }); + + const { setupPageRoute } = require('./helpers'); + setupPageRoute(app, '/debug/test', middleware, [], async (req, res) => { + // res.redirect(404); + const meta = require('../meta'); + res.render('test', { + now: new Date().toISOString(), + skins: [{ name: 'no-skin', value: '' }].concat(meta.css.supportedSkins.map(s => ({ name: s, value: s }))), + }); }); // Redoc diff --git a/src/routes/index.js b/src/routes/index.js index 3a7e72cdf6..b088109c12 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -169,6 +169,7 @@ function addCoreRoutes(app, router, middleware, mounts) { app.use(middleware.privateUploads); const statics = [ + { route: '/dist', path: path.join(__dirname, '../../dist') }, { route: '/assets', path: path.join(__dirname, '../../build/public') }, { route: '/assets', path: path.join(__dirname, '../../public') }, { route: '/plugins', path: path.join(__dirname, '../../build/public/plugins') }, diff --git a/src/translator.js b/src/translator.js index 21d4891332..872349a4aa 100644 --- a/src/translator.js +++ b/src/translator.js @@ -1,3 +1,12 @@ 'use strict'; -module.exports = require('../public/src/modules/translator'); +const winston = require('winston'); + +function warn(msg) { + winston.warn(msg); +} + +module.exports = require('../public/src/modules/translator.common')(require('./utils'), (lang, namespace) => { + const languages = require('./languages'); + return languages.get(lang, namespace); +}, warn); diff --git a/src/utils.js b/src/utils.js index 8913909584..524730f221 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,17 @@ 'use strict'; -module.exports = require('../public/src/utils'); +process.profile = function (operation, start) { + console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); +}; + +process.elapsedTimeSince = function (start) { + const diff = process.hrtime(start); + return (diff[0] * 1e3) + (diff[1] / 1e6); +}; +const utils = require('../public/src/utils.common'); + +utils.getLanguage = function () { + const meta = require('./meta'); + return meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB'; +}; +module.exports = utils; diff --git a/src/views/500-embed.tpl b/src/views/500-embed.tpl index 3bd6857048..c3e75bbe00 100644 --- a/src/views/500-embed.tpl +++ b/src/views/500-embed.tpl @@ -1,14 +1,15 @@ \ No newline at end of file diff --git a/src/views/partials/footer/js.tpl b/src/views/partials/footer/js.tpl index a0b8668f4c..0e2d84f944 100644 --- a/src/views/partials/footer/js.tpl +++ b/src/views/partials/footer/js.tpl @@ -1,4 +1,4 @@ - + {{{each scripts}}} diff --git a/src/views/test.tpl b/src/views/test.tpl new file mode 100644 index 0000000000..9282e39028 --- /dev/null +++ b/src/views/test.tpl @@ -0,0 +1,59 @@ + + +
    + + + +
    + + + +
    + + + +
    + + +
    + +
    + + +
    +
    + + + +
    + + +
    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +
    +
    + + +
    + + + +
    +
    
    +
    + + +
    + + + +
    +
    \ No newline at end of file diff --git a/src/webserver.js b/src/webserver.js index a4364ea317..6326ded225 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -34,7 +34,7 @@ const topicEvents = require('./topics/events'); const routes = require('./routes'); const auth = require('./routes/authentication'); -const helpers = require('../public/src/modules/helpers'); +const helpers = require('../public/src/modules/helpers.common')(require('./utils'), Benchpress, nconf.get('relative_path')); if (nconf.get('ssl')) { server = require('https').createServer({ diff --git a/test/template-helpers.js b/test/template-helpers.js index 535bdcffe9..f69f2b9354 100644 --- a/test/template-helpers.js +++ b/test/template-helpers.js @@ -2,9 +2,10 @@ const nconf = require('nconf'); const assert = require('assert'); +const benchpress = require('benchpressjs'); const db = require('./mocks/databasemock'); -const helpers = require('../public/src/modules/helpers'); +const helpers = require('../public/src/modules/helpers.common')(require('../src/utils'), benchpress, nconf.get('relative_path')); describe('helpers', () => { it('should return false if item doesn\'t exist', (done) => { diff --git a/webpack.common.js b/webpack.common.js new file mode 100644 index 0000000000..29446c4c92 --- /dev/null +++ b/webpack.common.js @@ -0,0 +1,59 @@ +'use strict'; + +const path = require('path'); +const url = require('url'); +const nconf = require('nconf'); + +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); + +const activePlugins = require('./build/active_plugins.json'); + +let relativePath = nconf.get('relative_path'); +if (relativePath === undefined) { + nconf.file({ + file: path.resolve(__dirname, nconf.any(['config', 'CONFIG']) || 'config.json'), + }); + + const urlObject = url.parse(nconf.get('url')); + relativePath = urlObject.pathname !== '/' ? urlObject.pathname.replace(/\/+$/, '') : ''; +} + +module.exports = { + plugins: [ + new CleanWebpackPlugin(), // cleans dist folder + ], + entry: { + app: './public/src/app.js', + // admin: './public/src/admin/admin.js', + }, + output: { + filename: '[name].bundle.js', + chunkFilename: '[name].bundle.js', + path: path.resolve(__dirname, 'dist'), + publicPath: `${relativePath}/dist/`, + }, + watchOptions: { + poll: 500, + aggregateTimeout: 500, + }, + resolve: { + symlinks: false, + modules: [ + 'build/public/src/modules', + 'public/src', + 'public/src/modules', + 'public/src/client', + 'node_modules', + ...activePlugins.map(p => `node_modules/${p}/node_modules`), + ], + alias: { + assets: path.resolve(__dirname, 'build/public'), + 'forum/plugins': path.resolve(__dirname, 'build/public/src/modules/forum/plugins'), + forum: path.resolve(__dirname, 'public/src/client'), + 'admin/plugins': path.resolve(__dirname, 'build/public/src/modules/admin/plugins'), + admin: path.resolve(__dirname, 'public/src/admin'), + vendor: path.resolve(__dirname, 'public/vendor'), + benchpress: path.resolve(__dirname, 'node_modules/benchpressjs'), + }, + }, +}; diff --git a/webpack.dev.js b/webpack.dev.js new file mode 100644 index 0000000000..360e05c064 --- /dev/null +++ b/webpack.dev.js @@ -0,0 +1,9 @@ +'use strict'; + +const { merge } = require('webpack-merge'); +const common = require('./webpack.common'); + +module.exports = merge(common, { + mode: 'development', + // devtool: 'inline-source-map', +}); diff --git a/webpack.installer.js b/webpack.installer.js new file mode 100644 index 0000000000..35d30b70c5 --- /dev/null +++ b/webpack.installer.js @@ -0,0 +1,25 @@ +// webpack config for webinstaller + +'use strict'; + +const path = require('path'); + +module.exports = { + mode: 'production', + entry: { + installer: './public/src/installer/install.js', + }, + output: { + filename: '[name].bundle.js', + chunkFilename: '[name].bundle.js', + path: path.resolve(__dirname, 'dist'), + publicPath: '/dist/', + }, + resolve: { + symlinks: false, + modules: [ + 'public/src', + 'node_modules', + ], + }, +}; diff --git a/webpack.prod.js b/webpack.prod.js new file mode 100644 index 0000000000..523e011f3d --- /dev/null +++ b/webpack.prod.js @@ -0,0 +1,8 @@ +'use strict'; + +const { merge } = require('webpack-merge'); +const common = require('./webpack.common'); + +module.exports = merge(common, { + mode: 'production', +});