@xingwangzhe/stalux 1.45.1 → 1.46.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.45.1",
3
+ "version": "1.46.1",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -7,9 +7,9 @@ import { getCategoryCountList } from "@utils/collections-stats";
7
7
  const categoryList = await getCategoryCountList(Astro.logger);
8
8
  ---
9
9
 
10
- <ul class="stalux-categories" data-stalux-motion-content>
10
+ <ul class="stalux-categories" data-stalux-motion-content data-stalux-reveal>
11
11
  {categoryList.map((category) => (
12
- <li>
12
+ <li data-stalux-reveal-item>
13
13
  <CategoriesCard category={category} />
14
14
  </li>
15
15
  ))}
@@ -10,9 +10,9 @@ const linksConfig = getLinksData(configEntries);
10
10
  const links = linksConfig?.sites ?? [];
11
11
  ---
12
12
 
13
- <ul class="stalux-links" data-stalux-motion-content>
13
+ <ul class="stalux-links" data-stalux-motion-content data-stalux-reveal>
14
14
  {links.map((link) => (
15
- <li class="stalux-link-list-item">
15
+ <li class="stalux-link-list-item" data-stalux-reveal-item>
16
16
  <LinkCard link={link} />
17
17
  </li>
18
18
  ))}
@@ -40,6 +40,7 @@ const description = post.data.desc || "";
40
40
  fetchpriority={priority ? "high" : "auto"}
41
41
  width="640"
42
42
  height="360"
43
+ data-stalux-cover
43
44
  />
44
45
  </div>
45
46
  </a>
@@ -12,9 +12,9 @@ const posts = props.posts ?? (await getCollection("posts", ({ data }) => !data.d
12
12
  ---
13
13
 
14
14
  <div data-stalux="postlist" class="stalux-post-list-container" data-stalux-motion-content>
15
- <ul class="post-list">
15
+ <ul class="post-list" data-stalux-reveal>
16
16
  {posts.map((post, index) => (
17
- <li>
17
+ <li data-stalux-reveal-item>
18
18
  <PostCard post={post} priority={index === 0} />
19
19
  </li>
20
20
  ))}
@@ -25,7 +25,7 @@ const tagLinks = tagList.map((t) => ({
25
25
  <div class="stalux-cloud-cards" id="tags-cards" data-stalux-tag-cards data-stalux-motion-content>
26
26
  {tagList.length > 0 ? (
27
27
  tagGroups.map((group) => (
28
- <div class="stalux-cloud-tag-group">
28
+ <div class="stalux-cloud-tag-group" data-stalux-reveal>
29
29
  {group.map(({ name, count }) => (
30
30
  <a
31
31
  href={`/tags/${name}/`}
@@ -33,6 +33,7 @@ const tagLinks = tagList.map((t) => ({
33
33
  data-stalux-card
34
34
  data-stalux-tag-card
35
35
  data-stalux-control="tag-card"
36
+ data-stalux-reveal-item
36
37
  data-count={count}
37
38
  data-astro-prefetch
38
39
  >
@@ -19,10 +19,10 @@ const sorted = [...entries].sort((a, b) => {
19
19
  });
20
20
  ---
21
21
 
22
- <ul class="stalux-word-list-list" data-stalux-motion-content>
22
+ <ul class="stalux-word-list-list" data-stalux-motion-content data-stalux-reveal>
23
23
  {sorted.map((entry) => {
24
24
  return (
25
- <li class="stalux-word-list-item">
25
+ <li class="stalux-word-list-item" data-stalux-reveal-item>
26
26
  <WordCard entry={entry} timezone={timezone} />
27
27
  </li>
28
28
  );
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ import { createInjectedRoutes } from "./internal/injected-routes";
49
49
  import { markdownPropagationPlugin } from "./internal/markdown-propagation";
50
50
  import { containMermaidSvgs } from "./internal/offscreen-layout";
51
51
  import { createPageOutputCache } from "./internal/page-output-cache";
52
+ import { loadCoverEncoder, restoreCoverFiles, upgradeCoverImages } from "./internal/post-cover";
52
53
  import { isPluginProject } from "./internal/project-mode";
53
54
  import { syncBackgroundSvgs, syncFontAssets } from "./internal/public-assets";
54
55
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
@@ -121,6 +122,9 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
121
122
  };
122
123
 
123
124
  const socialCardPaths = { publicDir: "", cacheDir: "", base: "/", fontPath: "" };
125
+ // Content-addressed card covers, kept outside the build output so a page that
126
+ // is served from the page-output cache still finds its file on disk.
127
+ let coverCacheDir = "";
124
128
 
125
129
  let pageFontData: { fontBuffer: Buffer; outputDir: string; cacheDir: string } | undefined;
126
130
 
@@ -378,6 +382,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
378
382
  base: config.base,
379
383
  fontPath: path.join(srcDir, "assets/fonts/LXGWWenKai-Regular.ttf"),
380
384
  });
385
+ coverCacheDir = fileURLToPath(new URL("stalux-post-covers/", config.cacheDir));
381
386
  const assetsLogger = logger.fork("stalux/assets");
382
387
  if (pageFontData) {
383
388
  pageFontData.outputDir = path.join(
@@ -411,6 +416,10 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
411
416
  const htmlFiles = await fs.readdir(outDir, { recursive: true });
412
417
  const sourceDir = fileURLToPath(new URL(".", import.meta.url));
413
418
  const resolveModule = createRequire(import.meta.url).resolve;
419
+ const requireModule = createRequire(import.meta.url);
420
+ const coverEncoder = coverCacheDir
421
+ ? loadCoverEncoder(requireModule, logger)
422
+ : undefined;
414
423
  const parserEntry = resolveModule("parse5");
415
424
  const imageSizeEntry = resolveModule("image-size");
416
425
  const nativeEntry = resolveModule("@xingwangzhe/cjk-font-split-native");
@@ -510,6 +519,22 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
510
519
  loadingPolicy: true,
511
520
  dependencies,
512
521
  });
522
+ // Card covers become content-addressed WebP. The encoded files
523
+ // are restored into the output at the end of every build, so a
524
+ // page served from the page-output cache still resolves them.
525
+ if (coverEncoder) {
526
+ try {
527
+ await upgradeCoverImages(images, {
528
+ outDir,
529
+ cacheDir: coverCacheDir,
530
+ sharp: coverEncoder,
531
+ dependencies,
532
+ logger,
533
+ });
534
+ } catch (error) {
535
+ logger.warn(`封面转 WebP 失败:${describeError(error)}`);
536
+ }
537
+ }
513
538
  if (pageFontData) {
514
539
  const linkedStylesheetText = readLinkedStylesheetText(
515
540
  "",
@@ -579,6 +604,13 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
579
604
  logger.info(
580
605
  `HTML postprocessing ${(performance.now() - started).toFixed(1)}ms; cache ${cacheHits}/${htmlFiles.filter((entry) => entry.endsWith(".html")).length}`,
581
606
  );
607
+ // Covers are emitted here rather than only while a page is
608
+ // post-processed: a cached page skips that pass entirely but its
609
+ // HTML already points at these files, so they must always exist.
610
+ if (coverCacheDir) {
611
+ const restored = restoreCoverFiles({ outDir, cacheDir: coverCacheDir });
612
+ if (restored > 0) logger.debug(`restored ${restored} card covers`);
613
+ }
582
614
 
583
615
  // 后处理:Pagefind 搜索索引。启用时任何失败都必须让构建失败,
584
616
  // 避免发布一个页面正常但搜索已损坏的产物。
@@ -0,0 +1,147 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import path from "node:path";
11
+ import type { HtmlImage } from "./html-images";
12
+
13
+ /** Public directory holding the generated covers, relative to the build output. */
14
+ export const COVER_DIR = "stalux-covers";
15
+
16
+ // Post cards render at 640x360 and are shown on 2x screens, so 1280 is the
17
+ // widest useful source. Bigger originals only cost bytes.
18
+ const MAX_WIDTH = 1280;
19
+ const QUALITY = 82;
20
+ // Bump when any encoder input above changes, so cached files are not reused.
21
+ const RECIPE = "v1";
22
+
23
+ /** Formats worth re-encoding. Vector and icon sources stay untouched. */
24
+ const CONVERTIBLE = /\.(?:png|jpe?g|avif|tiff?|bmp)$/iu;
25
+
26
+ interface SharpPipeline {
27
+ resize(options: Record<string, unknown>): SharpPipeline;
28
+ webp(options: Record<string, unknown>): SharpPipeline;
29
+ toBuffer(): Promise<Buffer>;
30
+ }
31
+ type SharpFactory = (input: Buffer) => SharpPipeline;
32
+
33
+ /**
34
+ * `sharp` ships as a dependency of Astro itself and as a dev dependency of the
35
+ * theme, so it is present in practice — but it must never become a hard
36
+ * requirement, because a project using another image service would otherwise
37
+ * fail to build at all. Loaded through `require` because the Vite module runner
38
+ * is already closed by the time `astro:build:done` runs.
39
+ */
40
+ export function loadCoverEncoder(
41
+ require: (id: string) => unknown,
42
+ logger?: { warn: (message: string) => void },
43
+ ): SharpFactory | undefined {
44
+ try {
45
+ const loaded = require("sharp") as { default?: unknown };
46
+ const factory = (loaded?.default ?? loaded) as unknown;
47
+ return typeof factory === "function" ? (factory as SharpFactory) : undefined;
48
+ } catch {
49
+ logger?.warn("封面转 WebP 已跳过:未找到 sharp,卡片继续使用原图");
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ export interface CoverContext {
55
+ outDir: string;
56
+ cacheDir: string;
57
+ sharp: SharpFactory;
58
+ /** Source files this page depends on, so editing a cover invalidates it. */
59
+ dependencies?: Set<string>;
60
+ logger?: { warn: (message: string) => void };
61
+ }
62
+
63
+ const digest = (parts: readonly (string | Buffer)[]): string => {
64
+ const hash = createHash("sha256");
65
+ for (const part of parts) hash.update(part).update("\0");
66
+ return hash.digest("hex");
67
+ };
68
+
69
+ /**
70
+ * Rewrites a marked card cover to a generated WebP.
71
+ *
72
+ * The name is content-addressed, so an unchanged cover keeps the same URL across
73
+ * builds and stays cacheable at the CDN, while an edited one gets a new name
74
+ * instead of a stale hit. The encoded file is kept in a cache directory outside
75
+ * the build output; `restoreCoverFiles` copies those back in at the end of every
76
+ * build, which is what keeps them present when a page was served from the
77
+ * page-output cache and this per-page pass never ran for it.
78
+ */
79
+ export async function upgradeCoverImages(
80
+ images: readonly HtmlImage[],
81
+ context: CoverContext,
82
+ ): Promise<number> {
83
+ const root = path.resolve(context.outDir);
84
+ const cacheDir = path.join(context.cacheDir, RECIPE);
85
+ let converted = 0;
86
+ for (const image of images) {
87
+ if (!image.attributes.has("data-stalux-cover")) continue;
88
+ const src = image.attributes.get("src");
89
+ if (!src?.startsWith("/")) continue;
90
+ let pathname: string;
91
+ try {
92
+ pathname = decodeURIComponent(new URL(src, "https://stalux.invalid").pathname);
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (!CONVERTIBLE.test(pathname)) continue;
97
+ const source = path.resolve(root, `.${pathname}`);
98
+ if (!source.startsWith(`${root}${path.sep}`)) continue;
99
+ if (!existsSync(source)) continue;
100
+ context.dependencies?.add(source);
101
+
102
+ let bytes: Buffer;
103
+ try {
104
+ bytes = readFileSync(source);
105
+ } catch {
106
+ continue;
107
+ }
108
+ const hash = digest([RECIPE, "webp", String(MAX_WIDTH), String(QUALITY), bytes]);
109
+ const filename = `${hash.slice(0, 16)}.webp`;
110
+ const cached = path.join(cacheDir, filename);
111
+ if (!existsSync(cached)) {
112
+ try {
113
+ const encoded = await context
114
+ .sharp(bytes)
115
+ .resize({ width: MAX_WIDTH, withoutEnlargement: true })
116
+ .webp({ quality: QUALITY, effort: 5 })
117
+ .toBuffer();
118
+ mkdirSync(cacheDir, { recursive: true });
119
+ writeFileSync(cached, encoded);
120
+ } catch (error) {
121
+ // One malformed source must not fail the page it appears on.
122
+ context.logger?.warn(`封面转 WebP 失败(${pathname}):${String(error)}`);
123
+ continue;
124
+ }
125
+ }
126
+ const attribute = image.node.attrs.find((entry) => entry.name === "src");
127
+ if (!attribute) continue;
128
+ attribute.value = `/${COVER_DIR}/${filename}`;
129
+ converted++;
130
+ }
131
+ return converted;
132
+ }
133
+
134
+ /** Copy every cached cover into the build output so cached pages still resolve. */
135
+ export function restoreCoverFiles(context: { outDir: string; cacheDir: string }): number {
136
+ const cacheDir = path.join(context.cacheDir, RECIPE);
137
+ if (!existsSync(cacheDir)) return 0;
138
+ const target = path.join(context.outDir, COVER_DIR);
139
+ mkdirSync(target, { recursive: true });
140
+ let copied = 0;
141
+ for (const name of readdirSync(cacheDir)) {
142
+ if (!name.endsWith(".webp")) continue;
143
+ copyFileSync(path.join(cacheDir, name), path.join(target, name));
144
+ copied++;
145
+ }
146
+ return copied;
147
+ }
@@ -23,6 +23,23 @@ function getRenderer(fontPath: string): Promise<Renderer> {
23
23
  return rendererPromise;
24
24
  }
25
25
 
26
+ /**
27
+ * Font identity for the render cache.
28
+ *
29
+ * The key used to carry the font's size and mtime, so re-installing the theme,
30
+ * restoring a checkout, or copying the package to a build machine changed every
31
+ * card's key at once and re-rendered all of them (about 8s each). Hashing the
32
+ * bytes keeps the key stable for identical font content and still changes when
33
+ * the font really is different.
34
+ */
35
+ let fontDigestPromise: Promise<string> | undefined;
36
+ function getFontDigest(fontPath: string): Promise<string> {
37
+ fontDigestPromise ??= readFile(fontPath).then((bytes) =>
38
+ createHash("sha256").update(bytes).digest("hex"),
39
+ );
40
+ return fontDigestPromise;
41
+ }
42
+
26
43
  /** Resolve only assets inside the final public directory, including encoded Chinese paths. */
27
44
  export function localCoverPath(cover: string, paths: SocialCardPaths): string | undefined {
28
45
  if (!cover.startsWith("/") || cover.startsWith("//")) return undefined;
@@ -248,9 +265,11 @@ export async function renderSocialCard(
248
265
  warn(`分享封面不可用,已回退标题卡片:${data.title}`);
249
266
  }
250
267
  }
251
- const font = await stat(paths.fontPath);
268
+ // Content-only key: identical card data + identical font + identical cover
269
+ // always reuses the cached render, whatever the filesystem timestamps say.
270
+ const fontDigest = await getFontDigest(paths.fontPath);
252
271
  const key = createHash("sha256")
253
- .update(JSON.stringify(["v2", format, data, font.size, font.mtimeMs]))
272
+ .update(JSON.stringify(["v3", format, data, fontDigest]))
254
273
  .update(cover ?? "")
255
274
  .digest("hex");
256
275
  const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
@@ -76,6 +76,13 @@ const routeKind = getRouteKind(Astro.url.pathname);
76
76
  import "../scripts/background.ts";
77
77
  import "../scripts/module-motion.ts";
78
78
  import "../scripts/view-transitions.ts";
79
+ // Every page type has at least one list, so the unroll cue is registered
80
+ // once in the shared layout instead of per page. Page-runtime dedupes it
81
+ // against the article and archive components that also request it.
82
+ import { mountListReveal } from "../scripts/list-reveal";
83
+ import { registerPageLifecycle } from "../scripts/page-runtime";
84
+
85
+ registerPageLifecycle("list-reveal", mountListReveal);
79
86
  </script>
80
87
  {aiConfig?.conformance !== "disabled" && <script>import "../scripts/webmcp.ts";</script>}
81
88
  <body
@@ -19,12 +19,15 @@ export async function getStaticPaths() {
19
19
  )
20
20
  .flatMap((post) => {
21
21
  const card = createSocialCardData(post.data, site, author);
22
- return (["webp", "png"] as const).map((format) => ({
22
+ // PNG only. og:image must stay PNG because several platforms refuse
23
+ // WebP and AVIF in share previews, and a second format per post
24
+ // doubled the render work (about 8s each) for no visible gain.
25
+ return (["png"] as const).map((format) => ({
23
26
  params: {
24
27
  image: socialCardPath(card)
25
28
  .split("/")
26
29
  .at(-1)
27
- ?.replace(/\.webp$/u, ""),
30
+ ?.replace(/\.png$/u, ""),
28
31
  format,
29
32
  },
30
33
  props: { card, format },
@@ -4,16 +4,32 @@ const ITEM = ":scope > [data-stalux-reveal-item]";
4
4
  // never a queue long enough to keep the reader waiting for the last item.
5
5
  const MAX_CONTAINERS = 48;
6
6
  const MAX_ITEMS = 16;
7
- const STAGGER_MS = 22;
8
- const MAX_DELAY_MS = 176;
9
- const DURATION_MS = 240;
7
+ const STAGGER_MS = 45;
8
+ const MAX_DELAY_MS = 400;
9
+ const DURATION_MS = 380;
10
+ // When a route cue is already moving the page body, the sheet waits for it to
11
+ // mostly settle before unrolling. Starting both at once makes the rows wipe
12
+ // open while the whole page is still semi-transparent, which reads as a smudge.
13
+ const ROUTE_LEAD_IN_MS = 150;
14
+ // Strong ease-out: the row arrives fast and settles softly. The built-in
15
+ // ease-out is too weak to read at this size.
16
+ const EASING = "cubic-bezier(0.23, 1, 0.32, 1)";
17
+ // The clip is inset by a negative margin so a card's box-shadow is not sheared
18
+ // off by the wipe. A hard `inset(0)` would clip the shadow to the border box and
19
+ // make it pop back in when the effect is released.
20
+ const BLEED = "-10px";
21
+ const UNROLL_START = `inset(${BLEED} ${BLEED} 100% ${BLEED})`;
22
+ const UNROLL_END = `inset(${BLEED} ${BLEED} ${BLEED} ${BLEED})`;
10
23
 
11
24
  /**
12
- * Lists cascade in once, when their group becomes visible.
25
+ * Rows unroll into place like paper: each one wipes open from its top edge
26
+ * downward while settling a few pixels, and the next row starts before the
27
+ * previous one finishes so the group reads as one continuous sheet unrolling.
13
28
  *
14
- * Text is authored fully visible; the effect only dims a group that is about to
15
- * be read. Rows outside the group cap, restored history entries, reduced
16
- * motion, persisted shells and hidden documents are left untouched.
29
+ * Text is authored fully visible. The effect only ever runs on a group that is
30
+ * about to be read, is skipped for restored history, reduced motion, hidden
31
+ * documents and persisted shells, and every clip is released afterwards so no
32
+ * clipped state can outlive the animation.
17
33
  */
18
34
  export function mountListReveal(): (() => void) | undefined {
19
35
  if (typeof IntersectionObserver !== "function") return;
@@ -25,6 +41,8 @@ export function mountListReveal(): (() => void) | undefined {
25
41
  .filter((element) => !element.closest("[data-swup-persist]"))
26
42
  .slice(0, MAX_CONTAINERS);
27
43
  if (!containers.length) return;
44
+ // A route cue leaves its preset on the document element while it runs.
45
+ const leadIn = document.documentElement.dataset.staluxVtPreset ? ROUTE_LEAD_IN_MS : 0;
28
46
 
29
47
  const active = new Set<Animation>();
30
48
  const cancel = () => {
@@ -38,21 +56,30 @@ export function mountListReveal(): (() => void) | undefined {
38
56
  if (typeof item.animate !== "function") continue;
39
57
  try {
40
58
  const animation = item.animate(
41
- {
42
- opacity: [0.6, 1],
43
- transform: ["translateY(8px)", "translateY(0)"],
44
- },
59
+ [
60
+ {
61
+ clipPath: UNROLL_START,
62
+ opacity: 0.4,
63
+ transform: "translate3d(0, -10px, 0)",
64
+ },
65
+ {
66
+ clipPath: UNROLL_END,
67
+ opacity: 1,
68
+ transform: "translate3d(0, 0, 0)",
69
+ },
70
+ ],
45
71
  {
46
72
  duration: DURATION_MS,
47
- delay: Math.min(index * STAGGER_MS, MAX_DELAY_MS),
48
- easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
49
- // Dimmed during the stagger delay instead of popping in.
73
+ delay: leadIn + Math.min(index * STAGGER_MS, MAX_DELAY_MS),
74
+ easing: EASING,
75
+ // Hidden during the stagger delay instead of popping in.
50
76
  fill: "backwards",
51
77
  },
52
78
  );
53
79
  active.add(animation);
54
80
  const release = () => {
55
81
  active.delete(animation);
82
+ // Drops the clip so nothing stays cut off after the wipe.
56
83
  animation.cancel();
57
84
  };
58
85
  void animation.finished.then(release, release);
@@ -1,3 +1,3 @@
1
1
  @layer theme, base, components, utilities;
2
- @layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;width:100%;max-width:900px;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;min-width:0;display:flex}.stalux-word-source-meta{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.stalux-word-source-date{color:var(--white-70p)}.stalux-word-source-text{color:var(--white-90p)}.stalux-word-card{border-left:2px solid var(--accent-50p);background:rgb(var(--stalux-accent-rgb) / .045);backdrop-filter:blur(3px);overflow-wrap:anywhere;scroll-margin-top:6rem}.stalux-word-mark{color:var(--accent-60p);opacity:.45;pointer-events:none;position:absolute;top:1.5rem;right:1.5rem}.stalux-word-body{padding-right:2.5rem}.stalux-word-footer{backdrop-filter:none;text-align:left;background:0 0;padding:.75rem 0 0}.stalux-word-source-meta{text-align:right}.stalux-word-source-link{align-items:center;min-height:44px;display:inline-flex}.stalux-word-permalink{min-height:44px;color:var(--white-70p);font-variant-numeric:tabular-nums;align-items:center;display:inline-flex}.stalux-word-permalink:focus-visible,.stalux-word-source-link:focus-visible{outline:2px solid var(--stalux-accent-color);outline-offset:4px;border-radius:4px}.stalux-word-source-meta{min-width:0;margin-left:auto}.stalux-word-body>:first-child{margin-top:0}.stalux-word-body>:last-child{margin-bottom:0}.stalux-word-body .expressive-code{max-width:100%;overflow-x:auto}.stalux-word-card:target,.stalux-word-card[data-glow]{border-color:var(--accent-90p)}.stalux-word-body h1,.stalux-word-body h2,.stalux-word-body h3,.stalux-word-body h4,.stalux-word-body h5,.stalux-word-body h6{margin:.5rem 0;font-size:1.1em}.stalux-word-body p{margin:.5rem 0}.stalux-word-body blockquote{border-left:3px solid var(--accent-60p);background:var(--white-05p);margin:.5rem 0;padding:.6rem 1rem}.stalux-word-body pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible}.stalux-word-body code{overflow-wrap:anywhere}@media (hover:hover) and (pointer:fine){.stalux-word-card:hover{border-color:var(--accent-80p);box-shadow:var(--card-box-shadow-hover)}.stalux-word-source-link:hover{color:var(--accent-90p)}}@media (width<=768px){.stalux-word-card{padding:1.25rem}.stalux-word-list-list{gap:1rem;margin:1.5rem auto}.stalux-word-list-item{max-width:100%}}}@layer utilities,theme,base;
2
+ @layer components{.stalux-word-card{transition:border-color var(--transition-normal), box-shadow var(--transition-normal);box-sizing:border-box;border-radius:var(--radius-lg,12px);background:var(--surface-glass);width:100%;min-width:0;box-shadow:var(--card-box-shadow);flex-direction:column;gap:1.5rem;padding:2rem 2rem 1.25rem;display:flex;position:relative}.stalux-word-date{font-variant-numeric:tabular-nums;color:var(--white-50p)}.stalux-word-footer{border-top:1px solid var(--white-10p);color:var(--white-70p);flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.75rem;margin-top:auto;padding-top:1rem;font-size:.85rem;display:flex}.stalux-word-source-link{text-decoration-color:var(--accent-50p);text-underline-offset:.15em;transition:color var(--transition-fast);color:var(--accent-90p);text-decoration-line:underline}.stalux-word-body{min-width:0;color:var(--white-90p);font-size:1.1rem;line-height:1.9;position:relative}.stalux-word-list-list{flex-direction:column;align-items:center;gap:1.5rem;width:100%;max-width:900px;margin:2rem auto;padding:0;list-style-type:none;display:flex}.stalux-word-list-item{width:100%;min-width:0;display:flex}.stalux-word-source-meta{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:.5rem;display:flex}.stalux-word-source-date{color:var(--white-70p)}.stalux-word-source-text{color:var(--white-90p)}.stalux-word-card{border:1px solid var(--white-15p);border-left:3px solid var(--accent-50p);backdrop-filter:blur(3px);overflow-wrap:anywhere;background:0 0;scroll-margin-top:6rem}.stalux-word-mark{color:var(--accent-60p);opacity:.45;pointer-events:none;position:absolute;top:1.5rem;right:1.5rem}.stalux-word-body{padding-right:2.5rem}.stalux-word-footer{backdrop-filter:none;text-align:left;background:0 0;padding:.75rem 0 0}.stalux-word-source-meta{text-align:right}.stalux-word-source-link{align-items:center;min-height:44px;display:inline-flex}.stalux-word-permalink{min-height:44px;color:var(--white-70p);font-variant-numeric:tabular-nums;align-items:center;display:inline-flex}.stalux-word-permalink:focus-visible,.stalux-word-source-link:focus-visible{outline:2px solid var(--stalux-accent-color);outline-offset:4px;border-radius:4px}.stalux-word-source-meta{min-width:0;margin-left:auto}.stalux-word-body>:first-child{margin-top:0}.stalux-word-body>:last-child{margin-bottom:0}.stalux-word-body .expressive-code{max-width:100%;overflow-x:auto}.stalux-word-card:target,.stalux-word-card[data-glow]{border-color:var(--accent-90p)}.stalux-word-body h1,.stalux-word-body h2,.stalux-word-body h3,.stalux-word-body h4,.stalux-word-body h5,.stalux-word-body h6{margin:.5rem 0;font-size:1.1em}.stalux-word-body p{margin:.5rem 0}.stalux-word-body blockquote{border-left:3px solid var(--accent-60p);background:var(--white-05p);margin:.5rem 0;padding:.6rem 1rem}.stalux-word-body pre{white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:visible}.stalux-word-body code{overflow-wrap:anywhere}@media (hover:hover) and (pointer:fine){.stalux-word-card:hover{border-color:var(--accent-80p);box-shadow:var(--card-box-shadow-hover)}.stalux-word-source-link:hover{color:var(--accent-90p)}}@media (width<=768px){.stalux-word-card{padding:1.25rem}.stalux-word-list-list{gap:1rem;margin:1.5rem auto}.stalux-word-list-item{max-width:100%}}}@layer utilities,theme,base;
3
3
  /*$vite$:1*/
@@ -41,7 +41,7 @@ export function socialCardPath(data: SocialCardData, base = "/"): string {
41
41
  .update(JSON.stringify(["stalux-takumi-v2", data]))
42
42
  .digest("hex")
43
43
  .slice(0, 20);
44
- return `${base.replace(/\/$/u, "")}/og/${hash}.webp`;
44
+ return `${base.replace(/\/$/u, "")}/og/${hash}.png`;
45
45
  }
46
46
 
47
47
  export function resolvePostSocialImage(
@@ -50,11 +50,14 @@ export function resolvePostSocialImage(
50
50
  base = "/",
51
51
  ): SocialImage {
52
52
  if (explicit?.trim() && isSocialImageUrl(explicit.trim())) return { url: explicit.trim() };
53
+ // PNG for the sharing image, always. Several platforms (and every legacy
54
+ // crawler) refuse WebP or AVIF in og:image, so this is the one place the
55
+ // modern format costs reach instead of saving bytes. In-page images are
56
+ // served as WebP instead, where the browser decides.
53
57
  return {
54
58
  url: socialCardPath(data, base),
55
59
  width: 1200,
56
60
  height: 630,
57
- type: "image/webp",
58
- fallbackUrl: socialCardPath(data, base).replace(/\.webp$/u, ".png"),
61
+ type: "image/png",
59
62
  };
60
63
  }