@xingwangzhe/stalux 1.2.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/LICENSE +21 -0
- package/README.md +192 -0
- package/package.json +108 -0
- package/scripts/cli.mjs +103 -0
- package/src/assets/BiCcCircle.svg +1 -0
- package/src/assets/astro.svg +1 -0
- package/src/assets/background/README.md +3 -0
- package/src/assets/background/pattern-1.min.svg +1 -0
- package/src/assets/background/pattern-10.min.svg +1 -0
- package/src/assets/background/pattern-11.min.svg +1 -0
- package/src/assets/background/pattern-12.min.svg +1 -0
- package/src/assets/background/pattern-13.min.svg +1 -0
- package/src/assets/background/pattern-14.min.svg +1 -0
- package/src/assets/background/pattern-15.min.svg +1 -0
- package/src/assets/background/pattern-16.min.svg +1 -0
- package/src/assets/background/pattern-17.min.svg +1 -0
- package/src/assets/background/pattern-18.min.svg +1 -0
- package/src/assets/background/pattern-19.min.svg +1 -0
- package/src/assets/background/pattern-2.min.svg +1 -0
- package/src/assets/background/pattern-20.min.svg +1 -0
- package/src/assets/background/pattern-21.min.svg +1 -0
- package/src/assets/background/pattern-22.min.svg +1 -0
- package/src/assets/background/pattern-23.min.svg +1 -0
- package/src/assets/background/pattern-24.min.svg +1 -0
- package/src/assets/background/pattern-25.min.svg +1 -0
- package/src/assets/background/pattern-26.min.svg +1 -0
- package/src/assets/background/pattern-27.min.svg +1 -0
- package/src/assets/background/pattern-28.min.svg +1 -0
- package/src/assets/background/pattern-29.min.svg +1 -0
- package/src/assets/background/pattern-3.min.svg +1 -0
- package/src/assets/background/pattern-30.min.svg +1 -0
- package/src/assets/background/pattern-31.min.svg +1 -0
- package/src/assets/background/pattern-32.min.svg +1 -0
- package/src/assets/background/pattern-33.min.svg +1 -0
- package/src/assets/background/pattern-34.min.svg +1 -0
- package/src/assets/background/pattern-35.min.svg +1 -0
- package/src/assets/background/pattern-36.min.svg +1 -0
- package/src/assets/background/pattern-37.min.svg +1 -0
- package/src/assets/background/pattern-38.min.svg +1 -0
- package/src/assets/background/pattern-39.min.svg +1 -0
- package/src/assets/background/pattern-4.min.svg +1 -0
- package/src/assets/background/pattern-40.min.svg +1 -0
- package/src/assets/background/pattern-41.min.svg +1 -0
- package/src/assets/background/pattern-42.min.svg +1 -0
- package/src/assets/background/pattern-5.min.svg +1 -0
- package/src/assets/background/pattern-6.min.svg +1 -0
- package/src/assets/background/pattern-7.min.svg +1 -0
- package/src/assets/background/pattern-8.min.svg +1 -0
- package/src/assets/background/pattern-9.min.svg +1 -0
- package/src/assets/background.svg +1 -0
- package/src/assets/cc-icon.svg +4 -0
- package/src/assets/fonts/GoogleSansCode-Italic.woff2 +0 -0
- package/src/assets/fonts/GoogleSansCode.woff2 +0 -0
- package/src/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
- package/src/assets/fonts/LXGWWenKai-Regular.woff2 +0 -0
- package/src/components/stalux/analytics/clarity.astro +25 -0
- package/src/components/stalux/analytics/google.astro +31 -0
- package/src/components/stalux/analytics/umami.astro +16 -0
- package/src/components/stalux/archives/archivesList.astro +116 -0
- package/src/components/stalux/archives/toArchive.astro +14 -0
- package/src/components/stalux/categories/categoriesCard.astro +49 -0
- package/src/components/stalux/categories/categoriesList.astro +17 -0
- package/src/components/stalux/common/author.astro +35 -0
- package/src/components/stalux/common/date.astro +59 -0
- package/src/components/stalux/common/search.astro +14 -0
- package/src/components/stalux/common/textType.astro +16 -0
- package/src/components/stalux/common/verCount.astro +10 -0
- package/src/components/stalux/footer/FooterBadges.astro +150 -0
- package/src/components/stalux/footer/FooterBeian.astro +91 -0
- package/src/components/stalux/footer/FooterCopyright.astro +40 -0
- package/src/components/stalux/footer/FooterStats.astro +108 -0
- package/src/components/stalux/footer/FooterThemeInfo.astro +67 -0
- package/src/components/stalux/layout/footer.astro +87 -0
- package/src/components/stalux/layout/head.astro +194 -0
- package/src/components/stalux/layout/listPage.astro +26 -0
- package/src/components/stalux/layout/navs.astro +174 -0
- package/src/components/stalux/links/linkCard.astro +50 -0
- package/src/components/stalux/links/linkList.astro +20 -0
- package/src/components/stalux/links/socialMedia.astro +102 -0
- package/src/components/stalux/posts/PhotoSwipeStyle.astro +3 -0
- package/src/components/stalux/posts/PostTaxonomy.astro +65 -0
- package/src/components/stalux/posts/WalineComment.astro +609 -0
- package/src/components/stalux/posts/postAuthor.astro +23 -0
- package/src/components/stalux/posts/postCC.astro +52 -0
- package/src/components/stalux/posts/postCard.astro +83 -0
- package/src/components/stalux/posts/postContent.astro +174 -0
- package/src/components/stalux/posts/postLeft.astro +41 -0
- package/src/components/stalux/posts/postList.astro +25 -0
- package/src/components/stalux/posts/postNavigation.astro +137 -0
- package/src/components/stalux/posts/postRight.astro +33 -0
- package/src/components/stalux/posts/postToc.astro +88 -0
- package/src/components/stalux/posts/randomPost.astro +134 -0
- package/src/components/stalux/tags/tagHeader.astro +50 -0
- package/src/components/stalux/tags/tagsCloud.astro +100 -0
- package/src/components/stalux/words/wordCard.astro +62 -0
- package/src/components/stalux/words/wordList.astro +70 -0
- package/src/config.ts +58 -0
- package/src/i18n/en.json +193 -0
- package/src/i18n/zh-CN.json +193 -0
- package/src/index.ts +215 -0
- package/src/internal/components-plugin.ts +54 -0
- package/src/internal/dev-toolbar.ts +33 -0
- package/src/internal/override.ts +99 -0
- package/src/internal/pagefind.ts +40 -0
- package/src/layouts/PostLayout.astro +90 -0
- package/src/layouts/Stalux.astro +91 -0
- package/src/pages/404.astro +103 -0
- package/src/pages/about.astro +59 -0
- package/src/pages/about.md.ts +15 -0
- package/src/pages/api/post.abbrlink.json.ts +20 -0
- package/src/pages/archives.astro +23 -0
- package/src/pages/archives.md.ts +24 -0
- package/src/pages/atom.xml.ts +29 -0
- package/src/pages/categories/[category].astro +35 -0
- package/src/pages/categories/[category].md.ts +34 -0
- package/src/pages/categories/index.astro +23 -0
- package/src/pages/categories/index.md.ts +27 -0
- package/src/pages/index.astro +30 -0
- package/src/pages/index.md.ts +18 -0
- package/src/pages/links.astro +23 -0
- package/src/pages/links.md.ts +18 -0
- package/src/pages/llms-full.txt.ts +25 -0
- package/src/pages/llms.txt.ts +26 -0
- package/src/pages/posts/[post].astro +69 -0
- package/src/pages/posts/[post].md.ts +109 -0
- package/src/pages/rss.xml.ts +29 -0
- package/src/pages/tags/[tag].astro +37 -0
- package/src/pages/tags/[tag].md.ts +34 -0
- package/src/pages/tags/index.astro +23 -0
- package/src/pages/tags/index.md.ts +27 -0
- package/src/pages/words.astro +24 -0
- package/src/pages/words.md.ts +14 -0
- package/src/plugins/feature-flags.ts +68 -0
- package/src/plugins/satteri-temml.ts +81 -0
- package/src/schemas/collections.ts +140 -0
- package/src/schemas/config.ts +226 -0
- package/src/scripts/background.ts +59 -0
- package/src/styles/base/init.css +286 -0
- package/src/styles/base/math.css +411 -0
- package/src/styles/components/archives/archivesList.module.css +378 -0
- package/src/styles/components/archives/toArchive.module.css +3 -0
- package/src/styles/components/categories/categoriesCard.module.css +87 -0
- package/src/styles/components/categories/categoriesList.module.css +27 -0
- package/src/styles/components/common/author.module.css +73 -0
- package/src/styles/components/common/date.module.css +15 -0
- package/src/styles/components/common/textType.module.css +10 -0
- package/src/styles/components/footer/footer.module.css +44 -0
- package/src/styles/components/layout/navs.module.css +250 -0
- package/src/styles/components/links/linkCard.module.css +99 -0
- package/src/styles/components/links/linkList.module.css +67 -0
- package/src/styles/components/links/socialLinks.module.css +22 -0
- package/src/styles/components/markdown.css +528 -0
- package/src/styles/components/posts/postAuthor.module.css +32 -0
- package/src/styles/components/posts/postCC.module.css +138 -0
- package/src/styles/components/posts/postCard.module.css +156 -0
- package/src/styles/components/posts/postCategories.module.css +50 -0
- package/src/styles/components/posts/postContent.module.css +230 -0
- package/src/styles/components/posts/postLayout.module.css +142 -0
- package/src/styles/components/posts/postLeft.module.css +38 -0
- package/src/styles/components/posts/postList.module.css +24 -0
- package/src/styles/components/posts/postNavigation.module.css +187 -0
- package/src/styles/components/posts/postRight.module.css +60 -0
- package/src/styles/components/posts/postSidebar.module.css +57 -0
- package/src/styles/components/posts/postTags.module.css +51 -0
- package/src/styles/components/posts/postTaxonomy.module.css +87 -0
- package/src/styles/components/posts/postToc.module.css +74 -0
- package/src/styles/components/posts/randomPost.module.css +131 -0
- package/src/styles/components/tags/tagHeader.module.css +44 -0
- package/src/styles/components/tags/tagsCloud.module.css +124 -0
- package/src/styles/components/words/wordCard.module.css +130 -0
- package/src/styles/components/words/wordList.module.css +39 -0
- package/src/styles/pages/archives.module.css +18 -0
- package/src/styles/pages/categories.module.css +13 -0
- package/src/styles/pages/home.module.css +51 -0
- package/src/styles/pages/listPage.module.css +13 -0
- package/src/styles/pages/tags.module.css +12 -0
- package/src/styles/pages/words.module.css +13 -0
- package/src/styles/shared/animations.css +87 -0
- package/src/styles/shared/stagger.css +20 -0
- package/src/utils/ai-discovery.ts +547 -0
- package/src/utils/badge-generator.ts +68 -0
- package/src/utils/cc.ts +46 -0
- package/src/utils/collections-stats.ts +48 -0
- package/src/utils/config-utils.ts +107 -0
- package/src/utils/count-words.ts +20 -0
- package/src/utils/dayjs.ts +106 -0
- package/src/utils/feed.ts +70 -0
- package/src/utils/i18n.ts +64 -0
- package/src/utils/lucide-icons.ts +52 -0
- package/src/utils/semantic-time.ts +11 -0
- package/src/utils/taxonomy.ts +44 -0
- package/src/utils/toc-scrollspy.ts +95 -0
- package/src/utils/waline.ts +27 -0
- package/src/utils/word-count-utils.ts +83 -0
- package/template/astro.config.mjs +51 -0
- package/template/package.json +27 -0
- package/template/public/robots.txt +3 -0
- package/template/src/content.config.ts +11 -0
- package/template/stalux/about/index.md +14 -0
- package/template/stalux/config/ai-discovery.yml +2 -0
- package/template/stalux/config/author.yml +4 -0
- package/template/stalux/config/comment.yml +5 -0
- package/template/stalux/config/footer.yml +13 -0
- package/template/stalux/config/head.yml +7 -0
- package/template/stalux/config/links.yml +4 -0
- package/template/stalux/config/media-links.yml +8 -0
- package/template/stalux/config/navs.yml +23 -0
- package/template/stalux/config/promote.yml +2 -0
- package/template/stalux/config/site.yml +7 -0
- package/template/stalux/config/typetexts.yml +5 -0
- package/template/stalux/posts/hello-stalux.md +47 -0
- package/template/stalux/words/_template.md +12 -0
- package/template/stalux/words/einstein-imagination.md +8 -0
- package/template/tsconfig.json +9 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from "astro:content";
|
|
2
|
+
|
|
3
|
+
type Post = CollectionEntry<"posts">;
|
|
4
|
+
|
|
5
|
+
/** 统一拉取已发布的文章集合,避免各统计函数重复查询。 */
|
|
6
|
+
async function getPosts(): Promise<Post[]> {
|
|
7
|
+
return getCollection("posts", ({ data }) => !data.draft);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 统计 posts 中每个分类键出现的次数。
|
|
12
|
+
* @param posts 文章集合
|
|
13
|
+
* @param keyFn 从单篇文章取出若干分类键(如标签、分类)
|
|
14
|
+
* @param defaultValue 文章没有任何分类键时的兜底分类名(可选)
|
|
15
|
+
*/
|
|
16
|
+
function countBy(
|
|
17
|
+
posts: Post[],
|
|
18
|
+
keyFn: (post: Post) => string[] | undefined,
|
|
19
|
+
defaultValue?: string,
|
|
20
|
+
): Array<{ name: string; count: number }> {
|
|
21
|
+
const counts = new Map<string, number>();
|
|
22
|
+
for (const post of posts) {
|
|
23
|
+
const keys = keyFn(post) ?? [];
|
|
24
|
+
const names = keys.length > 0 ? keys : defaultValue ? [defaultValue] : [];
|
|
25
|
+
for (const name of names) {
|
|
26
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return [...counts].map(([name, count]) => ({ name, count }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 获取标签计数统计(用于标签云)
|
|
34
|
+
* @returns Array<{ name: string; count: number }>
|
|
35
|
+
*/
|
|
36
|
+
export async function getTagCountList() {
|
|
37
|
+
const posts = await getPosts();
|
|
38
|
+
return countBy(posts, (p) => p.data.tags);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 获取分类计数统计(用于分类列表)
|
|
43
|
+
* @returns Array<{ name: string; count: number }>
|
|
44
|
+
*/
|
|
45
|
+
export async function getCategoryCountList() {
|
|
46
|
+
const posts = await getPosts();
|
|
47
|
+
return countBy(posts, (p) => p.data.categories, "uncategorized");
|
|
48
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorData,
|
|
3
|
+
AiDiscoveryData,
|
|
4
|
+
CommentData,
|
|
5
|
+
FooterData,
|
|
6
|
+
HeadData,
|
|
7
|
+
LinksData,
|
|
8
|
+
MediaLinksData,
|
|
9
|
+
NavsData,
|
|
10
|
+
PromoteData,
|
|
11
|
+
SiteData,
|
|
12
|
+
TypetextsData,
|
|
13
|
+
} from "@schemas/config";
|
|
14
|
+
/**
|
|
15
|
+
* 类型安全的 Config 访问工具
|
|
16
|
+
*
|
|
17
|
+
* 利用 discriminated union + for/if 窄化,零 as/any/unknown 类型垃圾。
|
|
18
|
+
*
|
|
19
|
+
* 每个函数单独写 for + if (entry.id === 具体字面量),
|
|
20
|
+
* TS 在此条件分支中自动收窄 entry.data 为对应类型。
|
|
21
|
+
*/
|
|
22
|
+
import type { CollectionEntry } from "astro:content";
|
|
23
|
+
|
|
24
|
+
// ===================================================================
|
|
25
|
+
// Config 集合 (stalux/config/*.yml)
|
|
26
|
+
// ===================================================================
|
|
27
|
+
|
|
28
|
+
export function getSiteData(entries: CollectionEntry<"config">[]): SiteData {
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry.id === "site") return entry.data;
|
|
31
|
+
}
|
|
32
|
+
throw new Error("Missing site config");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getAuthorData(entries: CollectionEntry<"config">[]): AuthorData {
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.id === "author") return entry.data;
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Missing author config");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getHeadData(entries: CollectionEntry<"config">[]): HeadData | undefined {
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.id === "head") return entry.data;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getNavsData(entries: CollectionEntry<"config">[]): NavsData | undefined {
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry.id === "navs") return entry.data;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getTypetextsData(entries: CollectionEntry<"config">[]): TypetextsData | undefined {
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.id === "typetexts") return entry.data;
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getMediaLinksData(
|
|
64
|
+
entries: CollectionEntry<"config">[],
|
|
65
|
+
): MediaLinksData | undefined {
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.id === "media-links") return entry.data;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getLinksData(entries: CollectionEntry<"config">[]): LinksData | undefined {
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.id === "links") return entry.data;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getFooterData(entries: CollectionEntry<"config">[]): FooterData | undefined {
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.id === "footer") return entry.data;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getCommentData(entries: CollectionEntry<"config">[]): CommentData | undefined {
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.id === "comment") return entry.data;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getPromoteData(entries: CollectionEntry<"config">[]): PromoteData | undefined {
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.id === "promote") return entry.data;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getAiDiscoveryData(
|
|
101
|
+
entries: CollectionEntry<"config">[],
|
|
102
|
+
): AiDiscoveryData | undefined {
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.id === "ai-discovery") return entry.data;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CJK 友好的字数统计 — 基于 W3C Intl.Segmenter API(UAX #29 标准分词)。
|
|
3
|
+
*
|
|
4
|
+
* 替代手写 Unicode 正则的方案:
|
|
5
|
+
* - CJK 字符:`Intl.Segmenter` 逐字分割,每个字计 1
|
|
6
|
+
* - 拉丁/数字/其他字母文字:按词计 1
|
|
7
|
+
* - 标点/空白不计
|
|
8
|
+
*/
|
|
9
|
+
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 统计文本字数(基于 Intl.Segmenter 分词)。
|
|
13
|
+
*/
|
|
14
|
+
export function countWords(text: string): number {
|
|
15
|
+
let count = 0;
|
|
16
|
+
for (const { isWordLike } of segmenter.segment(text)) {
|
|
17
|
+
if (isWordLike) count++;
|
|
18
|
+
}
|
|
19
|
+
return count;
|
|
20
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日期工具 — 纯原生 Date + Intl,无 date-fns 依赖。
|
|
3
|
+
* 保留旧 dayjs 封装导出名,方便现有调用方继续使用。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 解析日期字符串,返回 Date 对象 */
|
|
7
|
+
export const parseDate = (v: unknown): Date => {
|
|
8
|
+
if (v == null || v === 0 || v === "") return new Date(0);
|
|
9
|
+
const d = new Date(v as string | number | Date);
|
|
10
|
+
return Number.isNaN(d.getTime()) ? new Date(0) : d;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** 是否为有效日期 */
|
|
14
|
+
export const isValid = (d: Date): boolean => !Number.isNaN(d.getTime());
|
|
15
|
+
|
|
16
|
+
/** 获取时间戳(用于排序比较) */
|
|
17
|
+
export const toTimestamp = (v: unknown): number => parseDate(v).getTime();
|
|
18
|
+
|
|
19
|
+
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 轻量 format:仅覆盖本项目实际用到的 token。
|
|
23
|
+
* 支持:yyyy MM dd HH mm ss
|
|
24
|
+
*/
|
|
25
|
+
export function format(date: Date | string | number, formatStr: string): string {
|
|
26
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
27
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
28
|
+
|
|
29
|
+
// 本地时区(与原先 date-fns format 默认行为一致)
|
|
30
|
+
// 单次正则替换,避免 replaceAll 链式替换的 token 交叉问题
|
|
31
|
+
const tokens: Record<string, string> = {
|
|
32
|
+
yyyy: String(d.getFullYear()),
|
|
33
|
+
MM: pad2(d.getMonth() + 1),
|
|
34
|
+
dd: pad2(d.getDate()),
|
|
35
|
+
HH: pad2(d.getHours()),
|
|
36
|
+
mm: pad2(d.getMinutes()),
|
|
37
|
+
ss: pad2(d.getSeconds()),
|
|
38
|
+
};
|
|
39
|
+
return formatStr.replace(/yyyy|MM|dd|HH|mm|ss/g, (t) => tokens[t] ?? t);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 在指定 IANA 时区中格式化为带偏移的 ISO 局部串。
|
|
44
|
+
* 默认输出:yyyy-MM-dd'T'HH:mm:ssXXX(如 2026-03-27T14:30:00+08:00)
|
|
45
|
+
*/
|
|
46
|
+
export function formatInTimeZone(
|
|
47
|
+
date: Date | string | number,
|
|
48
|
+
timeZone: string,
|
|
49
|
+
formatStr: string = "yyyy-MM-dd'T'HH:mm:ssXXX",
|
|
50
|
+
): string {
|
|
51
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
52
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
53
|
+
|
|
54
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
55
|
+
timeZone,
|
|
56
|
+
year: "numeric",
|
|
57
|
+
month: "2-digit",
|
|
58
|
+
day: "2-digit",
|
|
59
|
+
hour: "2-digit",
|
|
60
|
+
minute: "2-digit",
|
|
61
|
+
second: "2-digit",
|
|
62
|
+
hourCycle: "h23",
|
|
63
|
+
timeZoneName: "longOffset",
|
|
64
|
+
}).formatToParts(d);
|
|
65
|
+
|
|
66
|
+
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
67
|
+
parts.find((p) => p.type === type)?.value ?? "";
|
|
68
|
+
|
|
69
|
+
const year = get("year");
|
|
70
|
+
const month = get("month");
|
|
71
|
+
const day = get("day");
|
|
72
|
+
const hour = get("hour");
|
|
73
|
+
const minute = get("minute");
|
|
74
|
+
const second = get("second");
|
|
75
|
+
|
|
76
|
+
let offset = get("timeZoneName"); // GMT+08:00 / UTC / GMT
|
|
77
|
+
if (offset === "GMT" || offset === "UTC") {
|
|
78
|
+
offset = "+00:00";
|
|
79
|
+
} else {
|
|
80
|
+
offset = offset.replace(/^GMT/, "").replace(/^UTC/, "");
|
|
81
|
+
if (!offset.startsWith("+") && !offset.startsWith("-")) {
|
|
82
|
+
offset = `+${offset}`;
|
|
83
|
+
}
|
|
84
|
+
const m = offset.match(/^([+-])(\d{1,2})(?::?(\d{2}))?$/);
|
|
85
|
+
if (m) {
|
|
86
|
+
offset = `${m[1]}${m[2].padStart(2, "0")}:${m[3] || "00"}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 目前调用方只使用这一种 pattern;保留 formatStr 参数以便兼容签名
|
|
91
|
+
if (formatStr === "yyyy-MM-dd'T'HH:mm:ssXXX") {
|
|
92
|
+
return `${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 回退:按 token 替换(不含 locale 名)
|
|
96
|
+
const tokens: Record<string, string> = {
|
|
97
|
+
yyyy: year,
|
|
98
|
+
MM: month,
|
|
99
|
+
dd: day,
|
|
100
|
+
HH: hour,
|
|
101
|
+
mm: minute,
|
|
102
|
+
ss: second,
|
|
103
|
+
XXX: offset,
|
|
104
|
+
};
|
|
105
|
+
return formatStr.replace(/yyyy|MM|dd|HH|mm|ss|XXX/g, (t) => tokens[t] ?? t);
|
|
106
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { toTimestamp, parseDate, isValid, formatInTimeZone } from "@utils/dayjs";
|
|
2
|
+
import { toMachineDateTime } from "@utils/semantic-time";
|
|
3
|
+
import { getPostDescriptions } from "@utils/word-count-utils";
|
|
4
|
+
import type { CollectionEntry } from "astro:content";
|
|
5
|
+
|
|
6
|
+
interface FeedConfig {
|
|
7
|
+
timezone: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface FeedItem {
|
|
11
|
+
title: string;
|
|
12
|
+
pubDate: Date;
|
|
13
|
+
description: string;
|
|
14
|
+
link: string;
|
|
15
|
+
categories: string[];
|
|
16
|
+
customData: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 构建 RSS / Atom 共用的 feed 条目列表。
|
|
21
|
+
* 两个端点的唯一差异是“更新时间”节点名(RSS 用 `atom:updated`,Atom 用 `updated`),
|
|
22
|
+
* 通过 updatedTag 参数化,避免整文件复制。
|
|
23
|
+
*
|
|
24
|
+
* 性能:文章描述走共享缓存 getPostDescriptions(),避免每个端点各自渲染;
|
|
25
|
+
* 排序用 updated ?? date 的时间戳做纯数字比较。
|
|
26
|
+
*/
|
|
27
|
+
export async function buildFeedItems(
|
|
28
|
+
stalux: FeedConfig,
|
|
29
|
+
posts: CollectionEntry<"posts">[],
|
|
30
|
+
updatedTag: string,
|
|
31
|
+
): Promise<FeedItem[]> {
|
|
32
|
+
const sortedPosts = [...posts].sort((a, b) => {
|
|
33
|
+
// Newest first: compare post dates, use the greater of updated/date
|
|
34
|
+
const aTime = toTimestamp(a.data.updated || a.data.date);
|
|
35
|
+
const bTime = toTimestamp(b.data.updated || b.data.date);
|
|
36
|
+
return bTime - aTime;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const descriptions = await getPostDescriptions();
|
|
40
|
+
return Promise.all(
|
|
41
|
+
sortedPosts.map(async (post) => {
|
|
42
|
+
const cached = descriptions.get(String(post.data.abbrlink));
|
|
43
|
+
|
|
44
|
+
let customData = "";
|
|
45
|
+
if (post.data.updated) {
|
|
46
|
+
const parsed = parseDate(post.data.updated);
|
|
47
|
+
if (isValid(parsed)) {
|
|
48
|
+
const updatedIso = formatInTimeZone(
|
|
49
|
+
parsed,
|
|
50
|
+
stalux.timezone,
|
|
51
|
+
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
|
52
|
+
);
|
|
53
|
+
customData += `<${updatedTag}>${updatedIso}</${updatedTag}>`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (post.data.cc) {
|
|
57
|
+
customData += `<rights>${post.data.cc}</rights>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
title: post.data.title,
|
|
62
|
+
pubDate: new Date(toMachineDateTime(post.data.date, stalux.timezone)),
|
|
63
|
+
description: cached?.desc || post.data.desc || "",
|
|
64
|
+
link: `/posts/${post.data.abbrlink}/`,
|
|
65
|
+
categories: post.data.categories || [],
|
|
66
|
+
customData,
|
|
67
|
+
};
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import en from "@i18n/en.json";
|
|
2
|
+
import zhCN from "@i18n/zh-CN.json";
|
|
3
|
+
|
|
4
|
+
const dicts: Record<string, Record<string, unknown>> = {
|
|
5
|
+
"zh-CN": zhCN as Record<string, unknown>,
|
|
6
|
+
en: en as Record<string, unknown>,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export interface Translator {
|
|
10
|
+
t(key: string, params?: Record<string, string | number>): string;
|
|
11
|
+
lang: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createTranslator(lang: string): Translator {
|
|
15
|
+
const dict = dicts[lang] || dicts["zh-CN"];
|
|
16
|
+
|
|
17
|
+
function t(key: string, params?: Record<string, string | number>): string {
|
|
18
|
+
const keys = key.split(".");
|
|
19
|
+
let text: unknown = dict;
|
|
20
|
+
for (const k of keys) {
|
|
21
|
+
if (text && typeof text === "object") {
|
|
22
|
+
text = (text as Record<string, unknown>)[k];
|
|
23
|
+
} else {
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (typeof text !== "string") return key;
|
|
28
|
+
|
|
29
|
+
let result = text;
|
|
30
|
+
if (params) {
|
|
31
|
+
for (const [k, v] of Object.entries(params)) {
|
|
32
|
+
result = result.replace(`{${k}}`, String(v));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { t, lang };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Map config lang value to OpenGraph locale format.
|
|
43
|
+
* e.g. "zh-CN" → "zh_CN", "en" → "en_US"
|
|
44
|
+
*/
|
|
45
|
+
export function langToOpenGraphLocale(lang: string): string {
|
|
46
|
+
const map: Record<string, string> = {
|
|
47
|
+
"zh-CN": "zh_CN",
|
|
48
|
+
"zh-TW": "zh_TW",
|
|
49
|
+
en: "en_US",
|
|
50
|
+
"en-US": "en_US",
|
|
51
|
+
"en-GB": "en_GB",
|
|
52
|
+
ja: "ja_JP",
|
|
53
|
+
ko: "ko_KR",
|
|
54
|
+
};
|
|
55
|
+
return map[lang] || lang;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map config lang value to RSS/Atom feed language tag.
|
|
60
|
+
* e.g. "zh-CN" → "zh-cn", "en" → "en-us"
|
|
61
|
+
*/
|
|
62
|
+
export function langToFeedLanguage(lang: string): string {
|
|
63
|
+
return lang.toLowerCase();
|
|
64
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导航等场景用到的 Lucide 图标静态映射。
|
|
3
|
+
* 按需从 @lucide/astro 导入,避免整包 lucide 依赖。
|
|
4
|
+
*
|
|
5
|
+
* 新增图标:在下方 import 并写入 iconMap 即可(key 为 kebab-case,与 config.yml 一致)。
|
|
6
|
+
* 图标列表: https://lucide.dev/icons/
|
|
7
|
+
*/
|
|
8
|
+
import type { AstroComponent } from "@lucide/astro";
|
|
9
|
+
import Airplay from "@lucide/astro/icons/airplay";
|
|
10
|
+
import Archive from "@lucide/astro/icons/archive";
|
|
11
|
+
import Folder from "@lucide/astro/icons/folder";
|
|
12
|
+
import House from "@lucide/astro/icons/house";
|
|
13
|
+
import Link from "@lucide/astro/icons/link";
|
|
14
|
+
import Menu from "@lucide/astro/icons/menu";
|
|
15
|
+
import Quote from "@lucide/astro/icons/quote";
|
|
16
|
+
import Search from "@lucide/astro/icons/search";
|
|
17
|
+
import Tag from "@lucide/astro/icons/tag";
|
|
18
|
+
import TrainFront from "@lucide/astro/icons/train-front";
|
|
19
|
+
import User from "@lucide/astro/icons/user";
|
|
20
|
+
import X from "@lucide/astro/icons/x";
|
|
21
|
+
|
|
22
|
+
/** config.yml 中 icon 字段(kebab-case)→ Astro 图标组件 */
|
|
23
|
+
const iconMap: Record<string, AstroComponent> = {
|
|
24
|
+
home: House,
|
|
25
|
+
house: House,
|
|
26
|
+
archive: Archive,
|
|
27
|
+
folder: Folder,
|
|
28
|
+
tag: Tag,
|
|
29
|
+
quote: Quote,
|
|
30
|
+
link: Link,
|
|
31
|
+
user: User,
|
|
32
|
+
"train-front": TrainFront,
|
|
33
|
+
menu: Menu,
|
|
34
|
+
search: Search,
|
|
35
|
+
x: X,
|
|
36
|
+
airplay: Airplay,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 按 kebab-case 名称取图标组件;找不到时打日志并返回 null。
|
|
41
|
+
*/
|
|
42
|
+
export function getLucideIcon(iconName: string | undefined | null): AstroComponent | null {
|
|
43
|
+
if (!iconName) return null;
|
|
44
|
+
const Icon = iconMap[iconName];
|
|
45
|
+
if (!Icon) {
|
|
46
|
+
console.error(
|
|
47
|
+
`lucide 图标 '${iconName}' 未找到,请在 src/utils/lucide-icons.ts 中添加映射`,
|
|
48
|
+
);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return Icon;
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { parseDate, formatInTimeZone } from "@utils/dayjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 将原始时间字符串转换为机器可读的带时区 ISO 格式
|
|
5
|
+
* @param dateStr - frontmatter 中的原始时间字符串
|
|
6
|
+
* @param timezone - IANA 时区字符串,如 "Asia/Shanghai"
|
|
7
|
+
* @returns 带时区的 ISO 8601 格式字符串
|
|
8
|
+
*/
|
|
9
|
+
export function toMachineDateTime(dateStr: string, timezone: string): string {
|
|
10
|
+
return formatInTimeZone(parseDate(dateStr), timezone, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
11
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { toTimestamp } from "@utils/dayjs";
|
|
2
|
+
import { getCollection, type CollectionEntry, type GetStaticPathsResult } from "astro:content";
|
|
3
|
+
|
|
4
|
+
type TaxonomyKey = "tags" | "categories";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 为 tags / categories 分类页生成 getStaticPaths 结果。
|
|
8
|
+
*
|
|
9
|
+
* 复杂度优化:每个 post 只解析一次时间(`postTs`),
|
|
10
|
+
* 用数字时间戳比较“最近更新”,避免原实现在内层循环里反复
|
|
11
|
+
* `new Date()` 解析同一个时间字符串(最坏 O(n × 平均标签数) 次 Date 构造)。
|
|
12
|
+
*/
|
|
13
|
+
export async function buildTaxonomyStaticPaths(key: TaxonomyKey): Promise<GetStaticPathsResult> {
|
|
14
|
+
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
15
|
+
const paramName = key === "tags" ? "tag" : "category";
|
|
16
|
+
const map = new Map<
|
|
17
|
+
string,
|
|
18
|
+
{ posts: CollectionEntry<"posts">[]; latestTs: number; latestTime: string }
|
|
19
|
+
>();
|
|
20
|
+
|
|
21
|
+
for (const post of posts) {
|
|
22
|
+
const postTime = post.data.updated ?? post.data.date;
|
|
23
|
+
const postTs = toTimestamp(postTime);
|
|
24
|
+
for (const name of post.data[key] ?? []) {
|
|
25
|
+
const entry = map.get(name) ?? { posts: [], latestTs: postTs, latestTime: postTime };
|
|
26
|
+
entry.posts.push(post);
|
|
27
|
+
if (postTs > entry.latestTs) {
|
|
28
|
+
entry.latestTs = postTs;
|
|
29
|
+
entry.latestTime = postTime;
|
|
30
|
+
}
|
|
31
|
+
map.set(name, entry);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [...map].map(([name, entry]) => ({
|
|
36
|
+
params: { [paramName]: name },
|
|
37
|
+
props: {
|
|
38
|
+
[paramName]: name,
|
|
39
|
+
count: entry.posts.length,
|
|
40
|
+
posts: entry.posts,
|
|
41
|
+
lastUpdateDate: entry.latestTime,
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOC 滚动同步 —— 文章正文滚动时,自动高亮右侧目录中对应的条目,
|
|
3
|
+
* 并将该条目滚动到侧边栏可视区域内。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function initTocScrollSpy(activeClass: string) {
|
|
7
|
+
const tocLinks = document.querySelectorAll<HTMLAnchorElement>("[data-toc-link]");
|
|
8
|
+
if (tocLinks.length === 0) return;
|
|
9
|
+
|
|
10
|
+
// 建立标题 ID → 目录链接元素的映射
|
|
11
|
+
const linkMap = new Map<string, HTMLAnchorElement>();
|
|
12
|
+
for (const link of tocLinks) {
|
|
13
|
+
const id = link.getAttribute("href")?.replace("#", "");
|
|
14
|
+
if (id) linkMap.set(id, link);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 按 DOM 顺序解析出文章中的标题元素
|
|
18
|
+
const headings = [...linkMap.keys()]
|
|
19
|
+
.map((id) => document.getElementById(id))
|
|
20
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
21
|
+
|
|
22
|
+
if (headings.length === 0) return;
|
|
23
|
+
|
|
24
|
+
let activeLink: HTMLAnchorElement | null = null;
|
|
25
|
+
let pending = false;
|
|
26
|
+
let sidebarHovered = false;
|
|
27
|
+
|
|
28
|
+
// 鼠标悬停在侧边栏上时跳过自动滚动,避免跟用户手动操作冲突
|
|
29
|
+
const sidebar = tocLinks[0]?.closest<HTMLElement>("[data-toc-active-class]");
|
|
30
|
+
if (sidebar) {
|
|
31
|
+
sidebar.addEventListener("mouseenter", () => {
|
|
32
|
+
sidebarHovered = true;
|
|
33
|
+
});
|
|
34
|
+
sidebar.addEventListener("mouseleave", () => {
|
|
35
|
+
sidebarHovered = false;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 切换当前高亮的目录项,并将该项滚动到侧边栏可视区域内
|
|
40
|
+
const setActive = (link: HTMLAnchorElement | null) => {
|
|
41
|
+
if (activeLink === link) return;
|
|
42
|
+
activeLink?.classList.remove(activeClass);
|
|
43
|
+
activeLink = link;
|
|
44
|
+
if (link && !sidebarHovered) {
|
|
45
|
+
link.classList.add(activeClass);
|
|
46
|
+
link.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
47
|
+
} else if (link) {
|
|
48
|
+
// 用户正在操作侧边栏时只高亮,不自动滚动
|
|
49
|
+
link.classList.add(activeClass);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const observer = new IntersectionObserver(
|
|
54
|
+
() => {
|
|
55
|
+
// 用 rAF 合并同一帧内的多次回调,避免频繁触发布局计算
|
|
56
|
+
if (pending) return;
|
|
57
|
+
pending = true;
|
|
58
|
+
requestAnimationFrame(() => {
|
|
59
|
+
pending = false;
|
|
60
|
+
|
|
61
|
+
// 找到视口上方 35% 区域内最靠上的标题
|
|
62
|
+
let current =
|
|
63
|
+
headings.find((h) => {
|
|
64
|
+
const rect = h.getBoundingClientRect();
|
|
65
|
+
return rect.top < window.innerHeight * 0.35 && rect.bottom > 0;
|
|
66
|
+
}) ?? null;
|
|
67
|
+
|
|
68
|
+
// 兜底:滚动到文章底部时,取最后一个仍在视口上方或已经越过顶部的标题
|
|
69
|
+
current ??=
|
|
70
|
+
headings.findLast((h) => {
|
|
71
|
+
const rect = h.getBoundingClientRect();
|
|
72
|
+
return rect.bottom <= window.innerHeight * 0.35 || rect.top < 0;
|
|
73
|
+
}) ?? null;
|
|
74
|
+
|
|
75
|
+
setActive(current ? (linkMap.get(current.id) ?? null) : null);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
rootMargin: "0px 0px -65% 0px",
|
|
80
|
+
threshold: 0,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
for (const h of headings) observer.observe(h);
|
|
85
|
+
|
|
86
|
+
// 拦截 TOC 锚点点击,替换为平滑滚动
|
|
87
|
+
for (const link of tocLinks) {
|
|
88
|
+
link.addEventListener("click", (e) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
const id = link.getAttribute("href")?.replace("#", "");
|
|
91
|
+
const target = id ? document.getElementById(id) : null;
|
|
92
|
+
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从主题配置(stalux.comment.waline,类型较宽松)中提取 Waline 组件所需的 props,
|
|
3
|
+
* 集中处理字段透传,避免在每个调用处(about / PostLayout)重复十几行 as any 赋值块。
|
|
4
|
+
*/
|
|
5
|
+
export function buildWalineProps(walineConfig: unknown) {
|
|
6
|
+
const c = (walineConfig ?? {}) as Record<string, unknown>;
|
|
7
|
+
return {
|
|
8
|
+
serverURL: c.serverURL,
|
|
9
|
+
lang: c.lang,
|
|
10
|
+
locale: c.locale,
|
|
11
|
+
emoji: c.emoji,
|
|
12
|
+
reaction: c.reaction,
|
|
13
|
+
meta: c.meta,
|
|
14
|
+
wordLimit: c.wordLimit,
|
|
15
|
+
pageSize: c.pageSize,
|
|
16
|
+
login: c.login,
|
|
17
|
+
recaptchaV3Key: c.recaptchaV3Key,
|
|
18
|
+
turnstileKey: c.turnstileKey,
|
|
19
|
+
dark: c.dark,
|
|
20
|
+
noCopyright: c.noCopyright,
|
|
21
|
+
commentSorting: c.commentSorting,
|
|
22
|
+
imageUploader: c.imageUploader,
|
|
23
|
+
highlighter: c.highlighter,
|
|
24
|
+
texRenderer: c.texRenderer,
|
|
25
|
+
search: c.search,
|
|
26
|
+
};
|
|
27
|
+
}
|