Newer
Older
navi-1 / webclient / src / composables / useMarkdown.js
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import hljs from 'highlight.js'
import { useLightbox } from '@/composables/useLightbox.js'

// Configure marked once
marked.setOptions({ breaks: true, gfm: true })

// Custom renderer to add code block header + copy button
const renderer = new marked.Renderer()
renderer.code = function({ text, lang }) {
  const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'
  const highlighted = hljs.highlight(text, { language }).value

  return `<div class="code-block"><div class="code-header"><span>${language}</span><button class="btn-icon copy-btn" data-code="${encodeURIComponent(text)}" title="Copy"><i class="ph ph-copy"></i></button></div><pre><code class="hljs language-${language}">${highlighted}</code></pre></div>`
}

// Only these URL schemes may end up in image src/href — anything else
// (javascript:, vbscript:, //host, …) is rendered as a broken placeholder.
// DOMPurify strips script-y attributes, but blocking the scheme at the
// renderer keeps the markup honest instead of relying on sanitization.
function safeImageUrl(href) {
  const raw = String(href ?? '').trim()
  if (/^data:image\//i.test(raw)) return raw
  if (/^https?:\/\//i.test(raw)) return raw
  if (raw.startsWith('/') && !raw.startsWith('//')) return raw
  return ''
}

renderer.image = function({ href, title, text }) {
  const safeHref = safeImageUrl(href)
  const safeText = String(text ?? '').replace(/"/g, '&quot;')
  if (!safeHref) {
    return `<span class="msg-md-image-link is-broken" title="${safeText}"></span>`
  }
  // Broken-image state is applied by a delegated 'error' listener in
  // attachImageLightbox — inline onerror would be stripped by DOMPurify.
  return `<a class="msg-md-image-link" href="${safeHref}" data-lightbox="${safeHref}" target="_blank" rel="noopener noreferrer"><img src="${safeHref}" alt="${safeText}" class="msg-md-image" loading="lazy" /></a>`
}

marked.use({ renderer })

// Fix malformed GFM tables:
// 1. Missing separator row — inject one between header and first data row.
// 2. Mixed separator/data row — inject proper separator before it.
// 3. Spurious all-separator rows inside the table body (model uses them as
//    visual dividers) — strip them once the real header separator has been seen.
function fixTables(text) {
  const lines = text.split('\n')
  const out = []
  const isPipeRow = l => l.trim().startsWith('|') && l.includes('|', 1)
  const isSepCell = c => /^:?-+:?$/.test(c.trim())
  const colCount = l => l.split('|').slice(1, -1).length

  let seenSep = false  // have we emitted the header separator for the current table?

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    const prev = out[out.length - 1] ?? ''

    if (!isPipeRow(line)) {
      // Leaving table context
      seenSep = false
      out.push(line)
      continue
    }

    const cells = line.split('|').slice(1, -1)
    const allSep = cells.every(isSepCell)
    const noneSep = cells.every(c => !isSepCell(c))

    if (allSep) {
      if (!seenSep) {
        // First separator row — keep it as the header separator
        seenSep = true
        out.push(line)
      }
      // Subsequent all-separator rows are spurious visual dividers — drop them
      continue
    }

    if (isPipeRow(prev)) {
      if (!seenSep) {
        if (!noneSep) {
          // Mixed row: mangled separator — inject a clean one, treat this line as data
          out.push('| ' + Array(colCount(prev)).fill('---').join(' | ') + ' |')
          seenSep = true
        } else if (i + 1 < lines.length && isPipeRow(lines[i + 1])) {
          // Separator row is missing entirely — inject it
          out.push('| ' + Array(colCount(prev)).fill('---').join(' | ') + ' |')
          seenSep = true
        }
      }
    }

    out.push(line)
  }
  return out.join('\n')
}

const LATEX_SYMBOLS = [
  // Arrows
  [/\$\\Leftrightarrow\$/g, '⇔'],
  [/\$\\leftrightarrow\$/g, '↔'],
  [/\$\\Rightarrow\$/g, '⇒'],
  [/\$\\Leftarrow\$/g, '⇐'],
  [/\$\\rightarrow\$/g, '→'],
  [/\$\\leftarrow\$/g, '←'],
  [/\$\\to\$/g, '→'],
  [/\$\\gets\$/g, '←'],
  [/\$\\uparrow\$/g, '↑'],
  [/\$\\downarrow\$/g, '↓'],
  [/\$\\nearrow\$/g, '↗'],
  [/\$\\searrow\$/g, '↘'],
  // Math
  [/\$\\approx\$/g, '≈'],
  [/\$\\neq\$/g, '≠'],
  [/\$\\leq\$/g, '≤'],
  [/\$\\geq\$/g, '≥'],
  [/\$\\times\$/g, '×'],
  [/\$\\div\$/g, '÷'],
  [/\$\\pm\$/g, '±'],
  [/\$\\infty\$/g, '∞'],
  [/\$\\cdot\$/g, '·'],
  [/\$\\ldots\$/g, '…'],
  [/\$\\in\$/g, '∈'],
  [/\$\\notin\$/g, '∉'],
  [/\$\\subset\$/g, '⊂'],
  [/\$\\cup\$/g, '∪'],
  [/\$\\cap\$/g, '∩'],
  [/\$\\forall\$/g, '∀'],
  [/\$\\exists\$/g, '∃'],
  [/\$\\neg\$/g, '¬'],
  [/\$\\land\$/g, '∧'],
  [/\$\\lor\$/g, '∨'],
]

function fixLatexSymbols(text) {
  for (const [re, sym] of LATEX_SYMBOLS) text = text.replace(re, sym)
  return text
}

export function renderMarkdown(text) {
  const html = marked.parse(fixTables(fixLatexSymbols(text ?? '')))
  // Wrap tables in a scrollable container for mobile (matches <table> with or without attributes)
  const wrapped = html
    .replace(/<table(\s[^>]*)?>/g, '<div class="table-wrap"><table$1>')
    .replace(/<\/table>/g, '</table></div>')
  // Agent output and tool results are untrusted content (they can carry text
  // scraped from files or the web) — sanitize before it reaches v-html.
  // DOMPurify's defaults keep everything marked/highlight.js produce
  // (data-* attributes, class, button, table, input task-lists) and strip
  // script tags, on* handlers and javascript:/data:text/html URLs.
  return DOMPurify.sanitize(wrapped)
}

// Attach copy button listeners after v-html renders
export function attachCopyButtons(el) {
  el?.querySelectorAll('.copy-btn').forEach(btn => {
    btn.onclick = () => {
      const code = decodeURIComponent(btn.dataset.code ?? '')
      const done = () => {
        btn.innerHTML = '<i class="ph ph-check"></i>'
        setTimeout(() => { btn.innerHTML = '<i class="ph ph-copy"></i>' }, 1500)
      }
      if (navigator.clipboard?.writeText) {
        navigator.clipboard.writeText(code).then(done)
      } else {
        const el = document.createElement('textarea')
        el.value = code
        el.style.cssText = 'position:fixed;opacity:0'
        document.body.appendChild(el)
        el.select()
        document.execCommand('copy')
        document.body.removeChild(el)
        done()
      }
    }
  })
}

const { open: openLightbox } = useLightbox()

// Attach lightbox click listeners to markdown images after v-html renders
export function attachImageLightbox(el) {
  el?.querySelectorAll('img.msg-md-image').forEach(img => {
    // Delegated broken-image marker — replaces the inline onerror that
    // DOMPurify (correctly) strips.
    img.addEventListener('error', () => {
      img.classList.add('is-broken')
      img.closest('.msg-md-image-link')?.classList.add('is-broken')
    })
  })
  el?.querySelectorAll('a.msg-md-image-link').forEach(link => {
    link.addEventListener('click', (e) => {
      if (link.classList.contains('is-broken')) return
      e.preventDefault()
      const src = link.dataset.lightbox || link.getAttribute('href')
      if (src) openLightbox(src)
    })
  })
}