template bundle

This commit is contained in:
Yuri Kuznetsov
2023-06-19 15:03:39 +03:00
parent a79892028a
commit 56f975c65e
6 changed files with 285 additions and 41 deletions

View File

@@ -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',

View File

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

View File

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

View File

@@ -20,10 +20,12 @@
id: '{{applicationId}}',
useCache: {{useCache}},
cacheTimestamp: {{cacheTimestamp}},
appTimestamp: {{appTimestamp}},
basePath: '{{basePath}}',
apiUrl: '{{apiUrl}}',
ajaxTimeout: {{ajaxTimeout}},
internalModuleList: {{internalModuleList}},
bundledModuleList: {{bundledModuleList}},
}, app => {
{{runScript}}
});

View 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;

View File

@@ -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."
}
}
}