/**
* Simple Markdown to HTML renderer
* Handles: headings, bold, italic, code, links, images, lists,
* blockquotes, hr, tables, wiki-links [[...]], gallery blocks
*/
window.renderMarkdown = function (src) {
if (!src) return ''
// Escape HTML
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 // 'ul' or 'ol'
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() === '```') {
// Close gallery — emit masonry grid
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
}
// Parse image lines inside gallery
var imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
if (imgMatch) {
var bp = (window.HnApi && window.HnApi.basePath) || ''
var imgSrc = (imgMatch[2].startsWith('/api/') && bp) ? bp + imgMatch[2] : imgMatch[2]
galleryImages.push({ alt: imgMatch[1], src: imgSrc })
}
// Skip non-image lines inside gallery (blank lines, etc.)
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
}
// Pass through HTML media tags (video, audio) without escaping
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])
}
html.push(block.join('\n'))
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
}
}
// Inline formatting
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 = null
title = parts
}
var href = nb ? '#/' + nb + '?link=' + encodeURIComponent(title) : '#?link=' + encodeURIComponent(title)
return '<a class="wikilink" href="' + href + '">' + parts + '</a>'
})
// Images & Videos  — rewrite /api/ paths with basePath for proxy support
// Video extensions render as <video>, everything else as <img>
// Also detect video by alt text hint () for content-addressed URLs without extensions
var bp = (window.HnApi && window.HnApi.basePath) || ''
var videoExts = /\.(mp4|webm|ogg|mov)$/i
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_, alt, url) {
var src = (url.startsWith('/api/') && bp) ? bp + url : url
src = safeUrl(src)
var isVideo = videoExts.test(url) || alt === 'video'
if (isVideo) {
// Extract hash from URL for HLS stream lookup
var hashMatch = url.match(/\/media\/([a-f0-9]{64})/)
var mediaHash = hashMatch ? hashMatch[1] : null
var streamUrl = mediaHash ? ((window.HnApi && window.HnApi.basePath) || '') + '/api/notebooks/' + (window._hnCurrentNotebook || '_') + '/media/' + mediaHash + '/stream' : ''
var statusUrl = mediaHash ? ((window.HnApi && window.HnApi.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)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</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 — skip if already inside an HTML tag
text = text.replace(/(^|[^"=\/a-z])(https?:\/\/[^\s<)]+)/gi, function (m, pre, url, offset) {
// Check if we're inside an HTML tag by looking for unclosed < before this position
var before = text.substring(0, offset)
var lastOpen = before.lastIndexOf('<')
var lastClose = before.lastIndexOf('>')
if (lastOpen > lastClose) return m // inside a tag
return pre + '<a href="' + url + '">' + url + '</a>'
})
return text
}
}