@xingwangzhe/stalux 1.41.5 → 1.43.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 (47) hide show
  1. package/README.md +19 -5
  2. package/package.json +12 -3
  3. package/src/components/stalux/archives/archivesList.astro +0 -2
  4. package/src/components/stalux/archives/toArchive.astro +1 -5
  5. package/src/components/stalux/common/Typewriter.astro +1 -7
  6. package/src/components/stalux/common/author.astro +1 -6
  7. package/src/components/stalux/common/date.astro +1 -11
  8. package/src/components/stalux/layout/PageHeading.astro +2 -11
  9. package/src/components/stalux/layout/footer.astro +1 -6
  10. package/src/components/stalux/layout/head.astro +9 -2
  11. package/src/components/stalux/links/socialMedia.astro +1 -10
  12. package/src/components/stalux/posts/ArticleSummary.astro +2 -8
  13. package/src/components/stalux/posts/PostTaxonomy.astro +3 -14
  14. package/src/components/stalux/posts/postCC.astro +2 -2
  15. package/src/components/stalux/posts/postCard.astro +1 -5
  16. package/src/components/stalux/posts/postContent.astro +4 -23
  17. package/src/components/stalux/posts/postLeft.astro +4 -22
  18. package/src/components/stalux/posts/postRight.astro +5 -27
  19. package/src/components/stalux/posts/postToc.astro +3 -10
  20. package/src/components/stalux/posts/randomPost.astro +0 -1
  21. package/src/components/stalux/tags/tagHeader.astro +1 -5
  22. package/src/env.d.ts +5 -0
  23. package/src/index.ts +41 -2
  24. package/src/internal/social-card-data.ts +34 -0
  25. package/src/internal/social-card-renderer.ts +307 -0
  26. package/src/internal/style-names.generated.ts +59 -69
  27. package/src/layouts/PostLayout.astro +1 -5
  28. package/src/layouts/Stalux.astro +6 -19
  29. package/src/pages/index.astro +1 -5
  30. package/src/pages/og/[image].[format].ts +47 -0
  31. package/src/pages/posts/[post].astro +8 -0
  32. package/src/pages/posts/[post].md.ts +1 -1
  33. package/src/schemas/collections.ts +9 -0
  34. package/src/scripts/navigation-client.ts +13 -0
  35. package/src/scripts/navigation-scripts.ts +191 -0
  36. package/src/scripts/route-motion.ts +78 -11
  37. package/src/scripts/swup-router.ts +366 -0
  38. package/src/scripts/tags-cloud.ts +1 -1
  39. package/src/scripts/view-transitions.ts +3 -233
  40. package/src/scripts/word-list.ts +9 -3
  41. package/src/styles/generated.css +1 -1
  42. package/src/styles/motion.css +2 -211
  43. package/src/utils/post-markdown.ts +2 -0
  44. package/src/utils/route-catalog.ts +1 -0
  45. package/src/utils/seo-document.ts +21 -4
  46. package/src/utils/social-image.ts +60 -0
  47. package/src/scripts/archive-reveal.ts +0 -76
@@ -0,0 +1,47 @@
1
+ import { getCollection } from "astro:content";
2
+ import paths from "virtual:stalux-social-card-paths";
3
+ import type { APIRoute } from "astro";
4
+ import { createSocialCardData } from "../../internal/social-card-data";
5
+ import { renderSocialCard } from "../../internal/social-card-renderer";
6
+ import { getAuthorData, getSiteData } from "../../utils/config-utils";
7
+ import { getPostContentIndex } from "../../utils/content-index";
8
+ import { isSocialImageUrl, type SocialCardData, socialCardPath } from "../../utils/social-image";
9
+
10
+ export async function getStaticPaths() {
11
+ const entries = await getCollection("config");
12
+ const site = getSiteData(entries);
13
+ const author = getAuthorData(entries);
14
+ const { posts } = await getPostContentIndex();
15
+ return posts
16
+ .filter(
17
+ (post) =>
18
+ !post.data["og:image"]?.trim() || !isSocialImageUrl(post.data["og:image"].trim()),
19
+ )
20
+ .flatMap((post) => {
21
+ const card = createSocialCardData(post.data, site, author);
22
+ return (["webp", "png"] as const).map((format) => ({
23
+ params: {
24
+ image: socialCardPath(card)
25
+ .split("/")
26
+ .at(-1)
27
+ ?.replace(/\.webp$/u, ""),
28
+ format,
29
+ },
30
+ props: { card, format },
31
+ // Covers may change independently of Markdown. Always recheck them; the renderer
32
+ // has its own content cache and writes outputs even after Astro cleans dist.
33
+ }));
34
+ });
35
+ }
36
+
37
+ export const GET: APIRoute = async ({ props, logger }) => {
38
+ const bytes = await renderSocialCard(
39
+ props.card as SocialCardData,
40
+ paths,
41
+ (message) => logger.warn(message),
42
+ props.format as "webp" | "png",
43
+ );
44
+ return new Response(new Uint8Array(bytes), {
45
+ headers: { "Content-Type": `image/${props.format}` },
46
+ });
47
+ };
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  import { type CollectionEntry, getCollection, render } from "astro:content";
3
3
  import { getRuntimeCacheKey } from "@internal/runtime-cache-key";
4
+ import { createSocialCardData } from "@internal/social-card-data";
4
5
  import PostLayout from "@layouts/PostLayout.astro";
5
6
  import Stalux from "@layouts/Stalux.astro";
6
7
  import { analyzeFeatureFlags } from "@plugins/feature-flags";
7
8
  import { getAuthorData, getSiteData } from "@utils/config-utils";
8
9
  import { getPostContentIndex } from "@utils/content-index";
10
+ import { resolvePostSocialImage } from "@utils/social-image";
9
11
 
10
12
  interface Props {
11
13
  post: CollectionEntry<"posts">;
@@ -33,6 +35,7 @@ export async function getStaticPaths() {
33
35
  post.data.updated ?? post.data.date,
34
36
  prevPost ? (prevPost.data.updated ?? prevPost.data.date) : "",
35
37
  nextPost ? (nextPost.data.updated ?? nextPost.data.date) : "",
38
+ JSON.stringify(post.data),
36
39
  getRuntimeCacheKey(),
37
40
  ].join("|");
38
41
  return {
@@ -56,6 +59,11 @@ export async function getStaticPaths() {
56
59
  title={`${props.post.data.title} | ${siteConfig.title}`}
57
60
  description={autoDesc}
58
61
  cover={props.post.data.cover}
62
+ socialImage={resolvePostSocialImage(
63
+ props.post.data["og:image"],
64
+ createSocialCardData(props.post.data, siteConfig, authorConfig),
65
+ import.meta.env.BASE_URL,
66
+ )}
59
67
  isArticle={true}
60
68
  date={props.post.data.date}
61
69
  updated={props.post.data.updated}
@@ -39,7 +39,7 @@ export const getStaticPaths = (async () => {
39
39
  const posts = await getCollection("posts", ({ data }) => !data.draft);
40
40
  return posts.map((post) => ({
41
41
  params: { post: String(post.data.abbrlink) },
42
- cacheKey: `${post.data.updated ?? post.data.date}|${getRuntimeCacheKey()}`,
42
+ cacheKey: `${post.data.updated ?? post.data.date}|${JSON.stringify(post.data)}|${getRuntimeCacheKey()}`,
43
43
  props: { post },
44
44
  }));
45
45
  }) satisfies GetStaticPaths;
@@ -19,6 +19,7 @@ import { defineCollection } from "astro:content";
19
19
  import { glob, type Loader } from "astro/loaders";
20
20
  import { z } from "astro/zod";
21
21
 
22
+ import { isSocialImageUrl } from "../utils/social-image";
22
23
  import { configSchema } from "./config";
23
24
 
24
25
  /** Preserve glob's name, watch behavior and incremental store; only trace the load boundary. */
@@ -98,6 +99,14 @@ const postSchema = z.object({
98
99
  desc: z.string({ error: "desc must be a string" }).min(1, "desc is required"),
99
100
  cc: z.string().optional().default("CC-BY-NC-SA-4.0"),
100
101
  cover: z.string().optional(),
102
+ "og:image": z
103
+ .string()
104
+ .trim()
105
+ .refine(
106
+ (value) => value === "" || isSocialImageUrl(value),
107
+ "og:image must be an HTTP(S) URL or site-root image path",
108
+ )
109
+ .optional(),
101
110
  });
102
111
 
103
112
  // ---------------------------------------------------------------------------
@@ -0,0 +1,13 @@
1
+ import type Swup from "swup";
2
+
3
+ declare global {
4
+ interface Window {
5
+ swup?: Swup;
6
+ }
7
+ }
8
+
9
+ /** Programmatic theme navigation uses the same controller as ordinary links. */
10
+ export function navigate(href: string): void {
11
+ if (window.swup) window.swup.navigate(href);
12
+ else window.location.assign(href);
13
+ }
@@ -0,0 +1,191 @@
1
+ import { createClientLogger } from "./logger";
2
+
3
+ const logger = createClientLogger("navigation-scripts");
4
+ const executions = new Map<string, Promise<void>>();
5
+ const executedNodes = new WeakSet<HTMLScriptElement>();
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
+ }
82
+
83
+ function abortable(promise: Promise<void>, signal?: AbortSignal): Promise<void> {
84
+ if (!signal) return promise;
85
+ if (signal.aborted) return Promise.resolve();
86
+ return new Promise((resolve) => {
87
+ const finish = () => {
88
+ signal.removeEventListener("abort", finish);
89
+ resolve();
90
+ };
91
+ signal.addEventListener("abort", finish, { once: true });
92
+ void promise.then(finish, finish);
93
+ });
94
+ }
95
+
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);
149
+
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;
163
+ }
164
+
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
+ */
172
+ export function prepareNavigationScripts(
173
+ nextDocument: Document,
174
+ 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) {
185
+ if (signal?.aborted) return;
186
+ await abortable(executeScript(script, options.timeoutMs ?? 15_000), signal);
187
+ }
188
+ })();
189
+ return executed;
190
+ };
191
+ }
@@ -1,4 +1,8 @@
1
+ import type { RouteMotionPreset } from "../utils/route-motion-policy";
2
+
1
3
  export interface RouteMotionEvent {
4
+ // Retained for integrations using the shared gates with a native transition.
5
+ // Stalux's Swup router leaves this unset and owns the animation lifecycle.
2
6
  viewTransition?: {
3
7
  ready?: PromiseLike<unknown>;
4
8
  finished?: PromiseLike<unknown>;
@@ -6,13 +10,42 @@ export interface RouteMotionEvent {
6
10
  signal?: AbortSignal;
7
11
  }
8
12
 
13
+ export interface RouteMotionOptions {
14
+ skip?: boolean;
15
+ preset?: RouteMotionPreset;
16
+ browserMotion?: boolean;
17
+ }
18
+
9
19
  export interface RouteMotion {
10
20
  ready: Promise<void>;
11
21
  finished: Promise<void>;
12
- start: (main: HTMLElement | null, options?: { skip?: boolean }) => void;
22
+ start: (heading: HTMLElement | null, options?: RouteMotionOptions) => void;
13
23
  cancel: () => void;
14
24
  }
15
25
 
26
+ interface HeadingMotion {
27
+ opacity: number;
28
+ duration: number;
29
+ x?: number;
30
+ y?: number;
31
+ }
32
+
33
+ // F / E / U / T / S / L / R / X from the route design. Only the incoming
34
+ // heading moves; article prose, lists, sidebars and the persistent shell do not.
35
+ const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
36
+ glide: { opacity: 0.86, duration: 100 },
37
+ article: { opacity: 0.86, duration: 100 },
38
+ enter: { opacity: 0.86, duration: 140, y: 6 },
39
+ up: { opacity: 0.9, duration: 120, y: -4 },
40
+ taxonomy: { opacity: 0.85, duration: 120 },
41
+ previous: { opacity: 0.9, duration: 140, x: -8 },
42
+ next: { opacity: 0.9, duration: 140, x: 8 },
43
+ panel: { opacity: 0.9, duration: 120, y: 4 },
44
+ home: { opacity: 0.9, duration: 120, y: 4 },
45
+ restore: { opacity: 0.96, duration: 80 },
46
+ error: { opacity: 0.9, duration: 80 },
47
+ };
48
+
16
49
  const motions = new WeakMap<object, RouteMotion>();
17
50
  const settle = (promise?: PromiseLike<unknown>): Promise<void> =>
18
51
  Promise.resolve(promise).then(
@@ -28,15 +61,30 @@ function completion() {
28
61
  return { promise, resolve };
29
62
  }
30
63
 
31
- /** Share the native swap and the real content animation across all route consumers. */
64
+ /** A deep history restore must not scroll to or animate an off-screen title. */
65
+ function isVisibleHeading(heading: HTMLElement): boolean {
66
+ if (!heading.isConnected || !heading.hasAttribute("data-stalux-motion-focus")) return false;
67
+ const rect = heading.getBoundingClientRect();
68
+ return (
69
+ rect.width > 0 &&
70
+ rect.height > 0 &&
71
+ rect.height <= Math.min(320, window.innerHeight / 2) &&
72
+ rect.top < window.innerHeight &&
73
+ rect.bottom > 0 &&
74
+ rect.left < window.innerWidth &&
75
+ rect.right > 0
76
+ );
77
+ }
78
+
79
+ /** Share Swup's actual heading animation with background and analytics consumers. */
32
80
  export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
33
81
  const native = event.viewTransition;
34
82
  const key = native ?? event;
35
83
  const existing = motions.get(key);
36
84
  if (existing) return existing;
37
85
 
38
- // Create these before swap: a consumer must not observe native.finished and
39
- // mistake the gap before after-swap installs WAAPI for completed motion.
86
+ // Installation is a separate gate: before-swap consumers must not mistake
87
+ // the gap before Swup's incoming animation hook for a completed transition.
40
88
  const installed = completion();
41
89
  const animationDone = completion();
42
90
  const canceled = completion();
@@ -47,6 +95,8 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
47
95
  const active = animation;
48
96
  animation = undefined;
49
97
  try {
98
+ // Remove fill effects and restore the authored final styles. No
99
+ // inline transform/opacity survives completion, failure or abort.
50
100
  active?.cancel();
51
101
  } catch {
52
102
  // Releasing an already detached effect must not hold up navigation.
@@ -73,24 +123,41 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
73
123
  const motion: RouteMotion = {
74
124
  ready,
75
125
  finished,
76
- start(main, options) {
126
+ start(heading, options) {
77
127
  if (started) return;
78
128
  started = true;
79
129
  try {
130
+ const preset = options?.preset ?? "glide";
131
+ const mobile = window.matchMedia?.("(max-width: 768px)").matches;
80
132
  if (
81
- !main ||
133
+ !heading ||
134
+ preset === "none" ||
82
135
  options?.skip ||
136
+ options?.browserMotion ||
83
137
  document.hidden ||
84
138
  window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
85
- typeof main.animate !== "function"
139
+ typeof heading.animate !== "function" ||
140
+ !isVisibleHeading(heading) ||
141
+ (mobile && preset === "restore")
86
142
  ) {
87
143
  animationDone.resolve();
88
144
  return;
89
145
  }
90
- animation = main.animate(
91
- { opacity: [0.7, 1] },
92
- { duration: 100, easing: "ease-out" },
93
- );
146
+ const spec = headingMotions[preset];
147
+ const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
148
+ // Phone routes use the specified pure fade. Unrelated article
149
+ // links also fade: lateral motion requires explicit prev/next.
150
+ if (!mobile && (spec.x || spec.y)) {
151
+ keyframes.transform = [
152
+ `translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
153
+ "translate3d(0px, 0px, 0)",
154
+ ];
155
+ }
156
+ animation = heading.animate(keyframes, {
157
+ duration: mobile ? 80 : spec.duration,
158
+ easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
159
+ fill: "both",
160
+ });
94
161
  void animation.finished.then(clearAnimation, clearAnimation);
95
162
  } catch {
96
163
  clearAnimation();