gotodev-image-optimizer 0.1.0 → 0.1.2

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.
@@ -0,0 +1,19 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ImgHTMLAttributes } from 'react';
3
+ import { M as ManifestEntry } from './vite-plugin-CpGEB8EW.js';
4
+ export { B as BuildManifest, D as DeviceFingerprint, I as ImageFormat, a as ImageMetadata, O as OutputFormat, P as PluginOptions, Q as QualityTier, g as gotodevImageOptimizer } from './vite-plugin-CpGEB8EW.js';
5
+ import 'vite';
6
+
7
+ interface GImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'width' | 'height'> {
8
+ src: ManifestEntry | string;
9
+ alt: string;
10
+ priority?: boolean;
11
+ sizes?: string;
12
+ disableAdaptive?: boolean;
13
+ placeholder?: 'blur' | 'none';
14
+ onLoad?: () => void;
15
+ onError?: () => void;
16
+ }
17
+ declare function GImage({ src, alt, priority, sizes, disableAdaptive, placeholder: placeholderMode, className, style, loading: loadingProp, onLoad, onError, ...imgProps }: GImageProps): react_jsx_runtime.JSX.Element;
18
+
19
+ export { GImage, type GImageProps, ManifestEntry, GImage as default };
package/dist/index.js ADDED
@@ -0,0 +1,348 @@
1
+ import {
2
+ gotodevImageOptimizer,
3
+ isAnimatedFormat
4
+ } from "./chunk-JJABKWGE.js";
5
+
6
+ // src/components/GImage.tsx
7
+ import { useCallback, useEffect, useRef, useState } from "react";
8
+
9
+ // src/adaptive/fingerprint.ts
10
+ function getConnectionType() {
11
+ try {
12
+ const conn = navigator.connection;
13
+ if (conn?.effectiveType) {
14
+ const type = conn.effectiveType;
15
+ if (["slow-2g", "2g", "3g", "4g"].includes(type)) {
16
+ return type;
17
+ }
18
+ }
19
+ } catch {
20
+ }
21
+ return "unknown";
22
+ }
23
+ function getDeviceMemory() {
24
+ try {
25
+ const mem = navigator.deviceMemory;
26
+ return mem ?? 4;
27
+ } catch {
28
+ return 4;
29
+ }
30
+ }
31
+ function getHardwareConcurrency() {
32
+ try {
33
+ return navigator.hardwareConcurrency ?? 4;
34
+ } catch {
35
+ return 4;
36
+ }
37
+ }
38
+ function getDevicePixelRatio() {
39
+ try {
40
+ return window.devicePixelRatio ?? 1;
41
+ } catch {
42
+ return 1;
43
+ }
44
+ }
45
+ function getSaveData() {
46
+ try {
47
+ const sd = navigator.connection;
48
+ return sd?.saveData ?? false;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+ function computeTier(fp) {
54
+ if (fp.saveData) return "low";
55
+ if (fp.effectiveType === "slow-2g" || fp.effectiveType === "2g") {
56
+ return "low";
57
+ }
58
+ const score = (fp.effectiveType === "4g" ? 40 : fp.effectiveType === "3g" ? 20 : 0) + Math.min(fp.deviceMemory / 8, 1) * 25 + Math.min(fp.hardwareConcurrency / 8, 1) * 20 + Math.min(fp.devicePixelRatio / 3, 1) * 15;
59
+ if (score >= 80) return "ultra";
60
+ if (score >= 55) return "high";
61
+ if (score >= 30) return "medium";
62
+ return "low";
63
+ }
64
+ var cachedFingerprint = null;
65
+ function getDeviceFingerprint() {
66
+ if (cachedFingerprint) return cachedFingerprint;
67
+ const base = {
68
+ effectiveType: getConnectionType(),
69
+ deviceMemory: getDeviceMemory(),
70
+ hardwareConcurrency: getHardwareConcurrency(),
71
+ devicePixelRatio: getDevicePixelRatio(),
72
+ saveData: getSaveData()
73
+ };
74
+ cachedFingerprint = {
75
+ ...base,
76
+ tier: computeTier(base)
77
+ };
78
+ return cachedFingerprint;
79
+ }
80
+ function clearFingerprintCache() {
81
+ cachedFingerprint = null;
82
+ }
83
+ function listenForChanges(onChange) {
84
+ const handler = () => {
85
+ clearFingerprintCache();
86
+ onChange(getDeviceFingerprint());
87
+ };
88
+ try {
89
+ const conn = navigator.connection;
90
+ conn?.addEventListener?.("change", handler);
91
+ return () => {
92
+ try {
93
+ ;
94
+ conn?.removeEventListener?.("change", handler);
95
+ } catch {
96
+ }
97
+ };
98
+ } catch {
99
+ return () => {
100
+ };
101
+ }
102
+ }
103
+
104
+ // src/adaptive/predictive.ts
105
+ var MIN_PRELOAD = 600;
106
+ var MAX_PRELOAD = 3e3;
107
+ var VELOCITY_SAMPLES = 5;
108
+ var PredictiveLoader = class {
109
+ state = {
110
+ velocity: 0,
111
+ direction: "none",
112
+ lastScrollY: 0,
113
+ lastTimestamp: Date.now(),
114
+ preloadDistance: MIN_PRELOAD
115
+ };
116
+ velocities = [];
117
+ observer = null;
118
+ constructor() {
119
+ if (typeof window !== "undefined") {
120
+ this.state.lastScrollY = window.scrollY;
121
+ window.addEventListener("scroll", this.onScroll, { passive: true });
122
+ }
123
+ }
124
+ onScroll = () => {
125
+ const now = Date.now();
126
+ const dt = Math.max(1, now - this.state.lastTimestamp);
127
+ const dy = Math.abs(window.scrollY - this.state.lastScrollY);
128
+ const velocity = dy / dt;
129
+ this.velocities.push(velocity);
130
+ if (this.velocities.length > VELOCITY_SAMPLES) {
131
+ this.velocities.shift();
132
+ }
133
+ const avgVelocity = this.velocities.reduce((a, b) => a + b, 0) / this.velocities.length;
134
+ this.state.velocity = avgVelocity;
135
+ this.state.direction = window.scrollY > this.state.lastScrollY ? "down" : window.scrollY < this.state.lastScrollY ? "up" : "none";
136
+ this.state.lastScrollY = window.scrollY;
137
+ this.state.lastTimestamp = now;
138
+ this.state.preloadDistance = Math.min(MAX_PRELOAD, MIN_PRELOAD + avgVelocity * 5e3);
139
+ };
140
+ getPreloadMargin() {
141
+ return `${this.state.preloadDistance}px`;
142
+ }
143
+ observe(element, callback) {
144
+ this.observer?.disconnect();
145
+ this.observer = new IntersectionObserver(
146
+ (entries) => {
147
+ for (const entry of entries) {
148
+ if (entry.isIntersecting) {
149
+ callback(entry);
150
+ this.observer?.unobserve(entry.target);
151
+ }
152
+ }
153
+ },
154
+ {
155
+ rootMargin: this.getPreloadMargin(),
156
+ threshold: 0.01
157
+ }
158
+ );
159
+ this.observer.observe(element);
160
+ }
161
+ unobserve(element) {
162
+ this.observer?.unobserve(element);
163
+ }
164
+ destroy() {
165
+ this.observer?.disconnect();
166
+ if (typeof window !== "undefined") {
167
+ window.removeEventListener("scroll", this.onScroll);
168
+ }
169
+ this.observer = null;
170
+ }
171
+ };
172
+
173
+ // src/components/GImage.tsx
174
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
175
+ function GImage({
176
+ src,
177
+ alt,
178
+ priority = false,
179
+ sizes = "100vw",
180
+ disableAdaptive = false,
181
+ placeholder: placeholderMode = "blur",
182
+ className,
183
+ style,
184
+ loading: loadingProp,
185
+ onLoad,
186
+ onError,
187
+ ...imgProps
188
+ }) {
189
+ const imgRef = useRef(null);
190
+ const containerRef = useRef(null);
191
+ const predictiveRef = useRef(null);
192
+ const [loadState, setLoadState] = useState({
193
+ loaded: false,
194
+ error: false,
195
+ currentTier: "ultra"
196
+ });
197
+ const metadata = typeof src === "string" ? null : {
198
+ src: src.src,
199
+ width: src.width,
200
+ height: src.height,
201
+ format: src.format,
202
+ placeholder: src.placeholder,
203
+ variants: src.variants,
204
+ tiers: src.tiers
205
+ };
206
+ useEffect(() => {
207
+ if (disableAdaptive || !metadata) {
208
+ setLoadState((prev) => ({ ...prev, currentTier: "ultra" }));
209
+ return;
210
+ }
211
+ const fp = getDeviceFingerprint();
212
+ setLoadState((prev) => ({ ...prev, currentTier: fp.tier }));
213
+ const cleanup = listenForChanges((newFp) => {
214
+ setLoadState((prev) => ({ ...prev, currentTier: newFp.tier }));
215
+ });
216
+ return cleanup;
217
+ }, [disableAdaptive, metadata]);
218
+ useEffect(() => {
219
+ if (priority || loadingProp === "eager") {
220
+ setLoadState((prev) => ({ ...prev, loaded: true }));
221
+ return;
222
+ }
223
+ if (!containerRef.current) return;
224
+ predictiveRef.current = new PredictiveLoader();
225
+ predictiveRef.current.observe(containerRef.current, () => {
226
+ setLoadState((prev) => ({ ...prev, loaded: true }));
227
+ });
228
+ return () => {
229
+ predictiveRef.current?.destroy();
230
+ };
231
+ }, [priority, loadingProp]);
232
+ const handleLoad = useCallback(() => {
233
+ setLoadState((prev) => ({ ...prev, loaded: true }));
234
+ onLoad?.();
235
+ }, [onLoad]);
236
+ const handleError = useCallback(() => {
237
+ setLoadState((prev) => ({ ...prev, error: true }));
238
+ onError?.();
239
+ }, [onError]);
240
+ if (!metadata) {
241
+ return /* @__PURE__ */ jsx(
242
+ "img",
243
+ {
244
+ ref: imgRef,
245
+ ...imgProps,
246
+ src: typeof src === "string" ? src : "",
247
+ alt: alt ?? "",
248
+ className,
249
+ style,
250
+ loading: priority ? "eager" : loadingProp ?? "lazy",
251
+ onLoad: handleLoad,
252
+ onError: handleError
253
+ }
254
+ );
255
+ }
256
+ const aspectRatio = metadata.width && metadata.height ? metadata.height / metadata.width * 100 : 0;
257
+ const tier = loadState.currentTier;
258
+ const tierVariants = metadata.variants.filter((v) => {
259
+ const tierWidths = {
260
+ ultra: 99999,
261
+ high: 1920,
262
+ medium: 1024,
263
+ low: 768
264
+ };
265
+ return v.width <= tierWidths[tier];
266
+ });
267
+ const hasPlaceholder = placeholderMode === "blur" && !!metadata.placeholder && !loadState.loaded;
268
+ const isAnimated = isAnimatedFormat(metadata.format);
269
+ const pictureContent = /* @__PURE__ */ jsxs("picture", { children: [
270
+ !isAnimated && tierVariants.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
271
+ /* @__PURE__ */ jsx(
272
+ "source",
273
+ {
274
+ type: "image/avif",
275
+ srcSet: tierVariants.filter((v) => v.format === "avif").map((v) => `${v.src} ${v.width}w`).join(", "),
276
+ sizes
277
+ }
278
+ ),
279
+ /* @__PURE__ */ jsx(
280
+ "source",
281
+ {
282
+ type: "image/webp",
283
+ srcSet: tierVariants.filter((v) => v.format === "webp").map((v) => `${v.src} ${v.width}w`).join(", "),
284
+ sizes
285
+ }
286
+ )
287
+ ] }),
288
+ /* @__PURE__ */ jsx(
289
+ "img",
290
+ {
291
+ ref: imgRef,
292
+ ...imgProps,
293
+ src: tierVariants.find((v) => v.format === "jpeg")?.src ?? metadata.src,
294
+ srcSet: tierVariants.filter((v) => v.format === "jpeg" || v.format === "png").map((v) => `${v.src} ${v.width}w`).join(", ") || void 0,
295
+ sizes,
296
+ alt: alt ?? "",
297
+ width: metadata.width,
298
+ height: metadata.height,
299
+ loading: loadState.loaded && !priority ? "lazy" : "eager",
300
+ fetchPriority: priority ? "high" : void 0,
301
+ decoding: priority ? "auto" : "async",
302
+ className,
303
+ style: {
304
+ width: "100%",
305
+ height: "auto",
306
+ display: "block",
307
+ background: hasPlaceholder ? `${metadata.placeholder} center/cover no-repeat` : void 0,
308
+ filter: hasPlaceholder ? "blur(20px)" : void 0,
309
+ transition: "filter 0.4s ease-out, opacity 0.4s ease-out",
310
+ opacity: loadState.loaded ? 1 : 0.99,
311
+ ...style
312
+ },
313
+ onLoad: handleLoad,
314
+ onError: handleError
315
+ }
316
+ )
317
+ ] });
318
+ if (aspectRatio > 0) {
319
+ return /* @__PURE__ */ jsx(
320
+ "div",
321
+ {
322
+ ref: containerRef,
323
+ style: {
324
+ position: "relative",
325
+ width: "100%",
326
+ paddingBottom: `${aspectRatio}%`,
327
+ overflow: "hidden"
328
+ },
329
+ children: /* @__PURE__ */ jsx(
330
+ "div",
331
+ {
332
+ style: {
333
+ position: "absolute",
334
+ inset: 0
335
+ },
336
+ children: pictureContent
337
+ }
338
+ )
339
+ }
340
+ );
341
+ }
342
+ return /* @__PURE__ */ jsx("div", { ref: containerRef, children: pictureContent });
343
+ }
344
+ export {
345
+ GImage,
346
+ GImage as default,
347
+ gotodevImageOptimizer
348
+ };
@@ -0,0 +1,63 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ type ImageFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'svg' | 'bmp' | 'tiff' | 'ico';
4
+ type OutputFormat = 'avif' | 'webp' | 'jpeg' | 'png';
5
+ type QualityTier = 'ultra' | 'high' | 'medium' | 'low';
6
+ interface TierConfig {
7
+ quality: number;
8
+ widths: number[];
9
+ }
10
+ interface PluginOptions {
11
+ tiers?: Partial<Record<QualityTier, TierConfig>>;
12
+ adaptive?: boolean;
13
+ autoTune?: boolean;
14
+ formats?: OutputFormat[];
15
+ maxFileSize?: number;
16
+ widths?: number[];
17
+ verbose?: boolean;
18
+ preprocess?: boolean;
19
+ faceDetection?: boolean;
20
+ }
21
+ interface ImageVariant {
22
+ src: string;
23
+ width: number;
24
+ format: OutputFormat;
25
+ size: number;
26
+ integrity: string;
27
+ }
28
+ interface ImageMetadata {
29
+ src: string;
30
+ width: number;
31
+ height: number;
32
+ format: ImageFormat;
33
+ placeholder: string;
34
+ variants: ImageVariant[];
35
+ tiers: Record<QualityTier, string>;
36
+ blurHash?: string;
37
+ }
38
+ interface DeviceFingerprint {
39
+ effectiveType: 'slow-2g' | '2g' | '3g' | '4g' | 'unknown';
40
+ deviceMemory: number;
41
+ hardwareConcurrency: number;
42
+ devicePixelRatio: number;
43
+ saveData: boolean;
44
+ tier: QualityTier;
45
+ }
46
+ interface ManifestEntry {
47
+ src: string;
48
+ width: number;
49
+ height: number;
50
+ format: ImageFormat;
51
+ placeholder: string;
52
+ tiers: Record<QualityTier, string>;
53
+ variants: ImageVariant[];
54
+ }
55
+ interface BuildManifest {
56
+ version: string;
57
+ generatedAt: string;
58
+ entries: Record<string, ManifestEntry>;
59
+ }
60
+
61
+ declare function gotodevImageOptimizer(userOptions?: PluginOptions): Plugin;
62
+
63
+ export { type BuildManifest as B, type DeviceFingerprint as D, type ImageFormat as I, type ManifestEntry as M, type OutputFormat as O, type PluginOptions as P, type QualityTier as Q, type ImageMetadata as a, gotodevImageOptimizer as g };
@@ -0,0 +1,2 @@
1
+ import 'vite';
2
+ export { g as default } from './vite-plugin-CpGEB8EW.js';
@@ -0,0 +1,6 @@
1
+ import {
2
+ gotodevImageOptimizer
3
+ } from "./chunk-JJABKWGE.js";
4
+ export {
5
+ gotodevImageOptimizer as default
6
+ };
package/package.json CHANGED
@@ -1,34 +1,41 @@
1
1
  {
2
2
  "name": "gotodev-image-optimizer",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Content-aware, device-adaptive image optimizer for React + Vite projects",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/index.ts",
8
- "./vite-plugin": "./src/vite-plugin.ts"
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./vite-plugin": {
12
+ "types": "./dist/vite-plugin.d.ts",
13
+ "default": "./dist/vite-plugin.js"
14
+ }
9
15
  },
10
- "types": "./src/index.ts",
16
+ "types": "./dist/index.d.ts",
11
17
  "files": [
12
- "src",
13
- "!src/**/*.test.ts",
14
- "!src/**/*.test.tsx",
18
+ "dist",
19
+ "!dist/**/*.test.*",
15
20
  "!playground"
16
21
  ],
17
22
  "scripts": {
18
23
  "dev": "vite playground",
19
- "build": "vite build playground",
24
+ "build": "tsup --clean",
25
+ "build:playground": "vite build playground",
20
26
  "preview": "vite preview playground",
21
27
  "typecheck": "tsc --noEmit",
22
28
  "lint": "biome check src/",
23
29
  "lint:fix": "biome check --apply src/",
24
30
  "test": "vitest run",
25
31
  "test:watch": "vitest",
32
+ "prepublishOnly": "npm run build",
26
33
  "prepare": "echo 'ready'"
27
34
  },
28
35
  "license": "MIT",
29
36
  "repository": {
30
37
  "type": "git",
31
- "url": "https://github.com/gotodev/image-optimizer.git"
38
+ "url": "git+https://github.com/gotodev/image-optimizer.git"
32
39
  },
33
40
  "engines": {
34
41
  "node": ">=22"
@@ -38,7 +45,7 @@
38
45
  },
39
46
  "peerDependencies": {
40
47
  "react": "^19.0.0",
41
- "vite": "^7.0.0"
48
+ "vite": "^7.0.0 || ^8.0.0"
42
49
  },
43
50
  "devDependencies": {
44
51
  "@biomejs/biome": "^2.4.15",
@@ -49,6 +56,7 @@
49
56
  "happy-dom": "^20.9.0",
50
57
  "react": "^19.2.6",
51
58
  "react-dom": "^19.2.6",
59
+ "tsup": "^8.5.1",
52
60
  "typescript": "^6.0.3",
53
61
  "vite": "^8.0.13",
54
62
  "vitest": "^4.1.6"
@@ -1,117 +0,0 @@
1
- import type { DeviceFingerprint, QualityTier } from '../core/types.ts'
2
-
3
- function getConnectionType(): 'slow-2g' | '2g' | '3g' | '4g' | 'unknown' {
4
- try {
5
- const conn = (navigator as unknown as { connection?: { effectiveType?: string } }).connection
6
- if (conn?.effectiveType) {
7
- const type = conn.effectiveType
8
- if (['slow-2g', '2g', '3g', '4g'].includes(type)) {
9
- return type as 'slow-2g' | '2g' | '3g' | '4g'
10
- }
11
- }
12
- } catch {}
13
- return 'unknown'
14
- }
15
-
16
- function getDeviceMemory(): number {
17
- try {
18
- const mem = (navigator as unknown as { deviceMemory?: number }).deviceMemory
19
- return mem ?? 4
20
- } catch {
21
- return 4
22
- }
23
- }
24
-
25
- function getHardwareConcurrency(): number {
26
- try {
27
- return navigator.hardwareConcurrency ?? 4
28
- } catch {
29
- return 4
30
- }
31
- }
32
-
33
- function getDevicePixelRatio(): number {
34
- try {
35
- return window.devicePixelRatio ?? 1
36
- } catch {
37
- return 1
38
- }
39
- }
40
-
41
- function getSaveData(): boolean {
42
- try {
43
- const sd = (navigator as unknown as { connection?: { saveData?: boolean } }).connection
44
- return sd?.saveData ?? false
45
- } catch {
46
- return false
47
- }
48
- }
49
-
50
- function computeTier(fp: Omit<DeviceFingerprint, 'tier'>): QualityTier {
51
- if (fp.saveData) return 'low'
52
-
53
- if (fp.effectiveType === 'slow-2g' || fp.effectiveType === '2g') {
54
- return 'low'
55
- }
56
-
57
- const score =
58
- (fp.effectiveType === '4g' ? 40 : fp.effectiveType === '3g' ? 20 : 0) +
59
- Math.min(fp.deviceMemory / 8, 1) * 25 +
60
- Math.min(fp.hardwareConcurrency / 8, 1) * 20 +
61
- Math.min(fp.devicePixelRatio / 3, 1) * 15
62
-
63
- if (score >= 80) return 'ultra'
64
- if (score >= 55) return 'high'
65
- if (score >= 30) return 'medium'
66
- return 'low'
67
- }
68
-
69
- let cachedFingerprint: DeviceFingerprint | null = null
70
-
71
- export function getDeviceFingerprint(): DeviceFingerprint {
72
- if (cachedFingerprint) return cachedFingerprint
73
-
74
- const base = {
75
- effectiveType: getConnectionType(),
76
- deviceMemory: getDeviceMemory(),
77
- hardwareConcurrency: getHardwareConcurrency(),
78
- devicePixelRatio: getDevicePixelRatio(),
79
- saveData: getSaveData(),
80
- }
81
-
82
- cachedFingerprint = {
83
- ...base,
84
- tier: computeTier(base),
85
- }
86
-
87
- return cachedFingerprint
88
- }
89
-
90
- export function clearFingerprintCache(): void {
91
- cachedFingerprint = null
92
- }
93
-
94
- export function listenForChanges(onChange: (fp: DeviceFingerprint) => void): () => void {
95
- const handler = () => {
96
- clearFingerprintCache()
97
- onChange(getDeviceFingerprint())
98
- }
99
-
100
- try {
101
- const conn = (
102
- navigator as unknown as {
103
- connection?: { addEventListener?: (type: string, handler: () => void) => void }
104
- }
105
- ).connection
106
- conn?.addEventListener?.('change', handler)
107
- return () => {
108
- try {
109
- ;(
110
- conn as { removeEventListener?: (t: string, h: () => void) => void }
111
- )?.removeEventListener?.('change', handler)
112
- } catch {}
113
- }
114
- } catch {
115
- return () => {}
116
- }
117
- }