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

@@ -0,0 +1,172 @@
---
import { type TocNode } from "@/lib/processHtml";
interface Props {
toc: TocNode[];
}
const { toc } = Astro.props;
const lowLevel = Math.min(...toc.map((v) => v.level));
---
<div class="toc-mount">
<div class="toc">
{
toc.map((item) => {
return (
<a
href={`#${item.id}`}
class="toc-item"
style={{ paddingLeft: `${(item.level - lowLevel) * 8}px` }}
>
{item.text}
</a>
);
})
}
</div>
</div>
<style>
.toc-mount {
position: absolute;
top: -20px;
height: 100%;
right: -320px;
width: 280px;
padding: 24px 0;
}
.toc {
position: sticky;
top: 60px;
height: calc(100vh - 120px);
width: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
border-left: var(--border-secondary);
padding-left: 24px;
}
.toc-item {
color: var(--text-primary);
font-size: 14px;
line-height: 20px;
}
.toc-item:hover {
color: var(--text-title-hover);
font-weight: bold;
}
.toc-item.is-active {
position: relative;
color: var(--text-title-hover);
font-weight: bold;
}
.toc-item.is-active::before {
content: "";
position: absolute;
left: -25px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 18px;
border-radius: 2px;
background: currentColor;
}
@media (max-width: 1250px) {
.toc-mount{
display: none;
}
}
</style>
<script>
const links = Array.from(
document.querySelectorAll<HTMLAnchorElement>(".toc-item"),
);
const headings = links
.map((link) => {
const id = link.getAttribute("href")?.slice(1);
return id ? { link, heading: document.getElementById(id) } : null;
})
.filter((x): x is { link: HTMLAnchorElement; heading: HTMLElement } =>
Boolean(x?.heading),
);
if (headings.length > 0) {
const toc = document.querySelector<HTMLElement>(".toc");
const ACTIVE_OFFSET = 100;
let currentId: string | null = null;
let raf = 0;
const scrollActiveIntoView = (link: HTMLAnchorElement) => {
if (!toc) return;
const containerTop = toc.getBoundingClientRect().top;
const linkTop =
link.getBoundingClientRect().top - containerTop + toc.scrollTop;
const linkBottom =
link.getBoundingClientRect().bottom - containerTop + toc.scrollTop;
if (linkTop < toc.scrollTop) {
toc.scrollTop = linkTop;
} else if (linkBottom > toc.scrollTop + toc.clientHeight) {
toc.scrollTop = linkBottom - toc.clientHeight;
}
};
const setActive = (id: string | null) => {
if (id === currentId) return;
currentId = id;
for (const { link, heading } of headings) {
const active = heading.id === id;
link.classList.toggle("is-active", active);
if (active) {
link.setAttribute("aria-current", "location");
scrollActiveIntoView(link);
} else {
link.removeAttribute("aria-current");
}
}
};
const update = () => {
raf = 0;
const doc = document.documentElement;
const atBottom =
window.scrollY + window.innerHeight >= doc.scrollHeight - 4;
let activeId: string | null = null;
if (atBottom) {
activeId = headings[headings.length - 1].heading.id;
} else {
for (const { heading } of headings) {
if (heading.getBoundingClientRect().top <= ACTIVE_OFFSET) {
activeId = heading.id;
} else {
break;
}
}
}
setActive(activeId);
};
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(update);
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
update();
}
</script>

View File

@@ -0,0 +1,35 @@
---
import "@/styles/global.css";
import { siteConfig } from "@/consts.ts";
import PageHeader from "./PageHeader.astro";
import PageFooter from "./PageFooter.astro";
const { siteTitle } = siteConfig;
interface Props {
title?: string;
description?: string;
}
const { title, description } = Astro.props;
const pageTitle = title ? `${title} - ${siteTitle}` : siteTitle;
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<meta name="theme-color" content="#ffffff" />
<title>{pageTitle}</title>
</head>
<body>
<slot />
</body>
</html>

View File

@@ -1,54 +1,23 @@
--- ---
import "@/styles/global.css"; import BaseLayout from "./BaseLayout.astro";
import {siteConfig} from "@/consts.ts";
import PageHeader from "./PageHeader.astro"; import PageHeader from "./PageHeader.astro";
import PageFooter from "./PageFooter.astro"; import PageFooter from "./PageFooter.astro";
const {siteTitle} = siteConfig;
interface Props {
title?: string;
description?: string;
}
const {title, description} = Astro.props;
const pageTitle = title ? `${title} - ${siteTitle}` : siteTitle;
--- ---
<!doctype html> <BaseLayout {...Astro.props}>
<html lang="zh-CN"> <div class="container">
<head> <PageHeader />
<meta charset="UTF-8"/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<link rel="icon" href="/favicon.ico"/>
<meta name="generator" content={Astro.generator}/>
<meta name="description" content={description}/>
<meta name="theme-color" content="#ffffff"/>
<title>{pageTitle}</title>
</head>
<body>
<div class="container">
<PageHeader/>
<main> <main>
<slot></slot> <slot />
</main> </main>
<PageFooter /> <PageFooter />
</div> </div>
</body> </BaseLayout>
</html>
<style> <style>
body {
background: var(--background);
}
.container { .container {
width: 100%; width: 100%;
max-width: var(--layout-width); max-width: var(--layout-width);
height: 100%;
min-height: 100%; min-height: 100%;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
@@ -59,6 +28,8 @@ const pageTitle = title ? `${title} - ${siteTitle}` : siteTitle;
main { main {
flex: 1; flex: 1;
padding: 24px 0; padding: 24px 0;
display: flex;
flex-direction: column;
} }
@media (max-width: 640px) { @media (max-width: 640px) {

View File

@@ -1,26 +1,23 @@
<footer> <footer>
<div>© 2026 Uroboros</div> <div>© 2026 Uroboros</div>
<a href="https://beian.miit.gov.cn" target="_blank">备案号:鄂ICP备2023000060号-2</a> <a href="https://beian.miit.gov.cn" target="_blank"
>备案号:鄂ICP备2023000060号-2</a
>
</footer> </footer>
<style> <style>
footer { footer {
flex-shrink: 0; flex-shrink: 0;
min-height: 0; min-height: 0;
height: 84px; height: 64px;
padding-bottom: 24px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
gap: 12px; gap: 12px;
color: var(--text-secondary); color: var(--text-secondary);
} max-width: var(--layout-width);
margin: 0 auto;
@media (max-width: 640px) { width: 100%;
footer {
height: 64px;
padding-bottom: 0;
}
} }
</style> </style>

View File

@@ -1,8 +1,8 @@
--- ---
import {siteConfig} from "@/consts.ts"; import { siteConfig } from "@/consts.ts";
import ThemeIcon from "@/components/ThemeIcon.astro"; import ThemeIcon from "@/components/ThemeIcon.astro";
const {siteTitle,git} = siteConfig; const { siteTitle, git } = siteConfig;
--- ---
<header> <header>
@@ -21,8 +21,10 @@ const {siteTitle,git} = siteConfig;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: var(--border-primary); border-bottom: var(--border-primary);
margin-top: 32px; width: 100%;
padding-bottom: 24px; max-width: var(--layout-width);
padding: 32px 0 24px;
margin: 0 auto 24px;
} }
nav { nav {

View File

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

View File

@@ -3,13 +3,14 @@ import "@/styles/github-markdown.css";
import "@/styles/github-markdown-reset.css"; import "@/styles/github-markdown-reset.css";
import "photoswipe/dist/photoswipe.css"; import "photoswipe/dist/photoswipe.css";
import Layout from '@/layouts/Layout.astro'; import Layout from "@/layouts/Layout.astro";
import {createHttp} from "@/lib/request.ts"; import Toc from "@/components/Post/Toc.astro";
import type {Post} from "@/types"; import { createHttp } from "@/lib/request.ts";
import {processHtmlWithJsdom} from "@/lib/processHtml.ts"; import type { Post } from "@/types";
import {formatDatetime} from "@/lib/format.ts"; import { processHtmlWithJsdom } from "@/lib/processHtml.ts";
import { formatDatetime } from "@/lib/format.ts";
const {slug} = Astro.params; const { slug } = Astro.params;
const http = createHttp(Astro.request.headers); const http = createHttp(Astro.request.headers);
@@ -22,11 +23,12 @@ try {
return Astro.redirect("/404"); return Astro.redirect("/404");
} }
const content = await processHtmlWithJsdom(data.content); const { html, toc } = await processHtmlWithJsdom(data.content);
const published_at = formatDatetime(data.published_at, "YYYY.MM.DD"); const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
--- ---
<Layout title={data.title} description={data.summary}> <Layout title={data.title} description={data.summary}>
<div class="post-wrap">
<article> <article>
<div class="meta"> <div class="meta">
<div class="meta-left"> <div class="meta-left">
@@ -36,35 +38,43 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
<div class="meta-view-count">{data.view_count} 次阅读</div> <div class="meta-view-count">{data.view_count} 次阅读</div>
</div> </div>
<h1>{data.title}</h1> <h1 class="title">{data.title}</h1>
{data.cover && {
data.cover && (
<div> <div>
<img class="cover" src={data.cover} alt={data.title}> <img class="cover" src={data.cover} alt={data.title} />
</div> </div>
)
} }
<p class="summary">{data.summary}</p> <p class="summary">{data.summary}</p>
<div class="divider"></div> <div class="divider"></div>
<div class="markdown-body" data-color-mode="dark" set:html={content}/> <div class="markdown-body" data-color-mode="dark" set:html={html} />
{ {
data.tags.length > 0 && data.tags.length > 0 && (
<div class="tags"> <div class="tags">
{data.tags.map(tag => <span>{tag.name}</span>)} {data.tags.map((tag) => (
<span>{tag.name}</span>
))}
</div> </div>
)
} }
</article> </article>
{toc.length > 0 && <Toc toc={toc} />}
</div>
</Layout> </Layout>
<style> <style>
article { .post-wrap {
width: 100%; position: relative;
margin: 0 auto;
flex: 1; flex: 1;
flex-shrink: 0; flex-shrink: 0;
width: 100%;
} }
.cover { .cover {
@@ -74,7 +84,7 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
.meta { .meta {
color: var(--text-meta); color: var(--text-meta);
font-size: 13px; font-size: 14px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -91,17 +101,18 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
color: var(--text-primary); color: var(--text-primary);
} }
h1 { .title {
font-size: 28px; font-size: 36px;
line-height: 1.4; line-height: 1.4;
font-weight: 500;
margin-bottom: 20px; margin-bottom: 20px;
color: var(--text-primary);
font-weight: bold;
} }
.summary { .summary {
font-size: 14px; font-size: 14px;
margin-bottom: 20px; margin-bottom: 20px;
color: var(--text-meta); color: var(--text-summary);
line-height: 1.4; line-height: 1.4;
} }
@@ -112,7 +123,7 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
.markdown-body { .markdown-body {
margin-top: 24px; margin-top: 24px;
line-height: 1.8; line-height: 1.8;
font-size: 15px; font-size: 16px;
color: var(--article-text-color); color: var(--article-text-color);
} }
@@ -132,8 +143,8 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
} }
@media (max-width: 640px) { @media (max-width: 640px) {
h1{ .title {
font-size: 24px; font-size: 30px;
} }
} }
</style> </style>
@@ -146,7 +157,7 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
const article = document.querySelector(".markdown-body")!; const article = document.querySelector(".markdown-body")!;
const images = Array.from(article.querySelectorAll("img")); const images = Array.from(article.querySelectorAll("img"));
if(images.length === 0) return if (images.length === 0) return;
const waitImageLoaded = (img: HTMLImageElement) => { const waitImageLoaded = (img: HTMLImageElement) => {
if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { if (img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -154,8 +165,8 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
} }
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
img.addEventListener("load", () => resolve(), {once: true}); img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => resolve(), {once: true}); img.addEventListener("error", () => resolve(), { once: true });
}); });
}; };
@@ -201,7 +212,6 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
init(); init();
const copyTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>(); const copyTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();
// 代码块复制功能 // 代码块复制功能
@@ -209,7 +219,7 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD");
text: (el) => { text: (el) => {
const sibling = el.closest(".code-header")?.nextElementSibling; const sibling = el.closest(".code-header")?.nextElementSibling;
return sibling?.textContent.trim() || ""; return sibling?.textContent.trim() || "";
} },
}); });
clipboard.on("success", (e) => { clipboard.on("success", (e) => {

View File

@@ -2,7 +2,10 @@
@import "css-reset.css"; @import "css-reset.css";
:root { :root {
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; --font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial,
sans-serif;
--layout-width: 760px; --layout-width: 760px;
--background: #ffffff; --background: #ffffff;
@@ -12,11 +15,11 @@
--text-secondary: #3f3f46; --text-secondary: #3f3f46;
--text-disabled: #d1d5db; --text-disabled: #d1d5db;
--text-meta: #71717a; --text-meta: #71717a;
--article-text-color: #404040; --article-text-color: #1e1e1e;
--text-title-hover: #000; --text-title-hover: #000;
--code-block-header-bg: #f5f5f4; --code-block-header-bg: #f5f5f4;
--code-block-copy-btn-hover-color: #6b7280; --code-block-copy-btn-hover-color: #6b7280;
--text-summary: #000;
} }
.dark { .dark {
@@ -29,18 +32,9 @@
--text-meta: #d4d4d8; --text-meta: #d4d4d8;
--article-text-color: #d4d4d8; --article-text-color: #d4d4d8;
--text-title-hover: #fff; --text-title-hover: #fff;
--code-block-header-bg: #374151; --code-block-header-bg: #374151;
--code-block-copy-btn-hover-color: #9ca3af; --code-block-copy-btn-hover-color: #9ca3af;
} --text-summary: #fff;
html,
body {
width: 100%;
height: 100%;
min-height: 100%;
font-family: var(--font-family), serif;
color: var(--text-primary);
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -55,11 +49,25 @@ body {
/* 滚动条滑块 */ /* 滚动条滑块 */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(139,139,139,.5); background: rgba(139, 139, 139, 0.5);
border-radius: 999px; border-radius: 999px;
} }
/* 鼠标悬停 */ /* 鼠标悬停 */
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(139,139,139,.7); background: rgba(139, 139, 139, 0.7);
}
html,
body {
width: 100%;
height: 100%;
min-height: 100%;
font-family: var(--font-family), serif;
color: var(--text-primary);
background: var(--background);
}
html {
scroll-behavior: smooth;
} }