import { JSDOM } from "jsdom"; import { createHighlighter } from "shiki"; const DEFAULT_THEME = "github-dark-dimmed"; type SupportedLang = (typeof SUPPORTED_LANGS)[number]; const highlighter = await createHighlighter({ themes: [DEFAULT_THEME], langs: [], }); const SUPPORTED_LANGS = [ "bash", "json", "yaml", "xml", "html", "css", "scss", "javascript", "typescript", "jsx", "tsx", "vue", "markdown", "sql", "python", "go", "dockerfile", "nginx", ] as const; const loadedLangs = new Set(["text"]); const supportedSet = new Set(SUPPORTED_LANGS); const ensureLangLoaded = async (lang: string): Promise => { if (lang === "text" || loadedLangs.has(lang)) return lang; if (!supportedSet.has(lang)) return "text"; try { await highlighter.loadLanguage(lang as SupportedLang); loadedLangs.add(lang); return lang; } catch { return "text"; } }; async function processCodeBlocks(document: Document) { const codeBlocks = document.querySelectorAll("pre code"); for (const codeBlock of codeBlocks) { const preEl = codeBlock.parentElement; if (!preEl || preEl.tagName !== "PRE") continue; const codeContent = codeBlock.textContent || ""; let lang = "text"; for (const cls of codeBlock.classList) { if (cls.startsWith("language-")) { lang = cls.slice("language-".length); break; } } lang = await ensureLangLoaded(lang); const highlighted = highlighter.codeToHtml(codeContent, { lang, theme: DEFAULT_THEME, }); // 替换 DOM const wrapper = document.createElement("div"); wrapper.innerHTML = highlighted.trim(); const newNode = wrapper.firstElementChild; if (newNode && preEl.parentNode) { const container = document.createElement("div"); container.className = "code-block"; const header = document.createElement("div"); header.className = "code-header"; const headerLeft = document.createElement("div"); headerLeft.className = "code-header-left"; const headerRight = document.createElement("div"); headerRight.className = "code-header-right"; // 装饰点 const actions = document.createElement("div"); actions.className = "code-actions"; const close = document.createElement("span"); close.className = "dot close"; const minimize = document.createElement("span"); minimize.className = "dot minimize"; const maximize = document.createElement("span"); maximize.className = "dot maximize"; actions.append(close, minimize, maximize); // 显示语言 const langDisplay = document.createElement("div"); langDisplay.className = "code-language"; langDisplay.textContent = lang ? lang : "text"; const copyBtn = document.createElement("div"); copyBtn.className = "code-copy-btn"; copyBtn.textContent = "复制代码"; headerLeft.append(actions, langDisplay); headerRight.append(copyBtn); header.append(headerLeft, headerRight); container.append(header, newNode); preEl.parentNode.replaceChild(container, preEl); } } } type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; export interface TocNode { level: HeadingLevel; id: string; text: string; element: HTMLHeadingElement; } function createHeadingId(text: string, doc: Document): string { const base = text.trim() || "heading"; let id = base; let index = 1; while (doc.getElementById(id)) { id = `${base}-${index++}`; } return id; } function getToc(document: Document): TocNode[] { const headings = Array.from( document.querySelectorAll("h1, h2, h3, h4, h5, h6"), ); const result: TocNode[] = []; for (const element of headings) { const level = Number(element.tagName.slice(1)) as HeadingLevel; const id = element.id || createHeadingId(element.textContent ?? "", document); element.id = id; const node: TocNode = { level, id, text: element.textContent?.trim() ?? "", element, }; result.push(node); } return result; } type ParseResult = { html: string; toc: TocNode[]; }; export const processHtmlWithJsdom = async ( rawHtml: string, ): Promise => { const dom = new JSDOM(rawHtml); const document = dom.window.document; await processCodeBlocks(document); const toc = getToc(document) return { html: document.body.innerHTML, toc, }; };