esbuild.js
Rama v2 · réplica de lectura del repositorio original.
/**
* ....
* .: '':.
* :::: ':..
* ::. ''..
* .:'.. ..':.:::' . :. '':.
* :. '' '' '. ::::.. ..:
* ::::. ..':.. .'''::::: .
* :::::::.. '..:::: :. :::: :
* ::'':::::::. ':::.'':.:::: :
* :.. ''::::::....': '':: :
* :::::. '::::: : .. '' .
* .''::::::::... ':::.'' ..'' :.''''.
* :..:::''::::: :::::...:'' :..:
* ::::::. ':::: :::::::: ..:: .
* ::::::::.:::: :::::::: :'':.:: .''
* ::: '::::::::.' ''::::: :.' '': :
* ::: :::::::::..' :::: ::...' .
* ::: .:::::::::: :::: :::: .:'
* '::' ''::::::: :::: : :: :
* ':::: :::: :'' .:
* :::: :::: ..''
* :::: ..:::: .:''
* '''' '''''
*
*
* 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\</g, '><')
.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(/<!--[\s\S]*?-->/g, '')
// collapse whitespace
.replace(/\s+/g, ' ')
// remove space between tags
.replace(/>\s+</g, '><')
.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);
});
}