@xingwangzhe/stalux 1.52.2 → 1.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.52.2",
3
+ "version": "1.54.1",
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",
@@ -140,6 +146,7 @@
140
146
  "devDependencies": {
141
147
  "@astrojs/check": "^0.9.12",
142
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",
@@ -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,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, resolve } from "node:path";
4
+ import type { Plugin } from "vite";
5
+
6
+ /** Keep a source-only devDependency optional in published consumer projects. */
7
+ export function swupDebugPlugin(development: boolean): Plugin {
8
+ const id = "virtual:stalux-swup-debug";
9
+ let entry: string | undefined;
10
+ if (development) {
11
+ try {
12
+ // require.resolve honours the "require" condition and returns the CJS
13
+ // build. Re-exporting that from a virtual ESM module makes the browser
14
+ // fail with "does not provide an export named 'default'". Pick the
15
+ // package's ESM entry from its manifest instead.
16
+ const cjs = createRequire(import.meta.url).resolve("@swup/debug-plugin");
17
+ const manifest = resolve(dirname(cjs), "../package.json");
18
+ const pkg = JSON.parse(readFileSync(manifest, "utf8")) as {
19
+ exports?: { "."?: { import?: string } };
20
+ module?: string;
21
+ };
22
+ const esm = pkg.exports?.["."]?.import ?? pkg.module;
23
+ if (esm) entry = resolve(dirname(manifest), esm);
24
+ } catch {
25
+ // Consumers can install @swup/debug-plugin as their own devDependency.
26
+ }
27
+ }
28
+ return {
29
+ name: "stalux:swup-debug",
30
+ resolveId: (source) => (source === id ? `\0${id}` : undefined),
31
+ load: (source) =>
32
+ source === `\0${id}`
33
+ ? entry
34
+ ? `export { default } from ${JSON.stringify(entry)};`
35
+ : "export default null;"
36
+ : undefined,
37
+ };
38
+ }
@@ -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,17 @@ 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[];
39
+ pruneObsoleteStyles?: () => void;
31
40
  glide?: PageGlide;
32
41
  releaseOutgoing?: () => void;
33
42
  }
34
43
 
35
44
  type Position = { top: number; left: number; focus?: string };
36
- const MAIN = "main[data-stalux-route]";
45
+ const MAIN = "#swup-main";
37
46
 
38
47
  function abortObsoleteVisit(visit: Visit): void {
39
48
  // `abort()` is @internal in swup 4.10.0 and stripped from the published
@@ -82,6 +91,7 @@ export function installSwupRouter(): Swup {
82
91
  let activeContext: NavigationContext | undefined;
83
92
  let visibleHistoryIndex = Number(window.history.state?.index ?? 1);
84
93
  let browserMotion = false;
94
+ const progress = new SwupProgressPlugin();
85
95
  const scroll = new SwupScrollPlugin({
86
96
  doScrollingRightAway: false,
87
97
  animateScroll: { betweenPages: false, samePageWithHash: true, samePage: true },
@@ -117,6 +127,42 @@ export function installSwupRouter(): Swup {
117
127
  );
118
128
  },
119
129
  plugins: [
130
+ navigationScripts,
131
+ new SwupBodyClassPlugin({
132
+ prefix: "page-",
133
+ attributes: [/^data-stalux-(?!bg-)/u],
134
+ }),
135
+ new SwupRouteNamePlugin({
136
+ routes: [
137
+ { name: "home", path: "/" },
138
+ { name: "article", path: "/posts/:post" },
139
+ { name: "archive", path: "/archives" },
140
+ { name: "tags-index", path: "/tags" },
141
+ { name: "tags-detail", path: "/tags/:tag" },
142
+ { name: "categories-index", path: "/categories" },
143
+ { name: "categories-detail", path: "/categories/:category" },
144
+ ...["about", "links", "words"].map((name) => ({ name, path: "/" + name })),
145
+ { name: "not-found", path: "/404" },
146
+ ],
147
+ }),
148
+ new SwupFragmentPlugin({
149
+ rules: [
150
+ {
151
+ from: ["/tags/:slug", "/categories/:slug"],
152
+ to: ["/tags/:slug", "/categories/:slug"],
153
+ containers: ["#swup-taxonomy"],
154
+ scroll: true,
155
+ },
156
+ {
157
+ from: "/posts/:post",
158
+ to: "/posts/:post",
159
+ containers: ["#swup-article"],
160
+ scroll: true,
161
+ },
162
+ ],
163
+ }),
164
+ new SwupParallelPlugin({ containers: [MAIN, "#swup-article", "#swup-taxonomy"] }),
165
+ progress,
120
166
  new SwupHeadPlugin({
121
167
  awaitAssets: true,
122
168
  // Keep old CSS readable until replacement, then prune authored
@@ -151,13 +197,18 @@ export function installSwupRouter(): Swup {
151
197
  releaseOutgoing: context.releaseOutgoing,
152
198
  },
153
199
  );
154
- await context.motion.finished;
200
+ await Promise.all([context.motion.finished, context.outgoingReady]);
155
201
  },
156
202
  },
157
203
  ],
158
204
  }),
159
205
  ],
160
206
  });
207
+ if (import.meta.env.DEV) {
208
+ void import("virtual:stalux-swup-debug").then(({ default: Debug }) => {
209
+ if (Debug) swup.use(new Debug());
210
+ });
211
+ }
161
212
  window.swup = swup;
162
213
  document.documentElement.dataset.staluxRouter = "swup";
163
214
  document.documentElement.dataset.staluxRoute = getRouteKind(window.location.pathname);
@@ -219,6 +270,7 @@ export function installSwupRouter(): Swup {
219
270
  contexts.set(visit, context);
220
271
  activeContext = context;
221
272
  visit.animation.wait = true;
273
+ if (context.preset === "none" || visit.history.popstate) visit.animation.parallel = false;
222
274
  visit.a11y.focus = visit.history.popstate ? false : { selector: MAIN, wait: true };
223
275
  if (dispatchNavigation("astro:before-preparation", context).defaultPrevented)
224
276
  visit.ignore();
@@ -232,6 +284,52 @@ export function installSwupRouter(): Swup {
232
284
  abortObsoleteVisit(visit);
233
285
  });
234
286
 
287
+ swup.hooks.before("content:insert", (visit, { containers }) => {
288
+ const context = contexts.get(visit);
289
+ if (!context) return;
290
+ context.motion = prepareRouteMotion(
291
+ dispatchNavigation("astro:before-swap", context, context.incomingDocument),
292
+ );
293
+ markRetainedShells();
294
+ const animations: Animation[] = [];
295
+ for (const { previous } of containers) {
296
+ const rect = previous.getBoundingClientRect();
297
+ previous.inert = true;
298
+ // The incoming document owns IDs and focus as soon as it is inserted.
299
+ // Persisted shells are moved by Swup and retain their original IDs.
300
+ for (const element of [previous, ...previous.querySelectorAll("[id]")]) {
301
+ if (!element.closest("[data-swup-persist]")) element.removeAttribute("id");
302
+ }
303
+ Object.assign(previous.style, {
304
+ position: "fixed",
305
+ top: rect.top + "px",
306
+ left: rect.left + "px",
307
+ width: rect.width + "px",
308
+ height: rect.height + "px",
309
+ // Clip the visible slice without truncating a deeply scrolled article.
310
+ clipPath: `inset(${Math.max(0, -rect.top)}px 0 ${Math.max(0, rect.bottom - window.innerHeight)}px 0)`,
311
+ overflow: "hidden",
312
+ pointerEvents: "none",
313
+ zIndex: "1",
314
+ });
315
+ animations.push(
316
+ previous.animate(
317
+ { opacity: [1, 0], transform: ["translateY(0)", "translateY(-12px)"] },
318
+ { duration: 160, easing: "ease-out", fill: "forwards" },
319
+ ),
320
+ );
321
+ }
322
+ const remove = () => {
323
+ for (const animation of animations) animation.cancel();
324
+ for (const { previous } of containers) previous.remove();
325
+ };
326
+ context.removeOutgoing = once(remove);
327
+ context.controller.signal.addEventListener("abort", context.removeOutgoing, { once: true });
328
+ context.outgoingReady = Promise.all(
329
+ animations.map((animation) => animation.finished.catch(() => {})),
330
+ ).then(() => context.removeOutgoing?.());
331
+ });
332
+
235
333
  swup.hooks.before(
236
334
  "content:replace",
237
335
  async (visit) => {
@@ -250,6 +348,7 @@ export function installSwupRouter(): Swup {
250
348
  abortObsoleteVisit(visit);
251
349
  return;
252
350
  }
351
+ context.incomingDocument = next.cloneNode(true) as Document;
253
352
  const incoming = [...next.head.querySelectorAll("style,link[rel=stylesheet]")];
254
353
  context.obsoleteStyles = authoredStyles.filter(
255
354
  (el) => !incoming.some((other) => el.isEqualNode(other)),
@@ -273,7 +372,11 @@ export function installSwupRouter(): Swup {
273
372
  // Two cues overlap on the outgoing document: the content stage leaving,
274
373
  // and the page walking back to the top. Persisted sidebars, the header
275
374
  // and the frame are never part of either one.
276
- if (context.navigationType !== "traverse" && context.preset !== "none") {
375
+ if (
376
+ !visit.animation.parallel &&
377
+ context.navigationType !== "traverse" &&
378
+ context.preset !== "none"
379
+ ) {
277
380
  const outgoing = animateRouteOut(context.preset, context.controller.signal);
278
381
  if (!context.to.hash) context.glide = glideToTop();
279
382
  await context.glide?.promise;
@@ -288,8 +391,12 @@ export function installSwupRouter(): Swup {
288
391
  html.dataset.staluxVtTo = getRouteKind(context.to.pathname);
289
392
  html.dataset.staluxVtDirection = context.direction;
290
393
  html.dataset.staluxVtPreset = context.preset;
291
- context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
292
394
  if (context.controller.signal.aborted) return;
395
+ if (!visit.animation.parallel) {
396
+ context.motion = prepareRouteMotion(
397
+ dispatchNavigation("astro:before-swap", context, next),
398
+ );
399
+ }
293
400
  // Remember which shells exist now: after the swap only those slots need
294
401
  // refilling, because only those nodes came from the previous document.
295
402
  markRetainedShells();
@@ -299,27 +406,30 @@ export function installSwupRouter(): Swup {
299
406
  // motion.start, in the same synchronous block as the entry cue: releasing
300
407
  // it here would paint the new document fully visible for one frame.
301
408
  if (context.controller.signal.aborted) return;
302
- for (const element of context.obsoleteStyles) element.remove();
409
+ // ParallelPlugin keeps the outgoing container visible while it fades out
410
+ // and only removes it at visit:end / content:remove. Its page-scoped CSS
411
+ // must outlive that DOM: pruning here paints the outgoing (and already
412
+ // overlapping) content unstyled for the whole overlap. `awaitAssets`
413
+ // only covers the incoming stylesheets, not the outgoing ones.
414
+ const pruneObsoleteStyles = once(() => {
415
+ for (const element of context.obsoleteStyles) element.remove();
416
+ });
417
+ context.pruneObsoleteStyles = pruneObsoleteStyles;
418
+ if (context.outgoingReady) void context.outgoingReady.then(pruneObsoleteStyles);
419
+ else pruneObsoleteStyles();
303
420
  const incomingStyles = [...next.head.querySelectorAll("style,link[rel=stylesheet]")];
304
421
  authoredStyles = [...document.head.querySelectorAll("style,link[rel=stylesheet]")].filter(
305
422
  (el) => incomingStyles.some((other) => el.isEqualNode(other)),
306
423
  );
307
424
  // Main alone is replaced: header/footer/sentinel and background layers
308
425
  // 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);
426
+ html.dataset.staluxRoute = visit.to.route ?? getRouteKind(context.to.pathname);
427
+ document.querySelector(MAIN)?.setAttribute("data-stalux-route", html.dataset.staluxRoute);
318
428
  visibleHistoryIndex = Number(window.history.state?.index ?? visibleHistoryIndex);
319
429
  // Kept shells whose contents belong to the page (the article sidebars'
320
430
  // lists) take their children from the incoming document now, before any
321
431
  // page module mounts, so nothing needs to re-render or re-measure later.
322
- hydrateShells(next);
432
+ hydrateShells(context.incomingDocument ?? next);
323
433
  dispatchNavigation("astro:after-swap", context, next);
324
434
  // The entry cue has to own the first painted frame of the new document.
325
435
  // content:scroll is an await and a rAF away, and running the incoming
@@ -375,7 +485,8 @@ export function installSwupRouter(): Swup {
375
485
  }
376
486
  await defaultScroll?.(visit, args);
377
487
  // ScrollPlugin scrolls in rAF; finish it before heading visibility checks.
378
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
488
+ if (!document.hidden)
489
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
379
490
  startMotion(visit);
380
491
  return true;
381
492
  },
@@ -407,8 +518,15 @@ export function installSwupRouter(): Swup {
407
518
  swup.hooks.replace("link:self", () => undefined);
408
519
 
409
520
  const cancel = (visit: Visit) => {
521
+ // A cancelled/failed visit never reaches page:view. Clear its delayed
522
+ // indicator without allowing a stale visit to hide a newer one.
523
+ if (visit === swup.visit) progress.stopShowingProgress();
410
524
  const context = contexts.get(visit);
411
525
  context?.controller.abort();
526
+ context?.removeOutgoing?.();
527
+ // An aborted visit never reaches visit:end, so its outgoing CSS has to
528
+ // be released here or it would linger in head forever.
529
+ context?.pruneObsoleteStyles?.();
412
530
  context?.glide?.cancel();
413
531
  // An aborted visit keeps no exit fill: without this the stage would stay
414
532
  // transparent until the next successful navigation.
@@ -424,6 +542,9 @@ export function installSwupRouter(): Swup {
424
542
  swup.hooks.on("visit:fail", cancel);
425
543
  swup.hooks.on("visit:end", (visit) => {
426
544
  const context = contexts.get(visit);
545
+ // Safety net for paths where the exit animation never settles. The prune
546
+ // is idempotent, so a completed outgoingReady does not remove twice.
547
+ context?.pruneObsoleteStyles?.();
427
548
  context?.glide?.cancel();
428
549
  context?.releaseOutgoing?.();
429
550
  context?.motion?.start(null, { skip: true });
@@ -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
+ }