import esbuild, { PluginBuild } from "esbuild"; import linaria from "@linaria/esbuild"; import fs from "fs"; import path from "path"; import postcss from "postcss"; import sass from "sass"; import postcssrc from "postcss-load-config"; // this should give us the current directory where // the project is being built const BASE = process.cwd(); type Assets = { base: string; files: string[]; }; export function getFilesInDirectory(startPath: string, regex?: RegExp): Assets { if (!fs.existsSync(startPath)) { return { base: startPath, files: [], }; } const files = fs.readdirSync(startPath); const result = files .flatMap((file) => { const filename = path.join(startPath, file); const stat = fs.lstatSync(filename); if (stat.isDirectory()) { return getFilesInDirectory(filename, regex).files; } if (!regex || regex.test(filename)) { return [filename]; } return []; }) .filter((x) => !!x); return { base: startPath, files: result, }; } let GIT_ROOT = BASE; while (!fs.existsSync(path.join(GIT_ROOT, ".git")) && GIT_ROOT !== "/") { GIT_ROOT = path.join(GIT_ROOT, "../"); } if (GIT_ROOT === "/") { // eslint-disable-next-line no-undef console.log("not found"); // eslint-disable-next-line no-undef process.exit(1); } const GIT_HASH = git_hash(); const buf = fs.readFileSync(path.join(BASE, "package.json")); let _package = JSON.parse(buf.toString("utf-8")); function git_hash() { const rev = fs .readFileSync(path.join(GIT_ROOT, ".git", "HEAD")) .toString() .trim() .split(/.*[: ]/) .slice(-1)[0]; if (rev.indexOf("/") === -1) { return rev; } else { return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim(); } } // FIXME: Put this into some helper library. function copyFilesPlugin(assets: Assets | Assets[]) { return { name: "copy-files", setup(build: PluginBuild) { if (!assets || (assets instanceof Array && !assets.length)) { return; } const list = assets instanceof Array ? assets : [assets]; const outDir = build.initialOptions.outdir; if (outDir === undefined) { throw Error("esbuild build options does not specify outdir"); } build.onEnd(() => { list.forEach((as) => { for (const file of as.files) { const destination = path.join(outDir, path.relative(as.base, file)); const dirname = path.dirname(destination); if (!fs.existsSync(dirname)) { fs.mkdirSync(dirname, { recursive: true }); } fs.copyFileSync(file, destination); } }); }); }, }; } const DEFAULT_SASS_FILTER = /\.(s[ac]ss|css)$/; const sassPlugin: esbuild.Plugin = { name: "custom-build-sass", setup(build) { build.onLoad({ filter: DEFAULT_SASS_FILTER }, ({ path: file }) => { const resolveDir = path.dirname(file); const { css: contents } = sass.compile(file, { loadPaths: ["./"] }); return { resolveDir, loader: "css", contents, }; }); }, }; /** * Problem: * No loader is configured for ".node" files: ../../node_modules/.pnpm/fsevents@2.3.3/node_modules/fsevents/fsevents.node * * Reference: * https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487 */ const nativeNodeModulesPlugin: esbuild.Plugin = { name: 'native-node-modules', setup(build) { // If a ".node" file is imported within a module in the "file" namespace, resolve // it to an absolute path and put it into the "node-file" virtual namespace. build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({ path: require.resolve(args.path, { paths: [args.resolveDir] }), namespace: 'node-file', })) // Files in the "node-file" virtual namespace call "require()" on the // path from esbuild of the ".node" file in the output directory. build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({ contents: ` import path from ${JSON.stringify(args.path)} try { module.exports = require(path) } catch {} `, })) // If a ".node" file is imported within a module in the "node-file" namespace, put // it in the "file" namespace where esbuild's default loading behavior will handle // it. It is already an absolute path since we resolved it to one above. build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({ path: args.path, namespace: 'file', })) // Tell esbuild's default loading behavior to use the "file" loader for // these ".node" files. let opts = build.initialOptions opts.loader = opts.loader || {} opts.loader['.node'] = 'file' }, } const postCssPlugin: esbuild.Plugin = { name: "custom-build-postcss", setup(build) { build.onLoad({ filter: DEFAULT_SASS_FILTER }, async ({ path: file }) => { const resolveDir = path.dirname(file); const sourceBuffer = fs.readFileSync(file); const source = sourceBuffer.toString("utf-8"); const postCssConfig = await postcssrc(); postCssConfig.options.from = file; const { css: contents } = await postcss(postCssConfig.plugins).process( source, postCssConfig.options, ); return { resolveDir, loader: "css", contents, }; }); }, }; /** * This should be able to load the plugin but but some reason it does not work * * text: "Cannot find module '../plugins/preeval'\n" + * */ function linariaPlugin() { const linariaCssPlugin: esbuild.Plugin = (linaria as any)({ babelOptions: { presets: ["@babel/preset-typescript", "@babel/preset-react", "@linaria"], }, sourceMap: true, }); return linariaCssPlugin; } const defaultEsBuildConfig: esbuild.BuildOptions = { bundle: true, loader: { ".svg": "file", ".inline.svg": "text", ".png": "dataurl", ".jpeg": "dataurl", ".ttf": "file", ".woff": "file", ".woff2": "file", ".eot": "file", }, target: ["es2020"], format: "esm", platform: "browser", jsxFactory: "h", jsxFragment: "Fragment", alias: { react: "preact/compat", "react-dom": "preact/compat", }, define: { __VERSION__: `"${_package.version}"`, __GIT_HASH__: `"${GIT_HASH}"`, }, }; export interface BuildParams { type: "development" | "test" | "production"; source: { assets: Assets | Assets[]; js: string[]; }; public?: string; destination: string; css: "sass" | "postcss" | "linaria"; linariaPlugin?: () => esbuild.Plugin; } export function computeConfig(params: BuildParams): esbuild.BuildOptions { const plugins: Array = [ copyFilesPlugin(params.source.assets), ]; if (params.css) { switch (params.css) { case "sass": { plugins.push(sassPlugin); break; } case "postcss": { plugins.push(postCssPlugin); break; } case "linaria": { if (params.linariaPlugin) { plugins.push(params.linariaPlugin()); } break; } default: { const cssType: never = params.css; throw Error(`not supported: ${cssType}`); } } } if (!params.type) { throw Error( `missing build type, it should be "test", "development" or "production"`, ); } // if (!params.source.js) { // throw Error(`no javascript entry points, nothing to compile?`); // } if (!params.destination) { throw Error(`missing destination folder`); } return { ...defaultEsBuildConfig, entryPoints: params.source.js, publicPath: params.public, outdir: params.destination, minify: false, //params.type === "production", sourcemap: true, //params.type !== "production", define: { ...defaultEsBuildConfig.define, "process.env.NODE_ENV": JSON.stringify(params.type), }, plugins, }; } /** * Build sources for prod environment */ export function build(config: BuildParams) { return esbuild.build(computeConfig(config)); } const LIVE_RELOAD_SCRIPT = "./node_modules/@gnu-taler/web-util/lib/live-reload.mjs"; /** * Do startup for development environment */ export function initializeDev( config: BuildParams, ): () => Promise { function buildDevelopment() { const result = computeConfig(config); result.inject = [LIVE_RELOAD_SCRIPT]; return esbuild.build(result); } return buildDevelopment; }