favicon-env 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/chunk-NRMMGQRU.js +146 -0
- package/dist/chunk-NRMMGQRU.js.map +1 -0
- package/dist/favicon-env.global.js +2 -0
- package/dist/favicon-env.global.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -0
- package/dist/ssr-CI5GvAY9.d.ts +122 -0
- package/dist/ssr.d.ts +1 -0
- package/dist/ssr.js +3 -0
- package/dist/ssr.js.map +1 -0
- package/package.json +84 -0
- package/skills/core/SKILL.md +246 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { E as EnvFaviconOptions, a as EnvRule, b as EnvTint } from './ssr-CI5GvAY9.js';
|
|
2
|
+
export { A as AutoOptions, B as Badge, c as BadgeCorner, d as BadgeTextFn, e as EnvConfig, R as RuleBadge, f as faviconDataUri, s as svgToDataUri, t as tintSvg } from './ssr-CI5GvAY9.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Return the tint of the first `rule` whose `match` matches `url`, or `null` if
|
|
6
|
+
* none do. A `RegExp` `match` is tested against `url.host` and its captures are
|
|
7
|
+
* interpolated into `badge.text` (`$1`, `$<name>`); a function `match` receives
|
|
8
|
+
* the `URL`. Exposed so the same rules can drive server-side rendering from a
|
|
9
|
+
* request URL — pair the result with `favicon-env/ssr`'s `faviconDataUri`.
|
|
10
|
+
*/
|
|
11
|
+
declare function matchRules(rules: EnvRule[], url: URL): EnvTint | null;
|
|
12
|
+
/**
|
|
13
|
+
* Tint / decorate the page favicon for the current environment. Resolves a tint
|
|
14
|
+
* from `rules` (URL match) → `auto` → `environments`/`detect`, then reads the
|
|
15
|
+
* existing `<link rel="icon">` (or a per-env `src` / `options.source`), redraws
|
|
16
|
+
* it on a `<canvas>` with the hue / filter / badge, and swaps in the result as a
|
|
17
|
+
* PNG data URL.
|
|
18
|
+
*
|
|
19
|
+
* A no-op during SSR (no `document`) or when nothing resolves. Works with any
|
|
20
|
+
* favicon format (svg / png / ico). A custom `src` with no recolour or badge is
|
|
21
|
+
* swapped in directly, skipping the canvas — which avoids cross-origin taint and
|
|
22
|
+
* keeps vector sources sharp. If a canvas source is cross-origin without CORS
|
|
23
|
+
* headers the canvas is tainted and the favicon is left untouched.
|
|
24
|
+
*
|
|
25
|
+
* @returns a promise that resolves once the swap has been attempted.
|
|
26
|
+
*/
|
|
27
|
+
declare function envFavicon(options?: EnvFaviconOptions): Promise<void>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default environment heuristic, based on `location.hostname`:
|
|
31
|
+
*
|
|
32
|
+
* - `localhost`, `127.0.0.1`, `::1`, `*.local`, `*.localhost`, a raw IPv4 → `'dev'`
|
|
33
|
+
* - a `staging` / `stg` / `qa` / `uat` / `preview` / `preprod` / `test` / `dev`
|
|
34
|
+
* segment anywhere in the host → `'staging'`
|
|
35
|
+
* - anything else → `'prod'`
|
|
36
|
+
*
|
|
37
|
+
* It's intentionally simple. Pass your own `detect` to `envFavicon` when your
|
|
38
|
+
* hostnames don't follow these conventions.
|
|
39
|
+
*
|
|
40
|
+
* @param hostname override the host to classify (defaults to `location.hostname`)
|
|
41
|
+
*/
|
|
42
|
+
declare function defaultDetect(hostname?: string): string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deterministic hue (0–359) derived from a string via FNV-1a. Stable across
|
|
46
|
+
* loads and well distributed, so distinct inputs (e.g. `location.host` values)
|
|
47
|
+
* get distinct colours. Powers `envFavicon`'s auto mode.
|
|
48
|
+
*
|
|
49
|
+
* @param input the string to hash (e.g. `location.host`)
|
|
50
|
+
* @param offset extra degrees added to the result, to shift the whole palette
|
|
51
|
+
*/
|
|
52
|
+
declare function hashHue(input: string, offset?: number): number;
|
|
53
|
+
|
|
54
|
+
export { EnvFaviconOptions, EnvRule, EnvTint, defaultDetect, envFavicon, hashHue, matchRules };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { normalizeBadge, cssFilter, DEFAULT_BADGE_COLOR, badgeText, placeBadge, contrastColor } from './chunk-NRMMGQRU.js';
|
|
2
|
+
export { faviconDataUri, svgToDataUri, tintSvg } from './chunk-NRMMGQRU.js';
|
|
3
|
+
|
|
4
|
+
// src/detect.ts
|
|
5
|
+
function defaultDetect(hostname = typeof location === "undefined" ? "" : location.hostname) {
|
|
6
|
+
const h = hostname.toLowerCase();
|
|
7
|
+
if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]" || h.endsWith(".local") || h.endsWith(".localhost") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h)) {
|
|
8
|
+
return "dev";
|
|
9
|
+
}
|
|
10
|
+
if (/(?:^|[.-])(?:staging|stg|qa|uat|preview|preprod|test|dev)(?:[.-]|$)/.test(h)) {
|
|
11
|
+
return "staging";
|
|
12
|
+
}
|
|
13
|
+
return "prod";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/hash.ts
|
|
17
|
+
function hashHue(input, offset = 0) {
|
|
18
|
+
let h = 2166136261;
|
|
19
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
20
|
+
h ^= input.charCodeAt(i);
|
|
21
|
+
h = Math.imul(h, 16777619);
|
|
22
|
+
}
|
|
23
|
+
return ((h >>> 0) % 360 + offset % 360 + 360) % 360;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/tint.ts
|
|
27
|
+
var ICON_LINK = 'link[rel~="icon"]';
|
|
28
|
+
var originalSource;
|
|
29
|
+
function currentIconHref() {
|
|
30
|
+
const links = document.querySelectorAll(ICON_LINK);
|
|
31
|
+
const last = links.item(links.length - 1);
|
|
32
|
+
return last?.href || void 0;
|
|
33
|
+
}
|
|
34
|
+
function interpolate(template, match) {
|
|
35
|
+
return template.replace(
|
|
36
|
+
/\$(?:(\d+)|<([^>]+)>)/g,
|
|
37
|
+
(_full, index, name) => name == null ? match[Number(index)] ?? "" : match.groups?.[name] ?? ""
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
function resolveRuleBadge(badge, match, url) {
|
|
41
|
+
if (typeof badge === "string") return { color: badge };
|
|
42
|
+
const { text, ...rest } = badge;
|
|
43
|
+
let resolved;
|
|
44
|
+
if (typeof text === "function") resolved = text(match, url);
|
|
45
|
+
else if (typeof text === "string" && match) resolved = interpolate(text, match);
|
|
46
|
+
else resolved = text;
|
|
47
|
+
return { ...rest, text: resolved };
|
|
48
|
+
}
|
|
49
|
+
function ruleToTint(rule, match, url) {
|
|
50
|
+
const tint = {};
|
|
51
|
+
if (rule.hue != null) tint.hue = rule.hue;
|
|
52
|
+
if (rule.filter) tint.filter = rule.filter;
|
|
53
|
+
if (rule.src) tint.src = rule.src;
|
|
54
|
+
if (rule.badge != null) tint.badge = resolveRuleBadge(rule.badge, match, url);
|
|
55
|
+
return tint;
|
|
56
|
+
}
|
|
57
|
+
function matchRules(rules, url) {
|
|
58
|
+
for (const rule of rules) {
|
|
59
|
+
if (typeof rule.match === "function") {
|
|
60
|
+
if (rule.match(url)) return ruleToTint(rule, null, url);
|
|
61
|
+
} else {
|
|
62
|
+
const m = url.host.match(rule.match);
|
|
63
|
+
if (m) return ruleToTint(rule, m, url);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
function resolveTint(options) {
|
|
69
|
+
const url = typeof location === "undefined" ? null : new URL(location.href);
|
|
70
|
+
if (options.rules && url) {
|
|
71
|
+
const matched = matchRules(options.rules, url);
|
|
72
|
+
if (matched) return matched;
|
|
73
|
+
}
|
|
74
|
+
if (options.auto) {
|
|
75
|
+
const offset = typeof options.auto === "object" ? options.auto.offset ?? 0 : 0;
|
|
76
|
+
return { hue: hashHue(url?.host ?? "", offset) };
|
|
77
|
+
}
|
|
78
|
+
const env = (options.detect ?? defaultDetect)();
|
|
79
|
+
const tint = env ? options.environments?.[env] : void 0;
|
|
80
|
+
return tint || null;
|
|
81
|
+
}
|
|
82
|
+
function inferType(src) {
|
|
83
|
+
if (src.startsWith("data:")) return /^data:([^;,]+)/.exec(src)?.[1];
|
|
84
|
+
if (/\.svg(?:[?#]|$)/i.test(src)) return "image/svg+xml";
|
|
85
|
+
if (/\.png(?:[?#]|$)/i.test(src)) return "image/png";
|
|
86
|
+
if (/\.ico(?:[?#]|$)/i.test(src)) return "image/x-icon";
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
function applyFavicon(href, type) {
|
|
90
|
+
document.querySelectorAll(ICON_LINK).forEach((link2) => link2.remove());
|
|
91
|
+
const link = document.createElement("link");
|
|
92
|
+
link.rel = "icon";
|
|
93
|
+
if (type) link.type = type;
|
|
94
|
+
link.href = href;
|
|
95
|
+
link.dataset.faviconEnv = "";
|
|
96
|
+
document.head.append(link);
|
|
97
|
+
}
|
|
98
|
+
function contrastText(ctx, background) {
|
|
99
|
+
ctx.fillStyle = background;
|
|
100
|
+
return contrastColor(ctx.fillStyle);
|
|
101
|
+
}
|
|
102
|
+
function traceRoundRect(ctx, x, y, w, h, r) {
|
|
103
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
104
|
+
ctx.beginPath();
|
|
105
|
+
ctx.moveTo(x + rr, y);
|
|
106
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
107
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
108
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
109
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
110
|
+
ctx.closePath();
|
|
111
|
+
}
|
|
112
|
+
function drawBadge(ctx, size, badge) {
|
|
113
|
+
const color = badge.color ?? DEFAULT_BADGE_COLOR;
|
|
114
|
+
const text = badgeText(badge);
|
|
115
|
+
const family = 'system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
116
|
+
if (badge.shape === "cover") {
|
|
117
|
+
traceRoundRect(ctx, 0, 0, size, size, Math.round(size * 0.2));
|
|
118
|
+
ctx.fillStyle = color;
|
|
119
|
+
ctx.fill();
|
|
120
|
+
if (text) {
|
|
121
|
+
const maxWidth2 = size * 0.84;
|
|
122
|
+
let fontSize2 = Math.round(size * 0.62);
|
|
123
|
+
ctx.font = `700 ${fontSize2}px ${family}`;
|
|
124
|
+
const textWidth = ctx.measureText(text).width;
|
|
125
|
+
if (textWidth > maxWidth2) {
|
|
126
|
+
fontSize2 = Math.max(7, Math.floor(fontSize2 * (maxWidth2 / textWidth)));
|
|
127
|
+
ctx.font = `700 ${fontSize2}px ${family}`;
|
|
128
|
+
}
|
|
129
|
+
ctx.fillStyle = badge.textColor ?? contrastText(ctx, color);
|
|
130
|
+
ctx.textAlign = "center";
|
|
131
|
+
ctx.textBaseline = "middle";
|
|
132
|
+
ctx.fillText(text, size / 2, size / 2);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const corner = badge.corner ?? "bottom-right";
|
|
137
|
+
const h = Math.round(size * (badge.size ?? 0.5));
|
|
138
|
+
const margin = Math.round(size * 0.02);
|
|
139
|
+
const maxWidth = size - margin * 2;
|
|
140
|
+
const pad = Math.round(h * 0.5);
|
|
141
|
+
let fontSize = Math.round(h * 0.66);
|
|
142
|
+
ctx.font = `700 ${fontSize}px ${family}`;
|
|
143
|
+
let w = text ? Math.max(h, Math.ceil(ctx.measureText(text).width) + pad) : h;
|
|
144
|
+
if (w > maxWidth) {
|
|
145
|
+
fontSize = Math.max(7, Math.floor(fontSize * (maxWidth / w)));
|
|
146
|
+
ctx.font = `700 ${fontSize}px ${family}`;
|
|
147
|
+
w = Math.min(maxWidth, Math.max(h, Math.ceil(ctx.measureText(text).width) + pad));
|
|
148
|
+
}
|
|
149
|
+
const [px, py] = placeBadge(corner, size, size, w, h, margin);
|
|
150
|
+
const x = Math.round(px);
|
|
151
|
+
const y = Math.round(py);
|
|
152
|
+
const radius = text ? Math.min(h / 2, size * 0.24) : h / 2;
|
|
153
|
+
traceRoundRect(ctx, x, y, w, h, radius);
|
|
154
|
+
ctx.fillStyle = color;
|
|
155
|
+
ctx.fill();
|
|
156
|
+
ctx.lineWidth = Math.max(1, size * 0.03);
|
|
157
|
+
ctx.strokeStyle = "rgba(0, 0, 0, 0.35)";
|
|
158
|
+
ctx.stroke();
|
|
159
|
+
if (text) {
|
|
160
|
+
ctx.fillStyle = badge.textColor ?? contrastText(ctx, color);
|
|
161
|
+
ctx.textAlign = "center";
|
|
162
|
+
ctx.textBaseline = "middle";
|
|
163
|
+
ctx.fillText(text, x + w / 2, y + h / 2);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function envFavicon(options = {}) {
|
|
167
|
+
if (typeof document === "undefined" || typeof HTMLCanvasElement === "undefined") {
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
}
|
|
170
|
+
const tint = resolveTint(options);
|
|
171
|
+
if (!tint) return Promise.resolve();
|
|
172
|
+
const badge = tint.badge != null ? normalizeBadge(tint.badge) : void 0;
|
|
173
|
+
const size = options.size ?? 64;
|
|
174
|
+
const cover = badge?.shape === "cover";
|
|
175
|
+
const alpha = badge?.opacity ?? 1;
|
|
176
|
+
if (cover && alpha >= 1) {
|
|
177
|
+
const canvas = document.createElement("canvas");
|
|
178
|
+
canvas.width = size;
|
|
179
|
+
canvas.height = size;
|
|
180
|
+
const ctx = canvas.getContext("2d");
|
|
181
|
+
if (ctx) {
|
|
182
|
+
drawBadge(ctx, size, badge);
|
|
183
|
+
applyFavicon(canvas.toDataURL("image/png"), "image/png");
|
|
184
|
+
}
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
}
|
|
187
|
+
const needsCanvas = tint.hue != null || Boolean(tint.filter) || Boolean(badge);
|
|
188
|
+
if (!needsCanvas) {
|
|
189
|
+
if (tint.src) applyFavicon(tint.src, inferType(tint.src));
|
|
190
|
+
return Promise.resolve();
|
|
191
|
+
}
|
|
192
|
+
originalSource ?? (originalSource = currentIconHref());
|
|
193
|
+
const source = tint.src ?? options.source ?? originalSource ?? "/favicon.ico";
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const img = new Image();
|
|
196
|
+
img.crossOrigin = "anonymous";
|
|
197
|
+
img.decoding = "async";
|
|
198
|
+
img.addEventListener("error", () => resolve());
|
|
199
|
+
img.addEventListener("load", () => {
|
|
200
|
+
try {
|
|
201
|
+
const canvas = document.createElement("canvas");
|
|
202
|
+
canvas.width = size;
|
|
203
|
+
canvas.height = size;
|
|
204
|
+
const ctx = canvas.getContext("2d");
|
|
205
|
+
if (ctx) {
|
|
206
|
+
if (!cover) {
|
|
207
|
+
const filter = cssFilter(tint);
|
|
208
|
+
if (filter) ctx.filter = filter;
|
|
209
|
+
}
|
|
210
|
+
ctx.drawImage(img, 0, 0, size, size);
|
|
211
|
+
ctx.filter = "none";
|
|
212
|
+
if (badge) {
|
|
213
|
+
ctx.globalAlpha = alpha;
|
|
214
|
+
drawBadge(ctx, size, badge);
|
|
215
|
+
ctx.globalAlpha = 1;
|
|
216
|
+
}
|
|
217
|
+
applyFavicon(canvas.toDataURL("image/png"), "image/png");
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
resolve();
|
|
222
|
+
});
|
|
223
|
+
img.src = source;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export { defaultDetect, envFavicon, hashHue, matchRules };
|
|
228
|
+
//# sourceMappingURL=index.js.map
|
|
229
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/detect.ts","../src/hash.ts","../src/tint.ts"],"names":["link","maxWidth","fontSize"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Default environment heuristic, based on `location.hostname`:\n *\n * - `localhost`, `127.0.0.1`, `::1`, `*.local`, `*.localhost`, a raw IPv4 → `'dev'`\n * - a `staging` / `stg` / `qa` / `uat` / `preview` / `preprod` / `test` / `dev`\n * segment anywhere in the host → `'staging'`\n * - anything else → `'prod'`\n *\n * It's intentionally simple. Pass your own `detect` to `envFavicon` when your\n * hostnames don't follow these conventions.\n *\n * @param hostname override the host to classify (defaults to `location.hostname`)\n */\nexport function defaultDetect(\n hostname: string = typeof location === 'undefined' ? '' : location.hostname,\n): string {\n const h = hostname.toLowerCase();\n if (\n h === 'localhost' ||\n h === '127.0.0.1' ||\n h === '::1' ||\n h === '[::1]' ||\n h.endsWith('.local') ||\n h.endsWith('.localhost') ||\n /^\\d{1,3}(?:\\.\\d{1,3}){3}$/.test(h)\n ) {\n return 'dev';\n }\n if (/(?:^|[.-])(?:staging|stg|qa|uat|preview|preprod|test|dev)(?:[.-]|$)/.test(h)) {\n return 'staging';\n }\n return 'prod';\n}\n","/**\n * Deterministic hue (0–359) derived from a string via FNV-1a. Stable across\n * loads and well distributed, so distinct inputs (e.g. `location.host` values)\n * get distinct colours. Powers `envFavicon`'s auto mode.\n *\n * @param input the string to hash (e.g. `location.host`)\n * @param offset extra degrees added to the result, to shift the whole palette\n */\nexport function hashHue(input: string, offset = 0): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < input.length; i += 1) {\n h ^= input.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return (((h >>> 0) % 360) + (offset % 360) + 360) % 360;\n}\n","import { badgeText, DEFAULT_BADGE_COLOR, normalizeBadge, placeBadge } from './badge';\nimport { contrastColor } from './color';\nimport { defaultDetect } from './detect';\nimport { cssFilter } from './filter';\nimport { hashHue } from './hash';\nimport type { Badge, EnvFaviconOptions, EnvRule, EnvTint, RuleBadge } from './types';\n\n/** Selector for the favicon `<link>`(s) we read from and replace. */\nconst ICON_LINK = 'link[rel~=\"icon\"]';\n\n/** Remembered original favicon href, so repeat calls don't tint an already-tinted icon. */\nlet originalSource: string | undefined;\n\nfunction currentIconHref(): string | undefined {\n const links = document.querySelectorAll<HTMLLinkElement>(ICON_LINK);\n const last = links.item(links.length - 1);\n return last?.href || undefined;\n}\n\n/** Fill `$1` / `$<name>` placeholders in a template from a regex match. */\nfunction interpolate(template: string, match: RegExpMatchArray): string {\n return template.replace(/\\$(?:(\\d+)|<([^>]+)>)/g, (_full, index, name) =>\n name == null ? (match[Number(index)] ?? '') : (match.groups?.[name] ?? ''),\n );\n}\n\nfunction resolveRuleBadge(\n badge: string | RuleBadge,\n match: RegExpMatchArray | null,\n url: URL,\n): Badge {\n if (typeof badge === 'string') return { color: badge };\n const { text, ...rest } = badge;\n let resolved: string | number | undefined;\n if (typeof text === 'function') resolved = text(match, url);\n else if (typeof text === 'string' && match) resolved = interpolate(text, match);\n else resolved = text;\n return { ...rest, text: resolved };\n}\n\nfunction ruleToTint(rule: EnvRule, match: RegExpMatchArray | null, url: URL): EnvTint {\n const tint: EnvTint = {};\n if (rule.hue != null) tint.hue = rule.hue;\n if (rule.filter) tint.filter = rule.filter;\n if (rule.src) tint.src = rule.src;\n if (rule.badge != null) tint.badge = resolveRuleBadge(rule.badge, match, url);\n return tint;\n}\n\n/**\n * Return the tint of the first `rule` whose `match` matches `url`, or `null` if\n * none do. A `RegExp` `match` is tested against `url.host` and its captures are\n * interpolated into `badge.text` (`$1`, `$<name>`); a function `match` receives\n * the `URL`. Exposed so the same rules can drive server-side rendering from a\n * request URL — pair the result with `favicon-env/ssr`'s `faviconDataUri`.\n */\nexport function matchRules(rules: EnvRule[], url: URL): EnvTint | null {\n for (const rule of rules) {\n if (typeof rule.match === 'function') {\n if (rule.match(url)) return ruleToTint(rule, null, url);\n } else {\n const m = url.host.match(rule.match);\n if (m) return ruleToTint(rule, m, url);\n }\n }\n return null;\n}\n\nfunction resolveTint(options: EnvFaviconOptions): EnvTint | null {\n const url = typeof location === 'undefined' ? null : new URL(location.href);\n if (options.rules && url) {\n const matched = matchRules(options.rules, url);\n if (matched) return matched;\n }\n if (options.auto) {\n const offset = typeof options.auto === 'object' ? (options.auto.offset ?? 0) : 0;\n return { hue: hashHue(url?.host ?? '', offset) };\n }\n const env = (options.detect ?? defaultDetect)();\n const tint = env ? options.environments?.[env] : undefined;\n return tint || null;\n}\n\nfunction inferType(src: string): string | undefined {\n if (src.startsWith('data:')) return /^data:([^;,]+)/.exec(src)?.[1];\n if (/\\.svg(?:[?#]|$)/i.test(src)) return 'image/svg+xml';\n if (/\\.png(?:[?#]|$)/i.test(src)) return 'image/png';\n if (/\\.ico(?:[?#]|$)/i.test(src)) return 'image/x-icon';\n return undefined;\n}\n\nfunction applyFavicon(href: string, type?: string): void {\n document.querySelectorAll(ICON_LINK).forEach((link) => link.remove());\n const link = document.createElement('link');\n link.rel = 'icon';\n if (type) link.type = type;\n link.href = href;\n link.dataset.faviconEnv = '';\n document.head.append(link);\n}\n\nfunction contrastText(ctx: CanvasRenderingContext2D, background: string): string {\n ctx.fillStyle = background; // canvas normalises any CSS colour to #rrggbb / rgba(…)\n return contrastColor(ctx.fillStyle);\n}\n\nfunction traceRoundRect(\n ctx: CanvasRenderingContext2D,\n x: number,\n y: number,\n w: number,\n h: number,\n r: number,\n): void {\n const rr = Math.min(r, w / 2, h / 2);\n ctx.beginPath();\n ctx.moveTo(x + rr, y);\n ctx.arcTo(x + w, y, x + w, y + h, rr);\n ctx.arcTo(x + w, y + h, x, y + h, rr);\n ctx.arcTo(x, y + h, x, y, rr);\n ctx.arcTo(x, y, x + w, y, rr);\n ctx.closePath();\n}\n\nfunction drawBadge(ctx: CanvasRenderingContext2D, size: number, badge: Badge): void {\n const color = badge.color ?? DEFAULT_BADGE_COLOR;\n const text = badgeText(badge);\n const family = 'system-ui, -apple-system, \"Segoe UI\", sans-serif';\n\n // `shape: 'cover'` fills the whole icon and centres the number as big as it fits.\n if (badge.shape === 'cover') {\n traceRoundRect(ctx, 0, 0, size, size, Math.round(size * 0.2));\n ctx.fillStyle = color;\n ctx.fill();\n if (text) {\n const maxWidth = size * 0.84;\n let fontSize = Math.round(size * 0.62);\n ctx.font = `700 ${fontSize}px ${family}`;\n const textWidth = ctx.measureText(text).width;\n if (textWidth > maxWidth) {\n fontSize = Math.max(7, Math.floor(fontSize * (maxWidth / textWidth)));\n ctx.font = `700 ${fontSize}px ${family}`;\n }\n ctx.fillStyle = badge.textColor ?? contrastText(ctx, color);\n ctx.textAlign = 'center';\n ctx.textBaseline = 'middle';\n ctx.fillText(text, size / 2, size / 2);\n }\n return;\n }\n\n const corner = badge.corner ?? 'bottom-right';\n const h = Math.round(size * (badge.size ?? 0.5));\n const margin = Math.round(size * 0.02);\n const maxWidth = size - margin * 2;\n const pad = Math.round(h * 0.5);\n // A dot / short label sits in a pill that widens (then the font shrinks) to fit\n // longer text. Bump badge.size + corner:'center' to make a number dominate.\n let fontSize = Math.round(h * 0.66);\n ctx.font = `700 ${fontSize}px ${family}`;\n let w = text ? Math.max(h, Math.ceil(ctx.measureText(text).width) + pad) : h;\n if (w > maxWidth) {\n fontSize = Math.max(7, Math.floor(fontSize * (maxWidth / w)));\n ctx.font = `700 ${fontSize}px ${family}`;\n w = Math.min(maxWidth, Math.max(h, Math.ceil(ctx.measureText(text).width) + pad));\n }\n const [px, py] = placeBadge(corner, size, size, w, h, margin);\n const x = Math.round(px);\n const y = Math.round(py);\n const radius = text ? Math.min(h / 2, size * 0.24) : h / 2;\n\n traceRoundRect(ctx, x, y, w, h, radius);\n ctx.fillStyle = color;\n ctx.fill();\n ctx.lineWidth = Math.max(1, size * 0.03);\n ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';\n ctx.stroke();\n\n if (text) {\n ctx.fillStyle = badge.textColor ?? contrastText(ctx, color);\n ctx.textAlign = 'center';\n ctx.textBaseline = 'middle';\n ctx.fillText(text, x + w / 2, y + h / 2);\n }\n}\n\n/**\n * Tint / decorate the page favicon for the current environment. Resolves a tint\n * from `rules` (URL match) → `auto` → `environments`/`detect`, then reads the\n * existing `<link rel=\"icon\">` (or a per-env `src` / `options.source`), redraws\n * it on a `<canvas>` with the hue / filter / badge, and swaps in the result as a\n * PNG data URL.\n *\n * A no-op during SSR (no `document`) or when nothing resolves. Works with any\n * favicon format (svg / png / ico). A custom `src` with no recolour or badge is\n * swapped in directly, skipping the canvas — which avoids cross-origin taint and\n * keeps vector sources sharp. If a canvas source is cross-origin without CORS\n * headers the canvas is tainted and the favicon is left untouched.\n *\n * @returns a promise that resolves once the swap has been attempted.\n */\nexport function envFavicon(options: EnvFaviconOptions = {}): Promise<void> {\n if (typeof document === 'undefined' || typeof HTMLCanvasElement === 'undefined') {\n return Promise.resolve();\n }\n const tint = resolveTint(options);\n if (!tint) return Promise.resolve();\n\n const badge = tint.badge != null ? normalizeBadge(tint.badge) : undefined;\n const size = options.size ?? 64;\n const cover = badge?.shape === 'cover';\n const alpha = badge?.opacity ?? 1;\n\n // An opaque `cover` replaces the icon entirely — no base image needed, so draw\n // it synchronously. A translucent cover falls through to composite over the base.\n if (cover && alpha >= 1) {\n const canvas = document.createElement('canvas');\n canvas.width = size;\n canvas.height = size;\n const ctx = canvas.getContext('2d');\n if (ctx) {\n drawBadge(ctx, size, badge);\n applyFavicon(canvas.toDataURL('image/png'), 'image/png');\n }\n return Promise.resolve();\n }\n\n // Plain image swap: a custom `src` with nothing to composite skips the canvas.\n const needsCanvas = tint.hue != null || Boolean(tint.filter) || Boolean(badge);\n if (!needsCanvas) {\n if (tint.src) applyFavicon(tint.src, inferType(tint.src));\n return Promise.resolve();\n }\n\n originalSource ??= currentIconHref();\n const source = tint.src ?? options.source ?? originalSource ?? '/favicon.ico';\n\n return new Promise<void>((resolve) => {\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.decoding = 'async';\n img.addEventListener('error', () => resolve());\n img.addEventListener('load', () => {\n try {\n const canvas = document.createElement('canvas');\n canvas.width = size;\n canvas.height = size;\n const ctx = canvas.getContext('2d');\n if (ctx) {\n // `cover` ignores the hue/filter (it's replacing the icon); a plain tint\n // applies it to the base. The base is always drawn here — a translucent\n // cover shows it through.\n if (!cover) {\n const filter = cssFilter(tint);\n if (filter) ctx.filter = filter;\n }\n ctx.drawImage(img, 0, 0, size, size);\n ctx.filter = 'none';\n if (badge) {\n ctx.globalAlpha = alpha;\n drawBadge(ctx, size, badge);\n ctx.globalAlpha = 1;\n }\n applyFavicon(canvas.toDataURL('image/png'), 'image/png');\n }\n } catch {\n // Tainted canvas (cross-origin source without CORS headers) or an\n // unsupported API — leave the existing favicon in place.\n }\n resolve();\n });\n img.src = source;\n });\n}\n"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** A tint / decoration to apply to the favicon for a given environment. */
|
|
2
|
+
interface EnvTint {
|
|
3
|
+
/** Hue rotation in degrees applied to the whole icon (e.g. `120`). */
|
|
4
|
+
hue?: number;
|
|
5
|
+
/**
|
|
6
|
+
* Explicit CSS filter string, e.g. `'hue-rotate(120deg) saturate(1.4)'`.
|
|
7
|
+
* Takes precedence over `hue` when both are set.
|
|
8
|
+
*/
|
|
9
|
+
filter?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Use this image URL (or data URI) as the base for this environment, instead
|
|
12
|
+
* of the page's current favicon — e.g. a different logo for staging. Any
|
|
13
|
+
* `hue` / `filter` / `badge` is composited on top of it.
|
|
14
|
+
*/
|
|
15
|
+
src?: string;
|
|
16
|
+
/**
|
|
17
|
+
* A corner badge. A `string` is shorthand for a dot of that colour; an object
|
|
18
|
+
* (`Badge`) can also carry `text`, such as a PR number, rendered as a pill.
|
|
19
|
+
*/
|
|
20
|
+
badge?: string | Badge;
|
|
21
|
+
}
|
|
22
|
+
/** A badge drawn on top of the icon. */
|
|
23
|
+
interface Badge {
|
|
24
|
+
/** Text to render, e.g. a PR number (`344`, `'#344'`). Omit for a plain dot. */
|
|
25
|
+
text?: string | number;
|
|
26
|
+
/** Background colour. Default `'#ef4444'`. */
|
|
27
|
+
color?: string;
|
|
28
|
+
/** Text colour. Default: auto — black or white, whichever contrasts with `color`. */
|
|
29
|
+
textColor?: string;
|
|
30
|
+
/**
|
|
31
|
+
* `'pill'` (default) draws a rounded badge on top of your icon, placed by
|
|
32
|
+
* `corner` / `size`. `'cover'` replaces the whole icon with the colour + a big
|
|
33
|
+
* centred number — nothing of the base shows through, so it reads at 16px.
|
|
34
|
+
*/
|
|
35
|
+
shape?: 'pill' | 'cover';
|
|
36
|
+
/** Where a pill sits. Default `'bottom-right'`; `'center'` overlays the icon. */
|
|
37
|
+
corner?: BadgeCorner;
|
|
38
|
+
/** Pill height as a fraction of the icon (0–1). Default `0.5`. (Ignored by `'cover'`.) */
|
|
39
|
+
size?: number;
|
|
40
|
+
/** Badge opacity, 0–1 (default `1`). With `shape: 'cover'`, below `1` lets the icon show through. */
|
|
41
|
+
opacity?: number;
|
|
42
|
+
}
|
|
43
|
+
type BadgeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
|
|
44
|
+
/** Per-environment config. `false` / `null` / omitted leaves the favicon untouched. */
|
|
45
|
+
type EnvConfig = EnvTint | false | null | undefined;
|
|
46
|
+
/** Computes badge text from a regex match (if any) and the current URL. */
|
|
47
|
+
type BadgeTextFn = (match: RegExpMatchArray | null, url: URL) => string | number;
|
|
48
|
+
/**
|
|
49
|
+
* A `Badge` for use in a `rule`, whose `text` may additionally be a function, or
|
|
50
|
+
* a template with `$1` / `$<name>` placeholders filled from the rule's regex
|
|
51
|
+
* captures — e.g. `'#$1'` against `/^pr-(\d+)\./`.
|
|
52
|
+
*/
|
|
53
|
+
type RuleBadge = Omit<Badge, 'text'> & {
|
|
54
|
+
text?: string | number | BadgeTextFn;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* A URL-matching rule. When `match` matches, this rule's tint is applied (and no
|
|
58
|
+
* later rule is tried). Rules are checked in order, before `auto` and
|
|
59
|
+
* `environments`/`detect`.
|
|
60
|
+
*/
|
|
61
|
+
type EnvRule = Omit<EnvTint, 'badge'> & {
|
|
62
|
+
/**
|
|
63
|
+
* A `RegExp` (tested against `location.host`, i.e. `hostname:port`) or a
|
|
64
|
+
* function receiving the full `URL`. Regex captures feed `badge.text`.
|
|
65
|
+
*/
|
|
66
|
+
match: RegExp | ((url: URL) => boolean);
|
|
67
|
+
/** Badge whose `text` may be a `$1` / `$<name>` template or a function of the match. */
|
|
68
|
+
badge?: string | RuleBadge;
|
|
69
|
+
};
|
|
70
|
+
/** Options for `envFavicon`. */
|
|
71
|
+
interface EnvFaviconOptions {
|
|
72
|
+
/** Map of environment name → tint. A missing entry (or `false`) means "leave as-is". */
|
|
73
|
+
environments?: Record<string, EnvConfig>;
|
|
74
|
+
/**
|
|
75
|
+
* URL-matching rules, checked in order before `auto` / `environments`. The
|
|
76
|
+
* first rule whose `match` matches wins, and its regex captures can be
|
|
77
|
+
* interpolated into `badge.text` (e.g. a PR number). Great for preview deploys.
|
|
78
|
+
*/
|
|
79
|
+
rules?: EnvRule[];
|
|
80
|
+
/**
|
|
81
|
+
* Return the current environment name (a key of `environments`). Defaults to
|
|
82
|
+
* `defaultDetect`, a `location.hostname` heuristic.
|
|
83
|
+
*/
|
|
84
|
+
detect?: () => string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Auto mode: ignore `environments` and derive a *stable, unique* hue from
|
|
87
|
+
* `location.host`, so every origin/port gets its own colour with no config.
|
|
88
|
+
* Pass an `AutoOptions` object to tune it.
|
|
89
|
+
*/
|
|
90
|
+
auto?: boolean | AutoOptions;
|
|
91
|
+
/** Favicon source URL. Defaults to the page's current icon, then `/favicon.ico`. */
|
|
92
|
+
source?: string;
|
|
93
|
+
/** Canvas raster size in px. Default `64`. */
|
|
94
|
+
size?: number;
|
|
95
|
+
}
|
|
96
|
+
/** Tuning for `EnvFaviconOptions.auto`. */
|
|
97
|
+
interface AutoOptions {
|
|
98
|
+
/** Extra hue offset (deg) added to the derived hue — shifts the whole palette. */
|
|
99
|
+
offset?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return `svg` (an SVG *string*) with the environment's tint and/or badge baked
|
|
104
|
+
* in — the form that survives being rendered as an `<img>` / favicon, with no
|
|
105
|
+
* first-paint flash. The tint is a CSS `filter` on a wrapping group; the badge
|
|
106
|
+
* is an appended `<g>` positioned via the SVG's `viewBox` (a badge is skipped if
|
|
107
|
+
* no `viewBox` or `width`/`height` can be read).
|
|
108
|
+
*
|
|
109
|
+
* Returns the SVG unchanged when `tint` is falsy, has nothing to apply, or the
|
|
110
|
+
* input isn't a recognisable `<svg>…</svg>` document.
|
|
111
|
+
*/
|
|
112
|
+
declare function tintSvg(svg: string, tint: EnvConfig): string;
|
|
113
|
+
/** Percent-encode an SVG string as a `data:` URI suitable for a favicon `href`. */
|
|
114
|
+
declare function svgToDataUri(svg: string): string;
|
|
115
|
+
/**
|
|
116
|
+
* Convenience: `tintSvg` + `svgToDataUri`. Give it your favicon SVG and the
|
|
117
|
+
* config for the current build's environment; get back a ready-to-use
|
|
118
|
+
* `<link rel="icon" href="…">` value with no first-paint flash.
|
|
119
|
+
*/
|
|
120
|
+
declare function faviconDataUri(svg: string, tint: EnvConfig): string;
|
|
121
|
+
|
|
122
|
+
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/dist/ssr.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { f as faviconDataUri, s as svgToDataUri, t as tintSvg } from './ssr-CI5GvAY9.js';
|
package/dist/ssr.js
ADDED
package/dist/ssr.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"ssr.js"}
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "favicon-env",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"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
|
+
"keywords": [
|
|
9
|
+
"favicon",
|
|
10
|
+
"environment",
|
|
11
|
+
"env",
|
|
12
|
+
"dev",
|
|
13
|
+
"staging",
|
|
14
|
+
"hue",
|
|
15
|
+
"tint",
|
|
16
|
+
"canvas",
|
|
17
|
+
"developer-experience",
|
|
18
|
+
"dx",
|
|
19
|
+
"tanstack-intent"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/amir-abushanab/favicon-env.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/amir-abushanab/favicon-env#readme",
|
|
26
|
+
"bugs": "https://github.com/amir-abushanab/favicon-env/issues",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./ssr": {
|
|
34
|
+
"types": "./dist/ssr.d.ts",
|
|
35
|
+
"import": "./dist/ssr.js"
|
|
36
|
+
},
|
|
37
|
+
"./global": "./dist/favicon-env.global.js"
|
|
38
|
+
},
|
|
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
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"simple-git-hooks": {
|
|
52
|
+
"pre-commit": "pnpm check"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@changesets/cli": "^2.31.0",
|
|
56
|
+
"@tanstack/intent": "^0.3.5",
|
|
57
|
+
"dependency-cruiser": "^18.0.0",
|
|
58
|
+
"knip": "^6.24.0",
|
|
59
|
+
"oxfmt": "^0.57.0",
|
|
60
|
+
"oxlint": "^1.72.0",
|
|
61
|
+
"publint": "^0.3.21",
|
|
62
|
+
"simple-git-hooks": "^2.13.1",
|
|
63
|
+
"tsup": "^8.5.1",
|
|
64
|
+
"typescript": "^6.0.3"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsup",
|
|
68
|
+
"dev": "node scripts/serve.mjs --watch",
|
|
69
|
+
"typecheck": "tsc --noEmit",
|
|
70
|
+
"lint": "oxlint src",
|
|
71
|
+
"format": "oxfmt src",
|
|
72
|
+
"knip": "knip",
|
|
73
|
+
"depcruise": "depcruise src",
|
|
74
|
+
"check": "oxfmt --check src && oxlint src && knip && depcruise src",
|
|
75
|
+
"publint": "publint --strict",
|
|
76
|
+
"intent:validate": "intent validate skills",
|
|
77
|
+
"pretest": "pnpm build",
|
|
78
|
+
"test": "node --test",
|
|
79
|
+
"ncu": "pnpm dlx npm-check-updates",
|
|
80
|
+
"changeset": "changeset",
|
|
81
|
+
"version": "changeset version",
|
|
82
|
+
"release": "changeset publish"
|
|
83
|
+
}
|
|
84
|
+
}
|