@xingwangzhe/stalux 1.51.0 → 1.51.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 +2 -3
- package/src/internal/social-card-cover.ts +0 -47
- 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 +44 -133
- package/src/utils/social-image.ts +5 -11
- package/src/internal/sharp-loader.ts +0 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.51.
|
|
3
|
+
"version": "1.51.1",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme — use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -111,12 +111,12 @@
|
|
|
111
111
|
"@mcp-b/webmcp-polyfill": "^5.1.0",
|
|
112
112
|
"@microsoft/clarity": "1.0.2",
|
|
113
113
|
"@pagefind/component-ui": "^1.5.2",
|
|
114
|
-
"@resvg/resvg-js": "2.6.2",
|
|
115
114
|
"@swup/a11y-plugin": "^5.2.1",
|
|
116
115
|
"@swup/head-plugin": "^2.3.1",
|
|
117
116
|
"@swup/js-plugin": "^3.2.0",
|
|
118
117
|
"@swup/preload-plugin": "^3.2.12",
|
|
119
118
|
"@swup/scroll-plugin": "^4.0.0",
|
|
119
|
+
"@takumi-rs/core": "2.17.1",
|
|
120
120
|
"@waline/client": "^3.16.1",
|
|
121
121
|
"@xingwangzhe/cjk-font-split-native": "^0.4.10",
|
|
122
122
|
"@xingwangzhe/satteri-mermaid": "^0.8.2",
|
|
@@ -131,7 +131,6 @@
|
|
|
131
131
|
"pagefind": "^1.5.2",
|
|
132
132
|
"parse5": "^8.0.1",
|
|
133
133
|
"photoswipe": "^5.4.4",
|
|
134
|
-
"satori": "0.63.2",
|
|
135
134
|
"satteri": "^0.10.5",
|
|
136
135
|
"simple-icons": "^16.35.0",
|
|
137
136
|
"swup": "4.10.0",
|
|
@@ -2,8 +2,6 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
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
5
|
|
|
8
6
|
export interface SocialCardPaths {
|
|
9
7
|
publicDir: string;
|
|
@@ -96,48 +94,3 @@ function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise<Buffe
|
|
|
96
94
|
path.join(paths.cacheDir, "covers", createHash("sha256").update(url.href).digest("hex")),
|
|
97
95
|
);
|
|
98
96
|
}
|
|
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,47 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import satori from "satori";
|
|
4
|
+
import { Renderer } from "@takumi-rs/core";
|
|
6
5
|
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";
|
|
6
|
+
import { loadCover, type SocialCardPaths } from "./social-card-cover";
|
|
7
|
+
import { CARD_FONT_FAMILY, CARD_HEIGHT, CARD_WIDTH, socialCardTree } from "./social-card-layout";
|
|
17
8
|
|
|
18
9
|
export type { SocialCardPaths } from "./social-card-cover";
|
|
19
10
|
export { localCoverPath } from "./social-card-cover";
|
|
20
11
|
export { socialCardTree } from "./social-card-layout";
|
|
21
12
|
|
|
22
|
-
/** Bump when the layout changes so every cached render is replaced. */
|
|
23
|
-
const TEMPLATE = "stalux-mono-
|
|
13
|
+
/** Bump when the layout or renderer changes so every cached render is replaced. */
|
|
14
|
+
const TEMPLATE = "stalux-mono-takumi-v2";
|
|
15
|
+
const JPEG_QUALITY = 60;
|
|
24
16
|
|
|
25
|
-
|
|
17
|
+
const renderers = new Map<string, Promise<Renderer>>();
|
|
26
18
|
|
|
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);
|
|
19
|
+
function getRenderer(fontPath: string): Promise<Renderer> {
|
|
20
|
+
let pending = renderers.get(fontPath);
|
|
91
21
|
if (!pending) {
|
|
92
|
-
pending =
|
|
93
|
-
|
|
22
|
+
pending = (async () => {
|
|
23
|
+
const renderer = new Renderer({ cacheMaxBytes: 32 * 1024 * 1024 });
|
|
24
|
+
await renderer.registerFont({
|
|
25
|
+
name: CARD_FONT_FAMILY,
|
|
26
|
+
data: await readFile(fontPath),
|
|
27
|
+
});
|
|
28
|
+
return renderer;
|
|
29
|
+
})();
|
|
30
|
+
renderers.set(fontPath, pending);
|
|
94
31
|
}
|
|
95
32
|
return pending;
|
|
96
33
|
}
|
|
97
34
|
|
|
98
|
-
|
|
35
|
+
const fontDigests = new Map<string, Promise<string>>();
|
|
99
36
|
function getFontDigest(fontPath: string): Promise<string> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
37
|
+
let pending = fontDigests.get(fontPath);
|
|
38
|
+
if (!pending) {
|
|
39
|
+
pending = readFile(fontPath).then((bytes) =>
|
|
40
|
+
createHash("sha256").update(bytes).digest("hex"),
|
|
41
|
+
);
|
|
42
|
+
fontDigests.set(fontPath, pending);
|
|
43
|
+
}
|
|
44
|
+
return pending;
|
|
104
45
|
}
|
|
105
46
|
|
|
106
47
|
export async function renderSocialCard(
|
|
@@ -109,7 +50,6 @@ export async function renderSocialCard(
|
|
|
109
50
|
warn: (message: string) => void,
|
|
110
51
|
): Promise<Buffer> {
|
|
111
52
|
const format = socialCardFormat(data);
|
|
112
|
-
const sharpAvailable = Boolean(loadSharp());
|
|
113
53
|
let coverBytes: Buffer | undefined;
|
|
114
54
|
if (data.cover) {
|
|
115
55
|
try {
|
|
@@ -120,67 +60,38 @@ export async function renderSocialCard(
|
|
|
120
60
|
}
|
|
121
61
|
// Content-only key: identical card data + identical font + identical cover
|
|
122
62
|
// always reuses the cached render, whatever the filesystem timestamps say.
|
|
123
|
-
// It is checked before
|
|
63
|
+
// It is checked before any raster work, so a warm build only reads bytes.
|
|
124
64
|
const fontDigest = await getFontDigest(paths.fontPath);
|
|
125
65
|
const key = createHash("sha256")
|
|
126
|
-
.update(
|
|
127
|
-
JSON.stringify([TEMPLATE, format, data, fontDigest, sharpAvailable ? "panel" : "flat"]),
|
|
128
|
-
)
|
|
66
|
+
.update(JSON.stringify([TEMPLATE, format, data, fontDigest]))
|
|
129
67
|
.update(coverBytes ?? "")
|
|
130
68
|
.digest("hex");
|
|
131
69
|
const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
|
|
132
70
|
const hit = await readFile(cacheFile).catch(() => undefined);
|
|
133
71
|
if (hit) return hit;
|
|
134
72
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
}
|
|
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);
|
|
173
|
-
};
|
|
73
|
+
const renderer = await getRenderer(paths.fontPath);
|
|
74
|
+
const render = (withCover: boolean) =>
|
|
75
|
+
renderer.render(socialCardTree(data, withCover), {
|
|
76
|
+
width: CARD_WIDTH,
|
|
77
|
+
height: CARD_HEIGHT,
|
|
78
|
+
format: format === "jpg" ? "jpeg" : "png",
|
|
79
|
+
quality: JPEG_QUALITY,
|
|
80
|
+
images: withCover && coverBytes ? [{ src: "stalux-cover", data: coverBytes }] : [],
|
|
81
|
+
});
|
|
174
82
|
let bytes: Buffer;
|
|
83
|
+
let cacheable = true;
|
|
175
84
|
try {
|
|
176
|
-
bytes = await
|
|
85
|
+
bytes = await render(Boolean(coverBytes));
|
|
177
86
|
} catch (error) {
|
|
178
|
-
if (!
|
|
87
|
+
if (!coverBytes) throw error;
|
|
179
88
|
warn(`分享封面解码失败,已回退标题卡片:${data.title}`);
|
|
180
89
|
// Do not cache the fallback under the artwork key: a later build should
|
|
181
90
|
// retry the cover instead of inheriting a short-lived decode failure.
|
|
182
|
-
|
|
91
|
+
bytes = await render(false);
|
|
92
|
+
cacheable = false;
|
|
183
93
|
}
|
|
94
|
+
if (!cacheable) return bytes;
|
|
184
95
|
await mkdir(paths.cacheDir, { recursive: true });
|
|
185
96
|
await writeFile(cacheFile, bytes);
|
|
186
97
|
return bytes;
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { loadSharp } from "../internal/sharp-loader";
|
|
3
2
|
|
|
4
3
|
export interface SocialCardData {
|
|
5
4
|
title: string;
|
|
6
|
-
description: string;
|
|
7
5
|
siteTitle: string;
|
|
8
6
|
author: string;
|
|
9
7
|
host: string;
|
|
10
|
-
|
|
8
|
+
postUrl: string;
|
|
9
|
+
tags: string[];
|
|
11
10
|
date: string;
|
|
12
11
|
category: string;
|
|
13
12
|
cover?: string;
|
|
@@ -37,20 +36,15 @@ export function isSocialImageUrl(value: string): boolean {
|
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
38
|
|
|
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
|
-
*/
|
|
39
|
+
/** Artwork cards are photographic, so JPEG saves bytes; plain cards stay PNG. */
|
|
46
40
|
export function socialCardFormat(data: Pick<SocialCardData, "cover">): SocialCardFormat {
|
|
47
|
-
return data.cover
|
|
41
|
+
return data.cover ? "jpg" : "png";
|
|
48
42
|
}
|
|
49
43
|
|
|
50
44
|
export function socialCardPath(data: SocialCardData, base = "/"): string {
|
|
51
45
|
// Bump the template revision when layout changes. Metadata changes get a fresh URL.
|
|
52
46
|
const hash = createHash("sha256")
|
|
53
|
-
.update(JSON.stringify(["stalux-mono-
|
|
47
|
+
.update(JSON.stringify(["stalux-mono-v2", data]))
|
|
54
48
|
.digest("hex")
|
|
55
49
|
.slice(0, 20);
|
|
56
50
|
return `${base.replace(/\/$/u, "")}/og/${hash}.${socialCardFormat(data)}`;
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
|
|
3
|
-
export interface SharpRawBuffer {
|
|
4
|
-
data: Buffer;
|
|
5
|
-
info: { width: number; height: number; channels: number };
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface SharpPipeline {
|
|
9
|
-
rotate: () => SharpPipeline;
|
|
10
|
-
resize: (options: Record<string, unknown>) => SharpPipeline;
|
|
11
|
-
flatten: (options: Record<string, unknown>) => SharpPipeline;
|
|
12
|
-
removeAlpha: () => SharpPipeline;
|
|
13
|
-
composite: (images: Record<string, unknown>[]) => SharpPipeline;
|
|
14
|
-
greyscale: () => SharpPipeline;
|
|
15
|
-
toColourspace: (colourspace: string) => SharpPipeline;
|
|
16
|
-
raw: () => SharpPipeline;
|
|
17
|
-
jpeg: (options: Record<string, unknown>) => SharpPipeline;
|
|
18
|
-
png: (options: Record<string, unknown>) => SharpPipeline;
|
|
19
|
-
toBuffer(): Promise<Buffer>;
|
|
20
|
-
toBuffer(options: { resolveWithObject: true }): Promise<SharpRawBuffer>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type SharpFactory = (input: Buffer) => SharpPipeline;
|
|
24
|
-
|
|
25
|
-
let cached: { factory: SharpFactory | undefined } | undefined;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* `sharp` is optional: it ships with Astro and is a dev dependency of the theme,
|
|
29
|
-
* but a project using another image service must still be able to build. It is
|
|
30
|
-
* loaded with `require` because the Vite module runner may already be closed and
|
|
31
|
-
* because a bare dynamic import would make the bundler resolve it eagerly.
|
|
32
|
-
*/
|
|
33
|
-
export function loadSharp(): SharpFactory | undefined {
|
|
34
|
-
if (cached) return cached.factory;
|
|
35
|
-
let factory: SharpFactory | undefined;
|
|
36
|
-
try {
|
|
37
|
-
const loaded = createRequire(import.meta.url)("sharp") as { default?: unknown };
|
|
38
|
-
const candidate = (loaded.default ?? loaded) as unknown;
|
|
39
|
-
if (typeof candidate === "function") factory = candidate as SharpFactory;
|
|
40
|
-
} catch {
|
|
41
|
-
factory = undefined;
|
|
42
|
-
}
|
|
43
|
-
cached = { factory };
|
|
44
|
-
return factory;
|
|
45
|
-
}
|