'use client';
import React, { useMemo } from 'react';
import Link from 'next/link';
import { useLocale } from 'next-intl';
import { parseMentions, hasMentions, type ParseMentionOptions } from '@/lib/mentions/mentionParser';
import { resolveMentions, segmentTextWithMentions, type ResolvedMention } from '@/lib/mentions/mentionResolver';
/**
* Props for the MentionRenderer component
*/
interface MentionRendererProps {
/** Text content that may contain @mentions */
text: string;
/** Optional className for the wrapper */
className?: string;
/** Current session for bill URLs (default: '45-1') */
session?: string;
/** Whether to open links in a new tab */
newTab?: boolean;
/** Custom render function for text segments (for markdown support) */
renderText?: (text: string, key: string) => React.ReactNode;
/** Enable natural language pattern detection (e.g., "Bill C-234") */
naturalLanguage?: boolean;
/** Use subtle styling for inline links (no background, just colored text) */
subtleLinks?: boolean;
}
/**
* Color classes for subtle link styling (inline text, no background)
*/
const SUBTLE_TYPE_COLORS: Record<string, string> = {
bill: 'text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300',
mp: 'text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300',
committee: 'text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300',
vote: 'text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300',
debate: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300',
petition: 'text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300',
user: 'text-pink-600 dark:text-pink-400 hover:text-pink-800 dark:hover:text-pink-300',
'standing-order': 'text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300',
};
/**
* Single mention link component
*/
const MentionLink: React.FC<{
mention: ResolvedMention;
newTab?: boolean;
subtle?: boolean;
}> = ({ mention, newTab, subtle = false }) => {
// External links (like Standing Orders) always open in new tab
const shouldOpenNewTab = newTab || mention.isExternal;
const linkProps = shouldOpenNewTab
? { target: '_blank' as const, rel: 'noopener noreferrer' }
: {};
// Use displayText for natural language mentions, otherwise use label
const displayText = mention.displayText || mention.label;
if (subtle) {
// Subtle styling: no background, just colored text with underline on hover
return (
<Link
href={mention.url}
className={`
font-medium no-underline hover:underline
transition-colors duration-150
${SUBTLE_TYPE_COLORS[mention.type] || 'text-blue-600 dark:text-blue-400'}
`}
title={`View ${mention.label}`}
{...linkProps}
>
{displayText}
</Link>
);
}
return (
<Link
href={mention.url}
className={`
inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded
font-medium text-sm no-underline
transition-colors duration-150
hover:opacity-80
${mention.colorClass}
`}
title={`View ${mention.label}`}
{...linkProps}
>
{displayText}
</Link>
);
};
/**
* MentionRenderer - Renders text with @mentions as clickable links
*
* Parses text for @mention syntax and renders them as styled, navigable links.
* Supports all entity types: bills, MPs, committees, votes, debates, petitions.
*
* @example
* ```tsx
* // Explicit @mention syntax
* <MentionRenderer text="Check out @bill:c-234 and @mp:pierre-poilievre" />
*
* // Natural language patterns (for Hansard content)
* <MentionRenderer text="Bill C-234 was discussed" naturalLanguage subtleLinks />
* ```
*/
export const MentionRenderer: React.FC<MentionRendererProps> = ({
text,
className = '',
session = '45-1',
newTab = false,
renderText,
naturalLanguage = false,
subtleLinks = false,
}) => {
const locale = useLocale();
// Build parse options
const parseOptions: ParseMentionOptions = {
naturalLanguage,
locale: locale as 'en' | 'fr',
};
// Memoize the parsing and resolution
const segments = useMemo(() => {
// Quick check if there are any mentions
if (!hasMentions(text, parseOptions)) {
return null;
}
// Parse mentions from text
const parsed = parseMentions(text, parseOptions);
if (parsed.length === 0) {
return null;
}
// Resolve mentions to URLs and metadata
const resolved = resolveMentions(parsed, locale, { session });
// Segment text for rendering
return segmentTextWithMentions(text, resolved);
}, [text, locale, session, naturalLanguage]);
// If no mentions, render text as-is
if (!segments) {
if (renderText) {
return <>{renderText(text, 'text-only')}</>;
}
return <span className={className}>{text}</span>;
}
// Render segmented text with mention links
return (
<span className={className}>
{segments.map((segment, index) => {
if (segment.type === 'text') {
if (renderText) {
return renderText(segment.content, `text-${index}`);
}
return <span key={`text-${index}`}>{segment.content}</span>;
}
// Mention segment
return (
<MentionLink
key={`mention-${index}-${segment.mention.raw}`}
mention={segment.mention}
newTab={newTab}
subtle={subtleLinks}
/>
);
})}
</span>
);
};
/**
* Hook for rendering text with mentions
* Useful when you need more control over the rendering process
*/
export function useMentionRenderer(
text: string,
options: { locale?: string; session?: string } = {}
) {
const locale = options.locale || 'en';
const session = options.session || '45-1';
return useMemo(() => {
if (!hasMentions(text)) {
return { hasMentions: false, segments: null, mentionCount: 0 };
}
const parsed = parseMentions(text);
if (parsed.length === 0) {
return { hasMentions: false, segments: null, mentionCount: 0 };
}
const resolved = resolveMentions(parsed, locale, { session });
const segments = segmentTextWithMentions(text, resolved);
return {
hasMentions: true,
segments,
mentionCount: parsed.length,
mentions: resolved,
};
}, [text, locale, session]);
}
export default MentionRenderer;