@xingwangzhe/stalux 1.50.0 → 1.51.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 +65 -330
- package/package.json +3 -2
- package/src/internal/sharp-loader.ts +45 -0
- package/src/internal/social-card-cover.ts +143 -0
- package/src/internal/social-card-data.ts +0 -1
- package/src/internal/social-card-layout.ts +153 -0
- package/src/internal/social-card-renderer.ts +138 -275
- package/src/pages/og/[image].[format].ts +20 -21
- package/src/utils/social-image.ts +17 -8
|
@@ -1,322 +1,185 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile,
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import type
|
|
4
|
+
import { Resvg } from "@resvg/resvg-js";
|
|
5
|
+
import satori from "satori";
|
|
6
|
+
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";
|
|
7
17
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
export type { SocialCardPaths } from "./social-card-cover";
|
|
19
|
+
export { localCoverPath } from "./social-card-cover";
|
|
20
|
+
export { socialCardTree } from "./social-card-layout";
|
|
21
|
+
|
|
22
|
+
/** Bump when the layout changes so every cached render is replaced. */
|
|
23
|
+
const TEMPLATE = "stalux-mono-v1";
|
|
24
|
+
|
|
25
|
+
type SatoriInput = Parameters<typeof satori>[0];
|
|
14
26
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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();
|
|
24
55
|
}
|
|
25
56
|
|
|
26
57
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
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.
|
|
34
62
|
*/
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
|
46
80
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
if (!target.startsWith(`${path.resolve(paths.publicDir)}${path.sep}`)) return undefined;
|
|
52
|
-
return target;
|
|
53
|
-
} catch {
|
|
54
|
-
return undefined;
|
|
81
|
+
return await encodeWithSharp(sharp(png), format, panel);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (format === "jpg") throw error;
|
|
84
|
+
return png;
|
|
55
85
|
}
|
|
56
86
|
}
|
|
57
87
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const key = `${paths.publicDir}|${cover}`;
|
|
62
|
-
let pending = coverRequests.get(key);
|
|
88
|
+
const fonts = new Map<string, Promise<Buffer>>();
|
|
89
|
+
function readFont(fontPath: string): Promise<Buffer> {
|
|
90
|
+
let pending = fonts.get(fontPath);
|
|
63
91
|
if (!pending) {
|
|
64
|
-
pending =
|
|
65
|
-
|
|
66
|
-
// Bound memory while sharing requests and failures between WebP and PNG renders.
|
|
67
|
-
if (coverRequests.size > 8) coverRequests.delete(coverRequests.keys().next().value ?? "");
|
|
92
|
+
pending = readFile(fontPath);
|
|
93
|
+
fonts.set(fontPath, pending);
|
|
68
94
|
}
|
|
69
95
|
return pending;
|
|
70
96
|
}
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} else {
|
|
79
|
-
const url = new URL(cover.startsWith("//") ? `https:${cover}` : cover);
|
|
80
|
-
if (!["https:", "http:"].includes(url.protocol)) throw new Error("invalid cover URL");
|
|
81
|
-
const remoteCache = path.join(
|
|
82
|
-
paths.cacheDir,
|
|
83
|
-
"covers",
|
|
84
|
-
createHash("sha256").update(url.href).digest("hex"),
|
|
85
|
-
);
|
|
86
|
-
const cachedStat = await stat(remoteCache).catch(() => undefined);
|
|
87
|
-
if (cachedStat && Date.now() - cachedStat.mtimeMs < 86_400_000)
|
|
88
|
-
return readFile(remoteCache);
|
|
89
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
90
|
-
if (!(response.ok && response.headers.get("content-type")?.startsWith("image/")))
|
|
91
|
-
throw new Error(`cover returned HTTP ${response.status} or non-image content`);
|
|
92
|
-
const chunks: Uint8Array[] = [];
|
|
93
|
-
let size = 0;
|
|
94
|
-
if (!response.body) throw new Error("empty cover");
|
|
95
|
-
for await (const chunk of response.body) {
|
|
96
|
-
size += chunk.length;
|
|
97
|
-
if (size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
|
|
98
|
-
chunks.push(chunk);
|
|
99
|
-
}
|
|
100
|
-
bytes = Buffer.concat(chunks);
|
|
101
|
-
const dimensions = imageSize(bytes);
|
|
102
|
-
if (
|
|
103
|
-
!(dimensions.width && dimensions.height) ||
|
|
104
|
-
dimensions.width * dimensions.height > 32_000_000
|
|
105
|
-
)
|
|
106
|
-
throw new Error("invalid cover dimensions");
|
|
107
|
-
await mkdir(path.dirname(remoteCache), { recursive: true });
|
|
108
|
-
await writeFile(remoteCache, bytes);
|
|
109
|
-
}
|
|
110
|
-
const dimensions = imageSize(bytes);
|
|
111
|
-
if (
|
|
112
|
-
!(dimensions.width && dimensions.height) ||
|
|
113
|
-
dimensions.width * dimensions.height > 32_000_000
|
|
114
|
-
)
|
|
115
|
-
throw new Error("invalid or oversized cover dimensions");
|
|
116
|
-
return bytes;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function text(value: string, style: Node["style"]): Node {
|
|
120
|
-
return { type: "text", text: value, style };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** A fixed editorial layout: site identity, readable title, optional artwork, quiet footer. */
|
|
124
|
-
export function socialCardTree(
|
|
125
|
-
data: SocialCardData,
|
|
126
|
-
hasCover: boolean,
|
|
127
|
-
titleSize = 60,
|
|
128
|
-
title = data.title,
|
|
129
|
-
): Node {
|
|
130
|
-
const titleWidth = hasCover ? 650 : 1056;
|
|
131
|
-
const accent = /^#[\da-f]{6}$/iu.test(data.accent) ? data.accent : "#EAB308";
|
|
132
|
-
const children: Node[] = [
|
|
133
|
-
{
|
|
134
|
-
type: "container",
|
|
135
|
-
style: {
|
|
136
|
-
position: "absolute",
|
|
137
|
-
right: -120,
|
|
138
|
-
top: -200,
|
|
139
|
-
width: 650,
|
|
140
|
-
height: 650,
|
|
141
|
-
borderRadius: "50%",
|
|
142
|
-
border: `1px solid ${accent}35`,
|
|
143
|
-
background: `radial-gradient(circle, ${accent}18, transparent 70%)`,
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
type: "container",
|
|
148
|
-
style: {
|
|
149
|
-
position: "absolute",
|
|
150
|
-
left: 0,
|
|
151
|
-
top: 0,
|
|
152
|
-
width: 1200,
|
|
153
|
-
height: 6,
|
|
154
|
-
backgroundColor: accent,
|
|
155
|
-
},
|
|
156
|
-
},
|
|
157
|
-
text(data.siteTitle, {
|
|
158
|
-
position: "absolute",
|
|
159
|
-
left: 72,
|
|
160
|
-
top: 48,
|
|
161
|
-
width: 820,
|
|
162
|
-
height: 46,
|
|
163
|
-
fontSize: 28,
|
|
164
|
-
color: accent,
|
|
165
|
-
overflow: "hidden",
|
|
166
|
-
}),
|
|
167
|
-
text(data.category || "ARTICLE", {
|
|
168
|
-
position: "absolute",
|
|
169
|
-
right: 72,
|
|
170
|
-
top: 54,
|
|
171
|
-
width: 240,
|
|
172
|
-
height: 36,
|
|
173
|
-
textAlign: "right",
|
|
174
|
-
fontSize: 21,
|
|
175
|
-
color: "#a9b5c6",
|
|
176
|
-
overflow: "hidden",
|
|
177
|
-
}),
|
|
178
|
-
text(title, {
|
|
179
|
-
position: "absolute",
|
|
180
|
-
left: 72,
|
|
181
|
-
top: 142,
|
|
182
|
-
width: titleWidth,
|
|
183
|
-
height: 260,
|
|
184
|
-
fontSize: titleSize,
|
|
185
|
-
lineHeight: 1.24,
|
|
186
|
-
color: "#f5f7fb",
|
|
187
|
-
overflow: "hidden",
|
|
188
|
-
overflowWrap: "anywhere",
|
|
189
|
-
}),
|
|
190
|
-
text([...data.description.replace(/\s+/gu, " ")].slice(0, hasCover ? 65 : 100).join(""), {
|
|
191
|
-
position: "absolute",
|
|
192
|
-
left: 72,
|
|
193
|
-
top: 421,
|
|
194
|
-
width: titleWidth,
|
|
195
|
-
height: 76,
|
|
196
|
-
fontSize: 25,
|
|
197
|
-
lineHeight: 1.45,
|
|
198
|
-
color: "#a9b5c6",
|
|
199
|
-
overflow: "hidden",
|
|
200
|
-
}),
|
|
201
|
-
{
|
|
202
|
-
type: "container",
|
|
203
|
-
style: {
|
|
204
|
-
position: "absolute",
|
|
205
|
-
left: 72,
|
|
206
|
-
top: 535,
|
|
207
|
-
width: 1056,
|
|
208
|
-
height: 1,
|
|
209
|
-
backgroundColor: "#ffffff22",
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
text(data.host, { position: "absolute", left: 72, top: 560, fontSize: 23, color: accent }),
|
|
213
|
-
text(`${data.author} / ${data.date}`, {
|
|
214
|
-
position: "absolute",
|
|
215
|
-
right: 72,
|
|
216
|
-
top: 560,
|
|
217
|
-
width: 650,
|
|
218
|
-
textAlign: "right",
|
|
219
|
-
fontSize: 23,
|
|
220
|
-
color: "#b9c4d3",
|
|
221
|
-
}),
|
|
222
|
-
];
|
|
223
|
-
if (hasCover)
|
|
224
|
-
children.push({
|
|
225
|
-
type: "image",
|
|
226
|
-
src: "stalux-cover",
|
|
227
|
-
style: {
|
|
228
|
-
position: "absolute",
|
|
229
|
-
right: 72,
|
|
230
|
-
top: 142,
|
|
231
|
-
width: 350,
|
|
232
|
-
height: 354,
|
|
233
|
-
objectFit: "cover",
|
|
234
|
-
borderRadius: 20,
|
|
235
|
-
},
|
|
236
|
-
});
|
|
237
|
-
return {
|
|
238
|
-
type: "container",
|
|
239
|
-
lang: data.lang,
|
|
240
|
-
style: {
|
|
241
|
-
width: 1200,
|
|
242
|
-
height: 630,
|
|
243
|
-
position: "relative",
|
|
244
|
-
backgroundColor: "#101722",
|
|
245
|
-
fontFamily: "Stalux Card",
|
|
246
|
-
overflow: "hidden",
|
|
247
|
-
},
|
|
248
|
-
children,
|
|
249
|
-
};
|
|
98
|
+
let fontDigestPromise: Promise<string> | undefined;
|
|
99
|
+
function getFontDigest(fontPath: string): Promise<string> {
|
|
100
|
+
fontDigestPromise ??= readFile(fontPath).then((bytes) =>
|
|
101
|
+
createHash("sha256").update(bytes).digest("hex"),
|
|
102
|
+
);
|
|
103
|
+
return fontDigestPromise;
|
|
250
104
|
}
|
|
251
105
|
|
|
252
106
|
export async function renderSocialCard(
|
|
253
107
|
data: SocialCardData,
|
|
254
108
|
paths: SocialCardPaths,
|
|
255
109
|
warn: (message: string) => void,
|
|
256
|
-
format: "webp" | "png" = "webp",
|
|
257
110
|
): Promise<Buffer> {
|
|
258
|
-
|
|
111
|
+
const format = socialCardFormat(data);
|
|
112
|
+
const sharpAvailable = Boolean(loadSharp());
|
|
113
|
+
let coverBytes: Buffer | undefined;
|
|
259
114
|
if (data.cover) {
|
|
260
115
|
try {
|
|
261
|
-
|
|
116
|
+
coverBytes = await loadCover(data.cover, paths);
|
|
262
117
|
} catch {
|
|
263
118
|
warn(`分享封面不可用,已回退标题卡片:${data.title}`);
|
|
264
119
|
}
|
|
265
120
|
}
|
|
266
121
|
// Content-only key: identical card data + identical font + identical cover
|
|
267
122
|
// 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.
|
|
268
124
|
const fontDigest = await getFontDigest(paths.fontPath);
|
|
269
125
|
const key = createHash("sha256")
|
|
270
|
-
.update(
|
|
271
|
-
|
|
126
|
+
.update(
|
|
127
|
+
JSON.stringify([TEMPLATE, format, data, fontDigest, sharpAvailable ? "panel" : "flat"]),
|
|
128
|
+
)
|
|
129
|
+
.update(coverBytes ?? "")
|
|
272
130
|
.digest("hex");
|
|
273
131
|
const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
|
|
274
132
|
const hit = await readFile(cacheFile).catch(() => undefined);
|
|
275
133
|
if (hit) return hit;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
fontFamily: "Stalux Card",
|
|
291
|
-
overflowWrap: "anywhere",
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
{ width },
|
|
295
|
-
);
|
|
296
|
-
if (measured.height <= 250) break;
|
|
297
|
-
if (size > 30) size -= 2;
|
|
298
|
-
else title = `${[...title].slice(0, -3).join("")}…`;
|
|
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);
|
|
299
148
|
}
|
|
300
|
-
|
|
149
|
+
}
|
|
150
|
+
|
|
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);
|
|
301
173
|
};
|
|
302
174
|
let bytes: Buffer;
|
|
303
175
|
try {
|
|
304
|
-
bytes = await
|
|
305
|
-
width: 1200,
|
|
306
|
-
height: 630,
|
|
307
|
-
format,
|
|
308
|
-
quality: 86,
|
|
309
|
-
images: cover ? [{ src: "stalux-cover", data: cover }] : [],
|
|
310
|
-
});
|
|
176
|
+
bytes = await renderTree(withCover);
|
|
311
177
|
} catch (error) {
|
|
312
|
-
if (!
|
|
178
|
+
if (!withCover) throw error;
|
|
313
179
|
warn(`分享封面解码失败,已回退标题卡片:${data.title}`);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
format,
|
|
318
|
-
quality: 86,
|
|
319
|
-
});
|
|
180
|
+
// Do not cache the fallback under the artwork key: a later build should
|
|
181
|
+
// retry the cover instead of inheriting a short-lived decode failure.
|
|
182
|
+
return renderTree(false);
|
|
320
183
|
}
|
|
321
184
|
await mkdir(paths.cacheDir, { recursive: true });
|
|
322
185
|
await writeFile(cacheFile, bytes);
|
|
@@ -5,7 +5,12 @@ import { createSocialCardData } from "../../internal/social-card-data";
|
|
|
5
5
|
import { renderSocialCard } from "../../internal/social-card-renderer";
|
|
6
6
|
import { getAuthorData, getSiteData } from "../../utils/config-utils";
|
|
7
7
|
import { getPostContentIndex } from "../../utils/content-index";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isSocialImageUrl,
|
|
10
|
+
type SocialCardData,
|
|
11
|
+
type SocialCardFormat,
|
|
12
|
+
socialCardPath,
|
|
13
|
+
} from "../../utils/social-image";
|
|
9
14
|
|
|
10
15
|
export async function getStaticPaths() {
|
|
11
16
|
const entries = await getCollection("config");
|
|
@@ -17,34 +22,28 @@ export async function getStaticPaths() {
|
|
|
17
22
|
(post) =>
|
|
18
23
|
!(post.data["og:image"]?.trim() && isSocialImageUrl(post.data["og:image"].trim())),
|
|
19
24
|
)
|
|
20
|
-
.
|
|
25
|
+
.map((post) => {
|
|
21
26
|
const card = createSocialCardData(post.data, site, author);
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
// The extension is part of the metadata contract: artwork cards are
|
|
28
|
+
// photographic JPEG, plain cards are palette PNG.
|
|
29
|
+
const file = socialCardPath(card).split("/").at(-1) ?? "";
|
|
30
|
+
const dot = file.lastIndexOf(".");
|
|
31
|
+
return {
|
|
26
32
|
params: {
|
|
27
|
-
image:
|
|
28
|
-
|
|
29
|
-
.at(-1)
|
|
30
|
-
?.replace(/\.png$/u, ""),
|
|
31
|
-
format,
|
|
33
|
+
image: file.slice(0, dot),
|
|
34
|
+
format: file.slice(dot + 1) as SocialCardFormat,
|
|
32
35
|
},
|
|
33
|
-
props: { card, format },
|
|
34
|
-
|
|
35
|
-
// has its own content cache and writes outputs even after Astro cleans dist.
|
|
36
|
-
}));
|
|
36
|
+
props: { card, format: file.slice(dot + 1) as SocialCardFormat },
|
|
37
|
+
};
|
|
37
38
|
});
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export const GET: APIRoute = async ({ props, logger }) => {
|
|
41
|
-
const bytes = await renderSocialCard(
|
|
42
|
-
|
|
43
|
-
paths,
|
|
44
|
-
(message) => logger.warn(message),
|
|
45
|
-
props.format as "webp" | "png",
|
|
42
|
+
const bytes = await renderSocialCard(props.card as SocialCardData, paths, (message) =>
|
|
43
|
+
logger.warn(message),
|
|
46
44
|
);
|
|
45
|
+
const type = props.format === "jpg" ? "image/jpeg" : "image/png";
|
|
47
46
|
return new Response(new Uint8Array(bytes), {
|
|
48
|
-
headers: { "Content-Type":
|
|
47
|
+
headers: { "Content-Type": type },
|
|
49
48
|
});
|
|
50
49
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { loadSharp } from "../internal/sharp-loader";
|
|
2
3
|
|
|
3
4
|
export interface SocialCardData {
|
|
4
5
|
title: string;
|
|
@@ -7,12 +8,13 @@ export interface SocialCardData {
|
|
|
7
8
|
author: string;
|
|
8
9
|
host: string;
|
|
9
10
|
lang: string;
|
|
10
|
-
accent: string;
|
|
11
11
|
date: string;
|
|
12
12
|
category: string;
|
|
13
13
|
cover?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type SocialCardFormat = "jpg" | "png";
|
|
17
|
+
|
|
16
18
|
export interface SocialImage {
|
|
17
19
|
url: string;
|
|
18
20
|
width?: number;
|
|
@@ -35,13 +37,23 @@ export function isSocialImageUrl(value: string): boolean {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Cards with artwork are photographic, so JPEG saves most of the bytes and is
|
|
42
|
+
* accepted by every crawler. Plain cards stay PNG: flat type compresses far
|
|
43
|
+
* better there. Without `sharp` neither palette PNG nor JPEG is available, so
|
|
44
|
+
* the card falls back to a plain PNG.
|
|
45
|
+
*/
|
|
46
|
+
export function socialCardFormat(data: Pick<SocialCardData, "cover">): SocialCardFormat {
|
|
47
|
+
return data.cover && loadSharp() ? "jpg" : "png";
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
export function socialCardPath(data: SocialCardData, base = "/"): string {
|
|
39
51
|
// Bump the template revision when layout changes. Metadata changes get a fresh URL.
|
|
40
52
|
const hash = createHash("sha256")
|
|
41
|
-
.update(JSON.stringify(["stalux-
|
|
53
|
+
.update(JSON.stringify(["stalux-mono-v1", data]))
|
|
42
54
|
.digest("hex")
|
|
43
55
|
.slice(0, 20);
|
|
44
|
-
return `${base.replace(/\/$/u, "")}/og/${hash}
|
|
56
|
+
return `${base.replace(/\/$/u, "")}/og/${hash}.${socialCardFormat(data)}`;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
export function resolvePostSocialImage(
|
|
@@ -50,14 +62,11 @@ export function resolvePostSocialImage(
|
|
|
50
62
|
base = "/",
|
|
51
63
|
): SocialImage {
|
|
52
64
|
if (explicit?.trim() && isSocialImageUrl(explicit.trim())) return { url: explicit.trim() };
|
|
53
|
-
|
|
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.
|
|
65
|
+
const format = socialCardFormat(data);
|
|
57
66
|
return {
|
|
58
67
|
url: socialCardPath(data, base),
|
|
59
68
|
width: 1200,
|
|
60
69
|
height: 630,
|
|
61
|
-
type: "image/png",
|
|
70
|
+
type: format === "jpg" ? "image/jpeg" : "image/png",
|
|
62
71
|
};
|
|
63
72
|
}
|