feat: auto-generate OG images for social sharing (WeChat/XHS)

- satori + sharp generates 1200×630 PNG per post at build time
- Blue gradient background matching sky-blue brand theme
- Chinese font: Noto Sans SC (static, downloaded at build time)
- Layout.astro now emits og:image + twitter:image meta tags
- scripts/download-fonts.sh for CI font provisioning

Closes the social sharing card gap — links shared to WeChat/XHS
now display a proper card with title, description, and branding.
This commit is contained in:
小橘 2026-04-06 13:14:15 +00:00
parent 26afab1d42
commit 1e3264c07e
8 changed files with 398 additions and 4 deletions

3
.gitignore vendored
View File

@ -29,3 +29,6 @@ yarn.lock
# ide # ide
.idea .idea
*.iml *.iml
# OG image fonts (downloaded at build time)
src/assets/fonts/*.ttf

View File

@ -6,7 +6,7 @@
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"check": "astro check", "check": "astro check",
"build": "astro build && pagefind --site dist", "build": "bash scripts/download-fonts.sh && astro build && pagefind --site dist",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"type-check": "tsc --noEmit --isolatedDeclarations", "type-check": "tsc --noEmit --isolatedDeclarations",
@ -54,6 +54,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0", "remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"satori": "^0.26.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"stylus": "^0.64.0", "stylus": "^0.64.0",
"svelte": "^5.39.8", "svelte": "^5.39.8",

121
pnpm-lock.yaml generated
View File

@ -122,6 +122,9 @@ importers:
sanitize-html: sanitize-html:
specifier: ^2.17.0 specifier: ^2.17.0
version: 2.17.0 version: 2.17.0
satori:
specifier: ^0.26.0
version: 0.26.0
sharp: sharp:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5 version: 0.34.5
@ -1901,6 +1904,11 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} 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': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@ -2296,6 +2304,10 @@ packages:
base-64@1.0.0: base-64@1.0.0:
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} 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: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -2384,6 +2396,9 @@ packages:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'} engines: {node: '>=16'}
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-api@3.0.0: caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@ -2536,12 +2551,26 @@ packages:
crossws@0.3.5: crossws@0.3.5:
resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} 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: css-declaration-sorter@6.4.1:
resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
peerDependencies: peerDependencies:
postcss: ^8.0.9 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: css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
@ -2551,6 +2580,9 @@ packages:
css-selector-parser@3.2.0: css-selector-parser@3.2.0:
resolution: {integrity: sha512-L1bdkNKUP5WYxiW5dW6vA2hd3sL8BdRNLy2FCX0rLVise4eNw9nBdeBuJHxlELieSE2H1f6bYQFfwVUwWCV9rQ==} resolution: {integrity: sha512-L1bdkNKUP5WYxiW5dW6vA2hd3sL8BdRNLy2FCX0rLVise4eNw9nBdeBuJHxlELieSE2H1f6bYQFfwVUwWCV9rQ==}
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
css-tree@1.1.3: css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -2765,6 +2797,10 @@ packages:
emmet@2.4.11: emmet@2.4.11:
resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} 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: emoji-regex@10.5.0:
resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==}
@ -2835,6 +2871,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5: escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
@ -2918,6 +2957,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
figures@1.7.0: figures@1.7.0:
resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3171,6 +3213,10 @@ packages:
hastscript@9.0.1: hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} 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: html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@ -3544,6 +3590,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@ -4020,6 +4069,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-css-color@0.2.1:
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
parse-entities@4.0.2: parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@ -4731,6 +4783,10 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
satori@0.26.0:
resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==}
engines: {node: '>=16'}
sax@1.4.1: sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
@ -4882,6 +4938,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
string.prototype.codepointat@0.2.1:
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
string.prototype.matchall@4.0.12: string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -5564,6 +5623,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'} engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zimmerframe@1.1.4: zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
@ -7312,6 +7374,11 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@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': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10
@ -7915,6 +7982,8 @@ snapshots:
base-64@1.0.0: {} base-64@1.0.0: {}
base64-js@0.0.8: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
bcp-47-match@2.0.3: {} bcp-47-match@2.0.3: {}
@ -8006,6 +8075,8 @@ snapshots:
camelcase@8.0.0: {} camelcase@8.0.0: {}
camelize@1.0.1: {}
caniuse-api@3.0.0: caniuse-api@3.0.0:
dependencies: dependencies:
browserslist: 4.25.1 browserslist: 4.25.1
@ -8167,10 +8238,18 @@ snapshots:
dependencies: dependencies:
uncrypto: 0.1.3 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): css-declaration-sorter@6.4.1(postcss@8.5.6):
dependencies: dependencies:
postcss: 8.5.6 postcss: 8.5.6
css-gradient-parser@0.0.17: {}
css-select@4.3.0: css-select@4.3.0:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
@ -8189,6 +8268,12 @@ snapshots:
css-selector-parser@3.2.0: {} 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: css-tree@1.1.3:
dependencies: dependencies:
mdn-data: 2.0.14 mdn-data: 2.0.14
@ -8413,6 +8498,8 @@ snapshots:
'@emmetio/abbreviation': 2.3.3 '@emmetio/abbreviation': 2.3.3
'@emmetio/css-abbreviation': 2.1.8 '@emmetio/css-abbreviation': 2.1.8
emoji-regex-xs@2.0.1: {}
emoji-regex@10.5.0: {} emoji-regex@10.5.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@ -8578,6 +8665,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {} escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
@ -8655,6 +8744,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fflate@0.7.4: {}
figures@1.7.0: figures@1.7.0:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5
@ -9019,6 +9110,8 @@ snapshots:
property-information: 7.0.0 property-information: 7.0.0
space-separated-tokens: 2.0.2 space-separated-tokens: 2.0.2
hex-rgb@4.3.0: {}
html-escaper@3.0.3: {} html-escaper@3.0.3: {}
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
@ -9351,6 +9444,11 @@ snapshots:
lilconfig@3.1.3: {} 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: {} lines-and-columns@1.2.4: {}
linkify-it@5.0.0: linkify-it@5.0.0:
@ -10069,6 +10167,11 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 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: parse-entities@4.0.2:
dependencies: dependencies:
'@types/unist': 2.0.11 '@types/unist': 2.0.11
@ -10913,6 +11016,20 @@ snapshots:
source-map-js: 1.2.1 source-map-js: 1.2.1
optional: true 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: {} sax@1.4.1: {}
scrl@2.0.0: {} scrl@2.0.0: {}
@ -11108,6 +11225,8 @@ snapshots:
get-east-asian-width: 1.4.0 get-east-asian-width: 1.4.0
strip-ansi: 7.1.2 strip-ansi: 7.1.2
string.prototype.codepointat@0.2.1: {}
string.prototype.matchall@4.0.12: string.prototype.matchall@4.0.12:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -11847,6 +11966,8 @@ snapshots:
yoctocolors@2.1.2: {} yoctocolors@2.1.2: {}
yoga-layout@3.2.1: {}
zimmerframe@1.1.4: {} zimmerframe@1.1.4: {}
zod-to-json-schema@3.24.6(zod@3.25.76): zod-to-json-schema@3.24.6(zod@3.25.76):

18
scripts/download-fonts.sh Executable file
View File

@ -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)"

View File

@ -26,9 +26,10 @@ interface Props {
description?: string; description?: string;
lang?: string; lang?: string;
setOGTypeArticle?: boolean; 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 // 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 // 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 =
) : ( ) : (
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
)} )}
{ogImage && <meta property="og:image" content={new URL(ogImage, Astro.site || 'https://oc-xiaoju.github.io').href} />}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta property="twitter:url" content={Astro.url}> <meta property="twitter:url" content={Astro.url}>
<meta name="twitter:title" content={pageTitle}> <meta name="twitter:title" content={pageTitle}>
<meta name="twitter:description" content={description || pageTitle}> <meta name="twitter:description" content={description || pageTitle}>
{ogImage && <meta name="twitter:image" content={new URL(ogImage, Astro.site || 'https://oc-xiaoju.github.io').href} />}
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />

View File

@ -22,6 +22,7 @@ interface Props {
lang?: string; lang?: string;
setOGTypeArticle?: boolean; setOGTypeArticle?: boolean;
headings?: MarkdownHeading[]; headings?: MarkdownHeading[];
ogImage?: string;
} }
const { const {
@ -31,6 +32,7 @@ const {
lang, lang,
setOGTypeArticle, setOGTypeArticle,
headings = [], headings = [],
ogImage,
} = Astro.props; } = Astro.props;
const hasBannerCredit = const hasBannerCredit =
siteConfig.banner.enable && siteConfig.banner.credit.enable; siteConfig.banner.enable && siteConfig.banner.credit.enable;
@ -41,7 +43,7 @@ const mainPanelTop = siteConfig.banner.enable
: "5.5rem"; : "5.5rem";
--- ---
<Layout title={title} banner={banner} description={description} lang={lang} setOGTypeArticle={setOGTypeArticle}> <Layout title={title} banner={banner} description={description} lang={lang} setOGTypeArticle={setOGTypeArticle} ogImage={ogImage}>
<!-- Navbar --> <!-- Navbar -->
<slot slot="head" name="head"></slot> <slot slot="head" name="head"></slot>

View File

@ -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",
},
});
};

View File

@ -45,7 +45,7 @@ const jsonLd = {
// TODO include cover image here // TODO include cover image here
}; };
--- ---
<MainGridLayout banner={entry.data.image} title={entry.data.title} description={entry.data.description} lang={entry.data.lang} setOGTypeArticle={true} headings={headings}> <MainGridLayout banner={entry.data.image} title={entry.data.title} description={entry.data.description} lang={entry.data.lang} setOGTypeArticle={true} headings={headings} ogImage={`/og/${entry.slug}.png`}>
<script is:inline slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)}></script> <script is:inline slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)}></script>
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4"> <div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4">
<div id="post-container" class:list={["card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ", <div id="post-container" class:list={["card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",