@xingwangzhe/stalux 1.35.11 → 1.35.13

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 (43) hide show
  1. package/README.md +24 -11
  2. package/package.json +5 -2
  3. package/src/components/stalux/common/Typewriter.astro +32 -1
  4. package/src/components/stalux/posts/postContent.astro +7 -1
  5. package/src/index.ts +51 -104
  6. package/src/internal/bounded-tasks.ts +28 -0
  7. package/src/internal/compact-styles.ts +55 -13
  8. package/src/internal/font-slices.ts +150 -47
  9. package/src/internal/html-images.ts +2 -0
  10. package/src/internal/html-output.ts +98 -0
  11. package/src/internal/image-dimensions.ts +99 -74
  12. package/src/internal/page-output-cache.ts +26 -9
  13. package/src/internal/public-assets.ts +59 -0
  14. package/src/internal/runtime-cache-key.ts +14 -3
  15. package/src/internal/style-names.generated.ts +27 -26
  16. package/src/layouts/PostLayout.astro +1 -0
  17. package/src/layouts/Stalux.astro +3 -1
  18. package/src/pages/api/post.abbrlink.json.ts +0 -3
  19. package/src/pages/api/posts.json.ts +16 -19
  20. package/src/pages/openapi.json.ts +3 -44
  21. package/src/pages/posts/[post].md.ts +4 -40
  22. package/src/plugins/feature-flags.ts +8 -1
  23. package/src/scripts/navigation-font.ts +28 -0
  24. package/src/scripts/navigation.ts +4 -2
  25. package/src/scripts/page-runtime.ts +20 -6
  26. package/src/scripts/pagefind-api.ts +63 -0
  27. package/src/scripts/photoswipe.ts +13 -3
  28. package/src/scripts/random-posts.ts +12 -10
  29. package/src/scripts/search-dialog.ts +2 -1
  30. package/src/scripts/view-transitions.ts +12 -1
  31. package/src/scripts/webmcp.ts +397 -141
  32. package/src/scripts/word-list.ts +13 -3
  33. package/src/styles/base.css +7 -0
  34. package/src/styles/generated.css +1 -1
  35. package/src/utils/collections-stats.ts +9 -22
  36. package/src/utils/content-index.ts +13 -4
  37. package/src/utils/dayjs.ts +26 -11
  38. package/src/utils/feed.ts +30 -29
  39. package/src/utils/post-lookup.ts +51 -0
  40. package/src/utils/post-markdown.ts +31 -0
  41. package/src/utils/site-path.ts +4 -0
  42. package/src/utils/toc-scrollspy.ts +20 -6
  43. package/src/utils/word-count-utils.ts +45 -41
@@ -1,5 +1,3 @@
1
- import { createClientLogger } from "./logger";
2
-
3
1
  /**
4
2
  * Stalux WebMCP 工具注册(纯前端,零后端)
5
3
  *
@@ -19,8 +17,13 @@ import { createClientLogger } from "./logger";
19
17
  */
20
18
  // 浏览器无原生 document.modelContext 时由 polyfill 兜底安装;
21
19
  // 有原生实现或已存在 WebMCP-aware 扩展时,polyfill 检测到后自动 no-op。
22
- // 放在模块顶部 import,保证其副作用在下方注册逻辑执行前完成。
23
- import "@mcp-b/webmcp-polyfill";
20
+ // ESM entry exports an initializer; importing it alone does not install the API.
21
+ import { initializeWebMCPPolyfill } from "@mcp-b/webmcp-polyfill";
22
+ import { createPostLookup } from "../utils/post-lookup";
23
+ import { sitePath } from "../utils/site-path";
24
+ import { createClientLogger } from "./logger";
25
+ import { registerPageLifecycle } from "./page-runtime";
26
+ import { loadPagefind } from "./pagefind-api";
24
27
 
25
28
  const logger = createClientLogger("webmcp");
26
29
 
@@ -45,7 +48,10 @@ interface WebMCPTool {
45
48
  readOnlyHint?: boolean;
46
49
  untrustedContentHint?: boolean;
47
50
  };
48
- execute: (input: Record<string, unknown>) => Promise<unknown>;
51
+ execute: (
52
+ input: Record<string, unknown>,
53
+ options?: { signal?: AbortSignal },
54
+ ) => Promise<unknown>;
49
55
  }
50
56
 
51
57
  interface ModelContext {
@@ -87,7 +93,10 @@ function err(
87
93
  }
88
94
 
89
95
  async function fetchJSON(url: string): Promise<unknown> {
90
- const r = await fetch(url, { headers: { Accept: "application/json" } });
96
+ const r = await fetch(url, {
97
+ headers: { Accept: "application/json" },
98
+ signal: AbortSignal.timeout(15000),
99
+ });
91
100
  if (!r.ok) {
92
101
  logger.debug(`JSON request failed; status=${r.status}`);
93
102
  return null;
@@ -95,27 +104,6 @@ async function fetchJSON(url: string): Promise<unknown> {
95
104
  return r.json();
96
105
  }
97
106
 
98
- /** 动态加载 Pagefind 全文索引(仅首次调用,结果跨调用缓存) */
99
- let pagefindLoadPromise: Promise<{ search: (q: string) => Promise<unknown> } | null> | null = null;
100
- async function loadPagefind(): Promise<{ search: (q: string) => Promise<unknown> } | null> {
101
- logger.debug(pagefindLoadPromise ? "Pagefind module cache hit" : "loading Pagefind module");
102
- if (!pagefindLoadPromise) {
103
- pagefindLoadPromise = (async () => {
104
- try {
105
- // 用完整 URL 构造,让 Vite 在 dev(vite:import-analysis)与 build 都不解析该路径;
106
- // pagefind 索引是构建期产物,运行时从站点根加载。
107
- const url = new URL("/pagefind/pagefind.js", location.origin).href;
108
- const mod = await import(/* @vite-ignore */ url);
109
- return mod.default ?? mod;
110
- } catch {
111
- logger.warn("Pagefind index unavailable; search requires a production index");
112
- return null;
113
- }
114
- })();
115
- }
116
- return pagefindLoadPromise;
117
- }
118
-
119
107
  const MAX_LIST_PAGE_SIZE = 50;
120
108
  const MAX_SEARCH_RESULTS = 10;
121
109
 
@@ -136,29 +124,61 @@ interface PostMeta {
136
124
  }
137
125
 
138
126
  let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
127
+ const postLookups = new WeakMap<PostMeta[], ReturnType<typeof createPostLookup<PostMeta>>>();
128
+
129
+ function lookupFor(list: PostMeta[]) {
130
+ let lookup = postLookups.get(list);
131
+ if (!lookup) {
132
+ lookup = createPostLookup(list);
133
+ postLookups.set(list, lookup);
134
+ }
135
+ return lookup;
136
+ }
139
137
 
140
138
  /** 加载文章元信息索引(惰性 + 缓存) */
141
139
  function loadPostsMeta(): Promise<PostMeta[] | null> {
142
140
  if (!postsMetaPromise) {
143
- postsMetaPromise = fetchJSON("/api/posts.json").then((data) =>
144
- Array.isArray(data) ? (data as PostMeta[]) : null,
145
- );
141
+ const attempt = fetchJSON(sitePath("api/posts.json"))
142
+ .then((data) => {
143
+ if (
144
+ !Array.isArray(data) ||
145
+ !data.every(
146
+ (post) =>
147
+ post &&
148
+ typeof post.title === "string" &&
149
+ typeof post.abbrlink === "string" &&
150
+ typeof post.url === "string" &&
151
+ (post.desc === undefined || typeof post.desc === "string") &&
152
+ (!post.categories ||
153
+ (Array.isArray(post.categories) &&
154
+ post.categories.every(
155
+ (category: unknown) => typeof category === "string",
156
+ ))) &&
157
+ (!post.tags ||
158
+ (Array.isArray(post.tags) &&
159
+ post.tags.every((tag: unknown) => typeof tag === "string"))),
160
+ )
161
+ )
162
+ return null;
163
+ return data as PostMeta[];
164
+ })
165
+ .catch(() => null);
166
+ postsMetaPromise = attempt;
167
+ void attempt.then((result) => {
168
+ if (!result && postsMetaPromise === attempt) postsMetaPromise = null;
169
+ });
146
170
  }
147
171
  return postsMetaPromise;
148
172
  }
149
173
 
150
174
  /** 按 abbrlink 精确查找 */
151
175
  function findById(list: PostMeta[], id: string): PostMeta | undefined {
152
- return list.find((p) => p.abbrlink === id);
176
+ return lookupFor(list).findById(id);
153
177
  }
154
178
 
155
179
  /** 按标题/标签关键词模糊查找(取第一条) */
156
180
  function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
157
- const kw = keyword.toLowerCase();
158
- return (
159
- list.find((p) => p.title.toLowerCase().includes(kw)) ??
160
- list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
161
- );
181
+ return lookupFor(list).findByKeyword(keyword);
162
182
  }
163
183
 
164
184
  /** 简化为输出用的元信息视图 */
@@ -171,15 +191,43 @@ function briefMeta(p: PostMeta): Record<string, unknown> {
171
191
  categories: p.categories ?? [],
172
192
  desc: p.desc ?? undefined,
173
193
  wordCount: p.wordCount ?? undefined,
174
- url: p.url,
194
+ url: new URL(p.url, location.origin).href,
175
195
  };
176
196
  }
177
197
 
178
198
  /** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
179
199
  function currentAbbrlinkFromPath(): string | null {
180
- const match = location.pathname.match(/^\/posts\/([^/]+?)(?:\.md)?\/?$/);
181
- const encoded = match?.[1];
182
- return encoded ? decodeURIComponent(encoded) : null;
200
+ return postIdFromUrl(location.href);
201
+ }
202
+
203
+ function postIdFromUrl(value: string): string | null {
204
+ try {
205
+ const url = new URL(value, location.origin);
206
+ if (url.origin !== location.origin) return null;
207
+ const prefix = sitePath("posts/");
208
+ if (!url.pathname.startsWith(prefix)) return null;
209
+ const suffix = url.pathname.slice(prefix.length);
210
+ const path = suffix.endsWith("/") ? suffix.slice(0, -1) : suffix.replace(/\.md$/, "");
211
+ if (!path || path.includes("/")) return null;
212
+ return decodeURIComponent(path);
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function integerInput(
219
+ value: unknown,
220
+ fallback: number,
221
+ minimum: number,
222
+ maximum = Number.MAX_SAFE_INTEGER,
223
+ ): number | undefined {
224
+ if (value === undefined) return fallback;
225
+ return typeof value === "number" &&
226
+ Number.isSafeInteger(value) &&
227
+ value >= minimum &&
228
+ value <= maximum
229
+ ? value
230
+ : undefined;
183
231
  }
184
232
 
185
233
  // ---------------------------------------------------------------------------
@@ -194,11 +242,11 @@ function listPostsTool(): WebMCPTool {
194
242
  "分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
195
243
  "需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
196
244
  "只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。" +
197
- "pageSize 建议设 50 一次拿完,减少翻页;页码越界时会自动返回最后一页(clamped 标记)。",
245
+ "pageSize 可设 50 减少翻页,按 totalPages 继续分页;页码越界时会自动返回最后一页(clamped 标记)。",
198
246
  "Paginated list of all published posts, returning title, permanent link (abbrlink), date, categories, tags, description and post page URL. " +
199
247
  "Use it to browse the whole blog or find a post's abbrlink; it does not return body content — " +
200
248
  "use stalux_get_post for one post's metadata, or stalux_read_post for the full body. " +
201
- "Prefer pageSize 50 to fetch everything in one call; out-of-range pages clamp to the last page (clamped flag).",
249
+ "Use pageSize 50 to reduce calls and follow totalPages to continue; out-of-range pages clamp to the last page (clamped flag).",
202
250
  ),
203
251
  annotations: { readOnlyHint: true, untrustedContentHint: false },
204
252
  inputSchema: {
@@ -224,11 +272,16 @@ function listPostsTool(): WebMCPTool {
224
272
  additionalProperties: false,
225
273
  },
226
274
  execute: async (input) => {
227
- const page = Math.max(1, Number(input.page) || 1);
228
- const pageSize = Math.min(
229
- MAX_LIST_PAGE_SIZE,
230
- Math.max(1, Number(input.pageSize) || 10),
231
- );
275
+ const page = integerInput(input.page, 1, 1);
276
+ const pageSize = integerInput(input.pageSize, 10, 1, MAX_LIST_PAGE_SIZE);
277
+ if (page === undefined || pageSize === undefined)
278
+ return err(
279
+ "BAD_INPUT",
280
+ pick(
281
+ "页码和每页条数必须是有效整数",
282
+ "Page and pageSize must be valid integers",
283
+ ),
284
+ );
232
285
  const list = await loadPostsMeta();
233
286
  if (!list)
234
287
  return err(
@@ -279,8 +332,8 @@ function getPostTool(): WebMCPTool {
279
332
  additionalProperties: false,
280
333
  },
281
334
  execute: async (input) => {
282
- const id = String(input.id ?? "").trim();
283
- if (!id) {
335
+ const id = typeof input.id === "string" ? input.id.trim() : "";
336
+ if (!id || id.length > 512) {
284
337
  return err(
285
338
  "BAD_INPUT",
286
339
  pick(
@@ -372,9 +425,11 @@ function searchPostsTool(): WebMCPTool {
372
425
  title: pick("搜索博客文章", "Search blog posts"),
373
426
  description: pick(
374
427
  "用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
428
+ "全文索引不可用时仅返回元信息匹配,并标记 fullText: false;不把索引故障当成全文无匹配。" +
375
429
  "适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
376
430
  "定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
377
431
  "Search the full text of all posts (title, tags, categories, body) by keyword, returning matching posts with title, URL and excerpt snippets. " +
432
+ "If Pagefind is unavailable, metadata-only matches are marked fullText: false; index failures are not treated as an empty full-text result. " +
378
433
  "Ideal for questions like 'has the blog covered X?' or 'find posts about Y'; " +
379
434
  "once a post is located, use stalux_read_post for the body or stalux_get_post for metadata.",
380
435
  ),
@@ -397,48 +452,98 @@ function searchPostsTool(): WebMCPTool {
397
452
  required: ["keyword"],
398
453
  additionalProperties: false,
399
454
  },
400
- execute: async (input) => {
401
- const keyword = String(input.keyword ?? "").trim();
402
- if (!keyword)
403
- return err("BAD_INPUT", pick("请提供搜索关键词", "Provide a search keyword"));
404
- const limit = Math.min(MAX_SEARCH_RESULTS, Math.max(1, Number(input.limit) || 10));
455
+ execute: async (input, options) => {
456
+ const keyword = typeof input.keyword === "string" ? input.keyword.trim() : "";
457
+ const limit = integerInput(input.limit, 10, 1, MAX_SEARCH_RESULTS);
458
+ if (!keyword || keyword.length > 256 || limit === undefined)
459
+ return err("BAD_INPUT", pick("搜索参数无效", "Invalid search parameters"));
460
+ const list = await loadPostsMeta();
461
+ if (!list)
462
+ return err("INDEX_UNAVAILABLE", pick("文章索引不可用", "Post index unavailable"));
463
+ const metadataMatches = lookupFor(list)
464
+ .findMatches(keyword, limit)
465
+ .map((post) => ({
466
+ abbrlink: post.abbrlink,
467
+ title: post.title,
468
+ url: new URL(post.url, location.origin).href,
469
+ excerpt: (post.desc ?? "").slice(0, 240),
470
+ }));
471
+ const fallback = (code: string) =>
472
+ metadataMatches.length
473
+ ? {
474
+ ok: true,
475
+ keyword,
476
+ count: metadataMatches.length,
477
+ posts: metadataMatches,
478
+ source: "metadata",
479
+ fullText: false,
480
+ notice: pick(
481
+ "全文索引不可用,仅返回标题、标签、分类和摘要匹配",
482
+ "Full-text index unavailable; returning title, tag, category and description matches only",
483
+ ),
484
+ }
485
+ : err(
486
+ code,
487
+ pick(
488
+ "全文索引不可用,且元信息没有匹配项",
489
+ "Full-text index unavailable and no metadata matches",
490
+ ),
491
+ );
405
492
  const pagefind = await loadPagefind();
406
- if (!pagefind) {
407
- return err(
408
- "INDEX_UNAVAILABLE",
409
- pick("全文搜索索引不可用", "Full-text search index unavailable"),
410
- );
411
- }
412
- const res = (await pagefind.search(keyword)) as {
413
- results?: Array<{ data: () => Promise<unknown> }>;
414
- };
415
- if (!res || !Array.isArray(res.results)) {
416
- return err(
417
- "SEARCH_FAILED",
418
- pick("搜索失败,请重试", "Search failed, please retry"),
419
- );
420
- }
421
- const items = await Promise.all(
422
- res.results.slice(0, limit).map(async (r) => {
423
- try {
424
- const data = (await r.data()) as {
425
- url?: string;
426
- title?: string;
427
- excerpt?: string;
428
- };
429
- return {
430
- url: data?.url ?? "",
431
- title: data?.title ?? "",
432
- excerpt: (data?.excerpt ?? "").slice(0, 240),
433
- };
434
- } catch {
435
- logger.warn("search result data unavailable; skipping result");
436
- return null;
493
+ options?.signal?.throwIfAborted();
494
+ if (!pagefind) return fallback("INDEX_UNAVAILABLE");
495
+ try {
496
+ const result = await pagefind.search(keyword);
497
+ if (!result || !Array.isArray(result.results)) return fallback("SEARCH_FAILED");
498
+ const posts: Record<string, unknown>[] = [...metadataMatches];
499
+ const seen = new Set(metadataMatches.map((post) => post.abbrlink));
500
+ for (
501
+ let offset = 0;
502
+ offset < result.results.length && posts.length < limit;
503
+ offset += MAX_SEARCH_RESULTS
504
+ ) {
505
+ options?.signal?.throwIfAborted();
506
+ const batch = await Promise.all(
507
+ result.results
508
+ .slice(offset, offset + MAX_SEARCH_RESULTS)
509
+ .map(async (entry) => {
510
+ try {
511
+ return await entry.data();
512
+ } catch {
513
+ return null;
514
+ }
515
+ }),
516
+ );
517
+ for (const data of batch) {
518
+ if (!data) continue;
519
+ const id = postIdFromUrl(data.url);
520
+ const post = id ? findById(list, id) : undefined;
521
+ if (!post || seen.has(post.abbrlink)) continue;
522
+ seen.add(post.abbrlink);
523
+ posts.push({
524
+ abbrlink: post.abbrlink,
525
+ url: new URL(data.url, location.origin).href,
526
+ title:
527
+ typeof data.meta?.title === "string" && data.meta.title
528
+ ? data.meta.title
529
+ : post.title,
530
+ excerpt: (data.plain_excerpt ?? post.desc ?? "").slice(0, 240),
531
+ });
532
+ if (posts.length >= limit) break;
437
533
  }
438
- }),
439
- );
440
- const posts = items.filter((x): x is NonNullable<typeof x> => x !== null);
441
- return { ok: true, keyword, count: posts.length, posts };
534
+ }
535
+ return {
536
+ ok: true,
537
+ keyword,
538
+ count: posts.length,
539
+ posts,
540
+ source: "pagefind+metadata",
541
+ fullText: true,
542
+ };
543
+ } catch {
544
+ if (options?.signal?.aborted) throw options.signal.reason;
545
+ return fallback("SEARCH_FAILED");
546
+ }
442
547
  },
443
548
  };
444
549
  }
@@ -446,19 +551,32 @@ function searchPostsTool(): WebMCPTool {
446
551
  function readPostTool(): WebMCPTool {
447
552
  return {
448
553
  name: "stalux_read_post",
449
- title: pick("读取文章 Markdown", "Read post Markdown"),
554
+ title: pick("读取文章内容", "Read post content"),
450
555
  description: pick(
451
- "按 abbrlink(文章永久链接 ID)读取一篇博客文章的 Markdown 全文,包含规范化 frontmatter、正文与版权脚注。" +
452
- "需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
556
+ "按 abbrlink 读取文章。开启 Markdown 导出时返回规范化 Markdown,否则从 Pagefind 返回已索引纯文本(不含 Markdown 语法)。" +
557
+ "format 标明格式;truncated 为 true 时用 nextOffset 续读,直到 nextOffset 为 null。只要元信息用 stalux_get_post(更便宜)。" +
453
558
  "获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
454
- "Read the full Markdown of a post by abbrlink (permanent link ID), including normalized frontmatter, body and copyright footer. " +
455
- "Use it for full content, quotes or summarization; for metadata only, stalux_get_post is cheaper. " +
559
+ "Read a post by abbrlink: normalized Markdown when exports are enabled, otherwise indexed plain text from Pagefind without Markdown syntax. " +
560
+ "Check format; when truncated, continue with nextOffset until null. For metadata only, stalux_get_post is cheaper. " +
456
561
  "To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
457
562
  ),
458
563
  annotations: { readOnlyHint: true, untrustedContentHint: false },
459
564
  inputSchema: {
460
565
  type: "object",
461
566
  properties: {
567
+ offset: {
568
+ type: "integer",
569
+ minimum: 0,
570
+ default: 0,
571
+ description: "UTF-16 content offset; continue with nextOffset",
572
+ },
573
+ maxChars: {
574
+ type: "integer",
575
+ minimum: 1,
576
+ maximum: 50000,
577
+ default: 50000,
578
+ description: "Maximum UTF-16 units returned; a surrogate pair is never split",
579
+ },
462
580
  id: {
463
581
  type: "string",
464
582
  description: pick(
@@ -470,22 +588,122 @@ function readPostTool(): WebMCPTool {
470
588
  required: ["id"],
471
589
  additionalProperties: false,
472
590
  },
473
- execute: async (input) => {
474
- const id = String(input.id ?? "").trim();
475
- if (!id)
476
- return err("BAD_INPUT", pick("请提供文章 abbrlink", "Provide a post abbrlink"));
477
- const url = `/posts/${encodeURIComponent(id)}.md`;
478
- const r = await fetch(url, { headers: { Accept: "text/markdown" } });
479
- if (r.status === 404)
480
- return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
481
- if (!r.ok) {
591
+ execute: async (input, options) => {
592
+ const id = typeof input.id === "string" ? input.id.trim() : "";
593
+ const offset = integerInput(input.offset, 0, 0);
594
+ const maxChars = integerInput(input.maxChars, 50000, 1, 50000);
595
+ if (!id || id.length > 512 || offset === undefined || maxChars === undefined)
482
596
  return err(
483
- "FETCH_FAILED",
484
- pick("无法获取文章 Markdown:", "Failed to fetch post Markdown: ") + url,
597
+ "BAD_INPUT",
598
+ pick(
599
+ "请提供有效的文章 ID 和读取范围",
600
+ "Provide a valid post ID and content range",
601
+ ),
485
602
  );
603
+ const list = await loadPostsMeta();
604
+ if (!list)
605
+ return err("INDEX_UNAVAILABLE", pick("文章索引不可用", "Post index unavailable"));
606
+ const post = findById(list, id);
607
+ if (!post) return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
608
+ const url = new URL(sitePath(`posts/${encodeURIComponent(id)}.md`), location.origin)
609
+ .href;
610
+ let content: string | undefined;
611
+ let format = "text/markdown";
612
+ let source = url;
613
+ if (document.body?.dataset.staluxMarkdownExport !== "false") {
614
+ try {
615
+ const signal = options?.signal
616
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(15000)])
617
+ : AbortSignal.timeout(15000);
618
+ const response = await fetch(url, {
619
+ headers: { Accept: "text/markdown" },
620
+ signal,
621
+ });
622
+ if (response.ok && !response.headers.get("Content-Type")?.includes("text/html"))
623
+ content = await response.text();
624
+ } catch {
625
+ if (options?.signal?.aborted) throw options.signal.reason;
626
+ }
627
+ }
628
+ if (content === undefined) {
629
+ const pagefind = await loadPagefind();
630
+ if (!pagefind)
631
+ return err(
632
+ "CONTENT_UNAVAILABLE",
633
+ pick(
634
+ "Markdown 和静态搜索正文均不可用",
635
+ "Neither Markdown nor indexed content is available",
636
+ ),
637
+ );
638
+ try {
639
+ const result = await pagefind.search(null, { filters: { post: id } });
640
+ for (const entry of result.results) {
641
+ const data = await entry.data();
642
+ if (postIdFromUrl(data.url) === id && typeof data.content === "string") {
643
+ content = data.content;
644
+ format = "text/plain";
645
+ source = new URL(data.url, location.origin).href;
646
+ break;
647
+ }
648
+ }
649
+ } catch {
650
+ return err(
651
+ "CONTENT_UNAVAILABLE",
652
+ pick(
653
+ "读取静态搜索正文失败,请重试",
654
+ "Unable to read indexed content; retry",
655
+ ),
656
+ );
657
+ }
486
658
  }
487
- const markdown = await r.text();
488
- return { ok: true, url, markdown: markdown.slice(0, 50000) };
659
+ if (content === undefined)
660
+ return err(
661
+ "CONTENT_UNAVAILABLE",
662
+ pick(
663
+ "该文章没有可读取的静态正文",
664
+ "No static content is available for this post",
665
+ ),
666
+ );
667
+ if (
668
+ offset > content.length ||
669
+ (offset > 0 &&
670
+ /[\uDC00-\uDFFF]/u.test(content.charAt(offset)) &&
671
+ /[\uD800-\uDBFF]/u.test(content.charAt(offset - 1)))
672
+ )
673
+ return err(
674
+ "BAD_INPUT",
675
+ pick(
676
+ "读取偏移超出正文范围或切开 Unicode 字符",
677
+ "Offset is outside the content or splits a Unicode character",
678
+ ),
679
+ );
680
+ let end = Math.min(content.length, offset + maxChars);
681
+ if (
682
+ end < content.length &&
683
+ /[\uD800-\uDBFF]/u.test(content.charAt(end - 1)) &&
684
+ /[\uDC00-\uDFFF]/u.test(content.charAt(end))
685
+ )
686
+ end--;
687
+ if (end === offset && end < content.length)
688
+ return err(
689
+ "BAD_INPUT",
690
+ pick(
691
+ "maxChars 太小,无法容纳当前 Unicode 字符",
692
+ "maxChars is too small for this Unicode character",
693
+ ),
694
+ );
695
+ const chunk = content.slice(offset, end);
696
+ return {
697
+ ok: true,
698
+ abbrlink: id,
699
+ url: source,
700
+ format,
701
+ ...(format === "text/markdown" ? { markdown: chunk } : { content: chunk }),
702
+ offset,
703
+ totalChars: content.length,
704
+ truncated: end < content.length,
705
+ nextOffset: end < content.length ? end : null,
706
+ };
489
707
  },
490
708
  };
491
709
  }
@@ -518,15 +736,17 @@ function siteInfoTool(): WebMCPTool {
518
736
  url: info.url ?? "",
519
737
  description: info.description ?? "",
520
738
  lang: info.lang ?? "",
521
- llms: `${info.url ?? ""}/llms.txt`,
522
- llmsFull: `${info.url ?? ""}/llms-full.txt`,
739
+ llms: new URL(sitePath("llms.txt"), location.origin).href,
740
+ llmsFull: new URL(sitePath("llms-full.txt"), location.origin).href,
741
+ markdownExport: document.body?.dataset.staluxMarkdownExport === "true",
742
+ transport: "WebMCP",
523
743
  };
524
744
  },
525
745
  };
526
746
  }
527
747
 
528
748
  function buildTools(): WebMCPTool[] {
529
- return [
749
+ const tools: WebMCPTool[] = [
530
750
  listPostsTool(),
531
751
  getPostTool(),
532
752
  currentPostTool(),
@@ -535,16 +755,46 @@ function buildTools(): WebMCPTool[] {
535
755
  readPostTool(),
536
756
  siteInfoTool(),
537
757
  ];
758
+ return tools.map((tool) => {
759
+ const execute = tool.execute;
760
+ return {
761
+ ...tool,
762
+ execute: async (input: Record<string, unknown>, options?: { signal?: AbortSignal }) => {
763
+ if (!input || typeof input !== "object" || Array.isArray(input))
764
+ return err("BAD_INPUT", pick("参数必须是对象", "Input must be an object"));
765
+ const properties =
766
+ (tool.inputSchema as { properties?: Record<string, unknown> })?.properties ??
767
+ {};
768
+ if (Object.keys(input).some((key) => !Object.hasOwn(properties, key)))
769
+ return err("BAD_INPUT", pick("包含未知参数", "Unknown input property"));
770
+ if (options?.signal?.aborted) throw options.signal.reason;
771
+ try {
772
+ const result = await execute(input, options);
773
+ options?.signal?.throwIfAborted();
774
+ return result;
775
+ } catch (error) {
776
+ if (options?.signal?.aborted) throw options.signal.reason;
777
+ logger.error("tool execution failed", error);
778
+ return err(
779
+ "FETCH_FAILED",
780
+ pick(
781
+ "读取静态博客资源失败,请重试",
782
+ "Unable to read static blog resources; retry",
783
+ ),
784
+ );
785
+ }
786
+ },
787
+ };
788
+ });
538
789
  }
539
790
 
540
791
  // ---------------------------------------------------------------------------
541
792
  // 注册逻辑
542
793
  // ---------------------------------------------------------------------------
543
794
 
544
- /** 当前注册批次使用的 AbortController,软导航时先 abort 注销再重注册 */
545
- let activeController: AbortController | null = null;
546
-
547
- async function registerTools(): Promise<{ registered: string[]; reason?: string }> {
795
+ async function registerTools(
796
+ signal: AbortSignal,
797
+ ): Promise<{ registered: string[]; reason?: string }> {
548
798
  const doc = document as DocumentWithModelContext;
549
799
  const ctx = doc.modelContext;
550
800
  if (!ctx || typeof ctx.registerTool !== "function") {
@@ -552,25 +802,28 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
552
802
  return { registered: [], reason: "unsupported" };
553
803
  }
554
804
 
555
- // 注销上一轮注册(View Transitions 软导航后 modelContext 可能已重置)
556
- if (activeController) {
557
- try {
558
- activeController.abort();
559
- } catch {
560
- logger.debug("previous registration cleanup unavailable");
561
- }
562
- }
563
- const controller = new AbortController();
564
- activeController = controller;
565
-
566
805
  const tools = buildTools();
567
806
  const registered: string[] = [];
568
807
  // 逐个注册:单个失败不中断其余工具
569
808
  for (const tool of tools) {
809
+ if (signal.aborted) break;
570
810
  try {
571
- await ctx.registerTool(tool, { signal: controller.signal });
811
+ await ctx.registerTool(
812
+ {
813
+ ...tool,
814
+ execute: (input, executionOptions) =>
815
+ tool.execute(input, {
816
+ signal: executionOptions?.signal
817
+ ? AbortSignal.any([signal, executionOptions.signal])
818
+ : signal,
819
+ }),
820
+ },
821
+ { signal },
822
+ );
823
+ if (signal.aborted) break;
572
824
  registered.push(tool.name);
573
825
  } catch (e) {
826
+ if (signal.aborted) break;
574
827
  logger.error(`tool registration failed: ${tool.name}`, e);
575
828
  }
576
829
  }
@@ -581,18 +834,21 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
581
834
  // 挂载与自动注册
582
835
  // ---------------------------------------------------------------------------
583
836
 
584
- if (typeof document !== "undefined") {
585
- // 首屏加载 + View Transitions 软导航都执行(soft navigation 时页面状态会重建)
586
- document.addEventListener("astro:page-load", () => {
587
- void registerTools()
588
- .then((r) => {
589
- if (r.reason === "unsupported") return; // 无 WebMCP 环境,静默跳过
590
- if (r.registered.length) {
591
- logger.debug(
592
- `已注册 ${r.registered.length} 个工具: ${r.registered.join(", ")}`,
593
- );
594
- }
595
- })
596
- .catch((error: unknown) => logger.error("registration batch failed", error));
597
- });
598
- }
837
+ registerPageLifecycle("webmcp", () => {
838
+ // Astro can hoist processed scripts from conditional template branches.
839
+ if (document.body?.dataset.staluxWebmcpEnabled === "false") return;
840
+ initializeWebMCPPolyfill();
841
+ const controller = new AbortController();
842
+ void registerTools(controller.signal)
843
+ .then((r) => {
844
+ if (controller.signal.aborted || r.reason === "unsupported") return;
845
+ if (r.registered.length) {
846
+ logger.debug(`已注册 ${r.registered.length} 个工具: ${r.registered.join(", ")}`);
847
+ }
848
+ })
849
+ .catch((error: unknown) => {
850
+ if (!controller.signal.aborted) logger.error("registration batch failed", error);
851
+ });
852
+ // Abort also unregisters tools already installed by this page's batch.
853
+ return () => controller.abort();
854
+ });