@xingwangzhe/stalux 1.51.0 → 1.52.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.
Files changed (45) hide show
  1. package/.release/cli.mjs +16 -20
  2. package/package.json +47 -45
  3. package/src/components/stalux/footer/FooterBadges.astro +2 -2
  4. package/src/components/stalux/layout/head.astro +1 -1
  5. package/src/components/stalux/posts/postContent.astro +1 -1
  6. package/src/index.ts +57 -51
  7. package/src/internal/cjk-search-index.ts +4 -7
  8. package/src/internal/components-plugin.ts +1 -3
  9. package/src/internal/font-slices.ts +36 -29
  10. package/src/internal/html-images.ts +1 -1
  11. package/src/internal/html-styles.ts +1 -1
  12. package/src/internal/image-dimensions.ts +3 -8
  13. package/src/internal/injected-routes.ts +1 -3
  14. package/src/internal/page-output-cache.ts +11 -15
  15. package/src/internal/post-cover.ts +22 -26
  16. package/src/internal/project-mode.ts +3 -2
  17. package/src/internal/public-assets.ts +42 -23
  18. package/src/internal/runtime-cache-key.ts +42 -27
  19. package/src/internal/satteri-config.ts +2 -2
  20. package/src/internal/search-style.ts +6 -7
  21. package/src/internal/social-card-cover.ts +12 -57
  22. package/src/internal/social-card-data.ts +6 -4
  23. package/src/internal/social-card-layout.ts +69 -63
  24. package/src/internal/social-card-renderer.ts +48 -140
  25. package/src/internal/webmcp-mutations.ts +11 -5
  26. package/src/layouts/Stalux.astro +4 -4
  27. package/src/pages/openapi.json.ts +1 -1
  28. package/src/plugins/feature-flags.ts +2 -2
  29. package/src/schemas/collections.ts +1 -1
  30. package/src/schemas/config.ts +4 -1
  31. package/src/scripts/list-reveal.ts +7 -5
  32. package/src/scripts/swup-router.ts +1 -1
  33. package/src/scripts/weather.ts +2 -2
  34. package/src/scripts/webmcp.ts +4 -4
  35. package/src/utils/accent-color.ts +1 -1
  36. package/src/utils/ai-discovery.ts +6 -6
  37. package/src/utils/badge-generator.ts +1 -2
  38. package/src/utils/cc.ts +2 -2
  39. package/src/utils/dayjs.ts +5 -5
  40. package/src/utils/diagnostics.ts +3 -3
  41. package/src/utils/public-routes.ts +3 -3
  42. package/src/utils/seo-document.ts +2 -2
  43. package/src/utils/site-path.ts +1 -1
  44. package/src/utils/social-image.ts +6 -14
  45. package/src/internal/sharp-loader.ts +0 -45
@@ -1,9 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
1
  import path from "node:path";
4
2
  import { imageSize } from "image-size";
5
- import { loadSharp, type SharpRawBuffer } from "./sharp-loader";
6
- import { CARD_HEIGHT, COVER_WIDTH } from "./social-card-layout";
7
3
 
8
4
  export interface SocialCardPaths {
9
5
  publicDir: string;
@@ -54,8 +50,9 @@ function assertDimensions(bytes: Buffer): void {
54
50
  }
55
51
 
56
52
  async function readLocalCover(local: string): Promise<Buffer> {
57
- if ((await stat(local)).size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
58
- const bytes = await readFile(local);
53
+ const file = Bun.file(local);
54
+ if (file.size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
55
+ const bytes = Buffer.from(await file.bytes());
59
56
  assertDimensions(bytes);
60
57
  return bytes;
61
58
  }
@@ -73,16 +70,15 @@ async function readResponseBytes(response: Response): Promise<Buffer> {
73
70
  }
74
71
 
75
72
  async function fetchCover(url: URL, cacheFile: string): Promise<Buffer> {
76
- const cachedStat = await stat(cacheFile).catch(() => undefined);
77
- if (cachedStat && Date.now() - cachedStat.mtimeMs < COVER_CACHE_TTL_MS)
78
- return readFile(cacheFile);
73
+ const cached = Bun.file(cacheFile);
74
+ if (cached.size > 0 && Date.now() - cached.lastModified < COVER_CACHE_TTL_MS)
75
+ return Buffer.from(await cached.bytes());
79
76
  const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
80
77
  if (!(response.ok && response.headers.get("content-type")?.startsWith("image/")))
81
78
  throw new Error(`cover returned HTTP ${response.status} or non-image content`);
82
79
  const bytes = await readResponseBytes(response);
83
80
  assertDimensions(bytes);
84
- await mkdir(path.dirname(cacheFile), { recursive: true });
85
- await writeFile(cacheFile, bytes);
81
+ await Bun.write(cacheFile, bytes);
86
82
  return bytes;
87
83
  }
88
84
 
@@ -93,51 +89,10 @@ function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise<Buffe
93
89
  if (!["https:", "http:"].includes(url.protocol)) throw new Error("invalid cover URL");
94
90
  return fetchCover(
95
91
  url,
96
- path.join(paths.cacheDir, "covers", createHash("sha256").update(url.href).digest("hex")),
92
+ path.join(
93
+ paths.cacheDir,
94
+ "covers",
95
+ new Bun.CryptoHasher("sha256").update(url.href).digest("hex"),
96
+ ),
97
97
  );
98
98
  }
99
-
100
- const preparedCovers = new Map<string, Promise<SharpRawBuffer>>();
101
-
102
- /**
103
- * Crop, flatten and downscale the artwork to the exact panel size. The pixels
104
- * are composited after rasterization instead of being embedded in the SVG: an
105
- * image inside the SVG made Resvg decode and scale it as part of vector
106
- * rasterization, which cost several hundred milliseconds per card.
107
- */
108
- export async function prepareCover(bytes: Buffer): Promise<SharpRawBuffer> {
109
- const sharp = loadSharp();
110
- if (!sharp) throw new Error("sharp is required to crop artwork");
111
- const digest = createHash("sha256").update(bytes).digest("hex");
112
- let pending = preparedCovers.get(digest);
113
- if (!pending) {
114
- pending = sharp(bytes)
115
- .rotate()
116
- .resize({
117
- width: COVER_WIDTH,
118
- height: CARD_HEIGHT,
119
- fit: "cover",
120
- position: "centre",
121
- })
122
- .flatten({ background: "#ffffff" })
123
- .removeAlpha()
124
- .raw()
125
- .toBuffer({ resolveWithObject: true });
126
- preparedCovers.set(digest, pending);
127
- if (preparedCovers.size > 16)
128
- preparedCovers.delete(preparedCovers.keys().next().value ?? "");
129
- }
130
- try {
131
- return await pending;
132
- } catch (error) {
133
- preparedCovers.delete(digest);
134
- throw new Error("cover could not be prepared", { cause: error });
135
- }
136
- }
137
-
138
- /** Only used when sharp is unavailable, so Satori can embed the original bytes. */
139
- export function coverDataUri(bytes: Buffer): string {
140
- const { type } = imageSize(bytes);
141
- const mime = type === "jpg" ? "image/jpeg" : `image/${type ?? "png"}`;
142
- return `data:${mime};base64,${bytes.toString("base64")}`;
143
- }
@@ -4,10 +4,11 @@ import type { SocialCardData } from "../utils/social-image";
4
4
 
5
5
  interface PostData {
6
6
  title: string;
7
- desc: string;
8
7
  date: string;
8
+ abbrlink: string;
9
9
  cover?: string;
10
10
  categories?: string[];
11
+ tags?: string[];
11
12
  }
12
13
 
13
14
  export function createSocialCardData(
@@ -15,13 +16,14 @@ export function createSocialCardData(
15
16
  site: SiteData,
16
17
  author: AuthorData,
17
18
  ): SocialCardData {
19
+ const siteUrl = new URL(site.canonical ?? site.url);
18
20
  return {
19
21
  title: post.title,
20
- description: post.desc,
21
22
  siteTitle: site.title,
22
23
  author: author.name,
23
- host: new URL(site.canonical ?? site.url).host,
24
- lang: site.lang || "zh-CN",
24
+ host: siteUrl.host,
25
+ postUrl: new URL(`/posts/${encodeURIComponent(post.abbrlink)}/`, siteUrl).href,
26
+ tags: post.tags ?? [],
25
27
  date: formatInTimeZone(
26
28
  parseDate(post.date, site.timezone || "Asia/Shanghai"),
27
29
  site.timezone || "Asia/Shanghai",
@@ -1,41 +1,34 @@
1
+ import type { Node } from "@takumi-rs/core";
1
2
  import type { SocialCardData } from "../utils/social-image";
2
3
 
3
4
  export const CARD_WIDTH = 1200;
4
5
  export const CARD_HEIGHT = 630;
5
6
  export const CARD_MARGIN = 72;
6
7
  export const COVER_WIDTH = 440;
7
- export const COVER_LEFT = CARD_WIDTH - COVER_WIDTH;
8
8
  export const CARD_FONT_FAMILY = "Stalux Card";
9
- export const CARD_FONT_WEIGHT = 400;
10
9
 
11
- export interface CardNode {
12
- type: string;
13
- props: Record<string, unknown> & { style?: Record<string, unknown>; children?: unknown };
14
- }
10
+ const MUTED = "#545454";
11
+ const RULE = "rgba(0, 0, 0, 0.16)";
15
12
 
16
- const text = (value: string, style: Record<string, unknown>): CardNode => ({
17
- type: "div",
18
- // `display: block` is what enables `lineClamp` and its ellipsis in Satori.
19
- props: { style: { display: "block", overflow: "hidden", ...style }, children: value },
20
- });
13
+ const text = (value: string, style: Record<string, unknown>): Node =>
14
+ ({ type: "text", text: value, style }) as Node;
21
15
 
22
- const rule = (top: number, width: number): CardNode => ({
23
- type: "div",
24
- props: {
16
+ const rule = (top: number, width: number): Node =>
17
+ ({
18
+ type: "container",
25
19
  style: {
26
20
  position: "absolute",
27
21
  left: CARD_MARGIN,
28
22
  top,
29
23
  width,
30
24
  height: 1,
31
- backgroundColor: "rgba(0, 0, 0, 0.16)",
25
+ backgroundColor: RULE,
32
26
  },
33
- },
34
- });
27
+ }) as Node;
35
28
 
36
- const row = (top: number, width: number, children: CardNode[]): CardNode => ({
37
- type: "div",
38
- props: {
29
+ const row = (top: number, width: number, children: Node[]): Node =>
30
+ ({
31
+ type: "container",
39
32
  style: {
40
33
  position: "absolute",
41
34
  left: CARD_MARGIN,
@@ -46,16 +39,16 @@ const row = (top: number, width: number, children: CardNode[]): CardNode => ({
46
39
  alignItems: "baseline",
47
40
  },
48
41
  children,
49
- },
50
- });
42
+ }) as Node;
51
43
 
52
- function header(data: SocialCardData, textWidth: number): CardNode {
44
+ function header(data: SocialCardData, textWidth: number): Node {
53
45
  return row(50, textWidth, [
54
46
  text(data.siteTitle, {
55
47
  fontSize: 26,
56
48
  lineHeight: 1.2,
57
49
  maxWidth: "62%",
58
50
  lineClamp: 1,
51
+ overflow: "hidden",
59
52
  color: "#000000",
60
53
  }),
61
54
  text(data.category || "", {
@@ -65,32 +58,34 @@ function header(data: SocialCardData, textWidth: number): CardNode {
65
58
  letterSpacing: 3,
66
59
  lineHeight: 1.2,
67
60
  lineClamp: 1,
61
+ overflow: "hidden",
68
62
  color: "#000000",
69
63
  }),
70
64
  ]);
71
65
  }
72
66
 
73
- function footer(data: SocialCardData, textWidth: number): CardNode {
67
+ function footer(data: SocialCardData, textWidth: number): Node {
74
68
  return row(550, textWidth, [
75
69
  text(data.host, { fontSize: 22, lineHeight: 1.3, color: "#000000" }),
76
70
  text(`${data.author} / ${data.date}`, {
77
71
  fontSize: 22,
78
72
  lineHeight: 1.3,
79
73
  textAlign: "right",
80
- color: "#545454",
74
+ color: MUTED,
81
75
  }),
82
76
  ]);
83
77
  }
84
78
 
85
79
  /**
86
80
  * A fixed editorial layout: black type on white, one full-colour artwork panel.
87
- * No accent colour, gradients, or decoration — the title and the image carry it.
81
+ * No accent colour, gradients, or decoration — the title, metadata and image
82
+ * carry it. Every text block is black or neutral gray; only the cover is colour.
88
83
  */
89
- export function socialCardTree(data: SocialCardData, withCover = false, cover?: string): CardNode {
84
+ export function socialCardTree(data: SocialCardData, withCover = false): Node {
90
85
  const textWidth = withCover
91
86
  ? CARD_WIDTH - COVER_WIDTH - CARD_MARGIN * 2
92
87
  : CARD_WIDTH - CARD_MARGIN * 2;
93
- const children: CardNode[] = [
88
+ const children: Node[] = [
94
89
  header(data, textWidth),
95
90
  rule(106, textWidth),
96
91
  text(data.title, {
@@ -101,53 +96,64 @@ export function socialCardTree(data: SocialCardData, withCover = false, cover?:
101
96
  fontSize: 58,
102
97
  lineHeight: 1.2,
103
98
  lineClamp: 3,
99
+ overflow: "hidden",
104
100
  overflowWrap: "anywhere",
105
101
  color: "#000000",
106
102
  }),
107
- text(data.description.replace(/\s+/gu, " ").trim(), {
103
+ ];
104
+ if (data.tags.length > 0)
105
+ children.push(
106
+ text(data.tags.map((tag) => `#${tag}`).join(" · "), {
107
+ position: "absolute",
108
+ left: CARD_MARGIN,
109
+ top: 384,
110
+ width: textWidth,
111
+ fontSize: 23,
112
+ lineHeight: 1.3,
113
+ lineClamp: 1,
114
+ overflow: "hidden",
115
+ color: "#000000",
116
+ }),
117
+ );
118
+ children.push(
119
+ text(data.postUrl.replace(/^https?:\/\//u, ""), {
108
120
  position: "absolute",
109
121
  left: CARD_MARGIN,
110
- top: 398,
122
+ top: 426,
111
123
  width: textWidth,
112
- fontSize: 24,
113
- lineHeight: 1.55,
114
- lineClamp: 2,
115
- overflowWrap: "anywhere",
116
- color: "#545454",
124
+ fontSize: 21,
125
+ lineHeight: 1.3,
126
+ lineClamp: 1,
127
+ overflow: "hidden",
128
+ color: MUTED,
117
129
  }),
118
130
  rule(532, textWidth),
119
131
  footer(data, textWidth),
120
- ];
121
- if (cover)
132
+ );
133
+ if (withCover)
122
134
  children.push({
123
- type: "img",
124
- props: {
125
- src: cover,
126
- style: {
127
- position: "absolute",
128
- right: 0,
129
- top: 0,
130
- width: COVER_WIDTH,
131
- height: CARD_HEIGHT,
132
- objectFit: "cover",
133
- },
134
- },
135
- });
136
- return {
137
- type: "div",
138
- props: {
135
+ type: "image",
136
+ src: "stalux-cover",
139
137
  style: {
140
- width: CARD_WIDTH,
138
+ position: "absolute",
139
+ right: 0,
140
+ top: 0,
141
+ width: COVER_WIDTH,
141
142
  height: CARD_HEIGHT,
142
- position: "relative",
143
- display: "flex",
144
- backgroundColor: "#ffffff",
145
- color: "#000000",
146
- fontFamily: CARD_FONT_FAMILY,
147
- overflow: "hidden",
143
+ objectFit: "cover",
148
144
  },
149
- lang: data.lang,
150
- children,
145
+ } as Node);
146
+ return {
147
+ type: "container",
148
+ style: {
149
+ width: CARD_WIDTH,
150
+ height: CARD_HEIGHT,
151
+ position: "relative",
152
+ backgroundColor: "#ffffff",
153
+ color: "#000000",
154
+ fontFamily: CARD_FONT_FAMILY,
155
+ overflow: "hidden",
151
156
  },
152
- };
157
+ children,
158
+ } as Node;
153
159
  }
@@ -1,106 +1,45 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
1
  import path from "node:path";
4
- import { Resvg } from "@resvg/resvg-js";
5
- import satori from "satori";
2
+ import { Renderer } from "@takumi-rs/core";
6
3
  import { type SocialCardData, socialCardFormat } from "../utils/social-image";
7
- import { loadSharp, type SharpPipeline, type SharpRawBuffer } from "./sharp-loader";
8
- import { coverDataUri, loadCover, prepareCover, type SocialCardPaths } from "./social-card-cover";
9
- import {
10
- CARD_FONT_FAMILY,
11
- CARD_FONT_WEIGHT,
12
- CARD_HEIGHT,
13
- CARD_WIDTH,
14
- COVER_LEFT,
15
- socialCardTree,
16
- } from "./social-card-layout";
4
+ import { loadCover, type SocialCardPaths } from "./social-card-cover";
5
+ import { CARD_FONT_FAMILY, CARD_HEIGHT, CARD_WIDTH, socialCardTree } from "./social-card-layout";
17
6
 
18
7
  export type { SocialCardPaths } from "./social-card-cover";
19
8
  export { localCoverPath } from "./social-card-cover";
20
9
  export { socialCardTree } from "./social-card-layout";
21
10
 
22
- /** Bump when the layout changes so every cached render is replaced. */
23
- const TEMPLATE = "stalux-mono-v1";
11
+ /** Bump when the layout or renderer changes so every cached render is replaced. */
12
+ const TEMPLATE = "stalux-mono-takumi-v2";
13
+ const JPEG_QUALITY = 60;
24
14
 
25
- type SatoriInput = Parameters<typeof satori>[0];
15
+ const renderers = new Map<string, Promise<Renderer>>();
26
16
 
27
- function encodeWithSharp(
28
- pipeline: SharpPipeline,
29
- format: "jpg" | "png",
30
- panel?: SharpRawBuffer,
31
- ): Promise<Buffer> {
32
- if (format === "jpg") {
33
- // The artwork keeps its colour. Without artwork the whole frame is
34
- // black and white, so the smaller true grayscale JPEG is used.
35
- const composed = panel
36
- ? pipeline.composite([
37
- {
38
- input: panel.data,
39
- raw: {
40
- width: panel.info.width,
41
- height: panel.info.height,
42
- channels: panel.info.channels,
43
- },
44
- left: COVER_LEFT,
45
- top: 0,
46
- },
47
- ])
48
- : pipeline.greyscale().toColourspace("b-w");
49
- return composed.jpeg({ quality: 80, mozjpeg: true }).toBuffer();
50
- }
51
- return pipeline
52
- .greyscale()
53
- .png({ compressionLevel: 9, palette: true, colours: 256, effort: 3 })
54
- .toBuffer();
55
- }
56
-
57
- /**
58
- * Rasterize with Resvg, then let Sharp compose and encode in a single pass.
59
- * System-font discovery is disabled because Satori already converted every
60
- * glyph to a path; leaving it on made each Resvg instance scan all installed
61
- * fonts, costing about 500ms per card.
62
- */
63
- async function renderCardBytes(
64
- svg: string,
65
- format: "jpg" | "png",
66
- panel?: SharpRawBuffer,
67
- ): Promise<Buffer> {
68
- const png = new Resvg(svg, {
69
- fitTo: { mode: "width", value: CARD_WIDTH },
70
- background: "#ffffff",
71
- font: { loadSystemFonts: false },
72
- })
73
- .render()
74
- .asPng();
75
- const sharp = loadSharp();
76
- if (!sharp) {
77
- if (format === "jpg") throw new Error("JPEG encoding requires sharp");
78
- return png;
79
- }
80
- try {
81
- return await encodeWithSharp(sharp(png), format, panel);
82
- } catch (error) {
83
- if (format === "jpg") throw error;
84
- return png;
85
- }
86
- }
87
-
88
- const fonts = new Map<string, Promise<Buffer>>();
89
- function readFont(fontPath: string): Promise<Buffer> {
90
- let pending = fonts.get(fontPath);
17
+ function getRenderer(fontPath: string): Promise<Renderer> {
18
+ let pending = renderers.get(fontPath);
91
19
  if (!pending) {
92
- pending = readFile(fontPath);
93
- fonts.set(fontPath, pending);
20
+ pending = (async () => {
21
+ const renderer = new Renderer({ cacheMaxBytes: 32 * 1024 * 1024 });
22
+ await renderer.registerFont({
23
+ name: CARD_FONT_FAMILY,
24
+ data: await Bun.file(fontPath).bytes(),
25
+ });
26
+ return renderer;
27
+ })();
28
+ renderers.set(fontPath, pending);
94
29
  }
95
30
  return pending;
96
31
  }
97
32
 
98
- let fontDigestPromise: Promise<string> | undefined;
33
+ const fontDigests = new Map<string, Promise<string>>();
99
34
  function getFontDigest(fontPath: string): Promise<string> {
100
- fontDigestPromise ??= readFile(fontPath).then((bytes) =>
101
- createHash("sha256").update(bytes).digest("hex"),
102
- );
103
- return fontDigestPromise;
35
+ let pending = fontDigests.get(fontPath);
36
+ if (!pending) {
37
+ pending = Bun.file(fontPath)
38
+ .bytes()
39
+ .then((bytes) => new Bun.CryptoHasher("sha256").update(bytes).digest("hex"));
40
+ fontDigests.set(fontPath, pending);
41
+ }
42
+ return pending;
104
43
  }
105
44
 
106
45
  export async function renderSocialCard(
@@ -109,7 +48,6 @@ export async function renderSocialCard(
109
48
  warn: (message: string) => void,
110
49
  ): Promise<Buffer> {
111
50
  const format = socialCardFormat(data);
112
- const sharpAvailable = Boolean(loadSharp());
113
51
  let coverBytes: Buffer | undefined;
114
52
  if (data.cover) {
115
53
  try {
@@ -120,68 +58,38 @@ export async function renderSocialCard(
120
58
  }
121
59
  // Content-only key: identical card data + identical font + identical cover
122
60
  // always reuses the cached render, whatever the filesystem timestamps say.
123
- // It is checked before cropping artwork so a warm build does no image work.
61
+ // It is checked before any raster work, so a warm build only reads bytes.
124
62
  const fontDigest = await getFontDigest(paths.fontPath);
125
- const key = createHash("sha256")
126
- .update(
127
- JSON.stringify([TEMPLATE, format, data, fontDigest, sharpAvailable ? "panel" : "flat"]),
128
- )
63
+ const key = new Bun.CryptoHasher("sha256")
64
+ .update(JSON.stringify([TEMPLATE, format, data, fontDigest]))
129
65
  .update(coverBytes ?? "")
130
66
  .digest("hex");
131
67
  const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
132
- const hit = await readFile(cacheFile).catch(() => undefined);
133
- if (hit) return hit;
134
-
135
- let panel: SharpRawBuffer | undefined;
136
- let inlineCover: string | undefined;
137
- if (coverBytes) {
138
- if (sharpAvailable) {
139
- try {
140
- panel = await prepareCover(coverBytes);
141
- } catch {
142
- warn(`分享封面不可用,已回退标题卡片:${data.title}`);
143
- }
144
- } else {
145
- // Without sharp there is no compositor, so the original bytes go
146
- // through Satori instead (PNG/JPEG only, and no palette encoding).
147
- inlineCover = coverDataUri(coverBytes);
148
- }
149
- }
68
+ const cached = Bun.file(cacheFile);
69
+ if (cached.size > 0) return Buffer.from(await cached.bytes());
150
70
 
151
- const withCover = Boolean(panel || inlineCover);
152
- const renderTree = async (hasCover: boolean) => {
153
- const svg = await satori(
154
- socialCardTree(
155
- data,
156
- hasCover,
157
- hasCover ? inlineCover : undefined,
158
- ) as unknown as SatoriInput,
159
- {
160
- width: CARD_WIDTH,
161
- height: CARD_HEIGHT,
162
- fonts: [
163
- {
164
- name: CARD_FONT_FAMILY,
165
- data: await readFont(paths.fontPath),
166
- weight: CARD_FONT_WEIGHT,
167
- style: "normal",
168
- },
169
- ],
170
- },
171
- );
172
- return renderCardBytes(svg, format, hasCover ? panel : undefined);
173
- };
71
+ const renderer = await getRenderer(paths.fontPath);
72
+ const render = (withCover: boolean) =>
73
+ renderer.render(socialCardTree(data, withCover), {
74
+ width: CARD_WIDTH,
75
+ height: CARD_HEIGHT,
76
+ format: format === "jpg" ? "jpeg" : "png",
77
+ quality: JPEG_QUALITY,
78
+ images: withCover && coverBytes ? [{ src: "stalux-cover", data: coverBytes }] : [],
79
+ });
174
80
  let bytes: Buffer;
81
+ let cacheable = true;
175
82
  try {
176
- bytes = await renderTree(withCover);
83
+ bytes = await render(Boolean(coverBytes));
177
84
  } catch (error) {
178
- if (!withCover) throw error;
85
+ if (!coverBytes) throw error;
179
86
  warn(`分享封面解码失败,已回退标题卡片:${data.title}`);
180
87
  // Do not cache the fallback under the artwork key: a later build should
181
88
  // retry the cover instead of inheriting a short-lived decode failure.
182
- return renderTree(false);
89
+ bytes = await render(false);
90
+ cacheable = false;
183
91
  }
184
- await mkdir(paths.cacheDir, { recursive: true });
185
- await writeFile(cacheFile, bytes);
92
+ if (!cacheable) return bytes;
93
+ await Bun.write(cacheFile, bytes);
186
94
  return bytes;
187
95
  }
@@ -1,6 +1,4 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
- import { pathToFileURL } from "node:url";
4
2
  import type { Plugin } from "vite";
5
3
  import { affectsDeclarativeTools } from "../scripts/webmcp-mutations";
6
4
 
@@ -21,9 +19,17 @@ export function optimizeWebMcpMutations(code: string, version: string): string |
21
19
  }
22
20
 
23
21
  export function webMcpMutationsPlugin(): Plugin {
24
- const entry = createRequire(import.meta.url).resolve("@mcp-b/webmcp-polyfill");
25
- const version = JSON.parse(
26
- readFileSync(new URL("../package.json", pathToFileURL(entry)), "utf8"),
22
+ const entry = Bun.resolveSync(
23
+ "@mcp-b/webmcp-polyfill",
24
+ Bun.fileURLToPath(new URL(".", import.meta.url)),
25
+ );
26
+ const version = (
27
+ JSON.parse(
28
+ readFileSync(
29
+ Bun.fileURLToPath(new URL("../package.json", Bun.pathToFileURL(entry))),
30
+ "utf8",
31
+ ),
32
+ ) as { version: string }
27
33
  ).version;
28
34
  return {
29
35
  name: "stalux:webmcp-mutations",
@@ -46,10 +46,10 @@ const htmlLang = siteConfig.lang || "zh-CN";
46
46
  // 保证增量构建依赖图 hash 稳定。
47
47
  const llmPromoteRaw = promoteConfig?.llm_promote ?? "";
48
48
  const llmPromote = llmPromoteRaw
49
- ?.replace(/\{author\}/g, authorConfig.name)
50
- ?.replace(/\{url\}/g, siteConfig.url)
51
- ?.replace(/\{title\}/g, siteConfig.title)
52
- ?.replace(/\{cc\}/g, "CC-BY-NC-SA-4.0");
49
+ ?.replace(/\{author\}/gu, authorConfig.name)
50
+ ?.replace(/\{url\}/gu, siteConfig.url)
51
+ ?.replace(/\{title\}/gu, siteConfig.title)
52
+ ?.replace(/\{cc\}/gu, "CC-BY-NC-SA-4.0");
53
53
  const routeKind = getRouteKind(Astro.url.pathname);
54
54
  ---
55
55
 
@@ -8,7 +8,7 @@ const responseSchema = {
8
8
  };
9
9
 
10
10
  export const GET: APIRoute = async ({ site }) => {
11
- const server = site?.toString().replace(/\/$/, "") || "";
11
+ const server = site?.toString().replace(/\/$/u, "") || "";
12
12
  const document = {
13
13
  openapi: "3.1.0",
14
14
  info: {
@@ -79,8 +79,8 @@ function getState(ctx: PluginContext): FeatureFlagsState {
79
79
  }
80
80
 
81
81
  function getFrontmatter(ctx: PluginContext): Record<string, unknown> {
82
- const astroData = ctx.data.astro;
83
- if (!astroData) throw new Error("Satteri did not provide Astro frontmatter data");
82
+ const astroData = ctx.data.astro as { frontmatter?: Record<string, unknown> } | undefined;
83
+ if (!astroData?.frontmatter) throw new Error("Satteri did not provide Astro frontmatter data");
84
84
  return astroData.frontmatter;
85
85
  }
86
86
 
@@ -61,7 +61,7 @@ export interface CollectionPaths {
61
61
  * 因此在 schema 层直接拒绝,让构建尽快失败,而不是产出错误时间。
62
62
  */
63
63
  export const PADDED_DATETIME_PATTERN =
64
- /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
64
+ /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:?\d{2})?)?$/u;
65
65
 
66
66
  const datetimeField = (label: string) =>
67
67
  z
@@ -18,7 +18,10 @@ export const siteSchema = z.object({
18
18
  description: z.string().min(1, "site.description is required"),
19
19
  accentColor: z
20
20
  .string()
21
- .regex(/^#[\da-fA-F]{6}$/, "site.accentColor must be a six-digit HEX color such as #EAB308")
21
+ .regex(
22
+ /^#[\da-fA-F]{6}$/u,
23
+ "site.accentColor must be a six-digit HEX color such as #EAB308",
24
+ )
22
25
  .optional()
23
26
  .default("#EAB308"),
24
27
  seoTitle: z.string().min(1).optional(),