@xingwangzhe/stalux 1.41.4 → 1.42.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 +14 -0
- package/package.json +6 -3
- package/src/components/stalux/layout/head.astro +9 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +21 -1
- package/src/internal/social-card-data.ts +34 -0
- package/src/internal/social-card-renderer.ts +307 -0
- package/src/layouts/Stalux.astro +3 -0
- package/src/pages/og/[image].[format].ts +47 -0
- package/src/pages/posts/[post].astro +8 -0
- package/src/pages/posts/[post].md.ts +1 -1
- package/src/schemas/collections.ts +9 -0
- package/src/scripts/analytics-runtime.ts +57 -7
- package/src/scripts/background.ts +11 -0
- package/src/scripts/page-runtime.ts +53 -12
- package/src/utils/post-markdown.ts +2 -0
- package/src/utils/route-catalog.ts +1 -0
- package/src/utils/seo-document.ts +21 -4
- package/src/utils/social-image.ts +60 -0
package/README.md
CHANGED
|
@@ -356,3 +356,17 @@ Theme code is **MIT**; the root [LICENSE](LICENSE) contains the standard text fo
|
|
|
356
356
|
| Font copyrights, reserved names and modification notices | Each font’s original license | [LICENSE-FONT-NOTICES.txt](LICENSES/LICENSE-FONT-NOTICES.txt) |
|
|
357
357
|
|
|
358
358
|
The npm package includes `LICENSES/` and font-specific texts in `public/fonts/`; builds synchronize font notices to the consumer’s public font directory alongside the served WOFF2 files. Native subsetting dependencies retain their [separate license inventory](https://github.com/xingwangzhe/cjk-font-split-native/blob/main/README.en.md#licenses-and-scope) and packaged `THIRD_PARTY_LICENSES.txt`. GitHub’s primary-license display identifies theme code; fonts and other third-party components retain the scopes listed above.
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
### Article social sharing cards
|
|
362
|
+
|
|
363
|
+
Image priority is **explicit `og:image` → a card with a valid `cover` → an automatic title card**. The article list continues to use `cover`. An explicit `og:image` is used as-is and accepts HTTP(S), protocol-relative URLs, or site-root paths; PNG/JPEG at 1200×630 is recommended. Omit it or leave it empty to generate a card automatically. Existing posts need no changes.
|
|
364
|
+
|
|
365
|
+
```yaml title="Article sharing images"
|
|
366
|
+
cover: /images/article-cover.webp
|
|
367
|
+
og:image: /images/custom-social-card.png
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`@takumi-rs/core` renders a static 1200×630 WebP at `/og/<metadata-hash>.webp` using the bundled LXGW WenKai font. Cards include the title, summary, site identity, author, category, accent color and date in the site timezone. No renderer JavaScript is sent to the browser. Static `og:image`, `twitter:image` and BlogPosting `image` use the same absolute URL; generated images also declare dimensions and MIME type. Articles use `summary_large_image`. A second `og:image` points to a PNG fallback; platforms choose which format they can read. WebP quality is 86; AVIF is not the default.
|
|
371
|
+
|
|
372
|
+
Local covers are read from the final `publicDir`. Remote covers are requested by the build machine, with an 8-second timeout and an 8 MiB limit (remote bytes are cached for 24 hours); unreadable or undecodable artwork emits a warning and falls back to a title card. No remote image-generation service is used. WebP and PNG outputs are cached in `stalux-social-cards/` under Astro's cache directory and copied into each build output. Explicit sharing images are fetched by social platforms, so they must be publicly accessible images. Platforms maintain their own preview caches and may not refresh an existing share immediately after deployment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme — use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"LICENSES"
|
|
51
51
|
],
|
|
52
52
|
"type": "module",
|
|
53
|
-
"packageManager": "bun@1.4.
|
|
53
|
+
"packageManager": "bun@1.4.4",
|
|
54
54
|
"exports": {
|
|
55
55
|
".": "./src/index.ts",
|
|
56
56
|
"./config": "./src/config.ts",
|
|
@@ -97,7 +97,8 @@
|
|
|
97
97
|
"styles:build": "node scripts/build-styles.ts && node scripts/build-style-names.ts",
|
|
98
98
|
"styles:watch": "node scripts/build-styles.ts --watch",
|
|
99
99
|
"benchmark:performance": "node scripts/benchmark-performance.ts",
|
|
100
|
-
"benchmark:builds": "python3 scripts/benchmark-builds.py"
|
|
100
|
+
"benchmark:builds": "python3 scripts/benchmark-builds.py",
|
|
101
|
+
"analyze": "SONDA=1 bun run build"
|
|
101
102
|
},
|
|
102
103
|
"dependencies": {
|
|
103
104
|
"@astrojs/compiler": "^4.0.0",
|
|
@@ -109,6 +110,7 @@
|
|
|
109
110
|
"@mcp-b/webmcp-polyfill": "^5.1.0",
|
|
110
111
|
"@microsoft/clarity": "1.0.2",
|
|
111
112
|
"@pagefind/component-ui": "^1.5.2",
|
|
113
|
+
"@takumi-rs/core": "2.17.1",
|
|
112
114
|
"@waline/client": "^3.16.1",
|
|
113
115
|
"@xingwangzhe/cjk-font-split-native": "^0.4.10",
|
|
114
116
|
"@xingwangzhe/satteri-mermaid": "^0.8.2",
|
|
@@ -137,6 +139,7 @@
|
|
|
137
139
|
"@vitest/coverage-v8": "^5.0.4",
|
|
138
140
|
"astro": "^7.4.2",
|
|
139
141
|
"knip": "^6.41.0",
|
|
142
|
+
"sonda": "^0.14.0",
|
|
140
143
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
141
144
|
"unocss": "^66.11.0",
|
|
142
145
|
"vite": "^8.3.4",
|
|
@@ -68,6 +68,15 @@ const ldJson = JSON.stringify(seo.schema).replace(/<\//g, "<\\/");
|
|
|
68
68
|
meta: seo.meta,
|
|
69
69
|
}}
|
|
70
70
|
/>
|
|
71
|
+
{seo.fallbackImage && (
|
|
72
|
+
<>
|
|
73
|
+
<meta property="og:image" content={seo.fallbackImage} />
|
|
74
|
+
<meta property="og:image:type" content="image/png" />
|
|
75
|
+
<meta property="og:image:width" content="1200" />
|
|
76
|
+
<meta property="og:image:height" content="630" />
|
|
77
|
+
<meta property="og:image:alt" content={seo.openGraph.basic.title} />
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
71
80
|
<!-- 代码字体由 Astro Fonts API 注入;正文按页面生成 native CJK 子集。 -->
|
|
72
81
|
<Font cssVariable="--font-code" />
|
|
73
82
|
<link rel="prefetch" as="style" href={pagefindStyles} data-stalux-search-style>
|
package/src/env.d.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -120,6 +120,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
120
120
|
...options,
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
const socialCardPaths = { publicDir: "", cacheDir: "", base: "/", fontPath: "" };
|
|
124
|
+
|
|
123
125
|
let pageFontData: { fontBuffer: Buffer; outputDir: string; cacheDir: string } | undefined;
|
|
124
126
|
|
|
125
127
|
const coreIntegration: AstroIntegration = {
|
|
@@ -166,12 +168,24 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
166
168
|
build: {
|
|
167
169
|
target: config.vite.build?.target ?? "esnext",
|
|
168
170
|
cssMinify: "lightningcss",
|
|
169
|
-
sourcemap
|
|
171
|
+
// 默认不产出 sourcemap;消费方显式开启时(如 SONDA=1 做体积分析)尊重其设置
|
|
172
|
+
sourcemap: config.vite.build?.sourcemap ?? false,
|
|
170
173
|
},
|
|
171
174
|
resolve: {
|
|
172
175
|
alias: createViteAliases(srcDir),
|
|
173
176
|
},
|
|
174
177
|
plugins: [
|
|
178
|
+
{
|
|
179
|
+
name: "stalux:social-card-paths",
|
|
180
|
+
resolveId: (id) =>
|
|
181
|
+
id === "virtual:stalux-social-card-paths"
|
|
182
|
+
? `\0${id}`
|
|
183
|
+
: undefined,
|
|
184
|
+
load: (id) =>
|
|
185
|
+
id === "\0virtual:stalux-social-card-paths"
|
|
186
|
+
? `export default ${JSON.stringify(socialCardPaths)}`
|
|
187
|
+
: undefined,
|
|
188
|
+
},
|
|
175
189
|
searchStylePlugin(command === "dev", config.base),
|
|
176
190
|
webMcpMutationsPlugin(),
|
|
177
191
|
...(command === "build"
|
|
@@ -339,6 +353,12 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
339
353
|
"astro:config:done": ({ config, logger }) => {
|
|
340
354
|
const srcDir = fileURLToPath(new URL(".", import.meta.url));
|
|
341
355
|
const publicDir = fileURLToPath(config.publicDir);
|
|
356
|
+
Object.assign(socialCardPaths, {
|
|
357
|
+
publicDir,
|
|
358
|
+
cacheDir: fileURLToPath(new URL("stalux-social-cards/", config.cacheDir)),
|
|
359
|
+
base: config.base,
|
|
360
|
+
fontPath: path.join(srcDir, "assets/fonts/LXGWWenKai-Regular.ttf"),
|
|
361
|
+
});
|
|
342
362
|
const assetsLogger = logger.fork("stalux/assets");
|
|
343
363
|
if (pageFontData) {
|
|
344
364
|
pageFontData.outputDir = path.join(
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AuthorData, SiteData } from "../schemas/config";
|
|
2
|
+
import { formatInTimeZone, parseDate } from "../utils/dayjs";
|
|
3
|
+
import type { SocialCardData } from "../utils/social-image";
|
|
4
|
+
|
|
5
|
+
interface PostData {
|
|
6
|
+
title: string;
|
|
7
|
+
desc: string;
|
|
8
|
+
date: string;
|
|
9
|
+
cover?: string;
|
|
10
|
+
categories?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createSocialCardData(
|
|
14
|
+
post: PostData,
|
|
15
|
+
site: SiteData,
|
|
16
|
+
author: AuthorData,
|
|
17
|
+
): SocialCardData {
|
|
18
|
+
return {
|
|
19
|
+
title: post.title,
|
|
20
|
+
description: post.desc,
|
|
21
|
+
siteTitle: site.title,
|
|
22
|
+
author: author.name,
|
|
23
|
+
host: new URL(site.canonical ?? site.url).host,
|
|
24
|
+
lang: site.lang || "zh-CN",
|
|
25
|
+
accent: site.accentColor || "#EAB308",
|
|
26
|
+
date: formatInTimeZone(
|
|
27
|
+
parseDate(post.date, site.timezone || "Asia/Shanghai"),
|
|
28
|
+
site.timezone || "Asia/Shanghai",
|
|
29
|
+
"yyyy-MM-dd",
|
|
30
|
+
),
|
|
31
|
+
category: post.categories?.[0] ?? "",
|
|
32
|
+
...(post.cover?.trim() ? { cover: post.cover.trim() } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { type Node, Renderer } from "@takumi-rs/core";
|
|
5
|
+
import { imageSize } from "image-size";
|
|
6
|
+
import type { SocialCardData } from "../utils/social-image";
|
|
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
|
+
let rendererPromise: Promise<Renderer> | undefined;
|
|
17
|
+
function getRenderer(fontPath: string): Promise<Renderer> {
|
|
18
|
+
rendererPromise ??= (async () => {
|
|
19
|
+
const renderer = new Renderer({ cacheMaxBytes: 32 * 1024 * 1024 });
|
|
20
|
+
await renderer.registerFont({ name: "Stalux Card", data: await readFile(fontPath) });
|
|
21
|
+
return renderer;
|
|
22
|
+
})();
|
|
23
|
+
return rendererPromise;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve only assets inside the final public directory, including encoded Chinese paths. */
|
|
27
|
+
export function localCoverPath(cover: string, paths: SocialCardPaths): string | undefined {
|
|
28
|
+
if (!cover.startsWith("/") || cover.startsWith("//")) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
let pathname = decodeURIComponent(cover.split(/[?#]/u)[0] ?? "");
|
|
31
|
+
const base = paths.base.replace(/\/$/u, "");
|
|
32
|
+
if (base && pathname.startsWith(`${base}/`)) pathname = pathname.slice(base.length);
|
|
33
|
+
const target = path.resolve(paths.publicDir, `.${pathname}`);
|
|
34
|
+
if (!target.startsWith(`${path.resolve(paths.publicDir)}${path.sep}`)) return undefined;
|
|
35
|
+
return target;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const coverRequests = new Map<string, Promise<Buffer>>();
|
|
42
|
+
|
|
43
|
+
async function loadCover(cover: string, paths: SocialCardPaths): Promise<Buffer> {
|
|
44
|
+
const key = `${paths.publicDir}|${cover}`;
|
|
45
|
+
let pending = coverRequests.get(key);
|
|
46
|
+
if (!pending) {
|
|
47
|
+
pending = loadCoverUncached(cover, paths);
|
|
48
|
+
coverRequests.set(key, pending);
|
|
49
|
+
// Bound memory while sharing requests and failures between WebP and PNG renders.
|
|
50
|
+
if (coverRequests.size > 8) coverRequests.delete(coverRequests.keys().next().value ?? "");
|
|
51
|
+
}
|
|
52
|
+
return pending;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise<Buffer> {
|
|
56
|
+
const local = localCoverPath(cover, paths);
|
|
57
|
+
let bytes: Buffer;
|
|
58
|
+
if (local) {
|
|
59
|
+
if ((await stat(local)).size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
|
|
60
|
+
bytes = await readFile(local);
|
|
61
|
+
} else {
|
|
62
|
+
const url = new URL(cover.startsWith("//") ? `https:${cover}` : cover);
|
|
63
|
+
if (!["https:", "http:"].includes(url.protocol)) throw new Error("invalid cover URL");
|
|
64
|
+
const remoteCache = path.join(
|
|
65
|
+
paths.cacheDir,
|
|
66
|
+
"covers",
|
|
67
|
+
createHash("sha256").update(url.href).digest("hex"),
|
|
68
|
+
);
|
|
69
|
+
const cachedStat = await stat(remoteCache).catch(() => undefined);
|
|
70
|
+
if (cachedStat && Date.now() - cachedStat.mtimeMs < 86_400_000)
|
|
71
|
+
return readFile(remoteCache);
|
|
72
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
73
|
+
if (!response.ok || !response.headers.get("content-type")?.startsWith("image/"))
|
|
74
|
+
throw new Error(`cover returned HTTP ${response.status} or non-image content`);
|
|
75
|
+
const chunks: Uint8Array[] = [];
|
|
76
|
+
let size = 0;
|
|
77
|
+
if (!response.body) throw new Error("empty cover");
|
|
78
|
+
for await (const chunk of response.body) {
|
|
79
|
+
size += chunk.length;
|
|
80
|
+
if (size > MAX_COVER_BYTES) throw new Error("cover exceeds 8 MiB");
|
|
81
|
+
chunks.push(chunk);
|
|
82
|
+
}
|
|
83
|
+
bytes = Buffer.concat(chunks);
|
|
84
|
+
const dimensions = imageSize(bytes);
|
|
85
|
+
if (
|
|
86
|
+
!dimensions.width ||
|
|
87
|
+
!dimensions.height ||
|
|
88
|
+
dimensions.width * dimensions.height > 32_000_000
|
|
89
|
+
)
|
|
90
|
+
throw new Error("invalid cover dimensions");
|
|
91
|
+
await mkdir(path.dirname(remoteCache), { recursive: true });
|
|
92
|
+
await writeFile(remoteCache, bytes);
|
|
93
|
+
}
|
|
94
|
+
const dimensions = imageSize(bytes);
|
|
95
|
+
if (
|
|
96
|
+
!dimensions.width ||
|
|
97
|
+
!dimensions.height ||
|
|
98
|
+
dimensions.width * dimensions.height > 32_000_000
|
|
99
|
+
)
|
|
100
|
+
throw new Error("invalid or oversized cover dimensions");
|
|
101
|
+
return bytes;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function text(value: string, style: Node["style"]): Node {
|
|
105
|
+
return { type: "text", text: value, style };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A fixed editorial layout: site identity, readable title, optional artwork, quiet footer. */
|
|
109
|
+
export function socialCardTree(
|
|
110
|
+
data: SocialCardData,
|
|
111
|
+
hasCover: boolean,
|
|
112
|
+
titleSize = 60,
|
|
113
|
+
title = data.title,
|
|
114
|
+
): Node {
|
|
115
|
+
const titleWidth = hasCover ? 650 : 1056;
|
|
116
|
+
const accent = /^#[\da-f]{6}$/iu.test(data.accent) ? data.accent : "#EAB308";
|
|
117
|
+
const children: Node[] = [
|
|
118
|
+
{
|
|
119
|
+
type: "container",
|
|
120
|
+
style: {
|
|
121
|
+
position: "absolute",
|
|
122
|
+
right: -120,
|
|
123
|
+
top: -200,
|
|
124
|
+
width: 650,
|
|
125
|
+
height: 650,
|
|
126
|
+
borderRadius: "50%",
|
|
127
|
+
border: `1px solid ${accent}35`,
|
|
128
|
+
background: `radial-gradient(circle, ${accent}18, transparent 70%)`,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
type: "container",
|
|
133
|
+
style: {
|
|
134
|
+
position: "absolute",
|
|
135
|
+
left: 0,
|
|
136
|
+
top: 0,
|
|
137
|
+
width: 1200,
|
|
138
|
+
height: 6,
|
|
139
|
+
backgroundColor: accent,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
text(data.siteTitle, {
|
|
143
|
+
position: "absolute",
|
|
144
|
+
left: 72,
|
|
145
|
+
top: 48,
|
|
146
|
+
width: 820,
|
|
147
|
+
height: 46,
|
|
148
|
+
fontSize: 28,
|
|
149
|
+
color: accent,
|
|
150
|
+
overflow: "hidden",
|
|
151
|
+
}),
|
|
152
|
+
text(data.category || "ARTICLE", {
|
|
153
|
+
position: "absolute",
|
|
154
|
+
right: 72,
|
|
155
|
+
top: 54,
|
|
156
|
+
width: 240,
|
|
157
|
+
height: 36,
|
|
158
|
+
textAlign: "right",
|
|
159
|
+
fontSize: 21,
|
|
160
|
+
color: "#a9b5c6",
|
|
161
|
+
overflow: "hidden",
|
|
162
|
+
}),
|
|
163
|
+
text(title, {
|
|
164
|
+
position: "absolute",
|
|
165
|
+
left: 72,
|
|
166
|
+
top: 142,
|
|
167
|
+
width: titleWidth,
|
|
168
|
+
height: 260,
|
|
169
|
+
fontSize: titleSize,
|
|
170
|
+
lineHeight: 1.24,
|
|
171
|
+
color: "#f5f7fb",
|
|
172
|
+
overflow: "hidden",
|
|
173
|
+
overflowWrap: "anywhere",
|
|
174
|
+
}),
|
|
175
|
+
text([...data.description.replace(/\s+/gu, " ")].slice(0, hasCover ? 65 : 100).join(""), {
|
|
176
|
+
position: "absolute",
|
|
177
|
+
left: 72,
|
|
178
|
+
top: 421,
|
|
179
|
+
width: titleWidth,
|
|
180
|
+
height: 76,
|
|
181
|
+
fontSize: 25,
|
|
182
|
+
lineHeight: 1.45,
|
|
183
|
+
color: "#a9b5c6",
|
|
184
|
+
overflow: "hidden",
|
|
185
|
+
}),
|
|
186
|
+
{
|
|
187
|
+
type: "container",
|
|
188
|
+
style: {
|
|
189
|
+
position: "absolute",
|
|
190
|
+
left: 72,
|
|
191
|
+
top: 535,
|
|
192
|
+
width: 1056,
|
|
193
|
+
height: 1,
|
|
194
|
+
backgroundColor: "#ffffff22",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
text(data.host, { position: "absolute", left: 72, top: 560, fontSize: 23, color: accent }),
|
|
198
|
+
text(`${data.author} / ${data.date}`, {
|
|
199
|
+
position: "absolute",
|
|
200
|
+
right: 72,
|
|
201
|
+
top: 560,
|
|
202
|
+
width: 650,
|
|
203
|
+
textAlign: "right",
|
|
204
|
+
fontSize: 23,
|
|
205
|
+
color: "#b9c4d3",
|
|
206
|
+
}),
|
|
207
|
+
];
|
|
208
|
+
if (hasCover)
|
|
209
|
+
children.push({
|
|
210
|
+
type: "image",
|
|
211
|
+
src: "stalux-cover",
|
|
212
|
+
style: {
|
|
213
|
+
position: "absolute",
|
|
214
|
+
right: 72,
|
|
215
|
+
top: 142,
|
|
216
|
+
width: 350,
|
|
217
|
+
height: 354,
|
|
218
|
+
objectFit: "cover",
|
|
219
|
+
borderRadius: 20,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
type: "container",
|
|
224
|
+
lang: data.lang,
|
|
225
|
+
style: {
|
|
226
|
+
width: 1200,
|
|
227
|
+
height: 630,
|
|
228
|
+
position: "relative",
|
|
229
|
+
backgroundColor: "#101722",
|
|
230
|
+
fontFamily: "Stalux Card",
|
|
231
|
+
overflow: "hidden",
|
|
232
|
+
},
|
|
233
|
+
children,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function renderSocialCard(
|
|
238
|
+
data: SocialCardData,
|
|
239
|
+
paths: SocialCardPaths,
|
|
240
|
+
warn: (message: string) => void,
|
|
241
|
+
format: "webp" | "png" = "webp",
|
|
242
|
+
): Promise<Buffer> {
|
|
243
|
+
let cover: Buffer | undefined;
|
|
244
|
+
if (data.cover) {
|
|
245
|
+
try {
|
|
246
|
+
cover = await loadCover(data.cover, paths);
|
|
247
|
+
} catch {
|
|
248
|
+
warn(`分享封面不可用,已回退标题卡片:${data.title}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const font = await stat(paths.fontPath);
|
|
252
|
+
const key = createHash("sha256")
|
|
253
|
+
.update(JSON.stringify(["v2", format, data, font.size, font.mtimeMs]))
|
|
254
|
+
.update(cover ?? "")
|
|
255
|
+
.digest("hex");
|
|
256
|
+
const cacheFile = path.join(paths.cacheDir, `${key}.${format}`);
|
|
257
|
+
const hit = await readFile(cacheFile).catch(() => undefined);
|
|
258
|
+
if (hit) return hit;
|
|
259
|
+
const renderer = await getRenderer(paths.fontPath);
|
|
260
|
+
const layout = async (withCover: boolean) => {
|
|
261
|
+
const width = withCover ? 650 : 1056;
|
|
262
|
+
let title = [...data.title].slice(0, 150).join("");
|
|
263
|
+
let size = 66;
|
|
264
|
+
while (true) {
|
|
265
|
+
const measured = await renderer.measure(
|
|
266
|
+
{
|
|
267
|
+
type: "text",
|
|
268
|
+
text: title,
|
|
269
|
+
style: {
|
|
270
|
+
width,
|
|
271
|
+
fontSize: size,
|
|
272
|
+
lineHeight: 1.24,
|
|
273
|
+
fontFamily: "Stalux Card",
|
|
274
|
+
overflowWrap: "anywhere",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{ width },
|
|
278
|
+
);
|
|
279
|
+
if (measured.height <= 250) break;
|
|
280
|
+
if (size > 30) size -= 2;
|
|
281
|
+
else title = `${[...title].slice(0, -3).join("")}…`;
|
|
282
|
+
}
|
|
283
|
+
return socialCardTree(data, withCover, size, title);
|
|
284
|
+
};
|
|
285
|
+
let bytes: Buffer;
|
|
286
|
+
try {
|
|
287
|
+
bytes = await renderer.render(await layout(Boolean(cover)), {
|
|
288
|
+
width: 1200,
|
|
289
|
+
height: 630,
|
|
290
|
+
format,
|
|
291
|
+
quality: 86,
|
|
292
|
+
images: cover ? [{ src: "stalux-cover", data: cover }] : [],
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!cover) throw error;
|
|
296
|
+
warn(`分享封面解码失败,已回退标题卡片:${data.title}`);
|
|
297
|
+
bytes = await renderer.render(await layout(false), {
|
|
298
|
+
width: 1200,
|
|
299
|
+
height: 630,
|
|
300
|
+
format,
|
|
301
|
+
quality: 86,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
await mkdir(paths.cacheDir, { recursive: true });
|
|
305
|
+
await writeFile(cacheFile, bytes);
|
|
306
|
+
return bytes;
|
|
307
|
+
}
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getPromoteData,
|
|
12
12
|
getSiteData,
|
|
13
13
|
} from "@utils/config-utils";
|
|
14
|
+
import type { SocialImage } from "@utils/social-image";
|
|
14
15
|
import { getRouteKind } from "@utils/view-transitions";
|
|
15
16
|
|
|
16
17
|
const props = Astro.props as Props;
|
|
@@ -19,6 +20,7 @@ interface Props {
|
|
|
19
20
|
title: string;
|
|
20
21
|
description: string;
|
|
21
22
|
cover?: string;
|
|
23
|
+
socialImage?: SocialImage;
|
|
22
24
|
isArticle?: boolean;
|
|
23
25
|
date?: string;
|
|
24
26
|
updated?: string;
|
|
@@ -60,6 +62,7 @@ const routeKind = getRouteKind(Astro.url.pathname);
|
|
|
60
62
|
title={props.title}
|
|
61
63
|
description={props.description}
|
|
62
64
|
cover={props.cover}
|
|
65
|
+
socialImage={props.socialImage}
|
|
63
66
|
isArticle={props.isArticle}
|
|
64
67
|
date={props.date}
|
|
65
68
|
updated={props.updated}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getCollection } from "astro:content";
|
|
2
|
+
import paths from "virtual:stalux-social-card-paths";
|
|
3
|
+
import type { APIRoute } from "astro";
|
|
4
|
+
import { createSocialCardData } from "../../internal/social-card-data";
|
|
5
|
+
import { renderSocialCard } from "../../internal/social-card-renderer";
|
|
6
|
+
import { getAuthorData, getSiteData } from "../../utils/config-utils";
|
|
7
|
+
import { getPostContentIndex } from "../../utils/content-index";
|
|
8
|
+
import { isSocialImageUrl, type SocialCardData, socialCardPath } from "../../utils/social-image";
|
|
9
|
+
|
|
10
|
+
export async function getStaticPaths() {
|
|
11
|
+
const entries = await getCollection("config");
|
|
12
|
+
const site = getSiteData(entries);
|
|
13
|
+
const author = getAuthorData(entries);
|
|
14
|
+
const { posts } = await getPostContentIndex();
|
|
15
|
+
return posts
|
|
16
|
+
.filter(
|
|
17
|
+
(post) =>
|
|
18
|
+
!post.data["og:image"]?.trim() || !isSocialImageUrl(post.data["og:image"].trim()),
|
|
19
|
+
)
|
|
20
|
+
.flatMap((post) => {
|
|
21
|
+
const card = createSocialCardData(post.data, site, author);
|
|
22
|
+
return (["webp", "png"] as const).map((format) => ({
|
|
23
|
+
params: {
|
|
24
|
+
image: socialCardPath(card)
|
|
25
|
+
.split("/")
|
|
26
|
+
.at(-1)
|
|
27
|
+
?.replace(/\.webp$/u, ""),
|
|
28
|
+
format,
|
|
29
|
+
},
|
|
30
|
+
props: { card, format },
|
|
31
|
+
// Covers may change independently of Markdown. Always recheck them; the renderer
|
|
32
|
+
// has its own content cache and writes outputs even after Astro cleans dist.
|
|
33
|
+
}));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const GET: APIRoute = async ({ props, logger }) => {
|
|
38
|
+
const bytes = await renderSocialCard(
|
|
39
|
+
props.card as SocialCardData,
|
|
40
|
+
paths,
|
|
41
|
+
(message) => logger.warn(message),
|
|
42
|
+
props.format as "webp" | "png",
|
|
43
|
+
);
|
|
44
|
+
return new Response(new Uint8Array(bytes), {
|
|
45
|
+
headers: { "Content-Type": `image/${props.format}` },
|
|
46
|
+
});
|
|
47
|
+
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { type CollectionEntry, getCollection, render } from "astro:content";
|
|
3
3
|
import { getRuntimeCacheKey } from "@internal/runtime-cache-key";
|
|
4
|
+
import { createSocialCardData } from "@internal/social-card-data";
|
|
4
5
|
import PostLayout from "@layouts/PostLayout.astro";
|
|
5
6
|
import Stalux from "@layouts/Stalux.astro";
|
|
6
7
|
import { analyzeFeatureFlags } from "@plugins/feature-flags";
|
|
7
8
|
import { getAuthorData, getSiteData } from "@utils/config-utils";
|
|
8
9
|
import { getPostContentIndex } from "@utils/content-index";
|
|
10
|
+
import { resolvePostSocialImage } from "@utils/social-image";
|
|
9
11
|
|
|
10
12
|
interface Props {
|
|
11
13
|
post: CollectionEntry<"posts">;
|
|
@@ -33,6 +35,7 @@ export async function getStaticPaths() {
|
|
|
33
35
|
post.data.updated ?? post.data.date,
|
|
34
36
|
prevPost ? (prevPost.data.updated ?? prevPost.data.date) : "",
|
|
35
37
|
nextPost ? (nextPost.data.updated ?? nextPost.data.date) : "",
|
|
38
|
+
JSON.stringify(post.data),
|
|
36
39
|
getRuntimeCacheKey(),
|
|
37
40
|
].join("|");
|
|
38
41
|
return {
|
|
@@ -56,6 +59,11 @@ export async function getStaticPaths() {
|
|
|
56
59
|
title={`${props.post.data.title} | ${siteConfig.title}`}
|
|
57
60
|
description={autoDesc}
|
|
58
61
|
cover={props.post.data.cover}
|
|
62
|
+
socialImage={resolvePostSocialImage(
|
|
63
|
+
props.post.data["og:image"],
|
|
64
|
+
createSocialCardData(props.post.data, siteConfig, authorConfig),
|
|
65
|
+
import.meta.env.BASE_URL,
|
|
66
|
+
)}
|
|
59
67
|
isArticle={true}
|
|
60
68
|
date={props.post.data.date}
|
|
61
69
|
updated={props.post.data.updated}
|
|
@@ -39,7 +39,7 @@ export const getStaticPaths = (async () => {
|
|
|
39
39
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
40
40
|
return posts.map((post) => ({
|
|
41
41
|
params: { post: String(post.data.abbrlink) },
|
|
42
|
-
cacheKey: `${post.data.updated ?? post.data.date}|${getRuntimeCacheKey()}`,
|
|
42
|
+
cacheKey: `${post.data.updated ?? post.data.date}|${JSON.stringify(post.data)}|${getRuntimeCacheKey()}`,
|
|
43
43
|
props: { post },
|
|
44
44
|
}));
|
|
45
45
|
}) satisfies GetStaticPaths;
|
|
@@ -19,6 +19,7 @@ import { defineCollection } from "astro:content";
|
|
|
19
19
|
import { glob, type Loader } from "astro/loaders";
|
|
20
20
|
import { z } from "astro/zod";
|
|
21
21
|
|
|
22
|
+
import { isSocialImageUrl } from "../utils/social-image";
|
|
22
23
|
import { configSchema } from "./config";
|
|
23
24
|
|
|
24
25
|
/** Preserve glob's name, watch behavior and incremental store; only trace the load boundary. */
|
|
@@ -98,6 +99,14 @@ const postSchema = z.object({
|
|
|
98
99
|
desc: z.string({ error: "desc must be a string" }).min(1, "desc is required"),
|
|
99
100
|
cc: z.string().optional().default("CC-BY-NC-SA-4.0"),
|
|
100
101
|
cover: z.string().optional(),
|
|
102
|
+
"og:image": z
|
|
103
|
+
.string()
|
|
104
|
+
.trim()
|
|
105
|
+
.refine(
|
|
106
|
+
(value) => value === "" || isSocialImageUrl(value),
|
|
107
|
+
"og:image must be an HTTP(S) URL or site-root image path",
|
|
108
|
+
)
|
|
109
|
+
.optional(),
|
|
101
110
|
});
|
|
102
111
|
|
|
103
112
|
// ---------------------------------------------------------------------------
|
|
@@ -274,6 +274,57 @@ function trackVercountPage(): void {
|
|
|
274
274
|
document.head.appendChild(script);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/** 文档是否已解析完、DOM 可查询(`loading` 之外的两种状态都算)。 */
|
|
278
|
+
function documentIsParsed(): boolean {
|
|
279
|
+
const readyState = document.readyState;
|
|
280
|
+
return readyState === "interactive" || readyState === "complete";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let pendingEarlyView = 0;
|
|
284
|
+
let earlyViewPending = false;
|
|
285
|
+
let viewedEarly = false;
|
|
286
|
+
|
|
287
|
+
function dispatchPageView(): void {
|
|
288
|
+
initializeTrackers();
|
|
289
|
+
trackGooglePage();
|
|
290
|
+
trackUmamiPage();
|
|
291
|
+
trackVercountPage();
|
|
292
|
+
state.previousPage = window.location.href;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 首访在文档解析完成后就上报一次,不等 window.load。
|
|
297
|
+
* `astro:page-load` 首访时由 window.load 派发,要等完所有图片、字体和第三方资源,
|
|
298
|
+
* 慢资源或卡死的请求会让页脚统计一直停在 Loading...。上报必须按页去重,
|
|
299
|
+
* 否则同一次访问会被重复计入、Vercount 也会重复追加脚本。
|
|
300
|
+
*/
|
|
301
|
+
function dispatchEarlyView(): void {
|
|
302
|
+
const token = ++pendingEarlyView;
|
|
303
|
+
earlyViewPending = true;
|
|
304
|
+
queueMicrotask(() => {
|
|
305
|
+
if (token !== pendingEarlyView || !earlyViewPending) return;
|
|
306
|
+
earlyViewPending = false;
|
|
307
|
+
viewedEarly = true;
|
|
308
|
+
dispatchPageView();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function handlePageLoad(): void {
|
|
313
|
+
if (earlyViewPending) {
|
|
314
|
+
// page-load 属于当前页,由它立即上报,作废排队中的早派发。
|
|
315
|
+
pendingEarlyView++;
|
|
316
|
+
earlyViewPending = false;
|
|
317
|
+
viewedEarly = true;
|
|
318
|
+
dispatchPageView();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (viewedEarly) {
|
|
322
|
+
viewedEarly = false;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
dispatchPageView();
|
|
326
|
+
}
|
|
327
|
+
|
|
277
328
|
if (state.installed && (!state.native || state.revision !== 4)) {
|
|
278
329
|
// Revision 4 replaces synchronous Google page views and the motion policy.
|
|
279
330
|
// An open tab can cross a deployment boundary. Its old anonymous worker
|
|
@@ -284,6 +335,9 @@ if (state.installed && (!state.native || state.revision !== 4)) {
|
|
|
284
335
|
state.revision = 4;
|
|
285
336
|
state.installed = true;
|
|
286
337
|
document.addEventListener("astro:before-swap", (event) => {
|
|
338
|
+
pendingEarlyView++;
|
|
339
|
+
earlyViewPending = false;
|
|
340
|
+
viewedEarly = false;
|
|
287
341
|
transitionReady = prepareRouteMotion(event as Event & RouteMotionEvent).ready;
|
|
288
342
|
// Invalidate an older page's frames, timer and posted task before this
|
|
289
343
|
// incoming snapshot starts. Its captured visit remains in the FIFO.
|
|
@@ -294,11 +348,7 @@ if (state.installed && (!state.native || state.revision !== 4)) {
|
|
|
294
348
|
document.addEventListener("visibilitychange", () => {
|
|
295
349
|
if (document.hidden) flushGooglePages();
|
|
296
350
|
});
|
|
297
|
-
document.addEventListener("astro:page-load",
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
trackUmamiPage();
|
|
301
|
-
trackVercountPage();
|
|
302
|
-
state.previousPage = window.location.href;
|
|
303
|
-
});
|
|
351
|
+
document.addEventListener("astro:page-load", handlePageLoad);
|
|
352
|
+
if (documentIsParsed()) dispatchEarlyView();
|
|
353
|
+
else document.addEventListener("DOMContentLoaded", dispatchEarlyView, { once: true });
|
|
304
354
|
}
|
|
@@ -92,9 +92,15 @@ function prepareBackground(): Promise<number | undefined> {
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
let firstCommitStarted = false;
|
|
96
|
+
|
|
95
97
|
function initializeBackground(): void {
|
|
96
98
|
const active = getLayerEl(getActiveLayer());
|
|
97
99
|
if (!prepared && active?.style.backgroundImage) return;
|
|
100
|
+
// 首访的早提交已经发起,但解码还没落地:此时 window.load 上的 page-load
|
|
101
|
+
// 不是新页面,不能再解码并淡入第二张。
|
|
102
|
+
if (!prepared && firstCommitStarted) return;
|
|
103
|
+
if (!prepared) firstCommitStarted = true;
|
|
98
104
|
const current = generation;
|
|
99
105
|
const ready = prepared ?? prepareBackground();
|
|
100
106
|
prepared = undefined;
|
|
@@ -119,4 +125,9 @@ if (!(window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTE
|
|
|
119
125
|
transitionFinished = prepareRouteMotion(event as RouteMotionEvent).finished;
|
|
120
126
|
});
|
|
121
127
|
document.addEventListener("astro:page-load", initializeBackground);
|
|
128
|
+
// 首访的 page-load 由 window.load 派发,任何慢图片或卡死的第三方资源都会把首屏背景
|
|
129
|
+
// 拖成一片纯色;文档解析完就提交首张背景,软导航仍由 page-load 负责。
|
|
130
|
+
const readyState = document.readyState;
|
|
131
|
+
if (readyState === "interactive" || readyState === "complete") initializeBackground();
|
|
132
|
+
else document.addEventListener("DOMContentLoaded", initializeBackground, { once: true });
|
|
122
133
|
}
|
|
@@ -31,10 +31,21 @@ function getBrowserEnvironment(): PageRuntimeEnvironment | undefined {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** 文档是否已解析完、DOM 可查询(`loading` 之外的两种状态都算)。 */
|
|
35
|
+
function isDocumentParsed(readyState: string | undefined): boolean {
|
|
36
|
+
return readyState === "interactive" || readyState === "complete";
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
/**
|
|
35
40
|
* Mount one page-scoped behavior for the current DOM and dispose it before Astro swaps the body.
|
|
36
41
|
* The key is global for the browser session, so a bundled module can be encountered repeatedly
|
|
37
42
|
* without accumulating document-level listeners.
|
|
43
|
+
*
|
|
44
|
+
* 首访不能只等 `astro:page-load`:Astro 在首次加载时从 `window.load` 派发该事件,而
|
|
45
|
+
* `window.load` 要等完所有图片、字体与第三方 iframe。首访一旦有子资源变慢或卡死
|
|
46
|
+
* (国内访问外部资源时的常态),事件就迟迟不来甚至永不到达,评论与统计整页空白,
|
|
47
|
+
* 此时 `document.readyState` 停在 `interactive`,只判 `complete` 的兜底也救不回来。
|
|
48
|
+
* 因此文档解析完成即接管本页;`astro:page-load` 只负责软导航后的新页面与重复信号。
|
|
38
49
|
*/
|
|
39
50
|
export function registerPageLifecycle(
|
|
40
51
|
key: string,
|
|
@@ -55,7 +66,13 @@ export function registerPageLifecycle(
|
|
|
55
66
|
logger.debug("lifecycle registered");
|
|
56
67
|
|
|
57
68
|
let active = true;
|
|
58
|
-
|
|
69
|
+
// 已安排但尚未执行的早挂载。自增即作废:早挂载一旦被 page-load 或换页接管,
|
|
70
|
+
// 排队中的任务不能再挂到已被替换的 DOM 上。
|
|
71
|
+
let pendingEarlyMount = 0;
|
|
72
|
+
let earlyMountPending = false;
|
|
73
|
+
// 早挂载已经覆盖当前页。此时 window.load 上的 page-load 只是同一页的重复信号,
|
|
74
|
+
// 再挂一次会造出第二份观察器或第二个评论实例。
|
|
75
|
+
let mountedEarly = false;
|
|
59
76
|
const registration: RuntimeRegistration = {
|
|
60
77
|
unregister: () => undefined,
|
|
61
78
|
};
|
|
@@ -73,7 +90,6 @@ export function registerPageLifecycle(
|
|
|
73
90
|
};
|
|
74
91
|
const mountPage = () => {
|
|
75
92
|
if (!active) return;
|
|
76
|
-
pageGeneration++;
|
|
77
93
|
disposePage();
|
|
78
94
|
const started = performance.now();
|
|
79
95
|
logger.debug("mount started");
|
|
@@ -87,11 +103,41 @@ export function registerPageLifecycle(
|
|
|
87
103
|
throw error;
|
|
88
104
|
}
|
|
89
105
|
};
|
|
90
|
-
const handlePageLoad = () =>
|
|
106
|
+
const handlePageLoad = () => {
|
|
107
|
+
if (earlyMountPending) {
|
|
108
|
+
// page-load 属于当前页,由它立即挂载,作废排队中的早挂载。
|
|
109
|
+
pendingEarlyMount++;
|
|
110
|
+
earlyMountPending = false;
|
|
111
|
+
mountedEarly = true;
|
|
112
|
+
mountPage();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (mountedEarly) {
|
|
116
|
+
mountedEarly = false;
|
|
117
|
+
logger.debug("page-load after early mount; keeping the live instance");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
mountPage();
|
|
121
|
+
};
|
|
91
122
|
const handleBeforeSwap = () => {
|
|
92
|
-
|
|
123
|
+
pendingEarlyMount++;
|
|
124
|
+
earlyMountPending = false;
|
|
125
|
+
mountedEarly = false;
|
|
93
126
|
disposePage();
|
|
94
127
|
};
|
|
128
|
+
const mountEarlyPage = () => {
|
|
129
|
+
const token = ++pendingEarlyMount;
|
|
130
|
+
earlyMountPending = true;
|
|
131
|
+
// 微任务:模块脚本执行时 DOM 已完整,但要等模块自身求值结束再调用 mount()
|
|
132
|
+
// (模块顶层的后置绑定可能仍在 TDZ)。
|
|
133
|
+
queueTask(() => {
|
|
134
|
+
if (!active || token !== pendingEarlyMount || !earlyMountPending) return;
|
|
135
|
+
earlyMountPending = false;
|
|
136
|
+
mountedEarly = true;
|
|
137
|
+
mountPage();
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const handleDomReady = () => mountEarlyPage();
|
|
95
141
|
|
|
96
142
|
target.addEventListener("astro:page-load", handlePageLoad);
|
|
97
143
|
target.addEventListener("astro:before-swap", handleBeforeSwap);
|
|
@@ -104,19 +150,14 @@ export function registerPageLifecycle(
|
|
|
104
150
|
} finally {
|
|
105
151
|
target.removeEventListener("astro:page-load", handlePageLoad);
|
|
106
152
|
target.removeEventListener("astro:before-swap", handleBeforeSwap);
|
|
153
|
+
target.removeEventListener("DOMContentLoaded", handleDomReady);
|
|
107
154
|
registry.delete(key);
|
|
108
155
|
}
|
|
109
156
|
};
|
|
110
157
|
registry.set(key, registration);
|
|
111
158
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const scheduledGeneration = pageGeneration;
|
|
115
|
-
queueTask(() => {
|
|
116
|
-
// A real page-load or swap supersedes this late-module fallback.
|
|
117
|
-
if (pageGeneration === scheduledGeneration) mountPage();
|
|
118
|
-
});
|
|
119
|
-
}
|
|
159
|
+
if (isDocumentParsed(target.readyState)) mountEarlyPage();
|
|
160
|
+
else target.addEventListener("DOMContentLoaded", handleDomReady, { once: true });
|
|
120
161
|
|
|
121
162
|
return registration.unregister;
|
|
122
163
|
}
|
|
@@ -11,6 +11,7 @@ interface MarkdownPostData {
|
|
|
11
11
|
draft?: boolean;
|
|
12
12
|
cc?: string;
|
|
13
13
|
cover?: string;
|
|
14
|
+
"og:image"?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Serialize the collection's parsed metadata as YAML AST; preserve the original Markdown body. */
|
|
@@ -26,6 +27,7 @@ export function reconstructMarkdown(data: MarkdownPostData, body = ""): string {
|
|
|
26
27
|
...(data.draft ? { draft: true } : {}),
|
|
27
28
|
...(data.cc && data.cc !== "CC-BY-NC-SA-4.0" ? { cc: data.cc } : {}),
|
|
28
29
|
...(data.cover ? { cover: data.cover } : {}),
|
|
30
|
+
...(data["og:image"] ? { "og:image": data["og:image"] } : {}),
|
|
29
31
|
});
|
|
30
32
|
return `---\n${frontmatter.toString({ lineWidth: 0 })}---\n${body}${body.endsWith("\n") ? "" : "\n"}`;
|
|
31
33
|
}
|
|
@@ -16,6 +16,7 @@ export const ROUTE_DEFINITIONS: readonly RouteDefinition[] = [
|
|
|
16
16
|
{ pattern: "/tags/[tag]", source: "./pages/tags/[tag].astro" },
|
|
17
17
|
{ pattern: "/categories/", source: "./pages/categories/index.astro" },
|
|
18
18
|
{ pattern: "/categories/[category]", source: "./pages/categories/[category].astro" },
|
|
19
|
+
{ pattern: "/og/[image].[format]", source: "./pages/og/[image].[format].ts" },
|
|
19
20
|
{ pattern: "/rss.xml", source: "./pages/rss.xml.ts" },
|
|
20
21
|
{ pattern: "/atom.xml", source: "./pages/atom.xml.ts" },
|
|
21
22
|
{ pattern: "/llms.txt", source: "./pages/llms.txt.ts" },
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AuthorData, SiteData } from "@schemas/config";
|
|
2
2
|
import type { SEOProps } from "astro-seo";
|
|
3
|
-
|
|
4
3
|
import { langToOpenGraphLocale } from "./i18n";
|
|
5
4
|
import { getMarkdownPath, getRouteKind, normalizePublicPath } from "./public-routes";
|
|
5
|
+
import type { SocialImage } from "./social-image";
|
|
6
6
|
|
|
7
7
|
export type TwitterCardType = "summary" | "summary_large_image";
|
|
8
8
|
|
|
@@ -10,6 +10,7 @@ export interface SeoPageInput {
|
|
|
10
10
|
title: string;
|
|
11
11
|
description: string;
|
|
12
12
|
cover?: string;
|
|
13
|
+
socialImage?: SocialImage;
|
|
13
14
|
isArticle?: boolean;
|
|
14
15
|
date?: string;
|
|
15
16
|
updated?: string;
|
|
@@ -41,6 +42,7 @@ export interface SeoDocument {
|
|
|
41
42
|
links: NonNullable<NonNullable<SEOProps["extend"]>["link"]>;
|
|
42
43
|
meta: NonNullable<NonNullable<SEOProps["extend"]>["meta"]>;
|
|
43
44
|
schema: Record<string, unknown>;
|
|
45
|
+
fallbackImage?: string;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function normalizeSiteUrl(value: string): string {
|
|
@@ -70,7 +72,11 @@ export function buildSeoDocument(input: SeoDocumentInput): SeoDocument {
|
|
|
70
72
|
const noindex = Boolean(site.noindex) || notFound || Boolean(page.noindex);
|
|
71
73
|
const nofollow = Boolean(site.nofollow) || notFound;
|
|
72
74
|
const favicon = site.favicon;
|
|
73
|
-
const image = absoluteUrl(
|
|
75
|
+
const image = absoluteUrl(
|
|
76
|
+
page.socialImage?.url ?? page.cover ?? author.avatar,
|
|
77
|
+
siteUrl,
|
|
78
|
+
input.protocol,
|
|
79
|
+
);
|
|
74
80
|
const isArticle = page.isArticle ?? false;
|
|
75
81
|
const isCollection = routeKind.endsWith("-index") || routeKind.endsWith("-detail");
|
|
76
82
|
|
|
@@ -86,7 +92,13 @@ export function buildSeoDocument(input: SeoDocumentInput): SeoDocument {
|
|
|
86
92
|
locale: langToOpenGraphLocale(site.lang),
|
|
87
93
|
siteName,
|
|
88
94
|
},
|
|
89
|
-
image: {
|
|
95
|
+
image: {
|
|
96
|
+
url: image,
|
|
97
|
+
alt: contentTitle,
|
|
98
|
+
...(page.socialImage?.width ? { width: page.socialImage.width } : {}),
|
|
99
|
+
...(page.socialImage?.height ? { height: page.socialImage.height } : {}),
|
|
100
|
+
...(page.socialImage?.type ? { type: page.socialImage.type } : {}),
|
|
101
|
+
},
|
|
90
102
|
};
|
|
91
103
|
if (isArticle) {
|
|
92
104
|
openGraph.article = {
|
|
@@ -99,7 +111,9 @@ export function buildSeoDocument(input: SeoDocumentInput): SeoDocument {
|
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
const twitter = {
|
|
102
|
-
card: (page.
|
|
114
|
+
card: (page.socialImage?.url || page.cover
|
|
115
|
+
? "summary_large_image"
|
|
116
|
+
: "summary") as TwitterCardType,
|
|
103
117
|
title: contentTitle,
|
|
104
118
|
description: page.description,
|
|
105
119
|
image,
|
|
@@ -268,5 +282,8 @@ export function buildSeoDocument(input: SeoDocumentInput): SeoDocument {
|
|
|
268
282
|
links,
|
|
269
283
|
meta,
|
|
270
284
|
schema,
|
|
285
|
+
...(page.socialImage?.fallbackUrl
|
|
286
|
+
? { fallbackImage: absoluteUrl(page.socialImage.fallbackUrl, siteUrl, input.protocol) }
|
|
287
|
+
: {}),
|
|
271
288
|
};
|
|
272
289
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface SocialCardData {
|
|
4
|
+
title: string;
|
|
5
|
+
description: string;
|
|
6
|
+
siteTitle: string;
|
|
7
|
+
author: string;
|
|
8
|
+
host: string;
|
|
9
|
+
lang: string;
|
|
10
|
+
accent: string;
|
|
11
|
+
date: string;
|
|
12
|
+
category: string;
|
|
13
|
+
cover?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SocialImage {
|
|
17
|
+
url: string;
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
type?: string;
|
|
21
|
+
fallbackUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Explicit sharing images accept web URLs and site-root paths, never executable schemes. */
|
|
25
|
+
export function isSocialImageUrl(value: string): boolean {
|
|
26
|
+
if (!value.trim() || /\s/u.test(value)) return false;
|
|
27
|
+
if (value.startsWith("/") && !value.startsWith("//")) return true;
|
|
28
|
+
try {
|
|
29
|
+
return (
|
|
30
|
+
["http:", "https:"].includes(new URL(value, "https://example.invalid").protocol) &&
|
|
31
|
+
/^(?:https?:)?\/\//iu.test(value)
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function socialCardPath(data: SocialCardData, base = "/"): string {
|
|
39
|
+
// Bump the template revision when layout changes. Metadata changes get a fresh URL.
|
|
40
|
+
const hash = createHash("sha256")
|
|
41
|
+
.update(JSON.stringify(["stalux-takumi-v2", data]))
|
|
42
|
+
.digest("hex")
|
|
43
|
+
.slice(0, 20);
|
|
44
|
+
return `${base.replace(/\/$/u, "")}/og/${hash}.webp`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolvePostSocialImage(
|
|
48
|
+
explicit: string | undefined,
|
|
49
|
+
data: SocialCardData,
|
|
50
|
+
base = "/",
|
|
51
|
+
): SocialImage {
|
|
52
|
+
if (explicit?.trim() && isSocialImageUrl(explicit.trim())) return { url: explicit.trim() };
|
|
53
|
+
return {
|
|
54
|
+
url: socialCardPath(data, base),
|
|
55
|
+
width: 1200,
|
|
56
|
+
height: 630,
|
|
57
|
+
type: "image/webp",
|
|
58
|
+
fallbackUrl: socialCardPath(data, base).replace(/\.webp$/u, ".png"),
|
|
59
|
+
};
|
|
60
|
+
}
|