@xingwangzhe/stalux 1.52.1 → 1.54.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.52.1",
3
+ "version": "1.54.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -112,9 +112,15 @@
112
112
  "@microsoft/clarity": "1.0.2",
113
113
  "@pagefind/component-ui": "^1.6.0",
114
114
  "@swup/a11y-plugin": "^5.2.1",
115
+ "@swup/body-class-plugin": "^3.3.0",
116
+ "@swup/fragment-plugin": "^1.3.1",
115
117
  "@swup/head-plugin": "^2.3.1",
116
118
  "@swup/js-plugin": "^3.2.0",
119
+ "@swup/parallel-plugin": "^0.4.0",
117
120
  "@swup/preload-plugin": "^3.2.12",
121
+ "@swup/progress-plugin": "^3.2.0",
122
+ "@swup/route-name-plugin": "^4.1.0",
123
+ "@swup/scripts-plugin": "^2.1.0",
118
124
  "@swup/scroll-plugin": "^4.0.0",
119
125
  "@takumi-rs/core": "2.19.0",
120
126
  "@waline/client": "^3.16.2",
@@ -139,23 +145,24 @@
139
145
  },
140
146
  "devDependencies": {
141
147
  "@astrojs/check": "^0.9.12",
142
- "@biomejs/biome": "^2.6.4",
148
+ "@biomejs/biome": "^2.6.5",
149
+ "@swup/debug-plugin": "^4.1.0",
143
150
  "@types/bun": "1.4.4",
144
151
  "@types/css-tree": "^3.2.0",
145
152
  "@unocss/astro": "^66.12.0",
146
153
  "@vitest/coverage-v8": "^5.1.0",
147
- "astro": "^7.4.5",
154
+ "astro": "^7.4.7",
148
155
  "happy-dom": "^21.0.4",
149
156
  "knip": "^6.44.1",
150
157
  "sharp": "0.35.5",
151
158
  "sonda": "^0.14.0",
152
159
  "typescript": "npm:@typescript/typescript6@6.0.2",
153
160
  "unocss": "^66.12.0",
154
- "vite": "^8.3.5",
161
+ "vite": "^8.3.6",
155
162
  "vitest": "^5.1.0"
156
163
  },
157
164
  "peerDependencies": {
158
- "astro": "^7.4.5"
165
+ "astro": "^7.4.7"
159
166
  },
160
167
  "overrides": {
161
168
  "satteri": "0.11.6"
@@ -40,6 +40,7 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/u, "")}.md` :
40
40
  ---
41
41
 
42
42
  <article
43
+ id="swup-article"
43
44
  class="stalux-article"
44
45
  data-stalux-motion-stage
45
46
  data-stalux-gallery={props.hasImage ? "true" : undefined}
package/src/env.d.ts CHANGED
@@ -12,3 +12,8 @@ declare module "virtual:stalux-social-card-paths" {
12
12
  const paths: import("./internal/social-card-renderer").SocialCardPaths;
13
13
  export default paths;
14
14
  }
15
+
16
+ declare module "virtual:stalux-swup-debug" {
17
+ const Debug: (new () => import("swup").Plugin) | null;
18
+ export default Debug;
19
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { forEachBounded } from "./internal/bounded-tasks.ts";
3
3
  import { compactStylesPlugin } from "./internal/compact-styles.ts";
4
4
  import { modernCssTargets } from "./internal/modern-css.ts";
5
5
  import { styleNames } from "./internal/style-names.generated.ts";
6
+ import { swupDebugPlugin } from "./internal/swup-debug-plugin.ts";
6
7
  /**
7
8
  * Stalux — Astro 博客主题集成入口
8
9
  *
@@ -182,6 +183,11 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
182
183
  include: [
183
184
  "swup",
184
185
  "@swup/a11y-plugin",
186
+ "@swup/body-class-plugin",
187
+ "@swup/fragment-plugin",
188
+ "@swup/parallel-plugin",
189
+ "@swup/route-name-plugin",
190
+ "@swup/scripts-plugin",
185
191
  "@swup/head-plugin",
186
192
  "@swup/js-plugin",
187
193
  "@swup/preload-plugin",
@@ -216,6 +222,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
216
222
  ? `export default ${JSON.stringify(socialCardPaths)}`
217
223
  : undefined,
218
224
  },
225
+ swupDebugPlugin(command === "dev"),
219
226
  searchStylePlugin(command === "dev", config.base),
220
227
  webMcpMutationsPlugin(),
221
228
  ...(command === "build"
@@ -0,0 +1,25 @@
1
+ import { createRequire } from "node:module";
2
+ import type { Plugin } from "vite";
3
+
4
+ /** Keep a source-only devDependency optional in published consumer projects. */
5
+ export function swupDebugPlugin(development: boolean): Plugin {
6
+ const id = "virtual:stalux-swup-debug";
7
+ let entry: string | undefined;
8
+ if (development) {
9
+ try {
10
+ entry = createRequire(import.meta.url).resolve("@swup/debug-plugin");
11
+ } catch {
12
+ // Consumers can install @swup/debug-plugin as their own devDependency.
13
+ }
14
+ }
15
+ return {
16
+ name: "stalux:swup-debug",
17
+ resolveId: (source) => (source === id ? `\0${id}` : undefined),
18
+ load: (source) =>
19
+ source === `\0${id}`
20
+ ? entry
21
+ ? `export { default } from ${JSON.stringify(entry)};`
22
+ : "export default null;"
23
+ : undefined,
24
+ };
25
+ }
@@ -86,7 +86,7 @@ const routeKind = getRouteKind(Astro.url.pathname);
86
86
  </script>
87
87
  {aiConfig?.conformance !== "disabled" && <script>import "../scripts/webmcp.ts";</script>}
88
88
  <body
89
- class="stalux-root dark"
89
+ class={`stalux-root dark page-${routeKind}`}
90
90
  data-stalux-bg-layer="a"
91
91
  data-stalux-bg-index="0"
92
92
  data-stalux-site-title={siteConfig.title}
@@ -116,7 +116,7 @@ const routeKind = getRouteKind(Astro.url.pathname);
116
116
  aria-hidden="true"
117
117
  ></div>
118
118
  <div class="bg-layer bg-b" data-stalux-background="b" aria-hidden="true"></div>
119
- <main class="stalux-main" data-stalux-route={routeKind}>
119
+ <main id="swup-main" class="stalux-main" data-stalux-route={routeKind}>
120
120
  <slot />
121
121
  </main>
122
122
  <Footer />
@@ -32,6 +32,8 @@ const props = Astro.props as Props;
32
32
  count: props.count,
33
33
  })}
34
34
  >
35
- <TagHeader type={t("taxonomy.category")} name={props.category} count={props.count} />
36
- <PostList posts={props.posts} />
35
+ <div id="swup-taxonomy">
36
+ <TagHeader type={t("taxonomy.category")} name={props.category} count={props.count} />
37
+ <PostList posts={props.posts} />
38
+ </div>
37
39
  </Stalux>
@@ -34,6 +34,8 @@ const props = Astro.props as Props;
34
34
  })}
35
35
  noindex={props.count < 3}
36
36
  >
37
- <TagHeader type={t("taxonomy.tag")} name={props.tag} count={props.count} />
38
- <PostList posts={props.posts} />
37
+ <div id="swup-taxonomy">
38
+ <TagHeader type={t("taxonomy.tag")} name={props.tag} count={props.count} />
39
+ <PostList posts={props.posts} />
40
+ </div>
39
41
  </Stalux>
@@ -1,84 +1,10 @@
1
+ import SwupScriptsPlugin from "@swup/scripts-plugin";
1
2
  import { createClientLogger } from "./logger";
2
3
 
3
4
  const logger = createClientLogger("navigation-scripts");
4
5
  const executions = new Map<string, Promise<void>>();
5
- const executedNodes = new WeakSet<HTMLScriptElement>();
6
6
  let seeded = false;
7
-
8
- interface NavigationScript {
9
- attributes: { name: string; value: string }[];
10
- content: string;
11
- source?: string;
12
- type: string;
13
- key: string;
14
- rerun: boolean;
15
- head: boolean;
16
- original: HTMLScriptElement;
17
- }
18
-
19
- const JAVASCRIPT_TYPES = new Set([
20
- "",
21
- "module",
22
- "text/javascript",
23
- "application/javascript",
24
- "text/ecmascript",
25
- "application/ecmascript",
26
- ]);
27
-
28
- function describeScript(script: HTMLScriptElement, base: string): NavigationScript | undefined {
29
- const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
30
- if (!JAVASCRIPT_TYPES.has(type) || script.hasAttribute("data-swup-ignore-script")) return;
31
- if (script.hasAttribute("nomodule") && "noModule" in document.createElement("script")) return;
32
- const rawSource = script.getAttribute("src");
33
- let source: string | undefined;
34
- if (rawSource !== null) {
35
- try {
36
- source = new URL(rawSource, base).href;
37
- } catch {
38
- return;
39
- }
40
- }
41
- const content = script.textContent ?? "";
42
- return {
43
- attributes: [...script.attributes].map(({ name, value }) => ({ name, value })),
44
- content,
45
- source,
46
- type,
47
- key: source ? `src:${source}` : `inline:${type}:${content}`,
48
- rerun: script.hasAttribute("data-astro-rerun"),
49
- head: script.parentElement?.tagName === "HEAD",
50
- original: script,
51
- };
52
- }
53
-
54
- function seedCurrentScripts(): void {
55
- if (seeded) return;
56
- seeded = true;
57
- for (const script of document.querySelectorAll<HTMLScriptElement>("script")) {
58
- const descriptor = describeScript(script, document.baseURI);
59
- if (descriptor) {
60
- executions.set(descriptor.key, Promise.resolve());
61
- executedNodes.add(script);
62
- }
63
- }
64
- }
65
-
66
- function waitForScript(script: HTMLScriptElement, timeoutMs: number): Promise<boolean> {
67
- return new Promise((resolve) => {
68
- let timer: ReturnType<typeof setTimeout>;
69
- const finish = (loaded: boolean) => {
70
- clearTimeout(timer);
71
- script.removeEventListener("load", loadedHandler);
72
- script.removeEventListener("error", failedHandler);
73
- resolve(loaded);
74
- };
75
- const loadedHandler = () => finish(true);
76
- const failedHandler = () => finish(false);
77
- script.addEventListener("load", loadedHandler, { once: true });
78
- script.addEventListener("error", failedHandler, { once: true });
79
- timer = setTimeout(failedHandler, timeoutMs);
80
- });
81
- }
7
+ const executedNodes = new WeakSet<HTMLScriptElement>();
82
8
 
83
9
  function abortable(promise: Promise<void>, signal?: AbortSignal): Promise<void> {
84
10
  if (!signal) return promise;
@@ -92,100 +18,141 @@ function abortable(promise: Promise<void>, signal?: AbortSignal): Promise<void>
92
18
  void promise.then(finish, finish);
93
19
  });
94
20
  }
21
+ const javascript = new Set([
22
+ "",
23
+ "module",
24
+ "text/javascript",
25
+ "application/javascript",
26
+ "text/ecmascript",
27
+ "application/ecmascript",
28
+ ]);
95
29
 
96
- function executeScript(descriptor: NavigationScript, timeoutMs: number): Promise<void> {
97
- // HeadPlugin clones incoming head nodes before container replacement. Those
98
- // scripts are inert, but are different nodes from the parsed document we
99
- // captured. Replace that clone rather than appending a second head script.
100
- const inertCopies = [...document.querySelectorAll<HTMLScriptElement>("script")].filter(
101
- (candidate) =>
102
- !executedNodes.has(candidate) &&
103
- describeScript(candidate, document.baseURI)?.key === descriptor.key,
104
- );
105
- const existing = executions.get(descriptor.key);
106
- if (existing && !descriptor.rerun) {
107
- for (const copy of inertCopies) copy.remove();
108
- return existing;
109
- }
110
-
111
- const script = document.createElement("script");
112
- // Dynamic scripts are async by default. Preserve document order, including
113
- // the module barrier used below, before copying the page's attributes.
114
- script.async = false;
115
- for (const { name, value } of descriptor.attributes) {
116
- if (name !== "src" && name !== "data-astro-exec") script.setAttribute(name, value);
117
- }
118
- if (descriptor.source) script.setAttribute("src", descriptor.source);
119
- script.textContent = descriptor.content;
120
- script.setAttribute("data-astro-exec", "");
121
- executedNodes.add(script);
122
-
123
- let observed = script;
124
- if (!descriptor.source && descriptor.type === "module") {
125
- // Inline module load events are not portable. Astro uses the same
126
- // ordered external module barrier to wait for deferred inline modules.
127
- observed = document.createElement("script");
128
- observed.type = "module";
129
- observed.async = false;
130
- observed.src = "data:application/javascript,";
131
- observed.setAttribute("data-swup-ignore-script", "");
132
- if (script.nonce) observed.nonce = script.nonce;
133
- }
134
- const loaded =
135
- descriptor.source || descriptor.type === "module"
136
- ? waitForScript(observed, timeoutMs)
137
- : Promise.resolve(true);
138
- const execution = loaded.then((success) => {
139
- if (observed !== script) observed.remove();
140
- if (!success) {
141
- // Failed fetches may succeed on a subsequent visit. Do not turn a
142
- // transient offline response into a session-long disabled feature.
143
- if (executions.get(descriptor.key) === execution) executions.delete(descriptor.key);
144
- script.remove();
145
- logger.warn(`page script failed to load: ${descriptor.source ?? "inline module"}`);
146
- }
147
- });
148
- executions.set(descriptor.key, execution);
30
+ /**
31
+ * Scripts Plugin owns script recreation. Astro's page-load event additionally
32
+ * needs an ordered completion barrier and once-per-session module semantics.
33
+ * Disable the plugin's automatic whole-document pass: that would also replay
34
+ * analytics SDKs and scripts in retained shells on every navigation.
35
+ */
36
+ export const navigationScripts = new SwupScriptsPlugin({ head: false, body: false });
149
37
 
150
- const parent = descriptor.head ? document.head : document.body;
151
- const original =
152
- descriptor.original.isConnected && descriptor.original.ownerDocument === document
153
- ? descriptor.original
154
- : inertCopies[0];
155
- if (original) {
156
- original.replaceWith(script);
157
- } else {
158
- parent.append(script);
159
- }
160
- for (const copy of inertCopies) if (copy !== original) copy.remove();
161
- if (observed !== script) parent.append(observed);
162
- return execution;
38
+ function describeScript(script: HTMLScriptElement, base: string) {
39
+ const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
40
+ if (!javascript.has(type) || script.hasAttribute("data-swup-ignore-script")) return;
41
+ if (script.hasAttribute("nomodule")) return;
42
+ const source = script.getAttribute("src");
43
+ const src = source === null ? undefined : new URL(source, base).href;
44
+ return {
45
+ script,
46
+ src,
47
+ type,
48
+ key: src ? `src:${src}` : `inline:${type}:${script.textContent ?? ""}`,
49
+ rerun:
50
+ script.hasAttribute("data-astro-rerun") ||
51
+ script.hasAttribute("data-swup-reload-script"),
52
+ head: script.parentElement?.tagName === "HEAD",
53
+ };
163
54
  }
164
55
 
165
- /**
166
- * Capture both head and body before Swup replaces the document's containers.
167
- * Call the returned function after replacement, before astro:page-load. Bundled
168
- * modules and ordinary inline scripts retain Astro's once-per-session behavior;
169
- * data-astro-rerun opts a script into every visit. SDK scripts already present in
170
- * the first document are never reinserted merely because head metadata changed.
171
- */
56
+ /** Capture before Parallel moves incoming containers out of the parsed document. */
172
57
  export function prepareNavigationScripts(
173
- nextDocument: Document,
58
+ next: Document,
174
59
  options: { timeoutMs?: number; baseUrl?: string } = {},
175
- ): (signal?: AbortSignal) => Promise<void> {
176
- seedCurrentScripts();
177
- const base = options.baseUrl ?? nextDocument.baseURI ?? document.baseURI;
178
- const scripts = [...nextDocument.querySelectorAll<HTMLScriptElement>("script")]
179
- .map((script) => describeScript(script, base))
180
- .filter((script): script is NavigationScript => script !== undefined);
181
- let executed: Promise<void> | undefined;
182
- return (signal) => {
183
- executed ??= (async () => {
184
- for (const script of scripts) {
60
+ ) {
61
+ if (!seeded) {
62
+ seeded = true;
63
+ for (const script of document.querySelectorAll<HTMLScriptElement>("script")) {
64
+ const item = describeScript(script, document.baseURI);
65
+ if (item) {
66
+ executions.set(item.key, Promise.resolve());
67
+ executedNodes.add(script);
68
+ }
69
+ }
70
+ }
71
+ const scripts = [...next.querySelectorAll<HTMLScriptElement>("script")]
72
+ .map((script) =>
73
+ describeScript(script, options.baseUrl ?? next.baseURI ?? document.baseURI),
74
+ )
75
+ .filter((item) => item !== undefined)
76
+ .map((item) => ({
77
+ ...item,
78
+ original: item.script,
79
+ script: item.script.cloneNode(true) as HTMLScriptElement,
80
+ }));
81
+ let execution: Promise<void> | undefined;
82
+ return (signal?: AbortSignal): Promise<void> => {
83
+ execution ??= (async () => {
84
+ for (const item of scripts) {
185
85
  if (signal?.aborted) return;
186
- await abortable(executeScript(script, options.timeoutMs ?? 15_000), signal);
86
+ const existing = executions.get(item.key);
87
+ if (existing && !item.rerun) {
88
+ for (const copy of document.querySelectorAll<HTMLScriptElement>("script")) {
89
+ if (
90
+ !executedNodes.has(copy) &&
91
+ describeScript(copy, document.baseURI)?.key === item.key
92
+ )
93
+ copy.remove();
94
+ }
95
+ await abortable(existing, signal);
96
+ continue;
97
+ }
98
+ const inert = item.script.cloneNode(true) as HTMLScriptElement;
99
+ if (item.src) inert.setAttribute("src", item.src);
100
+ inert.async = false;
101
+ inert.setAttribute("data-astro-exec", "");
102
+ // Reuse HeadPlugin's inert clone, or the inserted body script.
103
+ // Scripts outside replaced fragments are staged only if new.
104
+ const copy =
105
+ (item.original.isConnected && item.original.ownerDocument === document
106
+ ? item.original
107
+ : undefined) ??
108
+ [...document.querySelectorAll<HTMLScriptElement>("script")].find(
109
+ (script) =>
110
+ !executedNodes.has(script) &&
111
+ describeScript(script, document.baseURI)?.key === item.key,
112
+ );
113
+ if (copy) copy.replaceWith(inert);
114
+ else (item.head ? document.head : document.body).append(inert);
115
+ const script = navigationScripts.runScript(inert);
116
+ script.async = false;
117
+ executedNodes.add(script);
118
+ let observed = script;
119
+ if (!item.src && item.type === "module") {
120
+ observed = document.createElement("script");
121
+ observed.type = "module";
122
+ observed.async = false;
123
+ observed.src = "data:application/javascript,";
124
+ observed.setAttribute("data-swup-ignore-script", "");
125
+ if (script.nonce) observed.nonce = script.nonce;
126
+ document.body.append(observed);
127
+ }
128
+ const ready =
129
+ item.src || item.type === "module"
130
+ ? new Promise<void>((resolve) => {
131
+ const finish = (failed = false) => {
132
+ clearTimeout(timer);
133
+ observed.removeEventListener("load", loaded);
134
+ observed.removeEventListener("error", failedHandler);
135
+ if (observed !== script) observed.remove();
136
+ if (failed) {
137
+ executions.delete(item.key);
138
+ script.remove();
139
+ logger.warn(
140
+ `page script failed to load: ${item.src ?? "inline module"}`,
141
+ );
142
+ }
143
+ resolve();
144
+ };
145
+ const loaded = () => finish();
146
+ const failedHandler = () => finish(true);
147
+ const timer = setTimeout(failedHandler, options.timeoutMs ?? 15_000);
148
+ observed.addEventListener("load", loaded, { once: true });
149
+ observed.addEventListener("error", failedHandler, { once: true });
150
+ })
151
+ : Promise.resolve();
152
+ executions.set(item.key, ready);
153
+ await abortable(ready, signal);
187
154
  }
188
155
  })();
189
- return executed;
156
+ return execution;
190
157
  };
191
158
  }
@@ -1,7 +1,12 @@
1
1
  import SwupA11yPlugin from "@swup/a11y-plugin";
2
+ import SwupBodyClassPlugin from "@swup/body-class-plugin";
3
+ import SwupFragmentPlugin from "@swup/fragment-plugin";
2
4
  import SwupHeadPlugin from "@swup/head-plugin";
3
5
  import SwupJsPlugin from "@swup/js-plugin";
6
+ import SwupParallelPlugin from "@swup/parallel-plugin";
4
7
  import SwupPreloadPlugin from "@swup/preload-plugin";
8
+ import SwupProgressPlugin from "@swup/progress-plugin";
9
+ import SwupRouteNamePlugin from "@swup/route-name-plugin";
5
10
  import SwupScrollPlugin from "@swup/scroll-plugin";
6
11
  import Swup, { type Visit } from "swup";
7
12
  import { getRouteKind } from "../utils/public-routes";
@@ -9,7 +14,7 @@ import { getRouteMotionPreset, type RouteMotionPreset } from "../utils/route-mot
9
14
  import { installBackToTop } from "./back-to-top";
10
15
  import { prepareNavigationFont } from "./navigation-font";
11
16
  import "./navigation-client";
12
- import { prepareNavigationScripts } from "./navigation-scripts";
17
+ import { navigationScripts, prepareNavigationScripts } from "./navigation-scripts";
13
18
  import { prepareRouteMotion, type RouteMotion } from "./route-motion";
14
19
  import { animateRouteOut } from "./route-out";
15
20
  import { hydrateShells, markRetainedShells } from "./shell-hydration";
@@ -27,13 +32,16 @@ interface NavigationContext {
27
32
  motion?: RouteMotion;
28
33
  executeScripts?: (signal?: AbortSignal) => Promise<void>;
29
34
  scriptsReady?: Promise<void>;
35
+ outgoingReady?: Promise<void>;
36
+ incomingDocument?: Document;
37
+ removeOutgoing?: () => void;
30
38
  obsoleteStyles: Element[];
31
39
  glide?: PageGlide;
32
40
  releaseOutgoing?: () => void;
33
41
  }
34
42
 
35
43
  type Position = { top: number; left: number; focus?: string };
36
- const MAIN = "main[data-stalux-route]";
44
+ const MAIN = "#swup-main";
37
45
 
38
46
  function abortObsoleteVisit(visit: Visit): void {
39
47
  // `abort()` is @internal in swup 4.10.0 and stripped from the published
@@ -82,6 +90,7 @@ export function installSwupRouter(): Swup {
82
90
  let activeContext: NavigationContext | undefined;
83
91
  let visibleHistoryIndex = Number(window.history.state?.index ?? 1);
84
92
  let browserMotion = false;
93
+ const progress = new SwupProgressPlugin();
85
94
  const scroll = new SwupScrollPlugin({
86
95
  doScrollingRightAway: false,
87
96
  animateScroll: { betweenPages: false, samePageWithHash: true, samePage: true },
@@ -117,6 +126,42 @@ export function installSwupRouter(): Swup {
117
126
  );
118
127
  },
119
128
  plugins: [
129
+ navigationScripts,
130
+ new SwupBodyClassPlugin({
131
+ prefix: "page-",
132
+ attributes: [/^data-stalux-(?!bg-)/u],
133
+ }),
134
+ new SwupRouteNamePlugin({
135
+ routes: [
136
+ { name: "home", path: "/" },
137
+ { name: "article", path: "/posts/:post" },
138
+ { name: "archive", path: "/archives" },
139
+ { name: "tags-index", path: "/tags" },
140
+ { name: "tags-detail", path: "/tags/:tag" },
141
+ { name: "categories-index", path: "/categories" },
142
+ { name: "categories-detail", path: "/categories/:category" },
143
+ ...["about", "links", "words"].map((name) => ({ name, path: "/" + name })),
144
+ { name: "not-found", path: "/404" },
145
+ ],
146
+ }),
147
+ new SwupFragmentPlugin({
148
+ rules: [
149
+ {
150
+ from: ["/tags/:slug", "/categories/:slug"],
151
+ to: ["/tags/:slug", "/categories/:slug"],
152
+ containers: ["#swup-taxonomy"],
153
+ scroll: true,
154
+ },
155
+ {
156
+ from: "/posts/:post",
157
+ to: "/posts/:post",
158
+ containers: ["#swup-article"],
159
+ scroll: true,
160
+ },
161
+ ],
162
+ }),
163
+ new SwupParallelPlugin({ containers: [MAIN, "#swup-article", "#swup-taxonomy"] }),
164
+ progress,
120
165
  new SwupHeadPlugin({
121
166
  awaitAssets: true,
122
167
  // Keep old CSS readable until replacement, then prune authored
@@ -151,13 +196,18 @@ export function installSwupRouter(): Swup {
151
196
  releaseOutgoing: context.releaseOutgoing,
152
197
  },
153
198
  );
154
- await context.motion.finished;
199
+ await Promise.all([context.motion.finished, context.outgoingReady]);
155
200
  },
156
201
  },
157
202
  ],
158
203
  }),
159
204
  ],
160
205
  });
206
+ if (import.meta.env.DEV) {
207
+ void import("virtual:stalux-swup-debug").then(({ default: Debug }) => {
208
+ if (Debug) swup.use(new Debug());
209
+ });
210
+ }
161
211
  window.swup = swup;
162
212
  document.documentElement.dataset.staluxRouter = "swup";
163
213
  document.documentElement.dataset.staluxRoute = getRouteKind(window.location.pathname);
@@ -219,6 +269,7 @@ export function installSwupRouter(): Swup {
219
269
  contexts.set(visit, context);
220
270
  activeContext = context;
221
271
  visit.animation.wait = true;
272
+ if (context.preset === "none" || visit.history.popstate) visit.animation.parallel = false;
222
273
  visit.a11y.focus = visit.history.popstate ? false : { selector: MAIN, wait: true };
223
274
  if (dispatchNavigation("astro:before-preparation", context).defaultPrevented)
224
275
  visit.ignore();
@@ -232,6 +283,52 @@ export function installSwupRouter(): Swup {
232
283
  abortObsoleteVisit(visit);
233
284
  });
234
285
 
286
+ swup.hooks.before("content:insert", (visit, { containers }) => {
287
+ const context = contexts.get(visit);
288
+ if (!context) return;
289
+ context.motion = prepareRouteMotion(
290
+ dispatchNavigation("astro:before-swap", context, context.incomingDocument),
291
+ );
292
+ markRetainedShells();
293
+ const animations: Animation[] = [];
294
+ for (const { previous } of containers) {
295
+ const rect = previous.getBoundingClientRect();
296
+ previous.inert = true;
297
+ // The incoming document owns IDs and focus as soon as it is inserted.
298
+ // Persisted shells are moved by Swup and retain their original IDs.
299
+ for (const element of [previous, ...previous.querySelectorAll("[id]")]) {
300
+ if (!element.closest("[data-swup-persist]")) element.removeAttribute("id");
301
+ }
302
+ Object.assign(previous.style, {
303
+ position: "fixed",
304
+ top: rect.top + "px",
305
+ left: rect.left + "px",
306
+ width: rect.width + "px",
307
+ height: rect.height + "px",
308
+ // Clip the visible slice without truncating a deeply scrolled article.
309
+ clipPath: `inset(${Math.max(0, -rect.top)}px 0 ${Math.max(0, rect.bottom - window.innerHeight)}px 0)`,
310
+ overflow: "hidden",
311
+ pointerEvents: "none",
312
+ zIndex: "1",
313
+ });
314
+ animations.push(
315
+ previous.animate(
316
+ { opacity: [1, 0], transform: ["translateY(0)", "translateY(-12px)"] },
317
+ { duration: 160, easing: "ease-out", fill: "forwards" },
318
+ ),
319
+ );
320
+ }
321
+ const remove = () => {
322
+ for (const animation of animations) animation.cancel();
323
+ for (const { previous } of containers) previous.remove();
324
+ };
325
+ context.removeOutgoing = once(remove);
326
+ context.controller.signal.addEventListener("abort", context.removeOutgoing, { once: true });
327
+ context.outgoingReady = Promise.all(
328
+ animations.map((animation) => animation.finished.catch(() => {})),
329
+ ).then(() => context.removeOutgoing?.());
330
+ });
331
+
235
332
  swup.hooks.before(
236
333
  "content:replace",
237
334
  async (visit) => {
@@ -250,6 +347,7 @@ export function installSwupRouter(): Swup {
250
347
  abortObsoleteVisit(visit);
251
348
  return;
252
349
  }
350
+ context.incomingDocument = next.cloneNode(true) as Document;
253
351
  const incoming = [...next.head.querySelectorAll("style,link[rel=stylesheet]")];
254
352
  context.obsoleteStyles = authoredStyles.filter(
255
353
  (el) => !incoming.some((other) => el.isEqualNode(other)),
@@ -273,7 +371,11 @@ export function installSwupRouter(): Swup {
273
371
  // Two cues overlap on the outgoing document: the content stage leaving,
274
372
  // and the page walking back to the top. Persisted sidebars, the header
275
373
  // and the frame are never part of either one.
276
- if (context.navigationType !== "traverse" && context.preset !== "none") {
374
+ if (
375
+ !visit.animation.parallel &&
376
+ context.navigationType !== "traverse" &&
377
+ context.preset !== "none"
378
+ ) {
277
379
  const outgoing = animateRouteOut(context.preset, context.controller.signal);
278
380
  if (!context.to.hash) context.glide = glideToTop();
279
381
  await context.glide?.promise;
@@ -288,8 +390,12 @@ export function installSwupRouter(): Swup {
288
390
  html.dataset.staluxVtTo = getRouteKind(context.to.pathname);
289
391
  html.dataset.staluxVtDirection = context.direction;
290
392
  html.dataset.staluxVtPreset = context.preset;
291
- context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
292
393
  if (context.controller.signal.aborted) return;
394
+ if (!visit.animation.parallel) {
395
+ context.motion = prepareRouteMotion(
396
+ dispatchNavigation("astro:before-swap", context, next),
397
+ );
398
+ }
293
399
  // Remember which shells exist now: after the swap only those slots need
294
400
  // refilling, because only those nodes came from the previous document.
295
401
  markRetainedShells();
@@ -306,20 +412,13 @@ export function installSwupRouter(): Swup {
306
412
  );
307
413
  // Main alone is replaced: header/footer/sentinel and background layers
308
414
  // keep their node identities. Never reset live background state to SSR 0.
309
- for (const { name, value } of [...next.body.attributes]) {
310
- if (
311
- name.startsWith("data-stalux-") &&
312
- name !== "data-stalux-bg-index" &&
313
- name !== "data-stalux-bg-layer"
314
- )
315
- document.body.setAttribute(name, value);
316
- }
317
- html.dataset.staluxRoute = getRouteKind(context.to.pathname);
415
+ html.dataset.staluxRoute = visit.to.route ?? getRouteKind(context.to.pathname);
416
+ document.querySelector(MAIN)?.setAttribute("data-stalux-route", html.dataset.staluxRoute);
318
417
  visibleHistoryIndex = Number(window.history.state?.index ?? visibleHistoryIndex);
319
418
  // Kept shells whose contents belong to the page (the article sidebars'
320
419
  // lists) take their children from the incoming document now, before any
321
420
  // page module mounts, so nothing needs to re-render or re-measure later.
322
- hydrateShells(next);
421
+ hydrateShells(context.incomingDocument ?? next);
323
422
  dispatchNavigation("astro:after-swap", context, next);
324
423
  // The entry cue has to own the first painted frame of the new document.
325
424
  // content:scroll is an await and a rAF away, and running the incoming
@@ -375,7 +474,8 @@ export function installSwupRouter(): Swup {
375
474
  }
376
475
  await defaultScroll?.(visit, args);
377
476
  // ScrollPlugin scrolls in rAF; finish it before heading visibility checks.
378
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
477
+ if (!document.hidden)
478
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
379
479
  startMotion(visit);
380
480
  return true;
381
481
  },
@@ -407,8 +507,12 @@ export function installSwupRouter(): Swup {
407
507
  swup.hooks.replace("link:self", () => undefined);
408
508
 
409
509
  const cancel = (visit: Visit) => {
510
+ // A cancelled/failed visit never reaches page:view. Clear its delayed
511
+ // indicator without allowing a stale visit to hide a newer one.
512
+ if (visit === swup.visit) progress.stopShowingProgress();
410
513
  const context = contexts.get(visit);
411
514
  context?.controller.abort();
515
+ context?.removeOutgoing?.();
412
516
  context?.glide?.cancel();
413
517
  // An aborted visit keeps no exit fill: without this the stage would stay
414
518
  // transparent until the next successful navigation.
@@ -172,3 +172,16 @@ pagefind-modal dialog::backdrop {
172
172
  animation: none !important;
173
173
  }
174
174
  }
175
+
176
+ /* The official plugin prepends its defaults; keep theme color authoritative. */
177
+ html .swup-progress-bar {
178
+ height: 3px;
179
+ background-color: var(--stalux-accent-color);
180
+ pointer-events: none;
181
+ }
182
+
183
+ @media (prefers-reduced-motion: reduce) {
184
+ html .swup-progress-bar {
185
+ transition: none;
186
+ }
187
+ }