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('<html><head><title>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(/&#x([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() -}