diff --git a/components/citation-link.tsx b/components/citation-link.tsx index 9a244c358..8a419adab 100644 --- a/components/citation-link.tsx +++ b/components/citation-link.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import type { SearchResultItem } from '@/lib/types' import { cn } from '@/lib/utils' +import { isCitationLabel } from '@/lib/utils/citation' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { @@ -37,8 +38,7 @@ export const CitationLink = memo(function CitationLink({ }: CitationLinkProps) { const [open, setOpen] = useState(false) const childrenText = children?.toString() || '' - // Match domain names (alphanumeric and hyphens) or numbers for backward compatibility - const isCitation = /^[\w-]+$/.test(childrenText) + const isCitation = isCitationLabel(childrenText) const linkClasses = cn( isCitation diff --git a/components/custom-link.tsx b/components/custom-link.tsx index f6890e79c..05f428363 100644 --- a/components/custom-link.tsx +++ b/components/custom-link.tsx @@ -1,7 +1,7 @@ import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react' import type { SearchResultItem } from '@/lib/types' -import { cn } from '@/lib/utils' +import { isCitationLabel } from '@/lib/utils/citation' import { useCitation } from './citation-context' import { CitationLink } from './citation-link' @@ -19,8 +19,7 @@ export function Citing({ }: CustomLinkProps) { const { citationMaps } = useCitation() const childrenText = children?.toString() || '' - // Match domain names (alphanumeric and hyphens) or numbers for backward compatibility - const isCitation = /^[\w-]+$/.test(childrenText) + const isCitation = isCitationLabel(childrenText) // Get citation data if this is a citation let citationData: SearchResultItem | undefined = undefined diff --git a/lib/utils/__tests__/citation.test.ts b/lib/utils/__tests__/citation.test.ts index 9802a6772..e35406dd9 100644 --- a/lib/utils/__tests__/citation.test.ts +++ b/lib/utils/__tests__/citation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { SearchResultItem } from '@/lib/types' -import { processCitations } from '../citation' +import { isCitationLabel, processCitations } from '../citation' describe('processCitations', () => { const mockCitationMaps = { @@ -65,6 +65,30 @@ describe('processCitations', () => { ) }) + it('converts citations with dotted display labels', () => { + const citationMaps = { + toolCall1: { + 1: { + title: 'Global News', + url: 'https://topics.global.example.com/portal/news/page.html', + content: 'News article' + }, + 2: { + title: 'World Report', + url: 'https://articles.world.example.net/articles/-/123', + content: 'News article' + } + } as Record + } + + const content = 'Sources [1](#toolCall1) [2](#toolCall1)' + const result = processCitations(content, citationMaps) + + expect(result).toBe( + 'Sources [global.example](https://topics.global.example.com/portal/news/page.html) [world.example](https://articles.world.example.net/articles/-/123)' + ) + }) + it('returns empty string for invalid citation numbers', () => { const content = 'Invalid [999](#toolCall1) citation' const result = processCitations(content, mockCitationMaps) @@ -157,4 +181,20 @@ describe('processCitations', () => { // -1 doesn't match the regex pattern \d+, so it remains unchanged expect(result).toBe('Edge cases: [-1](#toolCall1)') }) + + describe('isCitationLabel', () => { + it('accepts numeric, simple domain, and dotted domain labels', () => { + expect(isCitationLabel('1')).toBe(true) + expect(isCitationLabel('youtube')).toBe(true) + expect(isCitationLabel('global.example')).toBe(true) + expect(isCitationLabel('world.example')).toBe(true) + }) + + it('rejects punctuation and whitespace outside the label', () => { + expect(isCitationLabel('')).toBe(false) + expect(isCitationLabel('global.example.')).toBe(false) + expect(isCitationLabel('.global.example')).toBe(false) + expect(isCitationLabel('global example')).toBe(false) + }) + }) }) diff --git a/lib/utils/citation.ts b/lib/utils/citation.ts index f46529b0e..11b74d3f2 100644 --- a/lib/utils/citation.ts +++ b/lib/utils/citation.ts @@ -14,6 +14,10 @@ function isValidUrl(url: string): boolean { } } +export function isCitationLabel(label: string): boolean { + return /^[\w-]+(?:\.[\w-]+)*$/.test(label) +} + /** * Extract citation maps from a message's tool parts * Returns a map of toolCallId to citation map