diff --git a/.changeset/tame-avocados-serve.md b/.changeset/tame-avocados-serve.md new file mode 100644 index 00000000..afcc4578 --- /dev/null +++ b/.changeset/tame-avocados-serve.md @@ -0,0 +1,5 @@ +--- +'@chialab/esbuild-plugin-html': patch +--- + +Add `extensions` and `preprocess` options to HTML plugin. diff --git a/docs/guide/esbuild-plugin-html.md b/docs/guide/esbuild-plugin-html.md index 54f4679d..ddb6be5d 100644 --- a/docs/guide/esbuild-plugin-html.md +++ b/docs/guide/esbuild-plugin-html.md @@ -94,6 +94,14 @@ It can be `link` or `script` (default). The options for the minification process. If the `htmlnano` module is installed, the plugin will minify the HTML output. +#### `extensions` + +An array of extensions to consider as HTML entrypoints. + +#### `preprocess(html: string, path: string): string | Promise` + +A function to preprocess the HTML content before parsing it. + ## How it works **Esbuild Plugin HTML** instructs esbuild to load a HTML file as entrypoint. It parses the HTML and runs esbuild on scripts, styles, assets and icons. @@ -279,3 +287,33 @@ await esbuild.build({ ], }); ``` + +## Preprocess + +The plugin accepts a `preprocess` function that can be used to modify the HTML content before parsing it. + +If you are using a specific extension for template files, you should also add it to the `extensions` option. + +```ts +import htmlPlugin from '@chialab/esbuild-plugin-html'; +import esbuild from 'esbuild'; + +await esbuild.build({ + entryPoints: ['src/index.hbs'], + outdir: 'public', + assetNames: 'assets/[name]-[hash]', + chunkNames: '[ext]/[name]-[hash]', + plugins: [ + htmlPlugin({ + extensions: ['.html', '.hbs'], + preprocess: async (html, path) => { + const { compile } = await import('handlebars'); + const template = compile(contents); + return template({}); + }, + }), + ], +}); +``` + +The plugin will rename the output file to the `.html` extension. diff --git a/packages/esbuild-plugin-html/lib/index.js b/packages/esbuild-plugin-html/lib/index.js index f2bb263e..b8e23a78 100644 --- a/packages/esbuild-plugin-html/lib/index.js +++ b/packages/esbuild-plugin-html/lib/index.js @@ -21,8 +21,10 @@ const loadHtml = /** @type {typeof cheerio.load} */ (cheerio.load || cheerio.def * @property {string} [entryNames] * @property {string} [chunkNames] * @property {string} [assetNames] + * @property {string[]} [extensions] * @property {'link' | 'script'} [injectStylesAs] * @property {import('htmlnano').HtmlnanoOptions} [minifyOptions] + * @property {(code: string, path: string) => string | Promise} [preprocess] */ /** @@ -66,6 +68,8 @@ export default function ({ modulesTarget = 'es2020', minifyOptions = {}, injectStylesAs = 'script', + extensions = ['.html'], + preprocess = (code) => code, } = {}) { /** * @type {import('esbuild').Plugin} @@ -154,7 +158,13 @@ export default function ({ const buffer = resultOutputFile ? Buffer.from(resultOutputFile.contents) : await readFile(actualOutputFile); - const finalOutputFile = build.resolveOutputFile(mainInput, buffer); + const finalOutputFile = build.resolveOutputFile( + path.join( + path.dirname(mainInput), + `${path.basename(mainInput, path.extname(mainInput))}.html` + ), + buffer + ); delete outputs[outputFile]; outputs[build.getOutputName(finalOutputFile)] = output; @@ -172,158 +182,172 @@ export default function ({ ); }); - build.onTransform({ filter: /\.html$/ }, async (args) => { - entryPoints.push(args.path); - - const [ - { collectStyles }, - { collectScripts }, - { collectAssets }, - { collectWebManifest }, - { collectIcons }, - { collectScreens }, - ] = await Promise.all([ - import('./collectStyles.js'), - import('./collectScripts.js'), - import('./collectAssets.js'), - import('./collectWebManifest.js'), - import('./collectIcons.js'), - import('./collectScreens.js'), - ]); - - const code = args.code; - const $ = loadHtml(code); - const root = $.root(); - let count = 0; - - /** - * @param {string} file - */ - const resolveFile = (file) => - build.resolve(`./${file}`, { - kind: 'dynamic-import', - importer: args.path, - resolveDir: path.dirname(args.path), - pluginData: null, - namespace: 'file', - }); - - /** - * @param {string} path - * @param {Partial} [options] - */ - const loadFile = (path, options = {}) => - build.load({ - pluginData: null, - namespace: 'file', - suffix: '', - ...options, - path, - with: {}, - }); - - const collectOptions = { - sourceDir: path.dirname(args.path), - workingDir, - outDir: /** @type {string} */ (build.getFullOutDir()), - entryDir: build.resolveOutputDir(args.path), - target: [scriptsTarget, modulesTarget], - }; - - /** - * Get entry name. - * - * @param {string} ext - * @param {string|undefined} suggestion - * @returns {string} - */ - const createEntry = (ext, suggestion) => { - const i = ++count; - - return `${suggestion ? `${suggestion}${i}` : i}.${ext}`; - }; - - /** - * @type {Helpers} - */ - const helpers = { - createEntry, - resolveAssetFile(source, buffer) { - return build.resolveOutputFile(source, buffer || Buffer.from(''), Build.ASSET); - }, - emitFile: build.emitFile.bind(build), - emitChunk: build.emitChunk.bind(build), - emitBuild: build.emitBuild.bind(build), - resolveRelativePath: build.resolveRelativePath.bind(build), - resolve: resolveFile, - load: loadFile, - }; - - const results = await collectWebManifest($, root, collectOptions, helpers); - results.push(...(await collectScreens($, root, collectOptions, helpers))); - results.push(...(await collectIcons($, root, collectOptions, helpers))); - results.push(...(await collectAssets($, root, collectOptions, helpers))); - results.push(...(await collectStyles($, root, collectOptions, helpers))); - results.push( - ...(await collectScripts( - $, - root, - { - ...collectOptions, - injectStylesAs, - }, - helpers - )) - ); + build.onTransform( + { + filter: new RegExp( + `(${extensions + .map((ext) => { + if (ext[0] !== '.') { + ext = `\\.${ext}`; + } + return `\\${ext}`; + }) + .join('|')})$` + ), + }, + async (args) => { + entryPoints.push(args.path); + + const [ + { collectStyles }, + { collectScripts }, + { collectAssets }, + { collectWebManifest }, + { collectIcons }, + { collectScreens }, + ] = await Promise.all([ + import('./collectStyles.js'), + import('./collectScripts.js'), + import('./collectAssets.js'), + import('./collectWebManifest.js'), + import('./collectIcons.js'), + import('./collectScreens.js'), + ]); + + const code = await preprocess(args.code, args.path); + const $ = loadHtml(code); + const root = $.root(); + let count = 0; + + /** + * @param {string} file + */ + const resolveFile = (file) => + build.resolve(`./${file}`, { + kind: 'dynamic-import', + importer: args.path, + resolveDir: path.dirname(args.path), + pluginData: null, + namespace: 'file', + }); - let resultHtml = $.html().replace(/\n\s*$/gm, ''); - if (minify) { - await import('htmlnano') - .then(async ({ default: htmlnano }) => { - resultHtml = ( - await htmlnano.process(resultHtml, { - minifyJs: false, - minifyCss: false, - minifyJson: false, - ...minifyOptions, - }) - ).html; - }) - .catch(() => { - warnings.push({ - id: 'missing-htmlnano', - pluginName: 'html', - text: `Unable to load "htmlnano" module for HTML minification.`, - location: null, - notes: [], - detail: '', - }); + /** + * @param {string} path + * @param {Partial} [options] + */ + const loadFile = (path, options = {}) => + build.load({ + pluginData: null, + namespace: 'file', + suffix: '', + ...options, + path, + with: {}, }); - } else { - resultHtml = beautify.html(resultHtml); - } - return { - code: resultHtml, - loader: 'file', - watchFiles: results.reduce((acc, result) => { - if (!result) { + const collectOptions = { + sourceDir: path.dirname(args.path), + workingDir, + outDir: /** @type {string} */ (build.getFullOutDir()), + entryDir: build.resolveOutputDir(args.path), + target: [scriptsTarget, modulesTarget], + }; + + /** + * Get entry name. + * + * @param {string} ext + * @param {string|undefined} suggestion + * @returns {string} + */ + const createEntry = (ext, suggestion) => { + const i = ++count; + + return `${suggestion ? `${suggestion}${i}` : i}.${ext}`; + }; + + /** + * @type {Helpers} + */ + const helpers = { + createEntry, + resolveAssetFile(source, buffer) { + return build.resolveOutputFile(source, buffer || Buffer.from(''), Build.ASSET); + }, + emitFile: build.emitFile.bind(build), + emitChunk: build.emitChunk.bind(build), + emitBuild: build.emitBuild.bind(build), + resolveRelativePath: build.resolveRelativePath.bind(build), + resolve: resolveFile, + load: loadFile, + }; + + const results = await collectWebManifest($, root, collectOptions, helpers); + results.push(...(await collectScreens($, root, collectOptions, helpers))); + results.push(...(await collectIcons($, root, collectOptions, helpers))); + results.push(...(await collectAssets($, root, collectOptions, helpers))); + results.push(...(await collectStyles($, root, collectOptions, helpers))); + results.push( + ...(await collectScripts( + $, + root, + { + ...collectOptions, + injectStylesAs, + }, + helpers + )) + ); + + let resultHtml = $.html().replace(/\n\s*$/gm, ''); + if (minify) { + await import('htmlnano') + .then(async ({ default: htmlnano }) => { + resultHtml = ( + await htmlnano.process(resultHtml, { + minifyJs: false, + minifyCss: false, + minifyJson: false, + ...minifyOptions, + }) + ).html; + }) + .catch(() => { + warnings.push({ + id: 'missing-htmlnano', + pluginName: 'html', + text: `Unable to load "htmlnano" module for HTML minification.`, + location: null, + notes: [], + detail: '', + }); + }); + } else { + resultHtml = beautify.html(resultHtml); + } + + return { + code: resultHtml, + loader: 'file', + watchFiles: results.reduce((acc, result) => { + if (!result) { + return acc; + } + if (result.watchFiles) { + return [...acc, ...result.watchFiles]; + } + if (result.metafile) { + return [ + ...acc, + ...Object.keys(result.metafile.inputs).map((key) => path.resolve(cwd, key)), + ]; + } return acc; - } - if (result.watchFiles) { - return [...acc, ...result.watchFiles]; - } - if (result.metafile) { - return [ - ...acc, - ...Object.keys(result.metafile.inputs).map((key) => path.resolve(cwd, key)), - ]; - } - return acc; - }, /** @type {string[]} */ ([])), - warnings, - }; - }); + }, /** @type {string[]} */ ([])), + warnings, + }; + } + ); }, }; diff --git a/packages/esbuild-plugin-html/package.json b/packages/esbuild-plugin-html/package.json index 736540b5..66dab326 100644 --- a/packages/esbuild-plugin-html/package.json +++ b/packages/esbuild-plugin-html/package.json @@ -51,6 +51,7 @@ "@types/js-beautify": "^1.13.3", "cheerio": "^1.0.0-rc.12", "esbuild": "^0.23.0", + "handlebars": "^4.7.8", "htmlnano": "^2.1.0", "js-beautify": "^1.14.0", "rimraf": "^6.0.0", diff --git a/packages/esbuild-plugin-html/test/fixture/index.icons.hbs b/packages/esbuild-plugin-html/test/fixture/index.icons.hbs new file mode 100644 index 00000000..ef1948c0 --- /dev/null +++ b/packages/esbuild-plugin-html/test/fixture/index.icons.hbs @@ -0,0 +1,12 @@ + + + + + + + Document + + + + + diff --git a/packages/esbuild-plugin-html/test/test.spec.js b/packages/esbuild-plugin-html/test/test.spec.js index f950911d..f0225d97 100644 --- a/packages/esbuild-plugin-html/test/test.spec.js +++ b/packages/esbuild-plugin-html/test/test.spec.js @@ -1475,4 +1475,63 @@ function loadStyle(url) { loadStyle('/public/index.css'); }()); `); }); + + test('should interop with other html preprocessors', async () => { + const { outputFiles } = await esbuild.build({ + absWorkingDir: fileURLToPath(new URL('.', import.meta.url)), + entryPoints: [fileURLToPath(new URL('fixture/index.icons.hbs', import.meta.url))], + sourceRoot: '/', + publicPath: '/public', + assetNames: 'icons/[name]', + outdir: 'out', + format: 'esm', + bundle: true, + write: false, + plugins: [ + htmlPlugin({ + extensions: ['.html', '.hbs'], + async preprocess(contents) { + const { compile } = await import('handlebars'); + const template = compile(contents); + return template({ + iconPath: 'img/icon.png', + }); + }, + }), + ], + }); + + const [index, ...icons] = outputFiles; + + expect(outputFiles).toHaveLength(7); + + expect(index.path).endsWith(path.join(path.sep, 'out', 'index.icons.html')); + expect(index.text).toBe(` + + + + + + + Document + + + + + + + + + + + + +`); + + expect(icons[0].path).endsWith(path.join(path.sep, 'out', 'icons', 'favicon-16x16.png')); + expect(icons[0].contents.byteLength).toBe(459); + + expect(icons[3].path).endsWith(path.join(path.sep, 'out', 'icons', 'favicon-196x196.png')); + expect(icons[3].contents.byteLength).toBe(6366); + }); }); diff --git a/yarn.lock b/yarn.lock index e56d7649..df790c6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1927,6 +1927,7 @@ __metadata: "@types/js-beautify": ^1.13.3 cheerio: ^1.0.0-rc.12 esbuild: ^0.23.0 + handlebars: ^4.7.8 htmlnano: ^2.1.0 js-beautify: ^1.14.0 rimraf: ^6.0.0