From 804f79cce6a2db3a32e19dd9ffb7ecb0b70f4463 Mon Sep 17 00:00:00 2001 From: vrugtehagel Date: Mon, 17 Jun 2024 00:09:35 +0200 Subject: [PATCH] Initial commit --- .github/workflows/publish.yml | 17 ++++ README.md | 16 ++++ deno.json | 11 +++ mod.ts | 170 ++++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 deno.json create mode 100644 mod.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ffdb8e4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,17 @@ +name: Publish + +on: {} + # push: + # branches: + # - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v1 + - run: deno publish diff --git a/README.md b/README.md index fe0fff5..1578a0f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # eleventy-asset-hash + Adds a hash query parameter to URLs in Eleventy projects + +## Installation + +To install, run any of the following commands: + +```bash +# For npm: +npx jsr add @vrugtehagel/eleventy-asset-hash +# For yarn: +yarn dlx jsr add @vrugtehagel/eleventy-asset-hash +# For pnpm: +pnpm dlx jsr add @vrugtehagel/eleventy-asset-hash +# For deno: +deno add @vrugtehagel/eleventy-asset-hash +``` diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..786dec6 --- /dev/null +++ b/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@vrugtehagel/eleventy-asset-hash", + "version": "0.0.1", + "exports": "./mod.ts", + "publish": { + "include": ["mod.ts", "README.md"] + }, + "tasks": { + "check": "deno publish --dry-run --allow-dirty" + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..81df424 --- /dev/null +++ b/mod.ts @@ -0,0 +1,170 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const DEFAULTS: EleventyAssetHashOptions = { + algorithm: "SHA-256", + maxLength: Infinity, + param: "v", + processExtensions: ["html", "css", "js"], + hashedExtensions: ["css", "js"], +}; + +/** The Eleventy config object, should be a better type than "any" but alas */ +type EleventyConfig = any; +/** Checksums are a shortish clash-tolerant string representing a file's contents */ +type Checksum = string; +/** Any asset path, relative to the file it's in or absolute (starting with /) */ +type AssetPath = string; +/** Asset paths, relative to the Eleventy config file */ +type FullAssetPath = string; +/** + * End index of a found asset path in the original source file. + * This essentially indicates where to insert the query parameter. + */ +type EndIndex = number; + +type EleventyAssetHashOptions = { + /** + * An algorithm to hash with. Must be supported by crypto.subtle.digest(). + * This option is ignored if a custom `computeChecksum` function is provided. + */ + algorithm: string; + /** + * Maximum length of the checksum, for shorter (but less clash-resistant) hashes. + * This option is ignored if a custom `computeChecksum` function is provided. + */ + maxLength: number; + /** Extensions of the files to transform URLs in. */ + processExtensions: string[]; + /** The name of the query param to use */ + param: string; + /** The extensions for the files to hash */ + hashedExtensions: string[]; + /** + * A path to resolve absolute URLs to. Defaults to Elevent output dir. + * Ignored if a custom `resolvePath` function is given. + */ + rootDir?: string; + /** + * Custom checksum function, mapping a file path to a checksum. + * Return null if the file should not be hashed (or does not exist) + */ + computeChecksum?: (path: FullAssetPath) => Promise; + /** + * Custom function to map a found asset path to a full path. + * The full path must be relative to the project root. + */ + resolvePath?: (path: AssetPath, page: any) => FullAssetPath; +}; + +/** + * Creates a `computeChecksum` function given an algorithm. + * Not used if the `computeChecksum` option is provided. + */ +function createChecksumComputer( + algorithm: string, + maxLength: number, +): (assetPath: FullAssetPath) => Checksum | null { + const syncCache = new Map(); + const asyncCache = new Map>(); + const computeChecksum = async ( + assetPath: FullAssetPath, + ): Promise => { + const body = await fs.readFile(assetPath).catch(() => null); + if (body == null) return null; + const buffer = crypto.subtle.digest(algorithm, body); + const uint8Array = new Uint8Array(buffer); + const rawChecksum = String.fromCharCode(...uint8Array); + const checksum = btoa(rawChecksum); + if (!Number.isFinite(maxLength)) return checksum; + return checksum.slice(0, maxLength); + }; + return async (assetPath: FullAssetPath): Promise => { + if (syncCache.has(assetPath)) return syncCache.get(assetPath); + if (asyncCache.has(assetPath)) return await asyncCache.get(assetPath); + const promise = computeChecksum(assetPath); + asyncCache.set(assetPath, promise); + const checksum = await promise; + syncCache.set(assetPath, checksum); + asyncCache.delete(assetPath); + return checksum; + }; +} + +/** Insert a string at a certain position into another string */ +function insertAt(target: string, inserted: string, index: number): string { + return target.slice(0, index) + inserted + target.slice(index); +} + +/** The plugin itself, with an optional options object as second argument. */ +export default function EleventyAssetHash( + config: EleventyConfig, + options: Partial = {}, +) { + const normalizedOptions = Object.assign({}, DEFAULTS, options); + if (!normalizedOptions.processExtensions.every((ext) => ext in PROCESSORS)) { + throw new Error(`Unprocessable extension "${ext}" specified.`); + } + const computeChecksum = normalizedOptions.computeChecksum ?? + createChecksumComputer( + normalizedOptions.algorithm, + normalizedOptions.maxLength, + ); + + /** Map an AssetPath to its FullAssetPath (relative to project root) */ + const rootDir = normalizedOptions.rootDir ?? config.dir.output; + function defaultResolvePath( + assetPath: AssetPath, + page: any, + ): FullAssetPath { + const isAbsolute = assetPath.startsWith("/"); + if (isAbsolute) return path.resolve(rootDir, `.${assetPath}`); + return path.resolve(path.dirname(page.outputDir), assetPath); + } + const resolvePath = normalizedOptions.resolvePath ?? defaultResolvePath; + const invalidHashedExtension = normalizedOptions.hashedExtensions + .find((extension) => /\W/.test(extension)); + if (invalidHashedExtension != null) { + throw new Error(`Cannot match extension "${invalidHashedExtension}"`); + } + const urlChars = `[-.\\w~:/?#[\\]@!$&'()*+,;%=]*`; + const extensionRefex = `(?:${normalizedOptions.hashedExtensions.join("|")})`; + const assetPathRegex = new RegExp( + `\\.{0,2}(? [match[0], match.index + match[0].length]); + if (matches.length == 0) return content; + const promises = assetPathMatches.map(async ([match, endIndex]) => [ + endIndex, + await computeChecksum(resolvePath(match)), + ]); + // Flip it so we can loop-and-replace without messing up end indexes + const insertions = await Promise.all(promises.reverse()); + let result = content; + for (const [endIndex, checksum] of insertions) { + const hasQueryParams = results[endIndex + 1] == "?"; + const param = `${normalizedOptions.param}=${checksum}`; + if (hasQueryParams) { + result = insertAt(result, `${param}&`, indexIndex + 1); + } else { + result = insertAt(result, param, endIndex); + } + } + return result; + }, + ); +}