diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx index d318250b..f826987c 100644 --- a/src/components/markdown.tsx +++ b/src/components/markdown.tsx @@ -1,5 +1,5 @@ import { createEffect, createSignal, onMount, onCleanup } from "solid-js" -import { renderMarkdown, onLanguagesLoaded, initMarkdown } from "../lib/markdown" +import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" import type { TextPart } from "../types/message" interface MarkdownProps { @@ -15,7 +15,8 @@ export function Markdown(props: MarkdownProps) { createEffect(async () => { const part = props.part - const text = part.text || "" + const rawText = typeof part.text === "string" ? part.text : "" + const text = decodeHtmlEntities(rawText) const dark = Boolean(props.isDark) const themeKey = dark ? "dark" : "light" @@ -72,7 +73,8 @@ export function Markdown(props: MarkdownProps) { // Register listener for language loading completion const cleanupLanguageListener = onLanguagesLoaded(async () => { const part = props.part - const text = part.text || "" + const rawText = typeof part.text === "string" ? part.text : "" + const text = decodeHtmlEntities(rawText) if (latestRequestedText !== text) { return diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index ad1e2376..a30ed0d0 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -71,7 +71,8 @@ function resolveLanguage(token: string): { canonical: string | null; raw: string // Check aliases for (const [key, lang] of Object.entries(bundledLanguages)) { - if (lang.aliases?.includes(normalized)) { + const aliases = (lang as { aliases?: string[] }).aliases + if (aliases?.includes(normalized)) { return { canonical: key, raw: normalized } } } @@ -114,7 +115,7 @@ async function ensureLanguages(content: string) { languageLoadQueue.push(async () => { try { const h = await getOrCreateHighlighter() - await h.loadLanguage(langKey) + await h.loadLanguage(langKey as never) loadedLanguages.add(langKey) triggerLanguageListeners() } catch { @@ -131,6 +132,52 @@ async function ensureLanguages(content: string) { } } +export function decodeHtmlEntities(content: string): string { + if (!content.includes("&")) { + return content + } + + const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g + const namedEntities: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + } + + let result = content + let previous = "" + + while (result.includes("&") && result !== previous) { + previous = result + result = result.replace(entityPattern, (match, entity) => { + if (!entity) { + return match + } + + if (entity[0] === "#") { + const isHex = entity[1]?.toLowerCase() === "x" + const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10) + if (!Number.isNaN(value)) { + try { + return String.fromCodePoint(value) + } catch { + return match + } + } + return match + } + + const decoded = namedEntities[entity.toLowerCase()] + return decoded !== undefined ? decoded : match + }) + } + + return result +} + async function runLanguageLoadQueue() { if (isQueueRunning || languageLoadQueue.length === 0) { return @@ -161,7 +208,8 @@ function setupRenderer(isDark: boolean) { const renderer = new marked.Renderer() renderer.code = (code: string, lang: string | undefined) => { - const encodedCode = encodeURIComponent(code) + const decodedCode = decodeHtmlEntities(code) + const encodedCode = encodeURIComponent(decodedCode) // Use "text" as default when no language is specified const resolvedLang = lang && lang.trim() ? lang.trim() : "text" @@ -182,7 +230,7 @@ function setupRenderer(isDark: boolean) { // Skip highlighting for "text" language or when highlighter is not available if (resolvedLang === "text" || !highlighter) { - return `
${header}
${escapeHtml(code)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } // Resolve language and check if it's loaded @@ -191,13 +239,13 @@ function setupRenderer(isDark: boolean) { // Skip highlighting for "text" aliases if (langKey === "text" || raw === "text") { - return `
${header}
${escapeHtml(code)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } // Use highlighting if language is loaded, otherwise fall back to plain code if (loadedLanguages.has(langKey)) { try { - const html = highlighter.codeToHtml(code, { + const html = highlighter.codeToHtml(decodedCode, { lang: langKey, theme: currentTheme === "dark" ? "github-dark" : "github-light", }) @@ -207,7 +255,7 @@ function setupRenderer(isDark: boolean) { } } - return `
${header}
${escapeHtml(code)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } renderer.link = (href: string, title: string | null | undefined, text: string) => { @@ -216,7 +264,8 @@ function setupRenderer(isDark: boolean) { } renderer.codespan = (code: string) => { - return `${escapeHtml(code)}` + const decoded = decodeHtmlEntities(code) + return `${escapeHtml(decoded)}` } marked.use({ renderer }) @@ -237,11 +286,13 @@ export async function renderMarkdown(content: string): Promise { await initMarkdown(currentTheme === "dark") } + const decoded = decodeHtmlEntities(content) + // Queue language loading but don't wait for it to complete - await ensureLanguages(content) + await ensureLanguages(decoded) // Proceed to parse immediately - highlighting will be available on next render - return marked.parse(content) as Promise + return marked.parse(decoded) as Promise } export async function getSharedHighlighter(): Promise { @@ -252,9 +303,8 @@ export function escapeHtml(text: string): string { const map: Record = { "&": "&", "<": "<", - ">": ">", '"': """, "'": "'", } - return text.replace(/[&<>"']/g, (m) => map[m]) + return text.replace(/[&<"']/g, (m) => map[m]) }