diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47d9d228..c93e5407 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Bounded the ESC-cancel worker-thread queue in `OpenAICompatibleProvider.chat_stream_response` (`src/providers/openai_compatible.py`) to `maxsize=64`. Previously an unbounded `queue.Queue` let an orphaned worker accumulate chunks in memory indefinitely when a proxy kept sending bytes after abort without closing the SDK iterator (#278).
+- **URLs the agent prints are clickable again.** The TUI markdown renderer
+ (`ui-tui/src/components/markdown.tsx`) replaced every visible URL with a
+ remote-fetched page `
` (silently HTTP-GETting each URL the agent
+ printed) or a slug-derived label, leaving the real URL only in OSC 8
+ metadata. Terminals without OSC 8 support (e.g. Apple Terminal) strip
+ that metadata, so the URL was invisible, unclickable, and uncopyable —
+ and in the default inline mode the TUI never captures the mouse, so
+ terminal-native detection over the visible text is the only affordance
+ that works everywhere. Bare URLs now render verbatim, `[label](url)`
+ renders as `label (url)`, the stealth title fetch is gone, and links
+ remain OSC 8-wrapped for terminals with first-class hyperlink support
+ (Cmd+click in VS Code/iTerm2, plain click in fullscreen mode).
### Changed
diff --git a/ui-tui/src/__tests__/externalLink.test.ts b/ui-tui/src/__tests__/externalLink.test.ts
index 5a3673f8..b4c3c201 100644
--- a/ui-tui/src/__tests__/externalLink.test.ts
+++ b/ui-tui/src/__tests__/externalLink.test.ts
@@ -1,149 +1,17 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
-import {
- __resetLinkTitleCache,
- fetchLinkTitle,
- hostPathLabel,
- isTitleFetchable,
- normalizeExternalUrl,
- urlSlugTitleLabel
-} from '../lib/externalLink.js'
-
-afterEach(() => {
- __resetLinkTitleCache()
- vi.restoreAllMocks()
- vi.unstubAllGlobals()
-})
+import { normalizeExternalUrl } from '../lib/externalLink.js'
describe('external link helpers', () => {
- it('formats URL fallbacks as host + path', () => {
- expect(
- hostPathLabel(
- 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
- )
- ).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894')
- })
-
- it('derives readable title fallbacks from URL slugs', () => {
- expect(
- urlSlugTitleLabel(
- 'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/'
- )
- ).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
- })
-
- it('keeps x.com status fallbacks link-like instead of generic Status labels', () => {
- expect(urlSlugTitleLabel('https://x.com/grok/status/2056065022749479209')).toBe(
- 'x.com/grok/status/2056065022749479209'
- )
- })
-
it('normalizes scheme-less links', () => {
expect(normalizeExternalUrl(' expedia.com/things-to-do/puerto-rico-el-yunque ')).toBe(
'https://expedia.com/things-to-do/puerto-rico-el-yunque'
)
})
- it('filters out local/non-http targets for title fetches', () => {
- expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
- expect(isTitleFetchable('http://localhost:5174')).toBe(false)
- expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false)
- expect(isTitleFetchable('mailto:hello@example.com')).toBe(false)
- })
-
- it('blocks private, link-local, and intranet hosts', () => {
- expect(isTitleFetchable('http://10.0.0.12/path')).toBe(false)
- expect(isTitleFetchable('http://172.22.5.4/path')).toBe(false)
- expect(isTitleFetchable('http://192.168.1.22/path')).toBe(false)
- expect(isTitleFetchable('http://169.254.169.254/latest/meta-data')).toBe(false)
- expect(isTitleFetchable('http://[fd00::1]/')).toBe(false)
- expect(isTitleFetchable('http://[fe80::1]/')).toBe(false)
- expect(isTitleFetchable('http://printer.local/status')).toBe(false)
- expect(isTitleFetchable('http://intranet/status')).toBe(false)
- expect(isTitleFetchable('https://8.8.8.8/status')).toBe(true)
- })
-
- it('deduplicates in-flight title fetches and caches results', async () => {
- const fetchMock = vi.fn().mockResolvedValue(
- new Response('El Yunque Tour Water Slide, Rope Swing & Pickup', {
- headers: { 'content-type': 'text/html; charset=utf-8' },
- status: 200
- })
- )
-
- vi.stubGlobal('fetch', fetchMock)
-
- const url =
- 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details'
-
- const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
-
- expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
- expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
- expect(fetchMock).toHaveBeenCalledTimes(1)
-
- const third = await fetchLinkTitle(url)
-
- expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
- expect(fetchMock).toHaveBeenCalledTimes(1)
- })
-
- it('shares cache across protocol/www URL variants', async () => {
- const fetchMock = vi.fn().mockResolvedValue(
- new Response('Shared Canonical Title', {
- headers: { 'content-type': 'text/html' },
- status: 200
- })
- )
-
- vi.stubGlobal('fetch', fetchMock)
-
- const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
- const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
-
- const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)])
-
- expect(a).toBe('Shared Canonical Title')
- expect(b).toBe('Shared Canonical Title')
- expect(fetchMock).toHaveBeenCalledTimes(1)
- })
-
- it('ignores error-like fetched titles', async () => {
- const fetchMock = vi.fn().mockResolvedValue(
- new Response('Just a moment...', {
- headers: { 'content-type': 'text/html' },
- status: 200
- })
- )
-
- vi.stubGlobal('fetch', fetchMock)
-
- const url =
- 'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
-
- await expect(fetchLinkTitle(url)).resolves.toBe('')
- })
-
- it('decodes HTML entities in fetched titles', async () => {
- const fetchMock = vi.fn().mockResolvedValue(
- new Response('AT&T 'Deals'', {
- headers: { 'content-type': 'text/html' },
- status: 200
- })
- )
-
- vi.stubGlobal('fetch', fetchMock)
-
- await expect(fetchLinkTitle('https://example.com/offers')).resolves.toBe("AT&T 'Deals'")
- })
-
- it('skips network fetch for non-fetchable targets', async () => {
- const fetchMock = vi.fn()
- vi.stubGlobal('fetch', fetchMock)
-
- await expect(fetchLinkTitle('http://localhost:3000/path')).resolves.toBe('')
- await expect(fetchLinkTitle('mailto:hello@example.com')).resolves.toBe('')
- await expect(fetchLinkTitle('file:///tmp/demo.html')).resolves.toBe('')
- expect(fetchMock).not.toHaveBeenCalled()
+ it('leaves URLs with schemes and non-domain text untouched', () => {
+ expect(normalizeExternalUrl('https://example.com/docs')).toBe('https://example.com/docs')
+ expect(normalizeExternalUrl('mailto:user@example.com')).toBe('mailto:user@example.com')
+ expect(normalizeExternalUrl('not a url')).toBe('not a url')
})
})
diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts
index 72331337..c662c127 100644
--- a/ui-tui/src/__tests__/markdown.test.ts
+++ b/ui-tui/src/__tests__/markdown.test.ts
@@ -43,6 +43,34 @@ const renderPlain = (node: React.ReactNode) => {
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
}
+// Like renderPlain, but returns the raw output stream with escape
+// sequences intact — for asserting on OSC 8 hyperlink metadata.
+const renderRaw = (node: React.ReactNode) => {
+ const stdout = new PassThrough()
+ const stdin = new PassThrough()
+ const stderr = new PassThrough()
+ let output = ''
+
+ Object.assign(stdout, { columns: 120, isTTY: false, rows: 24 })
+ Object.assign(stdin, { isTTY: false })
+ Object.assign(stderr, { isTTY: false })
+ stdout.on('data', chunk => {
+ output += chunk.toString()
+ })
+
+ const instance = renderSync(node, {
+ patchConsole: false,
+ stderr: stderr as NodeJS.WriteStream,
+ stdin: stdin as NodeJS.ReadStream,
+ stdout: stdout as NodeJS.WriteStream
+ })
+
+ instance.unmount()
+ instance.cleanup()
+
+ return output
+}
+
describe('INLINE_RE emphasis', () => {
it('matches word-boundary italic/bold', () => {
expect(matches('say _hi_ there')).toEqual(['_hi_'])
@@ -248,37 +276,185 @@ describe('Md wrapping', () => {
})
describe('Md link labels', () => {
- it('renders bare URLs with readable slug labels', () => {
+ // URLs must stay visible — see the ResolvedLink comment in
+ // components/markdown.tsx for why.
+ it('renders bare URLs verbatim', () => {
+ const lines = renderPlain(
+ React.createElement(
+ Box,
+ { width: 80 },
+ React.createElement(Md, {
+ t: DEFAULT_THEME,
+ text: 'see https://www.expedia.com/things-to-do/el-yunque for details'
+ })
+ )
+ )
+
+ expect(lines.join('\n')).toContain('https://www.expedia.com/things-to-do/el-yunque')
+ })
+
+ it('renders explicit markdown labels with the target URL alongside', () => {
+ const lines = renderPlain(
+ React.createElement(
+ Box,
+ { width: 80 },
+ React.createElement(Md, {
+ t: DEFAULT_THEME,
+ text: '[Trip details](https://www.expedia.com/deals)'
+ })
+ )
+ )
+
+ const rendered = lines.join('\n')
+
+ expect(rendered).toContain('Trip details')
+ expect(rendered).toContain('(https://www.expedia.com/deals)')
+ })
+
+ it('does not duplicate the URL when the label is the URL itself', () => {
+ const lines = renderPlain(
+ React.createElement(
+ Box,
+ { width: 80 },
+ React.createElement(Md, {
+ t: DEFAULT_THEME,
+ text: '[https://example.com/docs](https://example.com/docs) and '
+ })
+ )
+ )
+
+ const rendered = lines.join('\n')
+
+ expect(rendered).toContain('https://example.com/docs')
+ expect(rendered).not.toContain('(https://example.com/docs)')
+ expect(rendered).toContain('user@example.com')
+ expect(rendered).not.toContain('(mailto:user@example.com)')
+ })
+
+ it('strips the mailto scheme from labeled-link suffixes', () => {
const lines = renderPlain(
React.createElement(
Box,
- { width: 120 },
+ { width: 80 },
React.createElement(Md, {
t: DEFAULT_THEME,
- text: 'see https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure for details'
+ text: '[Email me](mailto:user@example.com)'
})
)
)
const rendered = lines.join('\n')
- expect(rendered).toContain('Puerto Rico El Yunque Rainforest Adventure')
- expect(rendered).not.toContain('https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
+ expect(rendered).toContain('Email me (user@example.com)')
+ expect(rendered).not.toContain('mailto:')
})
- it('keeps explicit markdown labels as the immediate fallback', () => {
+ it('suppresses the URL suffix when the label normalizes to the target', () => {
const lines = renderPlain(
React.createElement(
Box,
{ width: 80 },
React.createElement(Md, {
t: DEFAULT_THEME,
- text: '[Trip details](https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure)'
+ text: '[example.com/docs](https://example.com/docs)'
+ })
+ )
+ )
+
+ const rendered = lines.join('\n')
+
+ expect(rendered).toContain('https://example.com/docs')
+ expect(rendered).not.toContain('(https://example.com/docs)')
+ })
+
+ it('loses no characters when a long URL wraps across lines', () => {
+ const longUrl = `https://www.example.com/very/long/path/${'segment-abcdefgh/'.repeat(6)}index.html`
+
+ const lines = renderPlain(
+ React.createElement(Box, { width: 80 }, React.createElement(Md, { t: DEFAULT_THEME, text: `see ${longUrl}` }))
+ )
+
+ // Hard wrap may split the URL across rows; joining without newlines
+ // must reconstruct it exactly — the terminal shows every character.
+ expect(lines.join('')).toContain(longUrl)
+ })
+
+ it('keeps balanced parens in bare URLs but trims prose punctuation', () => {
+ const lines = renderPlain(
+ React.createElement(
+ Box,
+ { width: 100 },
+ React.createElement(Md, {
+ t: DEFAULT_THEME,
+ text: 'see https://en.wikipedia.org/wiki/Foo_(bar), then (https://x.com/a) after'
})
)
)
- expect(lines.join('\n')).toContain('Trip details')
+ const rendered = lines.join('\n')
+
+ expect(rendered).toContain('https://en.wikipedia.org/wiki/Foo_(bar), then')
+ expect(rendered).toContain('(https://x.com/a) after')
+ })
+
+ it('emits OSC 8 hyperlink metadata with the exact click target', () => {
+ const raw = renderRaw(
+ React.createElement(
+ Box,
+ { width: 100 },
+ React.createElement(Md, {
+ t: DEFAULT_THEME,
+ text: '[Trip details](https://www.expedia.com/deals) and https://en.wikipedia.org/wiki/Foo_(bar).'
+ })
+ )
+ )
+
+ // OSC 8 form: ESC ] 8 ; params ; URI terminator — the URI must be the
+ // full target (balanced paren kept, trailing period trimmed).
+ expect(raw).toMatch(new RegExp(`\\]8;[^;]*;https://www\\.expedia\\.com/deals(?:${BEL}|${ESC}\\\\)`))
+ expect(raw).toMatch(new RegExp(`\\]8;[^;]*;https://en\\.wikipedia\\.org/wiki/Foo_\\(bar\\)(?:${BEL}|${ESC}\\\\)`))
+ })
+
+ it('strips bidi and C0 control characters from displayed link text', () => {
+ // U+202E (right-to-left override) can visually reorder a domain on
+ // software-bidi terminals, and C0 controls should never leave the link
+ // text either — neither may reach the visible output.
+ const rlo = String.fromCharCode(0x202e)
+ const bel = String.fromCharCode(7)
+
+ const lines = renderPlain(
+ React.createElement(
+ Box,
+ { width: 80 },
+ React.createElement(Md, { t: DEFAULT_THEME, text: `see https://example.com/${rlo}gro${bel}.evil now` })
+ )
+ )
+
+ const rendered = lines.join('\n')
+
+ expect(rendered).toContain('https://example.com/gro.evil')
+ expect(rendered).not.toContain(rlo)
+ expect(rendered).not.toContain(bel)
+ })
+
+ it('handles paren floods after bare URLs in linear time', () => {
+ // Regression: trimBareUrl used to rescan the whole string once per
+ // trimmed character (O(n²)) — a model-printed URL followed by tens of
+ // thousands of ')' would freeze the render synchronously. Post-fix
+ // this completes instantly; pre-fix it blows the test timeout.
+ const flood = ')'.repeat(50_000)
+
+ const lines = renderPlain(
+ React.createElement(
+ Box,
+ { width: 80 },
+ React.createElement(Md, { t: DEFAULT_THEME, text: `see https://a.com/x${flood} end` })
+ )
+ )
+
+ // The link target stops at the URL; the flood renders as plain text
+ // after it, with no characters lost.
+ expect(lines.join('')).toContain(`https://a.com/x${flood}`)
})
})
diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx
index f2897fb9..83be599f 100644
--- a/ui-tui/src/components/markdown.tsx
+++ b/ui-tui/src/components/markdown.tsx
@@ -2,7 +2,7 @@ import { Box, Link, stringWidth, Text } from '@clawcodex/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import { ensureEmojiPresentation } from '../lib/emoji.js'
-import { normalizeExternalUrl, urlSlugTitleLabel, useLinkTitle } from '../lib/externalLink.js'
+import { normalizeExternalUrl } from '../lib/externalLink.js'
import { BOX_CLOSE, BOX_OPEN, texToUnicode } from '../lib/mathUnicode.js'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
import type { Theme } from '../theme.js'
@@ -150,8 +150,49 @@ const isTableDivider = (row: string) => {
const autolinkUrl = (raw: string) =>
raw.startsWith('mailto:') || raw.startsWith('http') || !raw.includes('@') ? raw : `mailto:${raw}`
-const defaultLinkLabel = (url: string) =>
- url.startsWith('mailto:') ? url.replace(/^mailto:/, '') : /^https?:\/\//i.test(url) ? urlSlugTitleLabel(url) : url
+// Trim trailing prose punctuation off a bare URL, but keep a ')' that
+// closes a '(' inside the URL — wikipedia-style /Foo_(bar) paths would
+// otherwise lose their final paren and the click target would 404.
+// Single O(n) pass: the paren balance is counted once and tracked while
+// trimming — model-controlled text can be arbitrarily long, and per-step
+// rescans would make a URL followed by N parens O(N²) inside render.
+const trimBareUrl = (raw: string): string => {
+ let opens = 0
+ let closes = 0
+
+ for (const ch of raw) {
+ if (ch === '(') {
+ opens++
+ } else if (ch === ')') {
+ closes++
+ }
+ }
+
+ let end = raw.length
+
+ while (end > 0) {
+ const ch = raw[end - 1]!
+
+ if (',.;:!?'.includes(ch)) {
+ end--
+
+ continue
+ }
+
+ if (ch === ')' && closes > opens) {
+ closes--
+ end--
+
+ continue
+ }
+
+ break
+ }
+
+ return raw.slice(0, end)
+}
+
+const defaultLinkLabel = (url: string) => (url.startsWith('mailto:') ? url.replace(/^mailto:/, '') : url)
const pickFallbackLabel = (label: string | undefined, target: string): string | undefined => {
const trimmed = label?.trim()
@@ -169,15 +210,51 @@ interface ResolvedLinkProps {
url: string
}
+// Sanitize link text for display. Two classes are dropped:
+// - Bidi controls (U+202A-202E embeddings/overrides, U+2066-2069 isolates):
+// they survive the renderer's C0 filter and can visually reorder a
+// displayed URL on software-bidi terminals (Trojan-Source-style domain
+// spoofing).
+// - C0/DEL controls: the renderer's cell-clustering pass strips these
+// downstream, but doing it locally keeps the guarantee independent of
+// the vendored renderer's internals (and of any path that serializes
+// this text outside the cell buffer).
+// Display-only - the OSC 8 target and the click opener still receive the
+// untouched URL. Character-code loop instead of a regex so eslint's
+// no-control-regex stays happy.
+const stripUnsafeDisplayChars = (s: string): string => {
+ let out = ''
+
+ for (const ch of s) {
+ const c = ch.codePointAt(0)!
+
+ if (c < 0x20 || c === 0x7f || (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) {
+ continue
+ }
+
+ out += ch
+ }
+
+ return out
+}
+
function ResolvedLink({ fallbackLabel, t, url }: ResolvedLinkProps) {
- const fetched = useLinkTitle(url)
- const display = fetched || fallbackLabel || defaultLinkLabel(url)
+ // The URL text must stay on screen. Terminals without OSC 8 support strip
+ // the metadata entirely, and inline mode (the default) never arms
+ // mouse tracking, so the in-process click opener can't fire there — the
+ // only universally working affordance is the terminal's own URL detection
+ // over the visible text (Cmd+click / Cmd+double-click). A custom markdown
+ // label still renders, with the target appended alongside.
+ const base = defaultLinkLabel(url)
+ const display = stripUnsafeDisplayChars(fallbackLabel || base)
+ const showTarget = Boolean(fallbackLabel) && fallbackLabel !== base
return (
{display}
+ {showTarget ? {` (${stripUnsafeDisplayChars(base)})`} : null}
)
}
@@ -621,7 +698,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
} else if (m[16]) {
// Bare URL — trim trailing prose punctuation into a sibling text node
// so `see https://x.com/, which…` keeps the comma outside the link.
- const url = m[16].replace(/[),.;:!?]+$/g, '')
+ const url = trimBareUrl(m[16])
parts.push(renderResolvedLink(parts.length, t, url))
diff --git a/ui-tui/src/lib/externalLink.ts b/ui-tui/src/lib/externalLink.ts
index b117e9eb..ee98180b 100644
--- a/ui-tui/src/lib/externalLink.ts
+++ b/ui-tui/src/lib/externalLink.ts
@@ -1,38 +1,4 @@
-import { isIP } from 'node:net'
-
-import { useEffect, useMemo, useState } from 'react'
-
-const titleCache = new Map()
-const titleInflight = new Map>()
-const titleSubs = new Map void>>()
-
-const TITLE_CACHE_LIMIT = 500
-const TITLE_MAX_LENGTH = 240
-const TITLE_BYTE_BUDGET = 96 * 1024
-const TITLE_TIMEOUT_MS = 5000
-
-const TITLE_USER_AGENT =
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
-
-const TITLE_ERROR_RE =
- /\b(?:access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i
-
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
-const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|clawcodex):/i
-const LOCAL_HOSTNAME_RE = /^(?:localhost|localhost\.localdomain)$/i
-const LOCAL_HOST_SUFFIXES = ['.corp', '.home', '.internal', '.lan', '.local', '.localdomain']
-const STATUS_PERMALINK_HOST_RE = /^(?:mobile\.)?(?:x|twitter)\.com$/i
-const STATUS_PERMALINK_PATH_RE = /^\/[^/]+\/status\/\d+\/?$/i
-
-const HTML_ENTITIES: Record = {
- '#39': "'",
- amp: '&',
- apos: "'",
- gt: '>',
- lt: '<',
- nbsp: ' ',
- quot: '"'
-}
export function normalizeExternalUrl(value: string): string {
const trimmed = value.trim()
@@ -43,398 +9,3 @@ export function normalizeExternalUrl(value: string): string {
return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed
}
-
-function parseUrl(value: string): null | URL {
- try {
- return new URL(normalizeExternalUrl(value))
- } catch {
- return null
- }
-}
-
-function titleCacheKey(value: string): string {
- const url = parseUrl(value)
-
- if (!url) {
- return normalizeExternalUrl(value)
- }
-
- const host = url.hostname.replace(/^www\./i, '').toLowerCase()
- const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
-
- return `${host}${pathname}${url.search || ''}`
-}
-
-function cacheTitle(key: string, title: string): void {
- if (titleCache.size >= TITLE_CACHE_LIMIT) {
- titleCache.delete(titleCache.keys().next().value as string)
- }
-
- titleCache.set(key, title)
-}
-
-export function hostPathLabel(value: string): string {
- const url = parseUrl(value)
-
- if (!url) {
- return value
- }
-
- const host = url.hostname.replace(/^www\./, '')
- const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''
-
- return `${host}${path}`
-}
-
-function cleanSlug(segment: string): string {
- try {
- return decodeURIComponent(segment)
- .replace(/\.a\d+\..*$/i, '')
- .replace(/\.(?:html?|php|aspx?)$/i, '')
- .replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '')
- .replace(/[_-]+/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- } catch {
- return ''
- }
-}
-
-export function urlSlugTitleLabel(value: string): string {
- const url = parseUrl(value)
-
- if (url && STATUS_PERMALINK_HOST_RE.test(url.hostname) && STATUS_PERMALINK_PATH_RE.test(url.pathname)) {
- return hostPathLabel(value)
- }
-
- for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) {
- const cleaned = cleanSlug(segment)
-
- if (!cleaned || !/[a-z]/i.test(cleaned)) {
- continue
- }
-
- if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) {
- continue
- }
-
- const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
-
- if (titled.length >= 4) {
- return titled
- }
- }
-
- return hostPathLabel(value)
-}
-
-function parseIpv4Octets(value: string): null | [number, number, number, number] {
- const parts = value.split('.')
-
- if (parts.length !== 4) {
- return null
- }
-
- const octets: number[] = []
-
- for (const part of parts) {
- if (!/^\d{1,3}$/.test(part)) {
- return null
- }
-
- const next = Number(part)
-
- if (!Number.isInteger(next) || next < 0 || next > 255) {
- return null
- }
-
- octets.push(next)
- }
-
- return [octets[0]!, octets[1]!, octets[2]!, octets[3]!]
-}
-
-function isPrivateIpv4(value: string): boolean {
- const octets = parseIpv4Octets(value)
-
- if (!octets) {
- return false
- }
-
- const [a, b] = octets
-
- return (
- a === 0 ||
- a === 10 ||
- a === 127 ||
- a === 255 ||
- (a === 100 && b >= 64 && b <= 127) ||
- (a === 169 && b === 254) ||
- (a === 172 && b >= 16 && b <= 31) ||
- (a === 192 && b === 168) ||
- (a === 198 && (b === 18 || b === 19))
- )
-}
-
-function isPrivateIpv6(value: string): boolean {
- const normalized = value.toLowerCase()
-
- if (normalized === '::' || normalized === '::1') {
- return true
- }
-
- if (normalized.startsWith('fc') || normalized.startsWith('fd')) {
- return true
- }
-
- if (
- normalized.startsWith('fe8') ||
- normalized.startsWith('fe9') ||
- normalized.startsWith('fea') ||
- normalized.startsWith('feb')
- ) {
- return true
- }
-
- if (normalized.startsWith('::ffff:')) {
- return isPrivateIpv4(normalized.slice('::ffff:'.length))
- }
-
- return false
-}
-
-function normalizeHostname(value: string): string {
- const withoutBrackets = value.replace(/^\[/, '').replace(/\]$/, '')
- const withoutZoneId = withoutBrackets.split('%', 1)[0]!
-
- return withoutZoneId.replace(/\.$/, '').toLowerCase()
-}
-
-function isPrivateOrLocalHost(hostname: string): boolean {
- const normalized = normalizeHostname(hostname)
-
- if (!normalized) {
- return true
- }
-
- if (LOCAL_HOSTNAME_RE.test(normalized)) {
- return true
- }
-
- if (LOCAL_HOST_SUFFIXES.some(suffix => normalized.endsWith(suffix))) {
- return true
- }
-
- const ipVersion = isIP(normalized)
-
- if (ipVersion === 4) {
- return isPrivateIpv4(normalized)
- }
-
- if (ipVersion === 6) {
- return isPrivateIpv6(normalized)
- }
-
- // Single-label hostnames are usually LAN names or enterprise intranet aliases.
- return !normalized.includes('.')
-}
-
-export function isTitleFetchable(value: string): boolean {
- if (!value || SKIP_PROTO_RE.test(value)) {
- return false
- }
-
- const url = parseUrl(value)
-
- return Boolean(url && /^https?:$/.test(url.protocol) && !isPrivateOrLocalHost(url.hostname))
-}
-
-function decodeHtmlEntities(value: string): string {
- return value
- .replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_match, key: string) => HTML_ENTITIES[key.toLowerCase()] ?? '')
- .replace(/([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(parseInt(hex, 16) || 32))
- .replace(/(\d+);/g, (_match, decimal: string) => String.fromCodePoint(parseInt(decimal, 10) || 32))
-}
-
-function parseHtmlTitle(html: string): string {
- const raw = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]
-
- return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : ''
-}
-
-async function readResponseSnippet(response: Response): Promise {
- const reader = response.body?.getReader()
-
- if (!reader) {
- return (await response.text()).slice(0, TITLE_BYTE_BUDGET)
- }
-
- const chunks: Uint8Array[] = []
- let done = false
- let bytes = 0
-
- try {
- while (bytes < TITLE_BYTE_BUDGET) {
- const chunk = await reader.read()
-
- if (chunk.done) {
- done = true
-
- break
- }
-
- const value = chunk.value
-
- if (!value?.length) {
- continue
- }
-
- const remaining = TITLE_BYTE_BUDGET - bytes
- const next = value.length > remaining ? value.subarray(0, remaining) : value
-
- chunks.push(next)
- bytes += next.length
-
- if (next.length < value.length) {
- break
- }
- }
- } catch {
- return ''
- } finally {
- if (!done) {
- try {
- await reader.cancel()
- } catch {
- // Ignore stream teardown failures.
- }
- }
- }
-
- if (!chunks.length) {
- return ''
- }
-
- const joined = new Uint8Array(bytes)
- let offset = 0
-
- for (const chunk of chunks) {
- joined.set(chunk, offset)
- offset += chunk.length
- }
-
- return new TextDecoder().decode(joined)
-}
-
-function usableTitle(value: string): string {
- const clean = value.replace(/\s+/g, ' ').trim()
-
- return clean && !TITLE_ERROR_RE.test(clean) ? clean : ''
-}
-
-async function fetchHtmlTitle(normalizedUrl: string): Promise {
- const controller = new AbortController()
- const timeout = setTimeout(() => controller.abort(), TITLE_TIMEOUT_MS)
-
- try {
- const response = await fetch(normalizedUrl, {
- headers: {
- Accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
- 'Accept-Language': 'en-US,en;q=0.7',
- 'User-Agent': TITLE_USER_AGENT
- },
- redirect: 'follow',
- signal: controller.signal
- })
-
- if (!response.ok) {
- return ''
- }
-
- const contentType = response.headers.get('content-type')
-
- if (contentType && !/(?:html|xml|text\/html)/i.test(contentType)) {
- return ''
- }
-
- const html = await readResponseSnippet(response)
-
- return parseHtmlTitle(html).slice(0, TITLE_MAX_LENGTH)
- } catch {
- return ''
- } finally {
- clearTimeout(timeout)
- }
-}
-
-export function fetchLinkTitle(url: string): Promise {
- const normalizedUrl = normalizeExternalUrl(url)
- const key = titleCacheKey(normalizedUrl)
-
- if (!isTitleFetchable(normalizedUrl)) {
- return Promise.resolve('')
- }
-
- if (titleCache.has(key)) {
- return Promise.resolve(titleCache.get(key) ?? '')
- }
-
- const pending = titleInflight.get(key)
-
- if (pending) {
- return pending
- }
-
- const promise = fetchHtmlTitle(normalizedUrl)
- .then(usableTitle)
- .catch(() => '')
- .then(clean => {
- cacheTitle(key, clean)
- titleSubs.get(key)?.forEach(sub => sub(clean))
-
- return clean
- })
- .finally(() => {
- titleInflight.delete(key)
- })
-
- titleInflight.set(key, promise)
-
- return promise
-}
-
-export function useLinkTitle(url?: null | string): string {
- const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url])
- const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl])
- const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : ''))
-
- useEffect(() => {
- setTitle(key ? (titleCache.get(key) ?? '') : '')
-
- if (!key || !isTitleFetchable(normalizedUrl)) {
- return
- }
-
- const subs = titleSubs.get(key) ?? new Set<(value: string) => void>()
-
- subs.add(setTitle)
- titleSubs.set(key, subs)
- void fetchLinkTitle(normalizedUrl)
-
- return () => {
- subs.delete(setTitle)
-
- if (!subs.size) {
- titleSubs.delete(key)
- }
- }
- }, [key, normalizedUrl])
-
- return title
-}
-
-export function __resetLinkTitleCache(): void {
- titleCache.clear()
- titleInflight.clear()
- titleSubs.clear()
-}