feat: 文章添加toc

This commit is contained in:
2026-08-28 22:14:41 +08:00
parent 81134bfa55
commit 26b8061389
8 changed files with 458 additions and 183 deletions

View File

@@ -1,115 +1,195 @@
import {JSDOM} from 'jsdom'
import {createHighlighter} from 'shiki'
import { JSDOM } from "jsdom";
import { createHighlighter } from "shiki";
const DEFAULT_THEME = 'github-dark-dimmed'
const DEFAULT_THEME = "github-dark-dimmed";
type SupportedLang = (typeof SUPPORTED_LANGS)[number];
const highlighter = await createHighlighter({
themes: [DEFAULT_THEME],
langs: []
})
langs: [],
});
const SUPPORTED_LANGS = [
"bash", "json", "yaml", "xml", "html", "css", "scss",
"javascript", "typescript", "jsx", "tsx", "vue",
"markdown", "sql", "python", "go", "dockerfile", "nginx"
] as const
"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 loadedLangs = new Set<string>(["text"]);
type SupportedLang = typeof SUPPORTED_LANGS[number]
const supportedSet = new Set<string>(SUPPORTED_LANGS)
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'
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
await highlighter.loadLanguage(lang as SupportedLang);
loadedLangs.add(lang);
return lang;
} catch {
return 'text'
return "text";
}
}
};
export const processHtmlWithJsdom = async (rawHtml: string) => {
const dom = new JSDOM(rawHtml)
const document = dom.window.document
const codeBlocks = document.querySelectorAll('pre code')
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 preEl = codeBlock.parentElement;
if (!preEl || preEl.tagName !== "PRE") continue;
const codeContent = codeBlock.textContent || ''
let lang = 'text'
const codeContent = codeBlock.textContent || "";
let lang = "text";
for (const cls of codeBlock.classList) {
if (cls.startsWith('language-')) {
lang = cls.slice('language-'.length)
break
if (cls.startsWith("language-")) {
lang = cls.slice("language-".length);
break;
}
}
lang = await ensureLangLoaded(lang) // ← 唯一改动点:按需加载
lang = await ensureLangLoaded(lang);
const highlighted = highlighter.codeToHtml(codeContent, {
lang,
theme: DEFAULT_THEME,
})
});
// 替换 DOM
const wrapper = document.createElement('div')
wrapper.innerHTML = highlighted.trim()
const wrapper = document.createElement("div");
wrapper.innerHTML = highlighted.trim();
const newNode = wrapper.firstElementChild
const newNode = wrapper.firstElementChild;
if (newNode && preEl.parentNode) {
const container = document.createElement('div')
container.className = 'code-block'
const container = document.createElement("div");
container.className = "code-block";
const header = document.createElement('div')
header.className = 'code-header'
const header = document.createElement("div");
header.className = "code-header";
const headerLeft = document.createElement('div')
headerLeft.className = 'code-header-left'
const headerLeft = document.createElement("div");
headerLeft.className = "code-header-left";
const headerRight = document.createElement('div')
headerRight.className = 'code-header-right'
const headerRight = document.createElement("div");
headerRight.className = "code-header-right";
// 装饰点
const actions = document.createElement('div')
actions.className = 'code-actions'
const actions = document.createElement("div");
actions.className = "code-actions";
const close = document.createElement('span')
close.className = 'dot close'
const close = document.createElement("span");
close.className = "dot close";
const minimize = document.createElement('span')
minimize.className = 'dot minimize'
const minimize = document.createElement("span");
minimize.className = "dot minimize";
const maximize = document.createElement('span')
maximize.className = 'dot maximize'
const maximize = document.createElement("span");
maximize.className = "dot maximize";
actions.append(close, minimize, maximize)
actions.append(close, minimize, maximize);
// 显示语言
const langDisplay = document.createElement('div')
langDisplay.className = 'code-language'
langDisplay.textContent = lang ? lang : 'text'
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 = '复制代码'
const copyBtn = document.createElement("div");
copyBtn.className = "code-copy-btn";
copyBtn.textContent = "复制代码";
headerLeft.append(actions, langDisplay)
headerRight.append(copyBtn)
headerLeft.append(actions, langDisplay);
headerRight.append(copyBtn);
header.append(headerLeft, headerRight)
header.append(headerLeft, headerRight);
container.append(header, newNode)
container.append(header, newNode);
preEl.parentNode.replaceChild(container, preEl)
preEl.parentNode.replaceChild(container, preEl);
}
}
return document.body.innerHTML
}
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,
};
};