Skip to content

Commit 7eb6bf9

Browse files
committed
feat: add Node.js support
1 parent 576ef78 commit 7eb6bf9

10 files changed

Lines changed: 212 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,17 @@ jobs:
1919
with:
2020
deno-version: v2.x
2121

22+
- name: Setup Node
23+
uses: actions/setup-node@v7
24+
with:
25+
node-version: 24
26+
package-manager-cache: false
27+
2228
- name: Check
2329
run: deno task check
2430

2531
- name: Validate package
2632
run: deno publish --dry-run
33+
34+
- name: Validate npm package
35+
run: deno task check:npm
Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Publish to JSR
1+
name: Publish
22

33
on:
44
push:
@@ -17,7 +17,7 @@ permissions:
1717
id-token: write
1818

1919
concurrency:
20-
group: publish-jsr-${{ github.ref }}
20+
group: publish-${{ github.ref }}
2121
cancel-in-progress: false
2222

2323
jobs:
@@ -35,6 +35,13 @@ jobs:
3535
with:
3636
deno-version: v2.x
3737

38+
- name: Setup Node
39+
uses: actions/setup-node@v7
40+
with:
41+
node-version: 24
42+
package-manager-cache: false
43+
registry-url: https://registry.npmjs.org
44+
3845
- name: Verify tag matches deno.json version
3946
shell: bash
4047
run: |
@@ -51,9 +58,20 @@ jobs:
5158
- name: Check
5259
run: deno task check
5360

54-
- name: Publish
61+
- name: Validate npm package
62+
run: deno task check:npm
63+
64+
- name: Publish to JSR
5565
run: deno publish
5666

67+
- name: Pack npm package
68+
run: deno pack --ignore=deno.lock --output dhash.tgz
69+
70+
- name: Publish to npm
71+
env:
72+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
73+
run: npm publish dhash.tgz --access public
74+
5775
- name: Generate release notes
5876
shell: bash
5977
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
node_modules/
22
coverage/
3+
*.tgz

AGENTS.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,19 @@ function/type, export it from `mod.ts` and add/adjust tests under `tests/`.
4949
- notes on permission changes or dependency changes (especially
5050
`npm:sharp@...`) and any `deno.lock` updates.
5151

52-
## Publishing (JSR)
52+
## Publishing
5353

5454
- Bump `version` in `deno.json`.
5555
- Validate locally: `deno fmt`, `deno lint`, `deno task test`.
5656
- Preview package contents: `deno publish --dry-run`.
57-
- Publish: `deno publish` (auth via token/login).
57+
- Preview the npm package: `deno pack --dry-run --ignore=deno.lock`.
58+
- Publish to JSR: `deno publish` (auth via token/login).
59+
- Publish to npm: `deno pack --ignore=deno.lock --output dhash.tgz` then
60+
`npm publish dhash.tgz --access public`.
5861
- CI publish: push a semver tag matching `deno.json`’s version (e.g. `v0.2.0`).
59-
GitHub Actions will run checks then publish via JSR’s tokenless GitHub Actions
60-
flow (OIDC). This requires linking the package to this GitHub repo in the JSR
61-
package settings.
62+
GitHub Actions will run checks, publish to JSR via its tokenless GitHub
63+
Actions flow (OIDC), and publish to npm using the `NPM_TOKEN` repository
64+
secret. JSR publishing requires linking the package to this GitHub repo in the
65+
JSR package settings.
6266
- CI also creates a GitHub Release for the tag using release notes generated
6367
from the git log since the previous `v*` tag.

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# dhash
22

3-
_A `dhash` implementation for Deno._
3+
_A `dhash` implementation for Deno and Node.js._
44

55
A fast algorithm that allows checking if two images are "kind of" the same (the
66
same source image, slightly modified). Examples:
@@ -31,6 +31,15 @@ deployed separately from the JSR package.
3131

3232
## Usage
3333

34+
Install from JSR with Deno, or directly from npm with Node.js:
35+
36+
```sh
37+
deno add jsr:@claudiu-ceia/dhash
38+
npm install @claudiu-ceia/dhash
39+
```
40+
41+
The npm package is ESM-only.
42+
3443
You can compare dhash values by simply computing the Hamming distance between
3544
them:
3645

@@ -41,7 +50,7 @@ them:
4150
of the same base image
4251

4352
```ts
44-
import { compare, dhash } from "jsr:@claudiu-ceia/dhash";
53+
import { compare, dhash } from "@claudiu-ceia/dhash";
4554

4655
const [hash1, hash2] = await Promise.all([
4756
dhash("./tests/dalle.png"),
@@ -71,13 +80,15 @@ preserved.
7180
## Development
7281

7382
- Run all checks: `deno task check`.
83+
- Build, install, and test the npm package with Node.js: `deno task check:npm`.
7484
- Try the experimental TypeScript 7 native checker: `deno task check:ts7`.
7585
Release CI continues to use Deno's stable default checker.
7686
- Run only tests: `deno task test` (requires
7787
`--allow-read --allow-write --allow-ffi --allow-env` because `sharp` uses
7888
native bindings, reads fixtures, and the `save()` test writes to a temporary
7989
directory).
8090
- Preview the JSR package: `deno publish --dry-run`.
91+
- Preview the npm package: `deno pack --dry-run --ignore=deno.lock`.
8192

8293
## License
8394

deno.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@claudiu-ceia/dhash",
3-
"version": "0.3.3",
3+
"version": "0.4.0",
44
"license": "MIT",
55
"exports": "./mod.ts",
66
"nodeModulesDir": "auto",
@@ -12,17 +12,18 @@
1212
"docs",
1313
"coverage",
1414
"node_modules",
15+
"scripts",
1516
"AGENTS.md"
1617
]
1718
},
1819
"tasks": {
19-
"check": "deno fmt --check && deno lint && deno check mod.ts && deno check --config docs/deno.json docs/main.ts && deno task test",
20-
"check:ts7": "deno check --unstable-tsgo mod.ts && deno check --unstable-tsgo --config docs/deno.json docs/main.ts && deno test --unstable-tsgo --allow-read --allow-write --allow-ffi --allow-env",
20+
"check": "deno fmt --check && deno lint && deno check mod.ts scripts/test_npm.ts && deno check --config docs/deno.json docs/main.ts && deno task test",
21+
"check:npm": "deno run -A scripts/test_npm.ts",
22+
"check:ts7": "deno check --unstable-tsgo mod.ts scripts/test_npm.ts && deno check --unstable-tsgo --config docs/deno.json docs/main.ts && deno test --unstable-tsgo --allow-read --allow-write --allow-ffi --allow-env",
2123
"test": "deno test --allow-read --allow-write --allow-ffi --allow-env"
2224
},
2325
"imports": {
2426
"@std/assert": "jsr:@std/assert@^1.0.19",
25-
"@std/path": "jsr:@std/path@^1.1.6",
2627
"sharp": "npm:sharp@0.35.3"
2728
}
2829
}

deno.lock

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/npm_integration.mjs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import assert from "node:assert/strict";
2+
import { readFile } from "node:fs/promises";
3+
import { join } from "node:path";
4+
import {
5+
compare,
6+
dhash,
7+
invertHash,
8+
raw,
9+
save,
10+
toAscii,
11+
} from "@claudiu-ceia/dhash";
12+
13+
const fixtures = process.env.DHASH_FIXTURES;
14+
assert.ok(fixtures, "DHASH_FIXTURES must point to the image fixtures");
15+
16+
const expected = {
17+
"dalle.png": "7735ac8c2da4746c",
18+
"dalle-copyright.png": "7735ac0c2da4746c",
19+
"dalle-bolder-copyright.jpeg": "7735ac852da4746c",
20+
"dalle-crop.jpeg": "19e52b2908084d6c",
21+
"dalle-edited.jpeg": "77352c0c2da4746c",
22+
"dalle-stickers.jpeg": "77358c8c2da4746c",
23+
};
24+
25+
const hashes = [];
26+
for (const [name, expectedHash] of Object.entries(expected)) {
27+
const path = join(fixtures, name);
28+
const pathHash = await dhash(path);
29+
const bytesHash = await dhash(await readFile(path));
30+
assert.equal(pathHash, expectedHash);
31+
assert.equal(bytesHash, expectedHash);
32+
hashes.push(pathHash);
33+
}
34+
35+
assert.equal(compare(hashes[0], hashes[1]), 1);
36+
assert.equal(compare(hashes[0], hashes[2]), 2);
37+
assert.equal(compare(hashes[0], hashes[3]), 27);
38+
assert.equal(compare(hashes[0], hashes[4]), 2);
39+
assert.equal(compare(hashes[0], hashes[5]), 1);
40+
assert.equal(invertHash(invertHash(hashes[0])), hashes[0]);
41+
assert.equal(toAscii(hashes[0]).split("\n").length, 8);
42+
43+
const png = await raw(hashes[0]);
44+
assert.deepEqual(
45+
Array.from(png.slice(0, 8)),
46+
[137, 80, 78, 71, 13, 10, 26, 10],
47+
);
48+
49+
const output = join(process.cwd(), "fingerprint");
50+
await save(hashes[0], output);
51+
assert.deepEqual(
52+
Array.from((await readFile(output + ".png")).slice(0, 8)),
53+
[137, 80, 78, 71, 13, 10, 26, 10],
54+
);
55+
56+
await assert.rejects(() => dhash(join(fixtures, "missing.png")));

scripts/test_npm.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { fileURLToPath } from "node:url";
2+
3+
const packageName = "@claudiu-ceia/dhash";
4+
const npm = Deno.build.os === "windows" ? "npm.cmd" : "npm";
5+
const npx = Deno.build.os === "windows" ? "npx.cmd" : "npx";
6+
const fixtureDirectory = fileURLToPath(new URL("../tests", import.meta.url));
7+
8+
async function run(
9+
command: string,
10+
args: string[],
11+
cwd: string,
12+
env?: Record<string, string>,
13+
) {
14+
const child = new Deno.Command(command, {
15+
args,
16+
cwd,
17+
env,
18+
stdin: "null",
19+
stdout: "inherit",
20+
stderr: "inherit",
21+
}).spawn();
22+
const status = await child.status;
23+
24+
if (!status.success) {
25+
throw new Error(`${command} exited with code ${status.code}`);
26+
}
27+
}
28+
29+
const directory = await Deno.makeTempDir({ prefix: "dhash-npm-" });
30+
const archive = `${directory}/dhash.tgz`;
31+
32+
try {
33+
await run(
34+
Deno.execPath(),
35+
["pack", "--allow-dirty", "--ignore=deno.lock", "--output", archive],
36+
Deno.cwd(),
37+
);
38+
39+
await Deno.writeTextFile(
40+
`${directory}/package.json`,
41+
JSON.stringify({ private: true, type: "module" }),
42+
);
43+
await Deno.copyFile(
44+
new URL("./npm_integration.mjs", import.meta.url),
45+
`${directory}/integration.mjs`,
46+
);
47+
await Deno.writeTextFile(
48+
`${directory}/consumer.ts`,
49+
`import { type DHashOptions, dhash } from "${packageName}";
50+
const options: DHashOptions = { invert: true };
51+
const hash: Promise<string> = dhash(new Uint8Array(), options);
52+
void hash;
53+
`,
54+
);
55+
await Deno.writeTextFile(
56+
`${directory}/tsconfig.json`,
57+
JSON.stringify({
58+
compilerOptions: {
59+
module: "NodeNext",
60+
moduleResolution: "NodeNext",
61+
noEmit: true,
62+
strict: true,
63+
target: "ES2022",
64+
},
65+
include: ["consumer.ts"],
66+
}),
67+
);
68+
69+
await run(npm, ["install", "--no-audit", "--no-fund", archive], directory);
70+
await run(npm, ["audit", "--omit=dev", "--audit-level=high"], directory);
71+
await run(
72+
npx,
73+
["--yes", "--package=typescript@7.0.2", "tsc", "--project", "."],
74+
directory,
75+
);
76+
77+
const versions = Deno.args.length > 0 ? Deno.args : [null];
78+
for (const version of versions) {
79+
const command = version === null ? "node" : npx;
80+
const args = version === null
81+
? ["integration.mjs"]
82+
: ["--yes", `--package=node@${version}`, "node", "integration.mjs"];
83+
await run(command, args, directory, {
84+
DHASH_FIXTURES: fixtureDirectory,
85+
});
86+
}
87+
88+
console.log(`Verified ${packageName} from a packed npm tarball.`);
89+
} finally {
90+
await Deno.remove(directory, { recursive: true });
91+
}

src/dhash.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sharp from "sharp";
2-
import { normalize, resolve } from "@std/path";
2+
import { readFile, writeFile } from "node:fs/promises";
3+
import { normalize, resolve } from "node:path";
34

45
/**
56
* Options for {@link dhash}.
@@ -36,7 +37,7 @@ export const invertHash = (hash: string): string => {
3637
* Bit convention: by default, bit `1` means the intensity increases left-to-right
3738
* (`left < right`). Use `options.invert` to flip this.
3839
*
39-
* @param pathOrSrc File path (relative to `Deno.cwd()`) or raw image bytes.
40+
* @param pathOrSrc File path (relative to the current working directory) or raw image bytes.
4041
* @returns 16-character lowercase hex string.
4142
*/
4243
export const dhash = async (
@@ -46,10 +47,10 @@ export const dhash = async (
4647
let file = pathOrSrc;
4748

4849
if (typeof pathOrSrc === "string") {
49-
const resolvedPath = resolve(Deno.cwd(), normalize(pathOrSrc));
50+
const resolvedPath = resolve(normalize(pathOrSrc));
5051

5152
try {
52-
file = await Deno.readFile(resolvedPath);
53+
file = await readFile(resolvedPath);
5354
} catch {
5455
throw new Error(`Failed to open "${resolvedPath}"`);
5556
}
@@ -136,5 +137,5 @@ export async function raw(hash: string): Promise<Uint8Array> {
136137
*/
137138
export async function save(hash: string, filePath: string): Promise<void> {
138139
const buffer = await raw(hash);
139-
await Deno.writeFile(`${filePath}.png`, buffer);
140+
await writeFile(`${filePath}.png`, buffer);
140141
}

0 commit comments

Comments
 (0)