@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,445 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { render, type CollectionEntry } from "astro:content";
|
|
3
|
+
import Layout from "./Layout.astro";
|
|
4
|
+
import Header from "../components/nav/Header.astro";
|
|
5
|
+
import Footer from "../components/nav/Footer.astro";
|
|
6
|
+
import Tag from "../components/ui/Tag.astro";
|
|
7
|
+
import Datetime from "../components/ui/Datetime.astro";
|
|
8
|
+
import BackButton from "../components/nav/BackButton.astro";
|
|
9
|
+
import { getLocalizedPostPath, getPostSlug } from "../utils/getPath";
|
|
10
|
+
import { t } from "../utils/i18n";
|
|
11
|
+
import { slugifyStr } from "../utils/slugify";
|
|
12
|
+
import IconChevronLeft from "../assets/icons/IconChevronLeft.svg";
|
|
13
|
+
import IconChevronRight from "../assets/icons/IconChevronRight.svg";
|
|
14
|
+
import { SITE } from "@/config";
|
|
15
|
+
import FloatingTOC from "../components/blog/FloatingTOC.astro";
|
|
16
|
+
import InlineTOC from "../components/blog/InlineTOC.astro";
|
|
17
|
+
import Comments from "../components/blog/Comments.astro";
|
|
18
|
+
import PostActions from "../components/blog/PostActions.astro";
|
|
19
|
+
import RelatedPosts from "../components/blog/RelatedPosts.astro";
|
|
20
|
+
import Copyright from "../components/blog/Copyright.astro";
|
|
21
|
+
import SeriesNav from "../components/blog/SeriesNav.astro";
|
|
22
|
+
import { getRelatedPosts } from "../utils/getRelatedPosts";
|
|
23
|
+
import { getReadingTime } from "../utils/getReadingTime";
|
|
24
|
+
import { getCategoryUrl } from "../utils/getCategoryPath";
|
|
25
|
+
|
|
26
|
+
type Props = {
|
|
27
|
+
post: CollectionEntry<"blog">;
|
|
28
|
+
posts: CollectionEntry<"blog">[];
|
|
29
|
+
lang?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const { post, posts, lang = "zh" } = Astro.props;
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
title,
|
|
36
|
+
author,
|
|
37
|
+
description,
|
|
38
|
+
ogImage: initOgImage,
|
|
39
|
+
canonicalURL,
|
|
40
|
+
pubDatetime,
|
|
41
|
+
modDatetime,
|
|
42
|
+
timezone,
|
|
43
|
+
tags,
|
|
44
|
+
category,
|
|
45
|
+
series,
|
|
46
|
+
} = post.data;
|
|
47
|
+
|
|
48
|
+
const { Content, headings } = await render(post);
|
|
49
|
+
const { minutes: readingMinutes, words: wordCount } = getReadingTime(
|
|
50
|
+
post.body ?? ""
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Banner image: only real local assets or valid remote URLs (not dynamic OG stubs)
|
|
54
|
+
let bannerImageUrl: string | undefined;
|
|
55
|
+
if (initOgImage && typeof initOgImage === "object" && "src" in initOgImage) {
|
|
56
|
+
bannerImageUrl = initOgImage.src;
|
|
57
|
+
} else if (typeof initOgImage === "string" && initOgImage.startsWith("http")) {
|
|
58
|
+
bannerImageUrl = initOgImage;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// OG meta image: includes dynamic fallback for social sharing
|
|
62
|
+
let ogImageUrl: string | undefined = bannerImageUrl;
|
|
63
|
+
if (!ogImageUrl && SITE.dynamicOgImage) {
|
|
64
|
+
ogImageUrl = `/${lang}/posts/${getPostSlug(post.id)}/index.png`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ogImage = ogImageUrl
|
|
68
|
+
? new URL(ogImageUrl, Astro.url.origin).href
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
const layoutProps = {
|
|
72
|
+
title: `${title} | ${SITE.title}`,
|
|
73
|
+
author,
|
|
74
|
+
description,
|
|
75
|
+
pubDatetime,
|
|
76
|
+
modDatetime,
|
|
77
|
+
canonicalURL,
|
|
78
|
+
ogImage,
|
|
79
|
+
scrollSmooth: true,
|
|
80
|
+
lang,
|
|
81
|
+
tags,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const breadcrumbList = {
|
|
85
|
+
"@context": "https://schema.org",
|
|
86
|
+
"@type": "BreadcrumbList",
|
|
87
|
+
itemListElement: [
|
|
88
|
+
{
|
|
89
|
+
"@type": "ListItem",
|
|
90
|
+
position: 1,
|
|
91
|
+
name: lang === "zh" ? "首页" : "Home",
|
|
92
|
+
item: `${SITE.website}${lang}/`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"@type": "ListItem",
|
|
96
|
+
position: 2,
|
|
97
|
+
name: lang === "zh" ? "文章" : "Posts",
|
|
98
|
+
item: `${SITE.website}${lang}/posts/`,
|
|
99
|
+
},
|
|
100
|
+
...(category
|
|
101
|
+
? [
|
|
102
|
+
{
|
|
103
|
+
"@type": "ListItem",
|
|
104
|
+
position: 3,
|
|
105
|
+
name: category,
|
|
106
|
+
item: `${SITE.website.replace(/\/$/, "")}${getCategoryUrl(lang, category)}`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"@type": "ListItem",
|
|
110
|
+
position: 4,
|
|
111
|
+
name: title,
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
: [
|
|
115
|
+
{
|
|
116
|
+
"@type": "ListItem",
|
|
117
|
+
position: 3,
|
|
118
|
+
name: title,
|
|
119
|
+
},
|
|
120
|
+
]),
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/* ========== Prev/Next Posts (Series-aware) ========== */
|
|
125
|
+
|
|
126
|
+
const seriesPosts = series
|
|
127
|
+
? posts
|
|
128
|
+
.filter(p => p.data.series?.name === series.name)
|
|
129
|
+
.sort((a, b) => (a.data.series?.order ?? 0) - (b.data.series?.order ?? 0))
|
|
130
|
+
: null;
|
|
131
|
+
|
|
132
|
+
const navList = (seriesPosts ?? posts).map(
|
|
133
|
+
({ data: { title }, id, filePath }) => ({
|
|
134
|
+
id,
|
|
135
|
+
title,
|
|
136
|
+
filePath,
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const currentPostIndex = navList.findIndex(a => a.id === post.id);
|
|
141
|
+
const prevPost = currentPostIndex > 0 ? navList[currentPostIndex - 1] : null;
|
|
142
|
+
const nextPost =
|
|
143
|
+
currentPostIndex >= 0 && currentPostIndex < navList.length - 1
|
|
144
|
+
? navList[currentPostIndex + 1]
|
|
145
|
+
: null;
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
<Layout {...layoutProps}>
|
|
149
|
+
<script
|
|
150
|
+
type="application/ld+json"
|
|
151
|
+
is:inline
|
|
152
|
+
set:html={JSON.stringify(breadcrumbList)}
|
|
153
|
+
/>
|
|
154
|
+
<Header lang={lang} />
|
|
155
|
+
|
|
156
|
+
<!-- Sticky Header for Article Reading Progress -->
|
|
157
|
+
<div
|
|
158
|
+
id="sticky-header"
|
|
159
|
+
class="fixed start-0 top-0 z-50 w-full -translate-y-full transform border-b border-border bg-background/80 opacity-0 backdrop-blur-md transition-all duration-300"
|
|
160
|
+
>
|
|
161
|
+
<div class="app-layout flex h-14 items-center justify-between">
|
|
162
|
+
<div class="flex items-center gap-4 overflow-hidden">
|
|
163
|
+
<span
|
|
164
|
+
class="cursor-pointer truncate text-sm font-medium text-foreground hover:text-accent"
|
|
165
|
+
onclick="window.scrollTo({top: 0, behavior: 'smooth'})"
|
|
166
|
+
>
|
|
167
|
+
{title}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="flex items-center gap-2">
|
|
171
|
+
<button
|
|
172
|
+
id="sticky-toc-toggle"
|
|
173
|
+
class="text-muted-foreground flex items-center gap-1 rounded-md p-2 hover:bg-muted/50 hover:text-accent sm:hidden"
|
|
174
|
+
aria-label="Toggle Table of Contents"
|
|
175
|
+
>
|
|
176
|
+
<svg
|
|
177
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
178
|
+
class="h-5 w-5"
|
|
179
|
+
viewBox="0 0 24 24"
|
|
180
|
+
fill="none"
|
|
181
|
+
stroke="currentColor"
|
|
182
|
+
stroke-width="2"
|
|
183
|
+
stroke-linecap="round"
|
|
184
|
+
stroke-linejoin="round"
|
|
185
|
+
>
|
|
186
|
+
<line x1="8" y1="6" x2="21" y2="6"></line>
|
|
187
|
+
<line x1="8" y1="12" x2="21" y2="12"></line>
|
|
188
|
+
<line x1="8" y1="18" x2="21" y2="18"></line>
|
|
189
|
+
<line x1="3" y1="6" x2="3.01" y2="6"></line>
|
|
190
|
+
<line x1="3" y1="12" x2="3.01" y2="12"></line>
|
|
191
|
+
<line x1="3" y1="18" x2="3.01" y2="18"></line>
|
|
192
|
+
</svg>
|
|
193
|
+
</button>
|
|
194
|
+
<button
|
|
195
|
+
class="text-muted-foreground rounded-md p-2 hover:bg-muted/50 hover:text-accent"
|
|
196
|
+
onclick="window.scrollTo({top: 0, behavior: 'smooth'})"
|
|
197
|
+
aria-label="Scroll to top"
|
|
198
|
+
>
|
|
199
|
+
<svg
|
|
200
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
201
|
+
class="h-5 w-5"
|
|
202
|
+
viewBox="0 0 24 24"
|
|
203
|
+
fill="none"
|
|
204
|
+
stroke="currentColor"
|
|
205
|
+
stroke-width="2"
|
|
206
|
+
stroke-linecap="round"
|
|
207
|
+
stroke-linejoin="round"
|
|
208
|
+
>
|
|
209
|
+
<path d="M12 19V5"></path>
|
|
210
|
+
<polyline points="5 12 12 5 19 12"></polyline>
|
|
211
|
+
</svg>
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div class="absolute start-0 bottom-0 h-[2px] w-full bg-muted/30">
|
|
216
|
+
<div
|
|
217
|
+
id="progress-bar"
|
|
218
|
+
class="h-full w-0 bg-accent transition-all duration-150 ease-out"
|
|
219
|
+
>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<BackButton />
|
|
225
|
+
<main
|
|
226
|
+
id="main-content"
|
|
227
|
+
class:list={["app-layout pb-12", { "mt-8": !SITE.showBackButton }]}
|
|
228
|
+
data-pagefind-body
|
|
229
|
+
>
|
|
230
|
+
{/* Article Hero Section */}
|
|
231
|
+
<div class="mb-6">
|
|
232
|
+
{bannerImageUrl && (
|
|
233
|
+
<div class="relative -mx-4 mb-6 h-48 overflow-hidden rounded-xl sm:h-56 md:h-64">
|
|
234
|
+
<img
|
|
235
|
+
src={bannerImageUrl}
|
|
236
|
+
alt={title}
|
|
237
|
+
class="h-full w-full object-cover object-center"
|
|
238
|
+
loading="eager"
|
|
239
|
+
decoding="async"
|
|
240
|
+
fetchpriority="high"
|
|
241
|
+
width="1200"
|
|
242
|
+
height="400"
|
|
243
|
+
/>
|
|
244
|
+
<div class="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
<h1
|
|
249
|
+
transition:name={slugifyStr(title.replaceAll(".", "-"))}
|
|
250
|
+
class="inline-block text-2xl font-bold text-accent sm:text-3xl"
|
|
251
|
+
>
|
|
252
|
+
{title}
|
|
253
|
+
</h1>
|
|
254
|
+
|
|
255
|
+
{description && (
|
|
256
|
+
<p class="mt-2 text-base text-foreground-soft leading-relaxed">{description}</p>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{/* Metadata bar */}
|
|
260
|
+
<div class="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-border/60 bg-card/40 px-4 py-3">
|
|
261
|
+
{category && (
|
|
262
|
+
<a
|
|
263
|
+
href={getCategoryUrl(lang, category)}
|
|
264
|
+
class="text-sm font-medium text-accent underline decoration-accent/30 underline-offset-2 hover:decoration-accent"
|
|
265
|
+
>
|
|
266
|
+
{category}
|
|
267
|
+
</a>
|
|
268
|
+
)}
|
|
269
|
+
{category && <span class="text-border">|</span>}
|
|
270
|
+
<Datetime {pubDatetime} {modDatetime} {timezone} size="lg" lang={lang} />
|
|
271
|
+
<span class="text-border">|</span>
|
|
272
|
+
<span
|
|
273
|
+
class="flex items-center gap-1 text-sm opacity-80 sm:text-base"
|
|
274
|
+
title={t("post.wordCount", lang).replace("{count}", String(wordCount))}
|
|
275
|
+
>
|
|
276
|
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
277
|
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
|
278
|
+
</svg>
|
|
279
|
+
{t("post.readingTime", lang).replace("{min}", String(readingMinutes))}
|
|
280
|
+
</span>
|
|
281
|
+
<span class="text-border">|</span>
|
|
282
|
+
<span class="text-sm opacity-80">
|
|
283
|
+
{wordCount.toLocaleString()} {lang === "zh" ? "字" : "words"}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* Tags preview */}
|
|
288
|
+
{tags.length > 0 && (
|
|
289
|
+
<div class="mt-3 flex flex-wrap gap-2" data-pagefind-ignore>
|
|
290
|
+
{tags.map(tag => (
|
|
291
|
+
<a
|
|
292
|
+
href={`/${lang}/tags/${slugifyStr(tag)}/`}
|
|
293
|
+
class="rounded-md bg-muted/60 px-2.5 py-1 text-xs font-medium text-foreground-soft transition-colors hover:bg-accent/10 hover:text-accent"
|
|
294
|
+
>
|
|
295
|
+
#{tag}
|
|
296
|
+
</a>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<InlineTOC headings={headings} />
|
|
303
|
+
<article
|
|
304
|
+
id="article"
|
|
305
|
+
class="app-prose mt-8 w-full max-w-app"
|
|
306
|
+
>
|
|
307
|
+
<Content />
|
|
308
|
+
</article>
|
|
309
|
+
<slot name="post-scripts" />
|
|
310
|
+
<FloatingTOC headings={headings} />
|
|
311
|
+
|
|
312
|
+
<hr class="my-8 border-dashed" />
|
|
313
|
+
|
|
314
|
+
<ul class="mt-4 mb-8 flex flex-wrap gap-4 sm:my-8">
|
|
315
|
+
{
|
|
316
|
+
tags.map(tag => (
|
|
317
|
+
<Tag tag={slugifyStr(tag)} tagName={tag} size="sm" lang={lang} />
|
|
318
|
+
))
|
|
319
|
+
}
|
|
320
|
+
</ul>
|
|
321
|
+
|
|
322
|
+
<Copyright title={title} pubDatetime={pubDatetime} lang={lang} />
|
|
323
|
+
|
|
324
|
+
<PostActions />
|
|
325
|
+
|
|
326
|
+
<hr class="my-6 border-dashed" />
|
|
327
|
+
|
|
328
|
+
<div data-pagefind-ignore>
|
|
329
|
+
<SeriesNav post={post} lang={lang} />
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<!-- Previous/Next Post Buttons -->
|
|
333
|
+
<div data-pagefind-ignore class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
|
334
|
+
{
|
|
335
|
+
prevPost && (
|
|
336
|
+
<a
|
|
337
|
+
href={getLocalizedPostPath(lang, prevPost.id)}
|
|
338
|
+
class="flex w-full gap-1 hover:opacity-75"
|
|
339
|
+
>
|
|
340
|
+
<IconChevronLeft class="inline-block flex-none rtl:rotate-180" />
|
|
341
|
+
<div>
|
|
342
|
+
<span>{t("post.prev", lang)}</span>
|
|
343
|
+
<div class="text-sm text-accent/85">{prevPost.title}</div>
|
|
344
|
+
</div>
|
|
345
|
+
</a>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
{
|
|
349
|
+
nextPost && (
|
|
350
|
+
<a
|
|
351
|
+
href={getLocalizedPostPath(lang, nextPost.id)}
|
|
352
|
+
class="flex w-full justify-end gap-1 text-end hover:opacity-75 sm:col-start-2"
|
|
353
|
+
>
|
|
354
|
+
<div>
|
|
355
|
+
<span>{t("post.next", lang)}</span>
|
|
356
|
+
<div class="text-sm text-accent/85">{nextPost.title}</div>
|
|
357
|
+
</div>
|
|
358
|
+
<IconChevronRight class="inline-block flex-none rtl:rotate-180" />
|
|
359
|
+
</a>
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<RelatedPosts posts={getRelatedPosts(post, posts)} lang={lang} />
|
|
365
|
+
|
|
366
|
+
<Comments />
|
|
367
|
+
</main>
|
|
368
|
+
<Footer lang={lang} />
|
|
369
|
+
</Layout>
|
|
370
|
+
|
|
371
|
+
<script is:inline data-astro-rerun>
|
|
372
|
+
function initStickyHeader() {
|
|
373
|
+
const header = document.getElementById("sticky-header");
|
|
374
|
+
const progressBar = document.getElementById("progress-bar");
|
|
375
|
+
const tocToggle = document.getElementById("sticky-toc-toggle");
|
|
376
|
+
|
|
377
|
+
if (!header || !progressBar) return;
|
|
378
|
+
|
|
379
|
+
// Handle scroll
|
|
380
|
+
document.addEventListener("scroll", () => {
|
|
381
|
+
const winScroll =
|
|
382
|
+
document.body.scrollTop || document.documentElement.scrollTop;
|
|
383
|
+
const height =
|
|
384
|
+
document.documentElement.scrollHeight -
|
|
385
|
+
document.documentElement.clientHeight;
|
|
386
|
+
const scrolled = (winScroll / height) * 100;
|
|
387
|
+
|
|
388
|
+
// Update progress bar
|
|
389
|
+
progressBar.style.width = scrolled + "%";
|
|
390
|
+
|
|
391
|
+
// Show/hide header based on scroll position (e.g. past 300px)
|
|
392
|
+
if (winScroll > 300) {
|
|
393
|
+
header.classList.remove("-translate-y-full", "opacity-0");
|
|
394
|
+
} else {
|
|
395
|
+
header.classList.add("-translate-y-full", "opacity-0");
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Handle TOC toggle
|
|
400
|
+
if (tocToggle) {
|
|
401
|
+
tocToggle.addEventListener("click", () => {
|
|
402
|
+
// Scroll to InlineTOC and open it
|
|
403
|
+
const inlineToc = document.querySelector("[data-inline-toc]");
|
|
404
|
+
if (inlineToc) {
|
|
405
|
+
inlineToc.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
406
|
+
const toggleBtn = inlineToc.querySelector("[data-toc-toggle]");
|
|
407
|
+
if (
|
|
408
|
+
toggleBtn &&
|
|
409
|
+
toggleBtn.getAttribute("aria-expanded") === "false"
|
|
410
|
+
) {
|
|
411
|
+
toggleBtn.click();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
initStickyHeader();
|
|
418
|
+
|
|
419
|
+
/** Attaches links to headings in the document,
|
|
420
|
+
* allowing sharing of sections easily */
|
|
421
|
+
function addHeadingLinks() {
|
|
422
|
+
const headings = Array.from(
|
|
423
|
+
document.querySelectorAll("h2, h3, h4, h5, h6")
|
|
424
|
+
);
|
|
425
|
+
for (const heading of headings) {
|
|
426
|
+
heading.classList.add("group");
|
|
427
|
+
const link = document.createElement("a");
|
|
428
|
+
link.className =
|
|
429
|
+
"heading-link ms-2 no-underline opacity-75 md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100";
|
|
430
|
+
link.href = "#" + heading.id;
|
|
431
|
+
|
|
432
|
+
const span = document.createElement("span");
|
|
433
|
+
span.ariaHidden = "true";
|
|
434
|
+
span.innerText = "#";
|
|
435
|
+
link.appendChild(span);
|
|
436
|
+
heading.appendChild(link);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
addHeadingLinks();
|
|
440
|
+
|
|
441
|
+
/* Go to page start after page swap */
|
|
442
|
+
document.addEventListener("astro:after-swap", () =>
|
|
443
|
+
window.scrollTo({ left: 0, top: 0, behavior: "instant" })
|
|
444
|
+
);
|
|
445
|
+
</script>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
interface HastNode {
|
|
2
|
+
type: string;
|
|
3
|
+
tagName?: string;
|
|
4
|
+
properties?: Record<string, unknown>;
|
|
5
|
+
children?: HastNode[];
|
|
6
|
+
value?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const HEADING_RE = /^h[1-6]$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rehype plugin that prepends a `#` anchor link to headings that have an `id`.
|
|
13
|
+
*/
|
|
14
|
+
export function rehypeAutolinkHeadings() {
|
|
15
|
+
return function (tree: HastNode) {
|
|
16
|
+
walkHeadings(tree);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function walkHeadings(node: HastNode) {
|
|
21
|
+
if (
|
|
22
|
+
node.type === "element" &&
|
|
23
|
+
node.tagName &&
|
|
24
|
+
HEADING_RE.test(node.tagName) &&
|
|
25
|
+
node.properties?.id
|
|
26
|
+
) {
|
|
27
|
+
const link: HastNode = {
|
|
28
|
+
type: "element",
|
|
29
|
+
tagName: "a",
|
|
30
|
+
properties: {
|
|
31
|
+
href: `#${node.properties.id}`,
|
|
32
|
+
class: "heading-anchor",
|
|
33
|
+
ariaHidden: "true",
|
|
34
|
+
tabIndex: -1,
|
|
35
|
+
},
|
|
36
|
+
children: [{ type: "text", value: "#" }],
|
|
37
|
+
};
|
|
38
|
+
node.children = node.children || [];
|
|
39
|
+
node.children.unshift(link);
|
|
40
|
+
}
|
|
41
|
+
if (node.children) {
|
|
42
|
+
for (const child of node.children) {
|
|
43
|
+
walkHeadings(child);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface HastNode {
|
|
2
|
+
type: string;
|
|
3
|
+
tagName?: string;
|
|
4
|
+
properties?: Record<string, unknown>;
|
|
5
|
+
children?: HastNode[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rehype plugin that adds `rel="nofollow noopener noreferrer"` and
|
|
10
|
+
* `target="_blank"` to external links (those starting with `http`).
|
|
11
|
+
*/
|
|
12
|
+
export function rehypeExternalLinks() {
|
|
13
|
+
return function (tree: HastNode) {
|
|
14
|
+
walkLinks(tree);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function walkLinks(node: HastNode) {
|
|
19
|
+
if (
|
|
20
|
+
node.type === "element" &&
|
|
21
|
+
node.tagName === "a" &&
|
|
22
|
+
node.properties
|
|
23
|
+
) {
|
|
24
|
+
const href = node.properties.href as string | undefined;
|
|
25
|
+
if (href && /^https?:\/\//.test(href)) {
|
|
26
|
+
node.properties.target = "_blank";
|
|
27
|
+
node.properties.rel = "nofollow noopener noreferrer";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (node.children) {
|
|
31
|
+
for (const child of node.children) {
|
|
32
|
+
walkLinks(child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface HastNode {
|
|
2
|
+
type: string;
|
|
3
|
+
tagName?: string;
|
|
4
|
+
properties?: Record<string, unknown>;
|
|
5
|
+
children?: HastNode[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rehype plugin that wraps `<table>` elements in a scrollable container
|
|
10
|
+
* to prevent horizontal overflow on mobile.
|
|
11
|
+
*/
|
|
12
|
+
export function rehypeTableScroll() {
|
|
13
|
+
return function (tree: HastNode) {
|
|
14
|
+
walkAndWrapTables(tree);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function walkAndWrapTables(node: HastNode) {
|
|
19
|
+
if (!node.children) return;
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
22
|
+
const child = node.children[i];
|
|
23
|
+
if (child.type === "element" && child.tagName === "table") {
|
|
24
|
+
const wrapper: HastNode = {
|
|
25
|
+
type: "element",
|
|
26
|
+
tagName: "div",
|
|
27
|
+
properties: { className: ["overflow-x-auto", "w-full"] },
|
|
28
|
+
children: [child],
|
|
29
|
+
};
|
|
30
|
+
node.children[i] = wrapper;
|
|
31
|
+
} else {
|
|
32
|
+
walkAndWrapTables(child);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
interface MdastNode {
|
|
2
|
+
type: string;
|
|
3
|
+
children?: MdastNode[];
|
|
4
|
+
data?: { hProperties?: Record<string, unknown>; [key: string]: unknown };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function visitImages(node: MdastNode, fn: (n: MdastNode) => void) {
|
|
8
|
+
if (node.type === "image") fn(node);
|
|
9
|
+
if (node.children) {
|
|
10
|
+
for (const child of node.children) {
|
|
11
|
+
visitImages(child, fn);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Remark plugin that adds a `zoomable` class to all images,
|
|
18
|
+
* enabling click-to-zoom via the lightbox script.
|
|
19
|
+
*/
|
|
20
|
+
export function remarkAddZoomable({ className = "zoomable" } = {}) {
|
|
21
|
+
return function (tree: MdastNode) {
|
|
22
|
+
visitImages(tree, node => {
|
|
23
|
+
node.data = node.data || {};
|
|
24
|
+
node.data.hProperties = node.data.hProperties || {};
|
|
25
|
+
(node.data.hProperties as Record<string, unknown>).class = className;
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getReadingTime } from "../utils/getReadingTime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remark plugin that calculates reading time and word count,
|
|
5
|
+
* injecting them into `data.astro.frontmatter`.
|
|
6
|
+
*/
|
|
7
|
+
export function remarkReadingTime() {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
return function (_tree: any, file: any) {
|
|
10
|
+
const content = file.value as string;
|
|
11
|
+
const { minutes, words } = getReadingTime(content);
|
|
12
|
+
const data = file.data as { astro?: { frontmatter?: Record<string, unknown> } };
|
|
13
|
+
if (data.astro?.frontmatter) {
|
|
14
|
+
data.astro.frontmatter.minutesRead = `${minutes} min`;
|
|
15
|
+
data.astro.frontmatter.words = words;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|