196 lines
4.4 KiB
TypeScript
196 lines
4.4 KiB
TypeScript
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<string>(["text"]);
|
|
|
|
const supportedSet = new Set<string>(SUPPORTED_LANGS);
|
|
|
|
const ensureLangLoaded = async (lang: string): Promise<string> => {
|
|
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<HTMLHeadingElement>("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<ParseResult> => {
|
|
const dom = new JSDOM(rawHtml);
|
|
const document = dom.window.document;
|
|
|
|
await processCodeBlocks(document);
|
|
|
|
const toc = getToc(document)
|
|
|
|
return {
|
|
html: document.body.innerHTML,
|
|
toc,
|
|
};
|
|
};
|