/**
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
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 
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
}
}