210 lines
10 KiB
TypeScript
210 lines
10 KiB
TypeScript
// Icons for /start link and service tiles, picked from the URL (no hotlinking):
|
|
// - brand marks from simple-icons, trimmed into link-icons.json by scripts/link-icons.ts
|
|
// (run `bun scripts/link-icons.ts` after adding a `si` rule below),
|
|
// - favicons saved under static/link-icons/ for brands simple-icons doesn't have (named
|
|
// rules below, or by host: link-favicons.json lists static/link-icons/<host>.png),
|
|
// - otherwise a coloured monogram.
|
|
import marks from './link-icons.json';
|
|
import savedFavicons from './link-favicons.json';
|
|
|
|
export type LinkIcon =
|
|
| { kind: 'svg'; body: string; color: string }
|
|
| { kind: 'img'; src: string }
|
|
| { kind: 'letter'; letter: string; color: string };
|
|
|
|
type Rule = {
|
|
/** Host suffix ("google.com" matches mail.google.com), or exact host with a leading "=". */
|
|
host: string;
|
|
port?: string;
|
|
path?: string;
|
|
si?: string;
|
|
color?: string; // brand colour; omitted = currentColor (black/white marks)
|
|
img?: string;
|
|
};
|
|
|
|
// First match wins, so specific rules go before broad ones.
|
|
export const RULES: Rule[] = [
|
|
{ host: 'mail.google.com', si: 'gmail', color: '#EA4335' },
|
|
{ host: 'calendar.google.com', si: 'googlecalendar', color: '#4285F4' },
|
|
{ host: 'drive.google.com', si: 'googledrive', color: '#1FA463' },
|
|
{ host: 'photos.google.com', si: 'googlephotos', color: '#FBBC04' },
|
|
{ host: 'maps.google.com', si: 'googlemaps', color: '#34A853' },
|
|
{ host: 'google.com', path: '/maps', si: 'googlemaps', color: '#34A853' },
|
|
{ host: 'whatsapp.com', si: 'whatsapp', color: '#25D366' },
|
|
{ host: 'discord.com', si: 'discord', color: '#5865F2' },
|
|
{ host: 'x.com', si: 'x' },
|
|
{ host: 'twitter.com', si: 'x' },
|
|
{ host: 'grok.com', img: 'grok.png' },
|
|
{ host: 'ticktick.com', si: 'ticktick', color: '#4772FA' },
|
|
{ host: 'spotify.com', si: 'spotify', color: '#1ED760' },
|
|
{ host: 'last.fm', si: 'lastdotfm', color: '#D51007' },
|
|
{ host: 'youtube.com', si: 'youtube', color: '#FF0000' },
|
|
{ host: 'twitch.tv', si: 'twitch', color: '#9146FF' },
|
|
{ host: 'osu.ppy.sh', si: 'osu', color: '#FF66AA' },
|
|
{ host: 'steampowered.com', si: 'steam' },
|
|
{ host: 'steamcommunity.com', si: 'steam' },
|
|
{ host: 'reddit.com', si: 'reddit', color: '#FF4500' },
|
|
{ host: 'linkedin.com', si: 'linkedin', color: '#0A66C2' },
|
|
{ host: 'claude.ai', si: 'claude', color: '#D97757' },
|
|
{ host: 'revolut.com', si: 'revolut' },
|
|
{ host: 'moneybird.com', img: 'moneybird.png' },
|
|
{ host: 'belastingdienst.nl', img: 'belastingdienst.png' },
|
|
{ host: 'megekko.nl', img: 'megekko.png' },
|
|
{ host: 'tweakers.net', img: 'tweakers.png' },
|
|
{ host: 'funda.nl', img: 'funda.png' },
|
|
{ host: 'pararius.nl', img: 'pararius.png' },
|
|
{ host: 'mijndak.nl', img: 'mijndak.png' },
|
|
{ host: 'ns.nl', img: 'ns.png' },
|
|
{ host: 'buienradar.nl', img: 'buienradar.png' },
|
|
// Self-hosted on stan-ovh
|
|
{ host: 'git.stanrunge.dev', si: 'gitea', color: '#609926' },
|
|
{ host: 'photos.stanrunge.dev', si: 'immich', color: '#4250AF' },
|
|
{ host: 'analytics.stanrunge.dev', si: 'umami' },
|
|
{ host: 'pgadmin.stanrunge.dev', si: 'postgresql', color: '#4169E1' },
|
|
{ host: 'logs.vash.software', si: 'docker', color: '#2496ED' },
|
|
{ host: 'dev.stanrunge.dev', si: 'svelte', color: '#FF3E00' },
|
|
{ host: 'stuhee.stanrunge.dev', img: 'stuhee.svg' },
|
|
{ host: 'vash.software', img: 'vash.ico' },
|
|
{ host: '=stanrunge.dev', img: '/favicon-32x32.png' },
|
|
{ host: 'ts.net', port: '19999', si: 'netdata', color: '#00AB44' },
|
|
{ host: 'ts.net', port: '9443', si: 'portainer', color: '#13BEF9' },
|
|
{ host: 'ntfy.sh', si: 'ntfy', color: '#3CB4A0' },
|
|
// Dev + infra
|
|
{ host: 'github.com', si: 'github' },
|
|
{ host: 'cursor.com', si: 'cursor' },
|
|
{ host: 'wix.com', si: 'wix' },
|
|
{ host: 'svelte.dev', si: 'svelte', color: '#FF3E00' },
|
|
{ host: 'drizzle.team', si: 'drizzle', color: '#C5F74F' },
|
|
{ host: 'cloudflare.com', si: 'cloudflare', color: '#F38020' },
|
|
{ host: 'ovh.com', si: 'ovh', color: '#4F8CF7' },
|
|
{ host: 'ovhcloud.com', si: 'ovh', color: '#4F8CF7' },
|
|
{ host: 'hetzner.com', si: 'hetzner', color: '#D50C2D' },
|
|
{ host: 'hetzner.cloud', si: 'hetzner', color: '#D50C2D' },
|
|
{ host: 'tailscale.com', si: 'tailscale' },
|
|
{ host: 'backblaze.com', si: 'backblaze', color: '#E21E29' },
|
|
{ host: '1password.com', si: '1password', color: '#3B66BC' },
|
|
{ host: 'squarespace.com', si: 'squarespace' },
|
|
// From Stan's browser bookmarks (Oct 2026)
|
|
{ host: 'analytics.google.com', si: 'googleanalytics', color: '#E37400' },
|
|
{ host: 'gemini.google.com', si: 'googlegemini', color: '#8E75B2' },
|
|
{ host: 'news.google.com', si: 'googlenews', color: '#4285F4' },
|
|
{ host: 'console.cloud.google.com', si: 'googlecloud', color: '#4285F4' },
|
|
{ host: 'chatgpt.com', si: 'openai' },
|
|
{ host: 'openai.com', si: 'openai' },
|
|
{ host: 'perplexity.ai', si: 'perplexity', color: '#1FB8CD' },
|
|
{ host: 'mistral.ai', si: 'mistralai', color: '#FA520F' },
|
|
{ host: 'huggingface.co', si: 'huggingface', color: '#FFD21E' },
|
|
{ host: 'kaggle.com', si: 'kaggle', color: '#20BEFF' },
|
|
{ host: 'sentry.io', si: 'sentry' },
|
|
{ host: 'hubspot.com', si: 'hubspot', color: '#FF7A59' },
|
|
{ host: 'netdata.cloud', si: 'netdata', color: '#00AB44' },
|
|
{ host: 'awsapps.com', si: 'amazonwebservices', color: '#FF9900' },
|
|
{ host: 'aws.amazon.com', si: 'amazonwebservices', color: '#FF9900' },
|
|
{ host: 'office.com', si: 'microsoftoffice', color: '#D83B01' },
|
|
{ host: 'developer.mozilla.org', si: 'mdnwebdocs' },
|
|
{ host: 'typescriptlang.org', si: 'typescript', color: '#3178C6' },
|
|
{ host: 'bun.com', si: 'bun' },
|
|
{ host: 'nodejs.org', si: 'nodedotjs', color: '#5FA04E' },
|
|
{ host: 'vite.dev', si: 'vite', color: '#9135FF' },
|
|
{ host: 'tailwindcss.com', si: 'tailwindcss', color: '#06B6D4' },
|
|
{ host: 'ui.shadcn.com', si: 'shadcnui' },
|
|
{ host: 'eslint.org', si: 'eslint', color: '#4B32C3' },
|
|
{ host: 'react.dev', si: 'react', color: '#61DAFB' },
|
|
{ host: 'reactnative.dev', si: 'react', color: '#61DAFB' },
|
|
{ host: 'vuejs.org', si: 'vuedotjs', color: '#4FC08D' },
|
|
{ host: 'nuxt.com', si: 'nuxt', color: '#00DC82' },
|
|
{ host: 'astro.build', si: 'astro', color: '#BC52EE' },
|
|
{ host: 'solidjs.com', si: 'solid', color: '#5A8BD6' },
|
|
{ host: 'sass-lang.com', si: 'sass', color: '#CC6699' },
|
|
{ host: 'html.spec.whatwg.org', si: 'html5', color: '#E34F26' },
|
|
{ host: 'w3.org', si: 'css', color: '#9B6BD6' },
|
|
{ host: 'nginx.org', si: 'nginx', color: '#009639' },
|
|
{ host: 'docker.com', si: 'docker', color: '#2496ED' },
|
|
{ host: 'hashicorp.com', si: 'terraform', color: '#844FBA' },
|
|
{ host: 'postgresql.org', si: 'postgresql', color: '#4169E1' },
|
|
{ host: 'redis.io', si: 'redis', color: '#FF4438' },
|
|
{ host: 'prometheus.io', si: 'prometheus', color: '#E6522C' },
|
|
{ host: 'git-scm.com', si: 'git', color: '#F03C2E' },
|
|
{ host: 'stripe.com', si: 'stripe', color: '#635BFF' },
|
|
{ host: 'rust-lang.org', si: 'rust' },
|
|
{ host: 'go.dev', si: 'go', color: '#00ADD8' },
|
|
{ host: 'python.org', si: 'python', color: '#3776AB' },
|
|
{ host: 'php.net', si: 'php', color: '#777BB4' },
|
|
{ host: 'swift.org', si: 'swift', color: '#F05138' },
|
|
{ host: 'pytorch.org', si: 'pytorch', color: '#EE4C2C' },
|
|
{ host: 'archlinux.org', si: 'archlinux', color: '#1793D1' },
|
|
{ host: 'nixos.wiki', si: 'nixos', color: '#5277C3' },
|
|
{ host: 'gnu.org', si: 'gnubash', color: '#4EAA25' },
|
|
{ host: 'man7.org', si: 'linux', color: '#FCC624' },
|
|
{ host: 'man.openbsd.org', si: 'tmux', color: '#1BB91F' },
|
|
{ host: 'code.visualstudio.com', si: 'visualstudiocode', color: '#007ACC' },
|
|
{ host: 'zed.dev', si: 'zedindustries', color: '#2F7CF6' },
|
|
{ host: 'owasp.org', si: 'owasp' },
|
|
{ host: 'levels.fyi', si: 'levelsdotfyi', color: '#788B95' },
|
|
{ host: 'glassdoor.nl', si: 'glassdoor', color: '#00A162' },
|
|
{ host: 'news.ycombinator.com', si: 'ycombinator', color: '#F0652F' },
|
|
{ host: 'lobste.rs', si: 'lobsters', color: '#AC130D' },
|
|
{ host: '4chan.org', si: '4chan', color: '#3A9A3A' },
|
|
{ host: 'exercism.org', si: 'exercism', color: '#009CAB' },
|
|
{ host: 'leetcode.com', si: 'leetcode', color: '#FFA116' },
|
|
{ host: 'roadmap.sh', si: 'roadmapdotsh' },
|
|
{ host: 'wolframalpha.com', si: 'wolfram', color: '#DD1100' },
|
|
{ host: 'plex.tv', si: 'plex', color: '#EBAF00' },
|
|
{ host: 'netflix.com', si: 'netflix', color: '#E50914' },
|
|
{ host: 'curseforge.com', si: 'curseforge', color: '#F16436' },
|
|
{ host: 'bbc.com', si: 'bbc' },
|
|
{ host: 'theguardian.com', si: 'theguardian' },
|
|
{ host: 'cnn.com', si: 'cnn', color: '#CC0000' },
|
|
{ host: 'axios.com', si: 'axios', color: '#7B5CF0' },
|
|
{ host: 'vox.com', si: 'vox', color: '#DA074A' },
|
|
{ host: 'washingtonpost.com', si: 'thewashingtonpost' },
|
|
{ host: 'nytimes.com', si: 'newyorktimes' },
|
|
{ host: 'mariowiki.com', si: 'nintendo', color: '#E60012' },
|
|
{ host: 'bulbagarden.net', si: 'pokemon', color: '#FFCB05' },
|
|
{ host: 'pokemmo.com', si: 'pokemon', color: '#FFCB05' },
|
|
{ host: 'cloudflareaccess.com', si: 'cloudflare', color: '#F38020' }
|
|
];
|
|
|
|
export const iconFile = (img: string) => (img.startsWith('/') ? img : `/link-icons/${img}`);
|
|
|
|
function parse(url: string) {
|
|
try {
|
|
const u = new URL(url);
|
|
return { host: u.hostname.replace(/^www\./, '').toLowerCase(), port: u.port, path: u.pathname };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function ruleFor(url: string): Rule | null {
|
|
const u = parse(url);
|
|
if (!u) return null;
|
|
return (
|
|
RULES.find((r) => {
|
|
const hostOk = r.host.startsWith('=')
|
|
? u.host === r.host.slice(1)
|
|
: u.host === r.host || u.host.endsWith(`.${r.host}`);
|
|
return hostOk && (!r.port || r.port === u.port) && (!r.path || u.path.startsWith(r.path));
|
|
}) ?? null
|
|
);
|
|
}
|
|
|
|
const marksBySlug = marks as Record<string, string>;
|
|
export const faviconHosts = new Set<string>(savedFavicons);
|
|
|
|
export function linkIcon(url: string, title = ''): LinkIcon {
|
|
const r = ruleFor(url);
|
|
if (r?.si && marksBySlug[r.si])
|
|
return { kind: 'svg', body: marksBySlug[r.si], color: r.color ?? 'currentColor' };
|
|
if (r?.img) return { kind: 'img', src: iconFile(r.img) };
|
|
const host = parse(url)?.host ?? url;
|
|
if (faviconHosts.has(host)) return { kind: 'img', src: iconFile(`${host}.png`) };
|
|
let h = 0;
|
|
for (const c of host) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
return {
|
|
kind: 'letter',
|
|
letter: (title.trim()[0] ?? host[0] ?? '?').toUpperCase(),
|
|
color: `hsl(${h % 360} 70% 55%)`
|
|
};
|
|
}
|