import { marked } from "marked" import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full" import { getLogger } from "./logger" import { tGlobal } from "./i18n" const log = getLogger("actions") let highlighter: Highlighter | null = null let highlighterPromise: Promise | null = null let currentTheme: "light" | "dark" = "light" let isInitialized = false let highlightSuppressed = false let rendererSetup = false const extensionToLanguage: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", py: "python", sh: "bash", bash: "bash", json: "json", html: "html", css: "css", md: "markdown", yaml: "yaml", yml: "yaml", sql: "sql", rs: "rust", go: "go", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", h: "cpp", c: "c", java: "java", cs: "csharp", php: "php", rb: "ruby", swift: "swift", kt: "kotlin", } export function getLanguageFromPath(path?: string | null): string | undefined { if (!path) return undefined const ext = path.split(".").pop()?.toLowerCase() return ext ? extensionToLanguage[ext] : undefined } // Track loaded languages and queue for on-demand loading const loadedLanguages = new Set() const queuedLanguages = new Set() const languageLoadQueue: Array<() => Promise> = [] 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) { log.error("Error in language listener", error) } } } async function getOrCreateHighlighter() { if (highlighter) { return highlighter } if (highlighterPromise) { return highlighterPromise } // Create highlighter with no preloaded languages highlighterPromise = createHighlighter({ themes: ["github-light", "github-light-high-contrast", "github-dark"], langs: [], }) highlighter = await highlighterPromise highlighterPromise = null 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)) { const aliases = (lang as { aliases?: string[] }).aliases if (aliases?.includes(normalized)) { return { canonical: key, raw: normalized } } } return { canonical: null, raw: normalized } } async function ensureLanguages(content: string) { if (highlightSuppressed) { return } // Extract code-fence language tokens via `marked` so we correctly handle code blocks // that contain backticks (e.g. JS template literals). Regex-based fence scans tend // to miss these and prevent languages from loading. const foundLanguages = new Set() try { const tokens = marked.lexer(content) as any marked.walkTokens(tokens, (token: any) => { if (token?.type !== "code") return const langToken = typeof token.lang === "string" ? token.lang : "" if (langToken.trim()) { foundLanguages.add(langToken.trim()) } }) } catch { // If tokenization fails for any reason, skip language preloading. return } // Queue language loading tasks for (const token of foundLanguages) { const { canonical, raw } = resolveLanguage(token) const langKey = canonical || raw // Skip "text" and aliases since Shiki handles plain text already if (langKey === "text" || raw === "text") { continue } // 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 as never) 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() } } 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 } isQueueRunning = true while (languageLoadQueue.length > 0) { const task = languageLoadQueue.shift() if (task) { await task() } } isQueueRunning = false } function setupRenderer(isDark: boolean) { currentTheme = isDark ? "dark" : "light" if (!highlighter) return if (rendererSetup) return marked.setOptions({ breaks: true, gfm: true, }) const renderer = new marked.Renderer() renderer.code = (code: string, lang: string | undefined) => { 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" const escapedLang = escapeHtml(resolvedLang) const copyLabel = escapeHtml(tGlobal("markdown.copy")) const header = `
${escapedLang}
`.trim() if (highlightSuppressed) { return `
${header}
${escapeHtml(decodedCode)}
` } // Skip highlighting for "text" language or when highlighter is not available if (resolvedLang === "text" || !highlighter) { return `
${header}
${escapeHtml(decodedCode)}
` } // Resolve language and check if it's loaded const { canonical, raw } = resolveLanguage(resolvedLang) const langKey = canonical || raw // Skip highlighting for "text" aliases if (langKey === "text" || raw === "text") { 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(decodedCode, { lang: langKey, theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast", }) return `
${header}${html}
` } catch { // Fall through to plain code if highlighting fails } } return `
${header}
${escapeHtml(decodedCode)}
` } renderer.link = (href: string, title: string | null | undefined, text: string) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : "" return `${text}` } renderer.codespan = (code: string) => { const decoded = decodeHtmlEntities(code) return `${escapeHtml(decoded)}` } marked.use({ renderer }) rendererSetup = true } export async function initMarkdown(isDark: boolean) { await getOrCreateHighlighter() setupRenderer(isDark) isInitialized = true } export function setMarkdownTheme(isDark: boolean) { currentTheme = isDark ? "dark" : "light" } export function isMarkdownReady(): boolean { return isInitialized && highlighter !== null } export async function renderMarkdown( content: string, options?: { suppressHighlight?: boolean }, ): Promise { if (!isInitialized) { await initMarkdown(currentTheme === "dark") } const suppressHighlight = options?.suppressHighlight ?? false const decoded = decodeHtmlEntities(content) if (!suppressHighlight) { // Queue language loading but don't wait for it to complete await ensureLanguages(decoded) } const previousSuppressed = highlightSuppressed highlightSuppressed = suppressHighlight try { // Proceed to parse immediately - highlighting will be available on next render return marked.parse(decoded) as Promise } finally { highlightSuppressed = previousSuppressed } } export async function getSharedHighlighter(): Promise { return getOrCreateHighlighter() } export function escapeHtml(text: string): string { const map: Record = { "&": "&", "<": "<", '"': """, "'": "'", } return text.replace(/[&<"']/g, (m) => map[m]) }