mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73047 from NousResearch/bb/link-brand-icons
feat(desktop): brand icons on links to known domains
This commit is contained in:
commit
c1964f977b
10 changed files with 539 additions and 13 deletions
|
|
@ -19,7 +19,15 @@ import { RowButton } from '@/components/ui/row-button'
|
|||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { resolveBrandIcon } from '@/lib/brand-icon'
|
||||
import {
|
||||
ExternalLink,
|
||||
ExternalLinkIcon,
|
||||
hostPathLabel,
|
||||
shortHostLabel,
|
||||
urlSlugTitleLabel,
|
||||
useLinkTitle
|
||||
} from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
|
@ -543,7 +551,8 @@ function ArtifactCellAction({
|
|||
|
||||
function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const isLink = artifact.kind === 'link'
|
||||
const Icon = isLink ? Link2 : FileText
|
||||
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
|
||||
const Icon = brand ?? (isLink ? Link2 : FileText)
|
||||
const fetchedTitle = useLinkTitle(isLink ? artifact.href : null)
|
||||
const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label
|
||||
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ export const SessionRefLink: FC<{
|
|||
|
||||
return (
|
||||
<a
|
||||
className="link-chip font-semibold wrap-anywhere"
|
||||
className="link-chip wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function MediaAttachment({ path }: { path: string }) {
|
|||
return (
|
||||
<span className="wrap-anywhere">
|
||||
<a
|
||||
className="link-chip font-semibold wrap-anywhere"
|
||||
className="link-chip wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
@ -283,7 +283,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
|
|||
if (!target || !/^https?:\/\//i.test(target)) {
|
||||
return (
|
||||
<a
|
||||
className={cn('link-chip font-semibold wrap-anywhere', className)}
|
||||
className={cn('link-chip wrap-anywhere', className)}
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
|
|||
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
|
||||
if (!href || !/^https?:\/\//i.test(href)) {
|
||||
return (
|
||||
<a className={cn('link-chip font-medium', className)} href={href} {...rest}>
|
||||
<a className={cn('link-chip', className)} href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
|
|||
if (failed && image) {
|
||||
return (
|
||||
<a
|
||||
className="mt-2 link-chip inline-block font-semibold wrap-anywhere"
|
||||
className="mt-2 link-chip inline-block wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
43
apps/desktop/src/lib/brand-icon.test.ts
Normal file
43
apps/desktop/src/lib/brand-icon.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
426
apps/desktop/src/lib/brand-icon.ts
Normal file
426
apps/desktop/src/lib/brand-icon.ts
Normal 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
|
||||
}
|
||||
|
|
@ -248,4 +248,28 @@ describe('external link helpers', () => {
|
|||
const link = screen.getByRole('link', { name: 'agent.log' })
|
||||
expect(link.getAttribute('href')).toBe('https://agent.log')
|
||||
})
|
||||
|
||||
it('prefixes a pretty link to a known host with its brand glyph', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
const url = 'https://github.com/NousResearch/hermes-agent/pull/123'
|
||||
|
||||
render(<PrettyLink fallbackLabel="#123" href={url} />)
|
||||
|
||||
const link = screen.getByTitle(url)
|
||||
|
||||
expect(link.querySelector('svg')).toBeTruthy()
|
||||
// The glyph is decorative — it must not pollute the link's accessible name.
|
||||
expect(link.textContent).toBe('#123')
|
||||
})
|
||||
|
||||
it('renders no brand glyph for an unknown host', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
const url = 'https://example.com/some/page'
|
||||
|
||||
render(<PrettyLink fallbackLabel="Some Page" href={url} />)
|
||||
|
||||
expect(screen.getByTitle(url).querySelector('svg')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { ArrowUpRight } from '@/lib/icons'
|
||||
|
||||
import { resolveBrandIcon } from './brand-icon'
|
||||
import { cn } from './utils'
|
||||
|
||||
const titleCache = new Map<string, string>()
|
||||
|
|
@ -210,6 +211,22 @@ export function ExternalLinkIcon({ className }: { className?: string }) {
|
|||
return <ArrowUpRight aria-hidden className={cn('ml-1 inline size-[0.78em] align-[-0.08em] opacity-70', className)} />
|
||||
}
|
||||
|
||||
// Brand mark for a known host, sized in `em` so it tracks the surrounding text
|
||||
// at any font size. It paints in `currentColor` rather than the brand hex —
|
||||
// several brand colors (GitHub's near-black, Unity's white) vanish against one
|
||||
// theme or the other.
|
||||
//
|
||||
// `title=""` is load-bearing: Simple Icons always renders a <title> defaulting
|
||||
// to the brand name, which lands in the anchor's textContent and accessible
|
||||
// name — a PR link would read "GitHub#123".
|
||||
export function LinkBrandIcon({ className, href }: { className?: string; href: string }) {
|
||||
const Icon = resolveBrandIcon(shortHostLabel(href))
|
||||
|
||||
return Icon ? (
|
||||
<Icon aria-hidden className={cn('mr-1 inline size-[0.85em] align-[-0.12em] opacity-80', className)} title="" />
|
||||
) : null
|
||||
}
|
||||
|
||||
export function ExternalLink({
|
||||
children,
|
||||
className,
|
||||
|
|
@ -222,7 +239,7 @@ export function ExternalLink({
|
|||
|
||||
return (
|
||||
<a
|
||||
className={cn('link-chip font-semibold', className)}
|
||||
className={cn('link-chip', className)}
|
||||
href={target}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
|
@ -261,7 +278,8 @@ export function PrettyLink({ className, fallbackLabel, href, label, ...rest }: P
|
|||
|
||||
return (
|
||||
<ExternalLink className={cn('wrap-break-word', className)} href={target} title={target} {...rest}>
|
||||
<span className="font-medium">{display}</span>
|
||||
<LinkBrandIcon href={target} />
|
||||
{display}
|
||||
</ExternalLink>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -556,11 +556,17 @@
|
|||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
}
|
||||
|
||||
/* Inline content links: a small tinted chip instead of an underline. Tint is
|
||||
currentColor-relative (the old `decoration-current/20` idiom), so text and
|
||||
fill share a hue on every theme from one `color` declaration. */
|
||||
/* Inline content links: color at rest, a tinted chip on hover, never an
|
||||
underline. Tint is currentColor-relative (the old `decoration-current/20`
|
||||
idiom), so text and fill share a hue on every theme from one `color`. */
|
||||
.link-chip {
|
||||
--link-chip-tint: 8%;
|
||||
/* The one knob: resting fill. 0% = color-only until hovered. */
|
||||
--link-chip-tint: 0%;
|
||||
|
||||
/* Prose links inherit the surrounding text's weight. `@tailwindcss/typography`
|
||||
sets `prose a { font-weight: 500 }`, which outranks a utility class on the
|
||||
anchor — so the override belongs here, on the shared chip. */
|
||||
font-weight: inherit;
|
||||
|
||||
/* `ch`/`em` so the chip tracks the text at any size. Block padding is
|
||||
asymmetric because an inline box's content area is ascent+descent — equal
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue