@astro-minimax/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/package.json +41 -0
- package/src/assets/icons/IconArchive.svg +1 -0
- package/src/assets/icons/IconArrowLeft.svg +1 -0
- package/src/assets/icons/IconArrowNarrowUp.svg +1 -0
- package/src/assets/icons/IconArrowRight.svg +1 -0
- package/src/assets/icons/IconArticle.svg +1 -0
- package/src/assets/icons/IconBrandX.svg +1 -0
- package/src/assets/icons/IconCalendar.svg +1 -0
- package/src/assets/icons/IconChevronLeft.svg +1 -0
- package/src/assets/icons/IconChevronRight.svg +1 -0
- package/src/assets/icons/IconEdit.svg +1 -0
- package/src/assets/icons/IconFacebook.svg +1 -0
- package/src/assets/icons/IconGitHub.svg +1 -0
- package/src/assets/icons/IconHash.svg +1 -0
- package/src/assets/icons/IconHome.svg +1 -0
- package/src/assets/icons/IconLinkedin.svg +1 -0
- package/src/assets/icons/IconMail.svg +1 -0
- package/src/assets/icons/IconMenuDeep.svg +1 -0
- package/src/assets/icons/IconMoon.svg +1 -0
- package/src/assets/icons/IconPinterest.svg +1 -0
- package/src/assets/icons/IconProject.svg +1 -0
- package/src/assets/icons/IconRss.svg +1 -0
- package/src/assets/icons/IconSearch.svg +1 -0
- package/src/assets/icons/IconSeries.svg +1 -0
- package/src/assets/icons/IconSunHigh.svg +1 -0
- package/src/assets/icons/IconTag.svg +1 -0
- package/src/assets/icons/IconTelegram.svg +1 -0
- package/src/assets/icons/IconUser.svg +1 -0
- package/src/assets/icons/IconWhatsapp.svg +1 -0
- package/src/assets/icons/IconX.svg +1 -0
- package/src/components/ai/AIChatWidget.astro +377 -0
- package/src/components/blog/Comments.astro +527 -0
- package/src/components/blog/Copyright.astro +152 -0
- package/src/components/blog/EditPost.astro +59 -0
- package/src/components/blog/FloatingTOC.astro +260 -0
- package/src/components/blog/InlineTOC.astro +223 -0
- package/src/components/blog/PostActions.astro +306 -0
- package/src/components/blog/RelatedPosts.astro +60 -0
- package/src/components/blog/SeriesNav.astro +176 -0
- package/src/components/blog/ShareLinks.astro +26 -0
- package/src/components/nav/BackButton.astro +37 -0
- package/src/components/nav/BackToTopButton.astro +223 -0
- package/src/components/nav/Breadcrumb.astro +57 -0
- package/src/components/nav/FloatingActions.astro +206 -0
- package/src/components/nav/Footer.astro +107 -0
- package/src/components/nav/Header.astro +252 -0
- package/src/components/nav/Pagination.astro +45 -0
- package/src/components/social/Socials.astro +19 -0
- package/src/components/social/Sponsors.astro +34 -0
- package/src/components/social/Sponsorship.astro +44 -0
- package/src/components/ui/Alert.astro +28 -0
- package/src/components/ui/Card.astro +206 -0
- package/src/components/ui/Collapse.astro +82 -0
- package/src/components/ui/ColorPreview.astro +29 -0
- package/src/components/ui/Datetime.astro +61 -0
- package/src/components/ui/GithubCard.astro +191 -0
- package/src/components/ui/LinkButton.astro +21 -0
- package/src/components/ui/Tag.astro +37 -0
- package/src/components/ui/TagCloud.astro +69 -0
- package/src/components/ui/Timeline.astro +39 -0
- package/src/layouts/AboutLayout.astro +24 -0
- package/src/layouts/Layout.astro +329 -0
- package/src/layouts/Main.astro +42 -0
- package/src/layouts/PostDetails.astro +445 -0
- package/src/plugins/rehype-autolink-headings.ts +46 -0
- package/src/plugins/rehype-external-links.ts +35 -0
- package/src/plugins/rehype-table-scroll.ts +35 -0
- package/src/plugins/remark-add-zoomable.ts +28 -0
- package/src/plugins/remark-reading-time.ts +18 -0
- package/src/plugins/shiki-transformers.ts +212 -0
- package/src/scripts/lightbox.ts +63 -0
- package/src/scripts/reading-position.ts +56 -0
- package/src/scripts/theme-utils.ts +19 -0
- package/src/scripts/theme.ts +179 -0
- package/src/scripts/web-vitals.ts +96 -0
- package/src/styles/code-blocks.css +194 -0
- package/src/styles/components.css +252 -0
- package/src/styles/global.css +403 -0
- package/src/styles/typography.css +149 -0
- package/src/types.ts +89 -0
- package/src/utils/generateOgImages.ts +38 -0
- package/src/utils/getCategoryPath.ts +23 -0
- package/src/utils/getPath.ts +52 -0
- package/src/utils/getPostsByCategory.ts +17 -0
- package/src/utils/getPostsByGroupCondition.ts +25 -0
- package/src/utils/getPostsByLang.ts +27 -0
- package/src/utils/getPostsByTag.ts +10 -0
- package/src/utils/getReadingTime.ts +33 -0
- package/src/utils/getRelatedPosts.ts +59 -0
- package/src/utils/getSeriesData.ts +57 -0
- package/src/utils/getSortedPosts.ts +18 -0
- package/src/utils/getTagsWithCount.ts +38 -0
- package/src/utils/getUniqueCategories.ts +81 -0
- package/src/utils/getUniqueTags.ts +23 -0
- package/src/utils/i18n.ts +249 -0
- package/src/utils/loadGoogleFont.ts +38 -0
- package/src/utils/og-templates/post.js +229 -0
- package/src/utils/og-templates/site.js +128 -0
- package/src/utils/pathUtils.ts +17 -0
- package/src/utils/postFilter.ts +11 -0
- package/src/utils/slugify.ts +23 -0
- package/src/utils/toc.ts +27 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CollectionEntry } from "astro:content";
|
|
3
|
+
import IconEdit from "../../assets/icons/IconEdit.svg";
|
|
4
|
+
import { SITE } from "@/config";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
hideEditPost?: CollectionEntry<"blog">["data"]["hideEditPost"];
|
|
8
|
+
class?: string;
|
|
9
|
+
post: CollectionEntry<"blog">;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const { hideEditPost, post, class: className = "" } = Astro.props;
|
|
13
|
+
|
|
14
|
+
const editHref = `${SITE.editPost.url}${post.filePath}`;
|
|
15
|
+
const historyHref = SITE.editPost.url
|
|
16
|
+
? `${SITE.editPost.url.replace("/edit/", "/commits/").replace(/\/$/, "")}/${post.filePath}`
|
|
17
|
+
: "";
|
|
18
|
+
const showEditPost =
|
|
19
|
+
SITE.editPost.enabled && !hideEditPost && editHref.trim() !== "";
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
showEditPost && (
|
|
24
|
+
<span class:list={["flex justify-baseline gap-3", className]}>
|
|
25
|
+
<a
|
|
26
|
+
href={editHref}
|
|
27
|
+
target="_blank"
|
|
28
|
+
rel="noopener noreferrer"
|
|
29
|
+
class="flex items-center gap-1.5 opacity-80 hover:text-accent"
|
|
30
|
+
>
|
|
31
|
+
<IconEdit class="inline-block" />
|
|
32
|
+
<span>{SITE.editPost.text}</span>
|
|
33
|
+
</a>
|
|
34
|
+
{historyHref && (
|
|
35
|
+
<a
|
|
36
|
+
href={historyHref}
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noopener noreferrer"
|
|
39
|
+
class="flex items-center gap-1.5 opacity-80 hover:text-accent"
|
|
40
|
+
title="Version history"
|
|
41
|
+
>
|
|
42
|
+
<svg
|
|
43
|
+
class="inline-block size-5"
|
|
44
|
+
viewBox="0 0 24 24"
|
|
45
|
+
fill="none"
|
|
46
|
+
stroke="currentColor"
|
|
47
|
+
stroke-width="2"
|
|
48
|
+
stroke-linecap="round"
|
|
49
|
+
stroke-linejoin="round"
|
|
50
|
+
>
|
|
51
|
+
<circle cx="12" cy="12" r="10" />
|
|
52
|
+
<polyline points="12 6 12 12 16 14" />
|
|
53
|
+
</svg>
|
|
54
|
+
<span>History</span>
|
|
55
|
+
</a>
|
|
56
|
+
)}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FloatingTOC - A floating table of contents that appears on the right side
|
|
4
|
+
* Features:
|
|
5
|
+
* - Vertical list style
|
|
6
|
+
* - Highlights active section while scrolling
|
|
7
|
+
* - Smooth scrolling
|
|
8
|
+
* - Idle state: Minimalist bars
|
|
9
|
+
* - Hover state: Full text
|
|
10
|
+
* - Auto-expand active section
|
|
11
|
+
*/
|
|
12
|
+
import { type TocHeading, buildNestedHeadings } from "../../utils/toc";
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
headings: TocHeading[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const { headings } = Astro.props;
|
|
19
|
+
|
|
20
|
+
const nestedHeadings = buildNestedHeadings(headings);
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<aside
|
|
24
|
+
class="floating-toc-container group/container fixed top-32 left-[calc(50%+28rem)] z-20 hidden flex-col items-start gap-2 xl:flex"
|
|
25
|
+
aria-label="文章目录"
|
|
26
|
+
data-floating-toc
|
|
27
|
+
>
|
|
28
|
+
{/* Invisible Hover Area: Extends coverage to make hover more stable */}
|
|
29
|
+
<div class="absolute -inset-8 z-[-1] min-w-[200px]"></div>
|
|
30
|
+
|
|
31
|
+
<div
|
|
32
|
+
class="floating-toc-panel relative flex w-auto flex-col items-start gap-1 bg-transparent p-0"
|
|
33
|
+
>
|
|
34
|
+
<nav class="toc-nav max-h-[70vh] pr-1 pl-1">
|
|
35
|
+
<ul class="flex flex-col items-start gap-3">
|
|
36
|
+
{
|
|
37
|
+
nestedHeadings.map(heading => (
|
|
38
|
+
<li
|
|
39
|
+
class="toc-item flex flex-col items-start"
|
|
40
|
+
data-slug={heading.slug}
|
|
41
|
+
>
|
|
42
|
+
<a
|
|
43
|
+
href={`#${heading.slug}`}
|
|
44
|
+
class="toc-link group/link relative block flex min-h-[16px] items-center transition-all duration-300"
|
|
45
|
+
data-toc-slug={heading.slug}
|
|
46
|
+
>
|
|
47
|
+
{/* Bar: Visible in Idle, transitions to 0 width in Container Hover */}
|
|
48
|
+
<span class="toc-bar block h-1 w-[48px] rounded-sm bg-muted transition-all duration-300 group-hover/container:w-0 group-hover/container:opacity-0 group-hover/link:bg-accent group-data-[active=true]/link:bg-accent" />
|
|
49
|
+
|
|
50
|
+
{/* Text: Hidden in Idle, Visible in Container Hover */}
|
|
51
|
+
<span class="toc-text text-muted-foreground pointer-events-none absolute left-0 -translate-x-2 text-sm whitespace-nowrap opacity-0 transition-all duration-300 group-hover/container:pointer-events-auto group-hover/container:translate-x-0 group-hover/container:opacity-100 group-hover/link:text-accent group-data-[active=true]/link:font-medium group-data-[active=true]/link:text-accent">
|
|
52
|
+
{heading.text}
|
|
53
|
+
</span>
|
|
54
|
+
</a>
|
|
55
|
+
|
|
56
|
+
{heading.children.length > 0 && (
|
|
57
|
+
<ul class="toc-sublist mt-3 hidden flex-col items-start gap-2 border-l border-border/0 pl-1">
|
|
58
|
+
{heading.children.map((subHeading: TocHeading) => (
|
|
59
|
+
<li data-slug={subHeading.slug} class="flex justify-start">
|
|
60
|
+
<a
|
|
61
|
+
href={`#${subHeading.slug}`}
|
|
62
|
+
class="toc-link group/link relative block flex min-h-[16px] items-center transition-all duration-300"
|
|
63
|
+
data-toc-slug={subHeading.slug}
|
|
64
|
+
>
|
|
65
|
+
{/* Sub-item Bar: shorter width */}
|
|
66
|
+
<span class="toc-bar block h-1 w-[38px] rounded-sm bg-muted transition-all duration-300 group-hover/container:w-0 group-hover/container:opacity-0 group-hover/link:bg-accent group-data-[active=true]/link:bg-accent" />
|
|
67
|
+
|
|
68
|
+
{/* Sub-item Text */}
|
|
69
|
+
<span class="toc-text text-muted-foreground pointer-events-none absolute left-0 -translate-x-2 text-xs whitespace-nowrap opacity-0 transition-all duration-300 group-hover/container:pointer-events-auto group-hover/container:translate-x-0 group-hover/container:opacity-100 group-hover/link:text-accent group-data-[active=true]/link:font-medium group-data-[active=true]/link:text-accent">
|
|
70
|
+
{subHeading.text}
|
|
71
|
+
</span>
|
|
72
|
+
</a>
|
|
73
|
+
</li>
|
|
74
|
+
))}
|
|
75
|
+
</ul>
|
|
76
|
+
)}
|
|
77
|
+
</li>
|
|
78
|
+
))
|
|
79
|
+
}
|
|
80
|
+
</ul>
|
|
81
|
+
</nav>
|
|
82
|
+
</div>
|
|
83
|
+
</aside>
|
|
84
|
+
|
|
85
|
+
<style>
|
|
86
|
+
/* Ensure sublists are hidden by default */
|
|
87
|
+
.toc-sublist {
|
|
88
|
+
display: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Show sublist when parent is active */
|
|
92
|
+
.toc-item.active .toc-sublist {
|
|
93
|
+
display: flex;
|
|
94
|
+
animation: fadeIn 0.3s ease;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Also show sublist if a child is active */
|
|
98
|
+
.toc-item:has(.toc-sublist .toc-link.active) .toc-sublist {
|
|
99
|
+
display: flex;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@keyframes fadeIn {
|
|
103
|
+
from {
|
|
104
|
+
opacity: 0;
|
|
105
|
+
transform: translateY(-2px);
|
|
106
|
+
}
|
|
107
|
+
to {
|
|
108
|
+
opacity: 1;
|
|
109
|
+
transform: translateY(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Scrollbar hiding */
|
|
114
|
+
.toc-nav {
|
|
115
|
+
scrollbar-width: none; /* Firefox */
|
|
116
|
+
-ms-overflow-style: none; /* IE/Edge */
|
|
117
|
+
}
|
|
118
|
+
.toc-nav::-webkit-scrollbar {
|
|
119
|
+
display: none; /* Chrome/Safari/Opera */
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
122
|
+
|
|
123
|
+
<script>
|
|
124
|
+
function initFloatingTOC() {
|
|
125
|
+
const tocContainer = document.querySelector("[data-floating-toc]");
|
|
126
|
+
if (!tocContainer) return;
|
|
127
|
+
|
|
128
|
+
const tocLinks =
|
|
129
|
+
tocContainer.querySelectorAll<HTMLAnchorElement>("a[data-toc-slug]");
|
|
130
|
+
const tocItems = tocContainer.querySelectorAll(".toc-item");
|
|
131
|
+
const indicator = tocContainer.querySelector<HTMLElement>(".toc-progress");
|
|
132
|
+
|
|
133
|
+
// Intersection Observer
|
|
134
|
+
const observerOptions = {
|
|
135
|
+
root: null,
|
|
136
|
+
rootMargin: "-100px 0px -66% 0px",
|
|
137
|
+
threshold: 0,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const headingElements = new Map<string, Element>();
|
|
141
|
+
|
|
142
|
+
tocLinks.forEach(link => {
|
|
143
|
+
const slug = link.dataset.tocSlug;
|
|
144
|
+
if (slug) {
|
|
145
|
+
const heading = document.getElementById(slug);
|
|
146
|
+
if (heading) {
|
|
147
|
+
headingElements.set(slug, heading);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const observer = new IntersectionObserver(entries => {
|
|
153
|
+
entries.forEach(entry => {
|
|
154
|
+
if (entry.isIntersecting) {
|
|
155
|
+
setActive(entry.target.id);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}, observerOptions);
|
|
159
|
+
|
|
160
|
+
headingElements.forEach(element => {
|
|
161
|
+
observer.observe(element);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function setActive(activeSlug: string) {
|
|
165
|
+
// 1. Update Active Links
|
|
166
|
+
tocLinks.forEach(link => {
|
|
167
|
+
const isActive = link.dataset.tocSlug === activeSlug;
|
|
168
|
+
if (isActive) {
|
|
169
|
+
link.setAttribute("data-active", "true");
|
|
170
|
+
link.classList.add("active");
|
|
171
|
+
} else {
|
|
172
|
+
link.removeAttribute("data-active");
|
|
173
|
+
link.classList.remove("active");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 2. Update Active Items (for sublist expansion)
|
|
178
|
+
tocItems.forEach(item => {
|
|
179
|
+
const slug = item.getAttribute("data-slug");
|
|
180
|
+
const hasActiveChild = item.querySelector(`.toc-link.active`);
|
|
181
|
+
|
|
182
|
+
// If this item is active OR has an active child, mark as active
|
|
183
|
+
if (slug === activeSlug || hasActiveChild) {
|
|
184
|
+
item.classList.add("active");
|
|
185
|
+
} else {
|
|
186
|
+
// Only remove if it's not the parent of the active item
|
|
187
|
+
// Actually, we re-evaluate all.
|
|
188
|
+
// Simpler: Just remove active from all, then add to the one matching activeSlug's parent h2
|
|
189
|
+
item.classList.remove("active");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Re-apply active to parent of currently active link
|
|
194
|
+
if (activeSlug && tocContainer) {
|
|
195
|
+
const activeLink = tocContainer.querySelector(
|
|
196
|
+
`.toc-link[data-toc-slug="${activeSlug}"]`
|
|
197
|
+
);
|
|
198
|
+
if (activeLink) {
|
|
199
|
+
const parentItem = activeLink.closest(".toc-item");
|
|
200
|
+
if (parentItem) {
|
|
201
|
+
parentItem.classList.add("active");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (indicator) {
|
|
206
|
+
const winScroll =
|
|
207
|
+
document.body.scrollTop || document.documentElement.scrollTop;
|
|
208
|
+
const height =
|
|
209
|
+
document.documentElement.scrollHeight -
|
|
210
|
+
document.documentElement.clientHeight;
|
|
211
|
+
const scrolled = (winScroll / height) * 100;
|
|
212
|
+
indicator.style.height = `${scrolled}%`;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Scroll Progress Update (Continuous)
|
|
217
|
+
document.addEventListener("scroll", () => {
|
|
218
|
+
if (indicator) {
|
|
219
|
+
const winScroll =
|
|
220
|
+
document.body.scrollTop || document.documentElement.scrollTop;
|
|
221
|
+
const height =
|
|
222
|
+
document.documentElement.scrollHeight -
|
|
223
|
+
document.documentElement.clientHeight;
|
|
224
|
+
const scrolled = (winScroll / height) * 100;
|
|
225
|
+
indicator.style.height = `${scrolled}%`;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Smooth Scroll Click Handler
|
|
230
|
+
tocLinks.forEach(link => {
|
|
231
|
+
link.addEventListener("click", e => {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
const slug = link.dataset.tocSlug;
|
|
234
|
+
if (slug) {
|
|
235
|
+
const heading = document.getElementById(slug);
|
|
236
|
+
if (heading) {
|
|
237
|
+
history.pushState(null, "", `#${slug}`);
|
|
238
|
+
const headerOffset = 80;
|
|
239
|
+
const elementPosition = heading.getBoundingClientRect().top;
|
|
240
|
+
const offsetPosition =
|
|
241
|
+
elementPosition + window.pageYOffset - headerOffset;
|
|
242
|
+
window.scrollTo({
|
|
243
|
+
top: offsetPosition,
|
|
244
|
+
behavior: "smooth",
|
|
245
|
+
});
|
|
246
|
+
setActive(slug);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Cleanup
|
|
253
|
+
document.addEventListener("astro:before-swap", () => {
|
|
254
|
+
observer.disconnect();
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
document.addEventListener("astro:page-load", initFloatingTOC);
|
|
259
|
+
initFloatingTOC();
|
|
260
|
+
</script>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* InlineTOC - An expandable table of contents below the title
|
|
4
|
+
* Features:
|
|
5
|
+
* - Expand/collapse toggle
|
|
6
|
+
* - Nested heading structure
|
|
7
|
+
* - Click to navigate
|
|
8
|
+
*/
|
|
9
|
+
import { type TocHeading, buildNestedHeadings } from "../../utils/toc";
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
headings: TocHeading[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const { headings } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const nestedHeadings = buildNestedHeadings(headings);
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<div
|
|
21
|
+
class="inline-toc bg-card/50 mt-8 mb-8 rounded-lg border border-border backdrop-blur-sm"
|
|
22
|
+
data-inline-toc
|
|
23
|
+
>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
class="inline-toc-toggle flex w-full items-center justify-between rounded-lg px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
27
|
+
aria-expanded="false"
|
|
28
|
+
aria-controls="inline-toc-content"
|
|
29
|
+
data-toc-toggle
|
|
30
|
+
>
|
|
31
|
+
<div class="flex items-center gap-2">
|
|
32
|
+
<svg
|
|
33
|
+
class="text-muted-foreground h-4 w-4"
|
|
34
|
+
viewBox="0 0 24 24"
|
|
35
|
+
fill="none"
|
|
36
|
+
stroke="currentColor"
|
|
37
|
+
stroke-width="2"
|
|
38
|
+
>
|
|
39
|
+
<path d="M3 12h18M3 6h18M3 18h18"></path>
|
|
40
|
+
</svg>
|
|
41
|
+
<span>目录</span>
|
|
42
|
+
<span
|
|
43
|
+
class="text-muted-foreground rounded-md bg-muted px-1.5 py-0.5 text-xs"
|
|
44
|
+
>
|
|
45
|
+
{nestedHeadings.length} 章节
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
<svg
|
|
49
|
+
class="text-muted-foreground h-4 w-4 transition-transform duration-200"
|
|
50
|
+
viewBox="0 0 24 24"
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
stroke-width="2"
|
|
54
|
+
data-toggle-icon
|
|
55
|
+
>
|
|
56
|
+
<path d="M6 9l6 6 6-6"></path>
|
|
57
|
+
</svg>
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
<div
|
|
61
|
+
id="inline-toc-content"
|
|
62
|
+
class="inline-toc-content grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
|
|
63
|
+
style="grid-template-rows: 0fr;"
|
|
64
|
+
data-toc-content
|
|
65
|
+
>
|
|
66
|
+
<nav class="bg-card/30 min-h-0 border-t border-border">
|
|
67
|
+
<ul class="space-y-1 px-4 py-2">
|
|
68
|
+
{
|
|
69
|
+
nestedHeadings.map(heading => (
|
|
70
|
+
<li>
|
|
71
|
+
<div class="group flex items-center justify-between">
|
|
72
|
+
<a
|
|
73
|
+
href={`#${heading.slug}`}
|
|
74
|
+
class="inline-toc-link text-muted-foreground block flex-1 truncate rounded-md px-2 py-1.5 text-sm font-medium transition-colors hover:bg-accent/5 hover:text-accent"
|
|
75
|
+
data-toc-slug={heading.slug}
|
|
76
|
+
data-toc-depth={heading.depth}
|
|
77
|
+
>
|
|
78
|
+
{heading.text}
|
|
79
|
+
</a>
|
|
80
|
+
{heading.children.length > 0 && (
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
class="text-muted-foreground rounded-md p-1 transition-colors hover:text-accent"
|
|
84
|
+
aria-label="Toggle subheadings"
|
|
85
|
+
onclick={`this.parentElement.nextElementSibling.classList.toggle('hidden'); this.querySelector('svg').classList.toggle('rotate-180')`}
|
|
86
|
+
>
|
|
87
|
+
<svg
|
|
88
|
+
class="h-3.5 w-3.5 transition-transform duration-200"
|
|
89
|
+
viewBox="0 0 24 24"
|
|
90
|
+
fill="none"
|
|
91
|
+
stroke="currentColor"
|
|
92
|
+
stroke-width="2"
|
|
93
|
+
>
|
|
94
|
+
<path d="M6 9l6 6 6-6" />
|
|
95
|
+
</svg>
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{heading.children.length > 0 && (
|
|
101
|
+
<ul class="mt-1 ml-2 hidden space-y-1 border-l border-border/50 pl-4">
|
|
102
|
+
{heading.children.map((subHeading: TocHeading) => (
|
|
103
|
+
<li>
|
|
104
|
+
<a
|
|
105
|
+
href={`#${subHeading.slug}`}
|
|
106
|
+
class="inline-toc-link text-muted-foreground block truncate rounded-md px-2 py-1 text-xs transition-colors hover:bg-accent/5 hover:text-accent"
|
|
107
|
+
data-toc-slug={subHeading.slug}
|
|
108
|
+
data-toc-depth={subHeading.depth}
|
|
109
|
+
>
|
|
110
|
+
{subHeading.text}
|
|
111
|
+
</a>
|
|
112
|
+
</li>
|
|
113
|
+
))}
|
|
114
|
+
</ul>
|
|
115
|
+
)}
|
|
116
|
+
</li>
|
|
117
|
+
))
|
|
118
|
+
}
|
|
119
|
+
</ul>
|
|
120
|
+
</nav>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<style>
|
|
125
|
+
.inline-toc-toggle[aria-expanded="true"] [data-toggle-icon] {
|
|
126
|
+
transform: rotate(180deg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.inline-toc-content[data-expanded="true"] {
|
|
130
|
+
grid-template-rows: 1fr;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.inline-toc-link.active {
|
|
134
|
+
color: var(--accent);
|
|
135
|
+
background-color: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
136
|
+
}
|
|
137
|
+
</style>
|
|
138
|
+
|
|
139
|
+
<script>
|
|
140
|
+
function initInlineTOC() {
|
|
141
|
+
// Only initialize once per container
|
|
142
|
+
const tocContainers = document.querySelectorAll("[data-inline-toc]");
|
|
143
|
+
|
|
144
|
+
tocContainers.forEach(tocContainer => {
|
|
145
|
+
if (tocContainer.hasAttribute("data-initialized")) return;
|
|
146
|
+
tocContainer.setAttribute("data-initialized", "true");
|
|
147
|
+
|
|
148
|
+
const toggle = tocContainer.querySelector("[data-toc-toggle]");
|
|
149
|
+
const content = tocContainer.querySelector("[data-toc-content]");
|
|
150
|
+
const links = tocContainer.querySelectorAll(".inline-toc-link");
|
|
151
|
+
|
|
152
|
+
if (!toggle || !content) return;
|
|
153
|
+
|
|
154
|
+
// Toggle expand/collapse
|
|
155
|
+
const handleToggle = (e: Event) => {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
const isExpanded = toggle.getAttribute("aria-expanded") === "true";
|
|
159
|
+
toggle.setAttribute("aria-expanded", isExpanded ? "false" : "true");
|
|
160
|
+
content.setAttribute("data-expanded", isExpanded ? "false" : "true");
|
|
161
|
+
(content as HTMLElement).style.gridTemplateRows = isExpanded
|
|
162
|
+
? "0fr"
|
|
163
|
+
: "1fr";
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
toggle.addEventListener("click", handleToggle);
|
|
167
|
+
|
|
168
|
+
// Track active section (optional for InlineTOC, but good for consistency)
|
|
169
|
+
const observerOptions = {
|
|
170
|
+
root: null,
|
|
171
|
+
rootMargin: "-80px 0px -70% 0px",
|
|
172
|
+
threshold: 0,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const headingElements = new Map();
|
|
176
|
+
|
|
177
|
+
links.forEach(link => {
|
|
178
|
+
const slug = (link as HTMLElement).dataset.tocSlug;
|
|
179
|
+
if (slug) {
|
|
180
|
+
const heading = document.getElementById(slug);
|
|
181
|
+
if (heading) {
|
|
182
|
+
headingElements.set(slug, heading);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const observer = new IntersectionObserver(entries => {
|
|
188
|
+
entries.forEach(entry => {
|
|
189
|
+
const slug = entry.target.id;
|
|
190
|
+
|
|
191
|
+
if (entry.isIntersecting) {
|
|
192
|
+
updateActiveLink(slug);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}, observerOptions);
|
|
196
|
+
|
|
197
|
+
headingElements.forEach(element => {
|
|
198
|
+
observer.observe(element);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
function updateActiveLink(activeSlug: string) {
|
|
202
|
+
links.forEach(link => {
|
|
203
|
+
const isActive = (link as HTMLElement).dataset.tocSlug === activeSlug;
|
|
204
|
+
link.classList.toggle("active", isActive);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Clean up observer when navigating away (Astro specific)
|
|
209
|
+
document.addEventListener(
|
|
210
|
+
"astro:before-swap",
|
|
211
|
+
() => {
|
|
212
|
+
observer.disconnect();
|
|
213
|
+
},
|
|
214
|
+
{ once: true }
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Handle initial load and view transitions
|
|
220
|
+
document.addEventListener("astro:page-load", initInlineTOC);
|
|
221
|
+
// Also run on DOMContentLoaded for first paint if not using View Transitions or as fallback
|
|
222
|
+
document.addEventListener("DOMContentLoaded", initInlineTOC);
|
|
223
|
+
</script>
|