Skip to content

Instantly share code, notes, and snippets.

@tgrushka
Created July 21, 2022 18:21
Show Gist options
  • Save tgrushka/fcfbbb5588b75eddc1a4f8f7956d8b2f to your computer and use it in GitHub Desktop.
Save tgrushka/fcfbbb5588b75eddc1a4f8f7956d8b2f to your computer and use it in GitHub Desktop.
// Adapted from https://github.com/jfortunato/esbuild-plugin-manifest/blob/develop/src/index.ts
// Plugin I modified to make esbuild generate a manifest for Spring Boot application. No longer used.
import fs from "fs"
import path from "path"
import util from "util"
export default (options = {}) => ({
name: "manifest",
setup(build) {
build.initialOptions.metafile = true
// assume that the user wants to hash their files by default,
// but don't override any hashing format they may have already set.
if (options.hash !== false && !build.initialOptions.entryNames) {
build.initialOptions.entryNames = "[dir]/[name]-[hash]"
}
build.onEnd((result) => {
// we'll map the input entry point filename to the output filename
const mappings = new Map()
const sourceRoot = build.initialOptions.sourceRoot || path.dirname(build.initialOptions.sourceRoot)
const outdir = build.initialOptions.outdir || path.dirname(build.initialOptions.outfile)
if (!result.metafile) {
throw new Error("Expected metafile, but it does not exist.")
}
const addMapping = (fullInputFilename, fullOutputFilename) => {
let inputFilename = fullInputFilename.replace(sourceRoot, "")
let outputFilename = fullOutputFilename.replace(outdir, "")
// Tom G. - 2022-07-15 - modified to work with com.github.dra11y:asset-thymeleaf-taglib
// Get the filename without the hash, i.e. /css/main-COMBIPD5.css => /css/main.css
let input = outputFilename.replace(/-[A-Z0-9]{8}/i, "")
// Original plugin:
// check if the shortNames option is being used on the input or output
// let input = shouldModify("input", options.shortNames) ? shortName(inputFilename) : inputFilename
let output = shouldModify("output", options.shortNames) ? shortName(outputFilename) : outputFilename
// check if the extensionless option is being used on the input or output
// input = shouldModify("input", options.extensionless) ? extensionless(input) : input
output = shouldModify("output", options.extensionless) ? extensionless(output) : output
// When shortNames are enabled, there can be conflicting filenames.
// For example if the entry points are ['src/pages/home/index.js', 'src/pages/about/index.js'] both of the
// short names will be 'index.js'. We'll just throw an error if a conflict is detected.
//
// There are also other scenarios that can cause a conflicting filename so we'll just ensure that the key
// we're trying to add doesn't already exist.
if (mappings.has(input)) {
throw new Error("There is a conflicting manifest key for '" + input + "'.")
}
mappings.set(input, output)
}
for (const outputFilename in result.metafile.outputs) {
const outputInfo = result.metafile.outputs[outputFilename]
// skip all outputs that don't have an entrypoint
if (!outputInfo.entryPoint) {
continue
}
addMapping(outputInfo.entryPoint, outputFilename)
// Check if this entrypoint has a "sibling" css file
// When esbuild encounters js files that import css files, it will gather all the css files referenced from the
// entrypoint and bundle it into a single sibling css file that follows the same naming structure as the entrypoint.
// So what we can do is simply check the outputs for a sibling file that matches the naming structure.
const siblingCssFile = findSiblingCssFile(result.metafile, outputFilename)
if (siblingCssFile !== undefined) {
// a sibling css file will always be given the same base name as its .js entrypoint,
// so it will always cause a conflict when used with the extensionless option
if (options.extensionless === true || options.extensionless === "input") {
throw new Error(`The extensionless option cannot be used when css is imported.`)
}
addMapping(siblingCssFile.input, siblingCssFile.output)
}
}
if (build.initialOptions.outdir === undefined && build.initialOptions.outfile === undefined) {
throw new Error("You must specify an 'outdir' when generating a manifest file.")
}
const filename = options.filename || "manifest.json"
const fullPath = path.resolve(`${outdir}/${filename}`)
const entries = fromEntries(mappings)
const resultObj = options.generate ? options.generate(entries) : entries
const text = typeof resultObj === "string" ? resultObj : JSON.stringify(resultObj, null, 2)
// With the esbuild write=false option, nothing will be written to disk. Instead, the build
// result will have an "outputFiles" property containing all the files that would have been written.
// We want to add the manifest file as one of those "outputFiles".
if (build.initialOptions.write === false) {
result.outputFiles?.push({
path: fullPath,
contents: new util.TextEncoder().encode(text),
get text() {
return text
},
})
return
}
return fs.promises.writeFile(fullPath, text)
})
},
})
const shouldModify = (inputOrOutput, optionValue) => {
return optionValue === inputOrOutput || optionValue === true
}
const shortName = (value) => {
return path.basename(value)
}
const extensionless = (value) => {
const parsed = path.parse(value)
const dir = parsed.dir !== "" ? `${parsed.dir}/` : ""
return `${dir}${parsed.name}`
}
const findSiblingCssFile = (metafile, outputFilename) => {
if (!outputFilename.endsWith(".js")) {
return
}
// we need to determine the difference in filenames between the input and output of the entrypoint, so that we can
// use that same logic to match against a potential sibling file
const entry = metafile.outputs[outputFilename].entryPoint
// "example.js" => "example"
const entryWithoutExtension = path.parse(entry).name
// "example-GQI5TWWV.js" => "example-GQI5TWWV"
const outputWithoutExtension = path.basename(outputFilename).replace(/\.js$/, "")
// "example-GQI5TWWV" => "-GQI5TWWV"
const diff = outputWithoutExtension.replace(entryWithoutExtension, "")
// esbuild uses [A-Z0-9]{8} as the hash, and that is not currently configurable so we should be able
// to match that exactly in the diff and replace it with the regex so we're left with:
// "-GQI5TWWV" => "-[A-Z0-9]{8}"
const hashRegex = new RegExp(diff.replace(/[A-Z0-9]{8}/, "[A-Z0-9]{8}"))
// the sibling entry is expected to be the same name as the entrypoint just with a css extension
const potentialSiblingEntry = path.parse(entry).dir + "/" + path.parse(entry).name + ".css"
const potentialSiblingOutput = outputFilename.replace(hashRegex, "").replace(/\.js$/, ".css")
const found = Object.keys(metafile.outputs).find(output => output.replace(hashRegex, "") === potentialSiblingOutput)
return found ? {input: potentialSiblingEntry, output: found} : undefined
}
const fromEntries = (map) => {
return Array.from(map).reduce((obj, [key, value]) => {
obj[key] = value
return obj
}, {})
}
import glob from "tiny-glob"
import path from "path"
import util from "util"
import {build} from "esbuild"
import {sassPlugin} from "esbuild-sass-plugin"
import manifestPlugin from "./manifest.esbuild.plugin.mjs"
import {esbuildCommands} from "esbuild-plugin-commands"
const isProd = process.env.NODE_ENV === "production"
const sourceRoot = "src/main/resources"
const entryPoints = await glob(`${sourceRoot}/{js,css}/**`, {
filesOnly: true,
})
const templatesDir = `${sourceRoot}/templates`
var templates = await glob(`${templatesDir}/**`, {})
const templateDirs = [templatesDir, `${templatesDir}/fragments`]
await build({
entryPoints,
sourceRoot,
outdir: sourceRoot + "/static",
watch: process.argv.includes("--watch"),
logLevel: "info",
bundle: true,
treeShaking: true,
minify: isProd,
sourcemap: !isProd,
format: "esm",
target: "es2020",
plugins: [
sassPlugin(),
manifestPlugin({
filename: "manifest.json",
}),
esbuildCommands({
onSuccess: "./gradlew assets",
}),
{
name: "additional-watch-files",
setup(build) {
build.onLoad({filter: /.+/}, async (args) => {
return {
watchFiles: templates,
watchDirs: templateDirs,
}
})
},
},
],
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment