@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { imageSize } from "image-size";
|
|
5
|
+
import { loadSharp, type SharpRawBuffer } from "./sharp-loader";
|
|
6
|
+
import { CARD_HEIGHT, COVER_WIDTH } from "./social-card-layout";
|
|
7
|
+
|
|
8
|
+
export interface SocialCardPaths {
|
|
9
|
+
publicDir: string;
|
|
10
|
+
cacheDir: string;
|
|
11
|
+
base: string;
|
|
12
|
+
fontPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_COVER_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
const COVER_CACHE_TTL_MS = 86_400_000;
|
|
17
|
+
|
|
18
|
+
/** Resolve only assets inside the final public directory, including encoded Chinese paths. */
|
|
19
|
+
export function localCoverPath(cover: string, paths: SocialCardPaths): string | undefined {
|
|
20
|
+
if (!cover.startsWith("/") || cover.startsWith("//")) return undefined;
|
|
21
|
+
try {
|
|
22
|
+
let pathname = decodeURIComponent(cover.split(/[?#]/u)[0] ?? "");
|
|
23
|
+
const base = paths.base.replace(/\/$/u, "");
|
|
24
|
+
if (base && pathname.startsWith(`${base}/`)) pathname = pathname.slice(base.length);
|
|
25
|
+
const target = path.resolve(paths.publicDir, `.${pathname}`);
|
|
26
|
+
if (!target.startsWith(`${path.resolve(paths.publicDir)}${path.sep}`)) return undefined;
|
|
27
|
+
return target;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const coverRequests = new Map<string, Promise<Buffer>>();
|
|
34
|
+
|
|
35
|
+
export function loadCover(cover: string, paths: SocialCardPaths): Promise<Buffer> {
|
|
36
|
+
const key = `${paths.publicDir}|${cover}`;
|
|
37
|
+
let pending = coverRequests.get(key);
|
|
38
|
+
if (!pending) {
|
|
39
|
+
pending = loadCoverUncached(cover, paths);
|
|
40
|
+
coverRequests.set(key, pending);
|
|
41
|
+
// Bound memory while sharing requests and failures between renders.
|
|
42
|
+
if (coverRequests.size > 8) coverRequests.delete(coverRequests.keys().next().value ?? "");
|
|
43
|
+
}
|
|
44
|
+
return pending;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertDimensions(bytes: Buffer): void {
|
|
48
|
+
const dimensions = imageSize(bytes);
|
|
49
|
+
if (
|
|
50
|
+
!(dimensions.width && dimensions.height) ||
|
|
51
|
+
dimensions.width * dimensions.height > 32_000_000
|
|
52
|
+
)
|
|
53
|
+
throw new Error("invalid or oversized cover dimensions");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
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);
|
|
59
|
+
assertDimensions(bytes);
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readResponseBytes(response: Response): Promise<Buffer> {
|
|
64
|
+
if (!response.body) throw new Error("empty cover");
|
|
65
|
+
const chunks: Uint8Array[] = [];
|
|
66
|
+
let size = 0;
|
|
67
|
+
for await (const chunk of response.body) {
|
|
68
|
+
size += chunk.length;
|
|
69
|
+
if (size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
}
|
|
72
|
+
return Buffer.concat(chunks);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
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);
|
|
79
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
80
|
+
if (!(response.ok && response.headers.get("content-type")?.startsWith("image/")))
|
|
81
|
+
throw new Error(`cover returned HTTP ${response.status} or non-image content`);
|
|
82
|
+
const bytes = await readResponseBytes(response);
|
|
83
|
+
assertDimensions(bytes);
|
|
84
|
+
await mkdir(path.dirname(cacheFile), { recursive: true });
|
|
85
|
+
await writeFile(cacheFile, bytes);
|
|
86
|
+
return bytes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise<Buffer> {
|
|
90
|
+
const local = localCoverPath(cover, paths);
|
|
91
|
+
if (local) return readLocalCover(local);
|
|
92
|
+
const url = new URL(cover.startsWith("//") ? `https:${cover}` : cover);
|
|
93
|
+
if (!["https:", "http:"].includes(url.protocol)) throw new Error("invalid cover URL");
|
|
94
|
+
return fetchCover(
|
|
95
|
+
url,
|
|
96
|
+
path.join(paths.cacheDir, "covers", createHash("sha256").update(url.href).digest("hex")),
|
|
97
|
+
);
|
|
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
|
+
}
|
|
@@ -22,7 +22,6 @@ export function createSocialCardData(
|
|
|
22
22
|
author: author.name,
|
|
23
23
|
host: new URL(site.canonical ?? site.url).host,
|
|
24
24
|
lang: site.lang || "zh-CN",
|
|
25
|
-
accent: site.accentColor || "#EAB308",
|
|
26
25
|
date: formatInTimeZone(
|
|
27
26
|
parseDate(post.date, site.timezone || "Asia/Shanghai"),
|
|
28
27
|
site.timezone || "Asia/Shanghai",
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { SocialCardData } from "../utils/social-image";
|
|
2
|
+
|
|
3
|
+
export const CARD_WIDTH = 1200;
|
|
4
|
+
export const CARD_HEIGHT = 630;
|
|
5
|
+
export const CARD_MARGIN = 72;
|
|
6
|
+
export const COVER_WIDTH = 440;
|
|
7
|
+
export const COVER_LEFT = CARD_WIDTH - COVER_WIDTH;
|
|
8
|
+
export const CARD_FONT_FAMILY = "Stalux Card";
|
|
9
|
+
export const CARD_FONT_WEIGHT = 400;
|
|
10
|
+
|
|
11
|
+
export interface CardNode {
|
|
12
|
+
type: string;
|
|
13
|
+
props: Record<string, unknown> & { style?: Record<string, unknown>; children?: unknown };
|
|
14
|
+
}
|
|
15
|
+
|
|
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
|
+
});
|
|
21
|
+
|
|
22
|
+
const rule = (top: number, width: number): CardNode => ({
|
|
23
|
+
type: "div",
|
|
24
|
+
props: {
|
|
25
|
+
style: {
|
|
26
|
+
position: "absolute",
|
|
27
|
+
left: CARD_MARGIN,
|
|
28
|
+
top,
|
|
29
|
+
width,
|
|
30
|
+
height: 1,
|
|
31
|
+
backgroundColor: "rgba(0, 0, 0, 0.16)",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const row = (top: number, width: number, children: CardNode[]): CardNode => ({
|
|
37
|
+
type: "div",
|
|
38
|
+
props: {
|
|
39
|
+
style: {
|
|
40
|
+
position: "absolute",
|
|
41
|
+
left: CARD_MARGIN,
|
|
42
|
+
top,
|
|
43
|
+
width,
|
|
44
|
+
display: "flex",
|
|
45
|
+
justifyContent: "space-between",
|
|
46
|
+
alignItems: "baseline",
|
|
47
|
+
},
|
|
48
|
+
children,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function header(data: SocialCardData, textWidth: number): CardNode {
|
|
53
|
+
return row(50, textWidth, [
|
|
54
|
+
text(data.siteTitle, {
|
|
55
|
+
fontSize: 26,
|
|
56
|
+
lineHeight: 1.2,
|
|
57
|
+
maxWidth: "62%",
|
|
58
|
+
lineClamp: 1,
|
|
59
|
+
color: "#000000",
|
|
60
|
+
}),
|
|
61
|
+
text(data.category || "", {
|
|
62
|
+
width: textWidth < 700 ? 180 : 240,
|
|
63
|
+
textAlign: "right",
|
|
64
|
+
fontSize: 19,
|
|
65
|
+
letterSpacing: 3,
|
|
66
|
+
lineHeight: 1.2,
|
|
67
|
+
lineClamp: 1,
|
|
68
|
+
color: "#000000",
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function footer(data: SocialCardData, textWidth: number): CardNode {
|
|
74
|
+
return row(550, textWidth, [
|
|
75
|
+
text(data.host, { fontSize: 22, lineHeight: 1.3, color: "#000000" }),
|
|
76
|
+
text(`${data.author} / ${data.date}`, {
|
|
77
|
+
fontSize: 22,
|
|
78
|
+
lineHeight: 1.3,
|
|
79
|
+
textAlign: "right",
|
|
80
|
+
color: "#545454",
|
|
81
|
+
}),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 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.
|
|
88
|
+
*/
|
|
89
|
+
export function socialCardTree(data: SocialCardData, withCover = false, cover?: string): CardNode {
|
|
90
|
+
const textWidth = withCover
|
|
91
|
+
? CARD_WIDTH - COVER_WIDTH - CARD_MARGIN * 2
|
|
92
|
+
: CARD_WIDTH - CARD_MARGIN * 2;
|
|
93
|
+
const children: CardNode[] = [
|
|
94
|
+
header(data, textWidth),
|
|
95
|
+
rule(106, textWidth),
|
|
96
|
+
text(data.title, {
|
|
97
|
+
position: "absolute",
|
|
98
|
+
left: CARD_MARGIN,
|
|
99
|
+
top: 156,
|
|
100
|
+
width: textWidth,
|
|
101
|
+
fontSize: 58,
|
|
102
|
+
lineHeight: 1.2,
|
|
103
|
+
lineClamp: 3,
|
|
104
|
+
overflowWrap: "anywhere",
|
|
105
|
+
color: "#000000",
|
|
106
|
+
}),
|
|
107
|
+
text(data.description.replace(/\s+/gu, " ").trim(), {
|
|
108
|
+
position: "absolute",
|
|
109
|
+
left: CARD_MARGIN,
|
|
110
|
+
top: 398,
|
|
111
|
+
width: textWidth,
|
|
112
|
+
fontSize: 24,
|
|
113
|
+
lineHeight: 1.55,
|
|
114
|
+
lineClamp: 2,
|
|
115
|
+
overflowWrap: "anywhere",
|
|
116
|
+
color: "#545454",
|
|
117
|
+
}),
|
|
118
|
+
rule(532, textWidth),
|
|
119
|
+
footer(data, textWidth),
|
|
120
|
+
];
|
|
121
|
+
if (cover)
|
|
122
|
+
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: {
|
|
139
|
+
style: {
|
|
140
|
+
width: CARD_WIDTH,
|
|
141
|
+
height: CARD_HEIGHT,
|
|
142
|
+
position: "relative",
|
|
143
|
+
display: "flex",
|
|
144
|
+
backgroundColor: "#ffffff",
|
|
145
|
+
color: "#000000",
|
|
146
|
+
fontFamily: CARD_FONT_FAMILY,
|
|
147
|
+
overflow: "hidden",
|
|
148
|
+
},
|
|
149
|
+
lang: data.lang,
|
|
150
|
+
children,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|