OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / markdown.js

278 lines · javascript ref:
/**
 * Server-side Markdown to HTML renderer
 *
 * Port of web/js/markdown.js for SSR. No DOM/window dependencies.
 * Handles: headings, bold, italic, code, links, images, lists,
 * blockquotes, hr, tables, wiki-links [[...]], gallery blocks, video
 *
 * @param {string} src - Markdown source text
 * @param {Object} opts
 * @param {string} [opts.notebookSlug] - Current notebook slug (for wiki-links and video URLs)
 * @param {string} [opts.pathPrefix] - Server path prefix (e.g., '/hypernote')
 * @returns {string} HTML string
 */
export function renderMarkdown (src, opts = {}) {
  if (!src) return ''

  const basePath = opts.pathPrefix || ''
  const currentNotebook = opts.notebookSlug || '_'

  function esc (s) {
    return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
  }

  function safeUrl (url) {
    var decoded = url
    try { decoded = decodeURIComponent(url) } catch (e) {}
    var trimmed = decoded.trim().toLowerCase()
    if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.startsWith('vbscript:')) return ''
    return url
  }

  var lines = src.split('\n')
  var html = []
  var inCode = false
  var inGallery = false
  var galleryImages = []
  var inList = null
  var inBlockquote = false
  var galleryCounter = 0

  for (var i = 0; i < lines.length; i++) {
    var line = lines[i]

    // Gallery blocks: ```gallery ... ```
    if (line.trimStart() === '```gallery') {
      closeList()
      closeBlockquote()
      inGallery = true
      galleryImages = []
      continue
    }
    if (inGallery) {
      if (line.trimStart() === '```') {
        var gid = 'gallery-' + (galleryCounter++)
        html.push('<div class="gallery-masonry" data-gallery="' + gid + '">')
        for (var gi = 0; gi < galleryImages.length; gi++) {
          var img = galleryImages[gi]
          html.push('<div class="gallery-item" data-gallery="' + gid + '" data-index="' + gi + '">')
          html.push('<img src="' + img.src + '" alt="' + esc(img.alt) + '" loading="lazy">')
          if (img.alt) html.push('<div class="gallery-caption">' + esc(img.alt) + '</div>')
          html.push('</div>')
        }
        html.push('</div>')
        inGallery = false
        galleryImages = []
        continue
      }
      var imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
      if (imgMatch) {
        var imgSrc = (imgMatch[2].startsWith('/api/') && basePath) ? basePath + imgMatch[2] : imgMatch[2]
        galleryImages.push({ alt: imgMatch[1], src: imgSrc })
      }
      continue
    }

    // Fenced code blocks
    if (line.trimStart().startsWith('```')) {
      if (inCode) {
        html.push('</code></pre>')
        inCode = false
      } else {
        closeList()
        closeBlockquote()
        html.push('<pre><code>')
        inCode = true
      }
      continue
    }
    if (inCode) {
      html.push(esc(line))
      if (i < lines.length - 1) html.push('\n')
      continue
    }

    // Blockquotes
    if (line.startsWith('> ')) {
      closeList()
      if (!inBlockquote) {
        html.push('<blockquote>')
        inBlockquote = true
      }
      html.push('<p>' + inline(line.substring(2)) + '</p>')
      continue
    } else if (inBlockquote) {
      closeBlockquote()
    }

    // Horizontal rule
    if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) {
      closeList()
      html.push('<hr>')
      continue
    }

    // Headings
    var headMatch = line.match(/^(#{1,6})\s+(.*)$/)
    if (headMatch) {
      closeList()
      var level = headMatch[1].length
      html.push('<h' + level + '>' + inline(headMatch[2]) + '</h' + level + '>')
      continue
    }

    // Unordered list
    if (/^[\s]*[-*]\s+/.test(line)) {
      if (inList !== 'ul') {
        closeList()
        html.push('<ul>')
        inList = 'ul'
      }
      html.push('<li>' + inline(line.replace(/^[\s]*[-*]\s+/, '')) + '</li>')
      continue
    }

    // Ordered list
    if (/^[\s]*\d+\.\s+/.test(line)) {
      if (inList !== 'ol') {
        closeList()
        html.push('<ol>')
        inList = 'ol'
      }
      html.push('<li>' + inline(line.replace(/^[\s]*\d+\.\s+/, '')) + '</li>')
      continue
    }

    closeList()

    // Empty line
    if (line.trim() === '') {
      continue
    }

    // Sanitized <video>/<audio> blocks: extract source URLs only and re-emit a
    // clean tag. Never pass raw author HTML through — that allowed event-handler
    // attributes (e.g. <video onloadstart=...>) to execute (stored XSS).
    if (/^\s*<(video|audio)[\s>]/.test(line)) {
      var tag = RegExp.$1
      var block = [line]
      while (i + 1 < lines.length && block.join('\n').indexOf('</' + tag + '>') === -1) {
        i++
        block.push(lines[i])
      }
      var raw = block.join('\n')
      var srcs = []
      var srcRe = /\bsrc\s*=\s*["']([^"']+)["']/gi
      var sm
      while ((sm = srcRe.exec(raw)) !== null) {
        var su = safeUrl(sm[1])
        if (su) srcs.push(su)
      }
      if (srcs.length) {
        var sources = srcs.map(function (u) { return '<source src="' + esc(u) + '">' }).join('')
        html.push('<' + tag + ' controls style="max-width:100%">' + sources + '</' + tag + '>')
      }
      continue
    }

    // Paragraph
    html.push('<p>' + inline(line) + '</p>')
  }

  if (inCode) html.push('</code></pre>')
  closeList()
  closeBlockquote()

  return html.join('\n')

  function closeList () {
    if (inList) {
      html.push('</' + inList + '>')
      inList = null
    }
  }

  function closeBlockquote () {
    if (inBlockquote) {
      html.push('</blockquote>')
      inBlockquote = false
    }
  }

  function inline (text) {
    text = esc(text)

    // Wiki-links [[notebook/title]] or [[title]]
    text = text.replace(/\[\[([^\]]+)\]\]/g, function (_, ref) {
      var parts = ref.trim()
      var slashIdx = parts.indexOf('/')
      var nb, title
      if (slashIdx > 0) {
        nb = parts.substring(0, slashIdx)
        title = parts.substring(slashIdx + 1)
      } else {
        nb = currentNotebook
        title = parts
      }
      var href = basePath + '/notebooks/' + encodeURIComponent(nb) + '?link=' + encodeURIComponent(title)
      return '<a class="wikilink" href="' + href + '">' + parts + '</a>'
    })

    // Images & Videos ![alt](url)
    var videoExts = /\.(mp4|webm|ogg|mov)$/i
    text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, url) {
      var src = (url.startsWith('/api/') && basePath) ? basePath + url : url
      src = safeUrl(src)
      var isVideo = videoExts.test(url) || alt === 'video'
      if (isVideo) {
        var hashMatch = url.match(/\/media\/([a-f0-9]{64})/)
        var mediaHash = hashMatch ? hashMatch[1] : null
        var streamUrl = mediaHash ? basePath + '/api/notebooks/' + currentNotebook + '/media/' + mediaHash + '/stream' : ''
        var statusUrl = mediaHash ? basePath + '/api/media/' + mediaHash + '/status' : ''

        return '<div class="hn-video-player" data-hash="' + (mediaHash || '') + '"'
          + ' data-stream="' + streamUrl + '"'
          + ' data-status="' + statusUrl + '"'
          + ' data-fallback="' + src + '"'
          + ' style="max-width:100%">'
          + '<video controls style="max-width:100%;background:#000" preload="metadata">'
          + '<source src="' + src + '">'
          + '</video>'
          + '</div>'
      }
      return '<img alt="' + alt + '" src="' + src + '">'
    })

    // Links [text](url) — url MUST go through safeUrl (esc() does not catch the
    // javascript:/data:/vbscript: scheme, so an unfiltered href is a stored XSS).
    text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, label, url) {
      return '<a href="' + safeUrl(url) + '">' + label + '</a>'
    })

    // Bold **text** or __text__
    text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
    text = text.replace(/__(.+?)__/g, '<strong>$1</strong>')

    // Italic *text* or _text_
    text = text.replace(/\*(.+?)\*/g, '<em>$1</em>')
    text = text.replace(/_(.+?)_/g, '<em>$1</em>')

    // Strikethrough ~~text~~
    text = text.replace(/~~(.+?)~~/g, '<del>$1</del>')

    // Inline code `code`
    text = text.replace(/`([^`]+)`/g, '<code>$1</code>')

    // Auto-link bare URLs
    text = text.replace(/(^|[^"=\/a-z])(https?:\/\/[^\s<)]+)/gi, function (m, pre, url, offset) {
      var before = text.substring(0, offset)
      var lastOpen = before.lastIndexOf('<')
      var lastClose = before.lastIndexOf('>')
      if (lastOpen > lastClose) return m
      return pre + '<a href="' + url + '">' + url + '</a>'
    })

    return text
  }
}