mirror of
https://github.com/espocrm/espocrm.git
synced 2026-03-03 00:27:01 +00:00
template bundle
This commit is contained in:
13
Gruntfile.js
13
Gruntfile.js
@@ -35,6 +35,7 @@ const path = require('path');
|
||||
const buildUtils = require('./js/build-utils');
|
||||
const BundlerGeneral = require("./js/bundler/bundler-general");
|
||||
const LayoutTypeBundler = require('./js/layout-template-bundler');
|
||||
const TemplateBundler = require('./js/template-bundler/template-bundler');
|
||||
const bundleConfig = require('./frontend/bundle-config.json');
|
||||
const libs = require('./frontend/libs.json');
|
||||
|
||||
@@ -293,6 +294,17 @@ module.exports = grunt => {
|
||||
}
|
||||
});
|
||||
|
||||
grunt.registerTask('bundle-templates', () => {
|
||||
let templateBundler = new TemplateBundler({
|
||||
dirs: [
|
||||
'client/res/templates',
|
||||
'client/modules/crm/res/templates',
|
||||
],
|
||||
});
|
||||
|
||||
templateBundler.process();
|
||||
});
|
||||
|
||||
grunt.registerTask('prepare-lib-original', () => {
|
||||
// Even though `npm ci` runs the same script, 'clean:start' deletes files.
|
||||
cp.execSync("node js/scripts/prepare-lib-original");
|
||||
@@ -482,6 +494,7 @@ module.exports = grunt => {
|
||||
'prepare-lib-original',
|
||||
'transpile',
|
||||
'bundle',
|
||||
'bundle-templates',
|
||||
'uglify:bundle',
|
||||
'copy:frontendLib',
|
||||
'prepare-lib',
|
||||
|
||||
@@ -192,6 +192,7 @@ class ClientManager
|
||||
'apiUrl' => 'api/v1',
|
||||
'applicationName' => $this->config->get('applicationName', 'EspoCRM'),
|
||||
'cacheTimestamp' => $cacheTimestamp,
|
||||
'appTimestamp' => $appTimestamp,
|
||||
'loaderCacheTimestamp' => Json::encode($loaderCacheTimestamp),
|
||||
'stylesheet' => $this->themeManager->getStylesheet(),
|
||||
'runScript' => $runScript,
|
||||
@@ -205,6 +206,7 @@ class ClientManager
|
||||
'faviconPath' => $faviconPath,
|
||||
'ajaxTimeout' => $this->config->get('ajaxTimeout') ?? 60000,
|
||||
'internalModuleList' => Json::encode($internalModuleList),
|
||||
'bundledModuleList' => Json::encode($this->getBundledModuleList()),
|
||||
'applicationDescription' => $this->config->get('applicationDescription') ?? self::APP_DESCRIPTION,
|
||||
'nonce' => $this->nonce,
|
||||
'loaderParams' => Json::encode([
|
||||
@@ -360,4 +362,24 @@ class ClientManager
|
||||
$modules
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getBundledModuleList(): array
|
||||
{
|
||||
if (!$this->isDeveloperMode()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$modules = array_values(array_filter(
|
||||
$this->module->getList(),
|
||||
fn ($item) => $this->module->get([$item, 'bundled'])
|
||||
));
|
||||
|
||||
return array_map(
|
||||
fn ($item) => Util::fromCamelCase($item, '-'),
|
||||
$modules
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,16 @@ class App {
|
||||
*/
|
||||
this.internalModuleList = options.internalModuleList || [];
|
||||
|
||||
/**
|
||||
* A list of bundled modules.
|
||||
*
|
||||
* @private
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.bundledModuleList = options.bundledModuleList || [];
|
||||
|
||||
this.appTimestamp = options.appTimestamp;
|
||||
|
||||
this.initCache(options)
|
||||
.then(() => this.init(options, callback));
|
||||
|
||||
@@ -292,8 +302,10 @@ class App {
|
||||
webSocketManager = null
|
||||
|
||||
/**
|
||||
* An application timestamp. Used for asset cache busting and update detection.
|
||||
*
|
||||
* @private
|
||||
* @type {?int}
|
||||
* @type {Number|null}
|
||||
*/
|
||||
appTimestamp = null
|
||||
|
||||
@@ -305,54 +317,46 @@ class App {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {module:app~Options} options
|
||||
* @return Promise
|
||||
*/
|
||||
initCache(options) {
|
||||
let cacheTimestamp = options.cacheTimestamp || null;
|
||||
let storedCacheTimestamp = null;
|
||||
|
||||
if (this.useCache) {
|
||||
this.cache = new Cache(cacheTimestamp);
|
||||
|
||||
storedCacheTimestamp = this.cache.getCacheTimestamp();
|
||||
|
||||
if (cacheTimestamp) {
|
||||
this.cache.handleActuality(cacheTimestamp);
|
||||
}
|
||||
else {
|
||||
this.cache.storeTimestamp();
|
||||
}
|
||||
if (!this.useCache) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let handleActuality = () => {
|
||||
if (
|
||||
!cacheTimestamp ||
|
||||
!storedCacheTimestamp ||
|
||||
cacheTimestamp !== storedCacheTimestamp
|
||||
) {
|
||||
return caches.delete('espo');
|
||||
}
|
||||
let cacheTimestamp = options.cacheTimestamp || null;
|
||||
|
||||
return new Promise(resolve => resolve());
|
||||
};
|
||||
this.cache = new Cache(cacheTimestamp);
|
||||
|
||||
let storedCacheTimestamp = this.cache.getCacheTimestamp();
|
||||
|
||||
cacheTimestamp ?
|
||||
this.cache.handleActuality(cacheTimestamp) :
|
||||
this.cache.storeTimestamp();
|
||||
|
||||
if (!window.caches) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
if (!this.useCache) {
|
||||
resolve();
|
||||
}
|
||||
let deleteCache = !cacheTimestamp ||
|
||||
!storedCacheTimestamp ||
|
||||
cacheTimestamp !== storedCacheTimestamp;
|
||||
|
||||
if (!window.caches) {
|
||||
resolve();
|
||||
}
|
||||
|
||||
handleActuality()
|
||||
(
|
||||
deleteCache ?
|
||||
caches.delete('espo') :
|
||||
Promise.resolve()
|
||||
)
|
||||
.then(() => caches.open('espo'))
|
||||
.then(responseCache => {
|
||||
this.responseCache = responseCache;
|
||||
.then(cache => {
|
||||
this.responseCache = cache;
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("Could not open `espo` cache.");
|
||||
console.error(`Could not open 'espo' cache.`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -360,6 +364,8 @@ class App {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {module:app~Options} options
|
||||
* @param {function} [callback]
|
||||
*/
|
||||
init(options, callback) {
|
||||
/** @type {Object.<string, *>} */
|
||||
@@ -393,7 +399,8 @@ class App {
|
||||
Promise
|
||||
.all([
|
||||
this.settings.load(),
|
||||
this.language.loadDefault()
|
||||
this.language.loadDefault(),
|
||||
this.initTemplateBundles(),
|
||||
])
|
||||
.then(() => {
|
||||
this.loader.setIsDeveloperMode(this.settings.get('isDeveloperMode'));
|
||||
@@ -412,8 +419,6 @@ class App {
|
||||
this.modelFactory = new ModelFactory(this.metadata);
|
||||
this.collectionFactory = new CollectionFactory(this.modelFactory, this.settings, this.metadata);
|
||||
|
||||
this.appTimestamp = this.settings.get('appTimestamp') || null;
|
||||
|
||||
if (this.settings.get('useWebSocket')) {
|
||||
this.webSocketManager = new WebSocketManager(this.settings);
|
||||
}
|
||||
@@ -1349,6 +1354,9 @@ class App {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
initDomEventListeners() {
|
||||
$(document).on('keydown.espo.button', e => {
|
||||
if (
|
||||
@@ -1368,11 +1376,94 @@ class App {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {Promise}
|
||||
*/
|
||||
initTemplateBundles() {
|
||||
if (!this.responseCache) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const key = 'templateBundlesCached';
|
||||
|
||||
if (this.cache.get('app', key)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let files = ['client/lib/templates.tpl'];
|
||||
|
||||
this.bundledModuleList.forEach(mod => {
|
||||
let modPath = this.internalModuleList.includes(mod) ?
|
||||
`client/modules/${mod}/` :
|
||||
`client/custom/modules/${mod}/`;
|
||||
|
||||
files.push(modPath + 'templates.tpl');
|
||||
});
|
||||
|
||||
let baseUrl = window.location.origin + window.location.pathname;
|
||||
let timestamp = this.loader.getCacheTimestamp();
|
||||
|
||||
let promiseList = files.map(file => {
|
||||
let url = new URL(baseUrl + this.basePath + file);
|
||||
url.searchParams.append('t', this.appTimestamp);
|
||||
|
||||
return new Promise(resolve => {
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.error(`Could not fetch ${url}.`);
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let promiseList = [];
|
||||
|
||||
response.text().then(text => {
|
||||
let index = text.indexOf('\n');
|
||||
|
||||
if (index <= 0) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let delimiter = text.slice(0, index + 1);
|
||||
text = text.slice(index + 1);
|
||||
|
||||
text.split(delimiter).forEach(item => {
|
||||
let index = item.indexOf('\n');
|
||||
|
||||
let file = item.slice(0, index);
|
||||
let content = item.slice(index + 1);
|
||||
|
||||
let url = baseUrl + this.basePath + 'client/' + file;
|
||||
|
||||
let urlObj = new URL(url);
|
||||
urlObj.searchParams.append('r', timestamp);
|
||||
|
||||
promiseList.push(
|
||||
this.responseCache.put(urlObj, new Response(content))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promiseList).then(() => resolve());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promiseList)
|
||||
.then(() => {
|
||||
this.cache.set('app', key, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @callback module:app~callback
|
||||
*
|
||||
* @param {App} app A created application instance.
|
||||
*/
|
||||
|
||||
@@ -1380,7 +1471,6 @@ class App {
|
||||
* Application options.
|
||||
*
|
||||
* @typedef {Object} module:app~Options
|
||||
*
|
||||
* @property {string} [id] An application ID.
|
||||
* @property {string} [basePath] A base path.
|
||||
* @property {boolean} [useCache] Use cache.
|
||||
@@ -1388,7 +1478,9 @@ class App {
|
||||
* @property {Number} [ajaxTimeout] A default ajax request timeout.
|
||||
* @property {string} [internalModuleList] A list of internal modules.
|
||||
* Internal modules located in the `client/modules` directory.
|
||||
* @property {string} [bundledModuleList] A list of bundled modules.
|
||||
* @property {Number|null} [cacheTimestamp] A cache timestamp.
|
||||
* @property {Number|null} [appTimestamp] An application timestamp.
|
||||
*/
|
||||
|
||||
Object.assign(App.prototype, Events);
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
id: '{{applicationId}}',
|
||||
useCache: {{useCache}},
|
||||
cacheTimestamp: {{cacheTimestamp}},
|
||||
appTimestamp: {{appTimestamp}},
|
||||
basePath: '{{basePath}}',
|
||||
apiUrl: '{{apiUrl}}',
|
||||
ajaxTimeout: {{ajaxTimeout}},
|
||||
internalModuleList: {{internalModuleList}},
|
||||
bundledModuleList: {{bundledModuleList}},
|
||||
}, app => {
|
||||
{{runScript}}
|
||||
});
|
||||
|
||||
111
js/template-bundler/template-bundler.js
Normal file
111
js/template-bundler/template-bundler.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM - Open Source CRM application.
|
||||
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* EspoCRM is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* EspoCRM is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
const fs = require('fs');
|
||||
const {globSync} = require('glob');
|
||||
|
||||
class TemplateBundler {
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* dirs?: string[],
|
||||
* dest?: string,
|
||||
* clientDir?: string,
|
||||
* }} config
|
||||
*/
|
||||
constructor(config) {
|
||||
this.dirs = this.dirs ?? ['client/res/templates'];
|
||||
this.dest = config.dest ?? 'client/lib/templates.tpl';
|
||||
this.clientDir = config.clientDir ?? 'client';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
process() {
|
||||
/** @type {string[]} */
|
||||
let allFiles = [];
|
||||
|
||||
this.dirs.forEach(dir => {
|
||||
let files = globSync(dir + '/**/*.tpl')
|
||||
.map(file => file.replaceAll('\\', '/'));
|
||||
|
||||
allFiles.push(...files);
|
||||
});
|
||||
|
||||
let contents = [];
|
||||
|
||||
allFiles.forEach(file => {
|
||||
let content = fs.readFileSync(file, 'utf-8');
|
||||
|
||||
if (file.indexOf(this.clientDir) !== 0) {
|
||||
throw new Error(`File ${file} not in the client dir.`);
|
||||
}
|
||||
|
||||
let path = file.slice(this.clientDir.length + 1);
|
||||
|
||||
content = path + '\n' + content;
|
||||
|
||||
contents.push(content);
|
||||
});
|
||||
|
||||
let delimiter = this.#generateDelimiter(contents);
|
||||
|
||||
let result = delimiter + '\n' + contents.join('\n' + delimiter + '\n');
|
||||
|
||||
fs.writeFileSync(this.dest, result, 'utf8');
|
||||
|
||||
console.log(` ${contents.length} templates bundled in ${this.dest}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} contents
|
||||
* @return {string}
|
||||
*/
|
||||
#generateDelimiter(contents) {
|
||||
let delimiter = '_delimiter_' + Math.random().toString(36).slice(2);
|
||||
|
||||
let retry = false;
|
||||
|
||||
for (let content of contents) {
|
||||
if (content.includes(delimiter)) {
|
||||
retry = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (retry) {
|
||||
return this.#generateDelimiter(contents);
|
||||
}
|
||||
|
||||
return delimiter;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TemplateBundler;
|
||||
@@ -12,6 +12,10 @@
|
||||
"jsTranspiled": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates that JS files in the module are transpiled."
|
||||
},
|
||||
"bundled": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates that frontend files in the module are bundled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user