diff --git a/.github/workflows/check-format-js.yml b/.github/workflows/check-format-js.yml
new file mode 100644
index 0000000..8e590c9
--- /dev/null
+++ b/.github/workflows/check-format-js.yml
@@ -0,0 +1,16 @@
+name: Check JS formatting
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.x'
+ - run: npm --prefix js i
+ - run: npm --prefix js run check-format
+
diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml
new file mode 100644
index 0000000..03f575f
--- /dev/null
+++ b/.github/workflows/test-js.yml
@@ -0,0 +1,16 @@
+name: Run JS Tests
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '25.x'
+ - run: npm --prefix js i
+ - run: npm --prefix js test
+
diff --git a/.gitignore b/.gitignore
index 59d6699..84897b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,4 +13,7 @@ font/package.json
font/pinhead.css
font/pinhead.ttf
font/preview.html
-qgis_resources_repo
\ No newline at end of file
+qgis_resources_repo
+node_modules/
+package-lock.json
+js/test/fixtures/*-diff.png
diff --git a/js/README.md b/js/README.md
new file mode 100644
index 0000000..80e74ff
--- /dev/null
+++ b/js/README.md
@@ -0,0 +1,223 @@
+# Pinhead JS
+
+**Pinhead JS** is a utility and library for composing [**Pinhead**](https://pinhead.ink) icons into
+various shapes, including pins, markers, circles, and squares. It's designed for map developers who
+need flexible, programmatically generated icons for MapLibre GL JS, Leaflet, or any other web-based
+mapping platform.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Features
+
+- **Icon Composition:** Layer any Pinhead icon onto background shapes.
+- **Smart Coloring:** Automatically chooses contrasting icon colors based on the background fill.
+- **Multiple Shapes:** Supports `circle`, `square`, `map_pin`, and `marker`.
+- **Basic transforms:** Rotate and flip icons.
+- **CLI & API:** Use it as a command-line tool for batch processing or as a JavaScript library in your app.
+- **Custom Icon SVGs:** Pass raw SVG strings as the icon name to use custom icons.
+- **Custom Shapes:** Use custom SVG strings or PNG data URIs as background shapes.
+- **Migration:** A function is provided to simplify the usage of Pinehead's `changelog.json`.
+
+## Installation
+
+```bash
+npm install @waysidemapping/pinhead-js
+```
+
+## Options
+
+These options are common across both the CLI and API.
+
+| Option | Description | Default |
+| :------------- | :--------------------------------------------------------------------------------------------- | :---------------------------------------------------- |
+| `cornerRadius` | Corner radius (applies to `square` only) | `4` |
+| `fill` | Sets the fill color of the icon | `black` or `white` (auto-calculated from `shapeFill`) |
+| `padding` | Internal padding between icon and shape edge | Varies by shape |
+| `scale` | Scale factor for the output SVG dimensions | `1` |
+| `shape` | Background shape: `square`, `circle`, `map_pin`, `marker`, a raw SVG string, or a PNG data URI | `none` |
+| `shapeFill` | Fill color of the background shape | `black` |
+| `stroke` | Color of the stroke (applies to shape if present, otherwise icon) | Auto-calculated (contrasting or darkened/lightened) |
+| `strokeWidth` | Width of the stroke | `1` for `marker`, else `0` |
+| `flip` | Flip the icon: `horizontal` or `vertical` | `none` |
+| `rotate` | Rotate the icon | `none` |
+
+---
+
+## Usage
+
+### JavaScript API
+
+#### Create Icons
+
+Ideal for dynamic icon generation in the browser or on the server.
+
+```javascript
+import { getIcon } from "@waysidemapping/pinhead-js";
+
+// Simple icon
+const svg = getIcon("cargobike");
+
+// Icon with background and custom colors
+const marker = getIcon("jeep", {
+ shape: "map_pin",
+ shapeFill: "#6486f5",
+ strokeWidth: 1,
+});
+```
+
+##### Examples
+
+| Result | Code |
+| :---------------------------------------- | :------------------------------------------------------------------------------------------------ |
+|  | `getIcon("cargobike")` |
+|  | `getIcon("cup_and_saucer", { strokeWidth: 1 })` |
+|  | `getIcon("bicycle", { shape: "circle", shapeFill: "white", fill: "#6dad6f", stroke: "#6dad6f" })` |
+|  | `getIcon("burger", { shape: "marker", shapeFill: "#3FB1CE" })` |
+|  | `getIcon("ice_cream_on_cone", { shape: "circle", shapeFill: "pink" })` |
+|  | `getIcon("rocketship", { shape: "map_pin", shapeFill: "purple" })` |
+
+#### Custom Icon SVGs
+
+You can pass a raw SVG string as the icon name to use a custom icon.
+
+```javascript
+import { getIcon } from "@waysidemapping/pinhead-js";
+
+const customIcon = getIcon(
+ '',
+ {
+ shape: "circle",
+ shapeFill: "white",
+ },
+);
+```
+
+#### Custom Shapes
+
+You can provide your own background shapes as SVG strings or PNG data URIs. Use the `padding` option as an array `[x, y]` to precisely position the 15x15 icon within your custom shape.
+
+```javascript
+import { getIcon } from "@waysidemapping/pinhead-js";
+
+// Custom SVG shape
+const customSvg = getIcon("bicycle", {
+ shape:
+ '',
+ padding: [5, 5],
+});
+
+// Custom PNG shape (base64 data URI)
+const customPng = getIcon("bus", {
+ shape:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAjCAYAAABhCKGo...",
+ padding: [5, 10],
+});
+```
+
+#### Migrate an icon name
+
+```javascript
+import { migrateName } from "@waysidemapping/pinhead-js";
+
+// Migrate a name previously used by Pinhead. If a name was used more than once, the more recent name is returned.
+let name = migrateName("pedestrian"); // -> "person_walking"
+
+// Migrate from a specific version (treasure_map was renamed, in v13, but a new treasure_map was introduced then too)
+migrateName("treasure_map", "pinhead@10"); // -> "bifold_map_with_dotted_line_to_x"
+migrateName("treasure_map", "pinhead@13"); // -> "treasure_map"
+
+// Migrate from a seed source
+migrateName("maps", "nps"); // -> "bifold_map_with_dotted_line_to_x"
+```
+
+### Command Line Interface (CLI)
+
+#### 1. Generate a single icon
+
+Outputs the SVG string directly to `stdout`.
+
+```bash
+npx pinhead get-icon cargobike --shape=square --shapeFill='#6486f5' > icon.svg
+```
+
+#### 2. Batch build from configuration
+
+The `build-icons` command creates a collection of SVG files based on a JSON configuration file. By default, it looks for `pinhead.json` and writes results to a `./svgs/` directory.
+
+```bash
+npx pinhead build-icons --config my-icons.json --outdir ./assets/icons
+```
+
+**`pinhead.json` structure:**
+
+```json
+{
+ "groups": [
+ {
+ "icons": {
+ "bicycle": "bike-icon",
+ "bus": "bus-marker"
+ },
+ "options": {
+ "shape": "circle",
+ "shapeFill": "#6486f5"
+ }
+ }
+ ]
+}
+```
+
+---
+
+## Custom Icon SVG requirements
+
+To work with Pinhead JS as a custom icon (passed as the `name` argument), custom SVG strings must follow these constraints:
+
+- Use only `` elements.
+- Path elements should only contain the `d` attribute.
+- The `viewBox` should be `"0 0 15 15"`, or `height` and `width` should be set to `15`.
+
+---
+
+## Integrations
+
+### MapLibre GL JS
+
+To use Pinhead JS dynamically with MapLibre:
+
+```javascript
+const svg = getIcon("greek_cross", { shape: "circle", shapeFill: "red" });
+
+const img = new Image();
+const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
+img.src = URL.createObjectURL(blob);
+await img.decode();
+
+map.addImage("hospital-icon", img);
+
+URL.revokeObjectURL(url);
+```
+
+---
+
+## Inspiration
+
+Pinhead JS is inspired by the [Maki Icon Editor](https://labs.mapbox.com/maki-icons/editor/) and [makiwich](https://github.com/mapbox/makiwich)
+
+## License
+
+Pinhead JS is distributed under [CC0](/LICENSE).
diff --git a/js/cli.js b/js/cli.js
new file mode 100755
index 0000000..04959d7
--- /dev/null
+++ b/js/cli.js
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+
+import fs from "fs";
+import { parseArgs } from "util";
+import { getIcon } from "./index.js";
+
+const commands = {
+ "get-icon": {
+ config: {
+ options: {
+ fill: { type: "string" },
+ shape: { type: "string" },
+ shapeFill: { type: "string" },
+ stroke: { type: "string" },
+ strokeWidth: { type: "string" },
+ padding: { type: "string" },
+ cornerRadius: { type: "string" },
+ scale: { type: "string" },
+ },
+ allowPositionals: true,
+ },
+ run: ({ values, positionals }) => {
+ if (values.strokeWidth)
+ values.strokeWidth = parseFloat(values.strokeWidth);
+ if (values.padding) values.padding = parseFloat(values.padding);
+ if (values.cornerRadius)
+ values.cornerRadius = parseFloat(values.cornerRadius);
+ if (values.scale) values.scale = parseFloat(values.scale);
+ // validate arg
+ switch (positionals.length) {
+ case 0:
+ console.error("No icon name specified!");
+ return 1;
+ case 1:
+ break;
+ default:
+ console.error("More than one icon name specified!");
+ return 1;
+ }
+ console.log(getIcon(positionals[0], values));
+ return 0;
+ },
+ },
+ "build-icons": {
+ config: {
+ options: {
+ config: { type: "string", default: "pinhead.json" },
+ outdir: { type: "string", default: "./svgs" },
+ },
+ },
+ run: ({ values }) => {
+ const config = JSON.parse(fs.readFileSync(values.config));
+ fs.mkdirSync(values.outdir, { recursive: true });
+ for (const { icons, options } of config.groups) {
+ for (const [icon, name] of Object.entries(icons)) {
+ fs.writeFileSync(
+ `${values.outdir}/${name}.svg`,
+ getIcon(icon, options),
+ );
+ }
+ }
+ return 0;
+ },
+ },
+};
+
+if (process.argv.length < 3) {
+ console.log(`Supported subcommands: ${Object.keys(commands).join(", ")}`);
+ process.exit(0);
+}
+const subcommand = process.argv[2];
+const args = process.argv.slice(3);
+const command = commands[subcommand];
+if (!command) {
+ if (subcommand.startsWith("-")) {
+ console.error(`No subcommand specified`);
+ } else {
+ console.error(`Unknown subcommand: ${subcommand}`);
+ }
+ process.exit(1);
+}
+process.exit(command.run(parseArgs({ ...command.config, args })));
diff --git a/js/examples/beer-marker-amber.svg b/js/examples/beer-marker-amber.svg
new file mode 100644
index 0000000..3c39b6f
--- /dev/null
+++ b/js/examples/beer-marker-amber.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/bike-circle-green.svg b/js/examples/bike-circle-green.svg
new file mode 100644
index 0000000..348e9d4
--- /dev/null
+++ b/js/examples/bike-circle-green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/burger-marker.svg b/js/examples/burger-marker.svg
new file mode 100644
index 0000000..66903d2
--- /dev/null
+++ b/js/examples/burger-marker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/bus-circle-blue.svg b/js/examples/bus-circle-blue.svg
new file mode 100644
index 0000000..b80fc47
--- /dev/null
+++ b/js/examples/bus-circle-blue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/cafe-black-stroke.svg b/js/examples/cafe-black-stroke.svg
new file mode 100644
index 0000000..fff3ed1
--- /dev/null
+++ b/js/examples/cafe-black-stroke.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/camera-marker-darkgrey.svg b/js/examples/camera-marker-darkgrey.svg
new file mode 100644
index 0000000..b0f4c4c
--- /dev/null
+++ b/js/examples/camera-marker-darkgrey.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/cargobike-square-blue.svg b/js/examples/cargobike-square-blue.svg
new file mode 100644
index 0000000..5101260
--- /dev/null
+++ b/js/examples/cargobike-square-blue.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/cargobike-stroke.svg b/js/examples/cargobike-stroke.svg
new file mode 100644
index 0000000..1063503
--- /dev/null
+++ b/js/examples/cargobike-stroke.svg
@@ -0,0 +1,71 @@
+
+
diff --git a/js/examples/cargobike.svg b/js/examples/cargobike.svg
new file mode 100644
index 0000000..6401048
--- /dev/null
+++ b/js/examples/cargobike.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/dot.svg b/js/examples/dot.svg
new file mode 100644
index 0000000..6429b52
--- /dev/null
+++ b/js/examples/dot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/generateExamples.js b/js/examples/generateExamples.js
new file mode 100644
index 0000000..9acc501
--- /dev/null
+++ b/js/examples/generateExamples.js
@@ -0,0 +1,11 @@
+import { writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { getIcon } from "../index.js";
+
+import { examples } from "../test/examples.js";
+
+for (const example of examples) {
+ console.log(`Generating fixture for ${example.name}...`);
+ const svg = getIcon(example.icon, example.properties);
+ writeFileSync(join("examples", `${example.name}.svg`), Buffer.from(svg));
+}
diff --git a/js/examples/ice_cream-circle-pink.svg b/js/examples/ice_cream-circle-pink.svg
new file mode 100644
index 0000000..e9c111c
--- /dev/null
+++ b/js/examples/ice_cream-circle-pink.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/jeep-map_pin-stroke-1.svg b/js/examples/jeep-map_pin-stroke-1.svg
new file mode 100644
index 0000000..1468c92
--- /dev/null
+++ b/js/examples/jeep-map_pin-stroke-1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/map-pointer-cargobike.svg b/js/examples/map-pointer-cargobike.svg
new file mode 100644
index 0000000..a30b5b6
--- /dev/null
+++ b/js/examples/map-pointer-cargobike.svg
@@ -0,0 +1,3 @@
+
+
\ No newline at end of file
diff --git a/js/examples/pinstash.svg b/js/examples/pinstash.svg
new file mode 100644
index 0000000..328d24b
--- /dev/null
+++ b/js/examples/pinstash.svg
@@ -0,0 +1 @@
+
diff --git a/js/examples/pizza-square-red.svg b/js/examples/pizza-square-red.svg
new file mode 100644
index 0000000..ef24719
--- /dev/null
+++ b/js/examples/pizza-square-red.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/plane-down-square-navy.svg b/js/examples/plane-down-square-navy.svg
new file mode 100644
index 0000000..478fa7c
--- /dev/null
+++ b/js/examples/plane-down-square-navy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/plane-down.svg b/js/examples/plane-down.svg
new file mode 100644
index 0000000..e21e56e
--- /dev/null
+++ b/js/examples/plane-down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/plane-square-navy.svg b/js/examples/plane-square-navy.svg
new file mode 100644
index 0000000..4c1af70
--- /dev/null
+++ b/js/examples/plane-square-navy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/png-map-pointer-cargobike.svg b/js/examples/png-map-pointer-cargobike.svg
new file mode 100644
index 0000000..ab89087
--- /dev/null
+++ b/js/examples/png-map-pointer-cargobike.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/rocket-map_pin-purple.svg b/js/examples/rocket-map_pin-purple.svg
new file mode 100644
index 0000000..7e06411
--- /dev/null
+++ b/js/examples/rocket-map_pin-purple.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/sun-square-yellow.svg b/js/examples/sun-square-yellow.svg
new file mode 100644
index 0000000..e045463
--- /dev/null
+++ b/js/examples/sun-square-yellow.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/tent-square-brown.svg b/js/examples/tent-square-brown.svg
new file mode 100644
index 0000000..fc2ac59
--- /dev/null
+++ b/js/examples/tent-square-brown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/translucent-cargobike.svg b/js/examples/translucent-cargobike.svg
new file mode 100644
index 0000000..330fa69
--- /dev/null
+++ b/js/examples/translucent-cargobike.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/tree-map_pin-green.svg b/js/examples/tree-map_pin-green.svg
new file mode 100644
index 0000000..17b3dd0
--- /dev/null
+++ b/js/examples/tree-map_pin-green.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/examples/upside-down-jeep-map_pin-stroke-1.svg b/js/examples/upside-down-jeep-map_pin-stroke-1.svg
new file mode 100644
index 0000000..92867a1
--- /dev/null
+++ b/js/examples/upside-down-jeep-map_pin-stroke-1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/js/icon.js b/js/icon.js
new file mode 100644
index 0000000..c253dd7
--- /dev/null
+++ b/js/icon.js
@@ -0,0 +1,224 @@
+import index from "@waysidemapping/pinhead/dist/icons/index.complete.json" with { type: "json" };
+import tinycolor from "tinycolor2";
+import { imageSize } from "image-size";
+import { getSvgPathStrings, minify } from "./util.js";
+
+// Hard coding for 15x15 requirement for Pinhead icons
+const size = 15;
+
+const defaultPadding = {
+ map_pin: 4,
+ circle: 2,
+ square: 2,
+ marker: 5,
+};
+
+export function getIcon(name, properties = {}) {
+ let iconSvg;
+ if (name.includes("