/** * .... * .: '':. * :::: ':.. * ::. ''.. * .:'.. ..':.:::' . :. '':. * :. '' '' '. ::::.. ..: * ::::. ..':.. .'''::::: . * :::::::.. '..:::: :. :::: : * ::'':::::::. ':::.'':.:::: : * :.. ''::::::....': '':: : * :::::. '::::: : .. '' . * .''::::::::... ':::.'' ..'' :.''''. * :..:::''::::: :::::...:'' :..: * ::::::. ':::: :::::::: ..:: . * ::::::::.:::: :::::::: :'':.:: .'' * ::: '::::::::.' ''::::: :.' '': : * ::: :::::::::..' :::: ::...' . * ::: .:::::::::: :::: :::: .:' * '::' ''::::::: :::: : :: : * ':::: :::: :'' .: * :::: :::: ..'' * :::: ..:::: .:'' * '''' ''''' * * * AUTOMAD * * Copyright (c) 2025-2026 by Marc Anton Dahmen * https://marcdahmen.de * * See LICENSE.md for license information. */ /* * Esbuild config for Automad * * This script handles automatic splitting of vendor modules and block classes in order * to be able to import those modules asynchronously. Splitting can be controlled entirely * by the file structure of the entry points. There are three main catergories: * * 1. Main "index" Files * * Index files are the main entry points that are loaded by the actual PHP pages. * They have to named "index.ts". * * 2. Block Classes * * Block classes are imported dynamically whenever a related web component is connected. * They have to match the pattern "blocks/components/*.ts". * All class modules are marked as external for esbuild and split into separate files * with a hashed filename. * * 3. Vendor Modules * * Large vendor modules are also split into separate files and also have hashed * filenames. Like blocks, they are also imported by other modules and therefore * are not loaded by PHP pages. * They have to match the pattern "vendor/*.ts". * * Note that the common module is not build as an external file. */ import pkg from './package.json' with { type: 'json' }; import esbuild from 'esbuild'; import less from 'less'; import postcss from 'esbuild-postcss'; import browserSync from 'browser-sync'; import { sassPlugin } from 'esbuild-sass-plugin'; import { fileURLToPath } from 'node:url'; import crypto from 'node:crypto'; import path from 'node:path'; import fs from 'node:fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const clientSrc = path.join(__dirname, 'automad/src/client'); const outdir = path.join(__dirname, 'automad/dist/build'); const isDev = process.argv.includes('--dev'); const hashPlaceholder = '@hash'; const banner = `/* This file is part of Automad. Copyright and license info at the end. */`; const licenseAutomad = ` /*! * Automad (https://automad.org) * * Copyright (c) ${new Date().getFullYear()} ${pkg.author} (https://marcdahmen.de) * See LICENSE.md for license information. */ `; const fileHash = (buffer, length = 8) => { return crypto .createHash('sha1') .update(buffer) .digest('hex') .slice(0, length); }; const findEntries = (pattern) => { return fs .globSync(`${clientSrc}/${pattern}`) .map((f) => f.replace(`${clientSrc}/`, '').replace('.ts', '')); }; const makeRelative = (file) => `../${path.basename(path.dirname(file))}/${path.basename(file)}`; const write = (file, contents) => { fs.mkdirSync(path.dirname(file), { recursive: true, }); fs.writeFileSync(file, contents); console.log(`  ${makeRelative(file)}`); }; /** * Create a path configuration including entry points, external files and aliases. */ const pathConfig = () => { const mainEntries = findEntries('*/index.ts').filter( (f) => !f.match(/common/) ); const vendorEntries = findEntries('vendor/*.ts'); const blockEntries = findEntries('blocks/components/*.ts'); // Prepare the entryPoints by adding a hash placeholder to all // non-index files. const entryPoints = [ ...mainEntries.map((entry) => { return { in: path.join(clientSrc, entry), out: entry, }; }), ...blockEntries.map((entry) => { return { in: path.join(clientSrc, entry), out: `${entry.replace('components/', '')}-${hashPlaceholder}`, }; }), ...vendorEntries.map((entry) => { return { in: path.join(clientSrc, entry), out: `${entry}-${hashPlaceholder}`, }; }), ]; const alias = {}; // The items in the alias list are modified by adding the hash placeholder // accordingly to match the entryPoints config. blockEntries.forEach((entry) => { const key = `@/${entry}`; alias[key] = `../${entry.replace('components/', '')}-${hashPlaceholder}.js`; }); vendorEntries.forEach((entry) => { const key = `@/${entry}`; alias[key] = `../${entry}-${hashPlaceholder}.js`; }); // Now mark all non-index files with their hashed filenames as external. const external = [ ...blockEntries.map( (entry) => `../${entry.replace('components/', '')}-${hashPlaceholder}.js` ), ...vendorEntries.map((entry) => `../${entry}-${hashPlaceholder}.js`), ]; return { alias, entryPoints, external }; }; /** * The output hash map that can be used across build steps in dev mode. */ const outputHashes = new Map(); /** * A plugin that hashes output files and updates import statments accordingly. */ const hashImportsPlugin = () => { return { name: 'hash-imports', setup(build) { build.onEnd(async (result) => { const outputs = new Map(); const placeholderRegex = new RegExp( `\\.\\.\\/[\\w\\/-]*-${hashPlaceholder}\\.js`, 'g' ); // First the esbuild output files array is converted // to a custom output map with additional data. result.outputFiles.forEach((outputFile) => { if (outputFile.path.match(/\.js$/)) { const content = new TextDecoder('utf-8').decode( outputFile.contents ); const pathRelative = makeRelative(outputFile.path); const pathAbsolute = outputFile.path; // Placeholder imports are imports that contain // the @hash placeholder in their path. const placeholderImports = content.match(placeholderRegex) ?? []; outputs.set(pathRelative, { content, pathRelative, pathAbsolute, placeholderImports, newPathAbsolute: null, }); } else { // Write all non-JS assets directly and without hashing // since they are not async imported. if ( !outputHashes.get(outputFile.path) || outputHashes.get(outputFile.path) != outputFile.hash ) { write(outputFile.path, outputFile.contents); outputHashes.set(outputFile.path, outputFile.hash); } } }); // Now iterate all output files once and store all files // that import it inside the dependents array. outputs.forEach((out) => { out.dependents = []; outputs.forEach((dep) => { if ( dep.placeholderImports?.includes(out.pathRelative) ) { out.dependents.push(dep.pathRelative); } }); }); // In order to resolve all paths correctly and hash their content // after the contained import placeholders are also resolved // and hashed as well, the output map is processed in a // while loop as longs as it contains elements. while (outputs.size > 0) { let finalizedFiles = []; // During each iteration, all files that do not contain any import // placeholders, are considered clean and finalized" and will be // added to the array of files that can be hashed and processed. outputs.forEach((out, key) => { if (out.placeholderImports.length == 0) { finalizedFiles.push(key); } }); // That hash is used to update the file path by replacing // the placeholder with it. Then all references in its dependents are updated as well. finalizedFiles.forEach((key) => { const out = outputs.get(key); // When processing the clean and finalized files, first, // a hash of the file content is generated. const hash = fileHash(out.content); // That hash is used to update the file path by replacing // the placeholder with it. out.newPathAbsolute = out.pathAbsolute.replace( hashPlaceholder, hash ); const newImport = out.pathRelative.replace( hashPlaceholder, hash ); out.dependents.forEach((path) => { const dep = outputs.get(path); // Then all file references in its dependents are updated as well. dep.content = dep.content.replaceAll( out.pathRelative, newImport ); // Now the finalized and hashed imports are removed from the // dependent's list of placeholder imports. dep.placeholderImports = dep.content.match(placeholderRegex) ?? []; }); if ( !outputHashes.get(key) || outputHashes.get(key) != hash ) { // In case the hash has changed since the last build or it is // the first build, remove old versions of the file first. fs.globSync( out.pathAbsolute.replace( /-[a-zA-Z0-9@]+\.js/, '-*.js' ) ).forEach((f) => { if (fs.existsSync(f)) { fs.unlinkSync(f); } }); // Then write the content to the file with the new hashed filename. write(out.newPathAbsolute, out.content); // And store the hash in the cross-build map. outputHashes.set(key, hash); } // Finally remove the file form the outputs map. outputs.delete(key); }); } }); }, }; }; /** * Return a license comment based on a file path. * For all files inside that the Automad src directory * that are not in vendor, a license comment is returned. */ const generateLicense = (path) => { if (path.match(/(vendor|node_modules)/)) { return ''; } return licenseAutomad; }; /** * Minify TS file and especially included string litrals. */ const minifyTs = (source) => source // Remove all single line comments in order to safely remove newlines. .replace(/\/\/\s.*$/gm, ' ') // Once replace multiple whitespace chars including newlines to a single space. .replace(/\s+/g, ' ') // From here on, on single \s needs to be matched. .replace(/\>\s\<') .replace(/(\w\>)\s/g, '$1') .replace(/\s(\<\/\w)/g, '$1') // Also trim template strings. .replace(/`([^`]+)`/g, (_, s) => `\`${s.trim()}\``); /** * A plugin that minifies TS code with string literals and * injects a license comment for Automad entry points. */ const tsOnLoadPlugin = () => { return { name: 'ts-on-load', setup(build) { build.onLoad( { filter: /\.ts$/, namespace: 'file' }, async (args) => { const source = await fs.promises.readFile( args.path, 'utf8' ); return { contents: generateLicense(args.path) + minifyTs(source), loader: 'ts', }; } ); }, }; }; /** * Build less files and inject license comment for Automad entry points. */ const lessOnLoadPlugin = () => { return { name: 'less-on-load', setup(build) { build.onLoad( { filter: /\.less$/, namespace: 'file' }, async (args) => { const source = await fs.promises.readFile( args.path, 'utf8' ); try { // https://lesscss.org/usage/#programmatic-usage const { css, imports } = await less.render(source, { // Set this in order to resolve imports filename: args.path, }); return { contents: generateLicense(args.path) + css, watchFiles: imports, loader: 'css', }; } catch (error) { process.exit(1); } } ); }, }; }; /** * Minify SVG files. */ const minifySvg = (source) => source // remove XML / HTML comments .replace(//g, '') // collapse whitespace .replace(/\s+/g, ' ') // remove space between tags .replace(/>\s+<') .trim(); /** * A plugin that minifies SVG files. */ const svgOnLoadPlugin = () => ({ name: 'svg-on-load', setup(build) { build.onLoad({ filter: /\.svg$/ }, async (args) => { const source = await fs.promises.readFile(args.path, 'utf8'); return { contents: minifySvg(source), loader: 'text', // still inline as string }; }); }, }); /** * A common config shared between build and dev mode. */ const commonConfig = { ...pathConfig(), bundle: true, format: 'esm', minify: !isDev, target: ['es2022'], assetNames: '[name]', write: false, outdir, banner: { js: banner, css: banner, }, legalComments: 'eof', pure: isDev ? [] : ['console.warn'], logLevel: 'info', loader: { '.svg': 'text', '.woff': 'file', '.woff2': 'file', }, metafile: true, define: { DEVELOPMENT: isDev.toString() }, plugins: [ svgOnLoadPlugin(), lessOnLoadPlugin(), sassPlugin({ quietDeps: true, filter: /\.scss/, }), postcss(), ...(isDev ? [] : [tsOnLoadPlugin()]), hashImportsPlugin(), ], }; async function buildAll() { await esbuild.build(commonConfig); } async function startDev() { await buildAll(); const bs = browserSync.create(); bs.init({ host: 'localhost', port: 3000, proxy: 'http://localhost:8000', open: false, notify: false, files: ['**/src/**/*.php', '**/dist/**/*.{js,css}'], serveStatic: [outdir], }); let ctx = await esbuild.context(commonConfig); await ctx.watch(); } if (isDev) { startDev().catch((err) => { console.error(err); process.exit(1); }); } else { buildAll().catch((err) => { console.error(err); process.exit(1); }); }