@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.
- package/.release/cli.mjs +16 -20
- package/package.json +47 -45
- package/src/components/stalux/footer/FooterBadges.astro +2 -2
- package/src/components/stalux/layout/head.astro +1 -1
- package/src/components/stalux/posts/postContent.astro +1 -1
- package/src/index.ts +57 -51
- package/src/internal/cjk-search-index.ts +4 -7
- package/src/internal/components-plugin.ts +1 -3
- package/src/internal/font-slices.ts +36 -29
- package/src/internal/html-images.ts +1 -1
- package/src/internal/html-styles.ts +1 -1
- package/src/internal/image-dimensions.ts +3 -8
- package/src/internal/injected-routes.ts +1 -3
- package/src/internal/page-output-cache.ts +11 -15
- package/src/internal/post-cover.ts +22 -26
- package/src/internal/project-mode.ts +3 -2
- package/src/internal/public-assets.ts +42 -23
- package/src/internal/runtime-cache-key.ts +42 -27
- package/src/internal/satteri-config.ts +2 -2
- package/src/internal/search-style.ts +6 -7
- package/src/internal/social-card-cover.ts +12 -57
- package/src/internal/social-card-data.ts +6 -4
- package/src/internal/social-card-layout.ts +69 -63
- package/src/internal/social-card-renderer.ts +48 -140
- package/src/internal/webmcp-mutations.ts +11 -5
- package/src/layouts/Stalux.astro +4 -4
- package/src/pages/openapi.json.ts +1 -1
- package/src/plugins/feature-flags.ts +2 -2
- package/src/schemas/collections.ts +1 -1
- package/src/schemas/config.ts +4 -1
- package/src/scripts/list-reveal.ts +7 -5
- package/src/scripts/swup-router.ts +1 -1
- package/src/scripts/weather.ts +2 -2
- package/src/scripts/webmcp.ts +4 -4
- package/src/utils/accent-color.ts +1 -1
- package/src/utils/ai-discovery.ts +6 -6
- package/src/utils/badge-generator.ts +1 -2
- package/src/utils/cc.ts +2 -2
- package/src/utils/dayjs.ts +5 -5
- package/src/utils/diagnostics.ts +3 -3
- package/src/utils/public-routes.ts +3 -3
- package/src/utils/seo-document.ts +2 -2
- package/src/utils/site-path.ts +1 -1
- package/src/utils/social-image.ts +6 -14
- 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
|
-
|
|
58
|
-
|
|
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
|
|
77
|
-
if (
|
|
78
|
-
return
|
|
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
|
|
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(
|
|
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:
|
|
24
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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>):
|
|
17
|
-
type: "
|
|
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):
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
25
|
+
backgroundColor: RULE,
|
|
32
26
|
},
|
|
33
|
-
}
|
|
34
|
-
});
|
|
27
|
+
}) as Node;
|
|
35
28
|
|
|
36
|
-
const row = (top: number, width: number, children:
|
|
37
|
-
|
|
38
|
-
|
|
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):
|
|
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):
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
122
|
+
top: 426,
|
|
111
123
|
width: textWidth,
|
|
112
|
-
fontSize:
|
|
113
|
-
lineHeight: 1.
|
|
114
|
-
lineClamp:
|
|
115
|
-
|
|
116
|
-
color:
|
|
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 (
|
|
132
|
+
);
|
|
133
|
+
if (withCover)
|
|
122
134
|
children.push({
|
|
123
|
-
type: "
|
|
124
|
-
|
|
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
|
-
|
|
138
|
+
position: "absolute",
|
|
139
|
+
right: 0,
|
|
140
|
+
top: 0,
|
|
141
|
+
width: COVER_WIDTH,
|
|
141
142
|
height: CARD_HEIGHT,
|
|
142
|
-
|
|
143
|
-
display: "flex",
|
|
144
|
-
backgroundColor: "#ffffff",
|
|
145
|
-
color: "#000000",
|
|
146
|
-
fontFamily: CARD_FONT_FAMILY,
|
|
147
|
-
overflow: "hidden",
|
|
143
|
+
objectFit: "cover",
|
|
148
144
|
},
|
|
149
|
-
|
|
150
|
-
|
|
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 {
|
|
5
|
-
import satori from "satori";
|
|
2
|
+
import { Renderer } from "@takumi-rs/core";
|
|
6
3
|
import { type SocialCardData, socialCardFormat } from "../utils/social-image";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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-
|
|
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
|
-
|
|
15
|
+
const renderers = new Map<string, Promise<Renderer>>();
|
|
26
16
|
|
|
27
|
-
function
|
|
28
|
-
|
|
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 =
|
|
93
|
-
|
|
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
|
-
|
|
33
|
+
const fontDigests = new Map<string, Promise<string>>();
|
|
99
34
|
function getFontDigest(fontPath: string): Promise<string> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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 =
|
|
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
|
|
133
|
-
if (
|
|
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
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
83
|
+
bytes = await render(Boolean(coverBytes));
|
|
177
84
|
} catch (error) {
|
|
178
|
-
if (!
|
|
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
|
-
|
|
89
|
+
bytes = await render(false);
|
|
90
|
+
cacheable = false;
|
|
183
91
|
}
|
|
184
|
-
|
|
185
|
-
await
|
|
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 =
|
|
25
|
-
|
|
26
|
-
|
|
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",
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -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\}/
|
|
50
|
-
?.replace(/\{url\}/
|
|
51
|
-
?.replace(/\{title\}/
|
|
52
|
-
?.replace(/\{cc\}/
|
|
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
|
|
|
@@ -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
|
package/src/schemas/config.ts
CHANGED
|
@@ -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(
|
|
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(),
|