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
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/** A tint / decoration to apply to the favicon for a given environment. */
|
|
2
|
-
interface EnvTint {
|
|
2
|
+
export interface EnvTint {
|
|
3
3
|
/** Hue rotation in degrees applied to the whole icon (e.g. `120`). */
|
|
4
4
|
hue?: number;
|
|
5
|
+
/**
|
|
6
|
+
* Invert the icon's colours — `true` for a full invert, or a number `0`–`1`
|
|
7
|
+
* for a partial one (maps to CSS `invert()`). Composes *with* `hue` (both are
|
|
8
|
+
* applied); an explicit `filter` beats it, and `tint` (a duotone) wins over it.
|
|
9
|
+
*/
|
|
10
|
+
invert?: boolean | number;
|
|
5
11
|
/**
|
|
6
12
|
* Colourise the icon to this exact colour (any CSS colour), preserving the
|
|
7
13
|
* artwork's shape and relative shading — a duotone from black up to `tint`.
|
|
@@ -27,7 +33,7 @@ interface EnvTint {
|
|
|
27
33
|
badge?: string | Badge;
|
|
28
34
|
}
|
|
29
35
|
/** A badge drawn on top of the icon. */
|
|
30
|
-
interface Badge {
|
|
36
|
+
export interface Badge {
|
|
31
37
|
/** Text to render, e.g. a PR number (`344`, `'#344'`). Omit for a plain dot. */
|
|
32
38
|
text?: string | number;
|
|
33
39
|
/** Background colour. Default `'#ef4444'`. */
|
|
@@ -47,17 +53,17 @@ interface Badge {
|
|
|
47
53
|
/** Badge opacity, 0–1 (default `1`). With `shape: 'cover'`, below `1` lets the icon show through. */
|
|
48
54
|
opacity?: number;
|
|
49
55
|
}
|
|
50
|
-
type BadgeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
|
|
56
|
+
export type BadgeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
|
|
51
57
|
/** Per-environment config. `false` / `null` / omitted leaves the favicon untouched. */
|
|
52
|
-
type EnvConfig = EnvTint | false | null | undefined;
|
|
58
|
+
export type EnvConfig = EnvTint | false | null | undefined;
|
|
53
59
|
/** Computes badge text from a regex match (if any) and the current URL. */
|
|
54
|
-
type BadgeTextFn = (match: RegExpMatchArray | null, url: URL) => string | number;
|
|
60
|
+
export type BadgeTextFn = (match: RegExpMatchArray | null, url: URL) => string | number;
|
|
55
61
|
/**
|
|
56
62
|
* A `Badge` for use in a `rule`, whose `text` may additionally be a function, or
|
|
57
63
|
* a template with `$1` / `$<name>` placeholders filled from the rule's regex
|
|
58
64
|
* captures — e.g. `'#$1'` against `/^pr-(\d+)\./`.
|
|
59
65
|
*/
|
|
60
|
-
type RuleBadge = Omit<Badge, 'text'> & {
|
|
66
|
+
export type RuleBadge = Omit<Badge, 'text'> & {
|
|
61
67
|
text?: string | number | BadgeTextFn;
|
|
62
68
|
};
|
|
63
69
|
/**
|
|
@@ -65,7 +71,7 @@ type RuleBadge = Omit<Badge, 'text'> & {
|
|
|
65
71
|
* later rule is tried). Rules are checked in order, before `auto` and
|
|
66
72
|
* `environments`/`detect`.
|
|
67
73
|
*/
|
|
68
|
-
type EnvRule = Omit<EnvTint, 'badge'> & {
|
|
74
|
+
export type EnvRule = Omit<EnvTint, 'badge'> & {
|
|
69
75
|
/**
|
|
70
76
|
* A `RegExp` (tested against `location.host`, i.e. `hostname:port`) or a
|
|
71
77
|
* function receiving the full `URL`. Regex captures feed `badge.text`.
|
|
@@ -75,7 +81,7 @@ type EnvRule = Omit<EnvTint, 'badge'> & {
|
|
|
75
81
|
badge?: string | RuleBadge;
|
|
76
82
|
};
|
|
77
83
|
/** Options for `envFavicon`. */
|
|
78
|
-
interface EnvFaviconOptions {
|
|
84
|
+
export interface EnvFaviconOptions {
|
|
79
85
|
/** Map of environment name → tint. A missing entry (or `false`) means "leave as-is". */
|
|
80
86
|
environments?: Record<string, EnvConfig>;
|
|
81
87
|
/**
|
|
@@ -101,29 +107,7 @@ interface EnvFaviconOptions {
|
|
|
101
107
|
size?: number;
|
|
102
108
|
}
|
|
103
109
|
/** Tuning for `EnvFaviconOptions.auto`. */
|
|
104
|
-
interface AutoOptions {
|
|
110
|
+
export interface AutoOptions {
|
|
105
111
|
/** Extra hue offset (deg) added to the derived hue — shifts the whole palette. */
|
|
106
112
|
offset?: number;
|
|
107
113
|
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Return `svg` (an SVG *string*) with the environment's tint and/or badge baked
|
|
111
|
-
* in — the form that survives being rendered as an `<img>` / favicon, with no
|
|
112
|
-
* first-paint flash. The tint is a CSS `filter` on a wrapping group; the badge
|
|
113
|
-
* is an appended `<g>` positioned via the SVG's `viewBox` (a badge is skipped if
|
|
114
|
-
* no `viewBox` or `width`/`height` can be read).
|
|
115
|
-
*
|
|
116
|
-
* Returns the SVG unchanged when `tint` is falsy, has nothing to apply, or the
|
|
117
|
-
* input isn't a recognisable `<svg>…</svg>` document.
|
|
118
|
-
*/
|
|
119
|
-
declare function tintSvg(svg: string, tint: EnvConfig): string;
|
|
120
|
-
/** Percent-encode an SVG string as a `data:` URI suitable for a favicon `href`. */
|
|
121
|
-
declare function svgToDataUri(svg: string): string;
|
|
122
|
-
/**
|
|
123
|
-
* Convenience: `tintSvg` + `svgToDataUri`. Give it your favicon SVG and the
|
|
124
|
-
* config for the current build's environment; get back a ready-to-use
|
|
125
|
-
* `<link rel="icon" href="…">` value with no first-paint flash.
|
|
126
|
-
*/
|
|
127
|
-
declare function faviconDataUri(svg: string, tint: EnvConfig): string;
|
|
128
|
-
|
|
129
|
-
export { type AutoOptions as A, type Badge as B, type EnvFaviconOptions as E, type RuleBadge as R, type EnvRule as a, type EnvTint as b, type BadgeCorner as c, type BadgeTextFn as d, type EnvConfig as e, faviconDataUri as f, svgToDataUri as s, tintSvg as t };
|
package/package.json
CHANGED
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "favicon-env",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.3.1",
|
|
5
4
|
"description": "Tint your favicon per environment (dev/staging/prod) so you can tell instances apart at a glance. Runtime canvas mode + build-time SVG mode. Zero dependencies.",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Amir Abushanab",
|
|
8
5
|
"keywords": [
|
|
9
|
-
"favicon",
|
|
10
|
-
"environment",
|
|
11
|
-
"env",
|
|
12
|
-
"dev",
|
|
13
|
-
"staging",
|
|
14
|
-
"hue",
|
|
15
|
-
"tint",
|
|
16
6
|
"canvas",
|
|
7
|
+
"dev",
|
|
17
8
|
"developer-experience",
|
|
18
9
|
"dx",
|
|
19
|
-
"
|
|
10
|
+
"env",
|
|
11
|
+
"environment",
|
|
12
|
+
"favicon",
|
|
13
|
+
"hue",
|
|
14
|
+
"staging",
|
|
15
|
+
"tanstack-intent",
|
|
16
|
+
"tint"
|
|
20
17
|
],
|
|
18
|
+
"homepage": "https://github.com/Amir-Abushanab/favicon-env#readme",
|
|
19
|
+
"bugs": "https://github.com/Amir-Abushanab/favicon-env/issues",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Amir Abushanab",
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
23
24
|
"url": "git+https://github.com/Amir-Abushanab/favicon-env.git"
|
|
24
25
|
},
|
|
25
|
-
"
|
|
26
|
-
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"skills",
|
|
31
|
+
"!skills/_artifacts"
|
|
32
|
+
],
|
|
33
|
+
"type": "module",
|
|
27
34
|
"sideEffects": false,
|
|
35
|
+
"unpkg": "./dist/favicon-env.global.js",
|
|
36
|
+
"jsdelivr": "./dist/favicon-env.global.js",
|
|
28
37
|
"exports": {
|
|
29
38
|
".": {
|
|
30
39
|
"types": "./dist/index.d.ts",
|
|
@@ -36,36 +45,30 @@
|
|
|
36
45
|
},
|
|
37
46
|
"./global": "./dist/favicon-env.global.js"
|
|
38
47
|
},
|
|
39
|
-
"unpkg": "./dist/favicon-env.global.js",
|
|
40
|
-
"jsdelivr": "./dist/favicon-env.global.js",
|
|
41
|
-
"files": [
|
|
42
|
-
"dist",
|
|
43
|
-
"README.md",
|
|
44
|
-
"LICENSE",
|
|
45
|
-
"skills",
|
|
46
|
-
"!skills/_artifacts"
|
|
47
|
-
],
|
|
48
48
|
"publishConfig": {
|
|
49
49
|
"access": "public"
|
|
50
50
|
},
|
|
51
|
-
"simple-git-hooks": {
|
|
52
|
-
"pre-commit": "pnpm check"
|
|
53
|
-
},
|
|
54
51
|
"devDependencies": {
|
|
55
|
-
"@changesets/cli": "^
|
|
56
|
-
"@
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
52
|
+
"@changesets/cli": "^3.0.1",
|
|
53
|
+
"@playwright/test": "^1.62.1",
|
|
54
|
+
"@swc/core": "^1.16.1",
|
|
55
|
+
"@tanstack/intent": "^0.3.7",
|
|
56
|
+
"dependency-cruiser": "^18.2.0",
|
|
57
|
+
"knip": "^6.32.3",
|
|
58
|
+
"oxfmt": "^0.65.0",
|
|
59
|
+
"oxlint": "^1.80.0",
|
|
60
|
+
"publint": "^0.3.24",
|
|
62
61
|
"simple-git-hooks": "^2.13.1",
|
|
63
62
|
"tsup": "^8.5.1",
|
|
64
|
-
"typescript": "^
|
|
63
|
+
"typescript": "^7.0.2"
|
|
64
|
+
},
|
|
65
|
+
"simple-git-hooks": {
|
|
66
|
+
"pre-commit": "pnpm check"
|
|
65
67
|
},
|
|
66
68
|
"scripts": {
|
|
67
|
-
"build": "tsup",
|
|
69
|
+
"build": "tsup && tsc -p tsconfig.build.json",
|
|
68
70
|
"dev": "node scripts/serve.mjs --watch",
|
|
71
|
+
"hero": "pnpm build && node scripts/hero.mjs",
|
|
69
72
|
"typecheck": "tsc --noEmit",
|
|
70
73
|
"lint": "oxlint src",
|
|
71
74
|
"format": "oxfmt src",
|
|
@@ -74,11 +77,22 @@
|
|
|
74
77
|
"check": "oxfmt --check src && oxlint src && knip && depcruise src",
|
|
75
78
|
"publint": "publint --strict",
|
|
76
79
|
"intent:validate": "intent validate skills",
|
|
80
|
+
"intent:stale": "intent stale skills",
|
|
81
|
+
"skill:sync": "node scripts/skill-version.mjs",
|
|
82
|
+
"skill:check": "node scripts/skill-version.mjs --check",
|
|
83
|
+
"skill:stale": "node scripts/skill-stale.mjs",
|
|
77
84
|
"pretest": "pnpm build",
|
|
78
|
-
"test": "node --test",
|
|
85
|
+
"test": "node --test test/pure.test.mjs test/skill.test.mjs",
|
|
86
|
+
"test:skill": "node --test test/skill.test.mjs",
|
|
87
|
+
"test:frameworks": "node test/frameworks/run.mjs",
|
|
88
|
+
"test:frameworks:firefox": "node test/frameworks/run.mjs --browser firefox",
|
|
89
|
+
"test:frameworks:webkit": "node test/frameworks/run.mjs --browser webkit",
|
|
90
|
+
"test:frameworks:browsers": "pnpm test:frameworks && pnpm test:frameworks:firefox && pnpm test:frameworks:webkit",
|
|
91
|
+
"dev:frameworks": "node test/frameworks/serve-all.mjs",
|
|
92
|
+
"test:all": "pnpm test && pnpm test:frameworks",
|
|
79
93
|
"ncu": "pnpm dlx npm-check-updates",
|
|
80
94
|
"changeset": "changeset",
|
|
81
|
-
"version": "changeset version",
|
|
95
|
+
"version": "changeset version && pnpm run skill:sync",
|
|
82
96
|
"release": "changeset publish"
|
|
83
97
|
}
|
|
84
98
|
}
|
package/skills/core/SKILL.md
CHANGED
|
@@ -3,13 +3,14 @@ name: core
|
|
|
3
3
|
description: >
|
|
4
4
|
favicon-env tints the browser favicon per environment (dev/staging/prod) so
|
|
5
5
|
identical tabs are distinguishable. Load when calling envFavicon, choosing
|
|
6
|
-
runtime (canvas) vs build-time (SSR) mode, adding hue/filter tints or
|
|
7
|
-
badges/PR numbers, wiring it into Next.js/TanStack/Astro/
|
|
8
|
-
detect, environments, rules, or
|
|
6
|
+
runtime (canvas) vs build-time (SSR) mode, adding hue/invert/filter tints or
|
|
7
|
+
badges/PR numbers, wiring it into Next.js/TanStack/Astro/SvelteKit/SolidStart/
|
|
8
|
+
Angular/Nuxt/Vite/plain HTML, or configuring detect, environments, rules, or
|
|
9
|
+
auto mode.
|
|
9
10
|
metadata:
|
|
10
11
|
type: core
|
|
11
12
|
library: favicon-env
|
|
12
|
-
library_version: '0.1
|
|
13
|
+
library_version: '0.3.1'
|
|
13
14
|
sources:
|
|
14
15
|
- 'Amir-Abushanab/favicon-env:README.md'
|
|
15
16
|
- 'Amir-Abushanab/favicon-env:src/tint.ts'
|
|
@@ -27,24 +28,65 @@ when you control the favicon SVG.
|
|
|
27
28
|
|
|
28
29
|
## Setup
|
|
29
30
|
|
|
30
|
-
Runtime —
|
|
31
|
+
Runtime — gate the import with a compile-time public environment value:
|
|
31
32
|
|
|
32
33
|
```js
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
34
|
+
const appEnv = import.meta.env.VITE_APP_ENV ?? 'prod';
|
|
35
|
+
|
|
36
|
+
if (appEnv === 'dev' || appEnv === 'staging') {
|
|
37
|
+
void import('favicon-env').then(({ envFavicon }) =>
|
|
38
|
+
envFavicon({
|
|
39
|
+
environments: {
|
|
40
|
+
dev: { tint: '#22c55e' },
|
|
41
|
+
staging: { badge: '#f59e0b' },
|
|
42
|
+
},
|
|
43
|
+
detect: () => appEnv,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
42
47
|
```
|
|
43
48
|
|
|
49
|
+
With a literal prod value, the bundler can remove the branch and package runtime.
|
|
50
|
+
An unconditional call remains bundled even when `prod` is absent from `environments`.
|
|
51
|
+
|
|
44
52
|
The environment name defaults to a `location.hostname` heuristic (`defaultDetect`):
|
|
45
53
|
`localhost`/`*.local`/raw IPs → `dev`; a `staging`/`preview`/`qa`/`uat`/… segment →
|
|
46
54
|
`staging`; everything else → `prod`.
|
|
47
55
|
|
|
56
|
+
### Framework placement
|
|
57
|
+
|
|
58
|
+
| Runtime | Put `envFavicon` here |
|
|
59
|
+
| -------------- | ------------------------------------------------------------------------------ |
|
|
60
|
+
| Next.js | Client component `useEffect`; render once from the root layout |
|
|
61
|
+
| TanStack Start | Optional `src/client.tsx`, before `hydrateRoot` |
|
|
62
|
+
| Astro | Bundled client `<script>` in the root layout |
|
|
63
|
+
| SvelteKit | `onMount` in persistent `src/routes/+layout.svelte` |
|
|
64
|
+
| SolidStart | `onMount` in `src/app.tsx`; use `onCleanup` for the observer |
|
|
65
|
+
| Angular SSR | `afterNextRender` in the standalone root component; clean up with `DestroyRef` |
|
|
66
|
+
| Nuxt | `app/plugins/favicon-env.client.ts`, using an explicit Vite build constant |
|
|
67
|
+
| Vite SPA | Client entry module (`src/main.ts`, etc.) |
|
|
68
|
+
| Plain HTML | Native module or the global build (`window.faviconEnv`) |
|
|
69
|
+
|
|
70
|
+
For any SSR router/head manager that declares the favicon itself, wrap the call in
|
|
71
|
+
the head-observer pattern below. Hydration or navigation can otherwise restore its
|
|
72
|
+
unmanaged `<link rel="icon">` after an early call. Apply only when an unmanaged icon
|
|
73
|
+
exists, queue one microtask at a time, and disconnect through the framework's native
|
|
74
|
+
cleanup hook when the integration is component-scoped.
|
|
75
|
+
|
|
76
|
+
### Zero runtime bytes in prod
|
|
77
|
+
|
|
78
|
+
Put `import('favicon-env')` behind a value the client bundler replaces at build time.
|
|
79
|
+
Use `NEXT_PUBLIC_*` in Next and `import.meta.env.VITE_*`/`PUBLIC_*` where those values
|
|
80
|
+
are folded to literals. SvelteKit and Nuxt may preserve their normal public runtime
|
|
81
|
+
configuration, so define a dedicated constant through Vite's `define` option and use
|
|
82
|
+
that in the guard.
|
|
83
|
+
|
|
84
|
+
Angular's application builder can emit a lazy chunk even behind a false `define`
|
|
85
|
+
guard. Put the dynamic import in a local loader module and replace that module with a
|
|
86
|
+
typed no-op in the prod build using Angular `fileReplacements`. For plain HTML, omit
|
|
87
|
+
the ESM/global script from the generated prod page. Do not use `enabled: false`: the
|
|
88
|
+
runtime must already be imported to read that option.
|
|
89
|
+
|
|
48
90
|
## Core Patterns
|
|
49
91
|
|
|
50
92
|
### Custom environments — any name, but supply a matching `detect`
|
|
@@ -53,11 +95,11 @@ The environment name defaults to a `location.hostname` heuristic (`defaultDetect
|
|
|
53
95
|
void envFavicon({
|
|
54
96
|
environments: { canary: { hue: 280 }, demo: { badge: '#22c55e' } },
|
|
55
97
|
detect: () => {
|
|
56
|
-
if (location.hostname.startsWith('canary.')) return 'canary'
|
|
57
|
-
if (location.hostname.endsWith('.demo.acme.com')) return 'demo'
|
|
58
|
-
return 'prod' // not in the map → untouched
|
|
98
|
+
if (location.hostname.startsWith('canary.')) return 'canary';
|
|
99
|
+
if (location.hostname.endsWith('.demo.acme.com')) return 'demo';
|
|
100
|
+
return 'prod'; // not in the map → untouched
|
|
59
101
|
},
|
|
60
|
-
})
|
|
102
|
+
});
|
|
61
103
|
```
|
|
62
104
|
|
|
63
105
|
`environments` keys are arbitrary strings, but `defaultDetect` only ever returns
|
|
@@ -72,7 +114,7 @@ void envFavicon({
|
|
|
72
114
|
{ match: /^pr-(\d+)\./, badge: { text: '#$1', color: '#8b5cf6', shape: 'cover' } },
|
|
73
115
|
{ match: /staging\./, hue: 45 },
|
|
74
116
|
],
|
|
75
|
-
})
|
|
117
|
+
});
|
|
76
118
|
```
|
|
77
119
|
|
|
78
120
|
`match` is a `RegExp` tested against `location.host` (includes `:port`) or a
|
|
@@ -82,7 +124,7 @@ it falls through to `auto`/`environments`.
|
|
|
82
124
|
### Auto mode — one stable colour per host, zero config
|
|
83
125
|
|
|
84
126
|
```js
|
|
85
|
-
void envFavicon({ auto: true })
|
|
127
|
+
void envFavicon({ auto: true });
|
|
86
128
|
```
|
|
87
129
|
|
|
88
130
|
Derives a deterministic hue from `location.host`, so every origin and port gets its
|
|
@@ -91,11 +133,11 @@ own colour — handy for telling several dev servers apart.
|
|
|
91
133
|
### Build-time SSR — no first-paint flash
|
|
92
134
|
|
|
93
135
|
```js
|
|
94
|
-
import { faviconDataUri } from 'favicon-env/ssr'
|
|
95
|
-
import favicon from './favicon.svg?raw'
|
|
136
|
+
import { faviconDataUri } from 'favicon-env/ssr';
|
|
137
|
+
import favicon from './favicon.svg?raw';
|
|
96
138
|
|
|
97
139
|
// during an Astro/Vite build; pick the tint for the current env
|
|
98
|
-
const href = faviconDataUri(favicon, { hue: 130 })
|
|
140
|
+
const href = faviconDataUri(favicon, { hue: 130 });
|
|
99
141
|
// → render into <link rel="icon" type="image/svg+xml" href={href}>
|
|
100
142
|
```
|
|
101
143
|
|
|
@@ -109,7 +151,7 @@ CSS `filter` and/or badge into the SVG using its `viewBox`.
|
|
|
109
151
|
Wrong:
|
|
110
152
|
|
|
111
153
|
```js
|
|
112
|
-
void envFavicon({ environments: { canary: { hue: 280 } } })
|
|
154
|
+
void envFavicon({ environments: { canary: { hue: 280 } } });
|
|
113
155
|
```
|
|
114
156
|
|
|
115
157
|
Correct:
|
|
@@ -118,7 +160,7 @@ Correct:
|
|
|
118
160
|
void envFavicon({
|
|
119
161
|
environments: { canary: { hue: 280 } },
|
|
120
162
|
detect: () => (location.hostname.startsWith('canary.') ? 'canary' : 'prod'),
|
|
121
|
-
})
|
|
163
|
+
});
|
|
122
164
|
```
|
|
123
165
|
|
|
124
166
|
`defaultDetect` only returns `dev`/`staging`/`prod`, so `environments.canary` is
|
|
@@ -132,28 +174,74 @@ Wrong:
|
|
|
132
174
|
|
|
133
175
|
```jsx
|
|
134
176
|
// app/page.tsx — a Next.js App Router Server Component
|
|
135
|
-
import { envFavicon } from 'favicon-env'
|
|
136
|
-
envFavicon({ environments: { dev: { hue: 130 } } }) // runs on the server
|
|
177
|
+
import { envFavicon } from 'favicon-env';
|
|
178
|
+
envFavicon({ environments: { dev: { hue: 130 } } }); // runs on the server
|
|
137
179
|
```
|
|
138
180
|
|
|
139
181
|
Correct:
|
|
140
182
|
|
|
141
183
|
```jsx
|
|
142
|
-
'use client'
|
|
143
|
-
import { useEffect } from 'react'
|
|
144
|
-
|
|
184
|
+
'use client';
|
|
185
|
+
import { useEffect } from 'react';
|
|
186
|
+
|
|
187
|
+
const appEnv = process.env.NEXT_PUBLIC_APP_ENV ?? 'prod';
|
|
145
188
|
|
|
146
189
|
export function FaviconEnv() {
|
|
147
190
|
useEffect(() => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
191
|
+
if (appEnv !== 'dev' && appEnv !== 'staging') return;
|
|
192
|
+
|
|
193
|
+
let queued = false;
|
|
194
|
+
const apply = async () => {
|
|
195
|
+
queued = false;
|
|
196
|
+
const { envFavicon } = await import('favicon-env');
|
|
197
|
+
await envFavicon({
|
|
198
|
+
environments: { dev: { tint: '#22c55e' }, staging: { badge: '#f59e0b' } },
|
|
199
|
+
detect: () => appEnv,
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
const schedule = () => {
|
|
203
|
+
if (queued) return;
|
|
204
|
+
queued = true;
|
|
205
|
+
queueMicrotask(apply);
|
|
206
|
+
};
|
|
207
|
+
const observer = new MutationObserver(() => {
|
|
208
|
+
if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) schedule();
|
|
209
|
+
});
|
|
210
|
+
observer.observe(document.head, {
|
|
211
|
+
childList: true,
|
|
212
|
+
subtree: true,
|
|
213
|
+
attributes: true,
|
|
214
|
+
attributeFilter: ['href', 'rel'],
|
|
215
|
+
});
|
|
216
|
+
schedule();
|
|
217
|
+
return () => observer.disconnect();
|
|
218
|
+
}, []);
|
|
219
|
+
return null;
|
|
151
220
|
}
|
|
152
221
|
```
|
|
153
222
|
|
|
154
223
|
Runtime mode needs `document`; on the server it is a no-op and never tints the
|
|
155
224
|
client. Use `useEffect` in a client component (Next App Router), or call it at a
|
|
156
|
-
client-entry module (Vite/TanStack `src/main.ts`,
|
|
225
|
+
client-entry module (Vite/TanStack Router `src/main.ts`, TanStack Start's optional
|
|
226
|
+
`src/client.tsx`, an Astro `<script>`, SvelteKit/Solid `onMount`, Angular
|
|
227
|
+
`afterNextRender`, or a Nuxt `.client.ts` plugin). In TanStack Start, preserve the standard
|
|
228
|
+
`hydrateRoot(document, <StartClient />)` code. Install the same head observer shown
|
|
229
|
+
above before hydration because TanStack's head hydration can restore its route-managed
|
|
230
|
+
icon after an early `envFavicon` call; no React effect cleanup is needed for this
|
|
231
|
+
page-lifetime client entry.
|
|
232
|
+
Do not put the Next.js integration in `instrumentation-client.ts`: it runs before
|
|
233
|
+
hydration, so Next's metadata reconciliation can restore the original icon after
|
|
234
|
+
`envFavicon` replaces it. In the client component, observe `document.head` and
|
|
235
|
+
reapply only when an unmanaged icon appears; disconnect the observer in the Next effect
|
|
236
|
+
cleanup for React StrictMode. This also handles route-level metadata changes.
|
|
237
|
+
|
|
238
|
+
In SvelteKit put the observer in a persistent `+layout.svelte` and return cleanup
|
|
239
|
+
from `onMount`. In SolidStart register cleanup with `onCleanup` inside `onMount`.
|
|
240
|
+
In Angular register it with `DestroyRef.onDestroy` inside `afterNextRender`. In Nuxt
|
|
241
|
+
use a `.client.ts` plugin; a page-lifetime plugin observer does not require component
|
|
242
|
+
cleanup. Use public build constants (`NEXT_PUBLIC_*`, `PUBLIC_*`, `VITE_*`, or a Vite
|
|
243
|
+
`define`) rather than changing `NODE_ENV` to `staging`. Use Angular file replacement
|
|
244
|
+
and explicit Vite constants for SvelteKit/Nuxt when prod must emit no runtime chunk.
|
|
157
245
|
|
|
158
246
|
Source: src/tint.ts (`typeof document` guard), README "Runtime mode"
|
|
159
247
|
|
|
@@ -163,27 +251,29 @@ Wrong:
|
|
|
163
251
|
|
|
164
252
|
```js
|
|
165
253
|
// trying to make dev green — but hue-rotate is relative to the base icon
|
|
166
|
-
void envFavicon({ environments: { dev: { hue: 130 } } })
|
|
254
|
+
void envFavicon({ environments: { dev: { hue: 130 } } });
|
|
167
255
|
```
|
|
168
256
|
|
|
169
257
|
Correct:
|
|
170
258
|
|
|
171
259
|
```js
|
|
172
|
-
void envFavicon({ environments: { dev: { tint: '#22c55e' } } })
|
|
260
|
+
void envFavicon({ environments: { dev: { tint: '#22c55e' } } });
|
|
173
261
|
```
|
|
174
262
|
|
|
175
|
-
`hue`
|
|
176
|
-
barely moves white/black/grey). `tint` colourises to an
|
|
177
|
-
preserving shape and shading. For a flat block, use a text-less `cover` badge.
|
|
263
|
+
`hue` _rotates_ the existing colours (the result depends on the base icon, and it
|
|
264
|
+
barely moves white/black/grey). `tint` colourises to an _exact_ colour while
|
|
265
|
+
preserving shape and shading. For a flat block, use a text-less `cover` badge. For a
|
|
266
|
+
quick high-contrast flip with no colour to choose, use `invert: true` (or a `0`–`1`
|
|
267
|
+
amount); it composes with `hue`, while `tint` and an explicit `filter` take precedence.
|
|
178
268
|
|
|
179
|
-
Source: src/types.ts (EnvTint.tint), src/tint.ts (colorize path)
|
|
269
|
+
Source: src/types.ts (EnvTint.tint / EnvTint.invert), src/tint.ts (colorize path), src/filter.ts
|
|
180
270
|
|
|
181
271
|
### MEDIUM — Multi-digit badge as a corner pill
|
|
182
272
|
|
|
183
273
|
Wrong:
|
|
184
274
|
|
|
185
275
|
```js
|
|
186
|
-
void envFavicon({ environments: { preview: { badge: { text: '#344' } } } })
|
|
276
|
+
void envFavicon({ environments: { preview: { badge: { text: '#344' } } } });
|
|
187
277
|
```
|
|
188
278
|
|
|
189
279
|
Correct:
|
|
@@ -191,7 +281,7 @@ Correct:
|
|
|
191
281
|
```js
|
|
192
282
|
void envFavicon({
|
|
193
283
|
environments: { preview: { badge: { text: '#344', shape: 'cover' } } },
|
|
194
|
-
})
|
|
284
|
+
});
|
|
195
285
|
```
|
|
196
286
|
|
|
197
287
|
The default `pill` badge sits in a corner at ~half the icon, so a 3–4 digit number
|
|
@@ -207,14 +297,14 @@ Wrong:
|
|
|
207
297
|
void envFavicon({
|
|
208
298
|
source: 'https://cdn.example.com/favicon.png', // served without CORS headers
|
|
209
299
|
environments: { dev: { hue: 130 } },
|
|
210
|
-
})
|
|
300
|
+
});
|
|
211
301
|
```
|
|
212
302
|
|
|
213
303
|
Correct:
|
|
214
304
|
|
|
215
305
|
```js
|
|
216
306
|
// serve the favicon same-origin, or with Access-Control-Allow-Origin
|
|
217
|
-
void envFavicon({ source: '/favicon.png', environments: { dev: { hue: 130 } } })
|
|
307
|
+
void envFavicon({ source: '/favicon.png', environments: { dev: { hue: 130 } } });
|
|
218
308
|
```
|
|
219
309
|
|
|
220
310
|
Tinting draws to a canvas; a cross-origin image without CORS taints it, so
|
|
@@ -228,16 +318,16 @@ Wrong:
|
|
|
228
318
|
|
|
229
319
|
```js
|
|
230
320
|
// runtime mode always shows the untinted icon until JS runs
|
|
231
|
-
import { envFavicon } from 'favicon-env'
|
|
232
|
-
void envFavicon({ environments: { dev: { hue: 130 } } })
|
|
321
|
+
import { envFavicon } from 'favicon-env';
|
|
322
|
+
void envFavicon({ environments: { dev: { hue: 130 } } });
|
|
233
323
|
```
|
|
234
324
|
|
|
235
325
|
Correct:
|
|
236
326
|
|
|
237
327
|
```js
|
|
238
328
|
// bake the tint into the initial HTML at build time — no flash
|
|
239
|
-
import { faviconDataUri } from 'favicon-env/ssr'
|
|
240
|
-
const href = faviconDataUri(faviconSvg, { hue: 130 })
|
|
329
|
+
import { faviconDataUri } from 'favicon-env/ssr';
|
|
330
|
+
const href = faviconDataUri(faviconSvg, { hue: 130 });
|
|
241
331
|
```
|
|
242
332
|
|
|
243
333
|
`envFavicon` runs after first paint, so the original icon flashes briefly. When
|
|
@@ -250,15 +340,15 @@ Source: README "First-paint flash", src/ssr.ts
|
|
|
250
340
|
Wrong:
|
|
251
341
|
|
|
252
342
|
```js
|
|
253
|
-
envFavicon({ environments: { dev: { hue: 130 } } })
|
|
254
|
-
const href = document.querySelector('link[rel~="icon"]').href // old icon — not swapped yet
|
|
343
|
+
envFavicon({ environments: { dev: { hue: 130 } } });
|
|
344
|
+
const href = document.querySelector('link[rel~="icon"]').href; // old icon — not swapped yet
|
|
255
345
|
```
|
|
256
346
|
|
|
257
347
|
Correct:
|
|
258
348
|
|
|
259
349
|
```js
|
|
260
|
-
await envFavicon({ environments: { dev: { hue: 130 } } })
|
|
261
|
-
const href = document.querySelector('link[rel~="icon"]').href
|
|
350
|
+
await envFavicon({ environments: { dev: { hue: 130 } } });
|
|
351
|
+
const href = document.querySelector('link[rel~="icon"]').href;
|
|
262
352
|
```
|
|
263
353
|
|
|
264
354
|
`envFavicon` returns a `Promise` and loads the base image asynchronously before
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/badge.ts","../src/color.ts","../src/filter.ts","../src/ssr.ts"],"names":[],"mappings":"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","file":"chunk-3JXNCGZF.js","sourcesContent":["import type { Badge, BadgeCorner } from './types';\n\n/** Default badge background — the red dot/pill you get when no `color` is set. */\nexport const DEFAULT_BADGE_COLOR = '#ef4444';\n\n/** Normalise the `badge` shorthand (a `string` is a dot of that colour) to a `Badge`. */\nexport function normalizeBadge(badge: string | Badge): Badge {\n return typeof badge === 'string' ? { color: badge } : badge;\n}\n\n/** A badge's text as a string (`''` when omitted) — so a `0` renders and `undefined` doesn't. */\nexport function badgeText(badge: Badge): string {\n return badge.text == null ? '' : String(badge.text);\n}\n\n/**\n * Top-left corner for a `bw`×`bh` badge inside a `cw`×`ch` box, per `corner`\n * (relative to the box origin; the caller adds any offset). Shared by the canvas\n * and SVG renderers so placement stays identical across runtime and build-time.\n */\nexport function placeBadge(\n corner: BadgeCorner,\n cw: number,\n ch: number,\n bw: number,\n bh: number,\n margin: number,\n): [number, number] {\n if (corner === 'center') return [(cw - bw) / 2, (ch - bh) / 2];\n return [\n corner.endsWith('left') ? margin : cw - bw - margin,\n corner.startsWith('top') ? margin : ch - bh - margin,\n ];\n}\n","/** Parse `#rgb` / `#rrggbb` / `rgb(…)` / `rgba(…)` into `[r, g, b]` (0–255), or `null`. */\nfunction parseRgb(color: string): [number, number, number] | null {\n const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);\n if (hex) {\n const h = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];\n const n = Number.parseInt(h, 16);\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n }\n const rgb = /^rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i.exec(color);\n if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];\n return null;\n}\n\n/**\n * Perceived lightness (0–1) of `color` for the black/white text decision:\n * rec601 luma for `#hex` / `rgb()`, or the `L` channel of CSS Color 4 colours —\n * `oklch()`/`oklab()` (0–1) and `lab()`/`lch()` (0–100). `null` if unreadable.\n */\nfunction lightness(color: string): number | null {\n const rgb = parseRgb(color);\n if (rgb) return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;\n const lch = /^(okl(?:ch|ab)|l(?:ch|ab))\\(\\s*([\\d.]+)(%?)/i.exec(color);\n if (lch) {\n const l = Number(lch[2]);\n if (lch[3]) return l / 100; // a percentage L, in any of these spaces\n return /^ok/i.test(lch[1]) ? l : l / 100; // oklab/oklch L is 0–1, lab/lch is 0–100\n }\n return null;\n}\n\n/**\n * Black or white — whichever reads better on `color` — by perceived lightness.\n * Handles `#hex`, `rgb()`, and CSS Color 4 `oklch()`/`oklab()`/`lab()`/`lch()`;\n * falls back to white for anything else (named colours, `hsl()`, `color()`, …).\n */\nexport function contrastColor(color: string): string {\n const l = lightness(color);\n if (l == null) return '#fff';\n return l > 0.6 ? '#000' : '#fff';\n}\n","import type { EnvTint } from './types';\n\n/** The CSS `filter` for a tint — an explicit `filter` beats `hue`; `null` if neither is set. */\nexport function cssFilter(tint: EnvTint): string | null {\n if (tint.filter) return tint.filter;\n if (typeof tint.hue === 'number') return `hue-rotate(${tint.hue}deg)`;\n return null;\n}\n","import { badgeText, DEFAULT_BADGE_COLOR, normalizeBadge, placeBadge } from './badge';\nimport { contrastColor } from './color';\nimport { cssFilter } from './filter';\nimport type { Badge, EnvConfig } from './types';\n\nconst XML_ESCAPES: Record<string, string> = {\n '&': '&',\n '<': '<',\n '>': '>',\n '\"': '"',\n \"'\": ''',\n};\n\nfunction escapeXml(value: string): string {\n return value.replace(/[&<>\"']/g, (c) => XML_ESCAPES[c] ?? c);\n}\n\nconst round = (n: number): number => Math.round(n * 100) / 100;\n\n/** ` opacity=\"…\"` attribute for a translucent badge group, else `''`. */\nfunction opacityAttr(badge: Badge): string {\n return (badge.opacity ?? 1) < 1 ? ` opacity=\"${round(badge.opacity ?? 1)}\"` : '';\n}\n\n/** Attributes that squeeze the glyphs into `len` when the natural text would overflow. */\nfunction fitText(len: number): string {\n return ` textLength=\"${round(len)}\" lengthAdjust=\"spacingAndGlyphs\"`;\n}\n\n/** A centred, bold `<text>` element at (`cx`, `cy`) — the one badge-label form. */\nfunction svgText(\n cx: number,\n cy: number,\n fill: string,\n fontSize: number,\n fit: string,\n text: string,\n): string {\n return (\n `<text x=\"${round(cx)}\" y=\"${round(cy)}\" fill=\"${escapeXml(fill)}\" ` +\n `font-family=\"system-ui, sans-serif\" font-size=\"${round(fontSize)}\" font-weight=\"700\" ` +\n `text-anchor=\"middle\" dominant-baseline=\"central\"${fit}>${escapeXml(text)}</text>`\n );\n}\n\n/** Read the drawable box from a root `<svg>` tag: its `viewBox`, else `width`/`height`. */\nfunction parseViewBox(openTag: string): [number, number, number, number] | null {\n const vb = /viewBox\\s*=\\s*[\"']([^\"']+)[\"']/i.exec(openTag);\n if (vb) {\n const parts = vb[1]\n .trim()\n .split(/[\\s,]+/)\n .map(Number);\n if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {\n return [parts[0], parts[1], parts[2], parts[3]];\n }\n }\n const w = /\\bwidth\\s*=\\s*[\"']?([\\d.]+)/i.exec(openTag);\n const h = /\\bheight\\s*=\\s*[\"']?([\\d.]+)/i.exec(openTag);\n if (w && h) return [0, 0, Number(w[1]), Number(h[1])];\n return null;\n}\n\n/** Build an SVG `<g>` badge (dot, or a pill with text) sized to the viewBox. */\nfunction svgBadge([minX, minY, w, h]: [number, number, number, number], badge: Badge): string {\n const text = badgeText(badge);\n const color = badge.color ?? DEFAULT_BADGE_COLOR;\n const corner = badge.corner ?? 'bottom-right';\n const bh = h * (badge.size ?? 0.5);\n const fontSize = bh * 0.62;\n const margin = w * 0.02;\n // SVG has no text metrics at build time; approximate glyph width at ~0.62em,\n // then clamp to the icon so a long label (e.g. a big PR number) can't overflow.\n const natural = text ? Math.max(bh, text.length * fontSize * 0.62 + bh * 0.5) : bh;\n const bw = text ? Math.min(natural, w - margin * 2) : bh;\n const [px, py] = placeBadge(corner, w, h, bw, bh, margin);\n const x = minX + px;\n const y = minY + py;\n const rx = text ? Math.min(bh / 2, w * 0.24) : bh / 2;\n // If clamped, force the glyphs to fit the pill width.\n const fit = bw < natural ? fitText(bw - bh * 0.4) : '';\n const label = text\n ? svgText(x + bw / 2, y + bh / 2, badge.textColor ?? contrastColor(color), fontSize, fit, text)\n : '';\n return (\n `<g${opacityAttr(badge)}><rect x=\"${round(x)}\" y=\"${round(y)}\" width=\"${round(bw)}\" height=\"${round(bh)}\" ` +\n `rx=\"${round(rx)}\" fill=\"${escapeXml(color)}\" stroke=\"rgba(0,0,0,0.35)\" ` +\n `stroke-width=\"${round(h * 0.015)}\"/>${label}</g>`\n );\n}\n\n/** Build a full-icon \"cover\" tile — a background rect plus a big centred number. */\nfunction svgCover([minX, minY, w, h]: [number, number, number, number], badge: Badge): string {\n const color = badge.color ?? DEFAULT_BADGE_COLOR;\n const text = badgeText(badge);\n const side = Math.min(w, h);\n const op = opacityAttr(badge);\n const rect =\n `<rect x=\"${round(minX)}\" y=\"${round(minY)}\" width=\"${round(w)}\" height=\"${round(h)}\" ` +\n `rx=\"${round(side * 0.2)}\" fill=\"${escapeXml(color)}\"/>`;\n if (!text) return `<g${op}>${rect}</g>`;\n const fontSize = side * 0.62;\n const maxLen = w * 0.84;\n const fit = text.length * fontSize * 0.62 > maxLen ? fitText(maxLen) : '';\n const label = svgText(\n minX + w / 2,\n minY + h / 2,\n badge.textColor ?? contrastColor(color),\n fontSize,\n fit,\n text,\n );\n return `<g${op}>${rect}${label}</g>`;\n}\n\n/**\n * An SVG `<filter>` that colourises the icon to `color`, mirroring the runtime\n * canvas path: desaturate → multiply the flood colour → re-mask the original\n * alpha. `color-interpolation-filters=\"sRGB\"` keeps it visually matching canvas.\n */\nfunction svgColorize(color: string): string {\n return (\n `<filter id=\"__favenv_c\" color-interpolation-filters=\"sRGB\">` +\n `<feColorMatrix type=\"saturate\" values=\"0\" result=\"g\"/>` +\n `<feFlood flood-color=\"${escapeXml(color)}\" result=\"f\"/>` +\n `<feBlend in=\"f\" in2=\"g\" mode=\"multiply\" result=\"b\"/>` +\n `<feComposite in=\"b\" in2=\"SourceGraphic\" operator=\"in\"/>` +\n `</filter>`\n );\n}\n\n/**\n * Return `svg` (an SVG *string*) with the environment's tint and/or badge baked\n * in — the form that survives being rendered as an `<img>` / favicon, with no\n * first-paint flash. The tint is a CSS `filter` on a wrapping group; the badge\n * is an appended `<g>` positioned via the SVG's `viewBox` (a badge is skipped if\n * no `viewBox` or `width`/`height` can be read).\n *\n * Returns the SVG unchanged when `tint` is falsy, has nothing to apply, or the\n * input isn't a recognisable `<svg>…</svg>` document.\n */\nexport function tintSvg(svg: string, tint: EnvConfig): string {\n if (!tint) return svg;\n const filter = cssFilter(tint);\n const colorize = tint.filter ? undefined : tint.tint; // explicit `filter` beats `tint`\n const badge = tint.badge ? normalizeBadge(tint.badge) : null;\n if (!filter && !colorize && !badge) return svg;\n const open = /<svg\\b[^>]*>/i.exec(svg);\n if (!open) return svg;\n const close = svg.lastIndexOf('</svg>');\n if (close === -1) return svg;\n const openEnd = open.index + open[0].length;\n\n // `shape: 'cover'` replaces the icon's content with a full-bleed number tile.\n // A translucent cover (opacity < 1) keeps the base showing through instead.\n if (badge?.shape === 'cover') {\n const vb = parseViewBox(open[0]);\n if (!vb) return svg;\n const base = (badge.opacity ?? 1) < 1 ? svg.slice(openEnd, close) : '';\n return `${svg.slice(0, openEnd)}${base}${svgCover(vb, badge)}${svg.slice(close)}`;\n }\n\n const inner = svg.slice(openEnd, close);\n // No XML comments injected here — XML comments may not contain `--`, which\n // every `--custom-property` does, and that silently breaks favicon SVGs.\n // `tint` colourises via an SVG `<filter>`; else `hue`/`filter` is a CSS filter.\n let defs = '';\n let body = inner;\n if (colorize) {\n defs = svgColorize(colorize);\n body = `<g filter=\"url(#__favenv_c)\">${inner}</g>`;\n } else if (filter) {\n defs = `<style>.__favenv{filter:${filter}}</style>`;\n body = `<g class=\"__favenv\">${inner}</g>`;\n }\n const viewBox = badge ? parseViewBox(open[0]) : null;\n const badgeSvg = badge && viewBox ? svgBadge(viewBox, badge) : '';\n return `${svg.slice(0, openEnd)}${defs}${body}${badgeSvg}${svg.slice(close)}`;\n}\n\n/** Percent-encode an SVG string as a `data:` URI suitable for a favicon `href`. */\nexport function svgToDataUri(svg: string): string {\n return `data:image/svg+xml,${encodeURIComponent(svg)}`;\n}\n\n/**\n * Convenience: `tintSvg` + `svgToDataUri`. Give it your favicon SVG and the\n * config for the current build's environment; get back a ready-to-use\n * `<link rel=\"icon\" href=\"…\">` value with no first-paint flash.\n */\nexport function faviconDataUri(svg: string, tint: EnvConfig): string {\n return svgToDataUri(tintSvg(svg, tint));\n}\n"]}
|