diff --git a/README.md b/README.md index 1ab2b4d..b24541f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Replace your template engine with fast JavaScript by leveraging the power of [tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates). +ta**ghtml** lets you replace your template engine with fast JavaScript by leveraging the power of [tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates). Inspired by [html-template-tag](https://github.com/AntonioVdlC/html-template-tag). diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 0000000..a390912 --- /dev/null +++ b/bin/README.md @@ -0,0 +1,66 @@ +Append unique hashes to assets referenced in your views to aggressively cache them while guaranteeing that clients receive the most recent versions. + +## Usage + +Running the following command will scan asset files found in the `roots` path(s) and replace their references with hashed versions in the `refs` path(s): + +```sh +npx ghtml --roots="path/to/scan/assets1/,path/to/scan/assets2/" --refs="views/path/to/append/hashes1/,views/path/to/append/hashes2/" +``` + +## Example (Fastify) + +Register `@fastify/static`: + +```js +await fastify.register(import("@fastify/static"), { + root: new URL("assets/", import.meta.url).pathname, + prefix: "/p/assets/", + wildcard: false, + index: false, + immutable: true, + maxAge: process.env.NODE_ENV === "production" ? 31536000 * 1000 : 0, +}); +``` + +Add the `ghtml` command to the build script: + +```json +"scripts": { + "build": "npx ghtml --roots=assets/ --refs=views/,routes/", +}, +``` + +Make sure to `npm run build` in `Dockerfile`: + +```dockerfile +FROM node:latest + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci --include=dev + +COPY . . + +RUN npm run build + +RUN npm prune --omit=dev + +CMD ["npm", "start"] +``` + +## Demo + +A full project that uses the `ghtml` executable can be found in the `example` folder: + +```sh +cd example + +npm i + +npm run build + +node . +``` diff --git a/bin/example/assets/cat.jpeg b/bin/example/assets/cat.jpeg new file mode 100644 index 0000000..8bdfc37 Binary files /dev/null and b/bin/example/assets/cat.jpeg differ diff --git a/bin/example/assets/style.css b/bin/example/assets/style.css new file mode 100644 index 0000000..866308c --- /dev/null +++ b/bin/example/assets/style.css @@ -0,0 +1,19 @@ +body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + margin: 0; + padding: 0; +} + +img { + max-width: 100%; + height: auto; +} + +.caption { + text-align: center; + margin-top: 10px; +} diff --git a/bin/example/package.json b/bin/example/package.json new file mode 100644 index 0000000..57bb9b0 --- /dev/null +++ b/bin/example/package.json @@ -0,0 +1,13 @@ +{ + "type": "module", + "main": "./server.js", + "scripts": { + "start": "node server.js", + "build": "node ../src/index.js --roots=assets/ --refs=routes/" + }, + "dependencies": { + "@fastify/static": "^7.0.1", + "fastify": "^4.26.1", + "fastify-html": "^0.3.3" + } +} diff --git a/bin/example/routes/index.js b/bin/example/routes/index.js new file mode 100644 index 0000000..dd94dde --- /dev/null +++ b/bin/example/routes/index.js @@ -0,0 +1,28 @@ +export default async (fastify) => { + const { html } = fastify; + + fastify.addLayout((inner) => { + return html` + + + + + Document + + + + !${inner} + + `; + }); + + fastify.get("/", async (request, reply) => { + return reply.html` +

Hello, world!

+ Picture of a cat + `; + }); +}; diff --git a/bin/example/server.js b/bin/example/server.js new file mode 100644 index 0000000..937e1ed --- /dev/null +++ b/bin/example/server.js @@ -0,0 +1,22 @@ +/* eslint n/no-missing-import: "off" */ + +import Fastify from "fastify"; + +const fastify = Fastify(); + +// Plugins +await fastify.register(import("@fastify/static"), { + root: new URL("assets/", import.meta.url).pathname, + prefix: "/p/assets/", + wildcard: false, + index: false, + immutable: true, + maxAge: 31536000 * 1000, +}); +await fastify.register(import("fastify-html")); + +// Routes +fastify.register(import("./routes/index.js")); + +await fastify.listen({ port: 5050 }); +console.warn("Server listening at http://localhost:5050"); diff --git a/bin/src/index.js b/bin/src/index.js new file mode 100644 index 0000000..4db6e35 --- /dev/null +++ b/bin/src/index.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import { generateHashesAndReplace } from "./utils.js"; +import process from "node:process"; + +const parseArguments = (args) => { + let roots = null; + let refs = null; + + for (const arg of args) { + if (arg.startsWith("--roots=")) { + roots = arg.split("=", 2)[1].split(","); + } else if (arg.startsWith("--refs=")) { + refs = arg.split("=", 2)[1].split(","); + } + } + + if (!roots || !refs) { + console.error( + 'Usage: npx ghtml --roots="path/to/scan/assets1/,path/to/scan/assets2/" --refs="views/path/to/append/hashes1/,views/path/to/append/hashes2/"', + ); + process.exit(1); + } + + return { roots, refs }; +}; + +const main = async () => { + const { roots, refs } = parseArguments(process.argv.slice(2)); + + try { + console.warn(`Generating hashes and updating file paths...`); + console.warn(`Scanning files in: ${roots}`); + console.warn(`Updating files in: ${refs}`); + + await generateHashesAndReplace({ + roots, + refs, + }); + + console.warn("Hash generation and file updates completed successfully."); + } catch (error) { + console.error(`Error occurred: ${error.message}`); + process.exit(1); + } +}; + +main(); diff --git a/bin/src/utils.js b/bin/src/utils.js new file mode 100644 index 0000000..0a4e13e --- /dev/null +++ b/bin/src/utils.js @@ -0,0 +1,129 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { win32, posix } from "node:path"; +import { cpus } from "node:os"; +import { Glob } from "glob"; +import { promise as fastq } from "fastq"; +const fastqConcurrency = Math.max(1, cpus().length - 1); + +const generateFileHash = async (filePath) => { + try { + const fileBuffer = await readFile(filePath); + return createHash("md5").update(fileBuffer).digest("hex").slice(0, 16); + } catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + return ""; + } +}; + +const updateFilePathsWithHashes = async ( + fileHashes, + refs, + includeDotFiles, + skipPatterns, +) => { + for (let ref of refs) { + ref = ref.split(win32.sep).join(posix.sep); + if (!ref.endsWith("/")) { + ref += "/"; + } + + const filesIterable = new Glob("**/**", { + nodir: true, + follow: true, + absolute: true, + cwd: ref, + dot: includeDotFiles, + ignore: skipPatterns, + }); + + for await (const file of filesIterable) { + let content = await readFile(file, "utf8"); + let found = false; + + for (const [originalPath, hash] of fileHashes) { + const escapedPath = originalPath.replace( + /[$()*+.?[\\\]^{|}]/gu, + "\\$&", + ); + const regex = new RegExp( + `(?${escapedPath})(\\?(?[^#"'\`]*))?`, + "gu", + ); + + content = content.replace( + regex, + (match, p1, p2, p3, offset, string, groups) => { + found = true; + const { path, queryString } = groups; + + return !queryString + ? `${path}?hash=${hash}` + : queryString.includes("hash=") + ? `${path}?${queryString.replace(/(?hash=)[\dA-Fa-f]*/u, `$1${hash}`)}` + : `${path}?hash=${hash}&${queryString}`; + }, + ); + } + + if (found) { + await writeFile(file, content); + } + } + } +}; + +const generateHashesAndReplace = async ({ + roots, + refs, + includeDotFiles = false, + skipPatterns = ["**/node_modules/**"], +}) => { + const fileHashes = new Map(); + roots = Array.isArray(roots) ? roots : [roots]; + refs = Array.isArray(refs) ? refs : [refs]; + + for (let rootPath of roots) { + rootPath = rootPath.split(win32.sep).join(posix.sep); + if (!rootPath.endsWith("/")) { + rootPath += "/"; + } + + const queue = fastq(generateFileHash, fastqConcurrency); + const queuePromises = []; + const files = []; + + const filesIterable = new Glob("**/**", { + nodir: true, + follow: true, + absolute: true, + cwd: rootPath, + dot: includeDotFiles, + ignore: skipPatterns, + }); + + for await (let file of filesIterable) { + file = file.split(win32.sep).join(posix.sep); + files.push(file); + queuePromises.push(queue.push(file)); + } + + const hashes = await Promise.all(queuePromises); + + for (let i = 0; i < files.length; i++) { + const fileRelativePath = posix.relative(rootPath, files[i]); + fileHashes.set(fileRelativePath, hashes[i]); + } + } + + await updateFilePathsWithHashes( + fileHashes, + refs, + includeDotFiles, + skipPatterns, + ); +}; + +export { generateFileHash, generateHashesAndReplace }; diff --git a/package.json b/package.json index 61076c8..6f5ac88 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "MIT", "version": "2.0.4", "type": "module", + "bin": "./bin/src/index.js", "main": "./src/index.js", "exports": { ".": "./src/index.js", @@ -19,9 +20,13 @@ "lint": "eslint . && prettier --check .", "lint:fix": "eslint --fix . && prettier --write ." }, + "dependencies": { + "fastq": "^1.17.1", + "glob": "^10.4.2" + }, "devDependencies": { "@fastify/pre-commit": "^2.1.0", - "c8": "^10.0.0", + "c8": "^10.1.2", "grules": "^0.17.2", "tinybench": "^2.8.0" }, diff --git a/src/html.js b/src/html.js index d4746cf..8d90955 100644 --- a/src/html.js +++ b/src/html.js @@ -1,7 +1,3 @@ -const arrayIsArray = Array.isArray; -const symbolIterator = Symbol.iterator; -const symbolAsyncIterator = Symbol.asyncIterator; - const escapeRegExp = /["&'<>`]/; const escapeFunction = (string) => { const stringLength = string.length; @@ -9,34 +5,34 @@ const escapeFunction = (string) => { let end = 0; let escaped = ""; - do { - switch (string.charCodeAt(end++)) { + for (; end !== stringLength; ++end) { + switch (string.charCodeAt(end)) { case 34: // " - escaped += string.slice(start, end - 1) + """; - start = end; + escaped += string.slice(start, end) + """; + start = end + 1; continue; case 38: // & - escaped += string.slice(start, end - 1) + "&"; - start = end; + escaped += string.slice(start, end) + "&"; + start = end + 1; continue; case 39: // ' - escaped += string.slice(start, end - 1) + "'"; - start = end; + escaped += string.slice(start, end) + "'"; + start = end + 1; continue; case 60: // < - escaped += string.slice(start, end - 1) + "<"; - start = end; + escaped += string.slice(start, end) + "<"; + start = end + 1; continue; case 62: // > - escaped += string.slice(start, end - 1) + ">"; - start = end; + escaped += string.slice(start, end) + ">"; + start = end + 1; continue; case 96: // ` - escaped += string.slice(start, end - 1) + "`"; - start = end; + escaped += string.slice(start, end) + "`"; + start = end + 1; continue; } - } while (end !== stringLength); + } escaped += string.slice(start, end); @@ -61,7 +57,7 @@ const html = ({ raw: literals }, ...expressions) => { ? expression : expression == null ? "" - : arrayIsArray(expression) + : Array.isArray(expression) ? expression.join("") : `${expression}`; @@ -98,7 +94,7 @@ const htmlGenerator = function* ({ raw: literals }, ...expressions) { } else if (expression == null) { string = ""; } else { - if (expression[symbolIterator]) { + if (expression[Symbol.iterator]) { const isRaw = literal !== "" && literal.charCodeAt(literal.length - 1) === 33; @@ -118,7 +114,7 @@ const htmlGenerator = function* ({ raw: literals }, ...expressions) { continue; } - if (expression[symbolIterator]) { + if (expression[Symbol.iterator]) { for (expression of expression) { if (typeof expression === "string") { string = expression; @@ -195,7 +191,7 @@ const htmlAsyncGenerator = async function* ({ raw: literals }, ...expressions) { } else if (expression == null) { string = ""; } else { - if (expression[symbolIterator] || expression[symbolAsyncIterator]) { + if (expression[Symbol.iterator] || expression[Symbol.asyncIterator]) { const isRaw = literal !== "" && literal.charCodeAt(literal.length - 1) === 33; @@ -215,7 +211,10 @@ const htmlAsyncGenerator = async function* ({ raw: literals }, ...expressions) { continue; } - if (expression[symbolIterator] || expression[symbolAsyncIterator]) { + if ( + expression[Symbol.iterator] || + expression[Symbol.asyncIterator] + ) { for await (expression of expression) { if (typeof expression === "string") { string = expression;