Improve markdown rendering and syntax highlighting
This commit is contained in:
@@ -3,6 +3,8 @@ import type { Highlighter } from "shiki/bundle/full"
|
|||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||||
|
|
||||||
|
const inlineLoadedLanguages = new Set<string>()
|
||||||
|
|
||||||
interface CodeBlockInlineProps {
|
interface CodeBlockInlineProps {
|
||||||
code: string
|
code: string
|
||||||
language?: string
|
language?: string
|
||||||
@@ -18,7 +20,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
highlighter = await getSharedHighlighter()
|
highlighter = await getSharedHighlighter()
|
||||||
setReady(true)
|
setReady(true)
|
||||||
updateHighlight()
|
await updateHighlight()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -26,11 +28,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
|||||||
isDark()
|
isDark()
|
||||||
props.code
|
props.code
|
||||||
props.language
|
props.language
|
||||||
updateHighlight()
|
void updateHighlight()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateHighlight = () => {
|
const updateHighlight = async () => {
|
||||||
if (!highlighter) return
|
if (!highlighter) return
|
||||||
|
|
||||||
if (!props.language) {
|
if (!props.language) {
|
||||||
@@ -39,6 +41,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!inlineLoadedLanguages.has(props.language)) {
|
||||||
|
await highlighter.loadLanguage(props.language)
|
||||||
|
inlineLoadedLanguages.add(props.language)
|
||||||
|
}
|
||||||
|
|
||||||
const highlighted = highlighter.codeToHtml(props.code, {
|
const highlighted = highlighter.codeToHtml(props.code, {
|
||||||
lang: props.language,
|
lang: props.language,
|
||||||
theme: isDark() ? "github-dark" : "github-light",
|
theme: isDark() ? "github-dark" : "github-light",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { renderMarkdown } from "../lib/markdown"
|
import { renderMarkdown, onLanguagesLoaded } from "../lib/markdown"
|
||||||
import type { TextPart } from "../types/message"
|
import type { TextPart } from "../types/message"
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
@@ -16,13 +16,13 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const part = props.part
|
const part = props.part
|
||||||
const text = part.text || ""
|
const text = part.text || ""
|
||||||
|
|
||||||
|
latestRequestedText = text
|
||||||
|
|
||||||
if (part.renderCache && part.renderCache.text === text) {
|
if (part.renderCache && part.renderCache.text === text) {
|
||||||
setHtml(part.renderCache.html)
|
setHtml(part.renderCache.html)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
latestRequestedText = text
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rendered = await renderMarkdown(text)
|
const rendered = await renderMarkdown(text)
|
||||||
|
|
||||||
@@ -62,8 +62,29 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
|
// Register listener for language loading completion
|
||||||
|
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||||
|
const part = props.part
|
||||||
|
const text = part.text || ""
|
||||||
|
|
||||||
|
if (latestRequestedText !== text) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rendered = await renderMarkdown(text)
|
||||||
|
if (latestRequestedText === text) {
|
||||||
|
setHtml(rendered)
|
||||||
|
part.renderCache = { text, html: rendered }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to re-render markdown after language load:", error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
|
cleanupLanguageListener()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
|
||||||
|
|
||||||
const CORE_LANGUAGES = [
|
|
||||||
"bash",
|
|
||||||
"shell",
|
|
||||||
"sh",
|
|
||||||
"javascript",
|
|
||||||
"typescript",
|
|
||||||
"tsx",
|
|
||||||
"jsx",
|
|
||||||
"json",
|
|
||||||
"yaml",
|
|
||||||
"yml",
|
|
||||||
"markdown",
|
|
||||||
"md",
|
|
||||||
"html",
|
|
||||||
"css",
|
|
||||||
"scss",
|
|
||||||
"python",
|
|
||||||
"go",
|
|
||||||
"rust",
|
|
||||||
]
|
|
||||||
|
|
||||||
let highlighter: Highlighter | null = null
|
let highlighter: Highlighter | null = null
|
||||||
let highlighterPromise: Promise<Highlighter> | null = null
|
let highlighterPromise: Promise<Highlighter> | null = null
|
||||||
let currentTheme: "light" | "dark" = "light"
|
let currentTheme: "light" | "dark" = "light"
|
||||||
let isInitialized = false
|
let isInitialized = false
|
||||||
|
|
||||||
|
// Track loaded languages and queue for on-demand loading
|
||||||
|
const loadedLanguages = new Set<string>()
|
||||||
|
const queuedLanguages = new Set<string>()
|
||||||
|
const languageLoadQueue: Array<() => Promise<void>> = []
|
||||||
|
let isQueueRunning = false
|
||||||
|
|
||||||
|
// Pub/sub mechanism for language loading notifications
|
||||||
|
const languageListeners: Array<() => void> = []
|
||||||
|
|
||||||
|
export function onLanguagesLoaded(callback: () => void): () => void {
|
||||||
|
languageListeners.push(callback)
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
const index = languageListeners.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
languageListeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerLanguageListeners() {
|
||||||
|
for (const listener of languageListeners) {
|
||||||
|
try {
|
||||||
|
listener()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in language listener:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getOrCreateHighlighter() {
|
async function getOrCreateHighlighter() {
|
||||||
if (highlighter) {
|
if (highlighter) {
|
||||||
return highlighter
|
return highlighter
|
||||||
@@ -36,11 +46,10 @@ async function getOrCreateHighlighter() {
|
|||||||
return highlighterPromise
|
return highlighterPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredLangs = CORE_LANGUAGES.filter((lang) => lang in bundledLanguages)
|
// Create highlighter with no preloaded languages
|
||||||
|
|
||||||
highlighterPromise = createHighlighter({
|
highlighterPromise = createHighlighter({
|
||||||
themes: ["github-light", "github-dark"],
|
themes: ["github-light", "github-dark"],
|
||||||
langs: filteredLangs,
|
langs: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
highlighter = await highlighterPromise
|
highlighter = await highlighterPromise
|
||||||
@@ -48,6 +57,91 @@ async function getOrCreateHighlighter() {
|
|||||||
return highlighter
|
return highlighter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLanguageToken(token: string): string {
|
||||||
|
return token.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
|
||||||
|
const normalized = normalizeLanguageToken(token)
|
||||||
|
|
||||||
|
// Check if it's a direct key match
|
||||||
|
if (normalized in bundledLanguages) {
|
||||||
|
return { canonical: normalized, raw: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check aliases
|
||||||
|
for (const [key, lang] of Object.entries(bundledLanguages)) {
|
||||||
|
if (lang.aliases?.includes(normalized)) {
|
||||||
|
return { canonical: key, raw: normalized }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canonical: null, raw: normalized }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLanguages(content: string) {
|
||||||
|
// Parse code fences to extract language tokens
|
||||||
|
const codeBlockRegex = /```(\w*[#.\-+\w]*)/g
|
||||||
|
const foundLanguages = new Set<string>()
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||||
|
const langToken = match[1]
|
||||||
|
if (langToken) {
|
||||||
|
foundLanguages.add(langToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue language loading tasks
|
||||||
|
for (const token of foundLanguages) {
|
||||||
|
const { canonical, raw } = resolveLanguage(token)
|
||||||
|
const langKey = canonical || raw
|
||||||
|
|
||||||
|
// Skip if already loaded or queued
|
||||||
|
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedLanguages.add(langKey)
|
||||||
|
|
||||||
|
// Queue the language loading task
|
||||||
|
languageLoadQueue.push(async () => {
|
||||||
|
try {
|
||||||
|
const h = await getOrCreateHighlighter()
|
||||||
|
await h.loadLanguage(langKey)
|
||||||
|
loadedLanguages.add(langKey)
|
||||||
|
triggerLanguageListeners()
|
||||||
|
} catch {
|
||||||
|
// Quietly ignore errors
|
||||||
|
} finally {
|
||||||
|
queuedLanguages.delete(langKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger queue runner if not already running
|
||||||
|
if (languageLoadQueue.length > 0 && !isQueueRunning) {
|
||||||
|
runLanguageLoadQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLanguageLoadQueue() {
|
||||||
|
if (isQueueRunning || languageLoadQueue.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isQueueRunning = true
|
||||||
|
|
||||||
|
while (languageLoadQueue.length > 0) {
|
||||||
|
const task = languageLoadQueue.shift()
|
||||||
|
if (task) {
|
||||||
|
await task()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isQueueRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
function setupRenderer(isDark: boolean) {
|
function setupRenderer(isDark: boolean) {
|
||||||
if (!highlighter) return
|
if (!highlighter) return
|
||||||
|
|
||||||
@@ -81,15 +175,24 @@ function setupRenderer(isDark: boolean) {
|
|||||||
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code>${escapeHtml(code)}</code></pre></div>`
|
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code>${escapeHtml(code)}</code></pre></div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Resolve language and check if it's loaded
|
||||||
const html = highlighter.codeToHtml(code, {
|
const { canonical, raw } = resolveLanguage(lang)
|
||||||
lang,
|
const langKey = canonical || raw
|
||||||
theme: isDark ? "github-dark" : "github-light",
|
|
||||||
})
|
// Use highlighting if language is loaded, otherwise fall back to plain code
|
||||||
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
|
if (loadedLanguages.has(langKey)) {
|
||||||
} catch {
|
try {
|
||||||
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(code)}</code></pre></div>`
|
const html = highlighter.codeToHtml(code, {
|
||||||
|
lang: langKey,
|
||||||
|
theme: currentTheme === "dark" ? "github-dark" : "github-light",
|
||||||
|
})
|
||||||
|
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
|
||||||
|
} catch {
|
||||||
|
// Fall through to plain code if highlighting fails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(code)}</code></pre></div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.link = (href: string, title: string | null | undefined, text: string) => {
|
renderer.link = (href: string, title: string | null | undefined, text: string) => {
|
||||||
@@ -118,6 +221,11 @@ export async function renderMarkdown(content: string): Promise<string> {
|
|||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
await initMarkdown(currentTheme === "dark")
|
await initMarkdown(currentTheme === "dark")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue language loading but don't wait for it to complete
|
||||||
|
await ensureLanguages(content)
|
||||||
|
|
||||||
|
// Proceed to parse immediately - highlighting will be available on next render
|
||||||
return marked.parse(content) as Promise<string>
|
return marked.parse(content) as Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user