@xingwangzhe/stalux 1.49.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/layouts/Stalux.astro +1 -0
- package/src/pages/og/[image].[format].ts +20 -21
- package/src/scripts/route-prefetch.ts +120 -0
- 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);
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -80,6 +80,7 @@ const routeKind = getRouteKind(Astro.url.pathname);
|
|
|
80
80
|
// against the article and archive components that also request it.
|
|
81
81
|
import { mountListReveal } from "../scripts/list-reveal";
|
|
82
82
|
import { registerPageLifecycle } from "../scripts/page-runtime";
|
|
83
|
+
import "../scripts/route-prefetch";
|
|
83
84
|
|
|
84
85
|
registerPageLifecycle("list-reveal", mountListReveal);
|
|
85
86
|
</script>
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm the main navigation routes after the homepage has settled.
|
|
3
|
+
*
|
|
4
|
+
* Swup already preloads hovered/focused links. This adds a low-priority,
|
|
5
|
+
* idle-time preload of the visible navigation routes, so the first click from
|
|
6
|
+
* the homepage can reuse Swup's page cache instead of fetching and parsing the
|
|
7
|
+
* document during the transition. The preload plugin enforces its own throttle,
|
|
8
|
+
* skips saveData/2G connections, deduplicates cached pages and ignores routes
|
|
9
|
+
* the router cannot handle.
|
|
10
|
+
*/
|
|
11
|
+
import { createClientLogger } from "./logger";
|
|
12
|
+
import { registerPageLifecycle } from "./page-runtime";
|
|
13
|
+
|
|
14
|
+
const logger = createClientLogger("route-prefetch");
|
|
15
|
+
|
|
16
|
+
const AFTER_LOAD_DELAY_MS = 1000;
|
|
17
|
+
const LOAD_CEILING_MS = 2500;
|
|
18
|
+
const IDLE_TIMEOUT_MS = 2000;
|
|
19
|
+
|
|
20
|
+
export function collectMainRouteUrls(
|
|
21
|
+
documentLike: Pick<Document, "querySelectorAll"> = document,
|
|
22
|
+
current = new URL(window.location.href),
|
|
23
|
+
): string[] {
|
|
24
|
+
const urls = new Set<string>();
|
|
25
|
+
for (const anchor of documentLike.querySelectorAll<HTMLAnchorElement>(
|
|
26
|
+
'a[data-ref="stalux-nav-link"]',
|
|
27
|
+
)) {
|
|
28
|
+
let url: URL;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(anchor.href, current);
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (url.origin !== current.origin) continue;
|
|
35
|
+
if (url.pathname === current.pathname && url.search === current.search) continue;
|
|
36
|
+
urls.add(`${url.pathname}${url.search}`);
|
|
37
|
+
}
|
|
38
|
+
return [...urls];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function mountRoutePrefetch(): (() => void) | undefined {
|
|
42
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
43
|
+
if (document.documentElement.dataset.staluxRoute !== "home") return;
|
|
44
|
+
const reducedData = window.matchMedia?.("(prefers-reduced-data: reduce)");
|
|
45
|
+
if (reducedData?.matches) return;
|
|
46
|
+
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
let scheduled = false;
|
|
49
|
+
let delayTimer: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
+
let ceilingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
let idleId: number | undefined;
|
|
52
|
+
|
|
53
|
+
const run = () => {
|
|
54
|
+
if (cancelled || document.hidden) return;
|
|
55
|
+
const swup = window.swup;
|
|
56
|
+
if (!swup || typeof swup.preload !== "function") {
|
|
57
|
+
logger.debug("swup preload API unavailable; skipping route prefetch");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const urls = collectMainRouteUrls();
|
|
61
|
+
if (!urls.length) return;
|
|
62
|
+
logger.debug(`prefetching ${urls.length} main routes`);
|
|
63
|
+
// One document at a time: keep the prefetch invisible to the current
|
|
64
|
+
// page's main thread and network instead of parsing six documents at once.
|
|
65
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
66
|
+
for (const url of urls) {
|
|
67
|
+
chain = chain
|
|
68
|
+
.then(() => {
|
|
69
|
+
if (cancelled) return;
|
|
70
|
+
return swup.preload?.(url, { priority: false });
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
// A failed warm-up must never affect navigation.
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
void chain;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const requestIdle = () => {
|
|
80
|
+
if (cancelled) return;
|
|
81
|
+
const idle = window.requestIdleCallback;
|
|
82
|
+
if (typeof idle === "function") {
|
|
83
|
+
idleId = idle(run, { timeout: IDLE_TIMEOUT_MS });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
idleId = setTimeout(run, 1) as unknown as number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const schedule = () => {
|
|
90
|
+
if (scheduled || cancelled) return;
|
|
91
|
+
scheduled = true;
|
|
92
|
+
delayTimer = setTimeout(requestIdle, AFTER_LOAD_DELAY_MS);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (document.readyState === "complete") {
|
|
96
|
+
schedule();
|
|
97
|
+
} else {
|
|
98
|
+
const onLoad = () => {
|
|
99
|
+
window.removeEventListener("load", onLoad);
|
|
100
|
+
schedule();
|
|
101
|
+
};
|
|
102
|
+
window.addEventListener("load", onLoad, { once: true });
|
|
103
|
+
// A slow image or third-party request must not postpone the warm-up forever.
|
|
104
|
+
ceilingTimer = setTimeout(() => {
|
|
105
|
+
window.removeEventListener("load", onLoad);
|
|
106
|
+
schedule();
|
|
107
|
+
}, LOAD_CEILING_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
if (delayTimer !== undefined) clearTimeout(delayTimer);
|
|
113
|
+
if (ceilingTimer !== undefined) clearTimeout(ceilingTimer);
|
|
114
|
+
if (idleId === undefined) return;
|
|
115
|
+
if (typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(idleId);
|
|
116
|
+
else clearTimeout(idleId);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
registerPageLifecycle("route-prefetch", mountRoutePrefetch);
|