feat: webpack 5 part 1

This commit is contained in:
Barış Soner Uşaklı
2022-02-14 21:35:33 -05:00
parent 4043f1791d
commit a1a1e8da18
53 changed files with 2550 additions and 2308 deletions

View File

@@ -137,9 +137,10 @@ module.exports = function (grunt) {
}); });
const build = require('./src/meta/build'); const build = require('./src/meta/build');
if (!grunt.option('skip')) { if (!grunt.option('skip')) {
await build.build(true); await build.build(true, { webpack: false });
} }
run(); run();
await build.webpack({ watch: true });
done(); done();
}); });
@@ -183,7 +184,7 @@ module.exports = function (grunt) {
return run(); return run();
} }
require('./src/meta/build').build([compiling], (err) => { require('./src/meta/build').build([compiling], { webpack: false }, (err) => {
if (err) { if (err) {
winston.error(err.stack); winston.error(err.stack);
} }

View File

@@ -60,6 +60,7 @@
"express": "4.17.2", "express": "4.17.2",
"express-session": "1.17.2", "express-session": "1.17.2",
"express-useragent": "1.0.15", "express-useragent": "1.0.15",
"file-loader": "6.2.0",
"graceful-fs": "4.2.9", "graceful-fs": "4.2.9",
"helmet": "5.0.2", "helmet": "5.0.2",
"html-to-text": "8.1.0", "html-to-text": "8.1.0",
@@ -112,7 +113,6 @@
"ioredis": "4.28.5", "ioredis": "4.28.5",
"request": "2.88.2", "request": "2.88.2",
"request-promise-native": "1.0.9", "request-promise-native": "1.0.9",
"requirejs": "2.3.6",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"rss": "1.2.2", "rss": "1.2.2",
"sanitize-html": "2.7.0", "sanitize-html": "2.7.0",
@@ -136,6 +136,7 @@
"uglify-es": "3.3.9", "uglify-es": "3.3.9",
"validator": "13.7.0", "validator": "13.7.0",
"visibilityjs": "2.0.2", "visibilityjs": "2.0.2",
"webpack": "^5.68.0",
"winston": "3.6.0", "winston": "3.6.0",
"xml": "1.0.1", "xml": "1.0.1",
"xregexp": "5.1.0", "xregexp": "5.1.0",
@@ -159,7 +160,9 @@
"mocha-lcov-reporter": "1.3.0", "mocha-lcov-reporter": "1.3.0",
"mockdate": "3.0.5", "mockdate": "3.0.5",
"nyc": "15.1.0", "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": { "bugs": {
"url": "https://github.com/NodeBB/NodeBB/issues" "url": "https://github.com/NodeBB/NodeBB/issues"

View File

@@ -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/');
});
}()); }());

View File

@@ -1,7 +1,7 @@
'use strict'; '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 = {}; const Errors = {};
Errors.init = function () { Errors.init = function () {

View File

@@ -1,6 +1,6 @@
'use strict'; '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 = {}; const Customise = {};
Customise.init = function () { Customise.init = function () {

View File

@@ -2,7 +2,7 @@
define('admin/dashboard', [ define('admin/dashboard', [
'Chart', 'translator', 'benchpress', 'bootbox', 'alerts', 'chart.js', 'translator', 'benchpress', 'bootbox', 'alerts',
], function (Chart, translator, Benchpress, bootbox, alerts) { ], function (Chart, translator, Benchpress, bootbox, alerts) {
const Admin = {}; const Admin = {};
const intervals = { const intervals = {

View File

@@ -6,7 +6,7 @@ define('admin/extend/plugins', [
'benchpress', 'benchpress',
'bootbox', 'bootbox',
'alerts', 'alerts',
'jquery-ui/widgets/sortable', 'jquery-ui/ui/widgets/sortable',
], function (translator, Benchpress, bootbox, alerts) { ], function (translator, Benchpress, bootbox, alerts) {
const Plugins = {}; const Plugins = {};
Plugins.init = function () { Plugins.init = function () {

View File

@@ -4,10 +4,10 @@
define('admin/extend/widgets', [ define('admin/extend/widgets', [
'bootbox', 'bootbox',
'alerts', 'alerts',
'jquery-ui/widgets/sortable', 'jquery-ui/ui/widgets/sortable',
'jquery-ui/widgets/draggable', 'jquery-ui/ui/widgets/draggable',
'jquery-ui/widgets/droppable', 'jquery-ui/ui/widgets/droppable',
'jquery-ui/widgets/datepicker', 'jquery-ui/ui/widgets/datepicker',
], function (bootbox, alerts) { ], function (bootbox, alerts) {
const Widgets = {}; const Widgets = {};

View File

@@ -5,7 +5,7 @@ define('admin/manage/categories', [
'benchpress', 'benchpress',
'categorySelector', 'categorySelector',
'api', 'api',
'Sortable', 'sortablejs',
'bootbox', 'bootbox',
'alerts', 'alerts',
], function (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) { ], function (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) {

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
define('admin/manage/category-analytics', ['Chart'], function (Chart) { define('admin/manage/category-analytics', ['chart.js'], function (Chart) {
const CategoryAnalytics = {}; const CategoryAnalytics = {};
CategoryAnalytics.init = function () { CategoryAnalytics.init = function () {

View File

@@ -1,6 +1,6 @@
'use strict'; '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 = { const Graph = {
_current: null, _current: null,
}; };

View File

@@ -2,7 +2,7 @@
define('admin/modules/selectable', [ define('admin/modules/selectable', [
'jquery-ui/widgets/selectable', 'jquery-ui/ui/widgets/selectable',
], function () { ], function () {
const selectable = {}; const selectable = {};

View File

@@ -1,7 +1,7 @@
'use strict'; '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 = {}; const module = {};
let emailEditor; let emailEditor;

View File

@@ -6,9 +6,9 @@ define('admin/settings/navigation', [
'iconSelect', 'iconSelect',
'benchpress', 'benchpress',
'alerts', 'alerts',
'jquery-ui/widgets/draggable', 'jquery-ui/ui/widgets/draggable',
'jquery-ui/widgets/droppable', 'jquery-ui/ui/widgets/droppable',
'jquery-ui/widgets/sortable', 'jquery-ui/ui/widgets/sortable',
], function (translator, iconSelect, Benchpress, alerts) { ], function (translator, iconSelect, Benchpress, alerts) {
const navigation = {}; const navigation = {};
let available; let available;

View File

@@ -1,8 +1,14 @@
'use strict'; '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 () { (function () {
let apiXHR = null; let apiXHR = null;
let ajaxifyTimer; let ajaxifyTimer;
@@ -328,14 +334,9 @@ ajaxify = window.ajaxify || {};
}; };
ajaxify.loadScript = function (tpl_url, callback) { ajaxify.loadScript = function (tpl_url, callback) {
let location = !app.inAdmin ? 'forum/' : '';
if (tpl_url.startsWith('admin')) {
location = '';
}
const data = { const data = {
tpl_url: tpl_url, 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`) // 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') { if (typeof script === 'string') {
return function (next) { return async function (next) {
require([script], function (module) { const module = await importScript(script);
// Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic),
// or call a method other than .init() // or call a method other than .init()
hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => { hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => {
if (module && module.init) { if (module && module.init) {
module.init(); module.init();
} }
next();
});
}, function () {
// ignore 404 error
next(); 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) { ajaxify.loadData = function (url, callback) {
url = ajaxify.removeRelativePath(url); url = ajaxify.removeRelativePath(url);
@@ -434,9 +450,24 @@ ajaxify = window.ajaxify || {};
}; };
ajaxify.loadTemplate = function (template, callback) { 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); console.error('Unable to load template: ' + template);
throw err; callback(new Error('[[error:unable-to-load-template]]'));
}); });
}; };

View File

@@ -1,5 +1,24 @@
'use strict'; '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 || {}; app = window.app || {};
@@ -8,6 +27,7 @@ app.currentRoom = null;
app.widgets = {}; app.widgets = {};
app.flags = {}; app.flags = {};
(function () { (function () {
let appLoaded = false; let appLoaded = false;
const isTouchDevice = utils.isTouchDevice(); 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) { app.logout = function (redirect) {
console.warn('[deprecated] app.logout is deprecated, please use logout module directly'); console.warn('[deprecated] app.logout is deprecated, please use logout module directly');
require(['logout'], function (logout) { require(['logout'], function (logout) {
@@ -322,11 +329,11 @@ app.flags = {};
return callback(); return callback();
} }
require([ require([
'jquery-ui/widgets/datepicker', 'jquery-ui/ui/widgets/datepicker',
'jquery-ui/widgets/autocomplete', 'jquery-ui/ui/widgets/autocomplete',
'jquery-ui/widgets/sortable', 'jquery-ui/ui/widgets/sortable',
'jquery-ui/widgets/resizable', 'jquery-ui/ui/widgets/resizable',
'jquery-ui/widgets/draggable', 'jquery-ui/ui/widgets/draggable',
], function () { ], function () {
callback(); callback();
}); });

View File

@@ -35,7 +35,7 @@ define('forum/account/header', [
components.get('account/chat').on('click', async function () { components.get('account/chat').on('click', async function () {
const roomId = await socket.emit('modules.chats.hasPrivateChat', ajaxify.data.uid); const roomId = await socket.emit('modules.chats.hasPrivateChat', ajaxify.data.uid);
const chat = await app.require('chat'); const chat = await import('chat');
if (roomId) { if (roomId) {
chat.openChat(roomId); chat.openChat(roomId);
} else { } else {
@@ -44,7 +44,7 @@ define('forum/account/header', [
}); });
components.get('account/new-chat').on('click', async function () { 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 () { chat.newChat(ajaxify.data.uid, function () {
components.get('account/chat').parent().removeClass('hidden'); components.get('account/chat').parent().removeClass('hidden');
}); });

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
define('forum/flags/list', [ define('forum/flags/list', [
'components', 'Chart', 'categoryFilter', 'autocomplete', 'api', 'alerts', 'components', 'chart.js', 'categoryFilter', 'autocomplete', 'api', 'alerts',
], function (components, Chart, categoryFilter, autocomplete, api, alerts) { ], function (components, Chart, categoryFilter, autocomplete, api, alerts) {
const Flags = {}; const Flags = {};

View File

@@ -1,7 +1,7 @@
'use strict'; '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 = {}; const Blacklist = {};
Blacklist.init = function () { Blacklist.init = function () {

101
public/src/client/test.js Normal file
View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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 '<meta ' + name + property + content + '/>\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 '<link ' + link + rel + as + type + sizes + title + href + crossorigin + '/>\n\t';
}
function stringify(obj) {
// Turns the incoming object into a JSON string
return JSON.stringify(obj).replace(/&/gm, '&amp;').replace(/</gm, '&lt;').replace(/>/gm, '&gt;')
.replace(/"/g, '&quot;');
}
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 += '<span class="category-children-item pull-left">' +
'<div role="presentation" class="icon pull-left" style="' + generateCategoryBackground(child) + '">' +
'<i class="fa fa-fw ' + child.icon + '"></i>' +
'</div>' +
'<a href="' + link + '"><small>' + child.name + '</small></a></span>';
}
});
html = html ? ('<span class="category-children">' + html + '</span>') : 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 '<button class="btn btn-danger" data-action="leave" data-group="' + groupObj.displayName + '"' + (groupObj.disableLeave ? ' disabled' : '') + '><i class="fa fa-times"></i> [[groups:membership.leave-group]]</button>';
}
if (groupObj.isPending && groupObj.name !== 'administrators') {
return '<button class="btn btn-warning disabled"><i class="fa fa-clock-o"></i> [[groups:membership.invitation-pending]]</button>';
} else if (groupObj.isInvited) {
return '<button class="btn btn-link" data-action="rejectInvite" data-group="' + groupObj.displayName + '">[[groups:membership.reject]]</button><button class="btn btn-success" data-action="acceptInvite" data-group="' + groupObj.name + '"><i class="fa fa-plus"></i> [[groups:membership.accept-invitation]]</button>';
} else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') {
return '<button class="btn btn-success" data-action="join" data-group="' + groupObj.displayName + '"><i class="fa fa-plus"></i> [[groups:membership.join-group]]</button>';
}
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 '<td class="text-center" data-privilege="' + priv.name + '" data-value="' + priv.state + '"><input autocomplete="off" type="checkbox"' + (priv.state ? ' checked' : '') + (disabled ? ' disabled="disabled"' : '') + ' /></td>';
}).join('');
}
function localeToHTML(locale, fallback) {
locale = locale || fallback || 'en-GB';
return locale.replace('_', '-');
}
function renderTopicImage(topicObj) {
if (topicObj.thumb) {
return '<img src="' + topicObj.thumb + '" class="img-circle user-img" title="' + topicObj.user.username + '" />';
}
return '<img component="user/picture" data-uid="' + topicObj.user.uid + '" src="' + topicObj.user.picture + '" class="user-img" title="' + topicObj.user.username + '" />';
}
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 += `<li component="topic/event" class="timeline-event" data-topic-event-id="${event.id}">
<div class="timeline-badge">
<i class="fa ${event.icon || 'fa-circle'}"></i>
</div>
<span class="timeline-text">
${event.href ? `<a href="${relative_path}${event.href}">${event.text}</a>` : event.text}&nbsp;
</span>
`;
if (event.user) {
if (!event.user.system) {
html += `<span><a href="${relative_path}/user/${event.user.userslug}">${buildAvatar(event.user, 'xs', true)}&nbsp;${event.user.username}</a></span>&nbsp;`;
} else {
html += `<span class="timeline-text">[[global:system-user]]</span>&nbsp;`;
}
}
html += `<span class="timeago timeline-text" title="${event.timestampISO}"></span>`;
if (this.privileges.isAdminOrMod) {
html += `&nbsp;<span component="topic/event/delete" data-topic-event-id="${event.id}" class="timeline-text pointer" title="[[topic:delete-event]]"><i class="fa fa-trash"></i></span>`;
}
return html;
}, '');
}
function renderDigestAvatar(block) {
if (block.teaser) {
if (block.teaser.user.picture) {
return '<img style="vertical-align: middle; width: 32px; height: 32px; border-radius: 50%;" src="' + block.teaser.user.picture + '" title="' + block.teaser.user.username + '" />';
}
return '<div style="vertical-align: middle; width: 32px; height: 32px; line-height: 32px; font-size: 16px; background-color: ' + block.teaser.user['icon:bgColor'] + '; color: white; text-align: center; display: inline-block; border-radius: 50%;">' + block.teaser.user['icon:text'] + '</div>';
}
if (block.user.picture) {
return '<img style="vertical-align: middle; width: 32px; height: 32px; border-radius: 50%;" src="' + block.user.picture + '" title="' + block.user.username + '" />';
}
return '<div style="vertical-align: middle; width: 32px; height: 32px; line-height: 32px; font-size: 16px; background-color: ' + block.user['icon:bgColor'] + '; color: white; text-align: center; display: inline-block; border-radius: 50%;">' + block.user['icon:text'] + '</div>';
}
function userAgentIcons(data) {
let icons = '';
switch (data.platform) {
case 'Linux':
icons += '<i class="fa fa-fw fa-linux"></i>';
break;
case 'Microsoft Windows':
icons += '<i class="fa fa-fw fa-windows"></i>';
break;
case 'Apple Mac':
icons += '<i class="fa fa-fw fa-apple"></i>';
break;
case 'Android':
icons += '<i class="fa fa-fw fa-android"></i>';
break;
case 'iPad':
icons += '<i class="fa fa-fw fa-tablet"></i>';
break;
case 'iPod': // intentional fall-through
case 'iPhone':
icons += '<i class="fa fa-fw fa-mobile"></i>';
break;
default:
icons += '<i class="fa fa-fw fa-question-circle"></i>';
break;
}
switch (data.browser) {
case 'Chrome':
icons += '<i class="fa fa-fw fa-chrome"></i>';
break;
case 'Firefox':
icons += '<i class="fa fa-fw fa-firefox"></i>';
break;
case 'Safari':
icons += '<i class="fa fa-fw fa-safari"></i>';
break;
case 'IE':
icons += '<i class="fa fa-fw fa-internet-explorer"></i>';
break;
case 'Edge':
icons += '<i class="fa fa-fw fa-edge"></i>';
break;
default:
icons += '<i class="fa fa-fw fa-question-circle"></i>';
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 '<img ' + attributes.join(' ') + ' src="' + userObj.picture + '" style="' + styles.join(' ') + '" />';
}
styles.push('background-color: ' + userObj['icon:bgColor'] + ';');
return '<span ' + attributes.join(' ') + ' style="' + styles.join(' ') + '">' + userObj['icon:text'] + '</span>';
}
function register() {
Object.keys(helpers).forEach(function (helperName) {
Benchpress.registerHelper(helperName, helpers[helperName]);
});
}
return helpers;
};

View File

@@ -1,377 +1,7 @@
'use strict'; 'use strict';
(function (factory) { const factory = require('./helpers.common');
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 helpers = { define('helpers', ['utils', 'benchpressjs'], function (utils, Benchpressjs) {
displayMenuItem, return factory(utils, Benchpressjs, config.relative_path);
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 '<meta ' + name + property + content + '/>\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 '<link ' + link + rel + as + type + sizes + title + href + crossorigin + '/>\n\t';
}
function stringify(obj) {
// Turns the incoming object into a JSON string
return JSON.stringify(obj).replace(/&/gm, '&amp;').replace(/</gm, '&lt;').replace(/>/gm, '&gt;')
.replace(/"/g, '&quot;');
}
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 += '<span class="category-children-item pull-left">' +
'<div role="presentation" class="icon pull-left" style="' + generateCategoryBackground(child) + '">' +
'<i class="fa fa-fw ' + child.icon + '"></i>' +
'</div>' +
'<a href="' + link + '"><small>' + child.name + '</small></a></span>';
}
});
html = html ? ('<span class="category-children">' + html + '</span>') : 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 '<button class="btn btn-danger" data-action="leave" data-group="' + groupObj.displayName + '"' + (groupObj.disableLeave ? ' disabled' : '') + '><i class="fa fa-times"></i> [[groups:membership.leave-group]]</button>';
}
if (groupObj.isPending && groupObj.name !== 'administrators') {
return '<button class="btn btn-warning disabled"><i class="fa fa-clock-o"></i> [[groups:membership.invitation-pending]]</button>';
} else if (groupObj.isInvited) {
return '<button class="btn btn-link" data-action="rejectInvite" data-group="' + groupObj.displayName + '">[[groups:membership.reject]]</button><button class="btn btn-success" data-action="acceptInvite" data-group="' + groupObj.name + '"><i class="fa fa-plus"></i> [[groups:membership.accept-invitation]]</button>';
} else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') {
return '<button class="btn btn-success" data-action="join" data-group="' + groupObj.displayName + '"><i class="fa fa-plus"></i> [[groups:membership.join-group]]</button>';
}
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 '<td class="text-center" data-privilege="' + priv.name + '" data-value="' + priv.state + '"><input autocomplete="off" type="checkbox"' + (priv.state ? ' checked' : '') + (disabled ? ' disabled="disabled"' : '') + ' /></td>';
}).join('');
}
function localeToHTML(locale, fallback) {
locale = locale || fallback || 'en-GB';
return locale.replace('_', '-');
}
function renderTopicImage(topicObj) {
if (topicObj.thumb) {
return '<img src="' + topicObj.thumb + '" class="img-circle user-img" title="' + topicObj.user.username + '" />';
}
return '<img component="user/picture" data-uid="' + topicObj.user.uid + '" src="' + topicObj.user.picture + '" class="user-img" title="' + topicObj.user.username + '" />';
}
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 += `<li component="topic/event" class="timeline-event" data-topic-event-id="${event.id}">
<div class="timeline-badge">
<i class="fa ${event.icon || 'fa-circle'}"></i>
</div>
<span class="timeline-text">
${event.href ? `<a href="${relative_path}${event.href}">${event.text}</a>` : event.text}&nbsp;
</span>
`;
if (event.user) {
if (!event.user.system) {
html += `<span><a href="${relative_path}/user/${event.user.userslug}">${buildAvatar(event.user, 'xs', true)}&nbsp;${event.user.username}</a></span>&nbsp;`;
} else {
html += `<span class="timeline-text">[[global:system-user]]</span>&nbsp;`;
}
}
html += `<span class="timeago timeline-text" title="${event.timestampISO}"></span>`;
if (this.privileges.isAdminOrMod) {
html += `&nbsp;<span component="topic/event/delete" data-topic-event-id="${event.id}" class="timeline-text pointer" title="[[topic:delete-event]]"><i class="fa fa-trash"></i></span>`;
}
return html;
}, '');
}
function renderDigestAvatar(block) {
if (block.teaser) {
if (block.teaser.user.picture) {
return '<img style="vertical-align: middle; width: 32px; height: 32px; border-radius: 50%;" src="' + block.teaser.user.picture + '" title="' + block.teaser.user.username + '" />';
}
return '<div style="vertical-align: middle; width: 32px; height: 32px; line-height: 32px; font-size: 16px; background-color: ' + block.teaser.user['icon:bgColor'] + '; color: white; text-align: center; display: inline-block; border-radius: 50%;">' + block.teaser.user['icon:text'] + '</div>';
}
if (block.user.picture) {
return '<img style="vertical-align: middle; width: 32px; height: 32px; border-radius: 50%;" src="' + block.user.picture + '" title="' + block.user.username + '" />';
}
return '<div style="vertical-align: middle; width: 32px; height: 32px; line-height: 32px; font-size: 16px; background-color: ' + block.user['icon:bgColor'] + '; color: white; text-align: center; display: inline-block; border-radius: 50%;">' + block.user['icon:text'] + '</div>';
}
function userAgentIcons(data) {
let icons = '';
switch (data.platform) {
case 'Linux':
icons += '<i class="fa fa-fw fa-linux"></i>';
break;
case 'Microsoft Windows':
icons += '<i class="fa fa-fw fa-windows"></i>';
break;
case 'Apple Mac':
icons += '<i class="fa fa-fw fa-apple"></i>';
break;
case 'Android':
icons += '<i class="fa fa-fw fa-android"></i>';
break;
case 'iPad':
icons += '<i class="fa fa-fw fa-tablet"></i>';
break;
case 'iPod': // intentional fall-through
case 'iPhone':
icons += '<i class="fa fa-fw fa-mobile"></i>';
break;
default:
icons += '<i class="fa fa-fw fa-question-circle"></i>';
break;
}
switch (data.browser) {
case 'Chrome':
icons += '<i class="fa fa-fw fa-chrome"></i>';
break;
case 'Firefox':
icons += '<i class="fa fa-fw fa-firefox"></i>';
break;
case 'Safari':
icons += '<i class="fa fa-fw fa-safari"></i>';
break;
case 'IE':
icons += '<i class="fa fa-fw fa-internet-explorer"></i>';
break;
case 'Edge':
icons += '<i class="fa fa-fw fa-edge"></i>';
break;
default:
icons += '<i class="fa fa-fw fa-question-circle"></i>';
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 '<img ' + attributes.join(' ') + ' src="' + userObj.picture + '" style="' + styles.join(' ') + '" />';
}
styles.push('background-color: ' + userObj['icon:bgColor'] + ';');
return '<span ' + attributes.join(' ') + ' style="' + styles.join(' ') + '">' + userObj['icon:text'] + '</span>';
}
function register() {
Object.keys(helpers).forEach(function (helperName) {
Benchpress.registerHelper(helperName, helpers[helperName]);
});
}
return helpers;
}));

View File

@@ -1,6 +1,6 @@
'use strict'; 'use strict';
define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { define('pictureCropper', ['alerts'], function (alerts) {
const module = {}; const module = {};
module.show = function (data, callback) { module.show = function (data, callback) {
@@ -36,7 +36,7 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) {
$('#crop-picture-modal').remove(); $('#crop-picture-modal').remove();
app.parseAndTranslate('modals/crop_picture', { app.parseAndTranslate('modals/crop_picture', {
url: utils.escapeHTML(data.url), url: utils.escapeHTML(data.url),
}, function (cropperModal) { }, async function (cropperModal) {
cropperModal.modal({ cropperModal.modal({
backdrop: 'static', backdrop: 'static',
}).modal('show'); }).modal('show');
@@ -45,6 +45,7 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) {
const cropBoxHeight = parseInt($(window).height() / 2, 10); const cropBoxHeight = parseInt($(window).height() / 2, 10);
const img = document.getElementById('cropped-image'); const img = document.getElementById('cropped-image');
$(img).css('max-height', cropBoxHeight); $(img).css('max-height', cropBoxHeight);
const Cropper = await import(/* webpackChunkName: "cropperjs" */ 'cropperjs');
let cropperTool = new Cropper(img, { let cropperTool = new Cropper(img, {
aspectRatio: data.aspectRatio, aspectRatio: data.aspectRatio,

View File

@@ -2,17 +2,6 @@
define('settings', ['hooks', 'alerts'], function (hooks, alerts) { 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 // eslint-disable-next-line prefer-const
let Settings; let Settings;
let onReady = []; let onReady = [];
@@ -574,7 +563,16 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
helper.registerReadyJobs(1); 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) { for (let i = 0; i < arguments.length; i += 1) {
Settings.registerPlugin(arguments[i]); Settings.registerPlugin(arguments[i]);
} }

View File

@@ -4,7 +4,7 @@ define('settings/sorted-list', [
'benchpress', 'benchpress',
'bootbox', 'bootbox',
'hooks', 'hooks',
'jquery-ui/widgets/sortable', 'jquery-ui/ui/widgets/sortable',
], function (benchpress, bootbox, hooks) { ], function (benchpress, bootbox, hooks) {
let Settings; let Settings;

View File

@@ -14,10 +14,12 @@ define('taskbar', ['benchpress', 'translator', 'hooks'], function (Benchpress, t
self.taskbar.on('click', 'li', function () { self.taskbar.on('click', 'li', function () {
const $btn = $(this); const $btn = $(this);
const module = $btn.attr('data-module'); const moduleName = $btn.attr('data-module');
const uuid = $btn.attr('data-uuid'); 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')) { if (!$btn.hasClass('active')) {
minimizeAll(); minimizeAll();
module.load(uuid); module.load(uuid);

View File

@@ -1,7 +1,7 @@
'use strict'; 'use strict';
define('topicThumbs', [ 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) { ], function (api, bootbox, alerts, uploader, Benchpress, translator) {
const Thumbs = {}; const Thumbs = {};

View File

@@ -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<string>}
*/
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<string>} backup - Text to use in case the key can't be found
* @returns {Promise<string>}
*/
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, '&#37;').replace(/\\,/g, '&#44;');
// fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206
escaped = escaped.replace(/&amp;lsqb;/g, '&lsqb;')
.replace(/&amp;rsqb;/g, '&rsqb;');
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<void>}
*/
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, '&lsqb;&lsqb;').replace(/\]\]/g, '&rsqb;&rsqb;') : text;
};
/**
* Unescape escaped translator patterns in text
* @param {string} text
* @returns {string}
*/
Translator.unescape = function unescape(text) {
return typeof text === 'string' ?
text.replace(/&lsqb;/g, '[').replace(/\\\[/g, '[')
.replace(/&rsqb;/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, '&#37;').replace(/,/g, '&#44;');
});
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;
};

View File

@@ -1,6 +1,8 @@
'use strict'; 'use strict';
(function (factory) { const factory = require('./translator.common');
define('translator', ['jquery', 'utils'], function (jQuery, utils) {
function loadClient(language, namespace) { function loadClient(language, namespace) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
jQuery.getJSON([config.assetBaseUrl, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) { 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); }; const warn = function () { console.warn.apply(console, arguments); };
if (typeof define === 'function' && define.amd) { return factory(utils, loadClient, warn);
// 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<string>}
*/
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<string>} backup - Text to use in case the key can't be found
* @returns {Promise<string>}
*/
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, '&#37;').replace(/\\,/g, '&#44;');
// fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206
escaped = escaped.replace(/&amp;lsqb;/g, '&lsqb;')
.replace(/&amp;rsqb;/g, '&rsqb;');
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<void>}
*/
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, '&lsqb;&lsqb;').replace(/\]\]/g, '&rsqb;&rsqb;') : text;
};
/**
* Unescape escaped translator patterns in text
* @param {string} text
* @returns {string}
*/
Translator.unescape = function unescape(text) {
return typeof text === 'string' ?
text.replace(/&lsqb;/g, '[').replace(/\\\[/g, '[')
.replace(/&rsqb;/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, '&#37;').replace(/,/g, '&#44;');
});
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;
}));

View File

@@ -1,89 +1,89 @@
'use strict'; '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') { if (typeof window !== 'undefined') {
(function ($) { (function ($) {
require(['translator'], function (translator) { $.fn.getCursorPosition = function () {
$.fn.getCursorPosition = function () { const el = $(this).get(0);
const el = $(this).get(0); let pos = 0;
let pos = 0; if ('selectionStart' in el) {
if ('selectionStart' in el) { pos = el.selectionStart;
pos = el.selectionStart; } else if ('selection' in document) {
} else if ('selection' in document) { el.focus();
el.focus(); const Sel = document.selection.createRange();
const Sel = document.selection.createRange(); const SelLength = document.selection.createRange().text.length;
const SelLength = document.selection.createRange().text.length; Sel.moveStart('character', -el.value.length);
Sel.moveStart('character', -el.value.length); pos = Sel.text.length - SelLength;
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);
});
});
} }
}); 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: {} })); }(jQuery || { fn: {} }));
(function () { (function () {

View File

@@ -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',
},
});

View File

@@ -1,8 +1,9 @@
'use strict'; 'use strict';
import io from 'socket.io-client';
import $ from 'jquery';
app = window.app || {}; app = window.app || {};
socket = window.socket;
(function () { (function () {
let reconnecting = false; let reconnecting = false;
@@ -14,7 +15,7 @@ socket = window.socket;
path: config.relative_path + '/socket.io', path: config.relative_path + '/socket.io',
}; };
socket = io(config.websocketAddress, ioParams); window.socket = io(config.websocketAddress, ioParams);
const oEmit = socket.emit; const oEmit = socket.emit;
socket.emit = function (event, data, callback) { socket.emit = function (event, data, callback) {

776
public/src/utils.common.js Normal file
View File

@@ -0,0 +1,776 @@
'use strict';
// add default escape function for escaping HTML entities
const escapeCharMap = Object.freeze({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;',
'=': '&#x3D;',
});
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 = $('<div>');
$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;

View File

@@ -1,793 +1,58 @@
/* eslint-disable no-redeclare */
'use strict'; 'use strict';
(function (factory) { const $ = require('jquery');
if (typeof module === 'object' && module.exports) { const utils = require('./utils.common');
module.exports = factory();
process.profile = function (operation, start) { utils.getLanguage = function () {
console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); let lang = 'en-GB';
}; if (typeof window === 'object' && window.config && window.utils) {
lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB';
process.elapsedTimeSince = function (start) {
const diff = process.hrtime(start);
return (diff[0] * 1e3) + (diff[1] / 1e6);
};
} else {
window.utils = factory();
} }
// eslint-disable-next-line return lang;
}(function () { };
// add default escape function for escaping HTML entities
const escapeCharMap = Object.freeze({
'&': '&amp;', utils.makeNumbersHumanReadable = function (elements) {
'<': '&lt;', elements.each(function () {
'>': '&gt;', $(this)
'"': '&quot;', .html(utils.makeNumberHumanReadable($(this).attr('title')))
"'": '&#x27;', .removeClass('hidden');
'`': '&#x60;',
'=': '&#x3D;',
}); });
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 = $('<div>');
$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({ utils.isMobile = function () {
amp: '&', const env = utils.findBootstrapEnvironment();
gt: '>', return ['xs', 'sm'].some(function (targetEnv) {
lt: '<', return targetEnv === env;
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 = { module.exports = 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 = $('<div>');
$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;
}));

View File

@@ -1,55 +1,54 @@
'use strict'; 'use strict';
(function (ajaxify) { // import $ from 'jquery';
ajaxify.widgets = {};
ajaxify.widgets.render = function (template) { export default function render(template) {
if (template.match(/^admin/)) { 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; return;
} }
const locations = Object.keys(ajaxify.data.widgets); const widgetsAtLocation = ajaxify.data.widgets[location] || [];
let html = '';
locations.forEach(function (location) { widgetsAtLocation.forEach(function (widget) {
let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); html += widget.html;
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($('<div class="row"><div data-widget-area="footer" class="col-xs-12"></div></div>'));
} else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) {
if ($('[component="account/cover"]').length) {
$('[component="account/cover"]').nextAll().wrapAll($('<div class="row"><div class="col-lg-9 col-xs-12"></div><div data-widget-area="sidebar" class="col-lg-3 col-xs-12"></div></div></div>'));
} else if ($('[component="groups/cover"]').length) {
$('[component="groups/cover"]').nextAll().wrapAll($('<div class="row"><div class="col-lg-9 col-xs-12"></div><div data-widget-area="sidebar" class="col-lg-3 col-xs-12"></div></div></div>'));
} else {
$('#content > *').wrapAll($('<div class="row"><div class="col-lg-9 col-xs-12"></div><div data-widget-area="sidebar" class="col-lg-3 col-xs-12"></div></div></div>'));
}
} else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) {
$('#content').prepend($('<div class="row"><div data-widget-area="header" class="col-xs-12"></div></div>'));
}
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) { if (location === 'footer' && !$('#content [widget-area="footer"],#content [data-widget-area="footer"]').length) {
hooks.fire('action:widgets.loaded', {}); $('#content').append($('<div class="row"><div data-widget-area="footer" class="col-xs-12"></div></div>'));
}); } else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) {
}; if ($('[component="account/cover"]').length) {
}(ajaxify || {})); $('[component="account/cover"]').nextAll().wrapAll($('<div class="row"><div class="col-lg-9 col-xs-12"></div><div data-widget-area="sidebar" class="col-lg-3 col-xs-12"></div></div></div>'));
} else if ($('[component="groups/cover"]').length) {
$('[component="groups/cover"]').nextAll().wrapAll($('<div class="row"><div class="col-lg-9 col-xs-12"></div><div data-widget-area="sidebar" class="col-lg-3 col-xs-12"></div></div></div>'));
} else {
$('#content > *').wrapAll($('<div class="row"><div class="col-lg-9 col-xs-12"></div><div data-widget-area="sidebar" class="col-lg-3 col-xs-12"></div></div></div>'));
}
} else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) {
$('#content').prepend($('<div class="row"><div data-widget-area="header" class="col-xs-12"></div></div>'));
}
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', {});
});
};

View File

@@ -192,6 +192,7 @@ program
.command('build [targets...]') .command('build [targets...]')
.description(`Compile static assets ${chalk.red('(JS, CSS, templates, languages)')}`) .description(`Compile static assets ${chalk.red('(JS, CSS, templates, languages)')}`)
.option('-s, --series', 'Run builds in series without extra processes') .option('-s, --series', 'Run builds in series without extra processes')
.option('-w, --webpack', 'Bundle assets with webpack', true)
.action((targets, options) => { .action((targets, options) => {
if (program.opts().dev) { if (program.opts().dev) {
process.env.NODE_ENV = 'development'; process.env.NODE_ENV = 'development';

View File

@@ -11,7 +11,7 @@ const navigationAdmin = require('../../navigation/admin');
const social = require('../../social'); const social = require('../../social');
const helpers = require('../helpers'); const helpers = require('../helpers');
const translator = require('../../../public/src/modules/translator'); const translator = require('../../translator');
const settingsController = module.exports; const settingsController = module.exports;

View File

@@ -8,6 +8,7 @@ const path = require('path');
const mkdirp = require('mkdirp'); const mkdirp = require('mkdirp');
const chalk = require('chalk'); const chalk = require('chalk');
const db = require('../database');
const cacheBuster = require('./cacheBuster'); const cacheBuster = require('./cacheBuster');
const { aliases } = require('./aliases'); const { aliases } = require('./aliases');
@@ -176,6 +177,11 @@ exports.build = async function (targets, options) {
const startTime = Date.now(); const startTime = Date.now();
await buildTargets(targets, !series); await buildTargets(targets, !series);
if (options.webpack) {
await exports.webpack(options);
}
const totalTime = (Date.now() - startTime) / 1000; const totalTime = (Date.now() - startTime) / 1000;
await cacheBuster.write(); await cacheBuster.write();
winston.info(`[build] Asset compilation successful. Completed in ${totalTime}sec.`); 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 () { exports.buildAll = async function () {
await exports.build(allTargets); await exports.build(allTargets);
}; };

View File

@@ -14,106 +14,80 @@ const minifier = require('./minifier');
const JS = module.exports; const JS = module.exports;
JS.scripts = { JS.scripts = {
base: [ base: [
'node_modules/socket.io-client/dist/socket.io.js', // 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.min.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/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.js',
'node_modules/benchpressjs/build/benchpress.js',
'node_modules/jquery-serializeobject/jquery.serializeObject.js', 'node_modules/jquery-serializeobject/jquery.serializeObject.js',
'node_modules/jquery-deserialize/src/jquery.deserialize.js',
'public/vendor/bootbox/wrapper.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 // plugins add entries into this object,
rjs: [ // they get linked into /build/public/src/modules
'public/src/client/header.js', modules: { },
'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',
},
}; };
// 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) { async function linkIfLinux(srcPath, destPath) {
if (process.platform === 'win32') { if (process.platform === 'win32') {
await fs.promises.copyFile(srcPath, destPath); await fs.promises.copyFile(srcPath, destPath);
@@ -124,36 +98,14 @@ async function linkIfLinux(srcPath, destPath) {
const basePath = path.resolve(__dirname, '../..'); 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() { async function linkModules() {
const { modules } = JS.scripts; 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) => { await Promise.all(Object.keys(modules).map(async (relPath) => {
const srcPath = path.join(__dirname, '../../', modules[relPath]); const srcPath = path.join(__dirname, '../../', modules[relPath]);
const destPath = path.join(__dirname, '../../build/public/src/modules', relPath); const destPath = path.join(__dirname, '../../build/public/src/modules', relPath);
@@ -163,56 +115,14 @@ async function linkModules() {
]); ]);
if (stats.isDirectory()) { if (stats.isDirectory()) {
await file.linkDirs(srcPath, destPath, true); await file.linkDirs(srcPath, destPath, true);
return; } else {
await linkIfLinux(srcPath, destPath);
} }
await linkIfLinux(srcPath, destPath);
})); }));
} }
const moduleDirs = ['modules', 'admin', 'client']; 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() { async function clearModules() {
const builtPaths = moduleDirs.map( const builtPaths = moduleDirs.map(
p => path.join(__dirname, '../../build/public/src', p) 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(); await clearModules();
if (process.env.NODE_ENV === 'development') { await linkModules();
await linkModules();
return;
}
const modules = await getModuleList();
await minifyModules(modules, fork);
}; };
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 () { JS.linkStatics = async function () {
await rimrafAsync(path.join(__dirname, '../../build/public/plugins')); await rimrafAsync(path.join(__dirname, '../../build/public/plugins'));
@@ -313,15 +169,7 @@ async function getBundleScriptList(target) {
pluginScripts = pluginScripts.concat(scripts); pluginScripts = pluginScripts.concat(scripts);
})); }));
let scripts = JS.scripts.base; pluginScripts = JS.scripts.base.concat(pluginScripts).map((script) => {
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) => {
const srcPath = path.resolve(basePath, script).replace(/\\/g, '/'); const srcPath = path.resolve(basePath, script).replace(/\\/g, '/');
return { return {
srcPath: srcPath, srcPath: srcPath,
@@ -329,23 +177,16 @@ async function getBundleScriptList(target) {
}; };
}); });
return scripts; return pluginScripts;
} }
JS.buildBundle = async function (target, fork) { JS.buildBundle = async function (target, fork) {
const fileNames = { const fileNames = {
client: 'nodebb.min.js', client: 'client-scripts.min.js',
admin: 'acp.min.js', admin: 'acp-scripts.min.js',
}; };
await requirejsOptimize(target);
const files = await getBundleScriptList(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 minify = process.env.NODE_ENV !== 'development';
const filePath = path.join(__dirname, '../../build/public', fileNames[target]); const filePath = path.join(__dirname, '../../build/public', fileNames[target]);

View File

@@ -6,11 +6,21 @@ const nconf = require('nconf');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
module.exports = function (app) { module.exports = function (app, middleware) {
const router = express.Router(); const router = express.Router();
router.get('/test', async (req, res) => { // router.get('/test', async (req, res) => {
res.redirect(404); // 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 // Redoc

View File

@@ -169,6 +169,7 @@ function addCoreRoutes(app, router, middleware, mounts) {
app.use(middleware.privateUploads); app.use(middleware.privateUploads);
const statics = [ const statics = [
{ route: '/dist', path: path.join(__dirname, '../../dist') },
{ route: '/assets', path: path.join(__dirname, '../../build/public') }, { route: '/assets', path: path.join(__dirname, '../../build/public') },
{ route: '/assets', path: path.join(__dirname, '../../public') }, { route: '/assets', path: path.join(__dirname, '../../public') },
{ route: '/plugins', path: path.join(__dirname, '../../build/public/plugins') }, { route: '/plugins', path: path.join(__dirname, '../../build/public/plugins') },

View File

@@ -1,3 +1,12 @@
'use strict'; '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);

View File

@@ -1,3 +1,17 @@
'use strict'; '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;

View File

@@ -1,14 +1,15 @@
<script> <script>
window.addEventListener('load', function () { // TODO: figure out how to embed this, maybe import static tpl into partials/footer/js.tpl
define(config.relative_path + '/assets/templates/500.js', function () { // window.addEventListener('load', function () {
function compiled(helpers, context, get, iter, helper) { // define(config.relative_path + '/assets/templates/500.js', function () {
return '<div class="alert alert-danger">\n\t<strong>[[global:500.title]]</strong>\n\t<p>[[global:500.message]]</p>\n\t<p>' + // function compiled(helpers, context, get, iter, helper) {
helpers.__escape(get(context && context['path'])) + '</p>\n\t' + // return '<div class="alert alert-danger">\n\t<strong>[[global:500.title]]</strong>\n\t<p>[[global:500.message]]</p>\n\t<p>' +
(get(context && context['error']) ? '<p>' + helpers.__escape(get(context && context['error'])) + '</p>' : '') + '\n\n\t' + // helpers.__escape(get(context && context['path'])) + '</p>\n\t' +
(get(context && context['returnLink']) ? '\n\t<p>[[error:goback]]</p>\n\t' : '') + '\n</div>\n'; // (get(context && context['error']) ? '<p>' + helpers.__escape(get(context && context['error'])) + '</p>' : '') + '\n\n\t' +
} // (get(context && context['returnLink']) ? '\n\t<p>[[error:goback]]</p>\n\t' : '') + '\n</div>\n';
// }
return compiled; // return compiled;
}); // });
}); // });
</script> </script>

View File

@@ -1,4 +1,4 @@
<script defer src="{relative_path}/assets/nodebb.min.js?{config.cache-buster}"></script> <script defer src="{relative_path}/dist/app.bundle.js?{config.cache-buster}"></script>
{{{each scripts}}} {{{each scripts}}}
<script defer type="text/javascript" src="{scripts.src}"></script> <script defer type="text/javascript" src="{scripts.src}"></script>

59
src/views/test.tpl Normal file
View File

@@ -0,0 +1,59 @@
<label>Tags Input</label>
<input id="inputTags" class="form-control" value="" placeholder="">
<hr/>
<label>Birthday</label>
<input id="inputBirthday" class="form-control" value="" placeholder="mm/dd/yyyy">
<hr/>
<label>Auto complete</label>
<input id="autocomplete" class="form-control">
<hr/>
<label>Color Picker</label>
<input id="colorpicker" type="color" class="form-control">
<hr/>
<label>Timeago</label>
<div>
<label>Language is [[language:name]]</label>
<br/>
<span id="timeago" class="timeago" title="{now}"></span>
<button id="change-language" type="button" class="btn btn-primary">Change Language</button>
</div>
<hr/>
<label>Change Skin</label>
<select id="change-skin" class="form-control">
{{{each skins}}}
<option value="{skins.value}">{skins.name}</option>
{{{end}}}
</select>
<hr/>
<label>Sortable</label>
<div>
<ul id="sortable-list">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</div>
<hr/>
<label>Form Serialize</label>
<form id="form-serialize">
<input name="a" value="1">
<input name="a" value="2">
<input name="bar" value="test">
</form>
<pre id="json-form-data"></pre>
<hr/>
<label>Form Deserialize</label>
<form id="form-deserialize">
<input name="foo" value="">
<input name="foo" value="">
<input name="moo" value="">
</form>
<hr/>

View File

@@ -34,7 +34,7 @@ const topicEvents = require('./topics/events');
const routes = require('./routes'); const routes = require('./routes');
const auth = require('./routes/authentication'); 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')) { if (nconf.get('ssl')) {
server = require('https').createServer({ server = require('https').createServer({

View File

@@ -2,9 +2,10 @@
const nconf = require('nconf'); const nconf = require('nconf');
const assert = require('assert'); const assert = require('assert');
const benchpress = require('benchpressjs');
const db = require('./mocks/databasemock'); 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', () => { describe('helpers', () => {
it('should return false if item doesn\'t exist', (done) => { it('should return false if item doesn\'t exist', (done) => {

59
webpack.common.js Normal file
View File

@@ -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'),
},
},
};

9
webpack.dev.js Normal file
View File

@@ -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',
});

25
webpack.installer.js Normal file
View File

@@ -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',
],
},
};

8
webpack.prod.js Normal file
View File

@@ -0,0 +1,8 @@
'use strict';
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'production',
});