/**
* Build safe HTML from plain transcript with non-overlapping span highlights.
* @param {string} text
* @param {Array<{ text: string, className: string, title?: string }>} spans
*/
export function highlightTranscript(text, spans = []) {
const raw = String(text || '')
if (!raw) return ''
const escape = (s) =>
String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
const ranges = []
for (const span of spans) {
const needle = String(span.text || '').trim()
if (!needle) continue
let from = 0
while (from < raw.length) {
const idx = raw.indexOf(needle, from)
if (idx === -1) break
ranges.push({
start: idx,
end: idx + needle.length,
className: span.className,
title: span.title || '',
})
from = idx + needle.length
}
}
if (!ranges.length) return escape(raw)
ranges.sort((a, b) => a.start - b.start || b.end - a.end)
const merged = []
for (const r of ranges) {
const last = merged[merged.length - 1]
if (!last || r.start >= last.end) {
merged.push({ ...r })
continue
}
if (r.end > last.end) last.end = r.end
}
let html = ''
let cursor = 0
for (const r of merged) {
if (r.start > cursor) html += escape(raw.slice(cursor, r.start))
const titleAttr = r.title ? ` title="${escape(r.title)}"` : ''
html += `${escape(raw.slice(r.start, r.end))}`
cursor = r.end
}
if (cursor < raw.length) html += escape(raw.slice(cursor))
return html
}