function escapeHtml(text) { return String(text || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapeRegex(text) { return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** * Build HTML for essay with inline grammar (rose) and vocabulary (amber) marks. */ export function buildAnnotatedEssayHtml(essay, grammarErrors = [], weakWords = []) { const source = String(essay || '') if (!source) return '' const marks = [] const pushMarks = (phrase, type, meta, priority) => { const trimmed = String(phrase || '').trim() if (trimmed.length < 2) return const escaped = escapeRegex(trimmed) const re = /\s/.test(trimmed) ? new RegExp(escaped, 'gi') : new RegExp(`\\b${escaped}\\b`, 'gi') let match while ((match = re.exec(source)) !== null) { marks.push({ start: match.index, end: match.index + match[0].length, text: match[0], type, meta, priority, }) } } for (const err of grammarErrors) { pushMarks(err?.original, 'grammar', err, 2) } for (const w of weakWords) { pushMarks(w?.word, 'vocab', w, 1) } if (!marks.length) { return escapeHtml(source).replace(/\n/g, '
') } marks.sort((a, b) => a.start - b.start || b.priority - a.priority || (b.end - b.start) - (a.end - a.start)) const merged = [] let cursor = 0 for (const m of marks) { if (m.start < cursor) continue merged.push(m) cursor = m.end } let html = '' let pos = 0 for (const m of merged) { html += escapeHtml(source.slice(pos, m.start)) const title = m.type === 'grammar' ? `Sửa: ${m.meta?.correction || ''}${m.meta?.rule ? ` — ${m.meta.rule}` : ''}` : `Gợi ý: ${m.meta?.better || ''}${m.meta?.reason ? ` — ${m.meta.reason}` : ''}` const cls = m.type === 'grammar' ? 'writing-mark writing-mark--grammar' : 'writing-mark writing-mark--vocab' html += `${escapeHtml(m.text)}` pos = m.end } html += escapeHtml(source.slice(pos)) return html.replace(/\n/g, '
') }