Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hash generator #22

Merged
merged 10 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
66 changes: 66 additions & 0 deletions bin/README.md
Original file line number Diff line number Diff line change
@@ -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 .
```
Binary file added bin/example/assets/cat.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions bin/example/assets/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions bin/example/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
28 changes: 28 additions & 0 deletions bin/example/routes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export default async (fastify) => {

Check warning on line 1 in bin/example/routes/index.js

View workflow job for this annotation

GitHub Actions / test (^18)

Async arrow function has no 'await' expression

Check warning on line 1 in bin/example/routes/index.js

View workflow job for this annotation

GitHub Actions / test (lts/*)

Async arrow function has no 'await' expression
const { html } = fastify;

fastify.addLayout((inner) => {
return html`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Document</title>
<link rel="stylesheet" href="/p/assets/style.css" />
</head>
<body>
!${inner}
</body>
</html>`;
});

fastify.get("/", async (request, reply) => {

Check warning on line 22 in bin/example/routes/index.js

View workflow job for this annotation

GitHub Actions / test (^18)

Async arrow function has no 'await' expression

Check warning on line 22 in bin/example/routes/index.js

View workflow job for this annotation

GitHub Actions / test (lts/*)

Async arrow function has no 'await' expression
return reply.html`
<h1 class="caption">Hello, world!</h1>
<img width="500" src="/p/assets/cat.jpeg" alt="Picture of a cat" />
`;
});
};
22 changes: 22 additions & 0 deletions bin/example/server.js
Original file line number Diff line number Diff line change
@@ -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");
48 changes: 48 additions & 0 deletions bin/src/index.js
Original file line number Diff line number Diff line change
@@ -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();
129 changes: 129 additions & 0 deletions bin/src/utils.js
Original file line number Diff line number Diff line change
@@ -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(
`(?<path>${escapedPath})(\\?(?<queryString>[^#"'\`]*))?`,
"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>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 };
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
Loading