feat(desktop): resolve a hostname to its Simple Icons brand mark

A lookup table of ~170 hosts plus a resolver that walks the hostname's
suffixes, so subdomains inherit their parent's mark (gist.github.com) while
a more specific entry (docs.google.com) still wins over the general one.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 21:23:46 -05:00
parent fa7b0fcf5d
commit 0d19e93f5d
2 changed files with 469 additions and 0 deletions

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { resolveBrandIcon } from './brand-icon'
describe('resolveBrandIcon', () => {
it('resolves a registrable domain to a brand glyph', () => {
expect(resolveBrandIcon('github.com')).toBeTruthy()
expect(resolveBrandIcon('gitlab.com')).toBeTruthy()
})
it('ignores case and a leading www.', () => {
const github = resolveBrandIcon('github.com')
expect(resolveBrandIcon('WWW.GitHub.com')).toBe(github)
})
it('inherits the parent brand on subdomains', () => {
const github = resolveBrandIcon('github.com')
expect(resolveBrandIcon('gist.github.com')).toBe(github)
expect(resolveBrandIcon('api.github.com')).toBe(github)
})
it('prefers the longest matching suffix over its parent', () => {
const google = resolveBrandIcon('google.com')
const docs = resolveBrandIcon('docs.google.com')
expect(docs).toBeTruthy()
expect(docs).not.toBe(google)
})
it('returns null for unknown hosts and bare labels', () => {
expect(resolveBrandIcon('example.com')).toBeNull()
expect(resolveBrandIcon('localhost')).toBeNull()
expect(resolveBrandIcon('')).toBeNull()
})
it('never matches on a bare public suffix', () => {
// `github.io` is a real entry; a lone `io` must not inherit it.
expect(resolveBrandIcon('io')).toBeNull()
expect(resolveBrandIcon('com')).toBeNull()
})
})

View file

@ -0,0 +1,426 @@
import {
SiAnthropic,
SiArchlinux,
SiArxiv,
SiAsana,
SiAtlassian,
SiBehance,
SiBitbucket,
SiBlender,
SiBluesky,
SiBun,
SiBuymeacoffee,
SiCircleci,
SiClaude,
SiCloudflare,
SiCodeberg,
SiCodecov,
SiCodesandbox,
SiConfluence,
SiCoursera,
SiCrunchbase,
SiCursor,
SiDatadog,
SiDebian,
SiDeno,
SiDevdotto,
SiDigitalocean,
SiDiscord,
SiDjango,
SiDocker,
SiDropbox,
SiElectron,
SiEslint,
SiExcalidraw,
SiFacebook,
SiFastapi,
SiFigma,
SiFirebase,
SiFlask,
SiForgejo,
SiGhost,
SiGitea,
SiGithub,
SiGitlab,
SiGmail,
SiGo,
SiGodotengine,
SiGoodreads,
SiGoogle,
SiGoogledocs,
SiGoogledrive,
SiGooglegemini,
SiGooglemaps,
SiGooglescholar,
SiGrafana,
SiGraphql,
SiHashnode,
SiHomebrew,
SiHuggingface,
SiImdb,
SiInstagram,
SiInternetarchive,
SiItchdotio,
SiJenkins,
SiJira,
SiJupyter,
SiKaggle,
SiKofi,
SiKotlin,
SiKubernetes,
SiLaravel,
SiLeetcode,
SiLinear,
SiMastodon,
SiMdnwebdocs,
SiMedium,
SiMiro,
SiMistralai,
SiMongodb,
SiNetflix,
SiNetlify,
SiNodedotjs,
SiNotion,
SiNpm,
SiNvidia,
SiObsidian,
SiOllama,
SiOpenrouter,
SiOpenstreetmap,
SiOverleaf,
SiPatreon,
SiPaypal,
SiPerplexity,
SiPhp,
SiPinterest,
SiPnpm,
SiPostgresql,
SiPostman,
SiPrisma,
SiProducthunt,
SiPypi,
SiPython,
SiPytorch,
SiQuora,
SiRailway,
SiRaspberrypi,
SiRaycast,
SiReact,
SiReadthedocs,
SiReddit,
SiRedis,
SiRender,
SiReplicate,
SiReplit,
SiResearchgate,
SiRuby,
SiRubyonrails,
SiRust,
SiScikitlearn,
SiSemanticscholar,
SiSentry,
SiServerfault,
SiShopify,
SiSnyk,
SiSoundcloud,
SiSourcehut,
SiSpotify,
SiStackblitz,
SiStackexchange,
SiStackoverflow,
SiSteam,
SiStorybook,
SiStripe,
SiSubstack,
SiSupabase,
SiSuperuser,
SiSwift,
SiTailwindcss,
SiTauri,
SiTelegram,
SiTensorflow,
SiThreedotjs,
SiTiktok,
SiTldraw,
SiTrello,
SiTurborepo,
SiTwitch,
SiTypescript,
SiUbuntu,
SiUdemy,
SiUnity,
SiUnsplash,
SiVercel,
SiVimeo,
SiVite,
SiWarp,
SiWebflow,
SiWeightsandbiases,
SiWikipedia,
SiWindsurf,
SiWordpress,
SiX,
SiYcombinator,
SiYelp,
SiYoutube,
SiZedindustries,
SiZoom,
SiZotero
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
// Simple Icons components accept a `title` prop on top of the usual SVG props.
// It matters: they always render a <title> element, defaulting to the brand
// name, so callers need to be able to blank it out (see `LinkBrandIcon`).
export type BrandIcon = ComponentType<SVGProps<SVGSVGElement> & { title?: string }>
// simpleicons.org brand marks keyed by registrable domain. Lookup walks the
// host's suffixes (see `resolveBrandIcon`), so one `github.com` entry also
// covers `gist.github.com` and `api.github.com` — only list a subdomain when it
// belongs to a *different* brand than its parent.
//
// Some brands are absent on purpose: Simple Icons has removed Slack, LinkedIn,
// OpenAI, Amazon, and CodePen at their owners' request, so those hosts fall
// through to no glyph rather than a lookalike.
const BRAND_ICONS: Record<string, BrandIcon> = {
// Code hosting & package registries
'github.com': SiGithub,
'github.io': SiGithub,
'githubusercontent.com': SiGithub,
'gitlab.com': SiGitlab,
'gitlab.io': SiGitlab,
'bitbucket.org': SiBitbucket,
'codeberg.org': SiCodeberg,
'gitea.com': SiGitea,
'gitea.io': SiGitea,
'forgejo.org': SiForgejo,
'sr.ht': SiSourcehut,
'npmjs.com': SiNpm,
'pypi.org': SiPypi,
'crates.io': SiRust,
'huggingface.co': SiHuggingface,
'hf.co': SiHuggingface,
// Q&A, docs & reference
'stackoverflow.com': SiStackoverflow,
'stackexchange.com': SiStackexchange,
'serverfault.com': SiServerfault,
'superuser.com': SiSuperuser,
'developer.mozilla.org': SiMdnwebdocs,
'readthedocs.io': SiReadthedocs,
'readthedocs.org': SiReadthedocs,
'wikipedia.org': SiWikipedia,
'archive.org': SiInternetarchive,
// Research
'arxiv.org': SiArxiv,
'semanticscholar.org': SiSemanticscholar,
'scholar.google.com': SiGooglescholar,
'researchgate.net': SiResearchgate,
'zotero.org': SiZotero,
'overleaf.com': SiOverleaf,
// AI
'anthropic.com': SiAnthropic,
'claude.ai': SiClaude,
'gemini.google.com': SiGooglegemini,
'perplexity.ai': SiPerplexity,
'openrouter.ai': SiOpenrouter,
'mistral.ai': SiMistralai,
'ollama.com': SiOllama,
'replicate.com': SiReplicate,
'wandb.ai': SiWeightsandbiases,
'kaggle.com': SiKaggle,
// Social
'x.com': SiX,
'twitter.com': SiX,
't.co': SiX,
'reddit.com': SiReddit,
'redd.it': SiReddit,
'news.ycombinator.com': SiYcombinator,
'bsky.app': SiBluesky,
'mastodon.social': SiMastodon,
'joinmastodon.org': SiMastodon,
'facebook.com': SiFacebook,
'instagram.com': SiInstagram,
'tiktok.com': SiTiktok,
'pinterest.com': SiPinterest,
'discord.com': SiDiscord,
'discord.gg': SiDiscord,
't.me': SiTelegram,
'telegram.org': SiTelegram,
'producthunt.com': SiProducthunt,
'crunchbase.com': SiCrunchbase,
'quora.com': SiQuora,
// Writing & blogs
'medium.com': SiMedium,
'substack.com': SiSubstack,
'dev.to': SiDevdotto,
'hashnode.dev': SiHashnode,
'hashnode.com': SiHashnode,
'wordpress.com': SiWordpress,
'wordpress.org': SiWordpress,
'ghost.org': SiGhost,
// Media
'youtube.com': SiYoutube,
'youtu.be': SiYoutube,
'vimeo.com': SiVimeo,
'twitch.tv': SiTwitch,
'soundcloud.com': SiSoundcloud,
'spotify.com': SiSpotify,
'netflix.com': SiNetflix,
'imdb.com': SiImdb,
'goodreads.com': SiGoodreads,
'unsplash.com': SiUnsplash,
'behance.net': SiBehance,
'store.steampowered.com': SiSteam,
'itch.io': SiItchdotio,
// Product & project tools
'linear.app': SiLinear,
'notion.so': SiNotion,
'notion.site': SiNotion,
'figma.com': SiFigma,
'atlassian.net': SiAtlassian,
'atlassian.com': SiJira,
'confluence.com': SiConfluence,
'asana.com': SiAsana,
'trello.com': SiTrello,
'obsidian.md': SiObsidian,
'miro.com': SiMiro,
'excalidraw.com': SiExcalidraw,
'tldraw.com': SiTldraw,
'zoom.us': SiZoom,
// Google
'google.com': SiGoogle,
'goo.gl': SiGoogle,
'docs.google.com': SiGoogledocs,
'drive.google.com': SiGoogledrive,
'mail.google.com': SiGmail,
'maps.google.com': SiGooglemaps,
'openstreetmap.org': SiOpenstreetmap,
// Hosting & infra
'vercel.com': SiVercel,
'vercel.app': SiVercel,
'netlify.com': SiNetlify,
'netlify.app': SiNetlify,
'cloudflare.com': SiCloudflare,
'pages.dev': SiCloudflare,
'workers.dev': SiCloudflare,
'railway.app': SiRailway,
'render.com': SiRender,
'digitalocean.com': SiDigitalocean,
'firebase.google.com': SiFirebase,
'supabase.com': SiSupabase,
'docker.com': SiDocker,
'kubernetes.io': SiKubernetes,
'grafana.com': SiGrafana,
'datadoghq.com': SiDatadog,
'sentry.io': SiSentry,
'snyk.io': SiSnyk,
'codecov.io': SiCodecov,
'circleci.com': SiCircleci,
'jenkins.io': SiJenkins,
// Languages, frameworks & tooling
'python.org': SiPython,
'nodejs.org': SiNodedotjs,
'react.dev': SiReact,
'typescriptlang.org': SiTypescript,
'tailwindcss.com': SiTailwindcss,
'vite.dev': SiVite,
'vitejs.dev': SiVite,
'rust-lang.org': SiRust,
'go.dev': SiGo,
'golang.org': SiGo,
'ruby-lang.org': SiRuby,
'rubyonrails.org': SiRubyonrails,
'php.net': SiPhp,
'laravel.com': SiLaravel,
'djangoproject.com': SiDjango,
'flask.palletsprojects.com': SiFlask,
'fastapi.tiangolo.com': SiFastapi,
'swift.org': SiSwift,
'kotlinlang.org': SiKotlin,
'deno.com': SiDeno,
'bun.sh': SiBun,
'pnpm.io': SiPnpm,
'turborepo.com': SiTurborepo,
'eslint.org': SiEslint,
'storybook.js.org': SiStorybook,
'graphql.org': SiGraphql,
'prisma.io': SiPrisma,
'postgresql.org': SiPostgresql,
'redis.io': SiRedis,
'mongodb.com': SiMongodb,
'pytorch.org': SiPytorch,
'tensorflow.org': SiTensorflow,
'scikit-learn.org': SiScikitlearn,
'jupyter.org': SiJupyter,
'threejs.org': SiThreedotjs,
'blender.org': SiBlender,
'godotengine.org': SiGodotengine,
'unity.com': SiUnity,
'electronjs.org': SiElectron,
'tauri.app': SiTauri,
'brew.sh': SiHomebrew,
'archlinux.org': SiArchlinux,
'debian.org': SiDebian,
'ubuntu.com': SiUbuntu,
'raspberrypi.com': SiRaspberrypi,
'nvidia.com': SiNvidia,
// Dev environments & editors
'codesandbox.io': SiCodesandbox,
'stackblitz.com': SiStackblitz,
'replit.com': SiReplit,
'cursor.com': SiCursor,
'windsurf.com': SiWindsurf,
'zed.dev': SiZedindustries,
'warp.dev': SiWarp,
'raycast.com': SiRaycast,
'postman.com': SiPostman,
// Commerce, learning & the rest
'stripe.com': SiStripe,
'paypal.com': SiPaypal,
'patreon.com': SiPatreon,
'ko-fi.com': SiKofi,
'buymeacoffee.com': SiBuymeacoffee,
'shopify.com': SiShopify,
'webflow.com': SiWebflow,
'dropbox.com': SiDropbox,
'leetcode.com': SiLeetcode,
'coursera.org': SiCoursera,
'udemy.com': SiUdemy,
'yelp.com': SiYelp
}
// Resolve a hostname to its brand glyph, walking suffixes so subdomains inherit
// their parent's mark (`api.github.com` → `github.com`). Longest match wins, so
// a subdomain entry like `docs.google.com` beats the `google.com` fallback. The
// bare TLD is never tested.
export function resolveBrandIcon(hostname: string): BrandIcon | null {
const host = hostname.trim().toLowerCase().replace(/^www\./, '')
if (!host.includes('.')) {
return null
}
const parts = host.split('.')
for (let i = 0; i < parts.length - 1; i += 1) {
const icon = BRAND_ICONS[parts.slice(i).join('.')]
if (icon) {
return icon
}
}
return null
}