@xingwangzhe/stalux 1.48.0 → 1.49.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
@@ -14,7 +14,9 @@ All HTML routes include a circular back-to-top button in the footer. It appears
14
14
 
15
15
  The homepage typewriter is built into Stalux and continues to use the `items` string list in `stalux/config/typetexts.yml`, with no new configuration. Defaults are 100ms per typed grapheme, 33ms per deleted grapheme, a 1500ms hold, and a 500ms pause after deletion. It pauses outside the viewport or in background tabs and cleans up on soft navigation. Reduced motion displays whole messages without a blinking cursor. Without JavaScript, the first message remains visible; screen readers and print receive the complete list.
16
16
 
17
- Standalone images written as `![Image description](url)` receive a `figure` and `figcaption` through a build-time satteri HAST plugin. Captions appear as small, centered gray text below the image, preserving image alt text and the PhotoSwipe lightbox. No configuration or browser script is required. Empty descriptions, inline images, multiple images in one paragraph, and existing figures are left unchanged. Captions are plain text with automatic escaping.
17
+ Standalone images written as `![Image description](url)` receive a `figure` and `figcaption` through a build-time satteri HAST plugin. Captions appear as small, centered gray text below the image, preserving image alt text and the PhotoSwipe lightbox. No configuration or browser script is required. Empty descriptions, inline images, multiple images in one paragraph, and existing figures are left unchanged. Captions are plain text with automatic escaping. External images can declare intrinsic size in the markdown title: `![description](https://example.com/a.webp "1150x439")`. The build turns it into `width`/`height`, removes the tooltip title, and gives PhotoSwipe matching `data-pswp-width/height`; images without a size declaration keep their previous output.
18
+
19
+ Friend-link icons are requested only when they approach the viewport (400px prefetch margin), instead of the browser's large native lazy window pulling every third-party avatar on the page. A failed icon keeps a placeholder disc and never affects the text content. The Google Analytics SDK loads after the first screen settles (first input, 600ms of stable LCP, or six seconds after parsing), while `page_view` snapshots and the dataLayer queue are still written during navigation without waiting for the SDK. The random-posts sidebar likewise requests its index only near the viewport.
18
20
 
19
21
  Code blocks default to the single `dark-plus` theme, with system color-scheme switching and theme selectors disabled. To opt into another theme or switching behavior, explicitly override `themes`, `useDarkModeMediaQuery`, and `themeCssSelector` through `expressiveCode`. This only changes generated code styles and adds no telemetry.
20
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -39,9 +39,13 @@ const icon = link.icon ?? link.avatar ?? "/favicon.ico";
39
39
  <div class="stalux-link-card">
40
40
  <div class="stalux-link-card-avatar">
41
41
  <img
42
- src={icon}
42
+ src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
43
+ data-src={icon}
44
+ data-stalux-link-icon
43
45
  alt={t("links.iconAlt", { name: title })}
44
46
  loading="lazy"
47
+ decoding="async"
48
+ fetchpriority="low"
45
49
  width="80"
46
50
  height="80"
47
51
  >
@@ -17,3 +17,5 @@ const links = linksConfig?.sites ?? [];
17
17
  </li>
18
18
  ))}
19
19
  </ul>
20
+
21
+ <script src="../../../scripts/link-icons.ts"></script>
@@ -128,6 +128,7 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
128
128
  <!-- Article Body -->
129
129
  <section
130
130
  class="stalux-post-body stalux-prose"
131
+ data-stalux-gallery-body
131
132
  data-stalux-motion-content
132
133
  data-pagefind-body
133
134
  data-stalux-post-id={props.postId === undefined ? undefined : String(props.postId)}
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ import { createViteAliases } from "./internal/vite-aliases";
64
64
  import { webMcpMutationsPlugin } from "./internal/webmcp-mutations";
65
65
  import { featureFlagsHast, featureFlagsMdast } from "./plugins/feature-flags";
66
66
  import { imageCaptions } from "./plugins/image-captions";
67
+ import { imageDimensions } from "./plugins/image-dimensions";
67
68
  import { temml } from "./plugins/satteri-temml";
68
69
  import { describeError } from "./utils/diagnostics";
69
70
 
@@ -293,6 +294,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
293
294
  }),
294
295
  seen,
295
296
  );
297
+ // Dimensions must land before PhotoSwipe copies them onto the link.
298
+ appendUniquePlugin(processorOptions.hastPlugins, imageDimensions, seen);
296
299
  appendUniquePlugin(processorOptions.hastPlugins, photoswipe(), seen);
297
300
  appendUniquePlugin(processorOptions.hastPlugins, imageCaptions, seen);
298
301
  appendUniquePlugin(processorOptions.hastPlugins, featureFlagsHast, seen);
@@ -0,0 +1,34 @@
1
+ import { defineHastPlugin } from "satteri";
2
+
3
+ /**
4
+ * Markdown 图片标题里的 `宽x高` 是外部图片的尺寸声明,不是给读者看的提示。
5
+ *
6
+ * 外部图片无法在构建期读取像素尺寸,浏览器只能等下载完才知道比例,正文会被
7
+ * 推开(CLS)。写成 `![alt](https://… "532x200")` 后,这里在 HAST 阶段补上
8
+ * width/height,PhotoSwipe 也能拿到 data-pswp-width/height;标题本身随即移除。
9
+ */
10
+ const DIMENSION_TITLE = /^(\d{1,6})\s*[x×]\s*(\d{1,6})$/u;
11
+
12
+ export const imageDimensions = defineHastPlugin({
13
+ name: "stalux-image-dimensions",
14
+ element: {
15
+ filter: ["img"],
16
+ visit(node, ctx) {
17
+ const title = node.properties?.title;
18
+ if (typeof title !== "string") return;
19
+ const match = DIMENSION_TITLE.exec(title.trim());
20
+ if (!match) return;
21
+ const width = Number(match[1]);
22
+ const height = Number(match[2]);
23
+ if (!(width > 0 && height > 0)) return;
24
+ if (node.properties?.width === undefined) {
25
+ ctx.setProperty(node, "width", String(width));
26
+ }
27
+ if (node.properties?.height === undefined) {
28
+ ctx.setProperty(node, "height", String(height));
29
+ }
30
+ // 尺寸声明已转成属性,不再让浏览器把它当成悬浮提示。
31
+ ctx.setProperty(node, "title", undefined);
32
+ },
33
+ },
34
+ });
@@ -202,6 +202,105 @@ function initializeClarity(id: string): void {
202
202
  });
203
203
  }
204
204
 
205
+ /**
206
+ * GA's SDK is 195 KB compressed / ~610 KB parsed, larger than every first-party
207
+ * asset combined. The dataLayer queue and the page_view snapshot do not need it:
208
+ * events are pushed immediately and consumed when the SDK finally arrives.
209
+ * Loading is gated on the same signals that mean "first paint is done" instead
210
+ * of `window.load`, which slow third-party resources can postpone forever.
211
+ */
212
+ const GOOGLE_SDK_FLOOR_MS = 1200;
213
+ const GOOGLE_SDK_LCP_QUIET_MS = 600;
214
+ const GOOGLE_SDK_CEILING_MS = 6000;
215
+ let googleSdkStarted = false;
216
+ let googleGateInstalled = false;
217
+ let googleGateOpen = false;
218
+
219
+ function runBackgroundTask(callback: () => void): void {
220
+ const scheduler = (
221
+ window as Window & {
222
+ scheduler?: {
223
+ postTask: (
224
+ task: () => void,
225
+ options: { priority: "background" },
226
+ ) => Promise<unknown>;
227
+ };
228
+ }
229
+ ).scheduler;
230
+ if (scheduler?.postTask) {
231
+ void scheduler
232
+ .postTask(callback, { priority: "background" })
233
+ .catch(() => setTimeout(callback, 0));
234
+ return;
235
+ }
236
+ setTimeout(callback, 0);
237
+ }
238
+
239
+ function startGoogleSdk(): void {
240
+ if (googleSdkStarted || !state.googleId) return;
241
+ googleSdkStarted = true;
242
+ void loadTrackerScript(
243
+ `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(state.googleId)}`,
244
+ { "data-stalux-google": "true" },
245
+ ).catch(() => logger.debug("Google SDK unavailable; queued events retained"));
246
+ }
247
+
248
+ /** Three independent doors: first input, LCP quiet, and a hard ceiling after parsing. */
249
+ function installGoogleGate(): void {
250
+ if (googleGateInstalled) return;
251
+ googleGateInstalled = true;
252
+ const startedAt = performance.now();
253
+ const floorAt = startedAt + GOOGLE_SDK_FLOOR_MS;
254
+ const ceilingAt = startedAt + GOOGLE_SDK_CEILING_MS;
255
+ let quietTimer: ReturnType<typeof setTimeout> | undefined;
256
+ let ceilingTimer: ReturnType<typeof setTimeout> | undefined;
257
+ let observer: PerformanceObserver | undefined;
258
+
259
+ const cleanup = () => {
260
+ for (const type of ["pointerdown", "keydown", "wheel", "touchstart"]) {
261
+ window.removeEventListener(type, handleInteraction);
262
+ }
263
+ if (quietTimer !== undefined) clearTimeout(quietTimer);
264
+ if (ceilingTimer !== undefined) clearTimeout(ceilingTimer);
265
+ observer?.disconnect();
266
+ observer = undefined;
267
+ };
268
+ const open = () => {
269
+ if (googleGateOpen) return;
270
+ googleGateOpen = true;
271
+ cleanup();
272
+ // Never compete with the input that just opened the gate.
273
+ runBackgroundTask(startGoogleSdk);
274
+ };
275
+ function handleInteraction(): void {
276
+ open();
277
+ }
278
+ const scheduleQuiet = () => {
279
+ if (googleGateOpen) return;
280
+ if (quietTimer !== undefined) clearTimeout(quietTimer);
281
+ const wait = Math.max(GOOGLE_SDK_LCP_QUIET_MS, floorAt - performance.now());
282
+ quietTimer = setTimeout(() => {
283
+ quietTimer = undefined;
284
+ if (performance.now() >= floorAt) open();
285
+ else scheduleQuiet();
286
+ }, wait);
287
+ };
288
+
289
+ if ("PerformanceObserver" in window) {
290
+ try {
291
+ observer = new window.PerformanceObserver(scheduleQuiet);
292
+ observer.observe({ type: "largest-contentful-paint", buffered: true });
293
+ } catch {
294
+ // Older engines may know the constructor but not this entry type.
295
+ }
296
+ }
297
+ for (const type of ["pointerdown", "keydown", "wheel", "touchstart"]) {
298
+ window.addEventListener(type, handleInteraction, { passive: true, once: true });
299
+ }
300
+ ceilingTimer = setTimeout(open, Math.max(0, ceilingAt - startedAt));
301
+ if (document.hidden) open();
302
+ }
303
+
205
304
  function initializeTrackers(): void {
206
305
  const config = document.body?.dataset;
207
306
  if (!config) return;
@@ -218,10 +317,10 @@ function initializeTrackers(): void {
218
317
  host.gtag("js", new Date());
219
318
  host.gtag("config", state.googleId, { send_page_view: false });
220
319
  }
221
- void loadTrackerScript(
222
- `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(state.googleId)}`,
223
- { "data-stalux-google": "true" },
224
- ).catch(() => logger.debug("Google SDK unavailable; queued events retained"));
320
+ installGoogleGate();
321
+ // A navigation can expose the measurement ID after the first document
322
+ // already opened its gate; in that case load immediately.
323
+ if (googleGateOpen) startGoogleSdk();
225
324
  }
226
325
  if (config.staluxClarityId) initializeClarity(config.staluxClarityId);
227
326
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Link-page icons come from arbitrary third-party hosts. Native lazy loading
3
+ * still fetches everything within the browser's large lazy threshold (up to
4
+ * 3000px), and a single link list therefore pulled several megabytes of
5
+ * avatars that the reader never reached. Load only icons near the viewport.
6
+ */
7
+ import { createClientLogger } from "./logger";
8
+ import { registerPageLifecycle } from "./page-runtime";
9
+
10
+ const logger = createClientLogger("link-icons");
11
+
12
+ function reveal(image: HTMLImageElement): void {
13
+ const source = image.dataset.src;
14
+ if (!source) return;
15
+ delete image.dataset.src;
16
+ image.addEventListener(
17
+ "error",
18
+ () => {
19
+ // Keep the reserved box; the stylesheet draws a neutral disc instead.
20
+ image.removeAttribute("src");
21
+ image.dataset.staluxLinkIconFailed = "true";
22
+ logger.debug("link icon failed to load; placeholder retained");
23
+ },
24
+ { once: true },
25
+ );
26
+ image.src = source;
27
+ }
28
+
29
+ export function mountLinkIcons(): (() => void) | undefined {
30
+ const images = Array.from(
31
+ document.querySelectorAll<HTMLImageElement>("img[data-stalux-link-icon]"),
32
+ );
33
+ if (!images.length) return;
34
+
35
+ if (!("IntersectionObserver" in window)) {
36
+ for (const image of images) reveal(image);
37
+ return;
38
+ }
39
+
40
+ const observer = new IntersectionObserver(
41
+ (entries) => {
42
+ for (const entry of entries) {
43
+ if (!entry.isIntersecting) continue;
44
+ observer.unobserve(entry.target);
45
+ reveal(entry.target as HTMLImageElement);
46
+ }
47
+ },
48
+ { rootMargin: "400px 0px" },
49
+ );
50
+ for (const image of images) observer.observe(image);
51
+ return () => observer.disconnect();
52
+ }
53
+
54
+ registerPageLifecycle("link-icons", mountLinkIcons);
@@ -6,6 +6,9 @@ import { createRetryableInitializer } from "./retryable-initializer";
6
6
  const logger = createClientLogger("photoswipe");
7
7
 
8
8
  const GALLERY_SELECTOR = "a.pswp-gallery-item";
9
+ // The article header, summary and keyword span also carry data-pagefind-body.
10
+ // Selecting the first of those gave PhotoSwipe an empty gallery (counter "1 / 0").
11
+ const GALLERY_CONTAINER_SELECTOR = "[data-stalux-gallery='true'] [data-stalux-gallery-body]";
9
12
 
10
13
  async function enrichPhotoSwipeData(container: Element, signal: AbortSignal): Promise<void> {
11
14
  const links = container.querySelectorAll<HTMLAnchorElement>(GALLERY_SELECTOR);
@@ -40,9 +43,7 @@ async function enrichPhotoSwipeData(container: Element, signal: AbortSignal): Pr
40
43
  }
41
44
 
42
45
  registerPageLifecycle("photoswipe", () => {
43
- const container = document.querySelector<HTMLElement>(
44
- "[data-stalux-gallery='true'] [data-pagefind-body]",
45
- );
46
+ const container = document.querySelector<HTMLElement>(GALLERY_CONTAINER_SELECTOR);
46
47
  if (!container) return;
47
48
 
48
49
  const controller = new AbortController();
@@ -106,21 +106,47 @@ export function mountRandomPosts(): (() => void) | undefined {
106
106
  }
107
107
  }
108
108
  };
109
+ let started = false;
110
+ let observer: IntersectionObserver | undefined;
111
+ const start = () => {
112
+ if (started || disposed) return;
113
+ started = true;
114
+ observer?.disconnect();
115
+ observer = undefined;
116
+ void refresh();
117
+ };
109
118
  container.querySelector("#refresh-random-posts")?.addEventListener(
110
119
  "click",
111
120
  (event) => {
112
121
  event.preventDefault();
122
+ if (!started) {
123
+ start();
124
+ return;
125
+ }
113
126
  void refresh();
114
127
  },
115
128
  { signal: controller.signal },
116
129
  );
117
130
  reducedMotion.addEventListener("change", cancelAnimation, { signal: controller.signal });
118
- void refresh();
131
+ if ("IntersectionObserver" in window) {
132
+ // The sidebar sits below the article on phones; do not fetch its index
133
+ // until the reader is close enough to see it.
134
+ observer = new IntersectionObserver(
135
+ (entries) => {
136
+ if (entries.some((entry) => entry.isIntersecting)) start();
137
+ },
138
+ { rootMargin: "300px 0px" },
139
+ );
140
+ observer.observe(container);
141
+ } else {
142
+ start();
143
+ }
119
144
  return () => {
120
145
  disposed = true;
121
146
  revision++;
122
147
  request?.abort();
123
148
  controller.abort();
149
+ observer?.disconnect();
124
150
  cancelAnimation();
125
151
  container.removeAttribute("aria-busy");
126
152
  };
@@ -1,3 +1,3 @@
1
1
  @layer theme, base, components, utilities;
2
- @layer components{.stalux-link-card{color:#fff;box-sizing:border-box;background:var(--surface-glass);width:100%;min-width:0;min-height:200px;box-shadow:var(--card-box-shadow);border-radius:10px;flex-direction:column;padding:16px;text-decoration:none;transition:transform .22s,background-color .22s,border-color .22s;display:flex;overflow:visible}.stalux-link-card-content{text-align:center;flex-direction:column;flex:1;display:flex}.stalux-links{box-sizing:border-box;grid-template-columns:repeat(auto-fill,minmax(min(180px,100%),1fr));gap:20px;width:100%;max-width:1600px;margin:30px auto;padding:0 1rem;list-style-type:none;display:grid}.stalux-link-card-avatar{justify-content:center;margin-bottom:12px;display:flex}.stalux-link-list-item{width:100%;min-width:0;display:flex}.stalux-link-shell{width:100%;min-width:0}.stalux-link-card-content{transition:transform .2s}.stalux-link-card-avatar img{object-fit:cover;border:2px solid var(--accent-60p);transform-origin:50%;border-radius:9999px;width:80px;height:80px;transition:transform .22s,border-color .22s}.stalux-link-card-title{white-space:nowrap;background:linear-gradient(90deg, var(--white-95p), var(--accent-90p));color:#0000;text-overflow:ellipsis;background-clip:text;margin:0 0 8px;font-size:1.1rem;overflow:hidden}.stalux-link-card-description{color:var(--white-78p);-webkit-line-clamp:3;text-overflow:ellipsis;-webkit-box-orient:vertical;margin:0;font-size:.95rem;line-height:1.45;display:-webkit-box;overflow:hidden}@media (1100px<=width<=1399px){.stalux-links{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (480px<=width<=767px){.stalux-links{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}}@media (768px<=width<=1099px){.stalux-links{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (hover:hover) and (pointer:fine){.stalux-link-shell:hover .stalux-link-card-content{transform:translateY(-2px)}.stalux-link-shell:hover .stalux-link-card{background:var(--white-05p);box-shadow:var(--card-box-shadow-hover)}.stalux-link-card-avatar:hover img{border-color:var(--accent-85p);transform:rotate(3deg)}}@media (width>=1400px){.stalux-links{grid-template-columns:repeat(5,minmax(0,1fr))}}@media (width<=480px){.stalux-link-card{padding:14px}.stalux-link-card-avatar img{width:70px;height:70px}.stalux-link-card-title{font-size:1rem}.stalux-link-card-description{font-size:.9rem}}@media (width<=479px){.stalux-links{grid-template-columns:1fr;gap:14px;margin:20px auto}}@media (prefers-reduced-motion:reduce) and (hover:hover) and (pointer:fine){.stalux-link-card-avatar:hover img{transition:none;animation:none;transform:none}}}@layer utilities,theme,base;
2
+ @layer components{.stalux-link-card{color:#fff;box-sizing:border-box;background:var(--surface-glass);width:100%;min-width:0;min-height:200px;box-shadow:var(--card-box-shadow);border-radius:10px;flex-direction:column;padding:16px;text-decoration:none;transition:transform .22s,background-color .22s,border-color .22s;display:flex;overflow:visible}.stalux-link-card-content{text-align:center;flex-direction:column;flex:1;display:flex}.stalux-links{box-sizing:border-box;grid-template-columns:repeat(auto-fill,minmax(min(180px,100%),1fr));gap:20px;width:100%;max-width:1600px;margin:30px auto;padding:0 1rem;list-style-type:none;display:grid}.stalux-link-card-avatar{justify-content:center;margin-bottom:12px;display:flex}.stalux-link-list-item{width:100%;min-width:0;display:flex}.stalux-link-shell{width:100%;min-width:0}.stalux-link-card-content{transition:transform .2s}.stalux-link-card-avatar img{object-fit:cover;border:2px solid var(--accent-60p);transform-origin:50%;border-radius:9999px;width:80px;height:80px;transition:transform .22s,border-color .22s}.stalux-link-card-avatar img[data-src],.stalux-link-card-avatar img[data-stalux-link-icon-failed]{background:var(--white-05p)}.stalux-link-card-title{white-space:nowrap;background:linear-gradient(90deg, var(--white-95p), var(--accent-90p));color:#0000;text-overflow:ellipsis;background-clip:text;margin:0 0 8px;font-size:1.1rem;overflow:hidden}.stalux-link-card-description{color:var(--white-78p);-webkit-line-clamp:3;text-overflow:ellipsis;-webkit-box-orient:vertical;margin:0;font-size:.95rem;line-height:1.45;display:-webkit-box;overflow:hidden}@media (1100px<=width<=1399px){.stalux-links{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (480px<=width<=767px){.stalux-links{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}}@media (768px<=width<=1099px){.stalux-links{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (hover:hover) and (pointer:fine){.stalux-link-shell:hover .stalux-link-card-content{transform:translateY(-2px)}.stalux-link-shell:hover .stalux-link-card{background:var(--white-05p);box-shadow:var(--card-box-shadow-hover)}.stalux-link-card-avatar:hover img{border-color:var(--accent-85p);transform:rotate(3deg)}}@media (width>=1400px){.stalux-links{grid-template-columns:repeat(5,minmax(0,1fr))}}@media (width<=480px){.stalux-link-card{padding:14px}.stalux-link-card-avatar img{width:70px;height:70px}.stalux-link-card-title{font-size:1rem}.stalux-link-card-description{font-size:.9rem}}@media (width<=479px){.stalux-links{grid-template-columns:1fr;gap:14px;margin:20px auto}}@media (prefers-reduced-motion:reduce) and (hover:hover) and (pointer:fine){.stalux-link-card-avatar:hover img{transition:none;animation:none;transform:none}}}@layer utilities,theme,base;
3
3
  /*$vite$:1*/