diff --git a/.gitignore b/.gitignore
index b2b378e..12fb8b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,6 @@ yarn.lock
# ide
.idea
*.iml
+
+# OG image fonts (downloaded at build time)
+src/assets/fonts/*.ttf
diff --git a/package.json b/package.json
index 79c0edf..a9fd40c 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"dev": "astro dev",
"start": "astro dev",
"check": "astro check",
- "build": "astro build && pagefind --site dist",
+ "build": "bash scripts/download-fonts.sh && astro build && pagefind --site dist",
"preview": "astro preview",
"astro": "astro",
"type-check": "tsc --noEmit --isolatedDeclarations",
@@ -54,6 +54,7 @@
"remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.0",
+ "satori": "^0.26.0",
"sharp": "^0.34.5",
"stylus": "^0.64.0",
"svelte": "^5.39.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a958251..057523f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -122,6 +122,9 @@ importers:
sanitize-html:
specifier: ^2.17.0
version: 2.17.0
+ satori:
+ specifier: ^0.26.0
+ version: 0.26.0
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -1901,6 +1904,11 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+ '@shuding/opentype.js@1.4.0-beta.0':
+ resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
+ engines: {node: '>= 8.0.0'}
+ hasBin: true
+
'@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -2296,6 +2304,10 @@ packages:
base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
+ base64-js@0.0.8:
+ resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
+ engines: {node: '>= 0.4'}
+
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -2384,6 +2396,9 @@ packages:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
+ camelize@1.0.1:
+ resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
+
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@@ -2536,12 +2551,26 @@ packages:
crossws@0.3.5:
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
+ css-background-parser@0.1.0:
+ resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
+
+ css-box-shadow@1.0.0-3:
+ resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
+
+ css-color-keywords@1.0.0:
+ resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
+ engines: {node: '>=4'}
+
css-declaration-sorter@6.4.1:
resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==}
engines: {node: ^10 || ^12 || >=14}
peerDependencies:
postcss: ^8.0.9
+ css-gradient-parser@0.0.17:
+ resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==}
+ engines: {node: '>=16'}
+
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
@@ -2551,6 +2580,9 @@ packages:
css-selector-parser@3.2.0:
resolution: {integrity: sha512-L1bdkNKUP5WYxiW5dW6vA2hd3sL8BdRNLy2FCX0rLVise4eNw9nBdeBuJHxlELieSE2H1f6bYQFfwVUwWCV9rQ==}
+ css-to-react-native@3.2.0:
+ resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
+
css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
@@ -2765,6 +2797,10 @@ packages:
emmet@2.4.11:
resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==}
+ emoji-regex-xs@2.0.1:
+ resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
+ engines: {node: '>=10.0.0'}
+
emoji-regex@10.5.0:
resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==}
@@ -2835,6 +2871,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
@@ -2918,6 +2957,9 @@ packages:
picomatch:
optional: true
+ fflate@0.7.4:
+ resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
+
figures@1.7.0:
resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==}
engines: {node: '>=0.10.0'}
@@ -3171,6 +3213,10 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+ hex-rgb@4.3.0:
+ resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
+ engines: {node: '>=6'}
+
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -3544,6 +3590,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
+ linebreak@1.1.0:
+ resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -4020,6 +4069,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse-css-color@0.2.1:
+ resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -4731,6 +4783,10 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
+ satori@0.26.0:
+ resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==}
+ engines: {node: '>=16'}
+
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
@@ -4882,6 +4938,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
+ string.prototype.codepointat@0.2.1:
+ resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
+
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -5564,6 +5623,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
+ yoga-layout@3.2.1:
+ resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
@@ -7312,6 +7374,11 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
+ '@shuding/opentype.js@1.4.0-beta.0':
+ dependencies:
+ fflate: 0.7.4
+ string.prototype.codepointat: 0.2.1
+
'@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies:
ejs: 3.1.10
@@ -7915,6 +7982,8 @@ snapshots:
base-64@1.0.0: {}
+ base64-js@0.0.8: {}
+
base64-js@1.5.1: {}
bcp-47-match@2.0.3: {}
@@ -8006,6 +8075,8 @@ snapshots:
camelcase@8.0.0: {}
+ camelize@1.0.1: {}
+
caniuse-api@3.0.0:
dependencies:
browserslist: 4.25.1
@@ -8167,10 +8238,18 @@ snapshots:
dependencies:
uncrypto: 0.1.3
+ css-background-parser@0.1.0: {}
+
+ css-box-shadow@1.0.0-3: {}
+
+ css-color-keywords@1.0.0: {}
+
css-declaration-sorter@6.4.1(postcss@8.5.6):
dependencies:
postcss: 8.5.6
+ css-gradient-parser@0.0.17: {}
+
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
@@ -8189,6 +8268,12 @@ snapshots:
css-selector-parser@3.2.0: {}
+ css-to-react-native@3.2.0:
+ dependencies:
+ camelize: 1.0.1
+ css-color-keywords: 1.0.0
+ postcss-value-parser: 4.2.0
+
css-tree@1.1.3:
dependencies:
mdn-data: 2.0.14
@@ -8413,6 +8498,8 @@ snapshots:
'@emmetio/abbreviation': 2.3.3
'@emmetio/css-abbreviation': 2.1.8
+ emoji-regex-xs@2.0.1: {}
+
emoji-regex@10.5.0: {}
emoji-regex@8.0.0: {}
@@ -8578,6 +8665,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
@@ -8655,6 +8744,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fflate@0.7.4: {}
+
figures@1.7.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -9019,6 +9110,8 @@ snapshots:
property-information: 7.0.0
space-separated-tokens: 2.0.2
+ hex-rgb@4.3.0: {}
+
html-escaper@3.0.3: {}
html-void-elements@3.0.0: {}
@@ -9351,6 +9444,11 @@ snapshots:
lilconfig@3.1.3: {}
+ linebreak@1.1.0:
+ dependencies:
+ base64-js: 0.0.8
+ unicode-trie: 2.0.0
+
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
@@ -10069,6 +10167,11 @@ snapshots:
dependencies:
callsites: 3.1.0
+ parse-css-color@0.2.1:
+ dependencies:
+ color-name: 1.1.4
+ hex-rgb: 4.3.0
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -10913,6 +11016,20 @@ snapshots:
source-map-js: 1.2.1
optional: true
+ satori@0.26.0:
+ dependencies:
+ '@shuding/opentype.js': 1.4.0-beta.0
+ css-background-parser: 0.1.0
+ css-box-shadow: 1.0.0-3
+ css-gradient-parser: 0.0.17
+ css-to-react-native: 3.2.0
+ emoji-regex-xs: 2.0.1
+ escape-html: 1.0.3
+ linebreak: 1.1.0
+ parse-css-color: 0.2.1
+ postcss-value-parser: 4.2.0
+ yoga-layout: 3.2.1
+
sax@1.4.1: {}
scrl@2.0.0: {}
@@ -11108,6 +11225,8 @@ snapshots:
get-east-asian-width: 1.4.0
strip-ansi: 7.1.2
+ string.prototype.codepointat@0.2.1: {}
+
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8
@@ -11847,6 +11966,8 @@ snapshots:
yoctocolors@2.1.2: {}
+ yoga-layout@3.2.1: {}
+
zimmerframe@1.1.4: {}
zod-to-json-schema@3.24.6(zod@3.25.76):
diff --git a/scripts/download-fonts.sh b/scripts/download-fonts.sh
new file mode 100755
index 0000000..bb395d5
--- /dev/null
+++ b/scripts/download-fonts.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Download fonts needed for OG image generation
+set -e
+
+FONT_DIR="src/assets/fonts"
+FONT_FILE="$FONT_DIR/NotoSansSC-Regular.ttf"
+
+if [ -f "$FONT_FILE" ] && [ "$(wc -c < "$FONT_FILE")" -gt 10000 ]; then
+ echo "โ
Font already exists: $FONT_FILE ($(du -h "$FONT_FILE" | cut -f1))"
+ exit 0
+fi
+
+echo "๐ฅ Downloading Noto Sans SC (static, ~2.5MB)..."
+mkdir -p "$FONT_DIR"
+curl -L -o "$FONT_FILE" \
+ "https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@latest/chinese-simplified-400-normal.ttf"
+
+echo "โ
Font downloaded: $(du -h "$FONT_FILE" | cut -f1)"
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index c180522..41e95e6 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -26,9 +26,10 @@ interface Props {
description?: string;
lang?: string;
setOGTypeArticle?: boolean;
+ ogImage?: string;
}
-let { title, banner, description, lang, setOGTypeArticle } = Astro.props;
+let { title, banner, description, lang, setOGTypeArticle, ogImage } = Astro.props;
// apply a class to the body element to decide the height of the banner, only used for initial page load
// Swup can update the body for each page visit, but it's after the page transition, causing a delay for banner height change
@@ -93,11 +94,13 @@ const bannerOffset =
) : (
)}
+ {ogImage && }
+ {ogImage && }
diff --git a/src/layouts/MainGridLayout.astro b/src/layouts/MainGridLayout.astro
index e1f8e5c..1a480a5 100644
--- a/src/layouts/MainGridLayout.astro
+++ b/src/layouts/MainGridLayout.astro
@@ -22,6 +22,7 @@ interface Props {
lang?: string;
setOGTypeArticle?: boolean;
headings?: MarkdownHeading[];
+ ogImage?: string;
}
const {
@@ -31,6 +32,7 @@ const {
lang,
setOGTypeArticle,
headings = [],
+ ogImage,
} = Astro.props;
const hasBannerCredit =
siteConfig.banner.enable && siteConfig.banner.credit.enable;
@@ -41,7 +43,7 @@ const mainPanelTop = siteConfig.banner.enable
: "5.5rem";
---
-
+
diff --git a/src/pages/og/[...slug].png.ts b/src/pages/og/[...slug].png.ts
new file mode 100644
index 0000000..c57c35c
--- /dev/null
+++ b/src/pages/og/[...slug].png.ts
@@ -0,0 +1,246 @@
+import type { APIRoute, GetStaticPaths } from "astro";
+import { getSortedPosts } from "@utils/content-utils";
+import satori from "satori";
+import sharp from "sharp";
+import fs from "node:fs";
+import path from "node:path";
+import { formatDateToYYYYMMDD } from "../../utils/date-utils";
+
+// Load font at module level (cached across calls during build)
+const fontPath = path.resolve("src/assets/fonts/NotoSansSC-Regular.ttf");
+let fontData: ArrayBuffer;
+try {
+ fontData = fs.readFileSync(fontPath).buffer as ArrayBuffer;
+} catch {
+ // Font will be downloaded by build script; fail gracefully if missing
+ fontData = new ArrayBuffer(0);
+}
+
+export const getStaticPaths: GetStaticPaths = async () => {
+ const posts = await getSortedPosts();
+ return posts.map((post) => ({
+ params: { slug: post.slug },
+ props: {
+ title: post.data.title,
+ description: post.data.description || "",
+ date: formatDateToYYYYMMDD(post.data.published),
+ tags: post.data.tags || [],
+ },
+ }));
+};
+
+export const GET: APIRoute = async ({ props }) => {
+ const { title, description, date, tags } = props as {
+ title: string;
+ description: string;
+ date: string;
+ tags: string[];
+ };
+
+ // Truncate title to ~40 chars for display
+ const displayTitle =
+ title.length > 42 ? title.slice(0, 40) + "โฆ" : title;
+ const displayDesc =
+ description.length > 70 ? description.slice(0, 68) + "โฆ" : description;
+ const displayTags = tags.slice(0, 3).join(" ยท ");
+
+ const svg = await satori(
+ {
+ type: "div",
+ props: {
+ style: {
+ width: "100%",
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ padding: "60px 64px",
+ background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 40%, #3b82f6 100%)",
+ color: "#ffffff",
+ fontFamily: "Noto Sans SC",
+ },
+ children: [
+ // Top: logo + branding
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ alignItems: "center",
+ gap: "12px",
+ },
+ children: [
+ {
+ type: "span",
+ props: {
+ style: { fontSize: "36px" },
+ children: "๐",
+ },
+ },
+ {
+ type: "span",
+ props: {
+ style: {
+ fontSize: "22px",
+ color: "rgba(255,255,255,0.7)",
+ letterSpacing: "0.05em",
+ },
+ children: "ๅฐๆฉ็ๆฅ่ฎฐ",
+ },
+ },
+ ],
+ },
+ },
+ // Middle: title + description
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "16px",
+ flex: "1",
+ justifyContent: "center",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ fontSize: "48px",
+ fontWeight: 700,
+ lineHeight: 1.3,
+ letterSpacing: "-0.02em",
+ textShadow: "0 2px 10px rgba(0,0,0,0.3)",
+ },
+ children: displayTitle,
+ },
+ },
+ description
+ ? {
+ type: "div",
+ props: {
+ style: {
+ fontSize: "22px",
+ color: "rgba(255,255,255,0.65)",
+ lineHeight: 1.5,
+ },
+ children: displayDesc,
+ },
+ }
+ : null,
+ ].filter(Boolean),
+ },
+ },
+ // Bottom: date + tags + site
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "flex-end",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "6px",
+ },
+ children: [
+ displayTags
+ ? {
+ type: "div",
+ props: {
+ style: {
+ fontSize: "16px",
+ color: "rgba(255,255,255,0.5)",
+ },
+ children: displayTags,
+ },
+ }
+ : null,
+ {
+ type: "div",
+ props: {
+ style: {
+ fontSize: "18px",
+ color: "rgba(255,255,255,0.6)",
+ },
+ children: date,
+ },
+ },
+ ].filter(Boolean),
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ alignItems: "center",
+ gap: "8px",
+ },
+ children: [
+ {
+ type: "span",
+ props: {
+ style: {
+ fontSize: "24px",
+ },
+ children: "โจ ๐ โ๏ธ",
+ },
+ },
+ {
+ type: "span",
+ props: {
+ style: {
+ fontSize: "16px",
+ color: "rgba(255,255,255,0.4)",
+ },
+ children: "oc-xiaoju.github.io",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ width: 1200,
+ height: 630,
+ fonts: fontData.byteLength > 0
+ ? [
+ {
+ name: "Noto Sans SC",
+ data: fontData,
+ weight: 400 as const,
+ style: "normal" as const,
+ },
+ {
+ name: "Noto Sans SC",
+ data: fontData, // variable font covers all weights
+ weight: 700 as const,
+ style: "normal" as const,
+ },
+ ]
+ : [],
+ }
+ );
+
+ const png = await sharp(Buffer.from(svg)).png().toBuffer();
+
+ return new Response(png, {
+ headers: {
+ "Content-Type": "image/png",
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+};
diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro
index 7fdfcb8..eea30cc 100644
--- a/src/pages/posts/[...slug].astro
+++ b/src/pages/posts/[...slug].astro
@@ -45,7 +45,7 @@ const jsonLd = {
// TODO include cover image here
};
---
-
+