@xingwangzhe/stalux 1.30.3 → 1.31.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/README.md CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  A dark-themed, high-performance Astro blog theme with elegant glassmorphism design, native per-page CJK font subsets, and a focus on content-first reading experience.
12
12
 
13
+ All HTML routes include a circular back-to-top button at the bottom right. It appears after scrolling 240px and scrolls smoothly to the top, respecting reduced motion preferences. A 64px right gutter keeps it clear of content; it is hidden when printing. No configuration is required.
14
+
13
15
  ---
14
16
 
15
17
  ## 🚀 Quick Start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.30.3",
3
+ "version": "1.31.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -20,35 +20,37 @@ const siteConfig = (configEntries.find((e) => e.id === "site")?.data ?? {}) as R
20
20
  const { t } = createTranslator((siteConfig.lang as string) || "zh-CN");
21
21
  ---
22
22
 
23
- <article class="stalux-category-card">
24
- <a
25
- href={`/categories/${props.category.name}/`}
26
- class="stalux-categoryheader a-none"
27
- data-astro-prefetch
28
- >
29
- <div class="stalux-category-icon">
30
- <svg
31
- aria-hidden="true"
32
- xmlns="http://www.w3.org/2000/svg"
33
- viewBox="0 0 24 24"
34
- width="24"
35
- height="24"
36
- fill="none"
37
- stroke="currentColor"
38
- stroke-width="2"
39
- stroke-linecap="round"
40
- stroke-linejoin="round"
41
- >
42
- <path
43
- d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
44
- ></path>
45
- </svg>
46
- </div>
47
- <div class="stalux-category-content">
48
- <h2 class="categoryName">{props.category.name}</h2>
49
- <div class="categoryCount">
50
- {t("categories.postCount", { n: props.category.count })}
23
+ <div class="stalux-category-shell">
24
+ <article class="stalux-category-card">
25
+ <a
26
+ href={`/categories/${props.category.name}/`}
27
+ class="stalux-categoryheader a-none"
28
+ data-astro-prefetch
29
+ >
30
+ <div class="stalux-category-icon">
31
+ <svg
32
+ aria-hidden="true"
33
+ xmlns="http://www.w3.org/2000/svg"
34
+ viewBox="0 0 24 24"
35
+ width="24"
36
+ height="24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ >
43
+ <path
44
+ d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
45
+ ></path>
46
+ </svg>
51
47
  </div>
52
- </div>
53
- </a>
54
- </article>
48
+ <div class="stalux-category-content">
49
+ <h2 class="categoryName">{props.category.name}</h2>
50
+ <div class="categoryCount">
51
+ {t("categories.postCount", { n: props.category.count })}
52
+ </div>
53
+ </div>
54
+ </a>
55
+ </article>
56
+ </div>
@@ -0,0 +1,69 @@
1
+ ---
2
+ import { ArrowUp } from "@lucide/astro";
3
+
4
+ const { lang = "zh-CN" } = Astro.props;
5
+ const label = lang.startsWith("zh") ? "回到顶部" : "Back to top";
6
+ ---
7
+
8
+ <button
9
+ type="button"
10
+ class="stalux-back-to-top"
11
+ data-stalux-back-to-top
12
+ aria-label={label}
13
+ title={label}
14
+ hidden
15
+ >
16
+ <ArrowUp size={20} aria-hidden="true" />
17
+ </button>
18
+
19
+ <script>
20
+ import { mountBackToTop } from "../../../scripts/back-to-top";
21
+ import { registerPageLifecycle } from "../../../scripts/page-runtime";
22
+
23
+ registerPageLifecycle("back-to-top", mountBackToTop);
24
+ </script>
25
+
26
+ <style is:global>
27
+ /* Reserve a rail outside content, including on narrow screens. */
28
+ .stalux-root {
29
+ box-sizing: border-box;
30
+ padding-right: calc(64px + env(safe-area-inset-right, 0px));
31
+ }
32
+ .stalux-back-to-top {
33
+ position: fixed;
34
+ right: calc(10px + env(safe-area-inset-right, 0px));
35
+ bottom: calc(20px + env(safe-area-inset-bottom, 0px));
36
+ width: 44px;
37
+ height: 44px;
38
+ display: grid;
39
+ place-items: center;
40
+ padding: 0;
41
+ border: 1px solid var(--stalux-accent-color);
42
+ border-radius: 50%;
43
+ color: var(--stalux-accent-color);
44
+ background: #202020;
45
+ box-shadow: 0 2px 12px #0006;
46
+ cursor: pointer;
47
+ z-index: 20;
48
+ }
49
+ .stalux-back-to-top[hidden] {
50
+ display: none;
51
+ }
52
+ .stalux-back-to-top:focus-visible {
53
+ outline: 2px solid white;
54
+ outline-offset: 3px;
55
+ }
56
+ @media (hover: hover) {
57
+ .stalux-back-to-top:hover {
58
+ background: #353535;
59
+ }
60
+ }
61
+ @media print {
62
+ .stalux-root {
63
+ padding-right: 0;
64
+ }
65
+ .stalux-back-to-top {
66
+ display: none;
67
+ }
68
+ }
69
+ </style>
@@ -29,18 +29,20 @@ const desc = link.description ?? "";
29
29
  const icon = link.icon ?? link.avatar ?? "/favicon.ico";
30
30
  ---
31
31
 
32
- <a class="stalux-link-card" href={href} target="_blank" rel="noopener">
33
- <div class="stalux-link-card-avatar">
34
- <img
35
- src={icon}
36
- alt={t("links.iconAlt", { name: title })}
37
- loading="lazy"
38
- width="80"
39
- height="80"
40
- >
41
- </div>
42
- <div class="stalux-link-card-content">
43
- <h2 class="stalux-link-card-title">{title}</h2>
44
- {desc && <p class="stalux-link-card-description">{desc}</p>}
32
+ <a class="stalux-link-shell a-none" href={href} target="_blank" rel="noopener">
33
+ <div class="stalux-link-card">
34
+ <div class="stalux-link-card-avatar">
35
+ <img
36
+ src={icon}
37
+ alt={t("links.iconAlt", { name: title })}
38
+ loading="lazy"
39
+ width="80"
40
+ height="80"
41
+ >
42
+ </div>
43
+ <div class="stalux-link-card-content">
44
+ <h2 class="stalux-link-card-title">{title}</h2>
45
+ {desc && <p class="stalux-link-card-description">{desc}</p>}
46
+ </div>
45
47
  </div>
46
48
  </a>
@@ -20,7 +20,7 @@ const timezone = siteConfig.timezone;
20
20
  const description = post.data.desc || "";
21
21
  ---
22
22
 
23
- <article class="block h-full text-inherit no-underline">
23
+ <article class="stalux-post-card-shell block h-full text-inherit no-underline">
24
24
  <div class="stalux-post-card stalux-u-flex stalux-u-flex-col stalux-u-w-full stalux-u-min-w-0">
25
25
  {post.data.cover && (
26
26
  <a
@@ -0,0 +1,65 @@
1
+ import { type DefaultTreeAdapterMap, parse } from "parse5";
2
+
3
+ export interface HtmlImage {
4
+ start: number;
5
+ end: number;
6
+ tag: string;
7
+ attributes: Map<string, string>;
8
+ locations: Map<string, { startOffset: number; endOffset: number }>;
9
+ body?: number;
10
+ }
11
+
12
+ /** Source ranges preserve scripts and code-copy attributes byte-for-byte. */
13
+ export function findHtmlImages(html: string): HtmlImage[] {
14
+ const images: HtmlImage[] = [];
15
+ function visit(node: DefaultTreeAdapterMap["node"], body?: number) {
16
+ if ("tagName" in node) {
17
+ if (node.tagName === "template") return;
18
+ const location = node.sourceCodeLocation;
19
+ if (
20
+ node.tagName === "section" &&
21
+ node.attrs.some((attr) => attr.name === "data-pagefind-body")
22
+ )
23
+ body = location?.startOffset;
24
+ if (node.tagName === "img" && location?.startTag) {
25
+ const { startOffset: start, endOffset: end } = location.startTag;
26
+ images.push({
27
+ start,
28
+ end,
29
+ tag: html.slice(start, end),
30
+ attributes: new Map(node.attrs.map((attr) => [attr.name, attr.value])),
31
+ locations: new Map(Object.entries(location.attrs ?? {})),
32
+ body,
33
+ });
34
+ }
35
+ }
36
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child, body);
37
+ }
38
+ visit(parse(html, { sourceCodeLocationInfo: true }));
39
+ return images.sort((left, right) => left.start - right.start);
40
+ }
41
+
42
+ export function updateImageTag(image: HtmlImage, updates: Record<string, string>): string {
43
+ let tag = image.tag;
44
+ const edits: Array<{ start: number; end: number; value: string }> = [];
45
+ const additions: string[] = [];
46
+ for (const [name, value] of Object.entries(updates)) {
47
+ const attribute = `${name}="${value.replaceAll("&", "&amp;").replaceAll('"', "&quot;")}"`;
48
+ const location = image.locations.get(name);
49
+ if (location)
50
+ edits.push({
51
+ start: location.startOffset - image.start,
52
+ end: location.endOffset - image.start,
53
+ value: attribute,
54
+ });
55
+ else additions.push(attribute);
56
+ }
57
+ for (const edit of edits.sort((left, right) => right.start - left.start))
58
+ tag = tag.slice(0, edit.start) + edit.value + tag.slice(edit.end);
59
+ if (additions.length)
60
+ tag = tag.replace(
61
+ /\s*\/?\s*>$/,
62
+ (ending) => ` ${additions.join(" ")}${ending.trimStart()}`,
63
+ );
64
+ return tag;
65
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { findHtmlImages, updateImageTag } from "./html-images";
3
4
 
4
5
  export type ImageDimensions = { width: number; height: number };
5
6
 
@@ -74,12 +75,11 @@ export function readImageDimensions(data: Buffer): ImageDimensions | undefined {
74
75
  export async function addLocalImageDimensions(html: string, outputDir: string): Promise<string> {
75
76
  const dimensionsCache = new Map<string, Promise<ImageDimensions | undefined>>();
76
77
  const root = path.resolve(outputDir);
77
- const tags = [...html.matchAll(/<img\b[^>]*>/gi)];
78
+ const tags = findHtmlImages(html);
78
79
 
79
- for (const match of tags) {
80
- const tag = match[0];
81
- if (!tag || (/\swidth=/i.test(tag) && /\sheight=/i.test(tag))) continue;
82
- const src = tag.match(/\ssrc="([^"]+)"/i)?.[1];
80
+ for (const image of tags.reverse()) {
81
+ if (image.attributes.has("width") && image.attributes.has("height")) continue;
82
+ const src = image.attributes.get("src");
83
83
  if (!src) continue;
84
84
 
85
85
  let filePath: string;
@@ -102,21 +102,11 @@ export async function addLocalImageDimensions(html: string, outputDir: string):
102
102
  const size = await dimensions;
103
103
  if (!size || size.width <= 0 || size.height <= 0) continue;
104
104
 
105
- const hasWidth = /\swidth=/i.test(tag);
106
- const hasHeight = /\sheight=/i.test(tag);
107
- const updated =
108
- hasWidth && !hasHeight
109
- ? tag.replace(/\s*\/?\s*>$/, (ending) => ` height="${size.height}"${ending}`)
110
- : hasHeight && !hasWidth
111
- ? tag.replace(
112
- /\sheight="[^"]*"/i,
113
- ` width="${size.width}" height="${size.height}"`,
114
- )
115
- : tag.replace(
116
- /\s*\/?\s*>$/,
117
- (ending) => ` width="${size.width}" height="${size.height}"${ending}`,
118
- );
119
- html = html.replace(tag, updated);
105
+ const updates: Record<string, string> = {};
106
+ if (!image.attributes.has("width")) updates.width = String(size.width);
107
+ if (!image.attributes.has("height")) updates.height = String(size.height);
108
+ const updated = updateImageTag(image, updates);
109
+ html = html.slice(0, image.start) + updated + html.slice(image.end);
120
110
  }
121
111
  return html;
122
112
  }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { getCollection } from "astro:content";
3
+ import BackToTop from "@components/stalux/layout/BackToTop.astro";
3
4
  import Footer from "@components/stalux/layout/footer.astro";
4
5
  import Head from "@components/stalux/layout/head.astro";
5
6
  import { getAccentColorStyle } from "@utils/accent-color";
@@ -103,5 +104,6 @@ const routeAnimation = getRouteAnimation(routeKind);
103
104
  <slot />
104
105
  </main>
105
106
  <Footer />
107
+ <BackToTop lang={htmlLang} />
106
108
  </body>
107
109
  </html>
@@ -16,6 +16,7 @@ import type { HastVisitorContext, MdastNode, MdastVisitorContext } from "satteri
16
16
  * - 数学公式由 satteri-temml 插件直接输出 MathML,无需额外标记
17
17
  */
18
18
  import { defineHastPlugin, defineMdastPlugin } from "satteri";
19
+ import { findHtmlImages, updateImageTag } from "../internal/html-images";
19
20
  import { logDetail } from "../utils/diagnostics";
20
21
 
21
22
  declare module "satteri" {
@@ -246,44 +247,25 @@ export function applyImageLoadingPolicy(
246
247
  }
247
248
 
248
249
  export function applyHtmlImageLoadingPolicy(html: string): string {
249
- return html.replace(
250
- /<section\b(?=[^>]*\bdata-pagefind-body\b)[^>]*>[\s\S]*?<\/section>/gi,
251
- (body) => {
252
- let imageIndex = 0;
253
- return body.replace(/<img\b[^>]*>/gi, (tag) => {
254
- const isFirstBodyImage = imageIndex++ === 0;
255
- const attributes = new Map<string, string>();
256
- for (const match of tag.matchAll(/\s([\w:-]+)(?:="([^"]*)")?/g)) {
257
- const name = match[1];
258
- if (name) attributes.set(name.toLowerCase(), match[2] ?? "");
259
- }
260
-
261
- const updates: Record<string, string> = {
262
- decoding: attributes.get("decoding") || "async",
263
- loading: isFirstBodyImage ? "eager" : "lazy",
264
- };
265
- if (isFirstBodyImage) {
266
- updates.fetchpriority = "high";
267
- } else if (attributes.get("fetchpriority") === "high") {
268
- updates.fetchpriority = "auto";
269
- }
270
-
271
- let result = tag;
272
- for (const [name, value] of Object.entries(updates)) {
273
- const pattern = new RegExp(`\\s${name}(?:="[^"]*")?`, "i");
274
- if (pattern.test(result)) {
275
- result = result.replace(pattern, ` ${name}="${value}"`);
276
- } else {
277
- result = result.replace(
278
- /\s*\/?>$/,
279
- (ending) => ` ${name}="${value}"${ending.trimStart()}`,
280
- );
281
- }
282
- }
283
- return result;
284
- });
285
- },
286
- );
250
+ const firstSeen = new Set<number>();
251
+ const changes = findHtmlImages(html)
252
+ .filter((image) => image.body !== undefined)
253
+ .map((image) => {
254
+ const body = image.body as number;
255
+ const first = !firstSeen.has(body);
256
+ firstSeen.add(body);
257
+ const updates: Record<string, string> = {
258
+ decoding: image.attributes.get("decoding") || "async",
259
+ loading: first ? "eager" : "lazy",
260
+ };
261
+ if (first) updates.fetchpriority = "high";
262
+ else if (image.attributes.get("fetchpriority") === "high")
263
+ updates.fetchpriority = "auto";
264
+ return { image, tag: updateImageTag(image, updates) };
265
+ });
266
+ for (const { image, tag } of changes.reverse())
267
+ html = html.slice(0, image.start) + tag + html.slice(image.end);
268
+ return html;
287
269
  }
288
270
 
289
271
  type FeatureFlagsResult = {
@@ -0,0 +1,23 @@
1
+ /** Bind to the current page DOM; the shared lifecycle disposes before a body swap. */
2
+ export function mountBackToTop() {
3
+ const button = document.querySelector<HTMLButtonElement>("[data-stalux-back-to-top]");
4
+ if (!button) return;
5
+ const update = () => {
6
+ button.hidden = window.scrollY < 240;
7
+ };
8
+ const scrollToTop = () => {
9
+ window.scrollTo({
10
+ top: 0,
11
+ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
12
+ ? "instant"
13
+ : "smooth",
14
+ });
15
+ };
16
+ update();
17
+ window.addEventListener("scroll", update, { passive: true });
18
+ button.addEventListener("click", scrollToTop);
19
+ return () => {
20
+ window.removeEventListener("scroll", update);
21
+ button.removeEventListener("click", scrollToTop);
22
+ };
23
+ }
@@ -48,7 +48,7 @@
48
48
  }
49
49
 
50
50
  /* 下划线动画 */
51
- a::after {
51
+ a:not(.a-none):not([class*="card"]):not(.stalux-nav-link)::after {
52
52
  content: "";
53
53
  position: absolute;
54
54
  left: 0;
@@ -61,19 +61,27 @@
61
61
  transition: transform 0.25s cubic-bezier(0.25, 0.9, 0.2, 1);
62
62
  pointer-events: none;
63
63
  }
64
-
65
- a:hover::after,
66
- a:focus-visible::after,
67
- a:active::after {
64
+ a:not(.a-none):not([class*="card"]):not(.stalux-nav-link):focus-visible::after,
65
+ a:not(.a-none):not([class*="card"]):not(.stalux-nav-link):active::after {
68
66
  transform: scaleX(1);
69
67
  }
68
+ @media (hover: hover) and (pointer: fine) {
69
+ a:not(.a-none):not([class*="card"]):not(.stalux-nav-link):hover::after {
70
+ transform: scaleX(1);
71
+ }
72
+ }
70
73
 
71
- a:hover,
72
74
  a:active,
73
75
  a:focus-visible {
74
76
  text-decoration: none;
75
77
  /* 保持颜色不变,避免 hover 时颜色跳变 */
76
78
  }
79
+ @media (hover: hover) and (pointer: fine) {
80
+ a:hover {
81
+ text-decoration: none;
82
+ /* 保持颜色不变,避免 hover 时颜色跳变 */
83
+ }
84
+ }
77
85
 
78
86
  a:focus {
79
87
  outline: none;
@@ -240,9 +248,10 @@
240
248
  border-radius: 4px;
241
249
  transition: background 0.3s ease-out;
242
250
  }
243
-
244
- ::-webkit-scrollbar-thumb:hover {
245
- background: #5a5a5a;
251
+ @media (hover: hover) and (pointer: fine) {
252
+ ::-webkit-scrollbar-thumb:hover {
253
+ background: #5a5a5a;
254
+ }
246
255
  }
247
256
 
248
257
  ::-webkit-scrollbar-corner {
@@ -285,3 +294,81 @@
285
294
  -webkit-backdrop-filter: blur(16px);
286
295
  }
287
296
  }
297
+
298
+ @layer components {
299
+ .stalux-main,
300
+ .stalux-post-page,
301
+ .stalux-post-layout > *,
302
+ .stalux-link-card-content,
303
+ .stalux-word-list-item,
304
+ .stalux-word-card {
305
+ min-width: 0;
306
+ max-width: 100%;
307
+ box-sizing: border-box;
308
+ }
309
+ .stalux-main {
310
+ overflow-wrap: anywhere;
311
+ }
312
+ .stalux-link-shell {
313
+ display: block;
314
+ min-width: 0;
315
+ max-width: 100%;
316
+ }
317
+ .stalux-link-card {
318
+ box-sizing: border-box;
319
+ max-width: 100%;
320
+ width: 100%;
321
+ }
322
+ .stalux-category-shell {
323
+ min-width: 0;
324
+ width: 100%;
325
+ }
326
+ .stalux-author-figure > a {
327
+ display: inline-block;
328
+ flex-shrink: 0;
329
+ }
330
+ .stalux-author-figure img {
331
+ display: block;
332
+ }
333
+ .stalux-post-card-shell,
334
+ .stalux-category-shell,
335
+ .stalux-link-shell {
336
+ position: relative;
337
+ }
338
+ .stalux-post-card,
339
+ .stalux-category-card,
340
+ .stalux-link-card,
341
+ .stalux-word-card {
342
+ transition:
343
+ transform 200ms ease,
344
+ background-color 200ms ease,
345
+ border-color 200ms ease,
346
+ box-shadow 200ms ease;
347
+ }
348
+ :is(.stalux-post-card-shell, .stalux-category-shell):focus-within > *,
349
+ .stalux-link-shell:focus-visible > * {
350
+ box-shadow: var(--card-box-shadow-hover);
351
+ }
352
+ :is(button, summary, [role="button"]):focus-visible {
353
+ outline: 2px solid var(--stalux-accent-color);
354
+ outline-offset: 3px;
355
+ }
356
+ @media (prefers-reduced-motion: reduce) {
357
+ .stalux-main *,
358
+ .stalux-main *::before,
359
+ .stalux-main *::after {
360
+ animation: none !important;
361
+ transition-duration: 0ms !important;
362
+ }
363
+ .stalux-post-card,
364
+ .stalux-category-card,
365
+ .stalux-link-card,
366
+ .stalux-link-card-content,
367
+ .stalux-card-post-cover img,
368
+ .stalux-category-icon,
369
+ .stalux-author-figure img,
370
+ .stalux-link-card-avatar img {
371
+ transform: none !important;
372
+ }
373
+ }
374
+ }