@xingwangzhe/stalux 1.25.4 → 1.25.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.25.4",
3
+ "version": "1.25.6",
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,10 @@
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/text-segmentation": "^0.7.0",
80
82
  "@expressive-code/plugin-line-numbers": "^0.44.1",
81
83
  "@lucide/astro": "^1.33.0",
82
84
  "@mcp-b/webmcp-polyfill": "^4.0.0",
@@ -90,6 +92,7 @@
90
92
  "astro-seo": "^1.1.0",
91
93
  "astro-typewriter": "^1.2.0",
92
94
  "badge-maker": "^6.0.0",
95
+ "js-tokens": "^10.0.0",
93
96
  "pagefind": "^1.5.2",
94
97
  "photoswipe": "^5.4.4",
95
98
  "satteri": "^0.10.5",
@@ -76,7 +76,7 @@ const contentTitle =
76
76
  : effectiveTitle;
77
77
  const resolvedNoindex = Boolean(siteConfig.noindex) || isNotFoundPage || Boolean(props.noindex);
78
78
  const resolvedNofollow = Boolean(siteConfig.nofollow) || isNotFoundPage;
79
- const staluxVersion = "1.25.4";
79
+ const staluxVersion = "1.25.5";
80
80
 
81
81
  const openGraphConfig: Record<string, unknown> = {
82
82
  basic: {
@@ -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
- {(Boolean(props.readingTime) || (props.readingMinutes ?? 0) > 0) && (
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
- : props.readingTime}
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}
@@ -37,7 +37,6 @@ const author = authorConfig.name;
37
37
  transitionName="about-content"
38
38
  date={undefined}
39
39
  updated={undefined}
40
- readingTime={undefined}
41
40
  >
42
41
  <Content />
43
42
  <PostCC
@@ -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 = posts.map((post) => ({
16
- title: post.data.title,
17
- abbrlink: String(post.data.abbrlink),
18
- date: post.data.date ?? undefined,
19
- updated: post.data.updated ?? undefined,
20
- tags: post.data.tags ?? [],
21
- categories: post.data.categories ?? [],
22
- desc: post.data.desc ?? "",
23
- wordCount: post.data.wordCount ?? undefined,
24
- url: `/posts/${post.data.abbrlink}/`,
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,
@@ -46,24 +46,14 @@ import { getSiteData, getAuthorData, getHeadData, getNavsData, getLinksData, get
46
46
 
47
47
  <style>
48
48
  .agent-home-summary {
49
- max-width: 52rem;
50
- margin: 1rem auto 0;
51
- padding: 0 1rem;
52
- text-align: left;
53
- }
54
-
55
- .agent-home-summary h2 {
56
- font-size: 1.25rem;
57
- margin: 0 0 0.5rem;
58
- }
59
-
60
- .agent-home-summary p {
61
- line-height: 1.7;
62
- margin: 0;
63
- }
64
-
65
- .agent-home-summary a {
66
- color: inherit;
67
- text-decoration: underline;
49
+ position: absolute;
50
+ width: 1px;
51
+ height: 1px;
52
+ padding: 0;
53
+ margin: -1px;
54
+ overflow: hidden;
55
+ clip: rect(0, 0, 0, 0);
56
+ white-space: normal;
57
+ border: 0;
68
58
  }
69
59
  </style>
@@ -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, remarkPluginFrontmatter } = await render(props.post);
18
+ const { Content, headings } = await render(props.post);
18
19
 
19
- // 从 remarkPluginFrontmatter 读取 feature flags 和字数统计(由 satteri 插件在构建时写入)
20
- const flags = {
21
- hasImage: remarkPluginFrontmatter?.hasImage ?? false,
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} readingTime={minutesRead} readingMinutes={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={wordCount} hasImage={flags.hasImage}>
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,6 +1,9 @@
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
- * 通过 ctx.data.astro.frontmatter 把最终结果传递到 frontmatter,运行时无需再算。
6
+ * 只从 Sätteri 的 MDAST/HAST 节点读取信息,并通过 ctx 注入最终结果。
4
7
  *
5
8
  * 字数统计策略:
6
9
  * - 只数 heading 和 paragraph 的内容(用 ctx.textContent 获取纯文本)。
@@ -15,34 +18,79 @@
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
- /** 从纯文本统计字数并累加到 frontmatter。 */
23
- function flushWordCount(ctx: any, text: string) {
24
- const prev = (ctx.data.astro.frontmatter.wordCount as number) ?? 0;
25
- const wc = prev + countWords(text);
26
- const fm = ctx.data.astro.frontmatter;
27
- fm.wordCount = wc;
28
- fm.readingMinutes = Math.ceil(wc / WORDS_PER_MINUTE);
29
- // 搜索全文:拼接所有 heading + paragraph 的纯文本
30
- fm.searchText = ((fm.searchText as string) || "") + " " + text;
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(_node, _ctx) {
37
- // 独立的 code 块不参与字数统计
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
- flushWordCount(ctx, ctx.textContent(node));
87
+ getState(ctx).proseText += ` ${ctx.textContent(node)}`;
88
+ injectState(ctx);
42
89
  },
43
90
 
44
91
  paragraph(node, ctx) {
45
- flushWordCount(ctx, ctx.textContent(node));
92
+ getState(ctx).proseText += ` ${ctx.textContent(node)}`;
93
+ injectState(ctx);
46
94
  },
47
95
  });
48
96
 
@@ -55,7 +103,9 @@ export const featureFlagsHast = defineHastPlugin({
55
103
  element: {
56
104
  filter: ["img"],
57
105
  visit(node, ctx) {
58
- ctx.data.astro.frontmatter.hasImage = true;
106
+ const state = getState(ctx);
107
+ state.hasImage = true;
108
+ (ctx.data.astro.frontmatter as Record<string, unknown>).hasImage = true;
59
109
  // 第一张正文图片可能成为 LCP,优先加载;其余图片延迟加载。
60
110
  const data = ctx.data as Record<string, unknown>;
61
111
  const firstImage = !data.staluxFirstImageSeen;
@@ -72,3 +122,32 @@ export const featureFlagsHast = defineHastPlugin({
72
122
  },
73
123
  },
74
124
  });
125
+
126
+ /**
127
+ * 直接用 Sätteri AST 分析一篇文章,供文章页、全局统计和 API 复用。
128
+ * 这里不复用内容集合的 data,也不读取 Astro 的渲染 metadata。
129
+ */
130
+ export async function analyzeFeatureFlags(body: string | undefined): Promise<{
131
+ hasImage: boolean;
132
+ readingMinutes: number;
133
+ wordCount: number;
134
+ }> {
135
+ const processor = await createSatteriMarkdownProcessor({
136
+ mdastPlugins: [featureFlagsMdast],
137
+ hastPlugins: [featureFlagsHast],
138
+ });
139
+ const result = await processor.render(body ?? "");
140
+ const metadata = result.metadata.frontmatter as Record<string, unknown>;
141
+ const proseWords = (
142
+ await splitToWords(String(metadata.proseText ?? ""))
143
+ ).nonPunctuationEntries.filter((entry) => /\S/u.test(entry.text)).length;
144
+ const codeNonEmptyLines = Number(metadata.codeNonEmptyLines ?? 0);
145
+ return {
146
+ hasImage: metadata.hasImage === true,
147
+ readingMinutes: Math.ceil(
148
+ proseWords / WORDS_PER_MINUTE +
149
+ (codeNonEmptyLines * CODE_SECONDS_PER_NON_EMPTY_LINE) / 60,
150
+ ),
151
+ wordCount: proseWords,
152
+ };
153
+ }
@@ -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
- * 因此改为直接从 post.body(原始 markdown)统计字数,不再依赖 Sätteri 的渲染结果。
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
- _cachedTotal = posts.reduce((sum, post) => sum + countWordsFromBody(post.body), 0);
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 = posts.map((post) => ({
67
- id: String(post.data.abbrlink ?? post.id),
68
- desc: post.data.desc || "",
69
- wordCount: countWordsFromBody(post.body),
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
  );