@xingwangzhe/stalux 1.11.0 → 1.12.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 CHANGED
@@ -171,12 +171,15 @@ extension) interact with your blog directly — **no backend required**.
171
171
 
172
172
  When a WebMCP-aware browser opens your site, these read-only tools are registered:
173
173
 
174
- | Tool | What it does | Backing data |
175
- | --------------------- | ----------------------------------------------------------------------- | ------------------------- |
176
- | `stalux_list_posts` | Paginated list of all posts | `/api/post.abbrlink.json` |
177
- | `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
178
- | `stalux_read_post` | Fetch a post's raw Markdown | `/posts/{abbrlink}.md` |
179
- | `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
174
+ | Tool | What it does | Backing data |
175
+ | --------------------- | ----------------------------------------------------------------------- | ----------------------- |
176
+ | `stalux_list_posts` | Paginated list of all posts (with meta) | `/api/posts.json` |
177
+ | `stalux_get_post` | Fetch one post's metadata by abbrlink or title keyword | `/api/posts.json` |
178
+ | `stalux_current_post` | Metadata of the post currently being viewed | `/api/posts.json` |
179
+ | `stalux_random_post` | Pick a random post's metadata | `/api/posts.json` |
180
+ | `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
181
+ | `stalux_read_post` | Fetch a post's raw Markdown | `/posts/{abbrlink}.md` |
182
+ | `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
180
183
 
181
184
  All tools are `readOnlyHint: true` — they never modify any state.
182
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
package/src/index.ts CHANGED
@@ -68,6 +68,10 @@ function getRoutes(baseDir: URL): RouteEntry[] {
68
68
  pattern: "/api/post.abbrlink.json",
69
69
  entrypoint: resolvePage("./pages/api/post.abbrlink.json.ts"),
70
70
  },
71
+ {
72
+ pattern: "/api/posts.json",
73
+ entrypoint: resolvePage("./pages/api/posts.json.ts"),
74
+ },
71
75
 
72
76
  // ---- Markdown 导出端点 ----
73
77
  { pattern: "/index.md", entrypoint: resolvePage("./pages/index.md.ts") },
@@ -0,0 +1,33 @@
1
+ import type { APIRoute } from "astro";
2
+ import { getCollection } from "astro:content";
3
+
4
+ export const prerender = true;
5
+
6
+ /**
7
+ * 文章元信息索引(供 WebMCP 工具与外部消费)
8
+ *
9
+ * 相比 /api/post.abbrlink.json,包含完整元信息(日期/分类/标签/摘要/字数),
10
+ * 是 stalux_get_post / stalux_random_post 等 WebMCP 工具的廉价数据源。
11
+ */
12
+ export const GET: APIRoute = async () => {
13
+ const posts = await getCollection("posts", ({ data }) => !data.draft);
14
+
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
+ }));
26
+
27
+ return new Response(JSON.stringify(payload), {
28
+ status: 200,
29
+ headers: {
30
+ "Content-Type": "application/json; charset=utf-8",
31
+ },
32
+ });
33
+ };
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * 遵循 W3C webmachinelearning/webmcp 草案:网页通过 document.modelContext
5
5
  * 向浏览器/代理注册工具。所有数据源均为构建期静态产物——
6
- * /api/post.abbrlink.json(文章索引)、/posts/{id}.md(源码导出)、
7
- * /llms.txt / /llms-full.txt(站点信息镜像)、/pagefind/(全文索引)。
6
+ * /api/posts.json(文章元信息索引)、/api/post.abbrlink.json(兼容列表)、
7
+ * /posts/{id}.md(源码导出)、/llms.txt / /llms-full.txt(站点信息镜像)、
8
+ * /pagefind/(全文索引)。
8
9
  *
9
10
  * 浏览器无原生 modelContext 时,由布局注入的 @mcp-b/webmcp-polyfill
10
11
  * 提供兜底实现;两者都没有则静默跳过注册,不影响普通访问者。
@@ -87,22 +88,79 @@ const MAX_LIST_PAGE_SIZE = 50;
87
88
  const MAX_SEARCH_RESULTS = 10;
88
89
 
89
90
  // ---------------------------------------------------------------------------
90
- // 工具定义
91
+ // 文章元信息数据层(/api/posts.json)
91
92
  // ---------------------------------------------------------------------------
92
93
 
93
- interface PostIndexEntry {
94
+ interface PostMeta {
94
95
  title: string;
95
- abbrlink: string | number;
96
+ abbrlink: string;
97
+ date?: string;
98
+ updated?: string;
99
+ tags?: string[];
100
+ categories?: string[];
101
+ desc?: string;
102
+ wordCount?: number;
103
+ url: string;
104
+ }
105
+
106
+ let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
107
+
108
+ /** 加载文章元信息索引(惰性 + 缓存) */
109
+ function loadPostsMeta(): Promise<PostMeta[] | null> {
110
+ if (!postsMetaPromise) {
111
+ postsMetaPromise = fetchJSON("/api/posts.json").then((data) =>
112
+ Array.isArray(data) ? (data as PostMeta[]) : null,
113
+ );
114
+ }
115
+ return postsMetaPromise;
116
+ }
117
+
118
+ /** 按 abbrlink 精确查找 */
119
+ function findById(list: PostMeta[], id: string): PostMeta | undefined {
120
+ return list.find((p) => p.abbrlink === id);
121
+ }
122
+
123
+ /** 按标题/标签关键词模糊查找(取第一条) */
124
+ function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
125
+ const kw = keyword.toLowerCase();
126
+ return (
127
+ list.find((p) => p.title.toLowerCase().includes(kw)) ??
128
+ list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
129
+ );
130
+ }
131
+
132
+ /** 简化为输出用的元信息视图 */
133
+ function briefMeta(p: PostMeta): Record<string, unknown> {
134
+ return {
135
+ title: p.title,
136
+ abbrlink: p.abbrlink,
137
+ date: p.date ?? undefined,
138
+ tags: p.tags ?? [],
139
+ categories: p.categories ?? [],
140
+ desc: p.desc ?? undefined,
141
+ wordCount: p.wordCount ?? undefined,
142
+ url: p.url,
143
+ };
144
+ }
145
+
146
+ /** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
147
+ function currentAbbrlinkFromPath(): string | null {
148
+ const m = location.pathname.match(/^\/posts\/([^/]+?)(?:\.md)?\/?$/);
149
+ return m ? decodeURIComponent(m[1]) : null;
96
150
  }
97
151
 
152
+ // ---------------------------------------------------------------------------
153
+ // 工具定义
154
+ // ---------------------------------------------------------------------------
155
+
98
156
  function listPostsTool(): WebMCPTool {
99
157
  return {
100
158
  name: "stalux_list_posts",
101
159
  title: "列出博客文章",
102
160
  description:
103
- "分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、文章页 URL。" +
161
+ "分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
104
162
  "需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
105
- "正文请用 stalux_read_post。",
163
+ "只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。",
106
164
  annotations: { readOnlyHint: true, untrustedContentHint: false },
107
165
  inputSchema: {
108
166
  type: "object",
@@ -124,9 +182,9 @@ function listPostsTool(): WebMCPTool {
124
182
  MAX_LIST_PAGE_SIZE,
125
183
  Math.max(1, Number(input.pageSize) || 10),
126
184
  );
127
- const index = (await fetchJSON("/api/post.abbrlink.json")) as PostIndexEntry[] | null;
128
- if (!Array.isArray(index)) return err("INDEX_UNAVAILABLE", "无法获取文章列表");
129
- const total = index.length;
185
+ const list = await loadPostsMeta();
186
+ if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章列表");
187
+ const total = list.length;
130
188
  const totalPages = Math.ceil(total / pageSize) || 1;
131
189
  const start = (page - 1) * pageSize;
132
190
  if (start >= total) {
@@ -140,23 +198,96 @@ function listPostsTool(): WebMCPTool {
140
198
  },
141
199
  );
142
200
  }
143
- const posts = index.slice(start, start + pageSize).map((p) => ({
144
- title: p.title,
145
- abbrlink: String(p.abbrlink),
146
- url: "/posts/" + p.abbrlink + "/",
147
- }));
201
+ const posts = list.slice(start, start + pageSize).map(briefMeta);
148
202
  return { ok: true, page, pageSize, total, totalPages, posts };
149
203
  },
150
204
  };
151
205
  }
152
206
 
207
+ function getPostTool(): WebMCPTool {
208
+ return {
209
+ name: "stalux_get_post",
210
+ title: "查看文章信息",
211
+ description:
212
+ "按 abbrlink 或标题关键词定位一篇文章,返回其元信息(标题、日期、分类、标签、摘要、字数、文章页 URL)。" +
213
+ "只要元信息时用它,比读取正文便宜得多;读正文请用 stalux_read_post。" +
214
+ "获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
215
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
216
+ inputSchema: {
217
+ type: "object",
218
+ properties: {
219
+ id: {
220
+ type: "string",
221
+ description:
222
+ "文章 abbrlink(如 f4442947)或标题关键词(模糊匹配,取第一条命中)",
223
+ },
224
+ },
225
+ required: ["id"],
226
+ additionalProperties: false,
227
+ },
228
+ execute: async (input) => {
229
+ const id = String(input.id ?? "").trim();
230
+ if (!id) return err("BAD_INPUT", "请提供文章 abbrlink 或标题关键词");
231
+ const list = await loadPostsMeta();
232
+ if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章索引");
233
+ const post = findById(list, id) ?? findByKeyword(list, id);
234
+ if (!post) return err("NOT_FOUND", "未找到文章: " + id);
235
+ return { ok: true, post: briefMeta(post) };
236
+ },
237
+ };
238
+ }
239
+
240
+ function currentPostTool(): WebMCPTool {
241
+ return {
242
+ name: "stalux_current_post",
243
+ title: "当前文章信息",
244
+ description:
245
+ "返回用户当前正在浏览的那篇文章的元信息。当用户说「这篇文章」「当前页面」时先调用它确定上下文," +
246
+ "比读正文便宜得多。不在文章页时返回错误。",
247
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
248
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
249
+ execute: async () => {
250
+ const id = currentAbbrlinkFromPath();
251
+ if (!id) {
252
+ return err("NOT_ON_POST_PAGE", "当前不在文章页(路径: " + location.pathname + ")");
253
+ }
254
+ const list = await loadPostsMeta();
255
+ if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章索引");
256
+ const post = findById(list, id);
257
+ if (!post) return err("NOT_FOUND", "未找到当前文章: " + id);
258
+ return { ok: true, post: briefMeta(post) };
259
+ },
260
+ };
261
+ }
262
+
263
+ function randomPostTool(): WebMCPTool {
264
+ return {
265
+ name: "stalux_random_post",
266
+ title: "随机一篇文章",
267
+ description:
268
+ "随机挑选一篇文章并返回其元信息(标题、abbrlink、日期、摘要、URL)。" +
269
+ "适合推荐、探索、或不确定从哪篇开始时使用;不返回正文,不导航。",
270
+ annotations: { readOnlyHint: true, untrustedContentHint: false },
271
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
272
+ execute: async () => {
273
+ const list = await loadPostsMeta();
274
+ if (!list || list.length === 0) {
275
+ return err("INDEX_UNAVAILABLE", "无法获取文章索引");
276
+ }
277
+ const post = list[Math.floor(Math.random() * list.length)];
278
+ return { ok: true, post: briefMeta(post) };
279
+ },
280
+ };
281
+ }
282
+
153
283
  function searchPostsTool(): WebMCPTool {
154
284
  return {
155
285
  name: "stalux_search_posts",
156
286
  title: "搜索博客文章",
157
287
  description:
158
288
  "用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
159
- "适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题。",
289
+ "适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
290
+ "定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
160
291
  annotations: { readOnlyHint: true, untrustedContentHint: false },
161
292
  inputSchema: {
162
293
  type: "object",
@@ -215,7 +346,8 @@ function readPostTool(): WebMCPTool {
215
346
  title: "读取文章 Markdown",
216
347
  description:
217
348
  "按 abbrlink(文章永久链接 ID)读取一篇博客文章的原始 Markdown 全文,包含 frontmatter 与版权脚注。" +
218
- "需要文章完整内容、引用或摘要时使用;获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
349
+ "需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
350
+ "获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
219
351
  annotations: { readOnlyHint: true, untrustedContentHint: false },
220
352
  inputSchema: {
221
353
  type: "object",
@@ -263,7 +395,15 @@ function siteInfoTool(): WebMCPTool {
263
395
  }
264
396
 
265
397
  function buildTools(): WebMCPTool[] {
266
- return [listPostsTool(), searchPostsTool(), readPostTool(), siteInfoTool()];
398
+ return [
399
+ listPostsTool(),
400
+ getPostTool(),
401
+ currentPostTool(),
402
+ randomPostTool(),
403
+ searchPostsTool(),
404
+ readPostTool(),
405
+ siteInfoTool(),
406
+ ];
267
407
  }
268
408
 
269
409
  // ---------------------------------------------------------------------------