@xingwangzhe/stalux 1.46.1 → 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 +214 -40
  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 +53 -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
@@ -4,7 +4,7 @@ import { observeVisibleClock } from "./visible-clock";
4
4
  registerPageLifecycle("runtime-counter", () => {
5
5
  const container = document.querySelector<HTMLElement>("[data-stalux-buildtime]");
6
6
  const counter = document.getElementById("runtime-counter");
7
- if (!container || !counter) return;
7
+ if (!(container && counter)) return;
8
8
  const start = new Date(container.dataset.staluxBuildtime ?? "");
9
9
  const format =
10
10
  container.dataset.staluxRuntimeFormat ?? "{days}d {hours}h {minutes}m {seconds}s";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Keep a shell's frame, refresh only the parts that describe the page.
3
+ *
4
+ * A `data-swup-persist` node is kept whole by Swup: that is what makes the
5
+ * sidebars, the header and the footer survive a visit. For a container whose
6
+ * *frame* is stable but whose *contents* belong to the page (the article's TOC,
7
+ * tag list and category list), keeping the node whole would show the previous
8
+ * article's headings. Marking it `data-stalux-shell-slot` says: keep this node,
9
+ * take the children from the page we are navigating to.
10
+ *
11
+ * Doing it here, synchronously after the swap and before `astro:after-swap`, means
12
+ * every page module that mounts later sees the refreshed lists and nothing has to
13
+ * be measured or re-rendered a second time.
14
+ */
15
+ const SLOT = "[data-stalux-shell-slot]";
16
+
17
+ /** Persist nodes that existed before this swap; only their slots need refilling. */
18
+ const retained = new WeakSet<Element>();
19
+
20
+ /** Call before the content is replaced, while the old document is still live. */
21
+ export function markRetainedShells(): void {
22
+ for (const element of document.querySelectorAll("[data-swup-persist]")) retained.add(element);
23
+ }
24
+
25
+ export function hydrateShells(next: Document | undefined): number {
26
+ if (!next || typeof next.querySelector !== "function") return 0;
27
+ let hydrated = 0;
28
+ for (const live of document.querySelectorAll<HTMLElement>(SLOT)) {
29
+ const slot = live.getAttribute?.("data-stalux-shell-slot");
30
+ if (!slot) continue;
31
+ // A slot that arrived with the incoming document already holds the right
32
+ // children; replacing them again would dirty layout for nothing.
33
+ const shell = live.closest("[data-swup-persist]");
34
+ if (!(shell && retained.has(shell))) continue;
35
+ const incoming = next.querySelector<HTMLElement>(`[data-stalux-shell-slot="${slot}"]`);
36
+ if (!incoming) continue;
37
+ // Two articles often share the same tags or the same category. Writing
38
+ // identical children back would dirty layout for a subtree that is
39
+ // already correct.
40
+ if (live.innerHTML === incoming.innerHTML) continue;
41
+ // The incoming nodes belong to another document; importNode adopts them.
42
+ const fresh = document.importNode(incoming, true);
43
+ live.replaceChildren(...fresh.childNodes);
44
+ // These lists are new content even though their frame is old, so the
45
+ // unroll cue still applies to them.
46
+ for (const group of live.querySelectorAll<HTMLElement>("[data-stalux-reveal]"))
47
+ group.setAttribute("data-stalux-shell-fresh", "");
48
+ hydrated++;
49
+ }
50
+ // A shell that is itself new (first visit to an article, or any move between
51
+ // page types) brings fresh lists with it: they are content, not a kept frame.
52
+ for (const group of document.querySelectorAll<HTMLElement>(
53
+ "[data-swup-persist] [data-stalux-reveal]",
54
+ )) {
55
+ const shell = group.closest("[data-swup-persist]");
56
+ if (shell && !retained.has(shell) && !group.hasAttribute("data-stalux-shell-fresh"))
57
+ group.setAttribute("data-stalux-shell-fresh", "");
58
+ }
59
+ return hydrated;
60
+ }
@@ -12,9 +12,9 @@ export interface PageGlide {
12
12
  }
13
13
 
14
14
  /** Bound the pre-swap return-to-top so a 6000px article does not add a second. */
15
- const GLIDE_MIN_MS = 160;
16
- const GLIDE_MAX_MS = 360;
17
- const GLIDE_SAFETY_MS = 760;
15
+ const GLIDE_MIN_MS = 80;
16
+ const GLIDE_MAX_MS = 180;
17
+ const GLIDE_SAFETY_MS = 380;
18
18
 
19
19
  /**
20
20
  * Walk the current document back to the top before the container is replaced.
@@ -42,7 +42,7 @@ export function glideToTop(): PageGlide {
42
42
 
43
43
  // Longer documents take proportionally longer, but the ceiling keeps the
44
44
  // whole cue inside the same budget as the content fade.
45
- const duration = Math.min(GLIDE_MAX_MS, GLIDE_MIN_MS + startTop / 10);
45
+ const duration = Math.min(GLIDE_MAX_MS, GLIDE_MIN_MS + startTop / 20);
46
46
  let frame: number | undefined;
47
47
  let settled = false;
48
48
  const release = () => {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Type restoration for swup members we depend on but the package omits.
3
+ *
4
+ * `Visit.abort()` exists in swup 4.10.0's source (`src/modules/Visit.ts`) and is
5
+ * marked `/** @internal *\/`, so it is stripped from the published
6
+ * `dist/types/modules/Visit.d.ts`. The router genuinely needs it: superseding a
7
+ * visit mid-navigation means marking the old one aborted, and no public API does
8
+ * that.
9
+ *
10
+ * Declaring it here is the honest alternative to casting at the call site: the
11
+ * compiler now checks the name and the signature everywhere, and there is exactly
12
+ * one place to revisit when swup changes. Callers still guard with
13
+ * `typeof visit.abort === "function"` so a future removal degrades to a no-op
14
+ * instead of throwing in the middle of a navigation.
15
+ *
16
+ * Recheck on every swup upgrade:
17
+ * grep -n "@internal" node_modules/swup/src/modules/Visit.ts
18
+ * grep -n "abort" node_modules/swup/dist/types/modules/Visit.d.ts
19
+ */
20
+
21
+ export {};
22
+
23
+ declare module "swup" {
24
+ interface Visit {
25
+ /**
26
+ * Mark this visit aborted. Internal in 4.10.0; see the note above.
27
+ * Not a public API: do not call it outside the router adapter.
28
+ */
29
+ abort(): void;
30
+ }
31
+ }
@@ -12,6 +12,7 @@ import "./navigation-client";
12
12
  import { prepareNavigationScripts } from "./navigation-scripts";
13
13
  import { prepareRouteMotion, type RouteMotion } from "./route-motion";
14
14
  import { animateRouteOut } from "./route-out";
15
+ import { hydrateShells, markRetainedShells } from "./shell-hydration";
15
16
  import { cancelPageScroll, glideToTop, type PageGlide, scrollPage } from "./smooth-scroll";
16
17
 
17
18
  interface NavigationContext {
@@ -35,9 +36,11 @@ type Position = { top: number; left: number; focus?: string };
35
36
  const MAIN = "main[data-stalux-route]";
36
37
 
37
38
  function abortObsoleteVisit(visit: Visit): void {
38
- // Swup 4.10 ships abort() but strips @internal members from its .d.ts.
39
- // Pin core to that audited version; recheck this adapter when upgrading it.
40
- (visit as Visit & { abort: () => void }).abort();
39
+ // `abort()` is @internal in swup 4.10.0 and stripped from the published
40
+ // types; swup-internals.d.ts restores the declaration, so this is checked
41
+ // like any other member. The guard keeps a future removal from throwing in
42
+ // the middle of a navigation: every caller returns right after this anyway.
43
+ if (typeof visit.abort === "function") visit.abort();
41
44
  }
42
45
 
43
46
  function dispatchNavigation(name: string, context: NavigationContext, nextDocument?: Document) {
@@ -88,9 +91,9 @@ export function installSwupRouter(): Swup {
88
91
  window.addEventListener(
89
92
  "popstate",
90
93
  (event) => {
91
- browserMotion =
92
- (event as PopStateEvent & { hasUAVisualTransition?: boolean })
93
- .hasUAVisualTransition === true;
94
+ // hasUAVisualTransition is part of the DOM lib's PopStateEvent, so
95
+ // this reads the event directly; the listener is typed for popstate.
96
+ browserMotion = event.hasUAVisualTransition === true;
94
97
  },
95
98
  { capture: true },
96
99
  );
@@ -181,8 +184,10 @@ export function installSwupRouter(): Swup {
181
184
  left: window.scrollX,
182
185
  focus,
183
186
  });
184
- if (historyPositions.size > 64)
185
- historyPositions.delete(historyPositions.keys().next().value as number);
187
+ if (historyPositions.size > 64) {
188
+ const oldest = historyPositions.keys().next();
189
+ if (!oldest.done) historyPositions.delete(oldest.value);
190
+ }
186
191
  const from = new URL(visit.from.url + (visit.from.hash ?? ""), window.location.origin);
187
192
  const to = new URL(visit.to.url + (visit.to.hash ?? ""), window.location.origin);
188
193
  const navigationType = visit.history.popstate ? "traverse" : visit.history.action;
@@ -261,7 +266,7 @@ export function installSwupRouter(): Swup {
261
266
  // missing document on an aborted visit is not a failed navigation.
262
267
  if (context?.controller.signal.aborted || visit.done || visit !== swup.visit) return;
263
268
  const next = visit.to.document;
264
- if (!context || !next || !replace) throw new Error("Missing Swup document");
269
+ if (!(context && next && replace)) throw new Error("Missing Swup document");
265
270
  if (context.controller.signal.aborted) return;
266
271
  // Two cues overlap on the outgoing document: the content stage leaving,
267
272
  // and the page walking back to the top. Persisted sidebars, the header
@@ -283,6 +288,9 @@ export function installSwupRouter(): Swup {
283
288
  html.dataset.staluxVtPreset = context.preset;
284
289
  context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
285
290
  if (context.controller.signal.aborted) return;
291
+ // Remember which shells exist now: after the swap only those slots need
292
+ // refilling, because only those nodes came from the previous document.
293
+ markRetainedShells();
286
294
  await replace(visit, args);
287
295
  // The stage node survives the swap, so the filled exit effect still holds
288
296
  // the incoming document at zero opacity. It is released inside
@@ -306,7 +314,38 @@ export function installSwupRouter(): Swup {
306
314
  }
307
315
  html.dataset.staluxRoute = getRouteKind(context.to.pathname);
308
316
  visibleHistoryIndex = Number(window.history.state?.index ?? visibleHistoryIndex);
317
+ // Kept shells whose contents belong to the page (the article sidebars'
318
+ // lists) take their children from the incoming document now, before any
319
+ // page module mounts, so nothing needs to re-render or re-measure later.
320
+ hydrateShells(next);
309
321
  dispatchNavigation("astro:after-swap", context, next);
322
+ // The entry cue has to own the first painted frame of the new document.
323
+ // content:scroll is an await and a rAF away, and running the incoming
324
+ // scripts can block the main thread for hundreds of milliseconds: the
325
+ // reader would see the finished page for one or more frames and only
326
+ // then watch it jump to the entry offset and slide back, which reads as
327
+ // the page shaking. Put the scroll at its final position and start the
328
+ // cue in this same synchronous block, before the scripts run, so the
329
+ // compositor already owns the cue while the main thread is busy. A visit
330
+ // with a hash or a history traversal keeps the deferred path, because its
331
+ // scroll is not decided yet.
332
+ if (
333
+ // A history traversal restores a position that is only known later,
334
+ // and a hash target is decided by the anchor.
335
+ !visit.history.popstate &&
336
+ context.navigationType !== "traverse" &&
337
+ !context.to.hash &&
338
+ context.preset !== "none" &&
339
+ // ScrollPlugin owns visit.scroll; a bare visit has no reset flag.
340
+ visit.scroll?.reset !== false
341
+ ) {
342
+ const position = scroll.getCachedScrollPositions?.(visit.to.url)?.window;
343
+ const top = position?.top ?? 0;
344
+ const left = position?.left ?? 0;
345
+ if (window.scrollY !== top || window.scrollX !== left)
346
+ window.scrollTo({ top, left, behavior: "instant" });
347
+ startMotion(visit);
348
+ }
310
349
  context.scriptsReady = context.executeScripts?.(context.controller.signal);
311
350
  });
312
351
 
@@ -392,8 +431,11 @@ export function installSwupRouter(): Swup {
392
431
  }
393
432
  });
394
433
  swup.hooks.on("cache:set", () => {
395
- while (swup.cache.size > 32)
396
- swup.cache.delete(swup.cache.all.keys().next().value as string);
434
+ while (swup.cache.size > 32) {
435
+ const oldest = swup.cache.all.keys().next();
436
+ if (oldest.done) break;
437
+ swup.cache.delete(oldest.value);
438
+ }
397
439
  });
398
440
  // Parsed DOM, not window.load: slow third-party resources cannot hold the
399
441
  // first page's interactions hostage. Page-runtime dedupes the early mount.
@@ -10,7 +10,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
10
10
  registerPageLifecycle("waline", () => {
11
11
  const container = document.querySelector<HTMLElement>("#waline-container");
12
12
  const commentElement = document.querySelector<HTMLElement>("#waline-comment");
13
- if (!container || !commentElement) return;
13
+ if (!(container && commentElement)) return;
14
14
 
15
15
  let decoded: unknown;
16
16
  try {
@@ -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
  }