diff --git a/package.json b/package.json
index f44d51a6..a2531924 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"@rollup/plugin-multi-entry": "^3.0.1",
"@rollup/plugin-node-resolve": "^8.4.0",
"cheerio": "^1.0.0-rc.3",
+ "commander": "^6.0.0",
"core-js": "^3.6.5",
"finalhandler": "^1.1.1",
"impunity": "^0.0.11",
@@ -37,8 +38,10 @@
"postcss-css-variables": "^0.17.0",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-import": "^12.0.1",
+ "postcss-url": "^8.0.0",
"regenerator-runtime": "^0.13.7",
"rollup": "^1.15.6",
- "serve-static": "^1.13.2"
+ "serve-static": "^1.13.2",
+ "xxhash": "^0.3.0"
}
}
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 611738cb..5ddcbafa 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -19,11 +19,13 @@ import cheerio from "cheerio";
import fsRoot from "fs";
const fs = fsRoot.promises;
import path from "path";
+import XXHash from 'xxhash';
import rollup from 'rollup';
import postcss from "postcss";
import postcssImport from "postcss-import";
import { fileURLToPath } from 'url';
import { dirname } from 'path';
+import commander from "commander";
// needed for legacy bundle
import babel from '@rollup/plugin-babel';
// needed to find the polyfill modules in the main-legacy.js bundle
@@ -32,6 +34,8 @@ import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
// multi-entry plugin so we can add polyfill file to main
import multi from '@rollup/plugin-multi-entry';
+// replace urls of asset names with content hashed version
+import postcssUrl from "postcss-url";
import cssvariables from "postcss-css-variables";
import flexbugsFixes from "postcss-flexbugs-fixes";
@@ -43,32 +47,20 @@ const PROJECT_NAME = "Hydrogen Chat";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectDir = path.join(__dirname, "../");
-const cssDir = path.join(projectDir, "src/ui/web/css/");
-const targetDir = path.join(projectDir, "target");
+const cssSrcDir = path.join(projectDir, "src/ui/web/css/");
+const targetDir = path.join(projectDir, "target/");
-const {debug, noOffline, legacy} = process.argv.reduce((params, param) => {
- if (param.startsWith("--")) {
- params[param.substr(2)] = true;
- }
- return params;
-}, {
- debug: false,
- noOffline: false,
- legacy: false
-});
+const program = new commander.Command();
+program
+ .option("--legacy", "make a build for IE11")
+ .option("--no-offline", "make a build without a service worker or appcache manifest")
+program.parse(process.argv);
+const {debug, noOffline, legacy} = program;
const offline = !noOffline;
async function build() {
// get version number
const version = JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).version;
- // clear target dir
- await removeDirIfExists(targetDir);
- await fs.mkdir(targetDir);
- let bundleName = `${PROJECT_ID}.js`;
- if (legacy) {
- bundleName = `${PROJECT_ID}-legacy.js`;
- }
-
const devHtml = await fs.readFile(path.join(projectDir, "index.html"), "utf8");
const doc = cheerio.load(devHtml);
@@ -76,25 +68,41 @@ async function build() {
findThemes(doc, themeName => {
themes.push(themeName);
});
-
+ // clear target dir
+ await removeDirIfExists(targetDir);
+ await createDirs(targetDir, themes);
// also creates the directories where the theme css bundles are placed in,
// so do it first
const themeAssets = await copyThemeAssets(themes, legacy);
+ const jsBundlePath = await (legacy ? buildJsLegacy() : buildJs());
+ const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets);
+ const assetPaths = createAssetPaths(jsBundlePath, cssBundlePaths, themeAssets);
- await buildHtml(doc, version, bundleName);
- if (legacy) {
- await buildJsLegacy(bundleName);
- } else {
- await buildJs(bundleName);
- }
- await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes);
+ let manifestPath;
if (offline) {
- await buildOffline(version, bundleName, themeAssets);
+ manifestPath = await buildOffline(version, assetPaths);
}
+ await buildHtml(doc, version, assetPaths, manifestPath);
console.log(`built ${PROJECT_ID}${legacy ? " legacy" : ""} ${version} successfully`);
}
+function createAssetPaths(jsBundlePath, cssBundlePaths, themeAssets) {
+ function trim(path) {
+ if (!path.startsWith(targetDir)) {
+ throw new Error("invalid target path: " + targetDir);
+ }
+ return path.substr(targetDir.length);
+ }
+ return {
+ jsBundle: () => trim(jsBundlePath),
+ cssMainBundle: () => trim(cssBundlePaths.main),
+ cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]),
+ cssThemeBundles: () => Object.values(cssBundlePaths.themes).map(a => trim(a)),
+ otherAssets: () => Object.values(themeAssets).map(a => trim(a))
+ };
+}
+
async function findThemes(doc, callback) {
doc("link[rel~=stylesheet][title]").each((i, el) => {
const theme = doc(el);
@@ -110,37 +118,39 @@ async function findThemes(doc, callback) {
});
}
-async function copyThemeAssets(themes, legacy) {
- const assets = [];
- // create theme directories and copy assets
- await fs.mkdir(path.join(targetDir, "themes"));
+async function createDirs(targetDir, themes) {
+ await fs.mkdir(targetDir);
+ const themeDir = path.join(targetDir, "themes");
+ await fs.mkdir(themeDir);
+ for (const theme of themes) {
+ await fs.mkdir(path.join(themeDir, theme));
+ }
+}
+
+async function copyThemeAssets(themes, legacy) {
+ const assets = {};
for (const theme of themes) {
- assets.push(`themes/${theme}/bundle.css`);
const themeDstFolder = path.join(targetDir, `themes/${theme}`);
- await fs.mkdir(themeDstFolder);
- const themeSrcFolder = path.join(cssDir, `themes/${theme}`);
- await copyFolder(themeSrcFolder, themeDstFolder, file => {
+ const themeSrcFolder = path.join(cssSrcDir, `themes/${theme}`);
+ const themeAssets = await copyFolder(themeSrcFolder, themeDstFolder, file => {
const isUnneededFont = legacy ? file.endsWith(".woff2") : file.endsWith(".woff");
- if (!file.endsWith(".css") && !isUnneededFont) {
- assets.push(file.substr(cssDir.length));
- return true;
- }
- return false;
+ return !file.endsWith(".css") && !isUnneededFont;
});
+ Object.assign(assets, themeAssets);
}
return assets;
}
-async function buildHtml(doc, version, bundleName) {
+async function buildHtml(doc, version, assetPaths, manifestPath) {
// transform html file
// change path to main.css to css bundle
- doc("link[rel=stylesheet]:not([title])").attr("href", `${PROJECT_ID}.css`);
+ doc("link[rel=stylesheet]:not([title])").attr("href", assetPaths.cssMainBundle());
// change paths to all theme stylesheets
findThemes(doc, (themeName, theme) => {
- theme.attr("href", `themes/${themeName}/bundle.css`);
+ theme.attr("href", assetPaths.cssThemeBundle(themeName));
});
doc("script#main").replaceWith(
- `` +
+ `` +
``);
removeOrEnableScript(doc("script#service-worker"), offline);
@@ -152,22 +162,25 @@ async function buildHtml(doc, version, bundleName) {
if (offline) {
doc("html").attr("manifest", "manifest.appcache");
- doc("head").append(``);
+ doc("head").append(``);
}
await fs.writeFile(path.join(targetDir, "index.html"), doc.html(), "utf8");
}
-async function buildJs(bundleName) {
+async function buildJs() {
// create js bundle
const bundle = await rollup.rollup({input: 'src/main.js'});
- await bundle.write({
- file: path.join(targetDir, bundleName),
+ const {output} = await bundle.generate({
format: 'iife',
name: `${PROJECT_ID}Bundle`
});
+ const code = output[0].code;
+ const bundlePath = resource(`${PROJECT_ID}.js`, code);
+ await fs.writeFile(bundlePath, code, "utf8");
+ return bundlePath;
}
-async function buildJsLegacy(bundleName) {
+async function buildJsLegacy() {
// compile down to whatever IE 11 needs
const babelPlugin = babel.babel({
babelHelpers: 'bundled',
@@ -189,24 +202,24 @@ async function buildJsLegacy(bundleName) {
plugins: [multi(), commonjs(), nodeResolve(), babelPlugin]
};
const bundle = await rollup.rollup(rollupConfig);
- await bundle.write({
- file: path.join(targetDir, bundleName),
+ const {output} = await bundle.generate({
format: 'iife',
name: `${PROJECT_ID}Bundle`
});
+ const code = output[0].code;
+ const bundlePath = resource(`${PROJECT_ID}-legacy.js`, code);
+ await fs.writeFile(bundlePath, code, "utf8");
+ return bundlePath;
}
-async function buildOffline(version, bundleName, themeAssets) {
- const {offlineAssets, cacheAssets} = themeAssets.reduce((result, asset) => {
- if (asset.endsWith(".css")) {
- result.offlineAssets.push(asset);
- } else {
- result.cacheAssets.push(asset);
- }
- return result;
- }, {offlineAssets: [], cacheAssets: []});
+async function buildOffline(version, assetPaths) {
// write offline availability
- const offlineFiles = [bundleName, `${PROJECT_ID}.css`, "index.html", "icon-192.png"].concat(offlineAssets);
+ const offlineFiles = [
+ assetPaths.jsBundle(),
+ assetPaths.cssMainBundle(),
+ "index.html",
+ "icon-192.png",
+ ].concat(assetPaths.cssThemeBundles());
// write appcache manifest
const manifestLines = [
@@ -223,7 +236,7 @@ async function buildOffline(version, bundleName, themeAssets) {
let swSource = await fs.readFile(path.join(projectDir, "src/service-worker.template.js"), "utf8");
swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`);
swSource = swSource.replace(`"%%OFFLINE_FILES%%"`, JSON.stringify(offlineFiles));
- swSource = swSource.replace(`"%%CACHE_FILES%%"`, JSON.stringify(cacheAssets));
+ swSource = swSource.replace(`"%%CACHE_FILES%%"`, JSON.stringify(assetPaths.otherAssets()));
await fs.writeFile(path.join(targetDir, "sw.js"), swSource, "utf8");
// write web manifest
const webManifest = {
@@ -233,35 +246,61 @@ async function buildOffline(version, bundleName, themeAssets) {
start_url: "index.html",
icons: [{"src": "icon-192.png", "sizes": "192x192", "type": "image/png"}],
};
- await fs.writeFile(path.join(targetDir, "manifest.json"), JSON.stringify(webManifest), "utf8");
+ const manifestJson = JSON.stringify(webManifest);
+ const manifestPath = resource("manifest.json", manifestJson);
+ await fs.writeFile(manifestPath, manifestJson, "utf8");
// copy icon
+ // should this icon have a content hash as well?
let icon = await fs.readFile(path.join(projectDir, "icon.png"));
await fs.writeFile(path.join(targetDir, "icon-192.png"), icon);
+ return manifestPath;
}
-async function buildCssBundles(buildFn, themes) {
- const cssMainFile = path.join(cssDir, "main.css");
- await buildFn(cssMainFile, path.join(targetDir, `${PROJECT_ID}.css`));
+async function buildCssBundles(buildFn, themes, themeAssets) {
+ const bundleCss = await buildFn(path.join(cssSrcDir, "main.css"));
+ const mainDstPath = resource(`${PROJECT_ID}.css`, bundleCss);
+ await fs.writeFile(mainDstPath, bundleCss, "utf8");
+ const bundlePaths = {main: mainDstPath, themes: {}};
for (const theme of themes) {
- await buildFn(
- path.join(cssDir, `themes/${theme}/theme.css`),
- path.join(targetDir, `themes/${theme}/bundle.css`)
- );
+ const urlBase = path.join(targetDir, `themes/${theme}/`);
+ const assetUrlMapper = ({absolutePath}) => {
+ const hashedDstPath = themeAssets[absolutePath];
+ if (hashedDstPath && hashedDstPath.startsWith(urlBase)) {
+ return hashedDstPath.substr(urlBase.length);
+ }
+ };
+ const themeCss = await buildFn(path.join(cssSrcDir, `themes/${theme}/theme.css`), assetUrlMapper);
+ const themeDstPath = resource(`themes/${theme}/bundle.css`, themeCss);
+ await fs.writeFile(themeDstPath, themeCss, "utf8");
+ bundlePaths.themes[theme] = themeDstPath;
}
+ return bundlePaths;
}
-async function buildCss(entryPath, bundlePath) {
+async function buildCss(entryPath, urlMapper = null) {
const preCss = await fs.readFile(entryPath, "utf8");
- const cssBundler = postcss([postcssImport]);
+ const options = [postcssImport];
+ if (urlMapper) {
+ options.push(postcssUrl({url: urlMapper}));
+ }
+ const cssBundler = postcss(options);
const result = await cssBundler.process(preCss, {from: entryPath});
- await fs.writeFile(bundlePath, result.css, "utf8");
+ return result.css;
}
-async function buildCssLegacy(entryPath, bundlePath) {
+async function buildCssLegacy(entryPath, urlMapper = null) {
const preCss = await fs.readFile(entryPath, "utf8");
- const cssBundler = postcss([postcssImport, cssvariables(), flexbugsFixes()]);
+ const options = [
+ postcssImport,
+ cssvariables(),
+ flexbugsFixes()
+ ];
+ if (urlMapper) {
+ options.push(postcssUrl({url: urlMapper}));
+ }
+ const cssBundler = postcss(options);
const result = await cssBundler.process(preCss, {from: entryPath});
- await fs.writeFile(bundlePath, result.css, "utf8");
+ return result.css;
}
function removeOrEnableScript(scriptNode, enable) {
@@ -283,17 +322,41 @@ async function removeDirIfExists(targetDir) {
}
async function copyFolder(srcRoot, dstRoot, filter) {
+ const assetPaths = {};
const dirEnts = await fs.readdir(srcRoot, {withFileTypes: true});
for (const dirEnt of dirEnts) {
const dstPath = path.join(dstRoot, dirEnt.name);
const srcPath = path.join(srcRoot, dirEnt.name);
if (dirEnt.isDirectory()) {
await fs.mkdir(dstPath);
- await copyFolder(srcPath, dstPath, filter);
+ Object.assign(assetPaths, await copyFolder(srcPath, dstPath, filter));
} else if (dirEnt.isFile() && filter(srcPath)) {
- await fs.copyFile(srcPath, dstPath);
+ const content = await fs.readFile(srcPath);
+ const hashedDstPath = resource(dstPath, content);
+ await fs.writeFile(hashedDstPath, content);
+ assetPaths[srcPath] = hashedDstPath;
}
}
+ return assetPaths;
}
+function resource(relPath, content) {
+ let fullPath = relPath;
+ if (!relPath.startsWith("/")) {
+ fullPath = path.join(targetDir, relPath);
+ }
+ const hash = contentHash(Buffer.from(content));
+ const dir = path.dirname(fullPath);
+ const extname = path.extname(fullPath);
+ const basename = path.basename(fullPath, extname);
+ return path.join(dir, `${basename}-${hash}${extname}`);
+}
+
+function contentHash(str) {
+ var hasher = new XXHash(0);
+ hasher.update(str);
+ return hasher.digest();
+}
+
+
build().catch(err => console.error(err));