diff --git a/dist/args.d.ts b/dist/args.d.ts index 3dc2025..dc31dd8 100644 --- a/dist/args.d.ts +++ b/dist/args.d.ts @@ -1,4 +1,6 @@ export interface Args { + path: string; + dest: string; watch: Boolean; sri: string[] | null; } diff --git a/dist/args.js b/dist/args.js index f0ed1f1..1ac0ff8 100644 --- a/dist/args.js +++ b/dist/args.js @@ -5,6 +5,8 @@ export const parseArgs = (args) => { result[key] = value; }); return { + path: result["path"], + dest: result["dest"], watch: result.hasOwnProperty("watch"), sri: result["sri"]?.split(","), }; diff --git a/dist/esbuild-plugin.d.ts b/dist/esbuild-plugin.d.ts index 5481084..a7e5814 100644 --- a/dist/esbuild-plugin.d.ts +++ b/dist/esbuild-plugin.d.ts @@ -1,12 +1,11 @@ import { Plugin } from "esbuild"; export interface PluginOptions { root: string; - publicDir: string; + sourceDir: string; destDir: string; - manifestPath: string; sriAlgorithms: Array; hash: boolean; } -export declare const defaults: Pick; -declare const hanamiEsbuild: (options?: PluginOptions) => Plugin; +export declare const defaults: Pick; +declare const hanamiEsbuild: (options: PluginOptions) => Plugin; export default hanamiEsbuild; diff --git a/dist/esbuild-plugin.js b/dist/esbuild-plugin.js index e28de4b..e7b2863 100644 --- a/dist/esbuild-plugin.js +++ b/dist/esbuild-plugin.js @@ -3,29 +3,20 @@ import path from "path"; import crypto from "node:crypto"; const URL_SEPARATOR = "/"; export const defaults = { - root: "", - publicDir: "public", - destDir: path.join("public", "assets"), - manifestPath: path.join("public", "assets.json"), sriAlgorithms: [], hash: true, }; -const hanamiEsbuild = (options = { ...defaults }) => { +const assetsDirName = "assets"; +const hanamiEsbuild = (options) => { return { name: "hanami-esbuild", setup(build) { build.initialOptions.metafile = true; - options.root = options.root || process.cwd(); - const manifest = path.join(options.root, options.manifestPath); + const manifestPath = path.join(options.root, options.destDir, "assets.json"); const externalDirs = build.initialOptions.external || []; build.onEnd(async (result) => { const outputs = result.metafile?.outputs; const assetsManifest = {}; - const calulateSourceUrl = (str) => { - return normalizeUrl(str) - .replace(/\/assets\//, "") - .replace(/-[A-Z0-9]{8}/, ""); - }; const calulateDestinationUrl = (str) => { return normalizeUrl(str).replace(/public/, ""); }; @@ -45,17 +36,34 @@ const hanamiEsbuild = (options = { ...defaults }) => { const result = crypto.createHash("sha256").update(hashBytes).digest("hex"); return result.slice(0, 8).toUpperCase(); }; - function extractEsbuildInputs(inputData) { - const inputs = {}; - for (const key in inputData) { - const entry = inputData[key]; - if (entry.inputs) { - for (const inputKey in entry.inputs) { - inputs[inputKey] = true; - } + // Transforms the esbuild metafile outputs into an object containing mappings of outputs + // generated from entryPoints only. + // + // Converts this: + // + // { + // 'public/assets/admin/app-ITGLRDE7.js': { + // imports: [], + // exports: [], + // entryPoint: 'slices/admin/assets/js/app.js', + // inputs: { 'slices/admin/assets/js/app.js': [Object] }, + // bytes: 95 + // } + // } + // + // To this: + // + // { + // 'public/assets/admin/app-ITGLRDE7.js': true + // } + function extractEsbuildCompiledEntrypoints(esbuildOutputs) { + const entryPoints = {}; + for (const key in esbuildOutputs) { + if (!key.endsWith(".map")) { + entryPoints[key] = true; } } - return inputs; + return entryPoints; } // TODO: profile the current implementation vs blindly copying the asset const copyAsset = (srcPath, destPath) => { @@ -73,31 +81,33 @@ const hanamiEsbuild = (options = { ...defaults }) => { fs.copyFileSync(srcPath, destPath); return; }; - const processAssetDirectory = (pattern, inputs, options) => { + const processAssetDirectory = (pattern, compiledEntryPoints, options) => { const dirPath = path.dirname(pattern); const files = fs.readdirSync(dirPath, { recursive: true }); const assets = []; files.forEach((file) => { - const srcPath = path.join(dirPath, file.toString()); + const sourcePath = path.join(dirPath, file.toString()); // Skip if the file is already processed by esbuild - if (inputs.hasOwnProperty(srcPath)) { + if (compiledEntryPoints.hasOwnProperty(sourcePath)) { return; } // Skip directories and any other non-files - if (!fs.statSync(srcPath).isFile()) { + if (!fs.statSync(sourcePath).isFile()) { return; } - const fileHash = calculateHash(fs.readFileSync(srcPath), options.hash); - const fileExtension = path.extname(srcPath); - const baseName = path.basename(srcPath, fileExtension); + const fileHash = calculateHash(fs.readFileSync(sourcePath), options.hash); + const fileExtension = path.extname(sourcePath); + const baseName = path.basename(sourcePath, fileExtension); const destFileName = [baseName, fileHash].filter((item) => item !== null).join("-") + fileExtension; - const destPath = path.join(options.destDir, path.relative(dirPath, srcPath).replace(path.basename(file.toString()), destFileName)); - if (fs.lstatSync(srcPath).isDirectory()) { - assets.push(...processAssetDirectory(destPath, inputs, options)); + const destPath = path.join(options.destDir, path + .relative(dirPath, sourcePath) + .replace(path.basename(file.toString()), destFileName)); + if (fs.lstatSync(sourcePath).isDirectory()) { + assets.push(...processAssetDirectory(destPath, compiledEntryPoints, options)); } else { - copyAsset(srcPath, destPath); - assets.push(destPath); + copyAsset(sourcePath, destPath); + assets.push({ sourcePath: sourcePath, destPath: destPath }); } }); return assets; @@ -105,30 +115,47 @@ const hanamiEsbuild = (options = { ...defaults }) => { if (typeof outputs === "undefined") { return; } - const inputs = extractEsbuildInputs(outputs); + const compiledEntryPoints = extractEsbuildCompiledEntrypoints(outputs); const copiedAssets = []; externalDirs.forEach((pattern) => { - copiedAssets.push(...processAssetDirectory(pattern, inputs, options)); + copiedAssets.push(...processAssetDirectory(pattern, compiledEntryPoints, options)); }); - const assetsToProcess = Object.keys(outputs).concat(copiedAssets); - for (const assetToProcess of assetsToProcess) { - if (assetToProcess.endsWith(".map")) { - continue; - } - const destinationUrl = calulateDestinationUrl(assetToProcess); - const sourceUrl = calulateSourceUrl(destinationUrl); + function prepareAsset(assetPath, destinationUrl) { var asset = { url: destinationUrl }; if (options.sriAlgorithms.length > 0) { asset.sri = []; for (const algorithm of options.sriAlgorithms) { - const subresourceIntegrity = calculateSubresourceIntegrity(algorithm, assetToProcess); + const subresourceIntegrity = calculateSubresourceIntegrity(algorithm, path.join(options.root, assetPath)); asset.sri.push(subresourceIntegrity); } } - assetsManifest[sourceUrl] = asset; + return asset; + } + // Process entrypoints + const fileHashRegexp = /(-[A-Z0-9]{8})(\.\S+)$/; + for (const compiledEntryPoint in compiledEntryPoints) { + // Convert "public/assets/app-2TLUHCQ6.js" to "app.js" + let sourceUrl = compiledEntryPoint + .replace(options.destDir + "/", "") + .replace(fileHashRegexp, "$2"); + const destinationUrl = calulateDestinationUrl(compiledEntryPoint); + assetsManifest[sourceUrl] = prepareAsset(compiledEntryPoint, destinationUrl); + } + // Process copied assets + for (const copiedAsset of copiedAssets) { + // TODO: I wonder if we can skip .map files earlier + if (copiedAsset.sourcePath.endsWith(".map")) { + continue; + } + const destinationUrl = calulateDestinationUrl(copiedAsset.destPath); + // Take the full path of the copied asset and remove everything up to (and including) the "assets/" dir + var sourceUrl = copiedAsset.sourcePath.replace(path.join(options.root, options.sourceDir, assetsDirName) + "/", ""); + // Then remove the first subdir (e.g. "images/"), since we do not include those in the asset paths + sourceUrl = sourceUrl.substring(sourceUrl.indexOf("/") + 1); + assetsManifest[sourceUrl] = prepareAsset(copiedAsset.destPath, destinationUrl); } // Write assets manifest to the destination directory - await fs.writeJson(manifest, assetsManifest, { spaces: 2 }); + await fs.writeJson(manifestPath, assetsManifest, { spaces: 2 }); }); }, }; diff --git a/dist/esbuild.js b/dist/esbuild.js index 80b613b..79f29d3 100644 --- a/dist/esbuild.js +++ b/dist/esbuild.js @@ -18,36 +18,28 @@ const loader = { ".eot": "file", ".ttf": "file", }; +const assetsDirName = "assets"; const entryPointExtensions = "app.{js,ts,mjs,mts,tsx,jsx}"; -// FIXME: make cross platform -const entryPointsMatcher = /(app\/assets\/js\/|slices\/(.*\/)assets\/js\/)/; -const findEntryPoints = (root) => { +const findEntryPoints = (sliceRoot) => { const result = {}; - // TODO: should this be done explicitly within the root? const entryPoints = globSync([ - path.join("app", "assets", "js", "**", entryPointExtensions), - path.join("slices", "*", "assets", "js", "**", entryPointExtensions), + path.join(sliceRoot, assetsDirName, "js", "**", entryPointExtensions), ]); entryPoints.forEach((entryPoint) => { - let modifiedPath = entryPoint.replace(entryPointsMatcher, "$2"); - const relativePath = path.relative(root, modifiedPath); - const { dir, name } = path.parse(relativePath); + let entryPointPath = entryPoint.replace(sliceRoot + "/assets/js/", ""); + const { dir, name } = path.parse(entryPointPath); if (dir) { - modifiedPath = path.join(dir, name); + entryPointPath = path.join(dir, name); } else { - modifiedPath = name; + entryPointPath = name; } - result[modifiedPath] = entryPoint; + result[entryPointPath] = entryPoint; }); return result; }; -// TODO: feels like this really should be passed a root too, to become the cwd for globSync -const externalDirectories = () => { - const assetDirsPattern = [ - path.join("app", "assets", "*"), - path.join("slices", "*", "assets", "*"), - ]; +const findExternalDirectories = (basePath) => { + const assetDirsPattern = [path.join(basePath, assetsDirName, "*")]; const excludeDirs = ["js", "css"]; try { const dirs = globSync(assetDirsPattern, { nodir: false }); @@ -66,20 +58,23 @@ const externalDirectories = () => { export const buildOptions = (root, args) => { const pluginOptions = { ...pluginDefaults, + root: root, + sourceDir: args.path, + destDir: args.dest, sriAlgorithms: args.sri || [], }; const plugin = esbuildPlugin(pluginOptions); const options = { bundle: true, - outdir: path.join(root, "public", "assets"), + outdir: args.dest, absWorkingDir: root, loader: loader, - external: externalDirectories(), + external: findExternalDirectories(path.join(root, args.path)), logLevel: "info", minify: true, sourcemap: true, entryNames: "[dir]/[name]-[hash]", - entryPoints: findEntryPoints(root), + entryPoints: findEntryPoints(path.join(root, args.path)), plugins: [plugin], }; return options; @@ -87,20 +82,23 @@ export const buildOptions = (root, args) => { export const watchOptions = (root, args) => { const pluginOptions = { ...pluginDefaults, + root: root, + sourceDir: args.path, + destDir: args.dest, hash: false, }; const plugin = esbuildPlugin(pluginOptions); const options = { bundle: true, - outdir: path.join(root, "public", "assets"), + outdir: args.dest, absWorkingDir: root, loader: loader, - external: externalDirectories(), + external: findExternalDirectories(path.join(root, args.path)), logLevel: "info", minify: false, sourcemap: false, entryNames: "[dir]/[name]", - entryPoints: findEntryPoints(root), + entryPoints: findEntryPoints(path.join(root, args.path)), plugins: [plugin], }; return options; diff --git a/dist/index.d.ts b/dist/index.d.ts index 0d2540c..f5e570e 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node import { BuildContext } from "esbuild"; import { Args } from "./args.js"; import { EsbuildOptions } from "./esbuild.js"; diff --git a/dist/index.js b/dist/index.js index 947313d..eb63733 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,10 +1,10 @@ -#!/usr/bin/env node import fs from "fs-extra"; import path from "path"; import esbuild from "esbuild"; import { parseArgs } from "./args.js"; import { buildOptions, watchOptions } from "./esbuild.js"; export const run = async function (options) { + // TODO: Allow root to be provided (optionally) as a --root arg const { root = process.cwd(), argv = process.argv, esbuildOptionsFn = null } = options || {}; const args = parseArgs(argv); // TODO: make nicer diff --git a/src/args.ts b/src/args.ts index fa12b84..1b0ae67 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,4 +1,6 @@ export interface Args { + path: string; + dest: string; watch: Boolean; sri: string[] | null; } @@ -12,6 +14,8 @@ export const parseArgs = (args: string[]): Args => { }); return { + path: result["path"], + dest: result["dest"], watch: result.hasOwnProperty("watch"), sri: result["sri"]?.split(","), }; diff --git a/src/esbuild-plugin.ts b/src/esbuild-plugin.ts index 0875313..aa0d544 100644 --- a/src/esbuild-plugin.ts +++ b/src/esbuild-plugin.ts @@ -7,21 +7,13 @@ const URL_SEPARATOR = "/"; export interface PluginOptions { root: string; - publicDir: string; + sourceDir: string; destDir: string; - manifestPath: string; sriAlgorithms: Array; hash: boolean; } -export const defaults: Pick< - PluginOptions, - "root" | "publicDir" | "destDir" | "manifestPath" | "sriAlgorithms" | "hash" -> = { - root: "", - publicDir: "public", - destDir: path.join("public", "assets"), - manifestPath: path.join("public", "assets.json"), +export const defaults: Pick = { sriAlgorithms: [], hash: true, }; @@ -31,27 +23,27 @@ interface Asset { sri?: Array; } -const hanamiEsbuild = (options: PluginOptions = { ...defaults }): Plugin => { +interface CopiedAsset { + sourcePath: string; + destPath: string; +} + +const assetsDirName = "assets"; + +const hanamiEsbuild = (options: PluginOptions): Plugin => { return { name: "hanami-esbuild", setup(build: PluginBuild) { build.initialOptions.metafile = true; - options.root = options.root || process.cwd(); - const manifest = path.join(options.root, options.manifestPath); + const manifestPath = path.join(options.root, options.destDir, "assets.json"); const externalDirs = build.initialOptions.external || []; build.onEnd(async (result: BuildResult) => { const outputs = result.metafile?.outputs; const assetsManifest: Record = {}; - const calulateSourceUrl = (str: string): string => { - return normalizeUrl(str) - .replace(/\/assets\//, "") - .replace(/-[A-Z0-9]{8}/, ""); - }; - const calulateDestinationUrl = (str: string): string => { return normalizeUrl(str).replace(/public/, ""); }; @@ -78,20 +70,38 @@ const hanamiEsbuild = (options: PluginOptions = { ...defaults }): Plugin => { return result.slice(0, 8).toUpperCase(); }; - function extractEsbuildInputs(inputData: Record): Record { - const inputs: Record = {}; - - for (const key in inputData) { - const entry = inputData[key]; - - if (entry.inputs) { - for (const inputKey in entry.inputs) { - inputs[inputKey] = true; - } + // Transforms the esbuild metafile outputs into an object containing mappings of outputs + // generated from entryPoints only. + // + // Converts this: + // + // { + // 'public/assets/admin/app-ITGLRDE7.js': { + // imports: [], + // exports: [], + // entryPoint: 'slices/admin/assets/js/app.js', + // inputs: { 'slices/admin/assets/js/app.js': [Object] }, + // bytes: 95 + // } + // } + // + // To this: + // + // { + // 'public/assets/admin/app-ITGLRDE7.js': true + // } + function extractEsbuildCompiledEntrypoints( + esbuildOutputs: Record, + ): Record { + const entryPoints: Record = {}; + + for (const key in esbuildOutputs) { + if (!key.endsWith(".map")) { + entryPoints[key] = true; } } - return inputs; + return entryPoints; } // TODO: profile the current implementation vs blindly copying the asset @@ -117,41 +127,43 @@ const hanamiEsbuild = (options: PluginOptions = { ...defaults }): Plugin => { const processAssetDirectory = ( pattern: string, - inputs: Record, + compiledEntryPoints: Record, options: PluginOptions, - ): string[] => { + ): CopiedAsset[] => { const dirPath = path.dirname(pattern); const files = fs.readdirSync(dirPath, { recursive: true }); - const assets: string[] = []; + const assets: CopiedAsset[] = []; files.forEach((file) => { - const srcPath = path.join(dirPath, file.toString()); + const sourcePath = path.join(dirPath, file.toString()); // Skip if the file is already processed by esbuild - if (inputs.hasOwnProperty(srcPath)) { + if (compiledEntryPoints.hasOwnProperty(sourcePath)) { return; } // Skip directories and any other non-files - if (!fs.statSync(srcPath).isFile()) { + if (!fs.statSync(sourcePath).isFile()) { return; } - const fileHash = calculateHash(fs.readFileSync(srcPath), options.hash); - const fileExtension = path.extname(srcPath); - const baseName = path.basename(srcPath, fileExtension); + const fileHash = calculateHash(fs.readFileSync(sourcePath), options.hash); + const fileExtension = path.extname(sourcePath); + const baseName = path.basename(sourcePath, fileExtension); const destFileName = [baseName, fileHash].filter((item) => item !== null).join("-") + fileExtension; const destPath = path.join( options.destDir, - path.relative(dirPath, srcPath).replace(path.basename(file.toString()), destFileName), + path + .relative(dirPath, sourcePath) + .replace(path.basename(file.toString()), destFileName), ); - if (fs.lstatSync(srcPath).isDirectory()) { - assets.push(...processAssetDirectory(destPath, inputs, options)); + if (fs.lstatSync(sourcePath).isDirectory()) { + assets.push(...processAssetDirectory(destPath, compiledEntryPoints, options)); } else { - copyAsset(srcPath, destPath); - assets.push(destPath); + copyAsset(sourcePath, destPath); + assets.push({ sourcePath: sourcePath, destPath: destPath }); } }); @@ -162,38 +174,66 @@ const hanamiEsbuild = (options: PluginOptions = { ...defaults }): Plugin => { return; } - const inputs = extractEsbuildInputs(outputs); - const copiedAssets: string[] = []; + const compiledEntryPoints = extractEsbuildCompiledEntrypoints(outputs); + + const copiedAssets: CopiedAsset[] = []; externalDirs.forEach((pattern) => { - copiedAssets.push(...processAssetDirectory(pattern, inputs, options)); + copiedAssets.push(...processAssetDirectory(pattern, compiledEntryPoints, options)); }); - const assetsToProcess = Object.keys(outputs).concat(copiedAssets); - - for (const assetToProcess of assetsToProcess) { - if (assetToProcess.endsWith(".map")) { - continue; - } - - const destinationUrl = calulateDestinationUrl(assetToProcess); - const sourceUrl = calulateSourceUrl(destinationUrl); - + function prepareAsset(assetPath: string, destinationUrl: string): Asset { var asset: Asset = { url: destinationUrl }; if (options.sriAlgorithms.length > 0) { asset.sri = []; for (const algorithm of options.sriAlgorithms) { - const subresourceIntegrity = calculateSubresourceIntegrity(algorithm, assetToProcess); + const subresourceIntegrity = calculateSubresourceIntegrity( + algorithm, + path.join(options.root, assetPath), + ); asset.sri.push(subresourceIntegrity); } } - assetsManifest[sourceUrl] = asset; + return asset; + } + + // Process entrypoints + const fileHashRegexp = /(-[A-Z0-9]{8})(\.\S+)$/; + for (const compiledEntryPoint in compiledEntryPoints) { + // Convert "public/assets/app-2TLUHCQ6.js" to "app.js" + let sourceUrl = compiledEntryPoint + .replace(options.destDir + "/", "") + .replace(fileHashRegexp, "$2"); + + const destinationUrl = calulateDestinationUrl(compiledEntryPoint); + + assetsManifest[sourceUrl] = prepareAsset(compiledEntryPoint, destinationUrl); + } + + // Process copied assets + for (const copiedAsset of copiedAssets) { + // TODO: I wonder if we can skip .map files earlier + if (copiedAsset.sourcePath.endsWith(".map")) { + continue; + } + + const destinationUrl = calulateDestinationUrl(copiedAsset.destPath); + + // Take the full path of the copied asset and remove everything up to (and including) the "assets/" dir + var sourceUrl = copiedAsset.sourcePath.replace( + path.join(options.root, options.sourceDir, assetsDirName) + "/", + "", + ); + // Then remove the first subdir (e.g. "images/"), since we do not include those in the asset paths + sourceUrl = sourceUrl.substring(sourceUrl.indexOf("/") + 1); + + assetsManifest[sourceUrl] = prepareAsset(copiedAsset.destPath, destinationUrl); } // Write assets manifest to the destination directory - await fs.writeJson(manifest, assetsManifest, { spaces: 2 }); + await fs.writeJson(manifestPath, assetsManifest, { spaces: 2 }); }); }, }; diff --git a/src/esbuild.ts b/src/esbuild.ts index db4231b..84512f7 100644 --- a/src/esbuild.ts +++ b/src/esbuild.ts @@ -26,44 +26,35 @@ const loader: { [ext: string]: Loader } = { ".ttf": "file", }; +const assetsDirName = "assets"; const entryPointExtensions = "app.{js,ts,mjs,mts,tsx,jsx}"; -// FIXME: make cross platform -const entryPointsMatcher = /(app\/assets\/js\/|slices\/(.*\/)assets\/js\/)/; -const findEntryPoints = (root: string): Record => { +const findEntryPoints = (sliceRoot: string): Record => { const result: Record = {}; - // TODO: should this be done explicitly within the root? const entryPoints = globSync([ - path.join("app", "assets", "js", "**", entryPointExtensions), - path.join("slices", "*", "assets", "js", "**", entryPointExtensions), + path.join(sliceRoot, assetsDirName, "js", "**", entryPointExtensions), ]); entryPoints.forEach((entryPoint) => { - let modifiedPath = entryPoint.replace(entryPointsMatcher, "$2"); - const relativePath = path.relative(root, modifiedPath); + let entryPointPath = entryPoint.replace(sliceRoot + "/assets/js/", ""); - const { dir, name } = path.parse(relativePath); + const { dir, name } = path.parse(entryPointPath); if (dir) { - modifiedPath = path.join(dir, name); + entryPointPath = path.join(dir, name); } else { - modifiedPath = name; + entryPointPath = name; } - result[modifiedPath] = entryPoint; + result[entryPointPath] = entryPoint; }); return result; }; -// TODO: feels like this really should be passed a root too, to become the cwd for globSync -const externalDirectories = (): string[] => { - const assetDirsPattern = [ - path.join("app", "assets", "*"), - path.join("slices", "*", "assets", "*"), - ]; - +const findExternalDirectories = (basePath: string): string[] => { + const assetDirsPattern = [path.join(basePath, assetsDirName, "*")]; const excludeDirs = ["js", "css"]; try { @@ -84,21 +75,24 @@ const externalDirectories = (): string[] => { export const buildOptions = (root: string, args: Args): EsbuildOptions => { const pluginOptions: PluginOptions = { ...pluginDefaults, + root: root, + sourceDir: args.path, + destDir: args.dest, sriAlgorithms: args.sri || [], }; const plugin = esbuildPlugin(pluginOptions); const options: EsbuildOptions = { bundle: true, - outdir: path.join(root, "public", "assets"), + outdir: args.dest, absWorkingDir: root, loader: loader, - external: externalDirectories(), + external: findExternalDirectories(path.join(root, args.path)), logLevel: "info", minify: true, sourcemap: true, entryNames: "[dir]/[name]-[hash]", - entryPoints: findEntryPoints(root), + entryPoints: findEntryPoints(path.join(root, args.path)), plugins: [plugin], }; @@ -108,21 +102,24 @@ export const buildOptions = (root: string, args: Args): EsbuildOptions => { export const watchOptions = (root: string, args: Args): EsbuildOptions => { const pluginOptions: PluginOptions = { ...pluginDefaults, + root: root, + sourceDir: args.path, + destDir: args.dest, hash: false, }; const plugin = esbuildPlugin(pluginOptions); const options: EsbuildOptions = { bundle: true, - outdir: path.join(root, "public", "assets"), + outdir: args.dest, absWorkingDir: root, loader: loader, - external: externalDirectories(), + external: findExternalDirectories(path.join(root, args.path)), logLevel: "info", minify: false, sourcemap: false, entryNames: "[dir]/[name]", - entryPoints: findEntryPoints(root), + entryPoints: findEntryPoints(path.join(root, args.path)), plugins: [plugin], }; diff --git a/src/index.ts b/src/index.ts index 202fb87..0182687 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import fs from "fs-extra"; import path from "path"; import esbuild, { BuildContext } from "esbuild"; @@ -15,6 +13,7 @@ interface RunOptions { type EsbuildOptionsFn = (args: Args, esbuildOptions: EsbuildOptions) => EsbuildOptions; export const run = async function (options?: RunOptions): Promise { + // TODO: Allow root to be provided (optionally) as a --root arg const { root = process.cwd(), argv = process.argv, esbuildOptionsFn = null } = options || {}; const args = parseArgs(argv); diff --git a/test/hanami-assets.test.ts b/test/hanami-assets.test.ts index b0ad447..ac23c53 100644 --- a/test/hanami-assets.test.ts +++ b/test/hanami-assets.test.ts @@ -11,10 +11,14 @@ const watchTimeout = 60000; // ms (60 seconds) // Helper function to create a test environment async function createTestEnvironment() { // Create temporary directories + await fs.ensureDir(path.join(dest, "app/assets/css")); await fs.ensureDir(path.join(dest, "app/assets/js")); - await fs.ensureDir(path.join(dest, "app/assets/images")); + await fs.ensureDir(path.join(dest, "app/assets/js/nested")); + await fs.ensureDir(path.join(dest, "app/assets/images/nested")); + await fs.ensureDir(path.join(dest, "app/assets/fonts")); await fs.ensureDir(path.join(dest, "slices/admin/assets/js")); - await fs.ensureDir(path.join(dest, "slices/metrics/assets/js")); + await fs.ensureDir(path.join(dest, "slices/admin/assets/images/nested")); + await fs.ensureDir(path.join(dest, "slices/admin/assets/fonts")); await fs.ensureDir(path.join(dest, "public")); process.chdir(dest); @@ -35,62 +39,113 @@ describe("hanami-assets", () => { await cleanTestEnvironment(); }); - test("copies assets from app/assets to public/assets and generates a manifest file", async () => { - const entryPoint1 = path.join(dest, "app/assets/js/app.js"); - const entryPoint2 = path.join(dest, "slices/admin/assets/js/app.js"); - const entryPoint3 = path.join(dest, "slices/metrics/assets/js/app.ts"); - await fs.writeFile(entryPoint1, "console.log('Hello, World!');"); - await fs.writeFile(entryPoint2, "console.log('Hello, Admin!');"); - await fs.writeFile(entryPoint3, "console.log('Hello, Metrics!');"); + test("copies assets from the app to public/assets and generates a manifest file", async () => { + // Prepare both app and assets slices to make it clear the slice assets are _not_ compiled here + const appEntryPoint = path.join(dest, "app/assets/js/app.js"); + await fs.writeFile(appEntryPoint, "console.log('Hello, World!');"); + const appImage = path.join(dest, "app/assets/images/nested/app-image.jpg"); + await fs.writeFile(appImage, "app-image"); + const appFont = path.join(dest, "app/assets/fonts/app-font.otf"); + await fs.writeFile(appFont, "app-font"); + + const sliceEntryPoint = path.join(dest, "slices/admin/assets/js/app.js"); + await fs.writeFile(sliceEntryPoint, "console.log('Hello, Admin!');"); + const sliceImage = path.join(dest, "slices/admin/assets/images/nested/slice-image.jpg"); + await fs.writeFile(sliceImage, ""); // Compile assets - await assets.run({ root: dest }); + await assets.run({ root: dest, argv: ["--path=app", "--dest=public/assets"] }); // FIXME: this path should take into account the file hashing in the file name const appAsset = globSync(path.join("public/assets/app-*.js"))[0]; const appAssetExists = await fs.pathExists(appAsset); expect(appAssetExists).toBe(true); - // FIXME: this path should take into account the file hashing in the file name - const sliceAsset1 = globSync(path.join("public/assets/admin/app-*.js"))[0]; - const sliceAssetExists1 = await fs.pathExists(sliceAsset1); - expect(sliceAssetExists1).toBe(true); + const manifestExists = await fs.pathExists(path.join(dest, "public/assets/assets.json")); + expect(manifestExists).toBe(true); + + // Read and parse the manifest file + const manifestContent = await fs.readFile( + path.join(dest, "public/assets/assets.json"), + "utf-8", + ); + const manifest = JSON.parse(manifestContent); + + // Check if the manifest contains the correct file paths + expect(manifest).toEqual({ + "app-font.otf": { + url: "/assets/app-font-E47AB73F.otf", + }, + "app.js": { + url: "/assets/app-JLSTK5SN.js", + }, + "nested/app-image.jpg": { + url: "/assets/nested/app-image-C6CAD725.jpg", + }, + }); + }); + + test("copies assets from an admin slice to public/assets/admin and generates a manifest file", async () => { + // Prepate both app and assets slices to make it clear the app assets are _not_ compiled here + const appEntryPoint = path.join(dest, "app/assets/js/app.js"); + await fs.writeFile(appEntryPoint, "console.log('Hello, World!');"); + const appImage = path.join(dest, "app/assets/images/nested/app-image.jpg"); + await fs.writeFile(appImage, ""); + + const sliceEntryPoint = path.join(dest, "slices/admin/assets/js/app.js"); + await fs.writeFile(sliceEntryPoint, "console.log('Hello, Admin!');"); + const sliceImage = path.join(dest, "slices/admin/assets/images/nested/slice-image.jpg"); + await fs.writeFile(sliceImage, "slice-image"); + const sliceFont = path.join(dest, "slices/admin/assets/fonts/slice-font.otf"); + await fs.writeFile(sliceFont, "slice-font"); + + // Compile assets + await assets.run({ root: dest, argv: ["--path=slices/admin", "--dest=public/assets/admin"] }); // FIXME: this path should take into account the file hashing in the file name - const sliceAsset2 = globSync(path.join("public/assets/metrics/app-*.js"))[0]; - const sliceAssetExists2 = await fs.pathExists(sliceAsset2); - expect(sliceAssetExists2).toBe(true); + const sliceAsset = globSync(path.join("public/assets/admin/app-*.js"))[0]; + const sliceAssetExists = await fs.pathExists(sliceAsset); + expect(sliceAssetExists).toBe(true); - const manifestExists = await fs.pathExists(path.join(dest, "public/assets.json")); + const manifestExists = await fs.pathExists(path.join(dest, "public/assets/admin/assets.json")); expect(manifestExists).toBe(true); // Read and parse the manifest file - const manifestContent = await fs.readFile(path.join(dest, "public/assets.json"), "utf-8"); + const manifestContent = await fs.readFile( + path.join(dest, "public/assets/admin/assets.json"), + "utf-8", + ); const manifest = JSON.parse(manifestContent); // Check if the manifest contains the correct file paths expect(manifest).toEqual({ - "admin/app.js": { - url: "/assets/admin/app-NLRESL5A.js", - }, "app.js": { - url: "/assets/app-JLSTK5SN.js", + url: "/assets/admin/app-ITGLRDE7.js", }, - "metrics/app.js": { - url: "/assets/metrics/app-27Z7ZALS.js", + "nested/slice-image.jpg": { + url: "/assets/admin/nested/slice-image-4951F7C9.jpg", + }, + "slice-font.otf": { + url: "/assets/admin/slice-font-826F93B7.otf", }, }); }); test("generates SRI", async () => { - const entryPoint1 = path.join(dest, "app/assets/js/app.js"); - await fs.writeFile(entryPoint1, "console.log('Hello, World!');"); + const appEntryPoint = path.join(dest, "app/assets/js/app.js"); + await fs.writeFile(appEntryPoint, "console.log('Hello, World!');"); // Compile assets - await assets.run({ root: dest, argv: ["--sri=sha256,sha384,sha512"] }); + await assets.run({ + root: dest, + argv: ["--path=app", "--dest=public/assets", "--sri=sha256,sha384,sha512"], + }); // Read and parse the manifest file - const manifestContent = await fs.readFile(path.join(dest, "public/assets.json"), "utf-8"); + const manifestContent = await fs.readFile( + path.join(dest, "public/assets/assets.json"), + "utf-8", + ); const manifest = JSON.parse(manifestContent); // Check if the manifest contains the correct file paths @@ -106,49 +161,66 @@ describe("hanami-assets", () => { }); }); - test("Full app", async () => { - fs.copySync(path.join(__dirname, "fixtures", "todo"), dest); + test("handles CSS", async () => { + const entryPoint = path.join(dest, "app/assets/js/app.js"); + await fs.writeFile(entryPoint, 'import "../css/app.css";'); + const cssFile = path.join(dest, "app/assets/css/app.css"); + await fs.writeFile(cssFile, ".btn { background: #f00; }"); + + await assets.run({ root: dest, argv: ["--path=app", "--dest=public/assets"] }); + + const entryPointExists = await fs.pathExists(path.join("public/assets/app-6PW7FGD5.js")); + expect(entryPointExists).toBe(true); + const cssExists = await fs.pathExists(path.join("public/assets/app-HYVEQYF6.css")); + expect(cssExists).toBe(true); + + const manifestContent = await fs.readFile( + path.join(dest, "public/assets/assets.json"), + "utf-8", + ); + const manifest = JSON.parse(manifestContent); + + expect(manifest).toEqual({ + "app.css": { + url: "/assets/app-HYVEQYF6.css", + }, + "app.js": { + url: "/assets/app-6PW7FGD5.js", + }, + }); + }); + + test("handles TypeScript", async () => { + const entryPoint1 = path.join(dest, "app/assets/js/app.ts"); + await fs.writeFile(entryPoint1, "console.log('Hello from TS!');"); + const entryPoint2 = path.join(dest, "app/assets/js/nested/app.tsx"); + await fs.writeFile(entryPoint2, "console.log('Hello from TSX!');"); // Compile assets - await assets.run({ root: dest, argv: ["--sri=sha384"] }); + await assets.run({ root: dest, argv: ["--path=app", "--dest=public/assets"] }); + + const asset1Exists = await fs.pathExists(path.join("public/assets/app-2TLUHCQ6.js")); + expect(asset1Exists).toBe(true); + const asset2Exists = await fs.pathExists(path.join("public/assets/nested/app-5VHYTKP2.js")); + expect(asset2Exists).toBe(true); + + const manifestExists = await fs.pathExists(path.join(dest, "public/assets/assets.json")); + expect(manifestExists).toBe(true); // Read and parse the manifest file - const manifestContent = await fs.readFile(path.join(dest, "public/assets.json"), "utf-8"); + const manifestContent = await fs.readFile( + path.join(dest, "public/assets/assets.json"), + "utf-8", + ); const manifest = JSON.parse(manifestContent); // Check if the manifest contains the correct file paths expect(manifest).toEqual({ "app.js": { - url: "/assets/app-YRYN3NGE.js", - sri: ["sha384-WAsFKE/RcOorRHTXmdRD8gxW+IxxfzKHbRgzcCuhFDC5StKi+6T+AawxcUmuv8Z5"], - }, - "background.jpg": { - url: "/assets/background-UU2XY655.jpg", - sri: ["sha384-M7QyKTUfzyVWNC4FoMYq0ypu7LDifAYWEtXRT5d6M3Prpau9t5wavW1216HhvCJc"], - }, - "app.css": { - url: "/assets/app-4HPGUYGF.css", - sri: ["sha384-KsEObWWMvw+PouA5LgKpXohYpsOO4h9dL9pv7LwznkIg83/n1gkJo+S/oU/9Qb8Q"], + url: "/assets/app-2TLUHCQ6.js", }, - "login/app.js": { - url: "/assets/login/app-I4563JRL.js", - sri: ["sha384-z0TVeAyYeMsyiCnAqNu/OYs+IxvLwkTocy2uchAChAHmXaV68xYonUUzn1wJ4myH"], - }, - "admin/app.js": { - url: "/assets/admin/app-H646WNEB.js", - sri: ["sha384-noZH9am6sCla+CnG7l+IGxBlTqo68Wz891fhqfIF1U2kgafUrRzZewAt0yA6jl15"], - }, - "font.otf": { - url: "/assets/font-E1A70B27.otf", - sri: ["sha384-Lpm/oUsCQkOg41WyENyyB1zjaX/FB522VWlU44JKakwzwBxvu11le0ILkiPsR73K"], - }, - "logo.png": { - url: "/assets/logo-C1EF77E4.png", - sri: ["sha384-7q5x+ZjZrCoWwyV0BTyc8HUPf1xr+n9l77gwxmwywPWSe0PtopZj1T8NTUPFo0FI"], - }, - "nested/image.jpg": { - url: "/assets/nested/image-83509E65.jpg", - sri: ["sha384-M7QyKTUfzyVWNC4FoMYq0ypu7LDifAYWEtXRT5d6M3Prpau9t5wavW1216HhvCJc"], + "nested/app.js": { + url: "/assets/nested/app-5VHYTKP2.js", }, }); }); @@ -170,7 +242,10 @@ describe("hanami-assets", () => { const imageAsset = path.join(dest, "public", "assets", "background.jpg"); // Watch for asset changes - let ctx = await assets.run({ root: dest, argv: ["--watch"] }); + let ctx = await assets.run({ + root: dest, + argv: ["--path=app", "--dest=public/assets", "--watch"], + }); await fs.writeFile(entryPoint, "console.log('Hello, Watch!');"); @@ -205,11 +280,14 @@ describe("hanami-assets", () => { // Check if the asset has the expected contents expect(assetContent).toMatch('console.log("Hello, Watch!");'); - const manifestExists = await fs.pathExists(path.join(dest, "public/assets.json")); + const manifestExists = await fs.pathExists(path.join(dest, "public/assets/assets.json")); expect(manifestExists).toBe(true); // Read and parse the manifest file - const manifestContent = await fs.readFile(path.join(dest, "public/assets.json"), "utf-8"); + const manifestContent = await fs.readFile( + path.join(dest, "public/assets/assets.json"), + "utf-8", + ); const manifest = JSON.parse(manifestContent); expect(manifest["background.jpg"]).toEqual({