| import type { Element as HastElement, ElementContent, Root as HastRoot } from "hast"; |
| import { getSharedHighlighter, isSupportedLang, normalizeLang, SHIKI_THEMES } from "../../shared/shiki-config.js"; |
| import { detectShikiLang } from "../../shared/detect-lang.js"; |
| import type { Transformer } from "./types.js"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| function hastToHtml(nodes: readonly ElementContent[]): string { |
| let out = ""; |
| for (const n of nodes) { |
| if (n.type === "text") { |
| out += escapeHtmlText(n.value); |
| continue; |
| } |
| if (n.type === "element") { |
| out += `<${n.tagName}`; |
| const props = n.properties || {}; |
| for (const [key, value] of Object.entries(props)) { |
| if (value === undefined || value === null || value === false) continue; |
| const attr = propToAttr(key); |
| const str = Array.isArray(value) ? value.join(" ") : String(value); |
| out += ` ${attr}="${escapeAttr(str)}"`; |
| } |
| if (isVoidElement(n.tagName)) { |
| out += " />"; |
| } else { |
| out += ">"; |
| out += hastToHtml(n.children); |
| out += `</${n.tagName}>`; |
| } |
| } |
| } |
| return out; |
| } |
|
|
| function propToAttr(key: string): string { |
| if (key === "className") return "class"; |
| if (key === "htmlFor") return "for"; |
| return key.toLowerCase(); |
| } |
|
|
| function escapeHtmlText(s: string): string { |
| return s.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">")); |
| } |
|
|
| function escapeAttr(s: string): string { |
| return s.replace(/[&"]/g, (c) => (c === "&" ? "&" : """)); |
| } |
|
|
| function isVoidElement(tag: string): boolean { |
| return ["br", "hr", "img", "input", "meta", "link"].includes(tag); |
| } |
|
|
| |
| |
| |
| |
| function extractLang(code: Element): string { |
| const cls = code.getAttribute("class") || ""; |
| const match = cls.match(/language-([\w+-]+)/i); |
| return match ? match[1] : ""; |
| } |
|
|
| |
| |
| |
| |
| |
| function injectLineNumbers(codeChildren: ElementContent[]): void { |
| let n = 0; |
| for (const child of codeChildren) { |
| if (child.type !== "element") continue; |
| |
| |
| const props = child.properties ?? {}; |
| const classValue = props.class ?? props.className; |
| const classTokens = Array.isArray(classValue) |
| ? classValue.map(String) |
| : typeof classValue === "string" |
| ? classValue.split(/\s+/) |
| : []; |
| if (!classTokens.includes("line")) continue; |
| n += 1; |
| const numSpan: HastElement = { |
| type: "element", |
| tagName: "span", |
| properties: { class: "code-line-num", "aria-hidden": "true" }, |
| children: [{ type: "text", value: String(n) }], |
| }; |
| child.children.unshift(numSpan); |
| } |
| } |
|
|
| export const highlightCodeTransformer: Transformer = { |
| name: "highlightCode", |
| async apply(document) { |
| const blocks = [...document.querySelectorAll("pre > code")]; |
| if (blocks.length === 0) return; |
|
|
| const highlighter = await getSharedHighlighter(); |
|
|
| for (const codeEl of blocks) { |
| const pre = codeEl.parentElement; |
| if (!pre || pre.tagName.toLowerCase() !== "pre") continue; |
| if (pre.classList.contains("mermaid")) continue; |
|
|
| const source = codeEl.textContent || ""; |
| if (!source) continue; |
|
|
| |
| |
| |
| const rawLang = extractLang(codeEl as unknown as Element); |
| const lang = normalizeLang(rawLang) || detectShikiLang(source); |
|
|
| let hast: HastRoot; |
| try { |
| hast = highlighter.codeToHast(source, { |
| lang: isSupportedLang(lang) ? lang : "text", |
| themes: SHIKI_THEMES, |
| defaultColor: false, |
| }) as HastRoot; |
| } catch { |
| continue; |
| } |
|
|
| const shikiPre = hast.children.find((c): c is HastElement => c.type === "element" && c.tagName === "pre"); |
| const shikiCode = shikiPre?.children.find((c): c is HastElement => c.type === "element" && c.tagName === "code"); |
| if (!shikiCode) continue; |
|
|
| injectLineNumbers(shikiCode.children); |
| codeEl.innerHTML = hastToHtml(shikiCode.children); |
|
|
| pre.classList.add("shiki"); |
| if (isSupportedLang(lang)) { |
| pre.setAttribute("data-lang", lang); |
| } else { |
| pre.removeAttribute("data-lang"); |
| } |
| const shikiStyle = shikiPre?.properties?.style; |
| if (typeof shikiStyle === "string" && shikiStyle) { |
| const existing = pre.getAttribute("style") || ""; |
| pre.setAttribute("style", existing ? `${existing};${shikiStyle}` : shikiStyle); |
| } |
| } |
| }, |
| }; |
|
|