From 26b80613893be79976a82bf9ed7e70fbf4bac4dc Mon Sep 17 00:00:00 2001 From: xy <10816187@qq.com> Date: Fri, 28 Aug 2026 22:14:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E7=AB=A0=E6=B7=BB=E5=8A=A0toc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Post/Toc.astro | 172 ++++++++++++++++++++++++++ src/layouts/BaseLayout.astro | 35 ++++++ src/layouts/Layout.astro | 53 ++------ src/layouts/PageFooter.astro | 17 ++- src/layouts/PageHeader.astro | 10 +- src/lib/processHtml.ts | 214 ++++++++++++++++++++++----------- src/pages/post/[...slug].astro | 102 +++++++++------- src/styles/global.css | 38 +++--- 8 files changed, 458 insertions(+), 183 deletions(-) create mode 100644 src/components/Post/Toc.astro create mode 100644 src/layouts/BaseLayout.astro diff --git a/src/components/Post/Toc.astro b/src/components/Post/Toc.astro new file mode 100644 index 0000000..94ed7b2 --- /dev/null +++ b/src/components/Post/Toc.astro @@ -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)); +--- + +
+
+ { + toc.map((item) => { + return ( + + {item.text} + + ); + }) + } +
+
+ + + + diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..744e133 --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -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; +--- + + + + + + + + + + + {pageTitle} + + + + + diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index a7f3d53..30cc71c 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,54 +1,23 @@ --- -import "@/styles/global.css"; -import {siteConfig} from "@/consts.ts"; +import BaseLayout from "./BaseLayout.astro"; 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; --- - - - - - - - - - - {pageTitle} - - -
- -
- -
- -
- - + +
+ +
+ +
+ +
+
diff --git a/src/layouts/PageHeader.astro b/src/layouts/PageHeader.astro index 74792af..ed3f099 100644 --- a/src/layouts/PageHeader.astro +++ b/src/layouts/PageHeader.astro @@ -1,8 +1,8 @@ --- -import {siteConfig} from "@/consts.ts"; +import { siteConfig } from "@/consts.ts"; import ThemeIcon from "@/components/ThemeIcon.astro"; -const {siteTitle,git} = siteConfig; +const { siteTitle, git } = siteConfig; ---
@@ -21,8 +21,10 @@ const {siteTitle,git} = siteConfig; justify-content: space-between; align-items: center; border-bottom: var(--border-primary); - margin-top: 32px; - padding-bottom: 24px; + width: 100%; + max-width: var(--layout-width); + padding: 32px 0 24px; + margin: 0 auto 24px; } nav { diff --git a/src/lib/processHtml.ts b/src/lib/processHtml.ts index 0cb9434..5ea4b32 100644 --- a/src/lib/processHtml.ts +++ b/src/lib/processHtml.ts @@ -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(['text']) +const loadedLangs = new Set(["text"]); -type SupportedLang = typeof SUPPORTED_LANGS[number] - -const supportedSet = new Set(SUPPORTED_LANGS) +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' + 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("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, + }; +}; diff --git a/src/pages/post/[...slug].astro b/src/pages/post/[...slug].astro index 846a2b0..a6e051d 100644 --- a/src/pages/post/[...slug].astro +++ b/src/pages/post/[...slug].astro @@ -3,13 +3,14 @@ import "@/styles/github-markdown.css"; import "@/styles/github-markdown-reset.css"; import "photoswipe/dist/photoswipe.css"; -import Layout from '@/layouts/Layout.astro'; -import {createHttp} from "@/lib/request.ts"; -import type {Post} from "@/types"; -import {processHtmlWithJsdom} from "@/lib/processHtml.ts"; -import {formatDatetime} from "@/lib/format.ts"; +import Layout from "@/layouts/Layout.astro"; +import Toc from "@/components/Post/Toc.astro"; +import { createHttp } from "@/lib/request.ts"; +import type { Post } from "@/types"; +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); @@ -22,49 +23,58 @@ try { 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"); --- -
-
-
- {data.category_name} · {data.category_code.toUpperCase()} - {published_at} +
+
+
+
+ {data.category_name} · {data.category_code.toUpperCase()} + {published_at} +
+ +
{data.view_count} 次阅读
+

{data.title}

-
{data.view_count} 次阅读
-
-

{data.title}

+ { + data.cover && ( +
+ {data.title} +
+ ) + } - {data.cover && -
- {data.title} -
- } +

{data.summary}

-

{data.summary}

+
-
+
-
+ { + data.tags.length > 0 && ( +
+ {data.tags.map((tag) => ( + {tag.name} + ))} +
+ ) + } +
- { - data.tags.length > 0 && -
- {data.tags.map(tag => {tag.name})} -
- } - + {toc.length > 0 && } +
@@ -146,7 +157,7 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD"); const article = document.querySelector(".markdown-body")!; const images = Array.from(article.querySelectorAll("img")); - if(images.length === 0) return + if (images.length === 0) return; const waitImageLoaded = (img: HTMLImageElement) => { 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((resolve) => { - img.addEventListener("load", () => resolve(), {once: true}); - img.addEventListener("error", () => resolve(), {once: true}); + img.addEventListener("load", () => resolve(), { once: true }); + img.addEventListener("error", () => resolve(), { once: true }); }); }; @@ -201,7 +212,6 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD"); init(); - const copyTimers = new WeakMap>(); // 代码块复制功能 @@ -209,7 +219,7 @@ const published_at = formatDatetime(data.published_at, "YYYY.MM.DD"); text: (el) => { const sibling = el.closest(".code-header")?.nextElementSibling; return sibling?.textContent.trim() || ""; - } + }, }); clipboard.on("success", (e) => { diff --git a/src/styles/global.css b/src/styles/global.css index 4f00e56..e101806 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2,7 +2,10 @@ @import "css-reset.css"; :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; --background: #ffffff; @@ -12,11 +15,11 @@ --text-secondary: #3f3f46; --text-disabled: #d1d5db; --text-meta: #71717a; - --article-text-color: #404040; + --article-text-color: #1e1e1e; --text-title-hover: #000; - --code-block-header-bg: #f5f5f4; --code-block-copy-btn-hover-color: #6b7280; + --text-summary: #000; } .dark { @@ -29,18 +32,9 @@ --text-meta: #d4d4d8; --article-text-color: #d4d4d8; --text-title-hover: #fff; - --code-block-header-bg: #374151; --code-block-copy-btn-hover-color: #9ca3af; -} - -html, -body { - width: 100%; - height: 100%; - min-height: 100%; - font-family: var(--font-family), serif; - color: var(--text-primary); + --text-summary: #fff; } ::-webkit-scrollbar { @@ -55,11 +49,25 @@ body { /* 滚动条滑块 */ ::-webkit-scrollbar-thumb { - background: rgba(139,139,139,.5); + background: rgba(139, 139, 139, 0.5); border-radius: 999px; } /* 鼠标悬停 */ ::-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; }