@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,52 @@
|
|
|
1
|
+
import { BLOG_PATH } from "@/content.config";
|
|
2
|
+
import { slugifyStr } from "./slugify";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get full path of a blog post
|
|
6
|
+
* @param id - id of the blog post (aka slug)
|
|
7
|
+
* @param filePath - the blog post full file location
|
|
8
|
+
* @param includeBase - whether to include `/posts` in return value
|
|
9
|
+
* @returns blog post path
|
|
10
|
+
*/
|
|
11
|
+
export function getPath(
|
|
12
|
+
id: string,
|
|
13
|
+
filePath: string | undefined,
|
|
14
|
+
includeBase = true
|
|
15
|
+
) {
|
|
16
|
+
const pathSegments = filePath
|
|
17
|
+
?.replace(BLOG_PATH, "")
|
|
18
|
+
.split("/")
|
|
19
|
+
.filter(path => path !== "") // remove empty string in the segments ["", "other-path"] <- empty string will be removed
|
|
20
|
+
.filter(path => !path.startsWith("_")) // exclude directories start with underscore "_"
|
|
21
|
+
.slice(0, -1) // remove the last segment_ file name_ since it's unnecessary
|
|
22
|
+
.map(segment => slugifyStr(segment)); // slugify each segment path
|
|
23
|
+
|
|
24
|
+
const basePath = includeBase ? "/posts" : "";
|
|
25
|
+
|
|
26
|
+
// Making sure `id` does not contain the directory
|
|
27
|
+
const blogId = id.split("/");
|
|
28
|
+
const slug = blogId.length > 0 ? blogId.slice(-1) : blogId;
|
|
29
|
+
|
|
30
|
+
// If not inside the sub-dir, simply return the file path
|
|
31
|
+
if (!pathSegments || pathSegments.length < 1) {
|
|
32
|
+
return [basePath, slug].join("/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [basePath, ...pathSegments, slug].join("/");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Slug for URL (filename without path, for use with [lang] routing) */
|
|
39
|
+
export function getPostSlug(id: string): string {
|
|
40
|
+
const parts = id.split("/");
|
|
41
|
+
return parts.length > 0 ? parts[parts.length - 1] : id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Localized post path: /{lang}/posts/{slug} */
|
|
45
|
+
export function getLocalizedPostPath(
|
|
46
|
+
lang: string,
|
|
47
|
+
id: string,
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
49
|
+
_filePath?: string
|
|
50
|
+
): string {
|
|
51
|
+
return `/${lang}/posts/${getPostSlug(id)}`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import getSortedPosts from "./getSortedPosts";
|
|
3
|
+
|
|
4
|
+
const getPostsByCategory = (
|
|
5
|
+
posts: CollectionEntry<"blog">[],
|
|
6
|
+
category: string
|
|
7
|
+
) =>
|
|
8
|
+
getSortedPosts(
|
|
9
|
+
posts.filter(
|
|
10
|
+
post =>
|
|
11
|
+
post.data.category &&
|
|
12
|
+
(post.data.category === category ||
|
|
13
|
+
post.data.category.startsWith(category + "/"))
|
|
14
|
+
)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export default getPostsByCategory;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
|
|
3
|
+
type GroupKey = string | number | symbol;
|
|
4
|
+
|
|
5
|
+
interface GroupFunction<T> {
|
|
6
|
+
(item: T, index?: number): GroupKey;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const getPostsByGroupCondition = (
|
|
10
|
+
posts: CollectionEntry<"blog">[],
|
|
11
|
+
groupFunction: GroupFunction<CollectionEntry<"blog">>
|
|
12
|
+
) => {
|
|
13
|
+
const result: Record<GroupKey, CollectionEntry<"blog">[]> = {};
|
|
14
|
+
for (let i = 0; i < posts.length; i++) {
|
|
15
|
+
const item = posts[i];
|
|
16
|
+
const groupKey = groupFunction(item, i);
|
|
17
|
+
if (!result[groupKey]) {
|
|
18
|
+
result[groupKey] = [];
|
|
19
|
+
}
|
|
20
|
+
result[groupKey].push(item);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default getPostsByGroupCondition;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect language from post filePath (directory-based).
|
|
5
|
+
* Posts under .../zh/ → "zh", posts under .../en/ → "en".
|
|
6
|
+
* Fallback: check id for "zh/" or "en/" prefix. Default "zh".
|
|
7
|
+
*/
|
|
8
|
+
export function getPostLang(post: CollectionEntry<"blog">): "zh" | "en" {
|
|
9
|
+
const path = post.filePath ?? post.id;
|
|
10
|
+
if (path.includes("/zh/")) return "zh";
|
|
11
|
+
if (path.includes("/en/")) return "en";
|
|
12
|
+
if (post.id.startsWith("zh/")) return "zh";
|
|
13
|
+
if (post.id.startsWith("en/")) return "en";
|
|
14
|
+
return "zh";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Filter blog posts by language (directory-based).
|
|
19
|
+
*/
|
|
20
|
+
export function getPostsByLang(
|
|
21
|
+
posts: CollectionEntry<"blog">[],
|
|
22
|
+
lang: "zh" | "en" | string
|
|
23
|
+
): CollectionEntry<"blog">[] {
|
|
24
|
+
return posts.filter(post => getPostLang(post) === lang);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default getPostsByLang;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import getSortedPosts from "./getSortedPosts";
|
|
3
|
+
import { slugifyAll } from "./slugify";
|
|
4
|
+
|
|
5
|
+
const getPostsByTag = (posts: CollectionEntry<"blog">[], tag: string) =>
|
|
6
|
+
getSortedPosts(
|
|
7
|
+
posts.filter(post => slugifyAll(post.data.tags).includes(tag))
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export default getPostsByTag;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g;
|
|
2
|
+
const WORD_REGEX = /\b[a-zA-Z0-9]+(?:[-'][a-zA-Z0-9]+)*\b/g;
|
|
3
|
+
|
|
4
|
+
const CJK_WPM = 350;
|
|
5
|
+
const EN_WPM = 230;
|
|
6
|
+
|
|
7
|
+
export function getReadingTime(content: string): {
|
|
8
|
+
minutes: number;
|
|
9
|
+
words: number;
|
|
10
|
+
} {
|
|
11
|
+
const text = content
|
|
12
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
13
|
+
.replace(/`[^`]*`/g, "")
|
|
14
|
+
.replace(/!\[.*?\]\(.*?\)/g, "")
|
|
15
|
+
.replace(/\[([^\]]*)\]\(.*?\)/g, "$1")
|
|
16
|
+
.replace(/<[^>]*>/g, "")
|
|
17
|
+
.replace(/^---[\s\S]*?---/m, "")
|
|
18
|
+
.replace(/#+\s/g, "")
|
|
19
|
+
.replace(/[*_~]/g, "");
|
|
20
|
+
|
|
21
|
+
const cjkChars = text.match(CJK_REGEX) || [];
|
|
22
|
+
const enWords = text.match(WORD_REGEX) || [];
|
|
23
|
+
|
|
24
|
+
const cjkCount = cjkChars.length;
|
|
25
|
+
const enCount = enWords.filter(
|
|
26
|
+
w => !CJK_REGEX.test(w) && w.length > 0
|
|
27
|
+
).length;
|
|
28
|
+
|
|
29
|
+
const totalWords = cjkCount + enCount;
|
|
30
|
+
const minutes = Math.max(1, Math.ceil(cjkCount / CJK_WPM + enCount / EN_WPM));
|
|
31
|
+
|
|
32
|
+
return { minutes, words: totalWords };
|
|
33
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import { slugifyStr } from "./slugify";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculate content similarity score between two posts
|
|
6
|
+
* based on shared tags and category overlap.
|
|
7
|
+
*/
|
|
8
|
+
function getSimilarityScore(
|
|
9
|
+
a: CollectionEntry<"blog">,
|
|
10
|
+
b: CollectionEntry<"blog">
|
|
11
|
+
): number {
|
|
12
|
+
if (a.id === b.id) return -1;
|
|
13
|
+
|
|
14
|
+
let score = 0;
|
|
15
|
+
|
|
16
|
+
const aTags = new Set(a.data.tags.map(t => slugifyStr(t)));
|
|
17
|
+
const bTags = new Set(b.data.tags.map(t => slugifyStr(t)));
|
|
18
|
+
for (const tag of aTags) {
|
|
19
|
+
if (bTags.has(tag)) score += 3;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (a.data.category && b.data.category) {
|
|
23
|
+
if (a.data.category === b.data.category) {
|
|
24
|
+
score += 5;
|
|
25
|
+
} else {
|
|
26
|
+
const aParts = a.data.category.split("/");
|
|
27
|
+
const bParts = b.data.category.split("/");
|
|
28
|
+
if (aParts[0] === bParts[0]) score += 2;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
a.data.series &&
|
|
34
|
+
b.data.series &&
|
|
35
|
+
a.data.series.name === b.data.series.name
|
|
36
|
+
) {
|
|
37
|
+
score += 4;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return score;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get related posts sorted by similarity score.
|
|
45
|
+
*/
|
|
46
|
+
export function getRelatedPosts(
|
|
47
|
+
currentPost: CollectionEntry<"blog">,
|
|
48
|
+
allPosts: CollectionEntry<"blog">[],
|
|
49
|
+
count = 3
|
|
50
|
+
): CollectionEntry<"blog">[] {
|
|
51
|
+
return allPosts
|
|
52
|
+
.map(post => ({ post, score: getSimilarityScore(currentPost, post) }))
|
|
53
|
+
.filter(({ score }) => score > 0)
|
|
54
|
+
.sort((a, b) => b.score - a.score)
|
|
55
|
+
.slice(0, count)
|
|
56
|
+
.map(({ post }) => post);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default getRelatedPosts;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import postFilter from "./postFilter";
|
|
3
|
+
import { slugifyStr } from "./slugify";
|
|
4
|
+
|
|
5
|
+
export interface SeriesInfo {
|
|
6
|
+
name: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
posts: CollectionEntry<"blog">[];
|
|
9
|
+
description?: string;
|
|
10
|
+
totalPosts: number;
|
|
11
|
+
latestDate: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function slugifySeries(name: string): string {
|
|
15
|
+
return slugifyStr(name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSeriesFromPosts(
|
|
19
|
+
posts: CollectionEntry<"blog">[]
|
|
20
|
+
): SeriesInfo[] {
|
|
21
|
+
const seriesMap = new Map<string, CollectionEntry<"blog">[]>();
|
|
22
|
+
|
|
23
|
+
posts.filter(postFilter).forEach(post => {
|
|
24
|
+
const series = post.data.series;
|
|
25
|
+
if (!series) return;
|
|
26
|
+
const existing = seriesMap.get(series.name) ?? [];
|
|
27
|
+
existing.push(post);
|
|
28
|
+
seriesMap.set(series.name, existing);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return Array.from(seriesMap.entries())
|
|
32
|
+
.map(([name, seriesPosts]) => {
|
|
33
|
+
const sorted = seriesPosts.sort(
|
|
34
|
+
(a, b) => (a.data.series?.order ?? 0) - (b.data.series?.order ?? 0)
|
|
35
|
+
);
|
|
36
|
+
const latestDate = sorted.reduce((latest, p) => {
|
|
37
|
+
const d = new Date(p.data.modDatetime ?? p.data.pubDatetime);
|
|
38
|
+
return d > latest ? d : latest;
|
|
39
|
+
}, new Date(0));
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
slug: slugifySeries(name),
|
|
44
|
+
posts: sorted,
|
|
45
|
+
totalPosts: sorted.length,
|
|
46
|
+
latestDate,
|
|
47
|
+
};
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => b.latestDate.getTime() - a.latestDate.getTime());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getSeriesByName(
|
|
53
|
+
posts: CollectionEntry<"blog">[],
|
|
54
|
+
name: string
|
|
55
|
+
): SeriesInfo | undefined {
|
|
56
|
+
return getSeriesFromPosts(posts).find(s => s.name === name);
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import postFilter from "./postFilter";
|
|
3
|
+
|
|
4
|
+
const getSortedPosts = (posts: CollectionEntry<"blog">[]) => {
|
|
5
|
+
return posts
|
|
6
|
+
.filter(postFilter)
|
|
7
|
+
.sort(
|
|
8
|
+
(a, b) =>
|
|
9
|
+
Math.floor(
|
|
10
|
+
new Date(b.data.modDatetime ?? b.data.pubDatetime).getTime() / 1000
|
|
11
|
+
) -
|
|
12
|
+
Math.floor(
|
|
13
|
+
new Date(a.data.modDatetime ?? a.data.pubDatetime).getTime() / 1000
|
|
14
|
+
)
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default getSortedPosts;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import { slugifyStr } from "./slugify";
|
|
3
|
+
import postFilter from "./postFilter";
|
|
4
|
+
|
|
5
|
+
interface TagWithCount {
|
|
6
|
+
tag: string;
|
|
7
|
+
tagName: string;
|
|
8
|
+
count: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const getTagsWithCount = (posts: CollectionEntry<"blog">[]) => {
|
|
12
|
+
const tagMap = new Map<string, { tagName: string; count: number }>();
|
|
13
|
+
|
|
14
|
+
posts
|
|
15
|
+
.filter(postFilter)
|
|
16
|
+
.flatMap(post => post.data.tags)
|
|
17
|
+
.forEach(tag => {
|
|
18
|
+
const slug = slugifyStr(tag);
|
|
19
|
+
const existing = tagMap.get(slug);
|
|
20
|
+
if (existing) {
|
|
21
|
+
existing.count++;
|
|
22
|
+
} else {
|
|
23
|
+
tagMap.set(slug, { tagName: tag, count: 1 });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const tags: TagWithCount[] = Array.from(tagMap.entries())
|
|
28
|
+
.map(([tag, data]) => ({
|
|
29
|
+
tag,
|
|
30
|
+
tagName: data.tagName,
|
|
31
|
+
count: data.count,
|
|
32
|
+
}))
|
|
33
|
+
.sort((a, b) => b.count - a.count);
|
|
34
|
+
|
|
35
|
+
return tags;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default getTagsWithCount;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
|
|
3
|
+
export type CategoryEntry = { category: string; count: number };
|
|
4
|
+
|
|
5
|
+
export type CategoryTreeNode = {
|
|
6
|
+
name: string;
|
|
7
|
+
fullPath: string;
|
|
8
|
+
count: number;
|
|
9
|
+
children: CategoryTreeNode[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getUniqueCategories = (
|
|
13
|
+
posts: CollectionEntry<"blog">[]
|
|
14
|
+
): CategoryEntry[] => {
|
|
15
|
+
const countMap = new Map<string, number>();
|
|
16
|
+
|
|
17
|
+
posts.forEach(post => {
|
|
18
|
+
if (post.data.category) {
|
|
19
|
+
countMap.set(
|
|
20
|
+
post.data.category,
|
|
21
|
+
(countMap.get(post.data.category) ?? 0) + 1
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return Array.from(countMap.entries())
|
|
27
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
28
|
+
.map(([category, count]) => ({ category, count }));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function getCategoryTree(
|
|
32
|
+
posts: CollectionEntry<"blog">[]
|
|
33
|
+
): CategoryTreeNode[] {
|
|
34
|
+
const flat = getUniqueCategories(posts);
|
|
35
|
+
const root: CategoryTreeNode[] = [];
|
|
36
|
+
|
|
37
|
+
for (const { category, count } of flat) {
|
|
38
|
+
const parts = category.split("/");
|
|
39
|
+
let current = root;
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < parts.length; i++) {
|
|
42
|
+
const fullPath = parts.slice(0, i + 1).join("/");
|
|
43
|
+
let node = current.find(n => n.fullPath === fullPath);
|
|
44
|
+
|
|
45
|
+
if (!node) {
|
|
46
|
+
node = { name: parts[i], fullPath, count: 0, children: [] };
|
|
47
|
+
current.push(node);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (i === parts.length - 1) {
|
|
51
|
+
node.count = count;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
current = node.children;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return root;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get all category paths including parent categories (for generating category pages).
|
|
63
|
+
* This extracts all possible category paths from the category tree.
|
|
64
|
+
*/
|
|
65
|
+
export function getAllCategoryPaths(tree: CategoryTreeNode[]): string[] {
|
|
66
|
+
const paths: string[] = [];
|
|
67
|
+
|
|
68
|
+
function traverse(nodes: CategoryTreeNode[]) {
|
|
69
|
+
for (const node of nodes) {
|
|
70
|
+
paths.push(node.fullPath);
|
|
71
|
+
if (node.children.length > 0) {
|
|
72
|
+
traverse(node.children);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
traverse(tree);
|
|
78
|
+
return paths;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default getUniqueCategories;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CollectionEntry } from "astro:content";
|
|
2
|
+
import { slugifyStr } from "./slugify";
|
|
3
|
+
import postFilter from "./postFilter";
|
|
4
|
+
|
|
5
|
+
interface Tag {
|
|
6
|
+
tag: string;
|
|
7
|
+
tagName: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const getUniqueTags = (posts: CollectionEntry<"blog">[]) => {
|
|
11
|
+
const tags: Tag[] = posts
|
|
12
|
+
.filter(postFilter)
|
|
13
|
+
.flatMap(post => post.data.tags)
|
|
14
|
+
.map(tag => ({ tag: slugifyStr(tag), tagName: tag }))
|
|
15
|
+
.filter(
|
|
16
|
+
(value, index, self) =>
|
|
17
|
+
self.findIndex(tag => tag.tag === value.tag) === index
|
|
18
|
+
)
|
|
19
|
+
.sort((tagA, tagB) => tagA.tag.localeCompare(tagB.tag));
|
|
20
|
+
return tags;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default getUniqueTags;
|