favicon-env 0.2.0 → 0.3.1
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 +556 -118
- package/dist/badge.d.ts +13 -0
- package/dist/{chunk-3JXNCGZF.js → chunk-OA3YX73Z.js} +6 -4
- package/dist/chunk-OA3YX73Z.js.map +1 -0
- package/dist/color.d.ts +6 -0
- package/dist/detect.d.ts +14 -0
- package/dist/favicon-env.global.js +1 -1
- package/dist/favicon-env.global.js.map +1 -1
- package/dist/filter.d.ts +7 -0
- package/dist/hash.d.ts +9 -0
- package/dist/index.d.ts +5 -54
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/ssr.d.ts +20 -1
- package/dist/ssr.js +1 -1
- package/dist/tint.d.ts +25 -0
- package/dist/{ssr-BzQ5WSMw.d.ts → types.d.ts} +15 -31
- package/package.json +51 -37
- package/skills/core/SKILL.md +142 -52
- package/dist/chunk-3JXNCGZF.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Tint your favicon per environment so you can tell instances apart at a glance — no more staring at three identical tabs wondering which one is production.
|
|
4
4
|
|
|
5
|
-

|
|
5
|
+

|
|
6
6
|
|
|
7
7
|
**[▶ Live, clickable demo](https://amir-abushanab.github.io/favicon-env/)**
|
|
8
8
|
|
|
9
|
-
- **Runtime mode** —
|
|
10
|
-
- **Build-time mode** —
|
|
11
|
-
- **Zero dependencies.** ~2.3 kB min+gzip
|
|
9
|
+
- **Runtime mode** — tint an existing SVG, PNG, or ICO in the browser.
|
|
10
|
+
- **Build-time mode** — bake changes into an SVG during build or SSR.
|
|
11
|
+
- **Zero dependencies.** ~2.3 kB min+gzip — and [tree-shakes to zero bytes in prod](#zero-bytes-in-prod).
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -16,42 +16,102 @@ Tint your favicon per environment so you can tell instances apart at a glance
|
|
|
16
16
|
pnpm add favicon-env
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
>
|
|
19
|
+
> **`dependencies` or `devDependencies`?** Either works — every mode below resolves the import during your build, so nothing looks `favicon-env` up at runtime on the deployed host, staging included. `dependencies` is the safer default: npm reads `NODE_ENV=production` as `--omit=dev`, so a CI or PaaS builder with that set skips a devDependency and fails the build on `Cannot find module 'favicon-env'` (pnpm doesn't couple the two). `-D` is defensible if you only use [build-time mode](#build-time--ssr-mode) and want a clean production audit. Neither costs you bundle size — [it tree-shakes to zero bytes in prod](#zero-bytes-in-prod).
|
|
20
|
+
|
|
21
|
+
> **Using an AI coding agent?** Install the bundled [Agent Skill](https://tanstack.com/intent) with `pnpm dlx @tanstack/intent@latest install`.
|
|
20
22
|
|
|
21
23
|
## Runtime mode
|
|
22
24
|
|
|
23
25
|
```js
|
|
24
|
-
import { envFavicon } from 'favicon-env'
|
|
26
|
+
import { envFavicon } from 'favicon-env';
|
|
25
27
|
|
|
26
28
|
envFavicon({
|
|
27
29
|
environments: {
|
|
28
|
-
dev:
|
|
29
|
-
staging: { badge: '#f59e0b'
|
|
30
|
-
// prod omitted → left untouched
|
|
30
|
+
dev: { tint: '#22c55e' },
|
|
31
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
31
32
|
},
|
|
32
|
-
})
|
|
33
|
+
});
|
|
33
34
|
```
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
### Zero bytes in prod
|
|
37
|
+
|
|
38
|
+
An unconditional call stays bundled even when `prod` is absent from `environments`. To remove the runtime, put `import('favicon-env')` behind a compile-time environment check. The examples below use this pattern, and the framework matrix scans their emitted prod assets to verify exclusion.
|
|
39
|
+
|
|
40
|
+
Call it on the client after the favicon link exists:
|
|
36
41
|
|
|
37
42
|
<details>
|
|
38
43
|
<summary><b>Next.js</b> — App Router</summary>
|
|
39
44
|
|
|
40
45
|
```tsx
|
|
41
46
|
// app/favicon-env.tsx — a client component
|
|
42
|
-
'use client'
|
|
43
|
-
import { useEffect } from 'react'
|
|
44
|
-
|
|
47
|
+
'use client';
|
|
48
|
+
import { useEffect } from 'react';
|
|
49
|
+
|
|
50
|
+
const appEnv = process.env.NEXT_PUBLIC_APP_ENV ?? 'prod';
|
|
45
51
|
|
|
46
52
|
export function FaviconEnv() {
|
|
47
53
|
useEffect(() => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
if (appEnv !== 'dev' && appEnv !== 'staging') return;
|
|
55
|
+
|
|
56
|
+
let queued = false;
|
|
57
|
+
const apply = async () => {
|
|
58
|
+
queued = false;
|
|
59
|
+
const { envFavicon } = await import('favicon-env');
|
|
60
|
+
await envFavicon({
|
|
61
|
+
environments: {
|
|
62
|
+
dev: { tint: '#22c55e' },
|
|
63
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
64
|
+
},
|
|
65
|
+
detect: () => appEnv,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const schedule = () => {
|
|
69
|
+
if (queued) return;
|
|
70
|
+
queued = true;
|
|
71
|
+
queueMicrotask(apply);
|
|
72
|
+
};
|
|
73
|
+
// Next can restore metadata-managed icons during hydration or navigation.
|
|
74
|
+
const observer = new MutationObserver(() => {
|
|
75
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
|
|
76
|
+
schedule();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
observer.observe(document.head, {
|
|
80
|
+
childList: true,
|
|
81
|
+
subtree: true,
|
|
82
|
+
attributes: true,
|
|
83
|
+
attributeFilter: ['href', 'rel'],
|
|
84
|
+
});
|
|
85
|
+
schedule();
|
|
86
|
+
return () => observer.disconnect();
|
|
87
|
+
}, []);
|
|
88
|
+
return null;
|
|
51
89
|
}
|
|
52
90
|
```
|
|
53
91
|
|
|
54
|
-
|
|
92
|
+
```tsx
|
|
93
|
+
// app/layout.tsx
|
|
94
|
+
import type { Metadata } from 'next';
|
|
95
|
+
import type { ReactNode } from 'react';
|
|
96
|
+
import { FaviconEnv } from './favicon-env';
|
|
97
|
+
|
|
98
|
+
export const metadata: Metadata = {
|
|
99
|
+
icons: { icon: '/favicon.svg' },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
103
|
+
return (
|
|
104
|
+
<html lang="en">
|
|
105
|
+
<body>
|
|
106
|
+
<FaviconEnv />
|
|
107
|
+
{children}
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The observer handles metadata reconciliation during hydration and navigation. Pages Router: render the component from `pages/_app.tsx`.
|
|
55
115
|
|
|
56
116
|
</details>
|
|
57
117
|
|
|
@@ -59,13 +119,54 @@ Then render `<FaviconEnv />` once inside `<body>` in `app/layout.tsx`. `useEffec
|
|
|
59
119
|
<summary><b>TanStack Router / Start</b></summary>
|
|
60
120
|
|
|
61
121
|
```tsx
|
|
62
|
-
// src/
|
|
63
|
-
import {
|
|
122
|
+
// src/client.tsx
|
|
123
|
+
import { StartClient } from '@tanstack/react-start/client';
|
|
124
|
+
import { StrictMode } from 'react';
|
|
125
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
126
|
+
|
|
127
|
+
const appEnv = import.meta.env.VITE_APP_ENV ?? 'prod';
|
|
128
|
+
|
|
129
|
+
if (appEnv === 'dev' || appEnv === 'staging') {
|
|
130
|
+
let queued = false;
|
|
131
|
+
const apply = async () => {
|
|
132
|
+
queued = false;
|
|
133
|
+
const { envFavicon } = await import('favicon-env');
|
|
134
|
+
await envFavicon({
|
|
135
|
+
environments: {
|
|
136
|
+
dev: { tint: '#22c55e' },
|
|
137
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
138
|
+
},
|
|
139
|
+
detect: () => appEnv,
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const schedule = () => {
|
|
143
|
+
if (queued) return;
|
|
144
|
+
queued = true;
|
|
145
|
+
queueMicrotask(apply);
|
|
146
|
+
};
|
|
147
|
+
const observer = new MutationObserver(() => {
|
|
148
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
|
|
149
|
+
schedule();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
observer.observe(document.head, {
|
|
153
|
+
childList: true,
|
|
154
|
+
subtree: true,
|
|
155
|
+
attributes: true,
|
|
156
|
+
attributeFilter: ['href', 'rel'],
|
|
157
|
+
});
|
|
158
|
+
schedule();
|
|
159
|
+
}
|
|
64
160
|
|
|
65
|
-
|
|
161
|
+
hydrateRoot(
|
|
162
|
+
document,
|
|
163
|
+
<StrictMode>
|
|
164
|
+
<StartClient />
|
|
165
|
+
</StrictMode>,
|
|
166
|
+
);
|
|
66
167
|
```
|
|
67
168
|
|
|
68
|
-
|
|
169
|
+
Create the optional `src/client.tsx` entry. For a TanStack Router SPA, use the same observer in `src/main.tsx` before rendering the app.
|
|
69
170
|
|
|
70
171
|
</details>
|
|
71
172
|
|
|
@@ -74,29 +175,373 @@ The entry runs on the client, so no `useEffect` is needed. (TanStack Start / SSR
|
|
|
74
175
|
|
|
75
176
|
```astro
|
|
76
177
|
---
|
|
77
|
-
// src/layouts/Layout.astro
|
|
178
|
+
// src/layouts/Layout.astro
|
|
78
179
|
---
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
180
|
+
<html lang="en">
|
|
181
|
+
<head>
|
|
182
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<slot />
|
|
186
|
+
<script>
|
|
187
|
+
const appEnv = import.meta.env.PUBLIC_APP_ENV ?? 'prod';
|
|
188
|
+
if (appEnv === 'dev' || appEnv === 'staging') {
|
|
189
|
+
void import('favicon-env').then(({ envFavicon }) =>
|
|
190
|
+
envFavicon({
|
|
191
|
+
environments: {
|
|
192
|
+
dev: { tint: '#22c55e' },
|
|
193
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
194
|
+
},
|
|
195
|
+
detect: () => appEnv,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
</script>
|
|
200
|
+
</body>
|
|
201
|
+
</html>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
For an SVG with no first-paint flash, use the build-time helper below.
|
|
205
|
+
|
|
206
|
+
</details>
|
|
207
|
+
|
|
208
|
+
<details>
|
|
209
|
+
<summary><b>SvelteKit</b></summary>
|
|
210
|
+
|
|
211
|
+
```svelte
|
|
212
|
+
<!-- src/routes/+layout.svelte -->
|
|
213
|
+
<script lang="ts">
|
|
214
|
+
import { onMount } from 'svelte';
|
|
215
|
+
|
|
216
|
+
let { children } = $props();
|
|
217
|
+
|
|
218
|
+
onMount(() => {
|
|
219
|
+
const appEnv = __FAVICON_ENV_APP_ENV__;
|
|
220
|
+
if (appEnv !== 'dev' && appEnv !== 'staging') return;
|
|
221
|
+
|
|
222
|
+
let queued = false;
|
|
223
|
+
const apply = async () => {
|
|
224
|
+
queued = false;
|
|
225
|
+
const { envFavicon } = await import('favicon-env');
|
|
226
|
+
await envFavicon({
|
|
227
|
+
environments: {
|
|
228
|
+
dev: { tint: '#22c55e' },
|
|
229
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
230
|
+
},
|
|
231
|
+
detect: () => appEnv,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const schedule = () => {
|
|
235
|
+
if (queued) return;
|
|
236
|
+
queued = true;
|
|
237
|
+
queueMicrotask(apply);
|
|
238
|
+
};
|
|
239
|
+
const observer = new MutationObserver(() => {
|
|
240
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
|
|
241
|
+
schedule();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
observer.observe(document.head, {
|
|
246
|
+
childList: true,
|
|
247
|
+
subtree: true,
|
|
248
|
+
attributes: true,
|
|
249
|
+
attributeFilter: ['href', 'rel'],
|
|
250
|
+
});
|
|
251
|
+
schedule();
|
|
252
|
+
|
|
253
|
+
return () => observer.disconnect();
|
|
254
|
+
});
|
|
82
255
|
</script>
|
|
256
|
+
|
|
257
|
+
<svelte:head>
|
|
258
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
259
|
+
</svelte:head>
|
|
260
|
+
|
|
261
|
+
{@render children()}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
// vite.config.ts
|
|
266
|
+
import { sveltekit } from '@sveltejs/kit/vite';
|
|
267
|
+
import { defineConfig } from 'vite';
|
|
268
|
+
|
|
269
|
+
export default defineConfig({
|
|
270
|
+
define: {
|
|
271
|
+
__FAVICON_ENV_APP_ENV__: JSON.stringify(process.env.PUBLIC_APP_ENV ?? 'prod'),
|
|
272
|
+
},
|
|
273
|
+
plugins: [sveltekit()],
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
// src/app.d.ts
|
|
279
|
+
declare const __FAVICON_ENV_APP_ENV__: string;
|
|
83
280
|
```
|
|
84
281
|
|
|
85
|
-
|
|
282
|
+
The explicit build constant lets Vite remove the import from prod output.
|
|
86
283
|
|
|
87
284
|
</details>
|
|
88
285
|
|
|
89
286
|
<details>
|
|
90
|
-
<summary><b>
|
|
287
|
+
<summary><b>SolidStart</b></summary>
|
|
91
288
|
|
|
92
|
-
```
|
|
93
|
-
//
|
|
94
|
-
import {
|
|
289
|
+
```tsx
|
|
290
|
+
// src/app.tsx
|
|
291
|
+
import { Link, MetaProvider } from '@solidjs/meta';
|
|
292
|
+
import { Router } from '@solidjs/router';
|
|
293
|
+
import { FileRoutes } from '@solidjs/start/router';
|
|
294
|
+
import { onCleanup, onMount, Suspense } from 'solid-js';
|
|
295
|
+
|
|
296
|
+
export default function App() {
|
|
297
|
+
onMount(() => {
|
|
298
|
+
const appEnv = import.meta.env.VITE_APP_ENV ?? 'prod';
|
|
299
|
+
if (appEnv !== 'dev' && appEnv !== 'staging') return;
|
|
300
|
+
|
|
301
|
+
let queued = false;
|
|
302
|
+
const apply = async () => {
|
|
303
|
+
queued = false;
|
|
304
|
+
const { envFavicon } = await import('favicon-env');
|
|
305
|
+
await envFavicon({
|
|
306
|
+
environments: {
|
|
307
|
+
dev: { tint: '#22c55e' },
|
|
308
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
309
|
+
},
|
|
310
|
+
detect: () => appEnv,
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
const schedule = () => {
|
|
314
|
+
if (queued) return;
|
|
315
|
+
queued = true;
|
|
316
|
+
queueMicrotask(apply);
|
|
317
|
+
};
|
|
318
|
+
const observer = new MutationObserver(() => {
|
|
319
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
|
|
320
|
+
schedule();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
observer.observe(document.head, {
|
|
325
|
+
childList: true,
|
|
326
|
+
subtree: true,
|
|
327
|
+
attributes: true,
|
|
328
|
+
attributeFilter: ['href', 'rel'],
|
|
329
|
+
});
|
|
330
|
+
schedule();
|
|
331
|
+
onCleanup(() => observer.disconnect());
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<Router
|
|
336
|
+
root={(props) => (
|
|
337
|
+
<MetaProvider>
|
|
338
|
+
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
339
|
+
<Suspense>{props.children}</Suspense>
|
|
340
|
+
</MetaProvider>
|
|
341
|
+
)}
|
|
342
|
+
>
|
|
343
|
+
<FileRoutes />
|
|
344
|
+
</Router>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Keep the hook in the app root so it survives route changes.
|
|
350
|
+
|
|
351
|
+
</details>
|
|
352
|
+
|
|
353
|
+
<details>
|
|
354
|
+
<summary><b>Angular SSR</b> — standalone application</summary>
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// src/app/app.ts
|
|
358
|
+
import { Component, DestroyRef, afterNextRender, inject } from '@angular/core';
|
|
359
|
+
import { loadEnvFavicon } from './favicon-env-loader';
|
|
360
|
+
|
|
361
|
+
declare const APP_ENV: string;
|
|
362
|
+
|
|
363
|
+
@Component({ selector: 'app-root', template: '<main>App</main>', imports: [] })
|
|
364
|
+
export class App {
|
|
365
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
366
|
+
|
|
367
|
+
constructor() {
|
|
368
|
+
afterNextRender(() => {
|
|
369
|
+
if (APP_ENV !== 'dev' && APP_ENV !== 'staging') return;
|
|
370
|
+
|
|
371
|
+
let queued = false;
|
|
372
|
+
const apply = async () => {
|
|
373
|
+
queued = false;
|
|
374
|
+
const envFavicon = await loadEnvFavicon();
|
|
375
|
+
if (!envFavicon) return;
|
|
376
|
+
await envFavicon({
|
|
377
|
+
environments: {
|
|
378
|
+
dev: { tint: '#22c55e' },
|
|
379
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
380
|
+
},
|
|
381
|
+
detect: () => APP_ENV,
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
const schedule = () => {
|
|
385
|
+
if (queued) return;
|
|
386
|
+
queued = true;
|
|
387
|
+
queueMicrotask(apply);
|
|
388
|
+
};
|
|
389
|
+
const observer = new MutationObserver(() => {
|
|
390
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
|
|
391
|
+
schedule();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
observer.observe(document.head, {
|
|
396
|
+
childList: true,
|
|
397
|
+
subtree: true,
|
|
398
|
+
attributes: true,
|
|
399
|
+
attributeFilter: ['href', 'rel'],
|
|
400
|
+
});
|
|
401
|
+
schedule();
|
|
402
|
+
this.destroyRef.onDestroy(() => observer.disconnect());
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
// src/app/favicon-env-loader.ts
|
|
410
|
+
export async function loadEnvFavicon() {
|
|
411
|
+
return (await import('favicon-env')).envFavicon;
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
// src/app/favicon-env-loader.prod.ts
|
|
417
|
+
import type { EnvFaviconOptions } from 'favicon-env';
|
|
418
|
+
|
|
419
|
+
type EnvFavicon = (options?: EnvFaviconOptions) => Promise<void>;
|
|
95
420
|
|
|
96
|
-
|
|
421
|
+
export async function loadEnvFavicon(): Promise<EnvFavicon | null> {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
In the production build configuration in `angular.json`:
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
{
|
|
430
|
+
"define": { "APP_ENV": "'prod'" },
|
|
431
|
+
"fileReplacements": [
|
|
432
|
+
{
|
|
433
|
+
"replace": "src/app/favicon-env-loader.ts",
|
|
434
|
+
"with": "src/app/favicon-env-loader.prod.ts"
|
|
435
|
+
}
|
|
436
|
+
]
|
|
437
|
+
}
|
|
97
438
|
```
|
|
98
439
|
|
|
99
|
-
|
|
440
|
+
Use the real loader for dev/staging. Angular's file replacement prevents it from emitting an unused lazy chunk in prod.
|
|
441
|
+
|
|
442
|
+
</details>
|
|
443
|
+
|
|
444
|
+
<details>
|
|
445
|
+
<summary><b>Nuxt</b></summary>
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
// app/plugins/favicon-env.client.ts
|
|
449
|
+
export default defineNuxtPlugin(() => {
|
|
450
|
+
const appEnv = __FAVICON_ENV_APP_ENV__;
|
|
451
|
+
if (appEnv !== 'dev' && appEnv !== 'staging') return;
|
|
452
|
+
|
|
453
|
+
let queued = false;
|
|
454
|
+
const apply = async () => {
|
|
455
|
+
queued = false;
|
|
456
|
+
const { envFavicon } = await import('favicon-env');
|
|
457
|
+
await envFavicon({
|
|
458
|
+
environments: {
|
|
459
|
+
dev: { tint: '#22c55e' },
|
|
460
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
461
|
+
},
|
|
462
|
+
detect: () => appEnv,
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
const schedule = () => {
|
|
466
|
+
if (queued) return;
|
|
467
|
+
queued = true;
|
|
468
|
+
queueMicrotask(apply);
|
|
469
|
+
};
|
|
470
|
+
const observer = new MutationObserver(() => {
|
|
471
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
|
|
472
|
+
schedule();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
observer.observe(document.head, {
|
|
477
|
+
childList: true,
|
|
478
|
+
subtree: true,
|
|
479
|
+
attributes: true,
|
|
480
|
+
attributeFilter: ['href', 'rel'],
|
|
481
|
+
});
|
|
482
|
+
schedule();
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
// nuxt.config.ts
|
|
488
|
+
export default defineNuxtConfig({
|
|
489
|
+
vite: {
|
|
490
|
+
define: {
|
|
491
|
+
__FAVICON_ENV_APP_ENV__: JSON.stringify(process.env.NUXT_PUBLIC_APP_ENV ?? 'prod'),
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
app: {
|
|
495
|
+
head: {
|
|
496
|
+
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
// app/types.d.ts
|
|
504
|
+
declare const __FAVICON_ENV_APP_ENV__: string;
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
The explicit build constant lets Vite remove the import from prod output.
|
|
508
|
+
|
|
509
|
+
</details>
|
|
510
|
+
|
|
511
|
+
<details>
|
|
512
|
+
<summary><b>Plain HTML</b> — ESM and global builds</summary>
|
|
513
|
+
|
|
514
|
+
```html
|
|
515
|
+
<!-- Native ESM through an ESM-aware CDN -->
|
|
516
|
+
<link rel="icon" href="/favicon.svg" />
|
|
517
|
+
<script type="module">
|
|
518
|
+
import { envFavicon } from 'https://esm.sh/favicon-env';
|
|
519
|
+
|
|
520
|
+
void envFavicon({
|
|
521
|
+
environments: {
|
|
522
|
+
dev: { tint: '#22c55e' },
|
|
523
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
</script>
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
```html
|
|
530
|
+
<!-- Classic global build -->
|
|
531
|
+
<link rel="icon" href="/favicon.svg" />
|
|
532
|
+
<script src="https://unpkg.com/favicon-env/dist/favicon-env.global.js"></script>
|
|
533
|
+
<script>
|
|
534
|
+
void faviconEnv.envFavicon({
|
|
535
|
+
environments: {
|
|
536
|
+
dev: { tint: '#22c55e' },
|
|
537
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
</script>
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
The global script can also auto-run with `data-auto` or `data-dev`/`data-staging` attributes.
|
|
544
|
+
For a zero-byte prod page, have the HTML build/template omit these scripts when the app environment is `prod`.
|
|
100
545
|
|
|
101
546
|
</details>
|
|
102
547
|
|
|
@@ -104,69 +549,84 @@ By default the environment is guessed from the hostname (`localhost` / `*.local`
|
|
|
104
549
|
|
|
105
550
|
```js
|
|
106
551
|
envFavicon({
|
|
107
|
-
environments: {
|
|
552
|
+
environments: {
|
|
553
|
+
dev: { tint: '#22c55e' },
|
|
554
|
+
staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
|
|
555
|
+
},
|
|
108
556
|
detect: () => (location.port === '4000' ? 'staging' : 'prod'),
|
|
109
|
-
})
|
|
557
|
+
});
|
|
110
558
|
```
|
|
111
559
|
|
|
112
|
-
Environment
|
|
560
|
+
Environment names are arbitrary. Custom names require a custom `detect`:
|
|
113
561
|
|
|
114
562
|
```js
|
|
115
563
|
envFavicon({
|
|
116
564
|
environments: {
|
|
117
565
|
canary: { hue: 280 },
|
|
118
|
-
demo:
|
|
566
|
+
demo: { badge: '#22c55e' },
|
|
119
567
|
},
|
|
120
568
|
detect: () => {
|
|
121
|
-
if (location.hostname.startsWith('canary.')) return 'canary'
|
|
122
|
-
if (location.hostname.endsWith('.demo.acme.com')) return 'demo'
|
|
123
|
-
return 'prod' // not in the map → favicon left untouched
|
|
569
|
+
if (location.hostname.startsWith('canary.')) return 'canary';
|
|
570
|
+
if (location.hostname.endsWith('.demo.acme.com')) return 'demo';
|
|
571
|
+
return 'prod'; // not in the map → favicon left untouched
|
|
124
572
|
},
|
|
125
|
-
})
|
|
573
|
+
});
|
|
126
574
|
```
|
|
127
575
|
|
|
128
|
-
`detect` can
|
|
576
|
+
`detect` can use any runtime value, such as `import.meta.env.MODE`.
|
|
129
577
|
|
|
130
578
|
### Exact colours
|
|
131
579
|
|
|
132
|
-
`hue`
|
|
580
|
+
Use `hue` for a relative colour shift or `tint` for a specific duotone colour:
|
|
133
581
|
|
|
134
582
|
```js
|
|
135
583
|
envFavicon({
|
|
136
584
|
environments: {
|
|
137
|
-
dev:
|
|
138
|
-
staging: { tint: '#f59e0b' },
|
|
585
|
+
dev: { tint: '#22c55e' },
|
|
586
|
+
staging: { tint: '#f59e0b' },
|
|
139
587
|
},
|
|
140
|
-
})
|
|
588
|
+
});
|
|
141
589
|
```
|
|
142
590
|
|
|
143
|
-
|
|
591
|
+
Colour fields accept CSS colours, including `oklch()`, `lab()`, and `color(display-p3 …)` where supported.
|
|
592
|
+
|
|
593
|
+
### Invert
|
|
594
|
+
|
|
595
|
+
Pass `true` for a full invert or `0`–`1` for a partial invert:
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
envFavicon({
|
|
599
|
+
environments: {
|
|
600
|
+
dev: { invert: true },
|
|
601
|
+
staging: { invert: 0.9 },
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
```
|
|
144
605
|
|
|
145
|
-
|
|
606
|
+
`invert` composes with `hue`; `tint` takes precedence, and `filter` overrides both.
|
|
146
607
|
|
|
147
608
|
### Auto mode
|
|
148
609
|
|
|
149
|
-
|
|
610
|
+
Derive a stable hue from `location.host`:
|
|
150
611
|
|
|
151
612
|
```js
|
|
152
|
-
envFavicon({ auto: true })
|
|
613
|
+
envFavicon({ auto: true });
|
|
153
614
|
```
|
|
154
615
|
|
|
155
616
|
### Badges, PR numbers & URL rules
|
|
156
617
|
|
|
157
|
-
A `badge`
|
|
618
|
+
A `badge` can be a colour dot or an object with text. Rules can interpolate regex captures with `$1` or `$<name>`:
|
|
158
619
|
|
|
159
620
|
```js
|
|
160
621
|
envFavicon({
|
|
161
622
|
rules: [
|
|
162
|
-
// e.g. a preview deploy at pr-344.myapp.dev → a "#344" pill
|
|
163
623
|
{ match: /^pr-(\d+)\./, badge: { text: '#$1', color: '#8b5cf6' } },
|
|
164
624
|
{ match: /staging\./, hue: 45 },
|
|
165
625
|
],
|
|
166
|
-
})
|
|
626
|
+
});
|
|
167
627
|
```
|
|
168
628
|
|
|
169
|
-
|
|
629
|
+
Regex rules test `location.host`, including the port. First match wins. Functions receive the full `URL`:
|
|
170
630
|
|
|
171
631
|
```js
|
|
172
632
|
rules: [
|
|
@@ -174,12 +634,10 @@ rules: [
|
|
|
174
634
|
match: (url) => url.searchParams.has('pr'),
|
|
175
635
|
badge: { text: (match, url) => `#${url.searchParams.get('pr')}` },
|
|
176
636
|
},
|
|
177
|
-
]
|
|
637
|
+
];
|
|
178
638
|
```
|
|
179
639
|
|
|
180
|
-
`
|
|
181
|
-
|
|
182
|
-
Multi-digit numbers get cramped in a corner at 16px. `shape: 'cover'` replaces the icon with a full-bleed number so it reads even in the tab (or keep the icon and just enlarge the pill with `size` + `corner: 'center'`):
|
|
640
|
+
Use `shape: 'cover'` to make multi-digit text readable at favicon size:
|
|
183
641
|
|
|
184
642
|
```js
|
|
185
643
|
{ badge: { text: '#344', color: '#8b5cf6', shape: 'cover' } }
|
|
@@ -187,7 +645,7 @@ Multi-digit numbers get cramped in a corner at 16px. `shape: 'cover'` replaces t
|
|
|
187
645
|
|
|
188
646
|
### A different image per environment
|
|
189
647
|
|
|
190
|
-
|
|
648
|
+
Use `src` to replace the base image. Other effects still apply:
|
|
191
649
|
|
|
192
650
|
```js
|
|
193
651
|
envFavicon({
|
|
@@ -195,30 +653,12 @@ envFavicon({
|
|
|
195
653
|
staging: { src: '/favicon.staging.svg' },
|
|
196
654
|
preview: { src: '/favicon.svg', badge: { text: '#344' } },
|
|
197
655
|
},
|
|
198
|
-
})
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
A plain `src` with no recolour/badge is applied directly (no canvas), so cross-origin images and crisp vectors just work.
|
|
202
|
-
|
|
203
|
-
### No build step
|
|
204
|
-
|
|
205
|
-
Drop in a `<script>` tag; it auto-runs from `data-*` attributes and also exposes `window.faviconEnv`:
|
|
206
|
-
|
|
207
|
-
```html
|
|
208
|
-
<!-- unique colour per host, zero config -->
|
|
209
|
-
<script src="https://unpkg.com/favicon-env/dist/favicon-env.global.js" data-auto></script>
|
|
210
|
-
|
|
211
|
-
<!-- or name your environments (hue in degrees) -->
|
|
212
|
-
<script
|
|
213
|
-
src="https://unpkg.com/favicon-env/dist/favicon-env.global.js"
|
|
214
|
-
data-dev="130"
|
|
215
|
-
data-staging="45"
|
|
216
|
-
></script>
|
|
656
|
+
});
|
|
217
657
|
```
|
|
218
658
|
|
|
219
659
|
## Build-time / SSR mode
|
|
220
660
|
|
|
221
|
-
|
|
661
|
+
Use `faviconDataUri` to bake changes into an SVG:
|
|
222
662
|
|
|
223
663
|
```astro
|
|
224
664
|
---
|
|
@@ -232,78 +672,77 @@ const tint = { dev: { hue: 130 }, staging: { hue: 45 }, prod: false }[env]
|
|
|
232
672
|
<link rel="icon" type="image/svg+xml" href={faviconDataUri(favicon, tint)} />
|
|
233
673
|
```
|
|
234
674
|
|
|
235
|
-
|
|
675
|
+
The SSR entry has no DOM dependency and supports badges:
|
|
236
676
|
|
|
237
677
|
```js
|
|
238
|
-
const pr = process.env.VERCEL_GIT_PULL_REQUEST_ID
|
|
239
|
-
faviconDataUri(favicon, pr ? { badge: { text: `#${pr}` } } : { hue: 45 })
|
|
678
|
+
const pr = process.env.VERCEL_GIT_PULL_REQUEST_ID;
|
|
679
|
+
faviconDataUri(favicon, pr ? { badge: { text: `#${pr}` } } : { hue: 45 });
|
|
240
680
|
```
|
|
241
681
|
|
|
242
682
|
### Vite
|
|
243
683
|
|
|
244
|
-
|
|
684
|
+
This Vite plugin rewrites an SVG favicon using the current mode:
|
|
245
685
|
|
|
246
686
|
```js
|
|
247
687
|
// vite.config.js
|
|
248
|
-
import { readFileSync } from 'node:fs'
|
|
249
|
-
import path from 'node:path'
|
|
250
|
-
import { defineConfig } from 'vite'
|
|
251
|
-
import { faviconDataUri } from 'favicon-env/ssr'
|
|
688
|
+
import { readFileSync } from 'node:fs';
|
|
689
|
+
import path from 'node:path';
|
|
690
|
+
import { defineConfig } from 'vite';
|
|
691
|
+
import { faviconDataUri } from 'favicon-env/ssr';
|
|
252
692
|
|
|
253
|
-
// keyed by Vite `mode` (e.g. `vite build --mode staging`); omit prod to leave it untouched
|
|
254
693
|
const tints = {
|
|
255
694
|
development: { hue: 130 },
|
|
256
695
|
staging: { hue: 45 },
|
|
257
|
-
}
|
|
696
|
+
};
|
|
258
697
|
|
|
259
698
|
function faviconEnv() {
|
|
260
|
-
let config
|
|
699
|
+
let config;
|
|
261
700
|
return {
|
|
262
701
|
name: 'favicon-env',
|
|
263
702
|
configResolved(resolved) {
|
|
264
|
-
config = resolved
|
|
703
|
+
config = resolved;
|
|
265
704
|
},
|
|
266
705
|
transformIndexHtml(html) {
|
|
267
|
-
const tint = tints[config.mode]
|
|
268
|
-
if (!tint) return
|
|
706
|
+
const tint = tints[config.mode];
|
|
707
|
+
if (!tint) return;
|
|
269
708
|
return html.replace(/<link\b[^>]*\brel=["']icon["'][^>]*>/i, (tag) => {
|
|
270
|
-
const href = tag.match(/\bhref=["']([^"']+)["']/i)?.[1]
|
|
271
|
-
if (!href?.endsWith('.svg')) return tag
|
|
272
|
-
let svg
|
|
709
|
+
const href = tag.match(/\bhref=["']([^"']+)["']/i)?.[1];
|
|
710
|
+
if (!href?.endsWith('.svg')) return tag;
|
|
711
|
+
let svg;
|
|
273
712
|
try {
|
|
274
|
-
svg = readFileSync(path.join(config.publicDir, href.replace(/^\//, '')), 'utf8')
|
|
713
|
+
svg = readFileSync(path.join(config.publicDir, href.replace(/^\//, '')), 'utf8');
|
|
275
714
|
} catch {
|
|
276
|
-
return tag
|
|
715
|
+
return tag;
|
|
277
716
|
}
|
|
278
|
-
return tag.replace(/\bhref=["'][^"']*["']/i, `href="${faviconDataUri(svg, tint)}"`)
|
|
279
|
-
})
|
|
717
|
+
return tag.replace(/\bhref=["'][^"']*["']/i, `href="${faviconDataUri(svg, tint)}"`);
|
|
718
|
+
});
|
|
280
719
|
},
|
|
281
|
-
}
|
|
720
|
+
};
|
|
282
721
|
}
|
|
283
722
|
|
|
284
723
|
export default defineConfig({
|
|
285
724
|
plugins: [faviconEnv()],
|
|
286
|
-
})
|
|
725
|
+
});
|
|
287
726
|
```
|
|
288
727
|
|
|
289
|
-
|
|
728
|
+
The favicon must be an SVG in `public/`. Omit a mode to leave it unchanged.
|
|
290
729
|
|
|
291
730
|
## API
|
|
292
731
|
|
|
293
732
|
### `envFavicon(options?): Promise<void>` — runtime
|
|
294
733
|
|
|
295
|
-
| option | type
|
|
296
|
-
| -------------- |
|
|
297
|
-
| `environments` | `Record<string, EnvTint \| false>`
|
|
298
|
-
| `rules` | `EnvRule[]`
|
|
299
|
-
| `detect` | `() => string \| undefined`
|
|
300
|
-
| `auto` | `boolean \| { offset?: number }`
|
|
301
|
-
| `source` | `string`
|
|
302
|
-
| `size` | `number`
|
|
734
|
+
| option | type | default | description |
|
|
735
|
+
| -------------- | ---------------------------------- | --------------------- | ------------------------------------------------------------------- |
|
|
736
|
+
| `environments` | `Record<string, EnvTint \| false>` | — | Map of env name (any string) → tint. Missing/`false` = untouched. |
|
|
737
|
+
| `rules` | `EnvRule[]` | — | URL-matched tints, checked first; regex captures fill `badge.text`. |
|
|
738
|
+
| `detect` | `() => string \| undefined` | hostname heuristic | Return the current env name (a key of `environments`). |
|
|
739
|
+
| `auto` | `boolean \| { offset?: number }` | `false` | Ignore `environments`; derive a unique hue from `location.host`. |
|
|
740
|
+
| `source` | `string` | current icon / `.ico` | Favicon URL to tint. |
|
|
741
|
+
| `size` | `number` | `64` | Canvas raster size in px. |
|
|
303
742
|
|
|
304
|
-
`EnvTint`: `{ hue?: number; tint?: string; filter?: string; src?: string; badge?: string | Badge }`.
|
|
743
|
+
`EnvTint`: `{ hue?: number; invert?: boolean | number; tint?: string; filter?: string; src?: string; badge?: string | Badge }`. Precedence is `filter` → `tint` → `hue`/`invert`; `src` replaces the base image.
|
|
305
744
|
|
|
306
|
-
`Badge`: `{ text?: string | number; color?: string; textColor?: string; shape?: 'pill' | 'cover'; corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'; size?: number; opacity?: number }`.
|
|
745
|
+
`Badge`: `{ text?: string | number; color?: string; textColor?: string; shape?: 'pill' | 'cover'; corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'; size?: number; opacity?: number }`. Without text it renders a dot. `textColor` defaults to automatic black/white contrast.
|
|
307
746
|
|
|
308
747
|
`EnvRule`: an `EnvTint` plus `match: RegExp | ((url: URL) => boolean)`. A `RegExp` is tested against `location.host` and its captures interpolate into `badge.text` (`$1`, `$<name>`); a function receives the `URL`, and in a rule `badge.text` may also be `(match, url) => string | number`.
|
|
309
748
|
|
|
@@ -319,12 +758,11 @@ Now `vite dev` and `vite build` serve the tint baked into the initial HTML — n
|
|
|
319
758
|
- `defaultDetect(hostname?) => string` — the built-in `dev`/`staging`/`prod` heuristic.
|
|
320
759
|
- `matchRules(rules, url) => EnvTint | null` — the pure rule matcher (first match wins, captures interpolated). Reuse it server-side with a request `URL` and feed the result to `favicon-env/ssr`'s `faviconDataUri`.
|
|
321
760
|
|
|
322
|
-
##
|
|
761
|
+
## Caveats
|
|
323
762
|
|
|
324
|
-
-
|
|
325
|
-
-
|
|
326
|
-
-
|
|
327
|
-
- **Browser support.** Runtime mode needs canvas `ctx.filter` (Baseline; unsupported browsers just get the untinted icon). SSR mode relies on SVG favicons honouring an embedded CSS `filter`, which all current evergreen browsers do.
|
|
763
|
+
- Cross-origin favicons need CORS permission for runtime canvas processing; failures leave the icon unchanged.
|
|
764
|
+
- Runtime mode may briefly show the original icon. Use the SSR helper to avoid this.
|
|
765
|
+
- Runtime mode requires canvas `ctx.filter`; unsupported browsers keep the original icon.
|
|
328
766
|
|
|
329
767
|
## License
|
|
330
768
|
|