@xingwangzhe/stalux 1.46.2 → 1.46.5

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 (48) hide show
  1. package/package.json +3 -2
  2. package/src/components/stalux/archives/archivesList.astro +1 -1
  3. package/src/components/stalux/categories/categoriesList.astro +6 -1
  4. package/src/components/stalux/common/Weather.astro +1 -1
  5. package/src/components/stalux/common/date.astro +1 -1
  6. package/src/components/stalux/footer/FooterBeian.astro +2 -0
  7. package/src/components/stalux/layout/head.astro +1 -1
  8. package/src/components/stalux/links/linkList.astro +1 -1
  9. package/src/components/stalux/posts/postContent.astro +5 -1
  10. package/src/components/stalux/posts/postList.astro +6 -1
  11. package/src/components/stalux/posts/postRight.astro +11 -4
  12. package/src/components/stalux/words/wordList.astro +11 -8
  13. package/src/index.ts +1 -1
  14. package/src/internal/compact-styles.ts +1 -1
  15. package/src/internal/font-slices.ts +3 -3
  16. package/src/internal/page-output-cache.ts +6 -7
  17. package/src/internal/social-card-renderer.ts +3 -5
  18. package/src/pages/og/[image].[format].ts +1 -1
  19. package/src/schemas/config.ts +5 -5
  20. package/src/scripts/analytics-runtime.ts +1 -1
  21. package/src/scripts/archive-descriptions.ts +1 -1
  22. package/src/scripts/back-to-top.ts +7 -6
  23. package/src/scripts/background.ts +2 -2
  24. package/src/scripts/countdown.ts +1 -1
  25. package/src/scripts/date-time.ts +1 -1
  26. package/src/scripts/list-reveal.ts +39 -18
  27. package/src/scripts/module-motion.ts +5 -3
  28. package/src/scripts/navigation-font.ts +4 -1
  29. package/src/scripts/navigation.ts +1 -1
  30. package/src/scripts/page-runtime.ts +6 -4
  31. package/src/scripts/random-posts.ts +2 -2
  32. package/src/scripts/route-motion.ts +67 -56
  33. package/src/scripts/route-out.ts +12 -14
  34. package/src/scripts/runtime-counter.ts +1 -1
  35. package/src/scripts/shell-hydration.ts +60 -0
  36. package/src/scripts/smooth-scroll.ts +4 -4
  37. package/src/scripts/swup-internals.d.ts +31 -0
  38. package/src/scripts/swup-router.ts +26 -11
  39. package/src/scripts/waline.ts +1 -1
  40. package/src/scripts/weather.ts +148 -82
  41. package/src/scripts/webmcp.ts +24 -20
  42. package/src/utils/ai-discovery.ts +1 -1
  43. package/src/utils/cc.ts +2 -2
  44. package/src/utils/collections-stats.ts +4 -2
  45. package/src/utils/dayjs.ts +2 -2
  46. package/src/utils/i18n.ts +2 -2
  47. package/src/utils/seo-document.ts +4 -4
  48. package/src/utils/taxonomy.ts +10 -12
@@ -8,22 +8,140 @@ export interface WeatherData {
8
8
  code: number;
9
9
  updated: number;
10
10
  }
11
- export type WeatherState = { status: "loading" | "ready" | "error"; data?: WeatherData };
11
+ export interface WeatherState {
12
+ status: "loading" | "ready" | "error";
13
+ data?: WeatherData;
14
+ }
12
15
  type Listener = (state: WeatherState) => void;
16
+ /** One provider request; the only shape callers may assume is `unknown`. */
17
+ type GeoRequest = (url: string) => Promise<unknown>;
18
+
19
+ interface ApproximateLocation {
20
+ city: string;
21
+ lat: number;
22
+ lon: number;
23
+ ip?: string;
24
+ }
25
+
26
+ interface WeatherReading {
27
+ temperature: number;
28
+ apparent: number;
29
+ code: number;
30
+ }
31
+
32
+ const invalidLocation = () => new Error("Invalid approximate location");
33
+
34
+ /**
35
+ * Read one provider's answer into a location, or reject it.
36
+ *
37
+ * The two providers use different field names, so this is the only place that
38
+ * knows either wire format; everything downstream sees ApproximateLocation.
39
+ * Narrowing happens through `in` checks, so no value is ever asserted.
40
+ */
41
+ function parseLocation(value: unknown): ApproximateLocation {
42
+ if (typeof value !== "object" || value === null) throw invalidLocation();
43
+ if ("error" in value && value.error) throw invalidLocation();
44
+ if (!("city" in value) || typeof value.city !== "string" || !value.city.trim())
45
+ throw invalidLocation();
46
+ if (!("latitude" in value) || typeof value.latitude !== "number") throw invalidLocation();
47
+ if (!("longitude" in value) || typeof value.longitude !== "number") throw invalidLocation();
48
+ const lat = value.latitude;
49
+ const lon = value.longitude;
50
+ if (
51
+ !(Number.isFinite(lat) && Number.isFinite(lon)) ||
52
+ Math.abs(lat) > 90 ||
53
+ Math.abs(lon) > 180
54
+ )
55
+ throw invalidLocation();
56
+ const usableIp =
57
+ "ip" in value &&
58
+ typeof value.ip === "string" &&
59
+ value.ip.length <= 45 &&
60
+ /^[0-9a-f:.]+$/i.test(value.ip);
61
+ return { city: value.city.trim(), lat, lon, ...(usableIp ? { ip: value.ip as string } : {}) };
62
+ }
63
+
64
+ /** Prefer the primary provider, fall back to the second one on any failure. */
65
+ async function resolveLocation(get: GeoRequest): Promise<ApproximateLocation> {
66
+ try {
67
+ return parseLocation(await get("https://api.ip.sb/geoip"));
68
+ } catch {
69
+ const fallback = await get("https://ipinfo.io/json");
70
+ if (typeof fallback !== "object" || fallback === null) throw invalidLocation();
71
+ const coordinates =
72
+ "loc" in fallback && typeof fallback.loc === "string" ? fallback.loc.split(",") : [];
73
+ const pair = coordinates.length === 2 ? coordinates : undefined;
74
+ return parseLocation({
75
+ city: "city" in fallback ? fallback.city : undefined,
76
+ ip: "ip" in fallback ? fallback.ip : undefined,
77
+ latitude: pair?.[0]?.trim() ? Number(pair[0]) : undefined,
78
+ longitude: pair?.[1]?.trim() ? Number(pair[1]) : undefined,
79
+ error: "error" in fallback ? fallback.error : undefined,
80
+ });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Parse an untrusted value into weather data, or reject it.
86
+ *
87
+ * `in` narrowing keeps this assertion-free: the value stays `unknown` until each
88
+ * field is proven, and the caller receives a domain type instead of a flag.
89
+ */
90
+ function parseWeatherData(value: unknown): WeatherData | undefined {
91
+ if (typeof value !== "object" || value === null) return undefined;
92
+ if (!("city" in value) || typeof value.city !== "string") return undefined;
93
+ const city = value.city;
94
+ if (city.length === 0 || city.length >= 160) return undefined;
95
+ let ip: string | undefined;
96
+ if ("ip" in value && value.ip !== undefined) {
97
+ if (
98
+ typeof value.ip !== "string" ||
99
+ value.ip.length > 45 ||
100
+ !/^[0-9a-f:.]+$/i.test(value.ip)
101
+ )
102
+ return undefined;
103
+ ip = value.ip;
104
+ }
105
+ if (!("temperature" in value)) return undefined;
106
+ if (!("apparent" in value)) return undefined;
107
+ if (!("code" in value)) return undefined;
108
+ if (!("updated" in value)) return undefined;
109
+ const temperature = finite(value.temperature);
110
+ const apparent = finite(value.apparent);
111
+ const code = finite(value.code);
112
+ const updated = finite(value.updated);
113
+ if (
114
+ temperature === undefined ||
115
+ apparent === undefined ||
116
+ code === undefined ||
117
+ updated === undefined
118
+ )
119
+ return undefined;
120
+ return { city, ...(ip === undefined ? {} : { ip }), temperature, apparent, code, updated };
121
+ }
13
122
 
14
- function validData(value: unknown): value is WeatherData {
15
- if (!value || typeof value !== "object") return false;
16
- const data = value as WeatherData;
17
- return (
18
- typeof data.city === "string" &&
19
- data.city.length > 0 &&
20
- data.city.length < 160 &&
21
- (data.ip === undefined ||
22
- (typeof data.ip === "string" &&
23
- data.ip.length <= 45 &&
24
- /^[0-9a-f:.]+$/i.test(data.ip))) &&
25
- [data.temperature, data.apparent, data.code, data.updated].every(Number.isFinite)
26
- );
123
+ const finite = (value: unknown): number | undefined =>
124
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
125
+
126
+ /** Read the three numbers the card shows, or reject the response. */
127
+ function parseReading(value: unknown): WeatherReading | undefined {
128
+ if (typeof value !== "object" || value === null) return undefined;
129
+ if (!("current" in value)) return undefined;
130
+ const current = value.current;
131
+ if (typeof current !== "object" || current === null) return undefined;
132
+ const number = (key: "temperature_2m" | "apparent_temperature" | "weather_code") => {
133
+ if (key === "temperature_2m" && "temperature_2m" in current)
134
+ return finite(current.temperature_2m);
135
+ if (key === "apparent_temperature" && "apparent_temperature" in current)
136
+ return finite(current.apparent_temperature);
137
+ if (key === "weather_code" && "weather_code" in current)
138
+ return finite(current.weather_code);
139
+ };
140
+ const temperature = number("temperature_2m");
141
+ const apparent = number("apparent_temperature");
142
+ const code = number("weather_code");
143
+ if (temperature === undefined || apparent === undefined || code === undefined) return undefined;
144
+ return { temperature, apparent, code };
27
145
  }
28
146
 
29
147
  export function createWeatherClient({
@@ -41,8 +159,8 @@ export function createWeatherClient({
41
159
  let timer: ReturnType<typeof setTimeout> | undefined;
42
160
  let generation = 0;
43
161
  try {
44
- const cached: unknown = JSON.parse(storage?.getItem(CACHE_KEY) ?? "null");
45
- if (validData(cached) && cached.updated <= now()) data = cached;
162
+ const cached = parseWeatherData(JSON.parse(storage?.getItem(CACHE_KEY) ?? "null"));
163
+ if (cached && cached.updated <= now()) data = cached;
46
164
  } catch {
47
165
  /* Storage is optional. */
48
166
  }
@@ -57,7 +175,7 @@ export function createWeatherClient({
57
175
  timer = undefined;
58
176
  };
59
177
  const refresh = async (force = false) => {
60
- if (!listeners.size || controller) return;
178
+ if (listeners.size === 0 || controller) return;
61
179
  clearTimeout(timer);
62
180
  if (!force && data && now() - data.updated < WEATHER_TTL) {
63
181
  emit("ready");
@@ -71,9 +189,9 @@ export function createWeatherClient({
71
189
  timer = timeout;
72
190
  emit("loading");
73
191
  try {
74
- const get = async (url: string) => {
192
+ const get = async (endpoint: string): Promise<unknown> => {
75
193
  if (request.signal.aborted) throw new Error("Request cancelled");
76
- const response = await fetcher(url, {
194
+ const response = await fetcher(endpoint, {
77
195
  signal: request.signal,
78
196
  credentials: "omit",
79
197
  referrerPolicy: "no-referrer",
@@ -82,55 +200,7 @@ export function createWeatherClient({
82
200
  throw new Error("Weather service unavailable");
83
201
  return response.json();
84
202
  };
85
- const validateLocation = (location: {
86
- city?: unknown;
87
- latitude?: unknown;
88
- longitude?: unknown;
89
- ip?: unknown;
90
- error?: unknown;
91
- }) => {
92
- const lat = location.latitude;
93
- const lon = location.longitude;
94
- if (
95
- location.error ||
96
- typeof location.city !== "string" ||
97
- !location.city.trim() ||
98
- typeof lat !== "number" ||
99
- typeof lon !== "number" ||
100
- !Number.isFinite(lat) ||
101
- !Number.isFinite(lon) ||
102
- Math.abs(lat) > 90 ||
103
- Math.abs(lon) > 180
104
- )
105
- throw new Error("Invalid approximate location");
106
- const ip =
107
- typeof location.ip === "string" &&
108
- location.ip.length <= 45 &&
109
- /^[0-9a-f:.]+$/i.test(location.ip)
110
- ? location.ip
111
- : undefined;
112
- return { city: location.city.trim(), lat, lon, ip };
113
- };
114
- let location: ReturnType<typeof validateLocation>;
115
- try {
116
- location = validateLocation(await get("https://api.ip.sb/geoip"));
117
- } catch {
118
- const info = await get("https://ipinfo.io/json");
119
- const coordinates = typeof info.loc === "string" ? info.loc.split(",") : [];
120
- location = validateLocation({
121
- city: info.city,
122
- ip: info.ip,
123
- latitude:
124
- coordinates.length === 2 && coordinates[0]?.trim()
125
- ? Number(coordinates[0])
126
- : undefined,
127
- longitude:
128
- coordinates.length === 2 && coordinates[1]?.trim()
129
- ? Number(coordinates[1])
130
- : undefined,
131
- error: info.error,
132
- });
133
- }
203
+ const location = await resolveLocation(get);
134
204
  const { lat, lon } = location;
135
205
  const url = new URL("https://api.open-meteo.com/v1/forecast");
136
206
  url.search = new URLSearchParams({
@@ -140,19 +210,15 @@ export function createWeatherClient({
140
210
  timezone: "auto",
141
211
  forecast_days: "1",
142
212
  }).toString();
143
- const weather = await get(url.href);
144
- const current = weather.current;
145
- const next: WeatherData = {
146
- city: location.city.trim(),
147
- ip: location.ip,
148
- temperature: current?.temperature_2m,
149
- apparent: current?.apparent_temperature,
150
- code: current?.weather_code,
213
+ const reading = parseReading(await get(url.href));
214
+ if (!reading) throw new Error("Invalid weather response");
215
+ if (token !== generation || request.signal.aborted) return;
216
+ data = {
217
+ city: location.city,
218
+ ...(location.ip === undefined ? {} : { ip: location.ip }),
219
+ ...reading,
151
220
  updated: now(),
152
221
  };
153
- if (!validData(next)) throw new Error("Invalid weather response");
154
- if (token !== generation || request.signal.aborted) return;
155
- data = next;
156
222
  try {
157
223
  storage?.setItem(CACHE_KEY, JSON.stringify(data));
158
224
  } catch {
@@ -173,7 +239,7 @@ export function createWeatherClient({
173
239
  void refresh();
174
240
  return () => {
175
241
  listeners.delete(listener);
176
- if (!listeners.size) stop();
242
+ if (listeners.size === 0) stop();
177
243
  };
178
244
  },
179
245
  refresh: () => void refresh(true),
@@ -222,7 +288,7 @@ export function weatherDescription(code: number, lang: string): string {
222
288
  let client: ReturnType<typeof createWeatherClient> | undefined;
223
289
  export function mountWeather() {
224
290
  const elements = [...document.querySelectorAll<HTMLElement>("[data-stalux-weather]")];
225
- if (!elements.length) return;
291
+ if (elements.length === 0) return;
226
292
  if (!client) {
227
293
  let storage: Storage | undefined;
228
294
  try {
@@ -238,7 +304,7 @@ export function mountWeather() {
238
304
  const locationOutput = element.querySelector<HTMLElement>("[data-weather-location]");
239
305
  const status = element.querySelector<HTMLElement>("[data-weather-status]");
240
306
  const retry = element.querySelector<HTMLButtonElement>("[data-weather-retry]");
241
- if (!output || !status || !retry) return () => {};
307
+ if (!(output && status && retry)) return () => undefined;
242
308
  const zh = (element.dataset.lang ?? "zh-CN").startsWith("zh");
243
309
  let visible = !globalThis.IntersectionObserver;
244
310
  let unsubscribe: (() => void) | undefined;
@@ -65,9 +65,9 @@ type DocumentWithModelContext = { modelContext?: ModelContext };
65
65
  // ---------------------------------------------------------------------------
66
66
 
67
67
  const SITE_LANG =
68
- (typeof document !== "undefined"
69
- ? document.body?.dataset.staluxSiteLang
70
- : "zh-CN"
68
+ (typeof document === "undefined"
69
+ ? "zh-CN"
70
+ : document.body?.dataset.staluxSiteLang
71
71
  )?.toLowerCase() ?? "zh-cn";
72
72
 
73
73
  /** 站点为英文站时为 true */
@@ -141,22 +141,26 @@ function loadPostsMeta(): Promise<PostMeta[] | null> {
141
141
  const attempt = fetchJSON(sitePath("api/posts.json"))
142
142
  .then((data) => {
143
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"))),
144
+ !(
145
+ Array.isArray(data) &&
146
+ data.every(
147
+ (post) =>
148
+ post &&
149
+ typeof post.title === "string" &&
150
+ typeof post.abbrlink === "string" &&
151
+ typeof post.url === "string" &&
152
+ (post.desc === undefined || typeof post.desc === "string") &&
153
+ (!post.categories ||
154
+ (Array.isArray(post.categories) &&
155
+ post.categories.every(
156
+ (category: unknown) => typeof category === "string",
157
+ ))) &&
158
+ (!post.tags ||
159
+ (Array.isArray(post.tags) &&
160
+ post.tags.every(
161
+ (tag: unknown) => typeof tag === "string",
162
+ ))),
163
+ )
160
164
  )
161
165
  )
162
166
  return null;
@@ -494,7 +498,7 @@ function searchPostsTool(): WebMCPTool {
494
498
  if (!pagefind) return fallback("INDEX_UNAVAILABLE");
495
499
  try {
496
500
  const result = await pagefind.search(keyword);
497
- if (!result || !Array.isArray(result.results)) return fallback("SEARCH_FAILED");
501
+ if (!(result && Array.isArray(result.results))) return fallback("SEARCH_FAILED");
498
502
  const posts: Record<string, unknown>[] = [...metadataMatches];
499
503
  const seen = new Set(metadataMatches.map((post) => post.abbrlink));
500
504
  for (
@@ -413,7 +413,7 @@ export async function renderLlmsTxt(
413
413
  for (const { name, url } of mediaLinks) {
414
414
  lines.push(`- ${t("ai.socialMedia")} (${escapeMd(name)}): ${url}`);
415
415
  }
416
- if (!authorName && !email && mediaLinks.length === 0) {
416
+ if (!(authorName || email) && mediaLinks.length === 0) {
417
417
  lines.push(`- ${t("ai.noContact")}`);
418
418
  }
419
419
  lines.push("");
package/src/utils/cc.ts CHANGED
@@ -18,7 +18,7 @@ export function buildCCLink(licenseCode: string): string {
18
18
 
19
19
  const pathPart = match[1];
20
20
  const version = match[2];
21
- if (!pathPart || !version) {
21
+ if (!(pathPart && version)) {
22
22
  return "https://creativecommons.org/licenses/by-nc-sa/4.0/";
23
23
  }
24
24
  return `https://creativecommons.org/licenses/${pathPart.toLowerCase()}/${version}/`;
@@ -45,7 +45,7 @@ export function buildCCName(licenseCode: string, t: Translator["t"]): string {
45
45
 
46
46
  const pathPart = match[1];
47
47
  const version = match[2];
48
- if (!pathPart || !version) {
48
+ if (!(pathPart && version)) {
49
49
  const elements = ["BY", "NC", "SA"].map((code) => t(`cc.${code}`)).join("-");
50
50
  return t("cc.format", { elements, version: "4.0" });
51
51
  }
@@ -6,8 +6,10 @@ function countUncategorized(posts: PostEntry[]): number {
6
6
  let count = 0;
7
7
  for (const post of posts) {
8
8
  const names = post.data.categories ?? [];
9
- if (!names.length) count++;
10
- else for (const name of names) if (name === "uncategorized") count++;
9
+ if (names.length)
10
+ for (const name of names)
11
+ if (name === "uncategorized") count++;
12
+ else count++;
11
13
  }
12
14
  return count;
13
15
  }
@@ -138,7 +138,7 @@ export function format(date: Date | string | number, formatStr: string): string
138
138
  export function formatInTimeZone(
139
139
  date: Date | string | number,
140
140
  timeZone: string,
141
- formatStr: string = "yyyy-MM-dd'T'HH:mm:ssXXX",
141
+ formatStr = "yyyy-MM-dd'T'HH:mm:ssXXX",
142
142
  ): string {
143
143
  const d = date instanceof Date ? date : new Date(date);
144
144
  if (Number.isNaN(d.getTime())) return "";
@@ -160,7 +160,7 @@ export function formatInTimeZone(
160
160
  offset = "+00:00";
161
161
  } else {
162
162
  offset = offset.replace(/^GMT/, "").replace(/^UTC/, "");
163
- if (!offset.startsWith("+") && !offset.startsWith("-")) {
163
+ if (!(offset.startsWith("+") || offset.startsWith("-"))) {
164
164
  offset = `+${offset}`;
165
165
  }
166
166
  const m = offset.match(/^([+-])(\d{1,2})(?::?(\d{2}))?$/);
package/src/utils/i18n.ts CHANGED
@@ -1,5 +1,5 @@
1
- import en from "@i18n/en.json";
2
- import zhCN from "@i18n/zh-CN.json";
1
+ import en from "@i18n/en.json" with { type: "json" };
2
+ import zhCN from "@i18n/zh-CN.json" with { type: "json" };
3
3
 
4
4
  const dicts: Record<string, Record<string, unknown>> = {
5
5
  "zh-CN": zhCN as Record<string, unknown>,
@@ -145,14 +145,14 @@ export function buildSeoDocument(input: SeoDocumentInput): SeoDocument {
145
145
  { name: "generator", content: "Stalux" },
146
146
  { name: "theme", content: "Stalux" },
147
147
  { name: "stalux-version", content: version },
148
- ...(!noindex
149
- ? [
148
+ ...(noindex
149
+ ? []
150
+ : [
150
151
  {
151
152
  name: "googlebot",
152
153
  content: "max-image-preview:large, max-snippet:-1, max-video-preview:-1",
153
154
  },
154
- ]
155
- : []),
155
+ ]),
156
156
  ];
157
157
 
158
158
  const sameAs = input.mediaLinks.filter((link) => /^https?:\/\//i.test(link));
@@ -15,16 +15,14 @@ export async function buildTaxonomyStaticPaths(key: TaxonomyKey): Promise<GetSta
15
15
  const paramName = key === "tags" ? "tag" : "category";
16
16
  const taxonomy = index[key];
17
17
 
18
- return [...taxonomy].map(([name, entry]) => {
19
- return {
20
- params: { [paramName]: name },
21
- cacheKey: `${entry.cacheKey}|${getRuntimeCacheKey()}`,
22
- props: {
23
- [paramName]: name,
24
- count: entry.posts.length,
25
- posts: entry.posts,
26
- lastUpdateDate: entry.latestTime,
27
- },
28
- };
29
- });
18
+ return [...taxonomy].map(([name, entry]) => ({
19
+ params: { [paramName]: name },
20
+ cacheKey: `${entry.cacheKey}|${getRuntimeCacheKey()}`,
21
+ props: {
22
+ [paramName]: name,
23
+ count: entry.posts.length,
24
+ posts: entry.posts,
25
+ lastUpdateDate: entry.latestTime,
26
+ },
27
+ }));
30
28
  }