philverify-api / frontend /src /components /WordHighlighter.jsx
Ryan Christian D. Deniega
fix: cold start 502, favicon, verify state persistence
b1c84b5
/**
* WordHighlighter — Phase 8: Suspicious Word Highlighter
* Highlights suspicious / clickbait trigger words in the claim text.
* Uses triggered_features from Layer 1 as hint words.
*
* architect-review: pure presentational, no side-effects.
* web-design-guidelines: uses <mark> with visible styles, screen-reader friendly.
*/
// Common suspicious/misinformation signal words to highlight
const SUSPICIOUS_PATTERNS = [
// English signals
/\b(shocking|exposed|revealed|secret|hoax|fake|false|confirmed|breaking|urgent|emergency|exclusive|banned|cover[\s-]?up|conspiracy|miracle|crisis|scandal|leaked|hidden|truth|they don't want you to know)\b/gi,
// Filipino signals
/\b(grabe|nakakagulat|totoo|peke|huwag maniwala|nagsisinungaling|lihim|inilabas|natuklasan|katotohanan|panlilinlang|kahirap-hirap|itinatago)\b/gi,
]
function getHighlightedSegments(text, triggerWords = []) {
if (!text) return []
// Build a combined pattern from both static patterns + dynamic trigger words
const allPatterns = [...SUSPICIOUS_PATTERNS]
if (triggerWords.length > 0) {
const escaped = triggerWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
allPatterns.push(new RegExp(`\\b(${escaped.join('|')})\\b`, 'gi'))
}
// Find all match intervals
const matches = []
for (const pattern of allPatterns) {
pattern.lastIndex = 0
let m
while ((m = pattern.exec(text)) !== null) {
matches.push({ start: m.index, end: m.index + m[0].length, word: m[0] })
}
}
if (matches.length === 0) return [{ text, highlighted: false }]
// Sort + merge overlapping intervals
matches.sort((a, b) => a.start - b.start)
const merged = []
for (const m of matches) {
const last = merged[merged.length - 1]
if (last && m.start <= last.end) {
last.end = Math.max(last.end, m.end)
} else {
merged.push({ ...m })
}
}
// Build segments
const segments = []
let cursor = 0
for (const { start, end, word } of merged) {
if (cursor < start) segments.push({ text: text.slice(cursor, start), highlighted: false })
segments.push({ text: text.slice(start, end), highlighted: true, word })
cursor = end
}
if (cursor < text.length) segments.push({ text: text.slice(cursor), highlighted: false })
return segments
}
export default function WordHighlighter({ text = '', triggerWords = [], className = '' }) {
const segments = getHighlightedSegments(text, triggerWords)
const hitCount = segments.filter(s => s.highlighted).length
if (segments.length === 1 && !segments[0].highlighted) {
// No suspicious words found
return (
<p className={className}
style={{ fontFamily: 'var(--font-body)', lineHeight: 1.7, color: 'var(--text-primary)' }}>
{text}
</p>
)
}
return (
<div>
{hitCount > 0 && (
<p className="text-xs mb-2"
style={{
color: 'var(--accent-gold)',
fontFamily: 'var(--font-display)',
letterSpacing: '0.08em',
}}
aria-live="polite">
⚠ {hitCount} suspicious signal{hitCount !== 1 ? 's' : ''} detected
</p>
)}
<p className={className} style={{ fontFamily: 'var(--font-body)', lineHeight: 1.7, color: 'var(--text-primary)' }}>
{segments.map((seg, i) =>
seg.highlighted ? (
<mark key={i}
title={`Suspicious signal: "${seg.word}"`}
style={{
background: 'rgba(220, 38, 38, 0.18)',
color: '#f87171',
borderRadius: 2,
padding: '0 2px',
fontWeight: 600,
outline: '1px solid rgba(220,38,38,0.3)',
}}>
{seg.text}
</mark>
) : (
<span key={i}>{seg.text}</span>
)
)}
</p>
</div>
)
}