@xingwangzhe/stalux 1.24.7 → 1.25.1

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.24.7",
3
+ "version": "1.25.1",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -1,25 +1,4 @@
1
1
  ---
2
- import { getCollection } from "astro:content";
3
- import { getHeadData } from "@utils/config-utils";
4
- const configEntries = await getCollection("config");
5
- const headConfig = getHeadData(configEntries);
6
- const id = headConfig?.bingClarityId;
7
2
  ---
8
3
 
9
- <script is:inline data-astro-rerun define:vars={{ id }}>
10
- // 防止重复初始化
11
- const load = () => {
12
- if (!id || document.getElementById("stalux-clarity-script") || window.clarity) return;
13
- (function (c, l, a, r, i, t, y) {
14
- c[a] = c[a] || function () { (c[a].q = c[a].q || []).push(arguments); };
15
- t = l.createElement(r);
16
- t.id = "stalux-clarity-script";
17
- t.async = 1;
18
- t.src = "https://www.clarity.ms/tag/" + i;
19
- y = l.getElementsByTagName(r)[0];
20
- y.parentNode.insertBefore(t, y);
21
- })(window, document, "clarity", "script", id);
22
- };
23
- if ("requestIdleCallback" in window) requestIdleCallback(load, { timeout: 4000 });
24
- else window.addEventListener("load", () => setTimeout(load, 0), { once: true });
25
- </script>
4
+ <script src="../../../scripts/clarity.ts"></script>
@@ -1,34 +1,4 @@
1
1
  ---
2
- import { getCollection } from "astro:content";
3
- import { getHeadData } from "@utils/config-utils";
4
- const configEntries = await getCollection("config");
5
- const headConfig = getHeadData(configEntries);
6
- const gaID = headConfig?.googleAnalyticsId;
7
2
  ---
8
3
 
9
- <script is:inline define:vars={{ gaID }}>
10
- (() => {
11
- const load = () => {
12
- if (window.__staluxAnalyticsLoaded) return;
13
- window.__staluxAnalyticsLoaded = true;
14
- const script = document.createElement("script");
15
- script.async = true;
16
- script.src = `https://www.googletagmanager.com/gtag/js?id=${gaID}`;
17
- document.head.appendChild(script);
18
- window.dataLayer = window.dataLayer || [];
19
- window.gtag = window.gtag || function () {
20
- window.dataLayer.push(arguments);
21
- };
22
- window.gtag("js", new Date());
23
- window.gtag("config", gaID);
24
- if (!window.__gtagListenerAdded) {
25
- document.addEventListener("astro:page-load", () => {
26
- window.gtag("event", "page_view", { page_path: window.location.pathname });
27
- });
28
- window.__gtagListenerAdded = true;
29
- }
30
- };
31
- if ("requestIdleCallback" in window) requestIdleCallback(load, { timeout: 3000 });
32
- else window.addEventListener("load", () => setTimeout(load, 0), { once: true });
33
- })();
34
- </script>
4
+ <script src="../../../scripts/google-analytics.ts"></script>
@@ -2,15 +2,4 @@
2
2
 
3
3
  ---
4
4
 
5
- <script is:inline data-astro-rerun>
6
- (() => {
7
- const load = () => {
8
- const script = document.createElement("script");
9
- script.defer = true;
10
- script.src = "https://events.vercount.one/js";
11
- document.head.appendChild(script);
12
- };
13
- if ("requestIdleCallback" in window) requestIdleCallback(load, { timeout: 5000 });
14
- else window.addEventListener("load", () => setTimeout(load, 0), { once: true });
15
- })();
16
- </script>
5
+ <script src="../../../scripts/vercount.ts"></script>
@@ -41,7 +41,7 @@ const { showStats = true, wordCount, buildtime } = Astro.props;
41
41
 
42
42
  {
43
43
  buildtime && (
44
- <div class="site-build-time">
44
+ <div class="site-build-time" data-stalux-buildtime={buildtime} data-stalux-runtime-format={t("footer.runtimeFormat")}>
45
45
  <span>
46
46
  {t("footer.runningTime")}
47
47
  <span id="runtime-counter" />
@@ -50,41 +50,7 @@ const { showStats = true, wordCount, buildtime } = Astro.props;
50
50
  )
51
51
  }
52
52
 
53
- <script define:vars={{ buildtime, runtimeFormat: t("footer.runtimeFormat") }}>
54
- document.addEventListener("astro:page-load", () => {
55
- if (!buildtime) return;
56
- const runtimeCounter = document.getElementById("runtime-counter");
57
- if (!runtimeCounter) return;
58
-
59
- const updateRuntime = () => {
60
- const now = new Date();
61
- const start = new Date(buildtime);
62
- const diffMs = now.getTime() - start.getTime();
63
- if (Number.isNaN(diffMs) || diffMs < 0) return;
64
-
65
- const totalSeconds = Math.floor(diffMs / 1000);
66
- const days = Math.floor(totalSeconds / 86400);
67
- const hours = Math.floor((totalSeconds % 86400) / 3600);
68
- const minutes = Math.floor((totalSeconds % 3600) / 60);
69
- const seconds = totalSeconds % 60;
70
-
71
- runtimeCounter.textContent = runtimeFormat
72
- .replace("{days}", String(days))
73
- .replace("{hours}", String(hours))
74
- .replace("{minutes}", String(minutes))
75
- .replace("{seconds}", String(seconds));
76
- };
77
-
78
- updateRuntime();
79
- // define:vars 内联脚本会在每次视图切换时重新执行,
80
- // 因此用 window 句柄记录定时器,创建新定时器前先清掉旧的,避免累加 1Hz 定时器。
81
- const w = window;
82
- if (w.__staluxRuntimeTimer != null) {
83
- clearInterval(w.__staluxRuntimeTimer);
84
- }
85
- w.__staluxRuntimeTimer = setInterval(updateRuntime, 1000);
86
- });
87
- </script>
53
+ <script src="../../../scripts/runtime-counter.ts"></script>
88
54
 
89
55
  <style>
90
56
  .site-stats {
@@ -269,7 +269,7 @@ if (isArticle) {
269
269
  ],
270
270
  };
271
271
  }
272
- const ldJson = JSON.stringify(schema, null, 2).replace(/<\//g, "<\\/");
272
+ const ldJson = JSON.stringify(schema).replace(/<\//g, "<\\/");
273
273
  ---
274
274
 
275
275
  <head>
@@ -3,132 +3,23 @@ import style from "@styles/components/posts/randomPost.module.css";
3
3
  import { getCollection } from "astro:content";
4
4
  import { createTranslator } from "@utils/i18n";
5
5
 
6
- interface Props {
7
- count?: number;
8
- title?: string;
9
- }
10
-
6
+ interface Props { count?: number; title?: string; }
11
7
  const configEntries = await getCollection("config");
12
- const siteConfig = (configEntries.find(e => e.id === "site")?.data ?? {}) as Record<string, unknown>;
8
+ const siteConfig = (configEntries.find((entry) => entry.id === "site")?.data ?? {}) as Record<string, unknown>;
13
9
  const { t } = createTranslator((siteConfig.lang as string) || "zh-CN");
14
10
  const { count = 5, title = t("randomPost.title") } = Astro.props;
15
11
  ---
16
-
17
- <aside class={style.container} transition:persist-props aria-label={t("randomPost.title")}>
12
+ <aside class={style.container} transition:persist-props aria-label={t("randomPost.title")} data-stalux-random-posts data-count={count} data-no-posts={t("randomPost.noPosts")} data-load-failed={t("randomPost.loadFailed")} data-load-error={t("randomPost.loadError")}>
18
13
  <div class={style.header}>
19
14
  <h3 class={style.title}>{title}</h3>
20
- <button
21
- id="refresh-random-posts"
22
- class={style.refreshButton}
23
- title={t("randomPost.refresh")}
24
- aria-label={t("randomPost.refresh")}
25
- >
26
- <svg
27
- xmlns="http://www.w3.org/2000/svg"
28
- width="16"
29
- height="16"
30
- viewBox="0 0 24 24"
31
- fill="none"
32
- stroke="currentColor"
33
- stroke-width="2"
34
- stroke-linecap="round"
35
- stroke-linejoin="round"
36
- >
37
- <path
38
- d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"
39
- ></path>
15
+ <button id="refresh-random-posts" class={style.refreshButton} title={t("randomPost.refresh")} aria-label={t("randomPost.refresh")}>
16
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
17
+ <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"></path>
40
18
  </svg>
41
19
  </button>
42
20
  </div>
43
21
  <ul class={style.list} id="random-posts-list">
44
22
  <li class={style.loading}>{t("randomPost.loading")}</li>
45
- </ul>
46
- </aside>
47
-
48
- <script define:vars={{ count, labels: { noPosts: t("randomPost.noPosts"), loadFailed: t("randomPost.loadFailed"), loadError: t("randomPost.loadError") } }}>
49
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
-
51
- // 费雪-耶茨洗牌(crypto 优先,回退 Math.random)
52
- const shuffleArray = (array) => {
53
- const shuffled = [...array];
54
- const randomIndex = (max) => {
55
- if (globalThis.crypto?.getRandomValues) {
56
- const buf = new Uint32Array(1);
57
- crypto.getRandomValues(buf);
58
- return buf[0] % max;
59
- }
60
- return Math.floor(Math.random() * max);
61
- };
62
- for (let i = shuffled.length - 1; i > 0; i--) {
63
- const j = randomIndex(i + 1);
64
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
65
- }
66
- return shuffled;
67
- };
68
-
69
- const fetchRandomPosts = async () => {
70
- try {
71
- const response = await fetch("/api/post.abbrlink.json");
72
- if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
73
- return (await response.json()) ?? [];
74
- } catch (error) {
75
- console.error("Failed to fetch posts:", error);
76
- return [];
77
- }
78
- };
79
-
80
- const generatePostsHTML = (posts) => {
81
- if (posts.length === 0) {
82
- return `<li class="no-posts">${labels.noPosts}</li>`;
83
- }
84
- return posts
85
- .map(
86
- (post) => `
87
- <li class="random-post-item">
88
- <a href="/posts/${post.abbrlink}/" class="random-post-link a-none">
89
- ${post.title}
90
- </a>
91
- </li>
92
- `,
93
- )
94
- .join("");
95
- };
96
-
97
- const renderRandomPosts = async (listEl, emptyMsg, errorMsg) => {
98
- try {
99
- const allPosts = await fetchRandomPosts();
100
- if (allPosts.length === 0) {
101
- listEl.innerHTML = `<li class="no-posts">${emptyMsg}</li>`;
102
- return;
103
- }
104
- listEl.innerHTML = generatePostsHTML(shuffleArray(allPosts).slice(0, count));
105
- } catch (error) {
106
- console.error("Error rendering random posts:", error);
107
- listEl.innerHTML = `<li class="error">${errorMsg}</li>`;
108
- }
109
- };
110
-
111
- const refreshRandomPosts = async () => {
112
- const randomPostsList = document.getElementById("random-posts-list");
113
- if (!randomPostsList) return;
114
-
115
- randomPostsList.style.opacity = "0.5";
116
- await sleep(300);
117
- await renderRandomPosts(randomPostsList, labels.noPosts, labels.loadFailed);
118
- randomPostsList.style.opacity = "1";
119
- };
120
-
121
- document.addEventListener("astro:page-load", () => {
122
- const refreshButton = document.getElementById("refresh-random-posts");
123
- refreshButton?.addEventListener("click", async (e) => {
124
- e.preventDefault();
125
- refreshButton.classList.add("rotating");
126
- await refreshRandomPosts();
127
- await sleep(600);
128
- refreshButton.classList.remove("rotating");
129
- });
130
-
131
- const list = document.getElementById("random-posts-list");
132
- if (list) void renderRandomPosts(list, labels.noPosts, labels.loadError);
133
- });
134
- </script>
23
+ </ul>
24
+ </aside>
25
+ <script src="../../../scripts/random-posts.ts"></script>
package/src/index.ts CHANGED
@@ -8,7 +8,14 @@
8
8
  * 基于 Astro 7.1.3 Integration API(astro:config:setup / injectRoute / injectScript)
9
9
  */
10
10
 
11
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
11
+ import {
12
+ copyFileSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ writeFileSync,
18
+ } from "node:fs";
12
19
  import path from "node:path";
13
20
  import { fileURLToPath } from "node:url";
14
21
 
@@ -28,6 +35,34 @@ import { runFontSlicing, type FontSlice } from "./internal/font-slices";
28
35
  import { featureFlagsHast, featureFlagsMdast } from "./plugins/feature-flags";
29
36
  import { temml } from "./plugins/satteri-temml";
30
37
 
38
+ function stripGeneratedHtmlComments(outDir: string): void {
39
+ const walk = (dir: string) => {
40
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
41
+ const target = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) walk(target);
43
+ else if (entry.isFile() && entry.name.endsWith(".html")) {
44
+ const source = readFileSync(target, "utf8");
45
+ const protectedBlocks: string[] = [];
46
+ const protectedSource = source.replace(
47
+ /<(pre|code|textarea)\b[\s\S]*?<\/\1>/gi,
48
+ (block) => {
49
+ protectedBlocks.push(block);
50
+ return `__STALUX_PROTECTED_HTML_${protectedBlocks.length - 1}__`;
51
+ },
52
+ );
53
+ const stripped = protectedSource
54
+ .replace(/<!--(?!\[if)[\s\S]*?-->/g, "")
55
+ .replace(
56
+ /__STALUX_PROTECTED_HTML_(\d+)__/g,
57
+ (_, index: string) => protectedBlocks[Number(index)] ?? "",
58
+ );
59
+ if (stripped !== source) writeFileSync(target, stripped);
60
+ }
61
+ }
62
+ };
63
+ walk(outDir);
64
+ }
65
+
31
66
  // 字体配置(Astro Fonts API)通过 updateConfig 注入,两种模式(源码模板/npm 插件)都生效。
32
67
  // 官方 local provider 完全本地读文件(readFile),不联网;
33
68
  // 7.2.2 已修复 Fonts API 随机端口污染 incrementalBuild dependencyHash 的问题(PR #17659)。
@@ -112,6 +147,7 @@ function getRoutes(baseDir: URL): RouteEntry[] {
112
147
  { pattern: "/atom.xml", entrypoint: resolvePage("./pages/atom.xml.ts") },
113
148
  { pattern: "/llms.txt", entrypoint: resolvePage("./pages/llms.txt.ts") },
114
149
  { pattern: "/llms-full.txt", entrypoint: resolvePage("./pages/llms-full.txt.ts") },
150
+ { pattern: "/openapi.json", entrypoint: resolvePage("./pages/openapi.json.ts") },
115
151
  {
116
152
  pattern: "/api/post.abbrlink.json",
117
153
  entrypoint: resolvePage("./pages/api/post.abbrlink.json.ts"),
@@ -438,9 +474,12 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
438
474
  },
439
475
 
440
476
  "astro:build:done": async ({ dir, logger }) => {
477
+ const outDir = fileURLToPath(dir);
478
+ stripGeneratedHtmlComments(outDir);
479
+ logger.info("Removed generated HTML comments (preserved pre/code/textarea blocks)");
480
+
441
481
  // 6. 后处理:Pagefind 搜索索引
442
482
  if (opt.pagefind) {
443
- const outDir = fileURLToPath(dir);
444
483
  logger.info("Running Pagefind indexer...");
445
484
 
446
485
  try {
@@ -5,7 +5,7 @@ import { getCollection } from "astro:content";
5
5
  import "@styles/base/init.css";
6
6
  import "@styles/shared/stagger.css";
7
7
  import "@styles/shared/view-transitions.css";
8
- import { getAuthorData, getAiDiscoveryData, getPromoteData, getSiteData } from "@utils/config-utils";
8
+ import { getAuthorData, getAiDiscoveryData, getHeadData, getPromoteData, getSiteData } from "@utils/config-utils";
9
9
  import { getRouteAnimation, getRouteKind } from "@utils/view-transitions";
10
10
  const props = Astro.props as Props;
11
11
 
@@ -25,6 +25,7 @@ interface Props {
25
25
  const configEntries = await getCollection("config");
26
26
  const siteConfig = getSiteData(configEntries);
27
27
  const authorConfig = getAuthorData(configEntries);
28
+ const headConfig = getHeadData(configEntries);
28
29
  const promoteConfig = getPromoteData(configEntries);
29
30
  const aiConfig = getAiDiscoveryData(configEntries);
30
31
  const htmlLang = siteConfig.lang || "zh-CN";
@@ -65,21 +66,6 @@ const routeAnimation = getRouteAnimation(routeKind);
65
66
  {
66
67
  aiConfig?.conformance !== "disabled" && (
67
68
  <>
68
- <script
69
- define:vars={{
70
- siteTitle: siteConfig.title,
71
- siteUrl: siteConfig.url,
72
- siteDesc: siteConfig.description,
73
- siteLang: siteConfig.lang,
74
- }}
75
- >
76
- window.__STALUX_SITE_INFO__ = {
77
- title: siteTitle,
78
- url: siteUrl,
79
- description: siteDesc,
80
- lang: siteLang,
81
- };
82
- </script>
83
69
  <script>
84
70
  import "../scripts/webmcp.ts";
85
71
  </script>
@@ -90,8 +76,18 @@ const routeAnimation = getRouteAnimation(routeKind);
90
76
  class="stalux-root dark"
91
77
  data-stalux-bg-layer="a"
92
78
  data-stalux-bg-index="0"
79
+ data-stalux-site-title={siteConfig.title}
80
+ data-stalux-site-url={siteConfig.url}
81
+ data-stalux-site-description={siteConfig.description}
82
+ data-stalux-site-lang={siteConfig.lang}
83
+ data-stalux-ga-id={headConfig?.googleAnalyticsId}
84
+ data-stalux-clarity-id={headConfig?.bingClarityId}
93
85
  >
94
- {llmPromote && <div style="display:none">{llmPromote}</div>}
86
+ {llmPromote && (
87
+ <section class="stalux-ai-prompt" data-stalux-ai-prompt aria-label="AI site context">
88
+ <p>{llmPromote}</p>
89
+ </section>
90
+ )}
95
91
  <div
96
92
  class="bg-layer bg-a"
97
93
  style="opacity: 1;"
@@ -116,3 +112,17 @@ const routeAnimation = getRouteAnimation(routeKind);
116
112
  </html>
117
113
 
118
114
  <style></style>
115
+
116
+ <style>
117
+ .stalux-ai-prompt {
118
+ position: absolute;
119
+ width: 1px;
120
+ height: 1px;
121
+ padding: 0;
122
+ margin: -1px;
123
+ overflow: hidden;
124
+ clip: rect(0, 0, 0, 0);
125
+ white-space: pre-wrap;
126
+ border: 0;
127
+ }
128
+ </style>
@@ -21,42 +21,12 @@ const { t } = createTranslator(siteConfig.lang || "zh-CN");
21
21
  <div class="error-content">
22
22
  <h1>404</h1>
23
23
  <p>{t("page404.pageNotFound")}</p>
24
- <p id="countdown" class="countdown-text">{t("page404.countdown", { n: 5 })}</p>
24
+ <p id="countdown" class="countdown-text" data-countdown-template={t("page404.countdown", { n: "{n}" })}>{t("page404.countdown", { n: 5 })}</p>
25
25
  <a href="/" class="home-btn" data-astro-prefetch>{t("page404.backHome")}</a>
26
26
  </div>
27
27
  </div>
28
28
 
29
- <script define:vars={{ countdownText: t("page404.countdown", { n: "{n}" }) }} is:inline>
30
- let timer;
31
-
32
- function startCountdown() {
33
- if (timer !== undefined) window.clearInterval(timer);
34
- let seconds = 5;
35
- const el = document.getElementById("countdown");
36
- if (!el) return;
37
-
38
- timer = window.setInterval(() => {
39
- seconds--;
40
- if (seconds <= 0) {
41
- if (timer !== undefined) window.clearInterval(timer);
42
- timer = undefined;
43
- const home = document.querySelector(".home-btn");
44
- home?.click();
45
- } else {
46
- el.textContent = countdownText.replace("{n}", String(seconds));
47
- }
48
- }, 1000);
49
- }
50
-
51
- function stopCountdown() {
52
- if (timer !== undefined) window.clearInterval(timer);
53
- timer = undefined;
54
- }
55
-
56
- document.addEventListener("astro:before-swap", stopCountdown);
57
- document.addEventListener("astro:page-load", startCountdown);
58
- startCountdown();
59
- </script>
29
+ <script src="../scripts/countdown.ts"></script>
60
30
  </Stalux>
61
31
 
62
32
  <style>
@@ -10,6 +10,9 @@ export const GET: APIRoute = async (context) => {
10
10
  const site = getSite(config, context.site?.toString());
11
11
  const text = await renderAboutMd(site);
12
12
  return new Response(text, {
13
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
13
+ headers: {
14
+ "Content-Type": "text/markdown; charset=utf-8",
15
+ Vary: "Accept, Accept-Encoding",
16
+ },
14
17
  });
15
18
  };
@@ -19,6 +19,9 @@ export const GET: APIRoute = async (context) => {
19
19
  const { t } = createTranslator(lang);
20
20
  const text = await renderArchivesMd(site, exportMd, t);
21
21
  return new Response(text, {
22
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
22
+ headers: {
23
+ "Content-Type": "text/markdown; charset=utf-8",
24
+ Vary: "Accept, Accept-Encoding",
25
+ },
23
26
  });
24
27
  };
@@ -29,6 +29,9 @@ export const GET: APIRoute = async (context) => {
29
29
  const site = getSite(config, context.site?.toString());
30
30
  const text = renderTaxonomyPageMd(category, posts, site, "categories", exportMd);
31
31
  return new Response(text, {
32
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
32
+ headers: {
33
+ "Content-Type": "text/markdown; charset=utf-8",
34
+ Vary: "Accept, Accept-Encoding",
35
+ },
33
36
  });
34
37
  };
@@ -22,6 +22,9 @@ export const GET: APIRoute = async (context) => {
22
22
  const map = buildTaxonomyMap(posts, "categories");
23
23
  const text = renderTaxonomyListMd(map, site, "categories", t("ai.allCategories"));
24
24
  return new Response(text, {
25
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
25
+ headers: {
26
+ "Content-Type": "text/markdown; charset=utf-8",
27
+ Vary: "Accept, Accept-Encoding",
28
+ },
26
29
  });
27
30
  };
@@ -13,6 +13,9 @@ export const GET: APIRoute = async (context) => {
13
13
  const { t } = createTranslator(lang);
14
14
  const text = await renderIndexMd(config, site, t);
15
15
  return new Response(text, {
16
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
16
+ headers: {
17
+ "Content-Type": "text/markdown; charset=utf-8",
18
+ Vary: "Accept, Accept-Encoding",
19
+ },
17
20
  });
18
21
  };
@@ -13,6 +13,9 @@ export const GET: APIRoute = async (context) => {
13
13
  const { t } = createTranslator(lang);
14
14
  const text = renderLinksMd(config, site, t);
15
15
  return new Response(text, {
16
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
16
+ headers: {
17
+ "Content-Type": "text/markdown; charset=utf-8",
18
+ Vary: "Accept, Accept-Encoding",
19
+ },
17
20
  });
18
21
  };
@@ -20,6 +20,7 @@ export const GET: APIRoute = async (context) => {
20
20
  return new Response(text, {
21
21
  headers: {
22
22
  "Content-Type": "text/plain; charset=utf-8",
23
+ Vary: "Accept, Accept-Encoding",
23
24
  },
24
25
  });
25
26
  };
@@ -21,6 +21,7 @@ export const GET: APIRoute = async (context) => {
21
21
  return new Response(text, {
22
22
  headers: {
23
23
  "Content-Type": "text/plain; charset=utf-8",
24
+ Vary: "Accept, Accept-Encoding",
24
25
  },
25
26
  });
26
27
  };
@@ -0,0 +1,77 @@
1
+ import type { APIRoute } from "astro";
2
+
3
+ export const prerender = true;
4
+
5
+ const responseSchema = {
6
+ type: "array",
7
+ items: { type: "object", additionalProperties: true },
8
+ };
9
+
10
+ export const GET: APIRoute = async ({ site }) => {
11
+ const server = site?.toString().replace(/\/$/, "") || "";
12
+ const document = {
13
+ openapi: "3.1.0",
14
+ info: {
15
+ title: "Stalux Blog Read API",
16
+ version: "1.0.0",
17
+ description:
18
+ "Read-only machine-readable endpoints for discovering and retrieving published Stalux blog content.",
19
+ },
20
+ servers: server ? [{ url: server }] : undefined,
21
+ paths: {
22
+ "/api/posts.json": {
23
+ get: {
24
+ operationId: "listPublishedPosts",
25
+ summary: "List published posts",
26
+ description: "Returns metadata for every published post, newest first.",
27
+ responses: {
28
+ "200": {
29
+ description: "Published post metadata",
30
+ content: { "application/json": { schema: responseSchema } },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ "/api/post.abbrlink.json": {
36
+ get: {
37
+ operationId: "listPostIdentifiers",
38
+ summary: "List post identifiers",
39
+ description: "Returns the title and stable abbrlink for every published post.",
40
+ responses: {
41
+ "200": {
42
+ description: "Published post identifiers",
43
+ content: { "application/json": { schema: responseSchema } },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ "/posts/{abbrlink}.md": {
49
+ get: {
50
+ operationId: "readPostMarkdown",
51
+ summary: "Read one post as Markdown",
52
+ description: "Returns the normalized Markdown source for a published post.",
53
+ parameters: [
54
+ {
55
+ name: "abbrlink",
56
+ in: "path",
57
+ required: true,
58
+ description: "Stable post identifier returned by the post index.",
59
+ schema: { type: "string" },
60
+ },
61
+ ],
62
+ responses: {
63
+ "200": {
64
+ description: "Markdown post content",
65
+ content: { "text/markdown": { schema: { type: "string" } } },
66
+ },
67
+ "404": { description: "Post not found" },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ };
73
+
74
+ return new Response(JSON.stringify(document, null, 2), {
75
+ headers: { "Content-Type": "application/json; charset=utf-8" },
76
+ });
77
+ };
@@ -98,6 +98,7 @@ export const GET: APIRoute = async ({ props }) => {
98
98
  status: 200,
99
99
  headers: {
100
100
  "Content-Type": "text/markdown; charset=utf-8",
101
+ Vary: "Accept, Accept-Encoding",
101
102
  },
102
103
  });
103
104
  };
@@ -29,6 +29,9 @@ export const GET: APIRoute = async (context) => {
29
29
  const site = getSite(config, context.site?.toString());
30
30
  const text = renderTaxonomyPageMd(tag, posts, site, "tags", exportMd);
31
31
  return new Response(text, {
32
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
32
+ headers: {
33
+ "Content-Type": "text/markdown; charset=utf-8",
34
+ Vary: "Accept, Accept-Encoding",
35
+ },
33
36
  });
34
37
  };
@@ -22,6 +22,9 @@ export const GET: APIRoute = async (context) => {
22
22
  const map = buildTaxonomyMap(posts, "tags");
23
23
  const text = renderTaxonomyListMd(map, site, "tags", t("ai.allTags"));
24
24
  return new Response(text, {
25
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
25
+ headers: {
26
+ "Content-Type": "text/markdown; charset=utf-8",
27
+ Vary: "Accept, Accept-Encoding",
28
+ },
26
29
  });
27
30
  };
@@ -9,6 +9,9 @@ export const GET: APIRoute = async () => {
9
9
 
10
10
  const text = await renderWordsMd();
11
11
  return new Response(text, {
12
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
12
+ headers: {
13
+ "Content-Type": "text/markdown; charset=utf-8",
14
+ Vary: "Accept, Accept-Encoding",
15
+ },
13
16
  });
14
17
  };
@@ -0,0 +1,26 @@
1
+ declare global {
2
+ interface Window {
3
+ clarity?: ((...args: unknown[]) => void) & { q?: unknown[][] };
4
+ __staluxClarityLoaded?: boolean;
5
+ }
6
+ }
7
+
8
+ function load() {
9
+ const id = document.body?.dataset.staluxClarityId;
10
+ if (!id || document.getElementById("stalux-clarity-script")) return;
11
+ window.__staluxClarityLoaded = true;
12
+ window.clarity =
13
+ window.clarity ||
14
+ Object.assign((...args: unknown[]) => window.clarity?.q?.push(args), { q: [] });
15
+ const script = document.createElement("script");
16
+ script.id = "stalux-clarity-script";
17
+ script.async = true;
18
+ script.src = `https://www.clarity.ms/tag/${encodeURIComponent(id)}`;
19
+ document.head.appendChild(script);
20
+ }
21
+
22
+ document.addEventListener("astro:page-load", load);
23
+ if (document.readyState === "loading")
24
+ document.addEventListener("DOMContentLoaded", load, { once: true });
25
+ else load();
26
+ export {};
@@ -0,0 +1,27 @@
1
+ let timer: number | undefined;
2
+
3
+ function stop() {
4
+ if (timer !== undefined) window.clearInterval(timer);
5
+ timer = undefined;
6
+ }
7
+
8
+ function start() {
9
+ stop();
10
+ const el = document.getElementById("countdown");
11
+ const template = el?.dataset.countdownTemplate;
12
+ if (!el || !template) return;
13
+ let seconds = 5;
14
+ timer = window.setInterval(() => {
15
+ seconds -= 1;
16
+ if (seconds <= 0) {
17
+ stop();
18
+ document.querySelector<HTMLAnchorElement>(".home-btn")?.click();
19
+ } else {
20
+ el.textContent = template.replace("{n}", String(seconds));
21
+ }
22
+ }, 1000);
23
+ }
24
+
25
+ document.addEventListener("astro:before-swap", stop);
26
+ document.addEventListener("astro:page-load", start);
27
+ start();
@@ -0,0 +1,47 @@
1
+ declare global {
2
+ interface Window {
3
+ dataLayer?: unknown[];
4
+ gtag?: (...args: unknown[]) => void;
5
+ __staluxAnalyticsLoaded?: boolean;
6
+ __staluxAnalyticsPageLoadListener?: boolean;
7
+ }
8
+ }
9
+
10
+ function load() {
11
+ const gaID = document.body?.dataset.staluxGaId;
12
+ if (!gaID) return;
13
+ const hasScript = document.querySelector('script[data-stalux-google="true"]');
14
+ window.dataLayer = window.dataLayer || [];
15
+ window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args));
16
+ if (!hasScript) {
17
+ const script = document.createElement("script");
18
+ script.async = true;
19
+ script.dataset.staluxGoogle = "true";
20
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(gaID)}`;
21
+ document.head.appendChild(script);
22
+ }
23
+ if (!window.__staluxAnalyticsLoaded) {
24
+ window.__staluxAnalyticsLoaded = true;
25
+ window.gtag("js", new Date());
26
+ window.gtag("config", gaID);
27
+ }
28
+ }
29
+
30
+ if (!window.__staluxAnalyticsPageLoadListener) {
31
+ window.__staluxAnalyticsPageLoadListener = true;
32
+ let firstPageLoad = true;
33
+ document.addEventListener("astro:page-load", () => {
34
+ load();
35
+ if (firstPageLoad) {
36
+ firstPageLoad = false;
37
+ return;
38
+ }
39
+ window.gtag?.("event", "page_view", { page_path: window.location.pathname });
40
+ });
41
+ }
42
+
43
+ if (document.readyState === "loading")
44
+ document.addEventListener("DOMContentLoaded", load, { once: true });
45
+ else load();
46
+
47
+ export {};
@@ -0,0 +1,58 @@
1
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
2
+
3
+ function shuffle<T>(items: T[]): T[] {
4
+ const result = [...items];
5
+ for (let i = result.length - 1; i > 0; i -= 1) {
6
+ const bytes = new Uint32Array(1);
7
+ const j = globalThis.crypto?.getRandomValues
8
+ ? (crypto.getRandomValues(bytes)[0] ?? 0) % (i + 1)
9
+ : Math.floor(Math.random() * (i + 1));
10
+ [result[i], result[j]] = [result[j], result[i]];
11
+ }
12
+ return result;
13
+ }
14
+
15
+ async function fetchPosts() {
16
+ const response = await fetch("/api/post.abbrlink.json");
17
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
18
+ return (await response.json()) as { abbrlink: string; title: string }[];
19
+ }
20
+
21
+ function render(posts: { abbrlink: string; title: string }[]) {
22
+ return posts
23
+ .map(
24
+ (post) =>
25
+ `<li class="random-post-item"><a href="/posts/${post.abbrlink}/" class="random-post-link a-none">${post.title}</a></li>`,
26
+ )
27
+ .join("");
28
+ }
29
+
30
+ async function refresh(container: HTMLElement, fallback: string) {
31
+ const list = container.querySelector<HTMLElement>("#random-posts-list");
32
+ if (!list) return;
33
+ list.style.opacity = "0.5";
34
+ await sleep(300);
35
+ try {
36
+ const posts = shuffle(await fetchPosts()).slice(0, Number(container.dataset.count ?? 5));
37
+ list.innerHTML = posts.length
38
+ ? render(posts)
39
+ : `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
40
+ } catch {
41
+ list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
42
+ } finally {
43
+ list.style.opacity = "1";
44
+ }
45
+ }
46
+
47
+ document.addEventListener("astro:page-load", () => {
48
+ const container = document.querySelector<HTMLElement>("[data-stalux-random-posts]");
49
+ if (!container || container.dataset.staluxBound === "true") return;
50
+ container.dataset.staluxBound = "true";
51
+ container.querySelector("#refresh-random-posts")?.addEventListener("click", async (event) => {
52
+ event.preventDefault();
53
+ await refresh(container, container.dataset.loadError ?? "Failed to load posts");
54
+ });
55
+ void refresh(container, container.dataset.loadError ?? "Failed to load posts");
56
+ });
57
+
58
+ export {};
@@ -0,0 +1,32 @@
1
+ declare global {
2
+ interface Window {
3
+ __staluxRuntimeTimer?: ReturnType<typeof setInterval>;
4
+ }
5
+ }
6
+
7
+ function update() {
8
+ const container = document.querySelector<HTMLElement>("[data-stalux-buildtime]");
9
+ const counter = document.getElementById("runtime-counter");
10
+ if (!container || !counter) return;
11
+ const start = new Date(container.dataset.staluxBuildtime ?? "");
12
+ const diffMs = Date.now() - start.getTime();
13
+ if (Number.isNaN(diffMs) || diffMs < 0) return;
14
+ const total = Math.floor(diffMs / 1000);
15
+ const format =
16
+ container.dataset.staluxRuntimeFormat ?? "{days}d {hours}h {minutes}m {seconds}s";
17
+ counter.textContent = format
18
+ .replace("{days}", String(Math.floor(total / 86400)))
19
+ .replace("{hours}", String(Math.floor((total % 86400) / 3600)))
20
+ .replace("{minutes}", String(Math.floor((total % 3600) / 60)))
21
+ .replace("{seconds}", String(total % 60));
22
+ }
23
+
24
+ function start() {
25
+ if (window.__staluxRuntimeTimer) clearInterval(window.__staluxRuntimeTimer);
26
+ update();
27
+ window.__staluxRuntimeTimer = setInterval(update, 1000);
28
+ }
29
+
30
+ document.addEventListener("astro:page-load", start);
31
+ start();
32
+ export {};
@@ -0,0 +1,21 @@
1
+ declare global {
2
+ interface Window {
3
+ __staluxVerCountLoaded?: boolean;
4
+ }
5
+ }
6
+
7
+ function load() {
8
+ if (document.querySelector("script[data-stalux-vercount]")) return;
9
+ window.__staluxVerCountLoaded = true;
10
+ const script = document.createElement("script");
11
+ script.defer = true;
12
+ script.dataset.staluxVercount = "true";
13
+ script.src = "https://events.vercount.one/js";
14
+ document.head.appendChild(script);
15
+ }
16
+
17
+ document.addEventListener("astro:page-load", load);
18
+ if (document.readyState === "loading")
19
+ document.addEventListener("DOMContentLoaded", load, { once: true });
20
+ else load();
21
+ export {};
@@ -56,9 +56,11 @@ interface DocumentWithModelContext extends Document {
56
56
  // 语言选择(跟随站点 site.yml 的 lang)
57
57
  // ---------------------------------------------------------------------------
58
58
 
59
- const SITE_LANG = (
60
- typeof window !== "undefined" ? (window.__STALUX_SITE_INFO__?.lang ?? "zh-CN") : "zh-CN"
61
- ).toLowerCase();
59
+ const SITE_LANG =
60
+ (typeof document !== "undefined"
61
+ ? document.body?.dataset.staluxSiteLang
62
+ : "zh-CN"
63
+ )?.toLowerCase() ?? "zh-cn";
62
64
 
63
65
  /** 站点为英文站时为 true */
64
66
  const IS_EN = SITE_LANG === "en" || SITE_LANG === "en-us" || SITE_LANG === "en-gb";
@@ -492,7 +494,13 @@ function siteInfoTool(): WebMCPTool {
492
494
  annotations: { readOnlyHint: true, untrustedContentHint: false },
493
495
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
494
496
  execute: async () => {
495
- const info = window.__STALUX_SITE_INFO__ ?? {};
497
+ const body = document.body;
498
+ const info = window.__STALUX_SITE_INFO__ ?? {
499
+ title: body?.dataset.staluxSiteTitle,
500
+ url: body?.dataset.staluxSiteUrl,
501
+ description: body?.dataset.staluxSiteDescription,
502
+ lang: body?.dataset.staluxSiteLang,
503
+ };
496
504
  return {
497
505
  ok: true,
498
506
  title: info.title ?? "",
@@ -468,6 +468,38 @@ export async function renderLlmsTxt(config: ConfigMap, site: string): Promise<st
468
468
  lines.push(buildMdLink("llms-full.txt", `${site}/llms-full.txt`, t("ai.llmsFullTxtDesc")));
469
469
  lines.push("");
470
470
 
471
+ // Developer resources and agent guidance. Keep these links predictable so an
472
+ // agent can discover the read API without scraping the HTML application shell.
473
+ lines.push("## Developer resources");
474
+ lines.push(
475
+ buildMdLink(
476
+ "OpenAPI specification",
477
+ `${site}/openapi.json`,
478
+ "Machine-readable description of the read-only blog API.",
479
+ ),
480
+ );
481
+ lines.push(
482
+ buildMdLink(
483
+ "Published post index",
484
+ `${site}/api/posts.json`,
485
+ "JSON metadata for published posts, including stable abbrlink identifiers.",
486
+ ),
487
+ );
488
+ lines.push(
489
+ buildMdLink(
490
+ "Post identifier index",
491
+ `${site}/api/post.abbrlink.json`,
492
+ "JSON mapping of published post titles to stable identifiers.",
493
+ ),
494
+ );
495
+ lines.push("");
496
+
497
+ lines.push("## When to use this site");
498
+ lines.push(
499
+ "Use the read API or Markdown endpoints when you need to search, cite, summarize, or retrieve the author's published technical articles. Start with `/openapi.json` or `/api/posts.json`, then fetch a specific `/posts/{abbrlink}.md` resource. The endpoints are read-only and do not require authentication.",
500
+ );
501
+ lines.push("");
502
+
471
503
  // Optional
472
504
  lines.push(`## ${t("ai.optional")}`);
473
505
  lines.push(buildMdLink(t("ai.rss"), `${site}/rss.xml`));