import { marked } from "marked" import { getLogger } from "./logger" import { tGlobal } from "./i18n" import type { Highlighter } from "shiki/bundle/full" import { decodeHtmlEntities, escapeHtml } from "./text-render-utils" 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 escapeRawHtmlEnabled = false let rendererSetup = false let shikiModulePromise: Promise | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null // 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 } highlighterPromise = (async () => { const shiki = await loadShikiModule() return shiki.createHighlighter({ themes: ["github-light", "github-light-high-contrast", "github-dark"], langs: [], }) })().catch((error) => { highlighterPromise = null throw error }) highlighter = await highlighterPromise highlighterPromise = null return highlighter } async function loadShikiModule() { if (!shikiModulePromise) { shikiModulePromise = import("shiki/bundle/full").then((module) => { bundledLanguagesCache = module.bundledLanguages return module }) } return shikiModulePromise } function queueHighlighterWarmup() { if (highlighter || highlighterPromise) { return } void getOrCreateHighlighter().catch((error) => { log.warn("Failed to initialize markdown highlighter", error) }) } function normalizeLanguageToken(token: string): string { return token.trim().toLowerCase() } function resolveLanguage(token: string): { canonical: string | null; raw: string } { const normalized = normalizeLanguageToken(token) const bundledLanguages = bundledLanguagesCache if (!bundledLanguages) { return { canonical: null, raw: normalized } } // 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 } } function collectCodeFenceLanguages(content: string): string[] { 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 { return [] } return [...foundLanguages] } export function hasPendingCodeHighlight(content: string): boolean { const languages = collectCodeFenceLanguages(content) for (const token of languages) { const rawToken = normalizeLanguageToken(token) if (!rawToken || rawToken === "text") { continue } const { canonical, raw } = resolveLanguage(token) const langKey = canonical || raw if (langKey === "text" || raw === "text") { continue } if (!highlighter || !loadedLanguages.has(langKey)) { return true } } return false } 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 = collectCodeFenceLanguages(content) // Queue language loading tasks for (const token of foundLanguages) { const rawToken = normalizeLanguageToken(token) if (!rawToken) { continue } // Skip "text" and aliases since Shiki handles plain text already if (rawToken === "text") { continue } // Skip if already loaded or queued if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) { continue } queuedLanguages.add(rawToken) // Queue the language loading task languageLoadQueue.push(async () => { try { await loadShikiModule() const { canonical, raw } = resolveLanguage(token) const langKey = canonical || raw if (langKey === "text" || raw === "text") { return } const h = await getOrCreateHighlighter() await h.loadLanguage(langKey as never) loadedLanguages.add(langKey) loadedLanguages.add(raw) triggerLanguageListeners() } catch { // Quietly ignore errors } finally { queuedLanguages.delete(rawToken) } }) } // 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) { currentTheme = isDark ? "dark" : "light" 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)}` } renderer.html = (html: string) => { if (!escapeRawHtmlEnabled) { return html } return escapeHtml(decodeHtmlEntities(html)) } marked.use({ renderer }) rendererSetup = true } export async function initMarkdown(isDark: boolean) { setupRenderer(isDark) queueHighlighterWarmup() await getOrCreateHighlighter() 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 escapeRawHtml?: boolean }, ): Promise { if (!isInitialized) { setupRenderer(currentTheme === "dark") isInitialized = true } const suppressHighlight = options?.suppressHighlight ?? false const escapeRawHtml = options?.escapeRawHtml ?? false const decoded = decodeHtmlEntities(content) if (!suppressHighlight) { queueHighlighterWarmup() void ensureLanguages(decoded) } const previousSuppressed = highlightSuppressed const previousEscapeRawHtml = escapeRawHtmlEnabled highlightSuppressed = suppressHighlight escapeRawHtmlEnabled = escapeRawHtml try { // Proceed to parse immediately - highlighting will be available on next render return marked.parse(decoded) as Promise } finally { highlightSuppressed = previousSuppressed escapeRawHtmlEnabled = previousEscapeRawHtml } } export async function getSharedHighlighter(): Promise { return getOrCreateHighlighter() }