/** * Utilities for rendering and processing markdown content */ /** * @import MarkdownIt from 'markdown-it' */ import markdownit from 'markdown-it' import markdownItContainer from 'markdown-it-container' // highlight.js is loaded separately via highlight.js bundle // Access it from window.hljs (set by app/web/highlight.js) const hljs = typeof window !== 'undefined' ? window.hljs : null /** * Admonition type configuration * Maps both container types (:::info) and GFM alert types ([!NOTE]) to styling */ const admonitionConfig = { info: { icon: 'info-circle', colorVar: 'accent', gfmType: 'NOTE' }, tip: { icon: 'lightbulb', colorVar: 'accent', gfmType: 'TIP' }, warning: { icon: 'exclamation-triangle', colorVar: 'attention', gfmType: 'WARNING' }, important: { icon: 'exclamation-circle', colorVar: 'danger', gfmType: 'IMPORTANT' }, caution: { icon: 'exclamation-octagon', colorVar: 'danger', gfmType: 'CAUTION' }, success: { icon: 'check-circle', colorVar: 'success', gfmType: null }, danger: { icon: 'x-octagon', colorVar: 'danger', gfmType: null }, note: { icon: 'info-circle', colorVar: 'accent', gfmType: 'NOTE' } } /** * Renders opening tag for an admonition block * @param {string} type - The admonition type (info, warning, etc.) * @returns {string} Opening HTML */ function renderAdmonitionOpen(type) { const config = admonitionConfig[type] || admonitionConfig.info return `
' +
hljs.highlight(str, { language: lang }).value +
''
} catch (error) {
console.warn(`Failed to highlight ${lang}:`, error)
}
}
// Fallback to plain code block without highlighting
return '' + markdownit().utils.escapeHtml(str) + ''
}
}
const md = markdownit(options)
// Add support for container-style admonitions (:::info, :::warning, etc.)
Object.keys(admonitionConfig).forEach(type => {
md.use(markdownItContainer, type, {
render: (tokens, idx) => {
if (tokens[idx].nesting === 1) {
// Opening tag
return renderAdmonitionOpen(type)
} else {
// Closing tag
return renderAdmonitionClose()
}
}
})
})
// Add support for GitHub-style alerts (> [!NOTE])
const defaultBlockquoteRender = md.renderer.rules.blockquote_open || function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.blockquote_open = (tokens, idx, options, _env, self) => {
// Check if next token is a paragraph containing a GFM alert marker
const nextToken = tokens[idx + 1]
if (nextToken && nextToken.type === 'paragraph_open') {
const contentToken = tokens[idx + 2]
if (contentToken && contentToken.type === 'inline') {
const alertMatch = contentToken.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/)
if (alertMatch) {
const gfmType = alertMatch[1]
// Find matching admonition type
const type = Object.keys(admonitionConfig).find(
key => admonitionConfig[key].gfmType === gfmType
) || 'info'
// Remove the alert marker from content
contentToken.content = contentToken.content.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/, '')
// Mark this blockquote as an alert for closing
tokens[idx].attrSet('data-alert-type', type)
return renderAdmonitionOpen(type)
}
}
}
return defaultBlockquoteRender(tokens, idx, options, _env, self)
}
const defaultBlockquoteCloseRender = md.renderer.rules.blockquote_close || function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.blockquote_close = (tokens, idx, options, _env, self) => {
// Check if this is closing an alert blockquote
const openToken = tokens.slice(0, idx).reverse().find(t => t.type === 'blockquote_open')
if (openToken && openToken.attrGet('data-alert-type')) {
return renderAdmonitionClose()
}
return defaultBlockquoteCloseRender(tokens, idx, options, _env, self)
}
// Add automatic heading IDs for TOC links
const defaultHeadingRender = md.renderer.rules.heading_open || function(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.heading_open = (tokens, idx, options, _env, self) => {
const token = tokens[idx]
const contentToken = tokens[idx + 1]
if (contentToken && contentToken.type === 'inline') {
const text = contentToken.content
// Generate anchor from heading text
const anchor = text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
token.attrSet('id', anchor)
}
return defaultHeadingRender(tokens, idx, options, _env, self)
}
return md
}
/**
* Fetches markdown content from a URL
* @param {string} url - URL to fetch markdown from
* @param {boolean} enableCache - Whether to enable browser caching
* @returns {PromiseNo headings found
' } // Build nested list let html = '' return html } /** * Renders markdown to HTML with standard processing * @param {MarkdownIt} md - Markdown renderer instance * @param {string} markdown - Markdown content to render * @param {object} options - Processing options (see processMarkdownHtml) * @returns {string} Processed HTML */ export function renderMarkdown(md, markdown, options) { // Replace TOC placeholder with generated table of contents (case-insensitive) const TOC_REGEX = /^(\[TOC\]|\[toc])$/m let processedMarkdown = markdown if (TOC_REGEX.test(markdown)) { const toc = generateTOC(markdown) processedMarkdown = markdown.replace(TOC_REGEX, toc) } const html = md.render(processedMarkdown) return processMarkdownHtml(html, options) }