OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders / web / js / markdown.js

139 lines · javascript ref:
// markdown.js — a small, SAFE markdown renderer for user-generated post and
// comment bodies. Everything is HTML-escaped first; we never emit raw user
// HTML. Only http(s)://, hyper://, pear:// and #/ (in-app) links are allowed.

import { escapeHtml } from './util.js'

const SAFE_LINK = /^(https?:\/\/|hyper:\/\/|pear:\/\/|#\/|\/)/i

function safeHref (url) {
  const u = String(url || '').trim()
  if (SAFE_LINK.test(u)) return u
  return null
}

// Inline: code spans, bold, italic, strikethrough, explicit links, autolinks.
function inline (text) {
  let s = escapeHtml(text)

  // `code`
  s = s.replace(/`([^`]+)`/g, (_, c) => '<code>' + c + '</code>')

  // [label](url)
  s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
    const href = safeHref(url)
    if (!href) return label
    const ext = /^https?:/i.test(href)
    return '<a href="' + href + '"' + (ext ? ' target="_blank" rel="noopener noreferrer nofollow"' : '') + '>' + label + '</a>'
  })

  // bare autolinks (avoid touching ones already inside an href="...")
  s = s.replace(/(^|[\s(])((?:https?|hyper|pear):\/\/[^\s<)]+)/g, (m, pre, url) => {
    const href = safeHref(url)
    if (!href) return m
    const ext = /^https?:/i.test(href)
    return pre + '<a href="' + href + '"' + (ext ? ' target="_blank" rel="noopener noreferrer nofollow"' : '') + '>' + url + '</a>'
  })

  // **bold**, *italic*/_italic_, ~~strike~~
  s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
  s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
  s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>')
  s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>')

  return s
}

export function renderMarkdown (src) {
  const text = String(src == null ? '' : src).replace(/\r\n?/g, '\n')
  const lines = text.split('\n')
  const out = []
  let i = 0

  const flushParagraph = (buf) => {
    if (buf.length) out.push('<p>' + inline(buf.join('\n')).replace(/\n/g, '<br>') + '</p>')
    buf.length = 0
  }
  const para = []

  while (i < lines.length) {
    const line = lines[i]

    // fenced code block ```
    if (/^```/.test(line)) {
      flushParagraph(para)
      const code = []
      i++
      while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++ }
      i++ // skip closing fence
      out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>')
      continue
    }

    // headings #..######
    const h = /^(#{1,6})\s+(.*)$/.exec(line)
    if (h) {
      flushParagraph(para)
      const lvl = h[1].length
      out.push('<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>')
      i++; continue
    }

    // horizontal rule
    if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
      flushParagraph(para)
      out.push('<hr>')
      i++; continue
    }

    // blockquote (collapse consecutive > lines)
    if (/^>\s?/.test(line)) {
      flushParagraph(para)
      const quote = []
      while (i < lines.length && /^>\s?/.test(lines[i])) { quote.push(lines[i].replace(/^>\s?/, '')); i++ }
      out.push('<blockquote>' + renderMarkdown(quote.join('\n')) + '</blockquote>')
      continue
    }

    // unordered list
    if (/^\s*[-*+]\s+/.test(line)) {
      flushParagraph(para)
      const items = []
      while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
        items.push('<li>' + inline(lines[i].replace(/^\s*[-*+]\s+/, '')) + '</li>'); i++
      }
      out.push('<ul>' + items.join('') + '</ul>')
      continue
    }

    // ordered list
    if (/^\s*\d+\.\s+/.test(line)) {
      flushParagraph(para)
      const items = []
      while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
        items.push('<li>' + inline(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>'); i++
      }
      out.push('<ol>' + items.join('') + '</ol>')
      continue
    }

    // blank line ends a paragraph
    if (/^\s*$/.test(line)) { flushParagraph(para); i++; continue }

    para.push(line)
    i++
  }
  flushParagraph(para)
  return out.join('\n')
}

// Plain-text excerpt (for feed previews / meta descriptions).
export function excerpt (src, n = 220) {
  const s = String(src || '')
    .replace(/[`*_~#>\-]/g, '')
    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
    .replace(/\s+/g, ' ')
    .trim()
  return s.length > n ? s.slice(0, n).trimEnd() + '…' : s
}