Skip to content

Commit e98cc9e

Browse files
committed
improvement(link-preview): render-time preview fetch, cheerio parsing, cleanup pass
- fetch previews when links render (emcn tooltip shows instantly — hover prefetch had no delay to race); tooltip reads the warmed cache, eliminating the URL-then-preview flash - parse OG metadata with cheerio (already used server-side) instead of hand-rolled regexes + entity decoding, fixing double-decode and quote-handling classes - drop the no-longer-needed prefetch hook; remove dead side prop on Tooltip.Content - extract ExternalLink to a sibling module per component-size guidelines; fix TSDoc placement
1 parent 242ad88 commit e98cc9e

4 files changed

Lines changed: 99 additions & 116 deletions

File tree

apps/sim/app/api/link-preview/route.ts

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createHash } from 'crypto'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { truncate } from '@sim/utils/string'
5+
import * as cheerio from 'cheerio'
56
import type { NextRequest } from 'next/server'
67
import { NextResponse } from 'next/server'
78
import type { LinkPreview } from '@/lib/api/contracts/link-preview'
@@ -24,51 +25,24 @@ const CACHE_TTL_SECONDS = 24 * 60 * 60
2425
const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60
2526
const CACHE_KEY_PREFIX = 'link-preview:v1:'
2627

27-
function decodeHtmlEntities(value: string): string {
28-
return value
29-
.replace(/&/g, '&')
30-
.replace(/&lt;/g, '<')
31-
.replace(/&gt;/g, '>')
32-
.replace(/&quot;/g, '"')
33-
.replace(/&#0?39;/g, "'")
34-
.replace(/&#x27;/gi, "'")
35-
.replace(/&nbsp;/g, ' ')
36-
}
37-
3828
/**
39-
* Content of a `<meta>` tag matched by `property` or `name`, handling either
40-
* attribute order. Only the document head matters for previews, so callers
41-
* pass a head-truncated HTML string.
29+
* Parses preview metadata from the document head. Only the head matters for
30+
* previews, so the input is truncated at `<body>` before parsing; cheerio
31+
* handles attribute order, quoting, and entity decoding.
4232
*/
43-
function metaContent(html: string, key: string): string | null {
44-
const attr = `(?:property|name)=["']${key}["']`
45-
const content = `content=(?:"([^"]*)"|'([^']*)')`
46-
const patterns = [
47-
new RegExp(`<meta[^>]*${attr}[^>]*${content}`, 'i'),
48-
new RegExp(`<meta[^>]*${content}[^>]*${attr}`, 'i'),
49-
]
50-
for (const pattern of patterns) {
51-
const match = html.match(pattern)
52-
const value = match?.[1] ?? match?.[2]
53-
if (value) return decodeHtmlEntities(value).trim() || null
54-
}
55-
return null
56-
}
57-
5833
function parsePreview(html: string): LinkPreview {
5934
const bodyIndex = html.search(/<body[\s>]/i)
60-
const head = bodyIndex === -1 ? html : html.slice(0, bodyIndex)
35+
const $ = cheerio.load(bodyIndex === -1 ? html : html.slice(0, bodyIndex))
36+
37+
const meta = (key: string): string | null => {
38+
const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content')
39+
return value?.trim() || null
40+
}
6141

62-
const titleTag = head.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]
6342
const title =
64-
metaContent(head, 'og:title') ??
65-
metaContent(head, 'twitter:title') ??
66-
(titleTag ? decodeHtmlEntities(titleTag).trim() || null : null)
67-
const description =
68-
metaContent(head, 'og:description') ??
69-
metaContent(head, 'twitter:description') ??
70-
metaContent(head, 'description')
71-
const siteName = metaContent(head, 'og:site_name')
43+
meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
44+
const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
45+
const siteName = meta('og:site_name')
7246

7347
if (!title && !description && !siteName) return null
7448
return {

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 5 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@ import 'prismjs/components/prism-bash'
1111
import 'prismjs/components/prism-css'
1212
import 'prismjs/components/prism-markup'
1313
import '@sim/emcn/components/code/code.css'
14-
import { Checkbox, CopyCodeButton, cn, highlight, languages, Tooltip } from '@sim/emcn'
14+
import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn'
1515
import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils'
16-
import { faviconUrl } from '@/lib/core/utils/favicon'
1716
import { extractTextContent } from '@/lib/core/utils/react-node-text'
1817
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
1918
import {
@@ -23,9 +22,9 @@ import {
2322
SpecialTags,
2423
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
2524
import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
26-
import { useLinkPreview } from '@/hooks/queries/link-preview'
2725
import { useSmoothText } from '@/hooks/use-smooth-text'
2826
import { sanitizeChatDisplayContent } from './chat-sanitize'
27+
import { ExternalLink, externalLinkHostname } from './external-link'
2928

3029
const LANG_ALIASES: Record<string, string> = {
3130
js: 'javascript',
@@ -162,55 +161,6 @@ function fileIconLabel(ref: string, fallback: string): string {
162161
return fallback
163162
}
164163

165-
/** Hides a favicon img that failed to load so the link degrades to plain text. */
166-
function hideBrokenFavicon(e: React.SyntheticEvent<HTMLImageElement>): void {
167-
e.currentTarget.style.display = 'none'
168-
}
169-
170-
interface ExternalLinkTooltipProps {
171-
href: string
172-
hostname: string
173-
}
174-
175-
/**
176-
* OG-metadata card for an external link's tooltip. Rendered only while the
177-
* tooltip is open (Radix lazy-mounts content), so the preview request fires on
178-
* hover intent; until metadata arrives — or when the site has none — it shows
179-
* the destination URL.
180-
*/
181-
function ExternalLinkTooltip({ href, hostname }: ExternalLinkTooltipProps) {
182-
const { data } = useLinkPreview(href)
183-
const preview = data?.preview
184-
185-
if (!preview) {
186-
return <span className='break-all'>{href}</span>
187-
}
188-
189-
return (
190-
<span className='flex flex-col gap-0.5'>
191-
{preview.title && <span className='font-medium'>{preview.title}</span>}
192-
{preview.description && (
193-
<span className='line-clamp-2 text-[var(--text-muted)]'>{preview.description}</span>
194-
)}
195-
<span className='text-[var(--text-muted)]'>{preview.siteName ?? hostname}</span>
196-
</span>
197-
)
198-
}
199-
200-
/**
201-
* Hostname for an external http(s) link, used to fetch its favicon. Returns
202-
* null for relative, anchor, mailto, and unparsable hrefs so those keep the
203-
* plain underlined treatment.
204-
*/
205-
function externalLinkHostname(href?: string): string | null {
206-
if (!href || !/^https?:\/\//i.test(href)) return null
207-
try {
208-
return new URL(href).hostname
209-
} catch {
210-
return null
211-
}
212-
}
213-
214164
const MARKDOWN_COMPONENTS = {
215165
table({ children }: { children?: React.ReactNode }) {
216166
return (
@@ -322,29 +272,9 @@ const MARKDOWN_COMPONENTS = {
322272
const hostname = externalLinkHostname(href)
323273
if (hostname && href) {
324274
return (
325-
<Tooltip.Root>
326-
<Tooltip.Trigger asChild>
327-
<a
328-
href={href}
329-
className='not-prose group text-[var(--text-primary)] no-underline'
330-
target='_blank'
331-
rel='noopener noreferrer'
332-
>
333-
<img
334-
src={faviconUrl(hostname, 32)}
335-
alt=''
336-
className='relative top-[0.5px] mr-[2px] inline size-[12px] rounded-[3px]'
337-
onError={hideBrokenFavicon}
338-
/>
339-
<span className='underline decoration-[color:var(--text-muted)] underline-offset-4 transition-colors group-hover:decoration-[color:var(--text-primary)]'>
340-
{children}
341-
</span>
342-
</a>
343-
</Tooltip.Trigger>
344-
<Tooltip.Content side='top'>
345-
<ExternalLinkTooltip href={href} hostname={hostname} />
346-
</Tooltip.Content>
347-
</Tooltip.Root>
275+
<ExternalLink href={href} hostname={hostname}>
276+
{children}
277+
</ExternalLink>
348278
)
349279
}
350280
return (
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client'
2+
3+
import { Tooltip } from '@sim/emcn'
4+
import { faviconUrl } from '@/lib/core/utils/favicon'
5+
import { useLinkPreview } from '@/hooks/queries/link-preview'
6+
7+
/** Hides a favicon img that failed to load so the link degrades to plain text. */
8+
function hideBrokenFavicon(e: React.SyntheticEvent<HTMLImageElement>): void {
9+
e.currentTarget.style.display = 'none'
10+
}
11+
12+
/**
13+
* Hostname for an external http(s) link, used to fetch its favicon. Returns
14+
* null for relative, anchor, mailto, and unparsable hrefs so those keep the
15+
* plain underlined treatment.
16+
*/
17+
export function externalLinkHostname(href?: string): string | null {
18+
if (!href || !/^https?:\/\//i.test(href)) return null
19+
try {
20+
return new URL(href).hostname
21+
} catch {
22+
return null
23+
}
24+
}
25+
26+
interface ExternalLinkProps {
27+
href: string
28+
hostname: string
29+
children?: React.ReactNode
30+
}
31+
32+
/**
33+
* Favicon + quiet-underline external link with an OG-preview tooltip. The
34+
* preview query fires when the link renders, so metadata is normally cached
35+
* (client and server side) before the first hover; the tooltip shows the
36+
* destination URL until metadata arrives or when the site has none.
37+
*/
38+
export function ExternalLink({ href, hostname, children }: ExternalLinkProps) {
39+
const { data } = useLinkPreview(href)
40+
const preview = data?.preview
41+
42+
return (
43+
<Tooltip.Root>
44+
<Tooltip.Trigger asChild>
45+
<a
46+
href={href}
47+
className='not-prose group text-[var(--text-primary)] no-underline'
48+
target='_blank'
49+
rel='noopener noreferrer'
50+
>
51+
<img
52+
src={faviconUrl(hostname, 32)}
53+
alt=''
54+
className='relative top-[0.5px] mr-[2px] inline size-[12px] rounded-[3px]'
55+
onError={hideBrokenFavicon}
56+
/>
57+
<span className='underline decoration-[color:var(--text-muted)] underline-offset-4 transition-colors group-hover:decoration-[color:var(--text-primary)]'>
58+
{children}
59+
</span>
60+
</a>
61+
</Tooltip.Trigger>
62+
<Tooltip.Content>
63+
{preview ? (
64+
<span className='flex flex-col gap-0.5'>
65+
{preview.title && <span className='font-medium'>{preview.title}</span>}
66+
{preview.description && (
67+
<span className='line-clamp-2 text-[var(--text-muted)]'>{preview.description}</span>
68+
)}
69+
<span className='text-[var(--text-muted)]'>{preview.siteName ?? hostname}</span>
70+
</span>
71+
) : (
72+
<span className='break-all'>{href}</span>
73+
)}
74+
</Tooltip.Content>
75+
</Tooltip.Root>
76+
)
77+
}

apps/sim/hooks/queries/link-preview.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ async function fetchLinkPreview(url: string, signal?: AbortSignal): Promise<Link
1717

1818
/**
1919
* OG metadata for an external URL, fetched through the SSRF-hardened
20-
* `/api/link-preview` proxy. Mount the consuming component lazily (e.g. inside
21-
* a tooltip that renders on open) so the request only fires on intent.
20+
* `/api/link-preview` proxy. Fires when the consuming component renders so the
21+
* preview is normally cached before the user hovers; results are long-lived
22+
* (client staleTime + 24h server-side Redis cache) and failures are not
23+
* retried.
2224
*/
2325
export function useLinkPreview(url?: string) {
2426
return useQuery({

0 commit comments

Comments
 (0)