Skip to content

Commit

Permalink
Export run function as a streamlined but flexible main entrypoint (#10
Browse files Browse the repository at this point in the history
)

Introduce a new `assets.run` entrypoint function that is:

- Streamlined enough for us to generate into every new Hanami app
- But flexible enough for users to provide arbitrary esbuild customisations

Here's what a default `config/assets.mjs` file would look like using this API:

```js
import * as assets from "hanami-assets";

await assets.run();
```

And here's how it would look to add a custom esbuild plugin:

```js
import * as assets from "hanami-assets";
import postcss from "esbuild-postcss";

await assets.run({
  esbuildOptionsFn: (args, esbuildOptions) => {
    const plugins = [...esbuildOptions.plugins, postcss()];

    return {
      ...esbuildOptions,
      plugins,
    };
  },
});
```

Also:

- Remove the config indicating that this package exposes an executable. Running via a ` config/assets.mjs` script is now the one and only way to use this package.
- Use Prettier for JS file linting/auto-formatting.
- Update TypeScript `compilerOptions` to use `"module": "NodeNext"` (per their recommendations for modern Node projects)
  • Loading branch information
timriley authored Oct 12, 2023
1 parent e357e6c commit 868d43e
Show file tree
Hide file tree
Showing 32 changed files with 836 additions and 559 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Lint
run: npx prettier . --check

- name: Test
run: make test
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Ignore artifacts
dist

# Ignore non-JS files
*.md
1 change: 1 addition & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "printWidth": 100 }
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ test: build
build:
rm -rf $(BUILD_DIR)
npm run build
chmod +x $(BUILD_DIR)/hanami-assets.js
5 changes: 5 additions & 0 deletions dist/args.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface Args {
watch: Boolean;
sri: string[] | null;
}
export declare const parseArgs: (args: string[]) => Args;
11 changes: 11 additions & 0 deletions dist/args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const parseArgs = (args) => {
const result = {};
args.forEach((arg) => {
const [key, value] = arg.replace(/^--/, "").split("=");
result[key] = value;
});
return {
watch: result.hasOwnProperty("watch"),
sri: result["sri"]?.split(","),
};
};
12 changes: 12 additions & 0 deletions dist/esbuild-plugin.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Plugin } from "esbuild";
export interface PluginOptions {
root: string;
publicDir: string;
destDir: string;
manifestPath: string;
sriAlgorithms: Array<string>;
hash: boolean;
}
export declare const defaults: Pick<PluginOptions, "root" | "publicDir" | "destDir" | "manifestPath" | "sriAlgorithms" | "hash">;
declare const hanamiEsbuild: (options?: PluginOptions) => Plugin;
export default hanamiEsbuild;
80 changes: 38 additions & 42 deletions dist/hanami-esbuild-plugin.js → dist/esbuild-plugin.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,48 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.defaults = void 0;
const fs_extra_1 = __importDefault(require("fs-extra"));
const path_1 = __importDefault(require("path"));
const node_crypto_1 = __importDefault(require("node:crypto"));
const URL_SEPARATOR = '/';
exports.defaults = {
root: '',
publicDir: 'public',
destDir: path_1.default.join('public', 'assets'),
manifestPath: path_1.default.join('public', 'assets.json'),
import fs from "fs-extra";
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 = { ...exports.defaults }) => {
const hanamiEsbuild = (options = { ...defaults }) => {
return {
name: 'hanami-esbuild',
name: "hanami-esbuild",
setup(build) {
build.initialOptions.metafile = true;
options.root = options.root || process.cwd();
const manifest = path_1.default.join(options.root, options.manifestPath);
const manifest = path.join(options.root, options.manifestPath);
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}/, '');
return normalizeUrl(str)
.replace(/\/assets\//, "")
.replace(/-[A-Z0-9]{8}/, "");
};
const calulateDestinationUrl = (str) => {
return normalizeUrl(str).replace(/public/, '');
return normalizeUrl(str).replace(/public/, "");
};
const normalizeUrl = (str) => {
return str.replace(/[\\]+/, URL_SEPARATOR);
};
const calculateSubresourceIntegrity = (algorithm, path) => {
const content = fs_extra_1.default.readFileSync(path, 'utf8');
const hash = node_crypto_1.default.createHash(algorithm).update(content).digest('base64');
const content = fs.readFileSync(path, "utf8");
const hash = crypto.createHash(algorithm).update(content).digest("base64");
return `${algorithm}-${hash}`;
};
// Inspired by https://github.com/evanw/esbuild/blob/2f2b90a99d626921d25fe6d7d0ca50bd48caa427/internal/bundler/bundler.go#L1057
const calculateHash = (hashBytes, hash) => {
if (!hash) {
return null;
}
const result = node_crypto_1.default.createHash('sha256').update(hashBytes).digest('hex');
const result = crypto.createHash("sha256").update(hashBytes).digest("hex");
return result.slice(0, 8).toUpperCase();
};
function extractEsbuildInputs(inputData) {
Expand All @@ -63,36 +59,36 @@ const hanamiEsbuild = (options = { ...exports.defaults }) => {
}
// TODO: profile the current implementation vs blindly copying the asset
const copyAsset = (srcPath, destPath) => {
if (fs_extra_1.default.existsSync(destPath)) {
const srcStat = fs_extra_1.default.statSync(srcPath);
const destStat = fs_extra_1.default.statSync(destPath);
if (fs.existsSync(destPath)) {
const srcStat = fs.statSync(srcPath);
const destStat = fs.statSync(destPath);
// File already exists and is up-to-date, skip copying
if (srcStat.mtimeMs <= destStat.mtimeMs) {
return;
}
}
if (!fs_extra_1.default.existsSync(path_1.default.dirname(destPath))) {
fs_extra_1.default.mkdirSync(path_1.default.dirname(destPath), { recursive: true });
if (!fs.existsSync(path.dirname(destPath))) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
}
fs_extra_1.default.copyFileSync(srcPath, destPath);
fs.copyFileSync(srcPath, destPath);
return;
};
const processAssetDirectory = (pattern, inputs, options) => {
const dirPath = path_1.default.dirname(pattern);
const files = fs_extra_1.default.readdirSync(dirPath);
const dirPath = path.dirname(pattern);
const files = fs.readdirSync(dirPath);
const assets = [];
files.forEach((file) => {
const srcPath = path_1.default.join(dirPath, file);
const srcPath = path.join(dirPath, file);
// Skip if the file is already processed by esbuild
if (inputs.hasOwnProperty(srcPath)) {
return;
}
const fileHash = calculateHash(fs_extra_1.default.readFileSync(srcPath), options.hash);
const fileExtension = path_1.default.extname(srcPath);
const baseName = path_1.default.basename(srcPath, fileExtension);
const destFileName = [baseName, fileHash].filter(item => item !== null).join("-") + fileExtension;
const destPath = path_1.default.join(options.destDir, path_1.default.relative(dirPath, srcPath).replace(file, destFileName));
if (fs_extra_1.default.lstatSync(srcPath).isDirectory()) {
const fileHash = calculateHash(fs.readFileSync(srcPath), options.hash);
const fileExtension = path.extname(srcPath);
const baseName = path.basename(srcPath, fileExtension);
const destFileName = [baseName, fileHash].filter((item) => item !== null).join("-") + fileExtension;
const destPath = path.join(options.destDir, path.relative(dirPath, srcPath).replace(file, destFileName));
if (fs.lstatSync(srcPath).isDirectory()) {
assets.push(...processAssetDirectory(destPath, inputs, options));
}
else {
Expand All @@ -102,7 +98,7 @@ const hanamiEsbuild = (options = { ...exports.defaults }) => {
});
return assets;
};
if (typeof outputs === 'undefined') {
if (typeof outputs === "undefined") {
return;
}
const inputs = extractEsbuildInputs(outputs);
Expand All @@ -112,7 +108,7 @@ const hanamiEsbuild = (options = { ...exports.defaults }) => {
});
const assetsToProcess = Object.keys(outputs).concat(copiedAssets);
for (const assetToProcess of assetsToProcess) {
if (assetToProcess.endsWith('.map')) {
if (assetToProcess.endsWith(".map")) {
continue;
}
const destinationUrl = calulateDestinationUrl(assetToProcess);
Expand All @@ -128,9 +124,9 @@ const hanamiEsbuild = (options = { ...exports.defaults }) => {
assetsManifest[sourceUrl] = asset;
}
// Write assets manifest to the destination directory
await fs_extra_1.default.writeJson(manifest, assetsManifest, { spaces: 2 });
await fs.writeJson(manifest, assetsManifest, { spaces: 2 });
});
},
};
};
exports.default = hanamiEsbuild;
export default hanamiEsbuild;
7 changes: 7 additions & 0 deletions dist/esbuild.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BuildOptions, Plugin } from "esbuild";
import { Args } from "./args.js";
export interface EsbuildOptions extends Partial<BuildOptions> {
plugins: Plugin[];
}
export declare const buildOptions: (root: string, args: Args) => EsbuildOptions;
export declare const watchOptions: (root: string, args: Args) => EsbuildOptions;
107 changes: 107 additions & 0 deletions dist/esbuild.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import path from "path";
import { globSync } from "glob";
import esbuildPlugin, { defaults as pluginDefaults } from "./esbuild-plugin.js";
const loader = {
".tsx": "tsx",
".ts": "ts",
".js": "js",
".jsx": "jsx",
".json": "json",
".png": "file",
".jpg": "file",
".jpeg": "file",
".gif": "file",
".svg": "file",
".woff": "file",
".woff2": "file",
".otf": "file",
".eot": "file",
".ttf": "file",
};
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 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),
]);
entryPoints.forEach((entryPoint) => {
let modifiedPath = entryPoint.replace(entryPointsMatcher, "$2");
const relativePath = path.relative(root, modifiedPath);
const { dir, name } = path.parse(relativePath);
if (dir) {
modifiedPath = path.join(dir, name);
}
else {
modifiedPath = name;
}
result[modifiedPath] = 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 excludeDirs = ["js", "css"];
try {
const dirs = globSync(assetDirsPattern, { nodir: false });
const filteredDirs = dirs.filter((dir) => {
const dirName = dir.split(path.sep).pop();
return !excludeDirs.includes(dirName);
});
return filteredDirs.map((dir) => path.join(dir, "*"));
}
catch (err) {
console.error("Error listing external directories:", err);
return [];
}
};
// TODO: reuse the logic between these two methods below
export const buildOptions = (root, args) => {
const pluginOptions = {
...pluginDefaults,
sriAlgorithms: args.sri || [],
};
const plugin = esbuildPlugin(pluginOptions);
const options = {
bundle: true,
outdir: path.join(root, "public", "assets"),
absWorkingDir: root,
loader: loader,
external: externalDirectories(),
logLevel: "silent",
minify: true,
sourcemap: true,
entryNames: "[dir]/[name]-[hash]",
entryPoints: findEntryPoints(root),
plugins: [plugin],
};
return options;
};
export const watchOptions = (root, args) => {
const pluginOptions = {
...pluginDefaults,
hash: false,
};
const plugin = esbuildPlugin(pluginOptions);
const options = {
bundle: true,
outdir: path.join(root, "public", "assets"),
absWorkingDir: root,
loader: loader,
external: externalDirectories(),
logLevel: "info",
minify: false,
sourcemap: false,
entryNames: "[dir]/[name]",
entryPoints: findEntryPoints(root),
plugins: [plugin],
};
return options;
};
2 changes: 0 additions & 2 deletions dist/hanami-assets.d.ts

This file was deleted.

Loading

0 comments on commit 868d43e

Please sign in to comment.