astro-hotspot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Hell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # astro-hotspot
2
+
3
+ Astro's built-in image cropping picks from a fixed nine-anchor enum (`"center"`, `"top"`, `"right top"`, `"attention"`, …). For any image where the subject sits between those anchors — a face slightly off to one side, a product in the lower third — there's no way to say "crop around this exact point" when you resize.
4
+
5
+ `astro-hotspot` adds a `hotspot` prop — same idea as CSS `object-position` / `background-position`, but applied to the underlying crop — on `<Image>`, `<Picture>`, and `getImage()`. The framing holds at every requested aspect ratio.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add astro-hotspot
11
+ ```
12
+
13
+ ```js
14
+ // astro.config.js
15
+ import { defineConfig } from "astro/config";
16
+ import hotspot from "astro-hotspot";
17
+
18
+ export default defineConfig({
19
+ integrations: [hotspot()],
20
+ });
21
+ ```
22
+
23
+ ## Use
24
+
25
+ ```astro
26
+ <Image src={hero} alt="…" width={1200} height={1600} hotspot="70% 34%" />
27
+ <Image src={hero} alt="…" width={1200} height={1600} hotspot={{ x: 0.7, y: 0.34 }} />
28
+ ```
29
+
30
+ Two equivalent forms — percent string or `{x, y}` in `[0, 1]`. Values outside the range are clamped (`"-10% 50%"` snaps to the left edge).
31
+
32
+ ## When you don't need it
33
+
34
+ If your subject is roughly centered, Sharp's built-in `position="attention"` (entropy-based auto-focus) gets it right most of the time and ships with Astro already. Reach for `astro-hotspot` when auto-focus misframes a particular image and you want explicit control.
35
+
36
+ ## License
37
+
38
+ MIT
@@ -0,0 +1,35 @@
1
+ import type { AstroIntegration } from "astro";
2
+ import type { Hotspot } from "./types.js";
3
+ export type { Hotspot, HotspotCoord } from "./types.js";
4
+ /**
5
+ * Registers the hotspot image service. Adds a `hotspot` prop to
6
+ * `<Image>`, `<Picture>`, and `getImage()` that takes `{x, y}` or
7
+ * `"<n>% <n>%"` coordinates and crops around that point.
8
+ *
9
+ * @example
10
+ * ```js
11
+ * // astro.config.js
12
+ * import hotspot from "astro-hotspot";
13
+ * export default defineConfig({ integrations: [hotspot()] });
14
+ * ```
15
+ *
16
+ * @example
17
+ * ```astro
18
+ * <Image src={hero} alt="…" width={1200} height={1600} hotspot="70% 34%" />
19
+ * <Image src={hero} alt="…" width={1200} height={1600} hotspot={{ x: 0.7, y: 0.34 }} />
20
+ * ```
21
+ */
22
+ declare const hotspot: () => AstroIntegration;
23
+ export default hotspot;
24
+ /**
25
+ * Surface `hotspot` as a known prop on `<Image>`, `<Picture>`, and
26
+ * `getImage()`. Without this, the service still works but call sites
27
+ * lose autocomplete + type-checking for the prop.
28
+ */
29
+ declare global {
30
+ namespace Astro {
31
+ interface CustomImageProps {
32
+ hotspot?: Hotspot;
33
+ }
34
+ }
35
+ }
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Registers the hotspot image service. Adds a `hotspot` prop to
3
+ * `<Image>`, `<Picture>`, and `getImage()` that takes `{x, y}` or
4
+ * `"<n>% <n>%"` coordinates and crops around that point.
5
+ *
6
+ * @example
7
+ * ```js
8
+ * // astro.config.js
9
+ * import hotspot from "astro-hotspot";
10
+ * export default defineConfig({ integrations: [hotspot()] });
11
+ * ```
12
+ *
13
+ * @example
14
+ * ```astro
15
+ * <Image src={hero} alt="…" width={1200} height={1600} hotspot="70% 34%" />
16
+ * <Image src={hero} alt="…" width={1200} height={1600} hotspot={{ x: 0.7, y: 0.34 }} />
17
+ * ```
18
+ */
19
+ const hotspot = () => ({
20
+ name: "astro-hotspot",
21
+ hooks: {
22
+ "astro:config:setup": ({ updateConfig }) => {
23
+ updateConfig({
24
+ image: {
25
+ service: { entrypoint: "astro-hotspot/service" },
26
+ },
27
+ });
28
+ },
29
+ },
30
+ });
31
+ export default hotspot;
@@ -0,0 +1,11 @@
1
+ import type { LocalImageService } from "astro";
2
+ import type { HotspotCoord } from "./types.js";
3
+ export type { Hotspot, HotspotCoord } from "./types.js";
4
+ export declare const parseHotspot: (value: unknown) => HotspotCoord | undefined;
5
+ /**
6
+ * LocalImageService that wraps the default Sharp service and adds
7
+ * hotspot cropping when a transform has `hotspot: {x, y}` or
8
+ * `hotspot: "<n>% <n>%"`. All other transforms pass through unchanged.
9
+ */
10
+ declare const hotspotImageService: LocalImageService;
11
+ export default hotspotImageService;
@@ -0,0 +1,109 @@
1
+ import baseSharpService from "astro/assets/services/sharp";
2
+ const clamp01 = (n) => Math.max(0, Math.min(1, n));
3
+ export const parseHotspot = (value) => {
4
+ if (value == null)
5
+ return undefined;
6
+ if (typeof value === "object" && "x" in value && "y" in value) {
7
+ const { x, y } = value;
8
+ if (typeof x === "number" &&
9
+ typeof y === "number" &&
10
+ isFinite(x) &&
11
+ isFinite(y)) {
12
+ return { x: clamp01(x), y: clamp01(y) };
13
+ }
14
+ }
15
+ if (typeof value === "string") {
16
+ const m = value.match(/^\s*(-?[\d.]+)%\s+(-?[\d.]+)%\s*$/);
17
+ if (m) {
18
+ return {
19
+ x: clamp01(parseFloat(m[1]) / 100),
20
+ y: clamp01(parseFloat(m[2]) / 100),
21
+ };
22
+ }
23
+ }
24
+ return undefined;
25
+ };
26
+ const getURL = (options, imageConfig) => {
27
+ const url = baseSharpService.getURL(options, imageConfig);
28
+ const hotspot = parseHotspot(options.hotspot);
29
+ if (!hotspot || typeof url !== "string")
30
+ return url;
31
+ const sep = url.includes("?") ? "&" : "?";
32
+ return `${url}${sep}hotspot=${hotspot.x},${hotspot.y}`;
33
+ };
34
+ const parseURL = (url, imageConfig) => {
35
+ const transform = baseSharpService.parseURL(url, imageConfig);
36
+ if (!transform || transform instanceof Promise)
37
+ return transform;
38
+ const raw = url.searchParams.get("hotspot");
39
+ if (raw) {
40
+ const [x, y] = raw.split(",").map(parseFloat);
41
+ if (isFinite(x) && isFinite(y)) {
42
+ transform.hotspot = { x, y };
43
+ }
44
+ }
45
+ return transform;
46
+ };
47
+ const transform = async (inputBuffer, transformOptions, config) => {
48
+ const hotspot = parseHotspot(transformOptions.hotspot);
49
+ if (!hotspot || !transformOptions.width || !transformOptions.height) {
50
+ return baseSharpService.transform(inputBuffer, transformOptions, config);
51
+ }
52
+ const { default: sharp } = await import("sharp");
53
+ const img = sharp(inputBuffer, { failOnError: false, pages: -1 }).rotate();
54
+ const meta = await img.metadata();
55
+ const srcW = meta.width;
56
+ const srcH = meta.height;
57
+ if (!srcW || !srcH) {
58
+ return baseSharpService.transform(inputBuffer, transformOptions, config);
59
+ }
60
+ const targetW = Math.round(Number(transformOptions.width));
61
+ const targetH = Math.round(Number(transformOptions.height));
62
+ const targetAR = targetW / targetH;
63
+ const srcAR = srcW / srcH;
64
+ // Largest rect of the target AR that fits in the source.
65
+ let cropW;
66
+ let cropH;
67
+ if (srcAR > targetAR) {
68
+ cropH = srcH;
69
+ cropW = Math.round(srcH * targetAR);
70
+ }
71
+ else {
72
+ cropW = srcW;
73
+ cropH = Math.round(srcW / targetAR);
74
+ }
75
+ // Center the rect on the hotspot, then clamp inside the source.
76
+ let left = Math.round(hotspot.x * srcW - cropW / 2);
77
+ let top = Math.round(hotspot.y * srcH - cropH / 2);
78
+ left = Math.max(0, Math.min(left, srcW - cropW));
79
+ top = Math.max(0, Math.min(top, srcH - cropH));
80
+ const cropped = await img
81
+ .extract({ left, top, width: cropW, height: cropH })
82
+ .toBuffer();
83
+ // Cropped buffer is already at target AR — delegate the final resize
84
+ // + format encode to the base service so we don't reimplement it.
85
+ return baseSharpService.transform(cropped, transformOptions, config);
86
+ };
87
+ /**
88
+ * LocalImageService that wraps the default Sharp service and adds
89
+ * hotspot cropping when a transform has `hotspot: {x, y}` or
90
+ * `hotspot: "<n>% <n>%"`. All other transforms pass through unchanged.
91
+ */
92
+ const hotspotImageService = {
93
+ ...baseSharpService,
94
+ propertiesToHash: [
95
+ "src",
96
+ "width",
97
+ "height",
98
+ "format",
99
+ "quality",
100
+ "fit",
101
+ "position",
102
+ "background",
103
+ "hotspot",
104
+ ],
105
+ getURL,
106
+ parseURL,
107
+ transform,
108
+ };
109
+ export default hotspotImageService;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Hotspot coordinates within the source image, normalized to [0, 1].
3
+ * `{x: 0, y: 0}` is top-left; `{x: 1, y: 1}` is bottom-right.
4
+ */
5
+ export type HotspotCoord = {
6
+ x: number;
7
+ y: number;
8
+ };
9
+ /**
10
+ * Anything the hotspot image service accepts as `hotspot`.
11
+ *
12
+ * Both forms route through the service's extract+resize path. Sharp's
13
+ * built-in anchor strings (`"center"`, `"top"`, `"attention"`, …) are
14
+ * not part of this type — pass those via Astro's regular `position`
15
+ * prop instead; the hotspot service doesn't intercept them.
16
+ *
17
+ * Values outside `[0, 1]` are clamped (negatives snap to `0`, `> 1`
18
+ * snaps to `1`).
19
+ */
20
+ export type Hotspot = HotspotCoord | `${number}% ${number}%`;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "astro-hotspot",
3
+ "version": "0.1.0",
4
+ "description": "Astro image service: hotspot cropping (x/y or '70% 34%') via Sharp's extract+resize",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "David Hell <dev@davidhell.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/whytspace/astro-hotspot.git"
11
+ },
12
+ "homepage": "https://github.com/whytspace/astro-hotspot#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/whytspace/astro-hotspot/issues"
15
+ },
16
+ "keywords": [
17
+ "astro-integration",
18
+ "astro-component",
19
+ "astro",
20
+ "image",
21
+ "sharp",
22
+ "hotspot",
23
+ "focal-point",
24
+ "art-direction"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./service": {
32
+ "types": "./dist/service.d.ts",
33
+ "import": "./dist/service.js"
34
+ },
35
+ "./types": {
36
+ "types": "./dist/types.d.ts",
37
+ "import": "./dist/types.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "README.md"
43
+ ],
44
+ "peerDependencies": {
45
+ "astro": ">=5.0.0 <7.0.0",
46
+ "sharp": ">=0.33.0 <0.36.0"
47
+ },
48
+ "devDependencies": {
49
+ "astro": "6.3.7",
50
+ "prettier": "3.8.3",
51
+ "sharp": "0.34.5",
52
+ "typescript": "6.0.3",
53
+ "vitest": "4.1.7"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc",
57
+ "check": "tsc --noEmit",
58
+ "test": "vitest run",
59
+ "format": "prettier --write ."
60
+ }
61
+ }