@xingwangzhe/stalux 1.25.5 → 1.25.7
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/package.json +5 -1
- package/src/components/stalux/posts/postContent.astro +2 -3
- package/src/layouts/PostLayout.astro +0 -2
- package/src/pages/about.astro +0 -1
- package/src/pages/api/posts.json.ts +14 -11
- package/src/pages/posts/[post].astro +6 -13
- package/src/plugins/feature-flags.ts +102 -18
- package/src/schemas/collections.ts +0 -2
- package/src/utils/word-count-utils.ts +12 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.7",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme — use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -75,8 +75,11 @@
|
|
|
75
75
|
"prepare": "git config core.hooksPath scripts/git-hooks"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
+
"@astrojs/markdown-satteri": "0.3.7",
|
|
78
79
|
"@astrojs/rss": "^4.0.19",
|
|
79
80
|
"@astrojs/sitemap": "^3.7.3",
|
|
81
|
+
"@echogarden/icu-segmentation-wasm": "^0.4.0",
|
|
82
|
+
"@echogarden/text-segmentation": "^0.7.0",
|
|
80
83
|
"@expressive-code/plugin-line-numbers": "^0.44.1",
|
|
81
84
|
"@lucide/astro": "^1.33.0",
|
|
82
85
|
"@mcp-b/webmcp-polyfill": "^4.0.0",
|
|
@@ -90,6 +93,7 @@
|
|
|
90
93
|
"astro-seo": "^1.1.0",
|
|
91
94
|
"astro-typewriter": "^1.2.0",
|
|
92
95
|
"badge-maker": "^6.0.0",
|
|
96
|
+
"js-tokens": "^10.0.0",
|
|
93
97
|
"pagefind": "^1.5.2",
|
|
94
98
|
"photoswipe": "^5.4.4",
|
|
95
99
|
"satteri": "^0.10.5",
|
|
@@ -12,7 +12,6 @@ interface Props {
|
|
|
12
12
|
transitionName?: string;
|
|
13
13
|
date?: string;
|
|
14
14
|
updated?: string;
|
|
15
|
-
readingTime?: string;
|
|
16
15
|
readingMinutes?: number;
|
|
17
16
|
wordCount?: number;
|
|
18
17
|
hasImage?: boolean;
|
|
@@ -62,13 +61,13 @@ const mdSourceUrl = exportMd
|
|
|
62
61
|
</div>
|
|
63
62
|
{
|
|
64
63
|
<div class={style.pageStats}>
|
|
65
|
-
{(
|
|
64
|
+
{(props.readingMinutes ?? 0) > 0 && (
|
|
66
65
|
<span class={style.readingTime}>
|
|
67
66
|
{t("post.readingTime")}{props.readingMinutes && props.readingMinutes > 0
|
|
68
67
|
? (props.readingMinutes < 1
|
|
69
68
|
? t("common.lessThanOneMinute")
|
|
70
69
|
: t("common.minute", { n: props.readingMinutes }))
|
|
71
|
-
:
|
|
70
|
+
: ""}
|
|
72
71
|
</span>
|
|
73
72
|
)}
|
|
74
73
|
{(props.wordCount ?? 0) > 0 && (
|
|
@@ -14,7 +14,6 @@ interface Props {
|
|
|
14
14
|
title: string;
|
|
15
15
|
date?: string;
|
|
16
16
|
updated?: string;
|
|
17
|
-
readingTime?: string;
|
|
18
17
|
readingMinutes?: number;
|
|
19
18
|
wordCount?: number;
|
|
20
19
|
tags?: string[];
|
|
@@ -57,7 +56,6 @@ const walineConfig = walineSection && walineSection.serverURL ? {
|
|
|
57
56
|
title={props.title}
|
|
58
57
|
date={props.date}
|
|
59
58
|
updated={props.updated}
|
|
60
|
-
readingTime={props.readingTime}
|
|
61
59
|
readingMinutes={props.readingMinutes}
|
|
62
60
|
wordCount={props.wordCount}
|
|
63
61
|
hasImage={props.hasImage}
|
package/src/pages/about.astro
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { analyzeFeatureFlags } from "@plugins/feature-flags";
|
|
1
2
|
import type { APIRoute } from "astro";
|
|
2
3
|
import { getCollection } from "astro:content";
|
|
3
4
|
|
|
@@ -12,17 +13,19 @@ export const prerender = true;
|
|
|
12
13
|
export const GET: APIRoute = async () => {
|
|
13
14
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
14
15
|
|
|
15
|
-
const payload =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
const payload = await Promise.all(
|
|
17
|
+
posts.map(async (post) => ({
|
|
18
|
+
title: post.data.title,
|
|
19
|
+
abbrlink: String(post.data.abbrlink),
|
|
20
|
+
date: post.data.date ?? undefined,
|
|
21
|
+
updated: post.data.updated ?? undefined,
|
|
22
|
+
tags: post.data.tags ?? [],
|
|
23
|
+
categories: post.data.categories ?? [],
|
|
24
|
+
desc: post.data.desc ?? "",
|
|
25
|
+
wordCount: (await analyzeFeatureFlags(post.body)).wordCount,
|
|
26
|
+
url: `/posts/${post.data.abbrlink}/`,
|
|
27
|
+
})),
|
|
28
|
+
);
|
|
26
29
|
|
|
27
30
|
return new Response(JSON.stringify(payload), {
|
|
28
31
|
status: 200,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Stalux from "@layouts/Stalux.astro";
|
|
3
3
|
import PostLayout from "@layouts/PostLayout.astro";
|
|
4
4
|
import { getCollection, type CollectionEntry, render } from "astro:content";
|
|
5
|
+
import { analyzeFeatureFlags } from "@plugins/feature-flags";
|
|
5
6
|
import { toTimestamp } from "@utils/dayjs";
|
|
6
7
|
import Navs from "@components/stalux/layout/navs.astro";
|
|
7
8
|
import { getSiteData, getAuthorData, getHeadData, getNavsData, getLinksData, getCommentData, getFooterData, getPromoteData, getTypetextsData, getMediaLinksData } from "@utils/config-utils";
|
|
@@ -14,19 +15,11 @@ const configEntries = await getCollection("config");
|
|
|
14
15
|
const siteConfig = getSiteData(configEntries);
|
|
15
16
|
const authorConfig = getAuthorData(configEntries);
|
|
16
17
|
const props = Astro.props as Props;
|
|
17
|
-
const { Content, headings
|
|
18
|
+
const { Content, headings } = await render(props.post);
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
const flags =
|
|
21
|
-
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
// 字数统计直接从 frontmatter 读取
|
|
25
|
-
const minutesRead = (remarkPluginFrontmatter?.minutesRead as string) ?? "";
|
|
26
|
-
const readingMinutes = (remarkPluginFrontmatter?.readingMinutes as number) ?? 0;
|
|
27
|
-
const wordCount = (remarkPluginFrontmatter?.wordCount as number) ?? 0;
|
|
28
|
-
// desc 可能为空字符串,需用 || 做“空串回退”,不能用 ??(?? 会把 "" 当作有效值)
|
|
29
|
-
const autoDesc = props.post.data.desc || (remarkPluginFrontmatter?.desc as string) || "";
|
|
20
|
+
// 文章统计完全由 Sätteri AST 分析,不读取 render metadata。
|
|
21
|
+
const flags = await analyzeFeatureFlags(props.post.body);
|
|
22
|
+
const autoDesc = props.post.data.desc || "";
|
|
30
23
|
|
|
31
24
|
export async function getStaticPaths() {
|
|
32
25
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
@@ -71,7 +64,7 @@ export async function getStaticPaths() {
|
|
|
71
64
|
>
|
|
72
65
|
<div style="height:3em"></div>
|
|
73
66
|
<Navs /><div style="height:3em"></div>
|
|
74
|
-
<PostLayout title={props.post.data.title} date={props.post.data.date} updated={props.post.data.updated}
|
|
67
|
+
<PostLayout title={props.post.data.title} date={props.post.data.date} updated={props.post.data.updated} readingMinutes={flags.readingMinutes} tags={props.post.data.tags||[]} categories={props.post.data.categories||[]} headings={headings} author={authorConfig.name} authorAvatar={authorConfig.avatar} prev={props.prev} next={props.next} cc={props.post.data.cc} wordCount={flags.wordCount} hasImage={flags.hasImage}>
|
|
75
68
|
<Content/>
|
|
76
69
|
</PostLayout>
|
|
77
70
|
</Stalux>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { createSatteriMarkdownProcessor } from "@astrojs/markdown-satteri";
|
|
2
|
+
import { splitToWords } from "@echogarden/text-segmentation";
|
|
3
|
+
import jsTokens from "js-tokens";
|
|
1
4
|
/**
|
|
2
5
|
* Sätteri 插件:在构建时完成字数统计和特性标记,
|
|
3
|
-
*
|
|
6
|
+
* 只从 Sätteri 的 MDAST/HAST 节点读取信息,并通过 ctx 注入最终结果。
|
|
4
7
|
*
|
|
5
8
|
* 字数统计策略:
|
|
6
|
-
* -
|
|
9
|
+
* - 从 root 递归收集正文文本,覆盖列表、引用、表格等嵌套块。
|
|
7
10
|
* - 独立的 code 块、math/displayMath 公式不参与计数
|
|
8
11
|
* (代码是"看"不是"读",符号表达式不按词计)。
|
|
9
12
|
* - 行内 code 和行内 math 会被 textContent(paragraph) 自然包含。
|
|
@@ -15,34 +18,84 @@
|
|
|
15
18
|
*/
|
|
16
19
|
import { defineMdastPlugin, defineHastPlugin } from "satteri";
|
|
17
20
|
|
|
18
|
-
import { countWords } from "../utils/count-words.ts";
|
|
19
|
-
|
|
20
21
|
const WORDS_PER_MINUTE = 400;
|
|
22
|
+
const CODE_SECONDS_PER_NON_EMPTY_LINE = 2;
|
|
23
|
+
|
|
24
|
+
type FeatureFlagsState = {
|
|
25
|
+
proseText: string;
|
|
26
|
+
codeTokens: number;
|
|
27
|
+
codeCharacters: number;
|
|
28
|
+
codeNonEmptyLines: number;
|
|
29
|
+
codeBlocks: number;
|
|
30
|
+
hasImage: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function getState(ctx: any): FeatureFlagsState {
|
|
34
|
+
const data = ctx.data as Record<string, unknown>;
|
|
35
|
+
return (data.staluxFeatureFlags ??= {
|
|
36
|
+
proseText: "",
|
|
37
|
+
codeTokens: 0,
|
|
38
|
+
codeCharacters: 0,
|
|
39
|
+
codeNonEmptyLines: 0,
|
|
40
|
+
codeBlocks: 0,
|
|
41
|
+
hasImage: false,
|
|
42
|
+
}) as FeatureFlagsState;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function injectState(ctx: any) {
|
|
46
|
+
const state = getState(ctx);
|
|
47
|
+
const injected = ctx.data.astro.frontmatter as Record<string, unknown>;
|
|
48
|
+
injected.proseText = state.proseText;
|
|
49
|
+
injected.codeTokens = state.codeTokens;
|
|
50
|
+
injected.codeCharacters = state.codeCharacters;
|
|
51
|
+
injected.codeNonEmptyLines = state.codeNonEmptyLines;
|
|
52
|
+
injected.codeBlocks = state.codeBlocks;
|
|
53
|
+
injected.hasImage = state.hasImage;
|
|
54
|
+
}
|
|
21
55
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
function countCodeTokens(value: string, lang?: string): number {
|
|
57
|
+
if (/^(?:js|jsx|javascript|ts|tsx|typescript)$/.test(lang ?? "")) {
|
|
58
|
+
return Array.from(jsTokens(value, { jsx: /jsx|tsx/.test(lang ?? "") })).filter(
|
|
59
|
+
(token) =>
|
|
60
|
+
![
|
|
61
|
+
"WhiteSpace",
|
|
62
|
+
"LineTerminatorSequence",
|
|
63
|
+
"MultiLineComment",
|
|
64
|
+
"SingleLineComment",
|
|
65
|
+
"HashbangComment",
|
|
66
|
+
].includes(token.type),
|
|
67
|
+
).length;
|
|
68
|
+
}
|
|
69
|
+
return value.match(/\p{L}[\p{L}\p{N}_]*|\p{N}+(?:\.\p{N}+)?|[^\s]/gu)?.length ?? 0;
|
|
31
70
|
}
|
|
32
71
|
|
|
33
72
|
export const featureFlagsMdast = defineMdastPlugin({
|
|
34
73
|
name: "feature-flags-mdast",
|
|
35
74
|
|
|
36
|
-
code(
|
|
37
|
-
|
|
75
|
+
code(node, ctx) {
|
|
76
|
+
const state = getState(ctx);
|
|
77
|
+
state.codeBlocks++;
|
|
78
|
+
state.codeCharacters += [...node.value].length;
|
|
79
|
+
state.codeNonEmptyLines += node.value
|
|
80
|
+
.split(/\r?\n/u)
|
|
81
|
+
.filter((line) => /\S/u.test(line)).length;
|
|
82
|
+
state.codeTokens += countCodeTokens(node.value, node.lang);
|
|
83
|
+
injectState(ctx);
|
|
38
84
|
},
|
|
39
85
|
|
|
40
86
|
heading(node, ctx) {
|
|
41
|
-
|
|
87
|
+
getState(ctx).proseText += ` ${ctx.textContent(node)}`;
|
|
88
|
+
injectState(ctx);
|
|
42
89
|
},
|
|
43
90
|
|
|
44
91
|
paragraph(node, ctx) {
|
|
45
|
-
|
|
92
|
+
getState(ctx).proseText += ` ${ctx.textContent(node)}`;
|
|
93
|
+
injectState(ctx);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
tableCell(node, ctx) {
|
|
97
|
+
getState(ctx).proseText += ` ${ctx.textContent(node)}`;
|
|
98
|
+
injectState(ctx);
|
|
46
99
|
},
|
|
47
100
|
});
|
|
48
101
|
|
|
@@ -55,7 +108,9 @@ export const featureFlagsHast = defineHastPlugin({
|
|
|
55
108
|
element: {
|
|
56
109
|
filter: ["img"],
|
|
57
110
|
visit(node, ctx) {
|
|
58
|
-
|
|
111
|
+
const state = getState(ctx);
|
|
112
|
+
state.hasImage = true;
|
|
113
|
+
(ctx.data.astro.frontmatter as Record<string, unknown>).hasImage = true;
|
|
59
114
|
// 第一张正文图片可能成为 LCP,优先加载;其余图片延迟加载。
|
|
60
115
|
const data = ctx.data as Record<string, unknown>;
|
|
61
116
|
const firstImage = !data.staluxFirstImageSeen;
|
|
@@ -72,3 +127,32 @@ export const featureFlagsHast = defineHastPlugin({
|
|
|
72
127
|
},
|
|
73
128
|
},
|
|
74
129
|
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 直接用 Sätteri AST 分析一篇文章,供文章页、全局统计和 API 复用。
|
|
133
|
+
* 这里不复用内容集合的 data,也不读取 Astro 的渲染 metadata。
|
|
134
|
+
*/
|
|
135
|
+
export async function analyzeFeatureFlags(body: string | undefined): Promise<{
|
|
136
|
+
hasImage: boolean;
|
|
137
|
+
readingMinutes: number;
|
|
138
|
+
wordCount: number;
|
|
139
|
+
}> {
|
|
140
|
+
const processor = await createSatteriMarkdownProcessor({
|
|
141
|
+
mdastPlugins: [featureFlagsMdast],
|
|
142
|
+
hastPlugins: [featureFlagsHast],
|
|
143
|
+
});
|
|
144
|
+
const result = await processor.render(body ?? "");
|
|
145
|
+
const metadata = result.metadata.frontmatter as Record<string, unknown>;
|
|
146
|
+
const proseWords = (
|
|
147
|
+
await splitToWords(String(metadata.proseText ?? ""))
|
|
148
|
+
).nonPunctuationEntries.filter((entry) => /\S/u.test(entry.text)).length;
|
|
149
|
+
const codeNonEmptyLines = Number(metadata.codeNonEmptyLines ?? 0);
|
|
150
|
+
return {
|
|
151
|
+
hasImage: metadata.hasImage === true,
|
|
152
|
+
readingMinutes: Math.ceil(
|
|
153
|
+
proseWords / WORDS_PER_MINUTE +
|
|
154
|
+
(codeNonEmptyLines * CODE_SECONDS_PER_NON_EMPTY_LINE) / 60,
|
|
155
|
+
),
|
|
156
|
+
wordCount: proseWords,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -61,8 +61,6 @@ const postSchema = z.object({
|
|
|
61
61
|
invalid_type_error: "desc must be a string",
|
|
62
62
|
})
|
|
63
63
|
.min(1, "desc is required"),
|
|
64
|
-
minutesRead: z.string().optional(),
|
|
65
|
-
wordCount: z.number().optional(),
|
|
66
64
|
cc: z.string().optional().default("CC-BY-NC-SA-4.0"),
|
|
67
65
|
cover: z.string().optional(),
|
|
68
66
|
});
|
|
@@ -1,34 +1,12 @@
|
|
|
1
|
+
import { analyzeFeatureFlags } from "@plugins/feature-flags";
|
|
1
2
|
/**
|
|
2
3
|
* 站点字数统计工具
|
|
3
4
|
*
|
|
4
5
|
* Astro 7.1 启用 deferRender 后,post.rendered 不再在 content sync 阶段预计算。
|
|
5
|
-
*
|
|
6
|
+
* 因此统一通过 Sätteri AST 分析 post.body,确保各出口使用同一套结果。
|
|
6
7
|
*/
|
|
7
8
|
import { getCollection } from "astro:content";
|
|
8
9
|
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// 字数统计算法(基于 W3C Intl.Segmenter,body-based)
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const bodySegmenter = new Intl.Segmenter("zh", { granularity: "word" });
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* 从原始 markdown 正文估算字数。
|
|
17
|
-
* - 中文:`Intl.Segmenter` 逐字分割,每个汉字计 1
|
|
18
|
-
* - 英文:按词计 1
|
|
19
|
-
* - 纯数字序列:不计入(与旧正则行为一致)
|
|
20
|
-
* - 标点/空白不计
|
|
21
|
-
*/
|
|
22
|
-
function countWordsFromBody(body: string | undefined | null): number {
|
|
23
|
-
if (!body) return 0;
|
|
24
|
-
let count = 0;
|
|
25
|
-
for (const { segment, isWordLike } of bodySegmenter.segment(body)) {
|
|
26
|
-
// 排除纯数字序列(如年份、代码行号等),与旧正则行为保持一致
|
|
27
|
-
if (isWordLike && !/^\d+$/.test(segment)) count++;
|
|
28
|
-
}
|
|
29
|
-
return count;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
10
|
// ---------------------------------------------------------------------------
|
|
33
11
|
// 模块级缓存(整个构建周期只计算一次)
|
|
34
12
|
// ---------------------------------------------------------------------------
|
|
@@ -43,7 +21,8 @@ export async function getTotalWordCount(): Promise<number> {
|
|
|
43
21
|
if (_cachedTotal !== null) return _cachedTotal;
|
|
44
22
|
try {
|
|
45
23
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
46
|
-
|
|
24
|
+
const results = await Promise.all(posts.map((post) => analyzeFeatureFlags(post.body)));
|
|
25
|
+
_cachedTotal = results.reduce((sum, result) => sum + result.wordCount, 0);
|
|
47
26
|
return _cachedTotal;
|
|
48
27
|
} catch (error) {
|
|
49
28
|
console.error("计算文章总字数时出错:", error);
|
|
@@ -56,18 +35,20 @@ let _cachedDescriptions: Map<string, { desc: string; wordCount: number }> | null
|
|
|
56
35
|
/**
|
|
57
36
|
* 获取所有已发布文章的描述和字数缓存,供 RSS/Atom/LLMs feed 共享。
|
|
58
37
|
* - desc :直接从 frontmatter 读取(post.data.desc)
|
|
59
|
-
* - wordCount:从 post.body
|
|
38
|
+
* - wordCount:从 Sätteri AST 分析 post.body
|
|
60
39
|
*/
|
|
61
40
|
export async function getPostDescriptions(): Promise<
|
|
62
41
|
Map<string, { desc: string; wordCount: number }>
|
|
63
42
|
> {
|
|
64
43
|
if (_cachedDescriptions !== null) return _cachedDescriptions;
|
|
65
44
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
66
|
-
const results =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
45
|
+
const results = await Promise.all(
|
|
46
|
+
posts.map(async (post) => ({
|
|
47
|
+
id: String(post.data.abbrlink ?? post.id),
|
|
48
|
+
desc: post.data.desc || "",
|
|
49
|
+
wordCount: (await analyzeFeatureFlags(post.body)).wordCount,
|
|
50
|
+
})),
|
|
51
|
+
);
|
|
71
52
|
_cachedDescriptions = new Map(
|
|
72
53
|
results.map((r) => [r.id, { desc: r.desc, wordCount: r.wordCount }]),
|
|
73
54
|
);
|