@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.
Files changed (103) hide show
  1. package/README.md +29 -0
  2. package/package.json +41 -0
  3. package/src/assets/icons/IconArchive.svg +1 -0
  4. package/src/assets/icons/IconArrowLeft.svg +1 -0
  5. package/src/assets/icons/IconArrowNarrowUp.svg +1 -0
  6. package/src/assets/icons/IconArrowRight.svg +1 -0
  7. package/src/assets/icons/IconArticle.svg +1 -0
  8. package/src/assets/icons/IconBrandX.svg +1 -0
  9. package/src/assets/icons/IconCalendar.svg +1 -0
  10. package/src/assets/icons/IconChevronLeft.svg +1 -0
  11. package/src/assets/icons/IconChevronRight.svg +1 -0
  12. package/src/assets/icons/IconEdit.svg +1 -0
  13. package/src/assets/icons/IconFacebook.svg +1 -0
  14. package/src/assets/icons/IconGitHub.svg +1 -0
  15. package/src/assets/icons/IconHash.svg +1 -0
  16. package/src/assets/icons/IconHome.svg +1 -0
  17. package/src/assets/icons/IconLinkedin.svg +1 -0
  18. package/src/assets/icons/IconMail.svg +1 -0
  19. package/src/assets/icons/IconMenuDeep.svg +1 -0
  20. package/src/assets/icons/IconMoon.svg +1 -0
  21. package/src/assets/icons/IconPinterest.svg +1 -0
  22. package/src/assets/icons/IconProject.svg +1 -0
  23. package/src/assets/icons/IconRss.svg +1 -0
  24. package/src/assets/icons/IconSearch.svg +1 -0
  25. package/src/assets/icons/IconSeries.svg +1 -0
  26. package/src/assets/icons/IconSunHigh.svg +1 -0
  27. package/src/assets/icons/IconTag.svg +1 -0
  28. package/src/assets/icons/IconTelegram.svg +1 -0
  29. package/src/assets/icons/IconUser.svg +1 -0
  30. package/src/assets/icons/IconWhatsapp.svg +1 -0
  31. package/src/assets/icons/IconX.svg +1 -0
  32. package/src/components/ai/AIChatWidget.astro +377 -0
  33. package/src/components/blog/Comments.astro +527 -0
  34. package/src/components/blog/Copyright.astro +152 -0
  35. package/src/components/blog/EditPost.astro +59 -0
  36. package/src/components/blog/FloatingTOC.astro +260 -0
  37. package/src/components/blog/InlineTOC.astro +223 -0
  38. package/src/components/blog/PostActions.astro +306 -0
  39. package/src/components/blog/RelatedPosts.astro +60 -0
  40. package/src/components/blog/SeriesNav.astro +176 -0
  41. package/src/components/blog/ShareLinks.astro +26 -0
  42. package/src/components/nav/BackButton.astro +37 -0
  43. package/src/components/nav/BackToTopButton.astro +223 -0
  44. package/src/components/nav/Breadcrumb.astro +57 -0
  45. package/src/components/nav/FloatingActions.astro +206 -0
  46. package/src/components/nav/Footer.astro +107 -0
  47. package/src/components/nav/Header.astro +252 -0
  48. package/src/components/nav/Pagination.astro +45 -0
  49. package/src/components/social/Socials.astro +19 -0
  50. package/src/components/social/Sponsors.astro +34 -0
  51. package/src/components/social/Sponsorship.astro +44 -0
  52. package/src/components/ui/Alert.astro +28 -0
  53. package/src/components/ui/Card.astro +206 -0
  54. package/src/components/ui/Collapse.astro +82 -0
  55. package/src/components/ui/ColorPreview.astro +29 -0
  56. package/src/components/ui/Datetime.astro +61 -0
  57. package/src/components/ui/GithubCard.astro +191 -0
  58. package/src/components/ui/LinkButton.astro +21 -0
  59. package/src/components/ui/Tag.astro +37 -0
  60. package/src/components/ui/TagCloud.astro +69 -0
  61. package/src/components/ui/Timeline.astro +39 -0
  62. package/src/layouts/AboutLayout.astro +24 -0
  63. package/src/layouts/Layout.astro +329 -0
  64. package/src/layouts/Main.astro +42 -0
  65. package/src/layouts/PostDetails.astro +445 -0
  66. package/src/plugins/rehype-autolink-headings.ts +46 -0
  67. package/src/plugins/rehype-external-links.ts +35 -0
  68. package/src/plugins/rehype-table-scroll.ts +35 -0
  69. package/src/plugins/remark-add-zoomable.ts +28 -0
  70. package/src/plugins/remark-reading-time.ts +18 -0
  71. package/src/plugins/shiki-transformers.ts +212 -0
  72. package/src/scripts/lightbox.ts +63 -0
  73. package/src/scripts/reading-position.ts +56 -0
  74. package/src/scripts/theme-utils.ts +19 -0
  75. package/src/scripts/theme.ts +179 -0
  76. package/src/scripts/web-vitals.ts +96 -0
  77. package/src/styles/code-blocks.css +194 -0
  78. package/src/styles/components.css +252 -0
  79. package/src/styles/global.css +403 -0
  80. package/src/styles/typography.css +149 -0
  81. package/src/types.ts +89 -0
  82. package/src/utils/generateOgImages.ts +38 -0
  83. package/src/utils/getCategoryPath.ts +23 -0
  84. package/src/utils/getPath.ts +52 -0
  85. package/src/utils/getPostsByCategory.ts +17 -0
  86. package/src/utils/getPostsByGroupCondition.ts +25 -0
  87. package/src/utils/getPostsByLang.ts +27 -0
  88. package/src/utils/getPostsByTag.ts +10 -0
  89. package/src/utils/getReadingTime.ts +33 -0
  90. package/src/utils/getRelatedPosts.ts +59 -0
  91. package/src/utils/getSeriesData.ts +57 -0
  92. package/src/utils/getSortedPosts.ts +18 -0
  93. package/src/utils/getTagsWithCount.ts +38 -0
  94. package/src/utils/getUniqueCategories.ts +81 -0
  95. package/src/utils/getUniqueTags.ts +23 -0
  96. package/src/utils/i18n.ts +249 -0
  97. package/src/utils/loadGoogleFont.ts +38 -0
  98. package/src/utils/og-templates/post.js +229 -0
  99. package/src/utils/og-templates/site.js +128 -0
  100. package/src/utils/pathUtils.ts +17 -0
  101. package/src/utils/postFilter.ts +11 -0
  102. package/src/utils/slugify.ts +23 -0
  103. 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;