feat: 文章添加toc
This commit is contained in:
172
src/components/Post/Toc.astro
Normal file
172
src/components/Post/Toc.astro
Normal 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>
|
||||||
35
src/layouts/BaseLayout.astro
Normal file
35
src/layouts/BaseLayout.astro
Normal 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>
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user