import { decodeHtmlEntities } from '~/utils/formatters'
interface UseMarkdownOptions {
text: string
/** When true, renders link text without the anchor tag (useful when inside another link) */
plain?: boolean
/** Package name to strip from the beginning of the description (if present) */
packageName?: string
}
/** @public */
export function useMarkdown(options: MaybeRefOrGetter) {
return computed(() => parseMarkdown(toValue(options)))
}
// Strip markdown image badges from text
function stripMarkdownImages(text: string): string {
// Remove linked images: [](link-url) - handles incomplete URLs too
// Using {0,500} instead of * to prevent ReDoS on pathological inputs
text = text.replace(/\[!\[[^\]]{0,500}\]\([^)]{0,2000}\)\]\([^)]{0,2000}\)?/g, '')
// Remove standalone images: 
text = text.replace(/!\[[^\]]{0,500}\]\([^)]{0,2000}\)/g, '')
// Remove any leftover empty links or broken markdown link syntax
text = text.replace(/\[\]\([^)]{0,2000}\)?/g, '')
return text.trim()
}
// Strip HTML tags and escape remaining HTML to prevent XSS
function stripAndEscapeHtml(text: string, packageName?: string): string {
// First decode any HTML entities in the input
let stripped = decodeHtmlEntities(text)
// Then strip markdown image badges
stripped = stripMarkdownImages(stripped)
// Then strip actual HTML tags (keep their text content)
// Only match tags that start with a letter or / (to avoid matching things like "a < b > c")
stripped = stripped.replace(/<\/?[a-z][^>]*>/gi, '')
// Strip HTML comments: (including unclosed comments from truncation)
stripped = stripped.replace(/|$)/g, '')
if (packageName) {
// Trim first to handle leading/trailing whitespace from stripped HTML
stripped = stripped.trim()
// Collapse multiple whitespace into single space
stripped = stripped.replace(/\s+/g, ' ')
// Escape special regex characters in package name
const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Match package name at the start, optionally followed by: space, dash, colon, hyphen, or just space
const namePattern = new RegExp(`^${escapedName}\\s*[-:—]?\\s*`, 'i')
stripped = stripped.replace(namePattern, '').trim()
}
// Then escape any remaining HTML entities
return stripped
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// Parse simple inline markdown to HTML
function parseMarkdown({ text, packageName, plain }: UseMarkdownOptions): string {
if (!text) return ''
// First strip HTML tags and escape remaining HTML
let html = stripAndEscapeHtml(text, packageName)
// Bold: **text** or __text__
html = html.replace(/\*\*(.+?)\*\*/g, '$1')
html = html.replace(/__(.+?)__/g, '$1')
// Italic: *text* or _text_
html = html.replace(/(?$1')
html = html.replace(/\b_(.+?)_\b/g, '$1')
// Inline code: `code`
html = html.replace(/`([^`]+)`/g, '$1')
// Strikethrough: ~~text~~
html = html.replace(/~~(.+?)~~/g, '$1')
// Links: [text](url) - only allow https, mailto
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
// In plain mode, just render the link text without the anchor
if (plain) {
return text
}
const decodedUrl = url.replace(/&/g, '&')
try {
const { protocol, href } = new URL(decodedUrl)
if (['https:', 'mailto:'].includes(protocol)) {
const safeUrl = href.replace(/"/g, '"')
return `${text}`
}
} catch {}
return `${text} (${url})`
})
return html
}