@xingwangzhe/stalux 1.46.2 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +4 -0
  2. package/package.json +3 -2
  3. package/src/components/stalux/archives/archivesList.astro +1 -1
  4. package/src/components/stalux/categories/categoriesList.astro +6 -1
  5. package/src/components/stalux/common/Weather.astro +1 -1
  6. package/src/components/stalux/common/date.astro +1 -1
  7. package/src/components/stalux/common/search.astro +2 -0
  8. package/src/components/stalux/footer/FooterBeian.astro +2 -0
  9. package/src/components/stalux/layout/head.astro +1 -1
  10. package/src/components/stalux/links/linkList.astro +1 -1
  11. package/src/components/stalux/posts/ArticleSummary.astro +8 -1
  12. package/src/components/stalux/posts/postContent.astro +20 -2
  13. package/src/components/stalux/posts/postList.astro +6 -1
  14. package/src/components/stalux/posts/postRight.astro +11 -4
  15. package/src/components/stalux/words/wordList.astro +11 -8
  16. package/src/index.ts +16 -4
  17. package/src/internal/cjk-search-index.ts +130 -0
  18. package/src/internal/compact-styles.ts +1 -1
  19. package/src/internal/font-slices.ts +3 -3
  20. package/src/internal/page-output-cache.ts +6 -7
  21. package/src/internal/search-style.ts +16 -0
  22. package/src/internal/social-card-renderer.ts +3 -5
  23. package/src/layouts/PostLayout.astro +2 -0
  24. package/src/pages/og/[image].[format].ts +1 -1
  25. package/src/pages/posts/[post].astro +1 -0
  26. package/src/schemas/collections.ts +1 -0
  27. package/src/schemas/config.ts +5 -5
  28. package/src/scripts/analytics-runtime.ts +1 -1
  29. package/src/scripts/archive-descriptions.ts +1 -1
  30. package/src/scripts/back-to-top.ts +7 -6
  31. package/src/scripts/background.ts +2 -2
  32. package/src/scripts/countdown.ts +1 -1
  33. package/src/scripts/date-time.ts +1 -1
  34. package/src/scripts/list-reveal.ts +39 -18
  35. package/src/scripts/module-motion.ts +5 -3
  36. package/src/scripts/navigation-font.ts +4 -1
  37. package/src/scripts/navigation.ts +1 -1
  38. package/src/scripts/page-runtime.ts +6 -4
  39. package/src/scripts/pagefind-api.ts +1 -1
  40. package/src/scripts/random-posts.ts +2 -2
  41. package/src/scripts/route-motion.ts +67 -56
  42. package/src/scripts/route-out.ts +12 -14
  43. package/src/scripts/runtime-counter.ts +1 -1
  44. package/src/scripts/search-engine-entry.ts +48 -0
  45. package/src/scripts/search-engine.ts +219 -0
  46. package/src/scripts/shell-hydration.ts +60 -0
  47. package/src/scripts/smooth-scroll.ts +4 -4
  48. package/src/scripts/swup-internals.d.ts +31 -0
  49. package/src/scripts/swup-router.ts +26 -11
  50. package/src/scripts/waline.ts +1 -1
  51. package/src/scripts/weather.ts +148 -82
  52. package/src/scripts/webmcp.ts +24 -20
  53. package/src/utils/ai-discovery.ts +1 -1
  54. package/src/utils/cc.ts +2 -2
  55. package/src/utils/cjk-search.ts +40 -0
  56. package/src/utils/collections-stats.ts +4 -2
  57. package/src/utils/dayjs.ts +2 -2
  58. package/src/utils/i18n.ts +2 -2
  59. package/src/utils/seo-document.ts +4 -4
  60. package/src/utils/taxonomy.ts +10 -12
@@ -0,0 +1,48 @@
1
+ import { sitePath } from "../utils/site-path";
2
+ import { createEnhancedSearch, type NativeSearch } from "./search-engine";
3
+
4
+ const base = import.meta.env.BASE_URL;
5
+ interface PagefindModule {
6
+ createInstance: (options: Record<string, unknown>) => NativeSearch;
7
+ }
8
+ const moduleUrl = sitePath("pagefind/pagefind.js", base);
9
+ let modulePromise: Promise<PagefindModule> | undefined;
10
+ function loadNative(): Promise<PagefindModule> {
11
+ if (!modulePromise) {
12
+ modulePromise = import(/* @vite-ignore */ moduleUrl);
13
+ void modulePromise?.catch(() => {
14
+ modulePromise = undefined;
15
+ });
16
+ }
17
+ return modulePromise as Promise<PagefindModule>;
18
+ }
19
+
20
+ /** Public Component UI bundle contract; no private instance fields or hooks are patched. */
21
+ export function createInstance(options: Record<string, unknown> = {}) {
22
+ let native: Promise<NativeSearch> | undefined;
23
+ const load = () => {
24
+ if (!native) {
25
+ native = loadNative().then((module) =>
26
+ module.createInstance({
27
+ ranking: { pageLength: 0.4, termFrequency: 0.8 },
28
+ ...options,
29
+ }),
30
+ );
31
+ void native.catch(() => {
32
+ native = undefined;
33
+ });
34
+ }
35
+ return native;
36
+ };
37
+ return createEnhancedSearch(load, base);
38
+ }
39
+ let instance: ReturnType<typeof createInstance> | undefined;
40
+ const shared = () => {
41
+ instance ??= createInstance();
42
+ return instance;
43
+ };
44
+ export const search = (...args: Parameters<NativeSearch["search"]>) => shared().search(...args);
45
+ export const options = (...args: Parameters<NativeSearch["options"]>) => shared().options(...args);
46
+ export const filters = () => shared().filters();
47
+ export const mergeIndex = (...args: Parameters<NativeSearch["mergeIndex"]>) =>
48
+ shared().mergeIndex(...args);
@@ -0,0 +1,219 @@
1
+ import {
2
+ CJK,
3
+ type CjkDocument,
4
+ type CjkManifest,
5
+ cjkGrams,
6
+ gramShard,
7
+ normalizeSearch,
8
+ } from "../utils/cjk-search";
9
+ import { sitePath } from "../utils/site-path";
10
+ import { createClientLogger } from "./logger";
11
+
12
+ export interface SearchData {
13
+ url: string;
14
+ meta?: Record<string, string>;
15
+ content?: string;
16
+ excerpt?: string;
17
+ plain_excerpt?: string;
18
+ filters?: Record<string, string[]>;
19
+ }
20
+ export interface SearchResult {
21
+ id: string;
22
+ data: () => Promise<SearchData>;
23
+ }
24
+ export interface SearchResponse {
25
+ results: SearchResult[];
26
+ filters?: Record<string, Record<string, number>>;
27
+ totalFilters?: Record<string, Record<string, number>>;
28
+ unfilteredTotalCount?: number;
29
+ }
30
+ export interface SearchOptions {
31
+ filters?: Record<string, string | string[]>;
32
+ }
33
+ export interface NativeSearch {
34
+ search: (query: string | null, options?: SearchOptions) => Promise<SearchResponse>;
35
+ options: (options: Record<string, unknown>) => Promise<void>;
36
+ filters: () => Promise<Record<string, Record<string, number>>>;
37
+ mergeIndex: (url: string, options?: Record<string, unknown>) => Promise<void>;
38
+ }
39
+ const logger = createClientLogger("search/cjk");
40
+ const escapeHtml = (text: string) =>
41
+ text.replace(
42
+ /[&<>"']/gu,
43
+ (char) =>
44
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char,
45
+ );
46
+
47
+ function excerpt(content: string, terms: string[]) {
48
+ const position = Math.max(0, normalizeSearch(content).indexOf(terms[0] ?? ""));
49
+ const start = Math.max(0, position - 45);
50
+ const plain = `${start ? "…" : ""}${content.slice(start, start + 180)}${content.length > start + 180 ? "…" : ""}`;
51
+ const pattern = terms
52
+ .map((term) => term.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
53
+ .sort((a, b) => b.length - a.length)
54
+ .join("|");
55
+ const regex = new RegExp(pattern, "giu");
56
+ let end = 0;
57
+ let highlighted = "";
58
+ for (const match of plain.matchAll(regex)) {
59
+ highlighted +=
60
+ escapeHtml(plain.slice(end, match.index)) + `<mark>${escapeHtml(match[0])}</mark>`;
61
+ end = match.index + match[0].length;
62
+ }
63
+ return { plain_excerpt: plain, excerpt: highlighted + escapeHtml(plain.slice(end)) };
64
+ }
65
+
66
+ export function createEnhancedSearch(
67
+ native: Promise<NativeSearch> | (() => Promise<NativeSearch>),
68
+ base = import.meta.env.BASE_URL,
69
+ request: typeof fetch = fetch,
70
+ language = () => document.documentElement.lang,
71
+ ) {
72
+ const getNative = () => (typeof native === "function" ? native() : native);
73
+ const directory = sitePath("pagefind/cjk/", base);
74
+ const cache = new Map<string, Promise<unknown>>();
75
+ function json<T>(file: string): Promise<T> {
76
+ let pending = cache.get(file);
77
+ if (!pending) {
78
+ pending = request(directory + file).then((response) => {
79
+ if (!response.ok) throw new Error(`CJK index HTTP ${response.status}`);
80
+ return response.json();
81
+ });
82
+ cache.set(file, pending);
83
+ void pending.catch(() => cache.delete(file));
84
+ }
85
+ return pending as Promise<T>;
86
+ }
87
+ const ids = new Map<string, Promise<string | undefined>>();
88
+ async function nativeId(api: NativeSearch, record: CjkDocument, original: SearchResult[]) {
89
+ const post = record.filters.post?.[0];
90
+ if (post) {
91
+ let pending = ids.get(record.url);
92
+ if (!pending) {
93
+ pending = api
94
+ .search(null, { filters: { post } })
95
+ .then((result) => result.results[0]?.id);
96
+ ids.set(record.url, pending);
97
+ void pending.catch(() => ids.delete(record.url));
98
+ }
99
+ return pending;
100
+ }
101
+ // Non-post pages are uncommon; resolve existing hits only to avoid duplicate URLs.
102
+ for (const result of original) {
103
+ if ((await result.data()).url.replace(/\/$/u, "") === record.url.replace(/\/$/u, ""))
104
+ return result.id;
105
+ }
106
+ }
107
+ async function search(query: string | null, options?: SearchOptions): Promise<SearchResponse> {
108
+ const api = await getNative();
109
+ if (query === null || !query.trim()) return api.search(query, options);
110
+ const normalized = normalizeSearch(query);
111
+ let original = await api.search(query, options);
112
+ // Keep Pagefind's quoted phrase semantics and compound filter semantics intact.
113
+ if (
114
+ /["“”]/u.test(query) ||
115
+ Object.values(options?.filters ?? {}).some((value) => value.length)
116
+ )
117
+ return original;
118
+ if (normalized !== query) {
119
+ const variant = await api.search(normalized, options);
120
+ const originalIds = new Set(original.results.map((result) => result.id));
121
+ const results = [
122
+ ...original.results,
123
+ ...variant.results.filter((result) => !originalIds.has(result.id)),
124
+ ];
125
+ original = { ...original, results, unfilteredTotalCount: results.length };
126
+ }
127
+ if (!CJK.test(normalized)) return original;
128
+ try {
129
+ const manifest = await json<CjkManifest>("manifest.json");
130
+ const grams = cjkGrams(normalized);
131
+ const shardKeys = [...new Set(grams.map(gramShard))];
132
+ const shards = new Map(
133
+ await Promise.all(
134
+ shardKeys.map(
135
+ async (key) =>
136
+ [
137
+ key,
138
+ manifest.shards[key]
139
+ ? await json<Record<string, number[]>>(
140
+ manifest.shards[key] as string,
141
+ )
142
+ : {},
143
+ ] as const,
144
+ ),
145
+ ),
146
+ );
147
+ const lists = grams
148
+ .map((gram) => shards.get(gramShard(gram))?.[gram] ?? [])
149
+ .sort((a, b) => a.length - b.length);
150
+ let candidates = new Set(lists[0] ?? []);
151
+ for (const list of lists.slice(1)) {
152
+ const allowed = new Set(list);
153
+ candidates = new Set([...candidates].filter((id) => allowed.has(id)));
154
+ }
155
+ const terms = normalized.split(" ").filter(Boolean);
156
+ const matches: Array<{ record: CjkDocument; content: string; score: number }> = [];
157
+ // Bound text downloads independently of result count. Text is fetched only for n-gram candidates.
158
+ const pending = [...candidates];
159
+ let cursor = 0;
160
+ await Promise.all(
161
+ Array.from({ length: Math.min(6, pending.length) }, async () => {
162
+ while (cursor < pending.length) {
163
+ const record = manifest.documents[pending[cursor++] as number];
164
+ if (!record || record.language.toLowerCase() !== language().toLowerCase())
165
+ continue;
166
+ const { content } = await json<{ content: string }>(record.file);
167
+ const normalizedContent = normalizeSearch(content);
168
+ if (!terms.every((term) => normalizedContent.includes(term))) continue;
169
+ const title = normalizeSearch(record.title);
170
+ const description = normalizeSearch(record.description);
171
+ const score =
172
+ title === normalized
173
+ ? 100
174
+ : title.includes(normalized)
175
+ ? 80
176
+ : terms.every((term) => title.includes(term))
177
+ ? 60
178
+ : terms.every((term) => description.includes(term))
179
+ ? 40
180
+ : 20;
181
+ matches.push({ record, content, score });
182
+ }
183
+ }),
184
+ );
185
+ matches.sort((a, b) => b.score - a.score || a.record.url.localeCompare(b.record.url));
186
+ const supplemental: SearchResult[] = [];
187
+ for (const { record, content } of matches) {
188
+ const id = await nativeId(api, record, original.results);
189
+ supplemental.push({
190
+ id: id ?? `cjk:${record.url}`,
191
+ data: () =>
192
+ Promise.resolve({
193
+ url: record.url,
194
+ meta: { title: record.title },
195
+ content,
196
+ filters: record.filters,
197
+ ...excerpt(content, terms),
198
+ }),
199
+ });
200
+ }
201
+ const seen = new Set(supplemental.map((result) => result.id));
202
+ const results = [
203
+ ...supplemental,
204
+ ...original.results.filter((result) => !seen.has(result.id)),
205
+ ];
206
+ return { ...original, results, unfilteredTotalCount: results.length };
207
+ } catch {
208
+ logger.warn("CJK substring index unavailable; using Pagefind");
209
+ return original;
210
+ }
211
+ }
212
+ return {
213
+ search,
214
+ options: async (options: Record<string, unknown>) => (await getNative()).options(options),
215
+ filters: async () => (await getNative()).filters(),
216
+ mergeIndex: async (url: string, options?: Record<string, unknown>) =>
217
+ (await getNative()).mergeIndex(url, options),
218
+ };
219
+ }
@@ -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,6 +314,10 @@ 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);
310
322
  // The entry cue has to own the first painted frame of the new document.
311
323
  // content:scroll is an await and a rAF away, and running the incoming
@@ -419,8 +431,11 @@ export function installSwupRouter(): Swup {
419
431
  }
420
432
  });
421
433
  swup.hooks.on("cache:set", () => {
422
- while (swup.cache.size > 32)
423
- 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
+ }
424
439
  });
425
440
  // Parsed DOM, not window.load: slow third-party resources cannot hold the
426
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 {