favicon-stealer 2.0.0 → 3.0.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/README.md CHANGED
@@ -27,14 +27,14 @@ import { Favicon } from 'favicon-stealer';
27
27
  | `alt` | `string` | The alt text for the favicon image. |
28
28
  | `size` | `number` | The size of the favicon in pixels. Default is 32. |
29
29
  | `className` | `string` | A class name to apply to the element. |
30
- | `timeout` | `number` | The timeout in milliseconds for fetching the favicon. Default is 3000 (3 seconds). |
30
+ | `timeout` | `number` | The timeout in milliseconds before giving up on a slow/unresponsive **auto-detected** source and trying the next one. A provided `src` is exempt — it is only abandoned on a real load failure, never on a timeout. Default is 2000 (2 seconds). |
31
31
  | `lazy` | `boolean` | Whether to load the favicon lazily. Default is false. |
32
32
  | `border` | `boolean` | Whether to show a border around the favicon. Default is false. |
33
33
  | `padding` | `number` | The padding in pixels.(px) Default is 0. |
34
34
  | `background` | `string` | The background color of the favicon. Default is transparent.(in hex) |
35
35
  | `borderRadius` | `number` | The border radius in pixels.(px) Default is 0. |
36
36
  | `preferFallback` | `boolean` | Whether to prefer fallback service (e.g.Google's favicon service) over the website's own favicon. Default is false. |
37
- | `preferSrc` | `boolean` | Whether to prefer the local image source over the website's own favicon(if both are provided). Default is true. |
37
+ | `preferSrc` | `boolean` | Whether to try the provided `src` before auto-detecting the website's own favicon (if both are provided). If `src` fails to load it falls back to auto-detection either way. Default is true. |
38
38
 
39
39
 
40
40
  # NPM Package
@@ -59,4 +59,10 @@ MIT License
59
59
  - v1.5.0: Update default timeout to 3000(3 seconds) (2025.2.27)
60
60
  - v1.6.0: change prop preferGoogle to preferFallback (2025.2.27)
61
61
  - v1.8.0: Add props(src, alt, preferSrc), add new fallback(favicon.im)(2025.3.13)
62
- - v1.9.0: Fix show bug when use 'src'(2025.3.26)
62
+ - v1.9.0: Fix show bug when use 'src'(2025.3.26)
63
+ - v2.0.0: Dual ESM/CJS build with an `exports` map — fixes Vite 8 / modern bundler SSR "exports is not defined". Breaking: output is now a single bundle, deep sub-path imports (e.g. `favicon-stealer/dist/Favicon`) are gone (2026.6.20)
64
+ - v3.0.0: Rewrite of favicon resolution + rendering.
65
+ - **BREAKING**: `react` is now a `peerDependency` (consumer must provide React); the unused `react-dom` peer was dropped.
66
+ - **BREAKING**: styling is fully self-contained inline `style` — no Tailwind classes are emitted, and default `padding`/`borderRadius`/`background` no longer override your `className`.
67
+ - **BREAKING**: `src` behavior — `preferSrc` is now honored, a provided `src` no longer times out (it falls back only on a real load failure), the default `timeout` is now 2000ms (was 3000), and the candidate source list was trimmed, so some sites/inputs resolve differently.
68
+ - Fixes: `src` load-failure no longer hangs on the skeleton; reload on `url`/`src` change without a stale frame or wasted request; cache/SSR hits use `img.decode()` (feature-detected — falls back to `naturalWidth` on engines without it, e.g. old WebViews / jsdom; no false-negative on dimension-less SVGs); offscreen `lazy` images wait for the viewport instead of being timed out through every source to the letter fallback; de-duped sources (no `key` collision); `border` works; `alt=""` is honored for decorative icons (and the loading image is silent to screen readers until shown); empty/invalid `url` no longer renders a blank box; pulse keyframes inject once per document (DOM-id de-duped across HMR / duplicate copies); `FaviconProps` is exported. (2026.6.21)
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
 
3
- interface IProps {
3
+ interface FaviconProps {
4
4
  url: string;
5
5
  src?: string;
6
6
  alt?: string;
@@ -15,6 +15,6 @@ interface IProps {
15
15
  preferFallback?: boolean;
16
16
  preferSrc?: boolean;
17
17
  }
18
- declare const Favicon: ({ url, src, alt, size, className, timeout, border, padding, background, borderRadius, lazy, preferFallback, preferSrc, }: IProps) => React.JSX.Element;
18
+ declare const Favicon: ({ url, src, alt, size, className, timeout, border, padding, background, borderRadius, lazy, preferFallback, preferSrc, }: FaviconProps) => React.ReactElement;
19
19
 
20
- export { Favicon };
20
+ export { Favicon, type FaviconProps };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
 
3
- interface IProps {
3
+ interface FaviconProps {
4
4
  url: string;
5
5
  src?: string;
6
6
  alt?: string;
@@ -15,6 +15,6 @@ interface IProps {
15
15
  preferFallback?: boolean;
16
16
  preferSrc?: boolean;
17
17
  }
18
- declare const Favicon: ({ url, src, alt, size, className, timeout, border, padding, background, borderRadius, lazy, preferFallback, preferSrc, }: IProps) => React.JSX.Element;
18
+ declare const Favicon: ({ url, src, alt, size, className, timeout, border, padding, background, borderRadius, lazy, preferFallback, preferSrc, }: FaviconProps) => React.ReactElement;
19
19
 
20
- export { Favicon };
20
+ export { Favicon, type FaviconProps };
package/dist/index.js CHANGED
@@ -2,10 +2,27 @@
2
2
  "use strict";
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
5
+ var __defProps = Object.defineProperties;
5
6
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
6
8
  var __getOwnPropNames = Object.getOwnPropertyNames;
9
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
7
10
  var __getProtoOf = Object.getPrototypeOf;
8
11
  var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
13
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
14
+ var __spreadValues = (a, b) => {
15
+ for (var prop in b || (b = {}))
16
+ if (__hasOwnProp.call(b, prop))
17
+ __defNormalProp(a, prop, b[prop]);
18
+ if (__getOwnPropSymbols)
19
+ for (var prop of __getOwnPropSymbols(b)) {
20
+ if (__propIsEnum.call(b, prop))
21
+ __defNormalProp(a, prop, b[prop]);
22
+ }
23
+ return a;
24
+ };
25
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
9
26
  var __export = (target, all) => {
10
27
  for (var name in all)
11
28
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -38,7 +55,8 @@ module.exports = __toCommonJS(index_exports);
38
55
  // src/lib/utils/index.ts
39
56
  var getDomain = (url) => {
40
57
  try {
41
- const urlWithProtocol = url.startsWith("http") ? url : `https://${url}`;
58
+ const hasProtocol = /^https?:\/\//i.test(url);
59
+ const urlWithProtocol = hasProtocol ? url : `https://${url}`;
42
60
  const domain = new URL(urlWithProtocol).hostname;
43
61
  return domain.replace(/^www\./, "");
44
62
  } catch (error) {
@@ -48,14 +66,40 @@ var getDomain = (url) => {
48
66
 
49
67
  // src/Favicon.tsx
50
68
  var import_react = __toESM(require("react"));
69
+ var reducer = (state, action) => {
70
+ switch (action.type) {
71
+ case "reset":
72
+ return { index: 0, status: "loading" };
73
+ case "load":
74
+ return state.status === "loaded" ? state : __spreadProps(__spreadValues({}, state), { status: "loaded" });
75
+ case "error": {
76
+ const next = state.index + 1;
77
+ return next < action.total ? { index: next, status: "loading" } : { index: state.index, status: "error" };
78
+ }
79
+ default:
80
+ return state;
81
+ }
82
+ };
83
+ var KEYFRAMES_ID = "favicon-stealer-keyframes";
84
+ var PULSE_KEYFRAMES = "@keyframes favicon-stealer-pulse{0%,100%{opacity:1}50%{opacity:.4}}";
85
+ var NEUTRAL_BG = "color-mix(in srgb, currentColor 10%, transparent)";
86
+ var keyframesInjected = false;
87
+ var injectKeyframes = () => {
88
+ if (keyframesInjected || typeof document === "undefined") return;
89
+ keyframesInjected = true;
90
+ if (document.getElementById(KEYFRAMES_ID)) return;
91
+ const el = document.createElement("style");
92
+ el.id = KEYFRAMES_ID;
93
+ el.textContent = PULSE_KEYFRAMES;
94
+ document.head.appendChild(el);
95
+ };
51
96
  var Favicon = ({
52
97
  url,
53
98
  src,
54
99
  alt,
55
100
  size = 32,
56
101
  className = "",
57
- timeout = 3e3,
58
- // 增加到3秒,给网站自己的favicon更多加载时间
102
+ timeout = 2e3,
59
103
  border = false,
60
104
  padding = 0,
61
105
  background = "transparent",
@@ -64,113 +108,136 @@ var Favicon = ({
64
108
  preferFallback = false,
65
109
  preferSrc = true
66
110
  }) => {
67
- const domain = getDomain(url);
68
- const [imgSrc, setImgSrc] = (0, import_react.useState)("");
69
- const [fallbackIndex, setFallbackIndex] = (0, import_react.useState)(0);
70
- const [isLoading, setIsLoading] = (0, import_react.useState)(true);
71
- const [hasError, setHasError] = (0, import_react.useState)(false);
72
- const [isInitialized, setIsInitialized] = (0, import_react.useState)(false);
73
- const standardSources = [
74
- `https://${domain}/favicon.ico`,
75
- `https://${domain}/logo.svg`,
76
- `https://${domain}/logo.png`,
77
- `https://${domain}/apple-touch-icon.png`,
78
- `https://${domain}/apple-touch-icon-precomposed.png`,
79
- `https://${domain}/static/img/favicon.ico`,
80
- `https://${domain}/static/img/favicon.png`,
81
- `https://${domain}/img/favicon.png`,
82
- `https://${domain}/img/favicon.ico`,
83
- `https://${domain}/static/img/logo.svg`,
84
- `https://${domain}/apple-touch-icon-precomposed.png`
85
- ];
86
- const fallbackServices = [
87
- `https://favicon.im/${domain}?larger=true`,
88
- `https://favicon.im/${domain}`,
89
- `https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`,
90
- `https://www.google.com/s2/favicons?domain=http://${domain}&sz=64`
91
- ];
92
- const fallbackSources = preferFallback ? [...fallbackServices, ...standardSources] : [...standardSources, ...fallbackServices];
111
+ const domain = (0, import_react.useMemo)(() => getDomain(url), [url]);
112
+ const sources = (0, import_react.useMemo)(() => {
113
+ const standard = [
114
+ `https://${domain}/favicon.ico`,
115
+ `https://${domain}/apple-touch-icon.png`,
116
+ `https://${domain}/logo.svg`,
117
+ `https://${domain}/logo.png`
118
+ ];
119
+ const services = [
120
+ `https://favicon.im/${domain}?larger=true`,
121
+ `https://favicon.im/${domain}`,
122
+ `https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`,
123
+ `https://www.google.com/s2/favicons?domain=http://${domain}&sz=64`
124
+ ];
125
+ const base = preferFallback ? [...services, ...standard] : [...standard, ...services];
126
+ const withSrc = !src ? base : preferSrc ? [src, ...base] : [...base, src];
127
+ return [...new Set(withSrc)];
128
+ }, [domain, src, preferFallback, preferSrc]);
129
+ const [state, dispatch] = (0, import_react.useReducer)(reducer, {
130
+ index: 0,
131
+ status: "loading"
132
+ });
133
+ const [prevSources, setPrevSources] = (0, import_react.useState)(sources);
134
+ if (sources !== prevSources) {
135
+ setPrevSources(sources);
136
+ dispatch({ type: "reset" });
137
+ }
138
+ const currentSrc = sources[state.index];
139
+ const isLoading = state.status === "loading";
140
+ const hasError = state.status === "error";
141
+ const isUserSrc = currentSrc === src;
93
142
  (0, import_react.useEffect)(() => {
94
- if (!isInitialized) {
95
- if (src) {
96
- setIsLoading(false);
97
- } else {
98
- setImgSrc(fallbackSources[0]);
99
- }
100
- setIsInitialized(true);
101
- }
102
- }, [isInitialized, fallbackSources, src]);
143
+ injectKeyframes();
144
+ }, []);
145
+ const imgRef = (0, import_react.useRef)(null);
103
146
  (0, import_react.useEffect)(() => {
104
- let timeoutId;
105
- if (isLoading && imgSrc && !src) {
106
- timeoutId = setTimeout(() => {
107
- handleError();
108
- }, timeout);
147
+ const img = imgRef.current;
148
+ if (!img || state.status !== "loading" || !img.complete) return;
149
+ let cancelled = false;
150
+ const onOk = () => !cancelled && dispatch({ type: "load" });
151
+ const onFail = () => !cancelled && dispatch({ type: "error", total: sources.length });
152
+ if (typeof img.decode === "function") {
153
+ img.decode().then(onOk, onFail);
154
+ } else {
155
+ img.naturalWidth > 0 ? onOk() : onFail();
109
156
  }
110
157
  return () => {
111
- if (timeoutId) {
112
- clearTimeout(timeoutId);
113
- }
158
+ cancelled = true;
114
159
  };
115
- }, [imgSrc, isLoading, timeout, src]);
116
- const handleError = () => {
117
- const nextIndex = fallbackIndex + 1;
118
- if (nextIndex < fallbackSources.length) {
119
- setFallbackIndex(nextIndex);
120
- setImgSrc(fallbackSources[nextIndex]);
121
- setIsLoading(true);
122
- } else {
123
- setHasError(true);
124
- setIsLoading(false);
125
- }
126
- };
127
- const handleLoad = () => {
128
- setIsLoading(false);
129
- setHasError(false);
160
+ }, [state.status, currentSrc, sources]);
161
+ (0, import_react.useEffect)(() => {
162
+ if (state.status !== "loading" || isUserSrc || lazy) return;
163
+ const id = setTimeout(() => {
164
+ dispatch({ type: "error", total: sources.length });
165
+ }, timeout);
166
+ return () => clearTimeout(id);
167
+ }, [state.status, currentSrc, isUserSrc, lazy, timeout, sources]);
168
+ const label = alt != null ? alt : domain ? `${domain} logo` : "favicon";
169
+ const letter = domain.charAt(0).toUpperCase() || "?";
170
+ const neutralSurface = {
171
+ borderRadius: borderRadius || void 0,
172
+ background: NEUTRAL_BG
130
173
  };
131
174
  return /* @__PURE__ */ import_react.default.createElement(
132
175
  "div",
133
176
  {
134
- className: `relative inline-block
135
- ${className}
136
- ${border ? "border" : ""}
137
- ${hasError ? "opacity-0" : ""}
138
- ${padding ? `p-[${padding}px]` : ""}
139
- ${borderRadius ? `rounded-[${borderRadius}px]` : ""}
140
- `,
177
+ className,
141
178
  style: {
179
+ position: "relative",
180
+ display: "inline-block",
142
181
  width: size,
143
182
  height: size,
144
- background,
145
- padding: padding ? `${padding}px` : 0,
146
- borderRadius: borderRadius ? `${borderRadius}px` : 0
183
+ boxSizing: "border-box",
184
+ // 默认 / 零值不写内联,留给消费方的 className 决定。
185
+ padding: padding || void 0,
186
+ borderRadius: borderRadius || void 0,
187
+ background: background === "transparent" ? void 0 : background,
188
+ border: border ? "1px solid color-mix(in srgb, currentColor 15%, transparent)" : void 0
147
189
  }
148
190
  },
149
- isLoading && /* @__PURE__ */ import_react.default.createElement("div", { className: "absolute inset-0 animate-pulse" }, /* @__PURE__ */ import_react.default.createElement("div", { className: "w-full h-full rounded-md bg-gray-200/60" })),
150
- (src || imgSrc) && /* @__PURE__ */ import_react.default.createElement(
191
+ isLoading && /* @__PURE__ */ import_react.default.createElement(
192
+ "div",
193
+ {
194
+ style: __spreadProps(__spreadValues({
195
+ position: "absolute",
196
+ inset: padding || 0
197
+ }, neutralSurface), {
198
+ animation: "favicon-stealer-pulse 1.5s ease-in-out infinite"
199
+ })
200
+ }
201
+ ),
202
+ currentSrc && !hasError && /* @__PURE__ */ import_react.default.createElement(
151
203
  "img",
152
204
  {
153
- src: src || imgSrc,
154
- alt: alt || `${domain} logo`,
205
+ key: currentSrc,
206
+ ref: imgRef,
207
+ src: currentSrc,
208
+ alt: isLoading ? "" : label,
155
209
  width: size,
156
210
  height: size,
157
211
  loading: lazy ? "lazy" : "eager",
158
- onError: handleError,
159
- onLoad: handleLoad,
160
- className: `inline-block transition-opacity duration-300 ${isLoading ? "opacity-0" : "opacity-100"}`,
212
+ decoding: "async",
213
+ onError: () => dispatch({ type: "error", total: sources.length }),
214
+ onLoad: () => dispatch({ type: "load" }),
161
215
  style: {
216
+ width: "100%",
217
+ height: "100%",
162
218
  objectFit: "contain",
163
- display: hasError ? "none" : "inline-block"
219
+ opacity: isLoading ? 0 : 1,
220
+ transition: "opacity 0.3s"
164
221
  }
165
222
  }
166
223
  ),
167
224
  hasError && /* @__PURE__ */ import_react.default.createElement(
168
225
  "div",
169
226
  {
170
- className: "w-full h-full flex items-center justify-center bg-gray-100 rounded-md",
171
- style: { fontSize: `${size * 0.5}px` }
227
+ role: "img",
228
+ "aria-label": label,
229
+ style: __spreadProps(__spreadValues({
230
+ width: "100%",
231
+ height: "100%",
232
+ display: "flex",
233
+ alignItems: "center",
234
+ justifyContent: "center"
235
+ }, neutralSurface), {
236
+ fontSize: size * 0.5,
237
+ lineHeight: 1
238
+ })
172
239
  },
173
- domain.charAt(0).toUpperCase()
240
+ letter
174
241
  )
175
242
  );
176
243
  };
package/dist/index.mjs CHANGED
@@ -1,9 +1,29 @@
1
1
  "use client";
2
+ var __defProp = Object.defineProperty;
3
+ var __defProps = Object.defineProperties;
4
+ var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
5
+ var __getOwnPropSymbols = Object.getOwnPropertySymbols;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __propIsEnum = Object.prototype.propertyIsEnumerable;
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
+ var __spreadValues = (a, b) => {
10
+ for (var prop in b || (b = {}))
11
+ if (__hasOwnProp.call(b, prop))
12
+ __defNormalProp(a, prop, b[prop]);
13
+ if (__getOwnPropSymbols)
14
+ for (var prop of __getOwnPropSymbols(b)) {
15
+ if (__propIsEnum.call(b, prop))
16
+ __defNormalProp(a, prop, b[prop]);
17
+ }
18
+ return a;
19
+ };
20
+ var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
2
21
 
3
22
  // src/lib/utils/index.ts
4
23
  var getDomain = (url) => {
5
24
  try {
6
- const urlWithProtocol = url.startsWith("http") ? url : `https://${url}`;
25
+ const hasProtocol = /^https?:\/\//i.test(url);
26
+ const urlWithProtocol = hasProtocol ? url : `https://${url}`;
7
27
  const domain = new URL(urlWithProtocol).hostname;
8
28
  return domain.replace(/^www\./, "");
9
29
  } catch (error) {
@@ -12,15 +32,41 @@ var getDomain = (url) => {
12
32
  };
13
33
 
14
34
  // src/Favicon.tsx
15
- import React, { useEffect, useState } from "react";
35
+ import React, { useEffect, useMemo, useReducer, useRef, useState } from "react";
36
+ var reducer = (state, action) => {
37
+ switch (action.type) {
38
+ case "reset":
39
+ return { index: 0, status: "loading" };
40
+ case "load":
41
+ return state.status === "loaded" ? state : __spreadProps(__spreadValues({}, state), { status: "loaded" });
42
+ case "error": {
43
+ const next = state.index + 1;
44
+ return next < action.total ? { index: next, status: "loading" } : { index: state.index, status: "error" };
45
+ }
46
+ default:
47
+ return state;
48
+ }
49
+ };
50
+ var KEYFRAMES_ID = "favicon-stealer-keyframes";
51
+ var PULSE_KEYFRAMES = "@keyframes favicon-stealer-pulse{0%,100%{opacity:1}50%{opacity:.4}}";
52
+ var NEUTRAL_BG = "color-mix(in srgb, currentColor 10%, transparent)";
53
+ var keyframesInjected = false;
54
+ var injectKeyframes = () => {
55
+ if (keyframesInjected || typeof document === "undefined") return;
56
+ keyframesInjected = true;
57
+ if (document.getElementById(KEYFRAMES_ID)) return;
58
+ const el = document.createElement("style");
59
+ el.id = KEYFRAMES_ID;
60
+ el.textContent = PULSE_KEYFRAMES;
61
+ document.head.appendChild(el);
62
+ };
16
63
  var Favicon = ({
17
64
  url,
18
65
  src,
19
66
  alt,
20
67
  size = 32,
21
68
  className = "",
22
- timeout = 3e3,
23
- // 增加到3秒,给网站自己的favicon更多加载时间
69
+ timeout = 2e3,
24
70
  border = false,
25
71
  padding = 0,
26
72
  background = "transparent",
@@ -29,113 +75,136 @@ var Favicon = ({
29
75
  preferFallback = false,
30
76
  preferSrc = true
31
77
  }) => {
32
- const domain = getDomain(url);
33
- const [imgSrc, setImgSrc] = useState("");
34
- const [fallbackIndex, setFallbackIndex] = useState(0);
35
- const [isLoading, setIsLoading] = useState(true);
36
- const [hasError, setHasError] = useState(false);
37
- const [isInitialized, setIsInitialized] = useState(false);
38
- const standardSources = [
39
- `https://${domain}/favicon.ico`,
40
- `https://${domain}/logo.svg`,
41
- `https://${domain}/logo.png`,
42
- `https://${domain}/apple-touch-icon.png`,
43
- `https://${domain}/apple-touch-icon-precomposed.png`,
44
- `https://${domain}/static/img/favicon.ico`,
45
- `https://${domain}/static/img/favicon.png`,
46
- `https://${domain}/img/favicon.png`,
47
- `https://${domain}/img/favicon.ico`,
48
- `https://${domain}/static/img/logo.svg`,
49
- `https://${domain}/apple-touch-icon-precomposed.png`
50
- ];
51
- const fallbackServices = [
52
- `https://favicon.im/${domain}?larger=true`,
53
- `https://favicon.im/${domain}`,
54
- `https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`,
55
- `https://www.google.com/s2/favicons?domain=http://${domain}&sz=64`
56
- ];
57
- const fallbackSources = preferFallback ? [...fallbackServices, ...standardSources] : [...standardSources, ...fallbackServices];
78
+ const domain = useMemo(() => getDomain(url), [url]);
79
+ const sources = useMemo(() => {
80
+ const standard = [
81
+ `https://${domain}/favicon.ico`,
82
+ `https://${domain}/apple-touch-icon.png`,
83
+ `https://${domain}/logo.svg`,
84
+ `https://${domain}/logo.png`
85
+ ];
86
+ const services = [
87
+ `https://favicon.im/${domain}?larger=true`,
88
+ `https://favicon.im/${domain}`,
89
+ `https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`,
90
+ `https://www.google.com/s2/favicons?domain=http://${domain}&sz=64`
91
+ ];
92
+ const base = preferFallback ? [...services, ...standard] : [...standard, ...services];
93
+ const withSrc = !src ? base : preferSrc ? [src, ...base] : [...base, src];
94
+ return [...new Set(withSrc)];
95
+ }, [domain, src, preferFallback, preferSrc]);
96
+ const [state, dispatch] = useReducer(reducer, {
97
+ index: 0,
98
+ status: "loading"
99
+ });
100
+ const [prevSources, setPrevSources] = useState(sources);
101
+ if (sources !== prevSources) {
102
+ setPrevSources(sources);
103
+ dispatch({ type: "reset" });
104
+ }
105
+ const currentSrc = sources[state.index];
106
+ const isLoading = state.status === "loading";
107
+ const hasError = state.status === "error";
108
+ const isUserSrc = currentSrc === src;
58
109
  useEffect(() => {
59
- if (!isInitialized) {
60
- if (src) {
61
- setIsLoading(false);
62
- } else {
63
- setImgSrc(fallbackSources[0]);
64
- }
65
- setIsInitialized(true);
66
- }
67
- }, [isInitialized, fallbackSources, src]);
110
+ injectKeyframes();
111
+ }, []);
112
+ const imgRef = useRef(null);
68
113
  useEffect(() => {
69
- let timeoutId;
70
- if (isLoading && imgSrc && !src) {
71
- timeoutId = setTimeout(() => {
72
- handleError();
73
- }, timeout);
114
+ const img = imgRef.current;
115
+ if (!img || state.status !== "loading" || !img.complete) return;
116
+ let cancelled = false;
117
+ const onOk = () => !cancelled && dispatch({ type: "load" });
118
+ const onFail = () => !cancelled && dispatch({ type: "error", total: sources.length });
119
+ if (typeof img.decode === "function") {
120
+ img.decode().then(onOk, onFail);
121
+ } else {
122
+ img.naturalWidth > 0 ? onOk() : onFail();
74
123
  }
75
124
  return () => {
76
- if (timeoutId) {
77
- clearTimeout(timeoutId);
78
- }
125
+ cancelled = true;
79
126
  };
80
- }, [imgSrc, isLoading, timeout, src]);
81
- const handleError = () => {
82
- const nextIndex = fallbackIndex + 1;
83
- if (nextIndex < fallbackSources.length) {
84
- setFallbackIndex(nextIndex);
85
- setImgSrc(fallbackSources[nextIndex]);
86
- setIsLoading(true);
87
- } else {
88
- setHasError(true);
89
- setIsLoading(false);
90
- }
91
- };
92
- const handleLoad = () => {
93
- setIsLoading(false);
94
- setHasError(false);
127
+ }, [state.status, currentSrc, sources]);
128
+ useEffect(() => {
129
+ if (state.status !== "loading" || isUserSrc || lazy) return;
130
+ const id = setTimeout(() => {
131
+ dispatch({ type: "error", total: sources.length });
132
+ }, timeout);
133
+ return () => clearTimeout(id);
134
+ }, [state.status, currentSrc, isUserSrc, lazy, timeout, sources]);
135
+ const label = alt != null ? alt : domain ? `${domain} logo` : "favicon";
136
+ const letter = domain.charAt(0).toUpperCase() || "?";
137
+ const neutralSurface = {
138
+ borderRadius: borderRadius || void 0,
139
+ background: NEUTRAL_BG
95
140
  };
96
141
  return /* @__PURE__ */ React.createElement(
97
142
  "div",
98
143
  {
99
- className: `relative inline-block
100
- ${className}
101
- ${border ? "border" : ""}
102
- ${hasError ? "opacity-0" : ""}
103
- ${padding ? `p-[${padding}px]` : ""}
104
- ${borderRadius ? `rounded-[${borderRadius}px]` : ""}
105
- `,
144
+ className,
106
145
  style: {
146
+ position: "relative",
147
+ display: "inline-block",
107
148
  width: size,
108
149
  height: size,
109
- background,
110
- padding: padding ? `${padding}px` : 0,
111
- borderRadius: borderRadius ? `${borderRadius}px` : 0
150
+ boxSizing: "border-box",
151
+ // 默认 / 零值不写内联,留给消费方的 className 决定。
152
+ padding: padding || void 0,
153
+ borderRadius: borderRadius || void 0,
154
+ background: background === "transparent" ? void 0 : background,
155
+ border: border ? "1px solid color-mix(in srgb, currentColor 15%, transparent)" : void 0
112
156
  }
113
157
  },
114
- isLoading && /* @__PURE__ */ React.createElement("div", { className: "absolute inset-0 animate-pulse" }, /* @__PURE__ */ React.createElement("div", { className: "w-full h-full rounded-md bg-gray-200/60" })),
115
- (src || imgSrc) && /* @__PURE__ */ React.createElement(
158
+ isLoading && /* @__PURE__ */ React.createElement(
159
+ "div",
160
+ {
161
+ style: __spreadProps(__spreadValues({
162
+ position: "absolute",
163
+ inset: padding || 0
164
+ }, neutralSurface), {
165
+ animation: "favicon-stealer-pulse 1.5s ease-in-out infinite"
166
+ })
167
+ }
168
+ ),
169
+ currentSrc && !hasError && /* @__PURE__ */ React.createElement(
116
170
  "img",
117
171
  {
118
- src: src || imgSrc,
119
- alt: alt || `${domain} logo`,
172
+ key: currentSrc,
173
+ ref: imgRef,
174
+ src: currentSrc,
175
+ alt: isLoading ? "" : label,
120
176
  width: size,
121
177
  height: size,
122
178
  loading: lazy ? "lazy" : "eager",
123
- onError: handleError,
124
- onLoad: handleLoad,
125
- className: `inline-block transition-opacity duration-300 ${isLoading ? "opacity-0" : "opacity-100"}`,
179
+ decoding: "async",
180
+ onError: () => dispatch({ type: "error", total: sources.length }),
181
+ onLoad: () => dispatch({ type: "load" }),
126
182
  style: {
183
+ width: "100%",
184
+ height: "100%",
127
185
  objectFit: "contain",
128
- display: hasError ? "none" : "inline-block"
186
+ opacity: isLoading ? 0 : 1,
187
+ transition: "opacity 0.3s"
129
188
  }
130
189
  }
131
190
  ),
132
191
  hasError && /* @__PURE__ */ React.createElement(
133
192
  "div",
134
193
  {
135
- className: "w-full h-full flex items-center justify-center bg-gray-100 rounded-md",
136
- style: { fontSize: `${size * 0.5}px` }
194
+ role: "img",
195
+ "aria-label": label,
196
+ style: __spreadProps(__spreadValues({
197
+ width: "100%",
198
+ height: "100%",
199
+ display: "flex",
200
+ alignItems: "center",
201
+ justifyContent: "center"
202
+ }, neutralSurface), {
203
+ fontSize: size * 0.5,
204
+ lineHeight: 1
205
+ })
137
206
  },
138
- domain.charAt(0).toUpperCase()
207
+ letter
139
208
  )
140
209
  );
141
210
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "favicon-stealer",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -33,11 +33,12 @@
33
33
  "author": "Corey Chiu",
34
34
  "license": "MIT",
35
35
  "description": "Get clear and consistent favicon of a website easily",
36
- "dependencies": {
37
- "react": "^19.0.0",
38
- "react-dom": "^19.0.0"
36
+ "peerDependencies": {
37
+ "react": ">=18"
39
38
  },
40
39
  "devDependencies": {
40
+ "react": "^19.0.0",
41
+ "react-dom": "^19.0.0",
41
42
  "@types/react": "^19.0.8",
42
43
  "@types/react-dom": "^19.0.3",
43
44
  "typescript": "^5.7.3",