feat: add SharePanel component for social sharing with QR code support
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m47s
docker-images / build-and-push (push) Failing after 7s
docker-images / submit-indexnow (push) Has been skipped

- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms.
- Integrated QR code generation for WeChat sharing using the `qrcode` library.
- Added localization support for English and Chinese languages.
- Created utility functions in `seo.ts` for building article summaries and FAQs.
- Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries.
- Enhanced SEO capabilities with structured data for articles and pages.
This commit is contained in:
2026-04-02 14:15:21 +08:00
parent a516be2e91
commit 3628a46ed1
53 changed files with 4390 additions and 91 deletions

View File

@@ -58,3 +58,27 @@ admin 侧上传封面时也会额外做:
- 上传前压缩
- 16:9 封面规范化
- 优先转为 `AVIF / WebP`
## GEO / AI 搜索补充
前台现在额外提供:
- `/llms.txt`
- `/llms-full.txt`
- `/indexnow-key.txt`(仅在配置 `INDEXNOW_KEY` 时可用)
如果你想在发布后主动推送 IndexNow可以配置
```env
INDEXNOW_KEY=your-indexnow-key
SITE_URL=https://your-frontend.example.com
PUBLIC_API_BASE_URL=https://your-frontend.example.com/api
```
然后运行:
```powershell
pnpm indexnow:submit
```
脚本会自动收集首页、文章、分类、标签、评测等 canonical URL 并提交到 IndexNow。

View File

@@ -9,7 +9,8 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"indexnow:submit": "node ./scripts/submit-indexnow.mjs"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.0.1",
@@ -21,6 +22,7 @@
"autoprefixer": "^10.4.27",
"lucide-astro": "^0.556.0",
"postcss": "^8.5.8",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"svelte": "^5.55.0",
"tailwindcss": "^3.4.19"

148
frontend/pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
postcss:
specifier: ^8.5.8
version: 8.5.8
qrcode:
specifier: ^1.5.4
version: 1.5.4
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -838,6 +841,10 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001781:
resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
@@ -869,6 +876,9 @@ packages:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -942,6 +952,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -983,6 +997,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@@ -1091,6 +1108,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
flattie@1.1.1:
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
engines: {node: '>=8'}
@@ -1264,6 +1285,10 @@ packages:
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -1499,10 +1524,18 @@ packages:
oniguruma-to-es@4.3.5:
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@7.3.0:
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
engines: {node: '>=20'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-queue@9.1.0:
resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==}
engines: {node: '>=20'}
@@ -1511,6 +1544,10 @@ packages:
resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
engines: {node: '>=20'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -1523,6 +1560,10 @@ packages:
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -1548,6 +1589,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -1605,6 +1650,11 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1681,6 +1731,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -1729,6 +1782,9 @@ packages:
server-destroy@1.0.1:
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -2129,10 +2185,17 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-pm-runs@1.1.0:
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
engines: {node: '>=4'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -2140,6 +2203,9 @@ packages:
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -2158,6 +2224,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -2166,6 +2236,10 @@ packages:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -2971,6 +3045,8 @@ snapshots:
camelcase-css@2.0.1: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001781: {}
ccount@2.0.1: {}
@@ -3003,6 +3079,12 @@ snapshots:
ci-info@4.4.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -3063,6 +3145,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -3091,6 +3175,8 @@ snapshots:
diff@8.0.4: {}
dijkstrajs@1.0.3: {}
dlv@1.1.3: {}
dom-serializer@2.0.0:
@@ -3206,6 +3292,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
flattie@1.1.1: {}
fontace@0.4.1:
@@ -3412,6 +3503,10 @@ snapshots:
locate-character@3.0.0: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
longest-streak@3.1.0: {}
lru-cache@11.2.7: {}
@@ -3818,10 +3913,18 @@ snapshots:
regex: 6.1.0
regex-recursion: 6.0.2
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@7.3.0:
dependencies:
yocto-queue: 1.2.2
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-queue@9.1.0:
dependencies:
eventemitter3: 5.0.4
@@ -3829,6 +3932,8 @@ snapshots:
p-timeout@7.0.1: {}
p-try@2.2.0: {}
package-manager-detector@1.6.0: {}
parse-latin@7.0.0:
@@ -3846,6 +3951,8 @@ snapshots:
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-parse@1.0.7: {}
piccolore@0.1.3: {}
@@ -3860,6 +3967,8 @@ snapshots:
pirates@4.0.7: {}
pngjs@5.0.0: {}
postcss-import@15.1.0(postcss@8.5.8):
dependencies:
postcss: 8.5.8
@@ -3908,6 +4017,12 @@ snapshots:
property-information@7.1.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
radix3@1.1.2: {}
@@ -4010,6 +4125,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -4102,6 +4219,8 @@ snapshots:
server-destroy@1.0.1: {}
set-blocking@2.0.0: {}
setprototypeof@1.2.0: {}
sharp@0.34.5:
@@ -4509,8 +4628,16 @@ snapshots:
web-namespaces@2.0.1: {}
which-module@2.0.1: {}
which-pm-runs@1.1.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -4519,6 +4646,8 @@ snapshots:
xxhash-wasm@1.1.0: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yaml-language-server@1.20.0:
@@ -4539,10 +4668,29 @@ snapshots:
yaml@2.8.3: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1

View File

@@ -0,0 +1,134 @@
const DEFAULT_API_PATH = '/api'
const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow'
function normalizeBase(value) {
return String(value || '').trim().replace(/\/$/, '')
}
function ensureAbsolute(base, path) {
return `${normalizeBase(base)}${String(path).startsWith('/') ? path : `/${path}`}`
}
async function fetchJson(url) {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
}
return response.json()
}
function collectStaticRoutes(siteSettings) {
const routes = [
'/',
'/about',
'/articles',
'/categories',
'/tags',
'/timeline',
'/reviews',
'/friends',
'/rss.xml',
'/sitemap.xml',
'/llms.txt',
'/llms-full.txt',
'/indexnow-key.txt',
]
if (siteSettings?.ai_enabled || siteSettings?.ai?.enabled) {
routes.push('/ask')
}
return routes
}
async function main() {
const indexNowKey = String(process.env.INDEXNOW_KEY || '').trim()
if (!indexNowKey) {
throw new Error('Missing INDEXNOW_KEY environment variable.')
}
const configuredSiteUrl = normalizeBase(process.env.SITE_URL || process.env.PUBLIC_SITE_URL || '')
const configuredApiBaseUrl = normalizeBase(
process.env.INTERNAL_API_BASE_URL || process.env.PUBLIC_API_BASE_URL || '',
)
const bootstrapApiBaseUrl = configuredApiBaseUrl || `${configuredSiteUrl}${DEFAULT_API_PATH}`
if (!bootstrapApiBaseUrl) {
throw new Error('Missing SITE_URL/PUBLIC_SITE_URL or API base URL for IndexNow submission.')
}
const siteSettings = await fetchJson(`${bootstrapApiBaseUrl}/site_settings`).catch(() => null)
const siteUrl = normalizeBase(configuredSiteUrl || siteSettings?.site_url || '')
if (!siteUrl) {
throw new Error('Unable to determine canonical SITE_URL for IndexNow submission.')
}
const apiBaseUrl = configuredApiBaseUrl || `${siteUrl}${DEFAULT_API_PATH}`
const [posts, categories, tags, reviews] = await Promise.all([
fetchJson(`${apiBaseUrl}/posts`).catch(() => []),
fetchJson(`${apiBaseUrl}/categories`).catch(() => []),
fetchJson(`${apiBaseUrl}/tags`).catch(() => []),
fetchJson(`${apiBaseUrl}/reviews`).catch(() => []),
])
const urls = new Set(collectStaticRoutes(siteSettings).map((path) => ensureAbsolute(siteUrl, path)))
for (const post of posts) {
if (!post?.slug || post?.noindex === true) continue
urls.add(ensureAbsolute(siteUrl, `/articles/${encodeURIComponent(post.slug)}`))
}
for (const category of categories) {
const token = category?.slug || category?.name
if (!token) continue
urls.add(ensureAbsolute(siteUrl, `/categories/${encodeURIComponent(token)}`))
}
for (const tag of tags) {
const token = tag?.slug || tag?.name
if (!token) continue
urls.add(ensureAbsolute(siteUrl, `/tags/${encodeURIComponent(token)}`))
}
for (const review of reviews) {
if (!review?.id) continue
urls.add(ensureAbsolute(siteUrl, `/reviews/${review.id}`))
}
const payload = {
host: new URL(siteUrl).host,
key: indexNowKey,
keyLocation: ensureAbsolute(siteUrl, '/indexnow-key.txt'),
urlList: [...urls],
}
const response = await fetch(INDEXNOW_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify(payload),
})
const responseText = await response.text().catch(() => '')
if (!response.ok) {
throw new Error(`IndexNow submission failed: ${response.status} ${response.statusText}\n${responseText}`)
}
console.log(`IndexNow submitted ${payload.urlList.length} URLs for ${siteUrl}`)
if (responseText) {
console.log(responseText)
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})

View File

@@ -0,0 +1,86 @@
---
import { getI18n } from '../../lib/i18n';
type FaqItem = {
question: string;
answer: string;
};
interface Props {
badge?: string;
kicker?: string;
title: string;
summary: string;
highlights?: string[];
faqs?: FaqItem[];
}
const {
badge = 'ai brief',
kicker = 'geo / summary',
title,
summary,
highlights = [],
faqs = [],
} = Astro.props as Props;
const { locale } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
---
<section class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.94),rgba(var(--primary-rgb),0.04))] p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--primary)]">
<i class="fas fa-brain text-[10px]"></i>
{badge}
</span>
<span class="terminal-kicker">
<i class="fas fa-sitemap"></i>
{kicker}
</span>
</div>
<div class="mt-4">
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3>
<p class="mt-3 max-w-4xl text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
</div>
<div class="mt-5 grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
{isEnglish ? 'Key signals' : '关键信号'}
</div>
{highlights.length > 0 ? (
<ul class="mt-3 space-y-3">
{highlights.map((item, index) => (
<li class="flex items-start gap-3 text-sm leading-7 text-[var(--text-secondary)]">
<span class="mt-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-xs font-semibold text-[var(--primary)]">
{index + 1}
</span>
<span>{item}</span>
</li>
))}
</ul>
) : (
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
)}
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
{isEnglish ? 'FAQ' : '常见问答'}
</div>
{faqs.length > 0 ? (
<div class="mt-3 space-y-3">
{faqs.slice(0, 3).map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/65 bg-[var(--bg)]/60 px-4 py-3">
<p class="text-sm font-semibold leading-6 text-[var(--title-color)]">{item.question}</p>
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{item.answer}</p>
</div>
))}
</div>
) : (
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
)}
</div>
</div>
</section>

View File

@@ -0,0 +1,89 @@
---
interface Props {
pageType: string;
entityId?: string;
postSlug?: string;
}
const props = Astro.props;
const pageType = props.pageType;
const entityId = props.entityId ?? '';
const postSlug = props.postSlug ?? '';
---
<script is:inline define:vars={{ pageType, entityId, postSlug }}>
(() => {
const endpoint = '/api/analytics/content';
const storageKey = `termi:pageview:${pageType}:${entityId || postSlug || 'root'}`;
function ensureSessionId() {
try {
const existing = window.sessionStorage.getItem(storageKey);
if (existing) return existing;
const nextId = crypto.randomUUID();
window.sessionStorage.setItem(storageKey, nextId);
return nextId;
} catch {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
}
function getReferrerHost() {
try {
return document.referrer ? new URL(document.referrer).host : '';
} catch {
return '';
}
}
function normalizeSource(value) {
const source = String(value || '').trim().toLowerCase();
if (!source) return 'direct';
if (source.includes('chatgpt') || source.includes('openai')) return 'chatgpt-search';
if (source.includes('perplexity')) return 'perplexity';
if (source.includes('copilot') || source.includes('bing')) return 'copilot-bing';
if (source.includes('gemini')) return 'gemini';
if (source.includes('google')) return 'google';
if (source.includes('claude')) return 'claude';
if (source.includes('duckduckgo')) return 'duckduckgo';
if (source.includes('kagi')) return 'kagi';
return source;
}
function buildMetadata() {
const currentUrl = new URL(window.location.href);
const utmSource = currentUrl.searchParams.get('utm_source')?.trim() || '';
const utmMedium = currentUrl.searchParams.get('utm_medium')?.trim() || '';
const utmCampaign = currentUrl.searchParams.get('utm_campaign')?.trim() || '';
const utmTerm = currentUrl.searchParams.get('utm_term')?.trim() || '';
const utmContent = currentUrl.searchParams.get('utm_content')?.trim() || '';
const referrerHost = getReferrerHost();
return {
pageType,
entityId: entityId || undefined,
referrerHost: referrerHost || undefined,
utmSource: utmSource || undefined,
utmMedium: utmMedium || undefined,
utmCampaign: utmCampaign || undefined,
utmTerm: utmTerm || undefined,
utmContent: utmContent || undefined,
landingSource: normalizeSource(utmSource || referrerHost),
};
}
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
event_type: 'page_view',
path: `${window.location.pathname}${window.location.search}`,
post_slug: postSlug || undefined,
session_id: ensureSessionId(),
referrer: document.referrer || undefined,
metadata: buildMetadata(),
}),
}).catch(() => undefined);
})();
</script>

View File

@@ -0,0 +1,630 @@
---
import QRCode from 'qrcode';
import { getI18n } from '../../lib/i18n';
type ShareStat = {
label: string;
value: string;
};
interface Props {
shareTitle: string;
summary: string;
canonicalUrl: string;
badge?: string;
kicker?: string;
title?: string;
description?: string;
stats?: ShareStat[];
wechatShareQrEnabled?: boolean;
}
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
const {
shareTitle,
summary,
canonicalUrl,
badge = isEnglish ? 'distribution' : '快速分发',
kicker = 'geo / share',
title = isEnglish ? 'Share & AI discovery' : '分享与 AI 分发',
description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '让规范链接持续通过社交渠道回流,方便用户传播,也方便 AI 搜索把信号聚合到同一个来源。',
stats = [],
wechatShareQrEnabled = false,
} = Astro.props as Props;
const copy = isEnglish
? {
summaryTitle: 'Share note',
canonical: 'Canonical',
copySummary: 'Copy note',
copySummarySuccess: 'Share note copied',
copySummaryFailed: 'Copy failed',
copyLink: 'Copy permalink',
copyLinkSuccess: 'Permalink copied',
copyLinkFailed: 'Permalink copy failed',
shareSummary: 'Share summary',
shareSuccess: 'Share panel opened',
shareFallback: 'Share text copied',
shareFailed: 'Share failed',
shareToX: 'Share to X',
shareToTelegram: 'Share to Telegram',
shareToWeChat: 'WeChat QR',
qrModalTitle: 'WeChat scan share',
qrModalDescription: 'Scan this local QR code in WeChat to open the canonical URL on mobile.',
qrModalHint: 'Keep the canonical link as the single source of truth for social sharing and AI discovery.',
downloadQr: 'Download QR',
downloadQrStarted: 'QR download started',
qrOpened: 'WeChat QR ready',
toastSuccessTitle: 'Done',
toastErrorTitle: 'Action failed',
toastInfoTitle: 'Share ready',
}
: {
summaryTitle: '分享摘要',
canonical: '规范地址',
copySummary: '复制摘要',
copySummarySuccess: '分享摘要已复制',
copySummaryFailed: '复制失败',
copyLink: '复制固定链接',
copyLinkSuccess: '固定链接已复制',
copyLinkFailed: '固定链接复制失败',
shareSummary: '分享摘要',
shareSuccess: '已打开分享面板',
shareFallback: '分享文案已复制',
shareFailed: '分享失败',
shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫码',
qrModalTitle: '微信扫码分享',
qrModalDescription: '使用本地生成的二维码,在微信里扫一扫,就能直接打开当前页面的规范链接。',
qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一个页面。',
downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败',
toastInfoTitle: '分享渠道已就绪',
};
const safeSummary = summary.trim() || shareTitle;
const panelIdSeed = `${shareTitle}-${canonicalUrl}`
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const panelId = `share-${panelIdSeed.slice(0, 48) || 'panel'}`;
const shareClipboardText = [shareTitle, safeSummary, `${copy.canonical}: ${canonicalUrl}`]
.filter(Boolean)
.join('\n\n');
const shareSummaryText = [shareTitle, safeSummary, canonicalUrl].filter(Boolean).join('\n\n');
const shareTeaser = [shareTitle, safeSummary].filter(Boolean).join(' — ').slice(0, 220);
const xShareUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(shareTeaser)}&url=${encodeURIComponent(canonicalUrl)}`;
const telegramShareUrl = `https://t.me/share/url?url=${encodeURIComponent(canonicalUrl)}&text=${encodeURIComponent(shareTeaser)}`;
let wechatShareQrSvg = '';
let wechatShareQrPngDataUrl = '';
if (wechatShareQrEnabled) {
try {
wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
type: 'svg',
margin: 1,
width: 220,
color: {
dark: '#111827',
light: '#ffffff',
},
});
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
margin: 1,
width: 360,
color: {
dark: '#111827',
light: '#ffffff',
},
});
} catch (error) {
console.error('Share panel QR generation error:', error);
}
}
---
<section
class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.1),rgba(var(--secondary-rgb),0.04)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6"
data-share-panel-id={panelId}
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
<i class="fas fa-satellite-dish text-[10px]"></i>
{badge}
</span>
<span class="terminal-kicker">
<i class="fas fa-share-nodes"></i>
{kicker}
</span>
</div>
<div class="space-y-2">
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3>
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{description}</p>
</div>
</div>
{stats.length > 0 ? (
<div class="grid gap-3 sm:grid-cols-2 lg:min-w-[16rem]">
{stats.slice(0, 4).map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
</div>
))}
</div>
) : null}
</div>
<div class="mt-5 rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_16px_40px_rgba(15,23,42,0.06)]">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
<p class="mt-3 text-base leading-8 text-[var(--title-color)]">{safeSummary}</p>
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex flex-wrap gap-2">
<button
type="button"
class="terminal-action-button"
data-share-copy-summary
data-default-label={copy.copySummary}
data-success-label={copy.copySummarySuccess}
data-failed-label={copy.copySummaryFailed}
>
<i class="fas fa-copy"></i>
<span>{copy.copySummary}</span>
</button>
<button
type="button"
class="terminal-action-button"
data-share-copy-link
data-default-label={copy.copyLink}
data-success-label={copy.copyLinkSuccess}
data-failed-label={copy.copyLinkFailed}
>
<i class="fas fa-link"></i>
<span>{copy.copyLink}</span>
</button>
<button
type="button"
class="terminal-action-button terminal-action-button-primary"
data-share-summary
data-default-label={copy.shareSummary}
data-success-label={copy.shareSuccess}
data-fallback-label={copy.shareFallback}
data-failed-label={copy.shareFailed}
>
<i class="fas fa-share-nodes"></i>
<span>{copy.shareSummary}</span>
</button>
</div>
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
</div>
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share channels' : '分享渠道'}
</p>
<p class="text-xs leading-6 text-[var(--text-secondary)]">{description}</p>
</div>
<div class="flex flex-wrap gap-2">
<a
href={xShareUrl}
target="_blank"
rel="noopener noreferrer nofollow"
class="terminal-action-button"
data-share-link
>
<i class="fab fa-twitter"></i>
<span>{copy.shareToX}</span>
</a>
<a
href={telegramShareUrl}
target="_blank"
rel="noopener noreferrer nofollow"
class="terminal-action-button"
data-share-link
>
<i class="fab fa-telegram-plane"></i>
<span>{copy.shareToTelegram}</span>
</a>
{wechatShareQrEnabled && wechatShareQrSvg ? (
<button
type="button"
class="terminal-action-button"
data-share-wechat-open
>
<i class="fab fa-weixin"></i>
<span>{copy.shareToWeChat}</span>
</button>
) : null}
</div>
</div>
</div>
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.canonical}</div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
{wechatShareQrEnabled && wechatShareQrSvg ? (
<div
class="fixed inset-0 z-[160] hidden bg-black/70 backdrop-blur-sm"
data-share-wechat-modal
aria-hidden="true"
>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<span class="terminal-kicker">
<i class="fab fa-weixin"></i>
{copy.shareToWeChat}
</span>
<div>
<h3 class="text-2xl font-semibold text-[var(--title-color)]">{copy.qrModalTitle}</h3>
<p class="mt-2 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{copy.qrModalDescription}
</p>
</div>
</div>
<button
type="button"
class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-share-wechat-close
aria-label={t('common.close')}
>
<i class="fas fa-xmark text-base"></i>
</button>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical}
</div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle}
</div>
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="terminal-action-button terminal-action-button-primary"
data-share-copy-link
data-default-label={copy.copyLink}
data-success-label={copy.copyLinkSuccess}
data-failed-label={copy.copyLinkFailed}
>
<i class="fas fa-link"></i>
<span>{copy.copyLink}</span>
</button>
<a
href={wechatShareQrPngDataUrl}
download={`${panelId}-wechat-share-qr.png`}
class="terminal-action-button"
data-share-qr-download
>
<i class="fas fa-download"></i>
<span>{copy.downloadQr}</span>
</a>
<button
type="button"
class="terminal-action-button"
data-share-wechat-close
>
<i class="fas fa-xmark"></i>
<span>{t('common.close')}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
) : null}
<div
class="pointer-events-none fixed bottom-5 right-5 z-[70] w-[min(22rem,calc(100vw-1.5rem))] translate-y-4 opacity-0 transition-all duration-300"
data-share-toast
data-title-success={copy.toastSuccessTitle}
data-title-error={copy.toastErrorTitle}
data-title-info={copy.toastInfoTitle}
aria-live="polite"
aria-atomic="true"
>
<div class="rounded-2xl border border-emerald-500/25 bg-[var(--terminal-bg)]/96 p-4 shadow-[0_18px_50px_rgba(15,23,42,0.18)] backdrop-blur">
<div class="flex items-start gap-3">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500/12 text-emerald-400"
data-share-toast-icon
>
<i class="fas fa-check"></i>
</span>
<div class="min-w-0">
<p class="text-sm font-semibold text-[var(--title-color)]" data-share-toast-title>
{copy.toastSuccessTitle}
</p>
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]" data-share-toast-message></p>
</div>
</div>
</div>
</div>
</section>
<script
is:inline
define:vars={{
panelId,
shareClipboardText,
shareSummaryText,
canonicalUrl,
shareTitle,
qrOpenedLabel: copy.qrOpened,
qrDownloadStartedLabel: copy.downloadQrStarted,
}}
>
(() => {
const root = document.querySelector(`[data-share-panel-id="${panelId}"]`);
if (!root) return;
const copySummaryButton = root.querySelector('[data-share-copy-summary]');
const shareSummaryButton = root.querySelector('[data-share-summary]');
const copyLinkButtons = root.querySelectorAll('[data-share-copy-link]');
const shareLinks = root.querySelectorAll('[data-share-link]');
const wechatOpenButtons = root.querySelectorAll('[data-share-wechat-open]');
const wechatCloseButtons = root.querySelectorAll('[data-share-wechat-close]');
const wechatModal = root.querySelector('[data-share-wechat-modal]');
const qrDownloadButtons = root.querySelectorAll('[data-share-qr-download]');
const status = root.querySelector('[data-share-status]');
const toast = root.querySelector('[data-share-toast]');
const toastIcon = root.querySelector('[data-share-toast-icon]');
const toastTitle = root.querySelector('[data-share-toast-title]');
const toastMessage = root.querySelector('[data-share-toast-message]');
let toastTimer = 0;
function setStatus(message) {
if (!status) return;
status.textContent = message || '';
}
function showToast(message, type = 'success') {
if (!toast || !toastIcon || !toastTitle || !toastMessage) return;
const title =
toast.getAttribute(`data-title-${type}`) ||
toast.getAttribute('data-title-success') ||
'';
toastTitle.textContent = title;
toastMessage.textContent = message || '';
toast.classList.remove('opacity-0', 'translate-y-4');
toastIcon.className = 'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl';
const iconElement = toastIcon.querySelector('i');
if (iconElement) {
iconElement.className =
type === 'error'
? 'fas fa-triangle-exclamation'
: type === 'info'
? 'fas fa-share-nodes'
: 'fas fa-check';
}
const toastCard = toast.firstElementChild;
toastCard?.classList.remove('border-emerald-500/25', 'border-rose-500/25', 'border-sky-500/25');
toastIcon.classList.remove(
'bg-emerald-500/12',
'text-emerald-400',
'bg-rose-500/12',
'text-rose-400',
'bg-sky-500/12',
'text-sky-400',
);
if (type === 'error') {
toastCard?.classList.add('border-rose-500/25');
toastIcon.classList.add('bg-rose-500/12', 'text-rose-400');
} else if (type === 'info') {
toastCard?.classList.add('border-sky-500/25');
toastIcon.classList.add('bg-sky-500/12', 'text-sky-400');
} else {
toastCard?.classList.add('border-emerald-500/25');
toastIcon.classList.add('bg-emerald-500/12', 'text-emerald-400');
}
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-4');
}, 2200);
}
function setButtonState(button, iconClass, label) {
if (!button) return;
button.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
}
function resetButton(button, fallbackIconClass) {
if (!button) return;
const defaultLabel = button.getAttribute('data-default-label') || '';
setButtonState(button, fallbackIconClass, defaultLabel);
}
async function writeClipboard(value) {
await navigator.clipboard.writeText(value);
}
async function handleCopySummary() {
const successLabel = copySummaryButton?.getAttribute('data-success-label') || '';
const failedLabel = copySummaryButton?.getAttribute('data-failed-label') || '';
try {
await writeClipboard(shareClipboardText);
setButtonState(copySummaryButton, 'fas fa-check', successLabel);
setStatus(successLabel);
showToast(successLabel, 'success');
} catch {
setButtonState(copySummaryButton, 'fas fa-triangle-exclamation', failedLabel);
setStatus(failedLabel);
showToast(failedLabel, 'error');
} finally {
window.setTimeout(() => {
resetButton(copySummaryButton, 'fas fa-copy');
}, 1800);
}
}
async function handleCopyLink(button) {
const successLabel = button?.getAttribute('data-success-label') || '';
const failedLabel = button?.getAttribute('data-failed-label') || '';
try {
await writeClipboard(canonicalUrl);
setButtonState(button, 'fas fa-check', successLabel);
setStatus(successLabel);
showToast(successLabel, 'success');
} catch {
setButtonState(button, 'fas fa-triangle-exclamation', failedLabel);
setStatus(failedLabel);
showToast(failedLabel, 'error');
} finally {
window.setTimeout(() => {
resetButton(button, 'fas fa-link');
}, 1800);
}
}
async function handleShareSummary() {
const successLabel = shareSummaryButton?.getAttribute('data-success-label') || '';
const fallbackLabel = shareSummaryButton?.getAttribute('data-fallback-label') || '';
const failedLabel = shareSummaryButton?.getAttribute('data-failed-label') || '';
try {
if (navigator.share) {
await navigator.share({
title: shareTitle,
text: shareSummaryText,
url: canonicalUrl,
});
setButtonState(shareSummaryButton, 'fas fa-check', successLabel);
setStatus(successLabel);
showToast(successLabel, 'success');
} else {
await writeClipboard(shareSummaryText);
setButtonState(shareSummaryButton, 'fas fa-copy', fallbackLabel);
setStatus(fallbackLabel);
showToast(fallbackLabel, 'info');
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
resetButton(shareSummaryButton, 'fas fa-share-nodes');
return;
}
setButtonState(shareSummaryButton, 'fas fa-triangle-exclamation', failedLabel);
setStatus(failedLabel);
showToast(failedLabel, 'error');
} finally {
window.setTimeout(() => {
resetButton(shareSummaryButton, 'fas fa-share-nodes');
}, 1800);
}
}
function openWechatModal() {
if (!wechatModal) return;
wechatModal.classList.remove('hidden');
wechatModal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
setStatus(qrOpenedLabel);
showToast(qrOpenedLabel, 'info');
}
function closeWechatModal() {
if (!wechatModal) return;
wechatModal.classList.add('hidden');
wechatModal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
copySummaryButton?.addEventListener('click', async () => {
await handleCopySummary();
});
shareSummaryButton?.addEventListener('click', async () => {
await handleShareSummary();
});
copyLinkButtons.forEach((button) => {
button.addEventListener('click', async () => {
await handleCopyLink(button);
});
});
shareLinks.forEach((link) => {
link.addEventListener('click', () => {
const label = link.textContent?.trim() || '';
if (!label) return;
setStatus(label);
showToast(label, 'info');
});
});
wechatOpenButtons.forEach((button) => {
button.addEventListener('click', () => {
openWechatModal();
});
});
wechatCloseButtons.forEach((button) => {
button.addEventListener('click', () => {
closeWechatModal();
});
});
qrDownloadButtons.forEach((button) => {
button.addEventListener('click', () => {
setStatus(qrDownloadStartedLabel);
showToast(qrDownloadStartedLabel, 'info');
});
});
wechatModal?.addEventListener('click', (event) => {
if (event.target === wechatModal) {
closeWechatModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && wechatModal && !wechatModal.classList.contains('hidden')) {
closeWechatModal();
}
});
})();
</script>

View File

@@ -65,6 +65,19 @@ const defaultJsonLdObjects = [
'query-input': 'required name=search_term_string',
},
},
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: siteSettings.siteName,
alternateName: siteSettings.siteShortName,
url: siteUrl,
description,
logo: siteSettings.ownerAvatarUrl || ogImage,
sameAs: [
siteSettings.social.github,
siteSettings.social.twitter,
].filter(Boolean),
},
{
'@context': 'https://schema.org',
'@type': 'Person',
@@ -97,8 +110,10 @@ const i18nPayload = JSON.stringify({ locale, messages });
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="author" content={siteSettings.ownerName} />
<meta name="robots" content={props.noindex ? 'noindex, nofollow' : 'index, follow'} />
<link rel="canonical" href={canonical} />
<meta property="og:locale" content={locale} />
<meta property="og:site_name" content={siteSettings.siteName} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
@@ -115,9 +130,28 @@ const i18nPayload = JSON.stringify({ locale, messages });
)}
{ogImage && <meta property="og:image" content={ogImage} />}
{ogImage && <meta name="twitter:image" content={ogImage} />}
<link
rel="alternate"
type="application/rss+xml"
title={`${siteSettings.siteName} RSS`}
href={`${siteUrl}/rss.xml`}
/>
<link
rel="alternate"
type="text/plain"
title={`${siteSettings.siteName} llms.txt`}
href={`${siteUrl}/llms.txt`}
/>
<link
rel="alternate"
type="text/plain"
title={`${siteSettings.siteName} llms-full.txt`}
href={`${siteUrl}/llms-full.txt`}
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
{jsonLd && <script type="application/ld+json" set:html={jsonLd}></script>}
<slot name="head" />
<style is:inline>
:root {

View File

@@ -294,6 +294,7 @@ export interface ApiSiteSettings {
subscription_popup_delay_seconds: number | null;
seo_default_og_image: string | null;
seo_default_twitter_handle: string | null;
seo_wechat_share_qr_enabled: boolean;
}
export interface ContentAnalyticsInput {
@@ -491,6 +492,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
seo: {
defaultOgImage: undefined,
defaultTwitterHandle: undefined,
wechatShareQrEnabled: false,
},
};
@@ -509,6 +511,8 @@ const normalizePost = (post: ApiPost): UiPost => ({
description: post.description,
content: post.content,
date: formatPostDate(post.created_at),
createdAt: post.created_at,
updatedAt: post.updated_at,
readTime: estimateReadTime(post.content || post.description),
type: post.post_type,
tags: post.tags ?? [],
@@ -662,6 +666,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
},
};
};

276
frontend/src/lib/seo.ts Normal file
View File

@@ -0,0 +1,276 @@
import type { Post, SiteSettings } from './types';
import { buildCategoryUrl, buildTagUrl } from './utils';
export interface ArticleFaqItem {
question: string;
answer: string;
}
export interface DiscoveryFaqOptions {
locale: string;
pageTitle: string;
summary: string;
primaryUrl: string;
primaryLabel: string;
relatedLinks?: Array<{
label: string;
url: string;
}>;
signals?: string[];
}
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function stripMarkdown(value: string): string {
return normalizeWhitespace(
value
.replace(/^---[\s\S]*?---/, ' ')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, ' ')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/[>*_~|]/g, ' ')
);
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}`;
}
function uniqueNonEmpty(values: string[]): string[] {
const seen = new Set<string>();
return values.filter((value) => {
const normalized = normalizeWhitespace(value).toLowerCase();
if (!normalized || seen.has(normalized)) {
return false;
}
seen.add(normalized);
return true;
});
}
export function splitMarkdownParagraphs(value: string): string[] {
return value
.replace(/\r\n/g, '\n')
.split(/\n{2,}/)
.map((item) => stripMarkdown(item))
.map((item) => item.replace(/^#\s+/, '').trim())
.filter(Boolean);
}
function splitSentences(value: string): string[] {
return value
.split(/(?<=[。!?!?;])\s*|(?<=\.)\s+/)
.map((item) => normalizeWhitespace(item))
.filter((item) => item.length >= 8);
}
export function buildArticleSynopsis(
post: Pick<Post, 'title' | 'description' | 'content'>,
maxLength = 220,
): string {
const contentParagraphs = splitMarkdownParagraphs(post.content || '').filter(
(item) => normalizeWhitespace(item) !== normalizeWhitespace(post.title),
);
const parts = uniqueNonEmpty([post.description, ...contentParagraphs].filter(Boolean));
return truncate(parts.join(' '), maxLength);
}
export function buildArticleHighlights(
post: Pick<Post, 'title' | 'description' | 'content'>,
limit = 3,
): string[] {
const paragraphs = splitMarkdownParagraphs(post.content || '').filter(
(item) => normalizeWhitespace(item) !== normalizeWhitespace(post.title),
);
const sentences = uniqueNonEmpty(
[post.description, ...paragraphs]
.filter(Boolean)
.flatMap((item) => splitSentences(item || '')),
);
return sentences.slice(0, limit).map((item) => truncate(item, 88));
}
export function resolvePostUpdatedAt(post: Pick<Post, 'updatedAt' | 'publishAt' | 'createdAt' | 'date'>): string {
return post.updatedAt || post.publishAt || post.createdAt || post.date;
}
export function buildArticleFaqs(
post: Pick<Post, 'title' | 'category' | 'tags'>,
options: {
locale: string;
summary: string;
readTimeMinutes: number;
},
): ArticleFaqItem[] {
const isEnglish = options.locale.startsWith('en');
const tags = post.tags.slice(0, 5);
const keywordList = tags.length ? tags.join(isEnglish ? ', ' : '、') : post.category;
const categoryUrl = buildCategoryUrl(post.category);
const tagUrls = tags.slice(0, 2).map((tag) => buildTagUrl(tag));
const items = isEnglish
? [
{
question: `What is "${post.title}" mainly about?`,
answer: options.summary,
},
{
question: 'What keywords or topics appear in this page?',
answer: `This page belongs to ${post.category} and highlights ${keywordList}. Estimated reading time is about ${Math.max(
options.readTimeMinutes,
1,
)} minute(s).`,
},
{
question: 'Where should I continue if I want related content?',
answer: `Start with the category page ${categoryUrl} and then continue with related tags ${tagUrls.join(
', ',
) || buildTagUrl('')}.`,
},
]
: [
{
question: `${post.title}》主要讲什么?`,
answer: options.summary,
},
{
question: '这页内容涉及哪些关键词?',
answer: `这篇内容归档在「${post.category}」,重点关键词包括 ${keywordList},预计阅读时间约 ${Math.max(
options.readTimeMinutes,
1,
)} 分钟。`,
},
{
question: '如果想继续阅读相关内容,应该从哪里开始?',
answer: `建议先看分类页 ${categoryUrl},再继续浏览相关标签 ${tagUrls.join('、') || buildTagUrl('')}`,
},
];
return items.map((item) => ({
question: truncate(item.question, 120),
answer: truncate(item.answer, 320),
}));
}
export function buildDiscoveryHighlights(values: string[], limit = 3, maxLength = 96): string[] {
return uniqueNonEmpty(values.map((item) => normalizeWhitespace(item)).filter(Boolean))
.slice(0, limit)
.map((item) => truncate(item, maxLength));
}
export function buildPageFaqs(options: DiscoveryFaqOptions): ArticleFaqItem[] {
const isEnglish = options.locale.startsWith('en');
const relatedLinks = (options.relatedLinks || []).slice(0, 3);
const relatedText = relatedLinks
.map((item) => `${item.label}: ${item.url}`)
.join(isEnglish ? '; ' : '');
const signalText = uniqueNonEmpty(options.signals || []).join(isEnglish ? ', ' : '、');
const items = isEnglish
? [
{
question: `What is the main purpose of "${options.pageTitle}"?`,
answer: options.summary,
},
{
question: 'Which URL should be cited as the canonical source?',
answer: `Use ${options.primaryLabel} as the canonical page: ${options.primaryUrl}.`,
},
{
question: 'Where should I continue for related information?',
answer: relatedText || signalText
? `Continue with ${relatedText || signalText}.`
: `Continue from the canonical page ${options.primaryUrl}.`,
},
]
: [
{
question: `${options.pageTitle}」这一页的核心作用是什么?`,
answer: options.summary,
},
{
question: '这一页应该引用哪个规范地址?',
answer: `优先引用 ${options.primaryLabel} 的规范地址:${options.primaryUrl}`,
},
{
question: '如果要继续浏览相关内容,应该看哪里?',
answer: relatedText || signalText
? `建议继续查看:${relatedText || signalText}`
: `建议从这个规范页继续展开:${options.primaryUrl}`,
},
];
return items.map((item) => ({
question: truncate(item.question, 120),
answer: truncate(item.answer, 320),
}));
}
export function buildFaqJsonLd(faqs: ArticleFaqItem[]) {
if (!faqs.length) {
return undefined;
}
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
};
}
export function buildPostItemList(posts: Post[], siteUrl: string) {
return posts.map((post, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/articles/${post.slug}`, siteUrl).toString(),
name: post.title,
description: post.description,
}));
}
export function buildPageTrackerLabel(referrer: string | null | undefined): string {
const source = normalizeWhitespace(referrer || '').toLowerCase();
if (!source) {
return 'direct';
}
if (source.includes('chatgpt') || source.includes('openai')) return 'chatgpt-search';
if (source.includes('perplexity')) return 'perplexity';
if (source.includes('copilot') || source.includes('bing')) return 'copilot-bing';
if (source.includes('gemini')) return 'gemini';
if (source.includes('google')) return 'google';
if (source.includes('claude')) return 'claude';
if (source.includes('duckduckgo')) return 'duckduckgo';
if (source.includes('kagi')) return 'kagi';
return source;
}
export function buildSiteTopicSummary(siteSettings: SiteSettings): string[] {
return uniqueNonEmpty([
siteSettings.siteDescription,
siteSettings.heroSubtitle,
siteSettings.ownerBio,
...siteSettings.techStack.slice(0, 6).map((item) => `${siteSettings.siteName} covers ${item}`),
]).slice(0, 4);
}

View File

@@ -5,6 +5,8 @@ export interface Post {
description: string;
content?: string;
date: string;
createdAt?: string;
updatedAt?: string;
readTime: string;
type: 'article' | 'tweet';
tags: string[];
@@ -105,6 +107,7 @@ export interface SiteSettings {
seo: {
defaultOgImage?: string;
defaultTwitterHandle?: string;
wechatShareQrEnabled: boolean;
};
}

View File

@@ -1,18 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import StatsList from '../../components/StatsList.astro';
import TechStackList from '../../components/TechStackList.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
let systemStats = [];
let techStack = [];
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
try {
const [settings, posts, tags, friendLinks] = await Promise.all([
@@ -42,13 +47,95 @@ try {
}
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const aboutCanonicalUrl = new URL('/about', siteBaseUrl).toString();
const sharePanelCopy = isEnglish
? {
badge: 'profile source',
title: 'Share the profile page',
description:
'Use this page as the canonical identity and capability profile so social sharing and AI search can cite one stable source.',
}
: {
badge: '身份主页',
title: '分享这张身份名片页',
description: '把这页当成统一的身份与能力来源分发出去,方便社交回流,也方便 AI 搜索引用到同一个规范地址。',
};
const aboutHighlights = buildDiscoveryHighlights([
siteSettings.ownerTitle,
siteSettings.ownerBio,
siteSettings.location || '',
siteSettings.techStack.slice(0, 4).join(' / '),
]);
const aboutFaqs = buildPageFaqs({
locale,
pageTitle: t('about.pageTitle'),
summary: siteSettings.ownerBio || siteSettings.siteDescription,
primaryLabel: t('about.pageTitle'),
primaryUrl: aboutCanonicalUrl,
relatedLinks: [
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
{ label: t('nav.timeline'), url: `${siteBaseUrl}/timeline` },
{ label: t('nav.ask'), url: `${siteBaseUrl}/ask` },
],
signals: aboutHighlights,
});
const aboutFaqJsonLd = buildFaqJsonLd(aboutFaqs);
const aboutJsonLd = [
{
'@context': 'https://schema.org',
'@type': 'AboutPage',
name: `${siteSettings.ownerName} / ${siteSettings.siteName}`,
description: siteSettings.siteDescription,
url: aboutCanonicalUrl,
inLanguage: locale,
},
{
'@context': 'https://schema.org',
'@type': 'ProfilePage',
name: siteSettings.ownerName,
url: aboutCanonicalUrl,
mainEntity: {
'@type': 'Person',
name: siteSettings.ownerName,
jobTitle: siteSettings.ownerTitle,
description: siteSettings.ownerBio,
image: siteSettings.ownerAvatarUrl || undefined,
sameAs: [
siteSettings.social.github,
siteSettings.social.twitter,
].filter(Boolean),
},
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('about.pageTitle'),
item: aboutCanonicalUrl,
},
],
},
aboutFaqJsonLd,
];
---
<BaseLayout
title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`}
description={siteSettings.siteDescription}
siteSettings={siteSettings}
jsonLd={aboutJsonLd.filter(Boolean)}
>
<PageViewTracker pageType="about" entityId="about" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/about" class="w-full">
<div class="mb-6 px-4">
@@ -77,6 +164,31 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
</span>
</div>
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={`${siteSettings.ownerName} / ${t('about.pageTitle')}`}
summary={siteSettings.ownerBio || siteSettings.siteDescription}
canonicalUrl={aboutCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / profile"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={systemStats.slice(0, 4)}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="ml-4 mt-4">
<DiscoveryBrief
badge={isEnglish ? 'profile brief' : '身份摘要'}
kicker="geo / profile"
title={isEnglish ? 'AI-readable profile brief' : '给 AI 看的身份摘要'}
summary={siteSettings.ownerBio || siteSettings.siteDescription}
highlights={aboutHighlights}
faqs={aboutFaqs}
/>
</div>
</div>
<div class="px-4">

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../../lib/seo';
import type { Category, Post, Tag } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../../lib/utils';
@@ -18,11 +22,13 @@ const selectedTag = url.searchParams.get('tag') || '';
const selectedCategory = url.searchParams.get('category') || '';
const requestedPage = Number.parseInt(url.searchParams.get('page') || '1', 10);
const postsPerPage = 10;
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
let paginatedPosts: Post[] = [];
let allTags: Tag[] = [];
let allCategories: Category[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
let totalPosts = 0;
let totalPages = 1;
let currentPage = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
@@ -63,6 +69,12 @@ try {
seenTagIds.add(key);
return true;
});
try {
siteSettings = await api.getSiteSettings();
} catch (settingsError) {
console.error('Failed to fetch site settings for articles index:', settingsError);
}
} catch (error) {
console.error('API Error:', error);
}
@@ -91,6 +103,64 @@ const tagPromptCommand = selectedTag
const hasActiveFilters =
Boolean(selectedSearch || selectedTag || selectedCategory || selectedType !== 'all' || currentPage > 1);
const canonicalUrl = hasActiveFilters ? '/articles' : undefined;
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const absoluteCanonicalUrl = new URL('/articles', siteBaseUrl).toString();
const sharePanelCopy = isEnglish
? {
badge: 'content archive',
title: 'Share the article archive',
description:
'Use the articles index as the canonical discovery shelf for AI retrieval and readers, then branch into type, category, and tag filters from one source.',
posts: 'Posts',
categories: 'Categories',
tags: 'Tags',
page: 'Page',
}
: {
badge: '内容归档',
title: '分享文章总归档页',
description: '把文章归档页当成统一入口分发出去,方便 AI 检索和读者从一个规范地址继续按类型、分类和标签深入浏览。',
posts: '文章数',
categories: '分类数',
tags: '标签数',
page: '页码',
};
const articleIndexHighlights = buildDiscoveryHighlights([
t('articlesPage.description'),
`${sharePanelCopy.posts}: ${totalPosts}`,
`${sharePanelCopy.categories}: ${allCategories.length}`,
`${sharePanelCopy.tags}: ${allTags.length}`,
selectedType !== 'all' ? `type=${selectedType}` : '',
]);
const articleIndexFaqs = buildPageFaqs({
locale,
pageTitle: t('articlesPage.title'),
summary: t('articlesPage.description'),
primaryLabel: t('articlesPage.title'),
primaryUrl: absoluteCanonicalUrl,
relatedLinks: [
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
{ label: t('nav.tags'), url: `${siteBaseUrl}/tags` },
{ label: t('nav.timeline'), url: `${siteBaseUrl}/timeline` },
],
signals: articleIndexHighlights,
});
const articleIndexFaqJsonLd = buildFaqJsonLd(articleIndexFaqs);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('articlesPage.title'),
description: t('articlesPage.description'),
url: new URL('/articles', siteBaseUrl).toString(),
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${t('articlesPage.title')} page ${currentPage}`,
itemListElement: buildPostItemList(paginatedPosts, siteBaseUrl),
},
];
const buildArticlesUrl = ({
type = selectedType,
@@ -120,9 +190,12 @@ const buildArticlesUrl = ({
<BaseLayout
title={`${t('articlesPage.title')} - Termi`}
siteSettings={siteSettings}
canonical={canonicalUrl}
noindex={hasActiveFilters}
jsonLd={[...jsonLd, articleIndexFaqJsonLd].filter(Boolean)}
>
<PageViewTracker pageType="articles" entityId={`articles-page-${currentPage}`} />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/articles/index" class="w-full">
<div class="px-4 pb-2">
@@ -163,6 +236,34 @@ const buildArticlesUrl = ({
</span>
)}
</div>
<SharePanel
shareTitle={`${t('articlesPage.title')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('articlesPage.description')}
canonicalUrl={absoluteCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / archive"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.posts, value: String(totalPosts) },
{ label: sharePanelCopy.categories, value: String(allCategories.length) },
{ label: sharePanelCopy.tags, value: String(allTags.length) },
{ label: sharePanelCopy.page, value: `${currentPage}/${Math.max(totalPages, 1)}` },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
<div class="mt-4">
<DiscoveryBrief
badge={isEnglish ? 'archive brief' : '归档摘要'}
kicker="geo / archive"
title={isEnglish ? 'AI-readable archive brief' : '给 AI 看的归档摘要'}
summary={t('articlesPage.description')}
highlights={articleIndexHighlights}
faqs={articleIndexFaqs}
/>
</div>
</div>
</div>

View File

@@ -1,13 +1,18 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
try {
@@ -28,13 +33,80 @@ const sampleQuestions = [
? 'What is the site owner\'s tech stack and personal profile?'
: '站长的技术栈和个人介绍是什么?'
];
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const askCanonicalUrl = new URL('/ask', siteBaseUrl).toString();
const askJsonLd = [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
name: t('ask.title'),
description: t('ask.pageDescription', { siteName: siteSettings.siteName }),
url: askCanonicalUrl,
inLanguage: locale,
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('ask.title'),
item: askCanonicalUrl,
},
],
},
];
const sharePanelCopy = isEnglish
? {
badge: 'ai search',
title: 'Share the AI ask page',
description:
'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
examples: 'Prompts',
ai: 'AI',
}
: {
badge: 'AI 检索',
title: '分享站内 AI 问答页',
description: '把这个 AI 问答入口作为基于问题的规范发现页分发出去,方便用户与 AI 都围绕站内稳定来源继续检索。',
examples: '示例问题',
ai: 'AI',
};
const askHighlights = buildDiscoveryHighlights([
t('ask.subtitle'),
aiEnabled ? t('common.featureOn') : t('common.featureOff'),
...sampleQuestions,
]);
const askFaqs = buildPageFaqs({
locale,
pageTitle: t('ask.pageTitle'),
summary: t('ask.pageDescription', { siteName: siteSettings.siteName }),
primaryLabel: t('ask.title'),
primaryUrl: askCanonicalUrl,
relatedLinks: [
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
],
signals: askHighlights,
});
const askFaqJsonLd = buildFaqJsonLd(askFaqs);
---
<BaseLayout
title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`}
description={t('ask.pageDescription', { siteName: siteSettings.siteName })}
siteSettings={siteSettings}
jsonLd={[...askJsonLd, askFaqJsonLd].filter(Boolean)}
>
<PageViewTracker pageType="ask" entityId="ask" />
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
@@ -53,6 +125,34 @@ const sampleQuestions = [
</div>
</div>
<div class="px-5 pt-6">
<SharePanel
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
canonicalUrl={askCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / ai"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
{ label: sharePanelCopy.ai, value: aiEnabled ? t('common.featureOn') : t('common.featureOff') },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="px-5 pt-6">
<DiscoveryBrief
badge={isEnglish ? 'ask brief' : '问答摘要'}
kicker="geo / ai"
title={isEnglish ? 'AI-readable ask-page brief' : '给 AI 看的问答页摘要'}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
highlights={askHighlights}
faqs={askFaqs}
/>
</div>
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
<div class="min-w-0">
{aiEnabled ? (

View File

@@ -1,23 +1,50 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import PostCard from '../../components/PostCard.astro';
import { api } from '../../lib/api/client';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildPostItemList } from '../../lib/seo';
import type { Category, Post } from '../../lib/types';
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
export const prerender = false;
const { slug } = Astro.params;
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
let categories: Category[] = [];
let posts: Post[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
let categoriesFailed = false;
try {
[categories, posts] = await Promise.all([api.getCategories(), api.getPosts()]);
const [categoriesResult, postsResult, settingsResult] = await Promise.allSettled([
api.getCategories(),
api.getPosts(),
api.getSiteSettings(),
]);
if (categoriesResult.status === 'fulfilled') {
categories = categoriesResult.value;
} else {
categoriesFailed = true;
console.error('Failed to fetch categories:', categoriesResult.reason);
}
if (postsResult.status === 'fulfilled') {
posts = postsResult.value;
} else {
console.error('Failed to fetch category posts:', postsResult.reason);
}
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch category detail data:', error);
}
@@ -31,7 +58,10 @@ const category =
}) || null;
if (!category) {
return new Response(null, { status: 404 });
return new Response(null, {
status: categoriesFailed ? 503 : 404,
headers: categoriesFailed ? { 'Retry-After': '120' } : undefined,
});
}
const canonicalUrl = buildCategoryUrl(category);
@@ -46,8 +76,24 @@ const categoryTheme = getCategoryTheme(category.name);
const pageTitle = category.seoTitle || `${category.name} - ${t('categories.title')}`;
const pageDescription =
category.seoDescription || category.description || t('categories.categoryPosts', { name: category.name });
const siteBaseUrl = new URL(Astro.request.url).origin;
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
const sharePanelCopy = isEnglish
? {
badge: 'category hub',
title: 'Share this category hub',
description:
'Turn this taxonomy page into a reusable discovery entry so people and AI search engines converge on the same topic cluster.',
posts: 'Posts',
slug: 'Slug',
}
: {
badge: '分类聚合',
title: '分享这个分类聚合页',
description: '把这个分类页当成主题入口持续分发,方便用户快速理解,也方便 AI 搜索把同主题信号聚合回这里。',
posts: '文章数',
slug: 'Slug',
};
const jsonLd = [
{
'@context': 'https://schema.org',
@@ -62,6 +108,12 @@ const jsonLd = [
},
keywords: [category.name, category.slug].filter(Boolean),
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${category.name} posts`,
itemListElement: buildPostItemList(filteredPosts, siteBaseUrl),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
@@ -97,6 +149,7 @@ const jsonLd = [
jsonLd={jsonLd}
twitterCard={category.coverImage ? 'summary_large_image' : 'summary'}
>
<PageViewTracker pageType="category" entityId={category.slug || category.name} />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={`~/categories/${category.slug || category.name}`} class="w-full">
<div class="px-4 pb-2">
@@ -141,6 +194,21 @@ const jsonLd = [
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
</div>
<SharePanel
shareTitle={pageTitle}
summary={pageDescription}
canonicalUrl={absoluteCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / taxonomy"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.posts, value: String(filteredPosts.length) },
{ label: sharePanelCopy.slug, value: category.slug || category.name },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
{category.coverImage ? (
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
<img

View File

@@ -1,26 +1,84 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import { api } from '../../lib/api/client';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Category } from '../../lib/types';
import { buildCategoryUrl, getAccentVars, getCategoryTheme } from '../../lib/utils';
export const prerender = false;
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
let categories: Category[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
try {
categories = await api.getCategories();
const [categoriesResult, settingsResult] = await Promise.allSettled([
api.getCategories(),
api.getSiteSettings(),
]);
if (categoriesResult.status === 'fulfilled') {
categories = categoriesResult.value;
} else {
console.error('Failed to fetch categories:', categoriesResult.reason);
}
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch categories:', error);
}
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const canonicalUrl = new URL('/categories', siteBaseUrl).toString();
const sharePanelCopy = isEnglish
? {
badge: 'taxonomy index',
title: 'Share the category index',
description:
'Use the category directory as a high-level topic map so AI search and human readers can branch into the right content hubs from one canonical page.',
categories: 'Categories',
site: 'Site',
}
: {
badge: '分类目录',
title: '分享分类总览页',
description: '把分类索引页作为全站主题地图分发出去,方便读者和 AI 搜索从一个规范入口继续下钻到对应专题。',
categories: '分类数',
site: '站点',
};
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('categories.title'),
description: t('categories.intro'),
url: canonicalUrl,
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${t('categories.title')} list`,
itemListElement: categories.map((category, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(buildCategoryUrl(category), siteBaseUrl).toString(),
name: category.name,
description: category.description || t('categories.categoryPosts', { name: category.name }),
})),
},
];
---
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
<BaseLayout title={`${t('categories.pageTitle')} - Termi`} jsonLd={jsonLd}>
<PageViewTracker pageType="categories" entityId="categories-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/categories" class="w-full">
<div class="mb-6 px-4">
@@ -49,6 +107,23 @@ try {
</span>
</div>
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={`${t('categories.pageTitle')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('categories.intro')}
canonicalUrl={canonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / taxonomy"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.categories, value: String(categories.length) },
{ label: sharePanelCopy.site, value: siteSettings.siteShortName || siteSettings.siteName },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
</div>
<div class="px-4">

View File

@@ -1,11 +1,15 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FriendLinkCard from '../../components/FriendLinkCard.astro';
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
import type { AppFriendLink } from '../../lib/api/client';
export const prerender = false;
@@ -13,7 +17,8 @@ export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
let friendLinks: AppFriendLink[] = [];
let error: string | null = null;
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
try {
[siteSettings, friendLinks] = await Promise.all([
@@ -31,9 +36,93 @@ const groupedLinks = categories.map(category => ({
category,
links: friendLinks.filter(friend => (friend.category || t('common.other')) === category)
}));
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const friendsCanonicalUrl = new URL('/friends', siteBaseUrl).toString();
const friendsJsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('friends.title'),
description: t('friends.pageDescription', { siteName: siteSettings.siteName }),
url: friendsCanonicalUrl,
inLanguage: locale,
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${t('friends.title')} list`,
itemListElement: friendLinks.map((friend, index) => ({
'@type': 'ListItem',
position: index + 1,
url: friend.url,
name: friend.name,
description: friend.description || friend.category || t('common.other'),
})),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('friends.title'),
item: friendsCanonicalUrl,
},
],
},
];
const sharePanelCopy = isEnglish
? {
badge: 'network map',
title: 'Share the friends directory',
description:
'Use the friend links page as a canonical network map so AI search and readers can understand the sites trusted neighbors and outbound references.',
links: 'Links',
groups: 'Groups',
}
: {
badge: '友链网络',
title: '分享友情链接页',
description: '把友情链接页当成站点网络地图分发出去,方便 AI 搜索和读者理解这个站点的可信邻居与外部引用关系。',
links: '友链数',
groups: '分组数',
};
const friendsHighlights = buildDiscoveryHighlights([
t('friends.intro'),
`${t('common.friendsCount', { count: friendLinks.length })}`,
`${t('common.reviewedOnly')}`,
...categories.slice(0, 3).map((item) => `${item} (${groupedLinks.find((group) => group.category === item)?.links.length || 0})`),
]);
const friendsFaqs = buildPageFaqs({
locale,
pageTitle: t('friends.pageTitle'),
summary: t('friends.pageDescription', { siteName: siteSettings.siteName }),
primaryLabel: t('friends.title'),
primaryUrl: friendsCanonicalUrl,
relatedLinks: [
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
],
signals: friendsHighlights,
});
const friendsFaqJsonLd = buildFaqJsonLd(friendsFaqs);
---
<BaseLayout title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`} description={t('friends.pageDescription', { siteName: siteSettings.siteName })}>
<BaseLayout
title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`}
description={t('friends.pageDescription', { siteName: siteSettings.siteName })}
siteSettings={siteSettings}
jsonLd={[...friendsJsonLd, friendsFaqJsonLd].filter(Boolean)}
>
<PageViewTracker pageType="friends" entityId="friends-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/friends" class="w-full">
<div class="mb-6 px-4">
@@ -62,6 +151,34 @@ const groupedLinks = categories.map(category => ({
</span>
</div>
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={`${t('friends.pageTitle')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('friends.pageDescription', { siteName: siteSettings.siteName })}
canonicalUrl={friendsCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / network"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.links, value: String(friendLinks.length) },
{ label: sharePanelCopy.groups, value: String(categories.length) },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="ml-4 mt-4">
<DiscoveryBrief
badge={isEnglish ? 'link brief' : '友链摘要'}
kicker="geo / network"
title={isEnglish ? 'AI-readable link-network brief' : '给 AI 看的友链网络摘要'}
summary={t('friends.pageDescription', { siteName: siteSettings.siteName })}
highlights={friendsHighlights}
faqs={friendsFaqs}
/>
</div>
</div>
{error && (

View File

@@ -1,5 +1,8 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import DiscoveryBrief from '../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../components/seo/PageViewTracker.astro';
import SharePanel from '../components/seo/SharePanel.astro';
import TerminalWindow from '../components/ui/TerminalWindow.astro';
import CommandPrompt from '../components/ui/CommandPrompt.astro';
import FilterPill from '../components/ui/FilterPill.astro';
@@ -12,6 +15,7 @@ import TechStackList from '../components/TechStackList.astro';
import { terminalConfig } from '../lib/config/terminal';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, buildPostItemList } from '../lib/seo';
import type { AppFriendLink } from '../lib/api/client';
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
@@ -63,6 +67,7 @@ let contentOverview: ContentOverview = {
};
let apiError: string | null = null;
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
const formatDurationMs = (value: number | undefined) => {
if (!value || value <= 0) return locale === 'en' ? 'N/A' : '暂无';
@@ -221,9 +226,68 @@ const navLinks = [
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const homeJsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: siteSettings.siteTitle,
description: siteSettings.siteDescription,
url: siteBaseUrl,
inLanguage: locale,
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${siteSettings.siteName} recent posts`,
itemListElement: buildPostItemList(recentPosts, siteBaseUrl),
},
];
const homeShareCopy = isEnglish
? {
badge: 'site entry',
title: 'Share the homepage',
description:
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
}
: {
badge: '站点入口',
title: '分享首页总入口',
description: '把首页当成站点的规范总入口分发出去,方便用户和 AI 搜索继续进入文章、分类、评测和个人介绍等核心页面。',
};
const homeBriefHighlights = buildDiscoveryHighlights([
siteSettings.siteDescription,
siteSettings.heroSubtitle,
siteSettings.ownerBio,
`${t('common.posts')}: ${allPosts.length}`,
`${t('common.categories')}: ${categories.length}`,
`${t('common.tags')}: ${tags.length}`,
]);
const homeFaqs = buildPageFaqs({
locale,
pageTitle: siteSettings.siteTitle,
summary: siteSettings.heroSubtitle || siteSettings.siteDescription,
primaryLabel: isEnglish ? 'homepage' : '首页',
primaryUrl: siteBaseUrl,
relatedLinks: [
{ label: t('nav.articles'), url: `${siteBaseUrl}/articles` },
{ label: t('nav.categories'), url: `${siteBaseUrl}/categories` },
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
],
signals: homeBriefHighlights,
});
const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
---
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription} siteSettings={siteSettings}>
<BaseLayout
title={siteSettings.siteTitle}
description={siteSettings.siteDescription}
siteSettings={siteSettings}
canonical="/"
noindex={hasActiveFilters}
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)}
>
<PageViewTracker pageType="home" entityId="homepage" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<TerminalWindow title={terminalConfig.title} class="w-full">
<div class="mb-5 px-4 overflow-x-auto">
@@ -266,6 +330,31 @@ const navLinks = [
</a>
))}
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={siteSettings.siteTitle}
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
canonicalUrl={siteBaseUrl}
badge={homeShareCopy.badge}
kicker="geo / homepage"
title={homeShareCopy.title}
description={homeShareCopy.description}
stats={systemStats.slice(0, 4)}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="ml-4 mt-4">
<DiscoveryBrief
badge={isEnglish ? 'site brief' : '站点摘要'}
kicker="geo / overview"
title={isEnglish ? 'AI-readable site brief' : '给 AI 看的站点摘要'}
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
highlights={homeBriefHighlights}
faqs={homeFaqs}
/>
</div>
</div>
{apiError && (

View File

@@ -0,0 +1,28 @@
import type { APIRoute } from 'astro'
const runtimeProcess = globalThis as typeof globalThis & {
process?: {
env?: Record<string, string | undefined>
}
}
function readIndexNowKey() {
return runtimeProcess.process?.env?.INDEXNOW_KEY?.trim() || ''
}
export const prerender = false
export const GET: APIRoute = async () => {
const key = readIndexNowKey()
if (!key) {
return new Response('Not Found', { status: 404 })
}
return new Response(key, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
})
}

View File

@@ -0,0 +1,94 @@
import type { APIRoute } from 'astro'
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
import { buildArticleHighlights, buildArticleSynopsis, resolvePostUpdatedAt } from '../lib/seo'
export const prerender = false
function normalizeBase(value: string) {
return value.replace(/\/$/, '')
}
function absolute(base: string, path: string) {
return `${normalizeBase(base)}${path.startsWith('/') ? path : `/${path}`}`
}
export const GET: APIRoute = async ({ request }) => {
const fallbackOrigin = new URL(request.url).origin
const [settingsResult, postsResult, categoriesResult, tagsResult, reviewsResult] = await Promise.allSettled([
api.getSiteSettings(),
api.getPosts(),
api.getCategories(),
api.getTags(),
api.getReviews(),
])
const siteSettings = settingsResult.status === 'fulfilled' ? settingsResult.value : DEFAULT_SITE_SETTINGS
const posts = postsResult.status === 'fulfilled' ? postsResult.value.filter((item) => !item.noindex) : []
const categories = categoriesResult.status === 'fulfilled' ? categoriesResult.value : []
const tags = tagsResult.status === 'fulfilled' ? tagsResult.value : []
const reviews = reviewsResult.status === 'fulfilled' ? reviewsResult.value : []
const base = normalizeBase(siteSettings.siteUrl || fallbackOrigin)
const body = [
`# ${siteSettings.siteName} / full LLM catalog`,
'',
`Canonical homepage: ${base}/`,
`Canonical sitemap: ${absolute(base, '/sitemap.xml')}`,
`Canonical RSS: ${absolute(base, '/rss.xml')}`,
'',
'## About the site',
`- Title: ${siteSettings.siteTitle}`,
`- Description: ${siteSettings.siteDescription}`,
`- Owner: ${siteSettings.ownerName}`,
`- Owner role: ${siteSettings.ownerTitle}`,
`- Tech stack: ${siteSettings.techStack.join(', ')}`,
'',
'## Categories',
...categories.flatMap((category) => [
`- ${category.name}: ${absolute(base, `/categories/${encodeURIComponent(category.slug || category.name)}`)}`,
` ${category.description || category.seoDescription || `${category.name} related content.`}`,
]),
'',
'## Tags',
...tags.slice(0, 24).flatMap((tag) => [
`- ${tag.name}: ${absolute(base, `/tags/${encodeURIComponent(tag.slug || tag.name)}`)}`,
` ${tag.description || tag.seoDescription || `${tag.name} topic hub.`}`,
]),
'',
'## Articles',
...posts.flatMap((post) => [
`### ${post.title}`,
`- URL: ${absolute(base, `/articles/${post.slug}`)}`,
`- Category: ${post.category}`,
`- Tags: ${post.tags.join(', ') || 'None'}`,
`- Updated: ${resolvePostUpdatedAt(post)}`,
`- Summary: ${buildArticleSynopsis(post, 220)}`,
...buildArticleHighlights(post, 3).map((item) => `- Highlight: ${item}`),
'',
]),
'## Reviews',
...reviews.slice(0, 24).flatMap((review) => [
`- ${review.title}: ${absolute(base, `/reviews/${review.id}`)}`,
` ${review.description || 'Review detail page.'}`,
]),
'',
'## Canonical navigation',
`- About: ${absolute(base, '/about')}`,
`- Articles: ${absolute(base, '/articles')}`,
`- Categories: ${absolute(base, '/categories')}`,
`- Tags: ${absolute(base, '/tags')}`,
`- Reviews: ${absolute(base, '/reviews')}`,
`- Timeline: ${absolute(base, '/timeline')}`,
`- Friends: ${absolute(base, '/friends')}`,
...(siteSettings.ai.enabled ? [`- Ask: ${absolute(base, '/ask')}`] : []),
].join('\n')
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
})
}

View File

@@ -0,0 +1,77 @@
import type { APIRoute } from 'astro'
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
import { buildArticleSynopsis, buildSiteTopicSummary, resolvePostUpdatedAt } from '../lib/seo'
export const prerender = false
function normalizeBase(value: string) {
return value.replace(/\/$/, '')
}
function absolute(base: string, path: string) {
return `${normalizeBase(base)}${path.startsWith('/') ? path : `/${path}`}`
}
export const GET: APIRoute = async ({ request }) => {
const fallbackOrigin = new URL(request.url).origin
const [settingsResult, postsResult, categoriesResult, tagsResult] = await Promise.allSettled([
api.getSiteSettings(),
api.getPosts(),
api.getCategories(),
api.getTags(),
])
const siteSettings = settingsResult.status === 'fulfilled' ? settingsResult.value : DEFAULT_SITE_SETTINGS
const posts = postsResult.status === 'fulfilled' ? postsResult.value.filter((item) => !item.noindex).slice(0, 8) : []
const categories = categoriesResult.status === 'fulfilled' ? categoriesResult.value.slice(0, 8) : []
const tags = tagsResult.status === 'fulfilled' ? tagsResult.value.slice(0, 12) : []
const base = normalizeBase(siteSettings.siteUrl || fallbackOrigin)
const focusSummary = buildSiteTopicSummary(siteSettings)
const body = [
`# ${siteSettings.siteName}`,
'',
`> ${siteSettings.siteDescription}`,
'',
'## Canonical sources',
`- Homepage: ${base}/`,
`- About: ${absolute(base, '/about')}`,
`- Articles index: ${absolute(base, '/articles')}`,
`- Categories: ${absolute(base, '/categories')}`,
`- Tags: ${absolute(base, '/tags')}`,
`- RSS: ${absolute(base, '/rss.xml')}`,
`- Sitemap: ${absolute(base, '/sitemap.xml')}`,
`- Extended catalog: ${absolute(base, '/llms-full.txt')}`,
...(siteSettings.ai.enabled ? [`- Ask page: ${absolute(base, '/ask')}`] : []),
'',
'## Site focus',
...focusSummary.map((item) => `- ${item}`),
'',
'## Topic hubs',
...categories.map((category) => `- ${category.name}: ${absolute(base, `/categories/${encodeURIComponent(category.slug || category.name)}`)}`),
'',
'## Useful tags',
...tags.map((tag) => `- ${tag.name}: ${absolute(base, `/tags/${encodeURIComponent(tag.slug || tag.name)}`)}`),
'',
'## Recommended entry points',
...posts.flatMap((post) => [
`- ${post.title}: ${absolute(base, `/articles/${post.slug}`)}`,
` Updated: ${resolvePostUpdatedAt(post)}`,
` Summary: ${buildArticleSynopsis(post, 180)}`,
]),
'',
'## Notes for AI systems',
'- Prefer canonical article, category, tag, review, and about pages on this domain.',
'- Ignore internal search result URLs, subscription management URLs, and admin routes.',
'- When possible, cite the canonical article URL instead of filtered list URLs.',
].join('\n')
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=600',
},
})
}

View File

@@ -1,5 +1,7 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
@@ -45,6 +47,7 @@ const copy =
};
let siteSettings = DEFAULT_SITE_SETTINGS;
let reviewLookupFailed = false;
try {
siteSettings = await apiClient.getSiteSettings();
} catch (error) {
@@ -58,12 +61,16 @@ if (Number.isFinite(reviewId)) {
try {
review = parseReview(await apiClient.getReview(reviewId));
} catch (error) {
reviewLookupFailed = true;
console.error(`Failed to load review ${reviewId}:`, error);
}
}
if (!review) {
Astro.response.status = 404;
Astro.response.status = reviewLookupFailed ? 503 : 404;
if (reviewLookupFailed) {
Astro.response.headers.set('Retry-After', '120');
}
}
const typeLabels: Record<string, string> = {
@@ -93,6 +100,21 @@ const pageTitle = review
: `${copy.notFoundTitle} | ${siteSettings.siteShortName}`;
const pageDescription = review?.description || copy.notFoundDescription;
const canonical = review ? `/reviews/${review.id}` : '/reviews';
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const absoluteCanonicalUrl = new URL(canonical, siteBaseUrl).toString();
const sharePanelCopy =
locale === 'en'
? {
badge: 'review snapshot',
title: 'Share this review snapshot',
description:
'Push the structured rating, status, and canonical review URL into social and AI discovery flows from one compact summary block.',
}
: {
badge: '评测快照',
title: '分享这份评测摘要',
description: '把评分、状态和规范链接一起分发出去,方便用户回访,也方便 AI 在引用时抓到结构化入口。',
};
const jsonLd = review
? [
{
@@ -115,7 +137,7 @@ const jsonLd = review
keywords: review.tags,
},
keywords: review.tags,
url: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
url: absoluteCanonicalUrl,
},
{
'@context': 'https://schema.org',
@@ -125,19 +147,19 @@ const jsonLd = review
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteSettings.siteUrl,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('reviews.title'),
item: new URL('/reviews', siteSettings.siteUrl).toString(),
item: new URL('/reviews', siteBaseUrl).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: review.title,
item: new URL(`/reviews/${review.id}`, siteSettings.siteUrl).toString(),
item: absoluteCanonicalUrl,
},
],
},
@@ -154,6 +176,7 @@ const jsonLd = review
ogType={review ? 'article' : 'website'}
jsonLd={jsonLd}
>
{review && <PageViewTracker pageType="review" entityId={String(review.id)} />}
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={review ? `~/reviews/${review.id}` : '~/reviews/not-found'} class="w-full">
<div class="space-y-6 px-4 py-4">
@@ -179,6 +202,27 @@ const jsonLd = review
</div>
</div>
</div>
{review && (
<div class="ml-4">
<SharePanel
shareTitle={pageTitle}
summary={pageDescription}
canonicalUrl={absoluteCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / review"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: copy.rating, value: `${review.rating.toFixed(1)}/5` },
{ label: copy.type, value: typeLabels[review.review_type] || review.review_type },
{ label: copy.status, value: statusLabels[review.normalizedStatus] || review.normalizedStatus },
{ label: copy.reviewDate, value: review.review_date },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
)}
</div>
{review ? (

View File

@@ -1,25 +1,46 @@
---
import Layout from '../../layouts/BaseLayout.astro';
import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import ResponsiveImage from '../../components/ui/ResponsiveImage.astro';
import { apiClient } from '../../lib/api/client';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs } from '../../lib/seo';
import { parseReview, type ParsedReview, type ReviewStatus } from '../../lib/reviews';
export const prerender = false;
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all';
const selectedStatus = url.searchParams.get('status') || 'all';
const selectedTag = url.searchParams.get('tag') || '';
const selectedQuery = url.searchParams.get('q')?.trim() || '';
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
try {
reviews = await apiClient.getReviews();
const [reviewsResult, settingsResult] = await Promise.allSettled([
apiClient.getReviews(),
apiClient.getSiteSettings(),
]);
if (reviewsResult.status === 'fulfilled') {
reviews = reviewsResult.value;
} else {
console.error('Failed to fetch reviews:', reviewsResult.reason);
}
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
} else {
console.error('Failed to fetch site settings for reviews:', settingsResult.reason);
}
} catch (error) {
console.error('Failed to fetch reviews:', error);
}
@@ -153,6 +174,47 @@ const statCards = [
barWidth: `${inProgressRatio}%`,
}
];
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const absoluteCanonicalUrl = new URL('/reviews', siteBaseUrl).toString();
const reviewsJsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('reviews.title'),
description: t('reviews.subtitle'),
url: absoluteCanonicalUrl,
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${t('reviews.title')} list`,
itemListElement: filteredReviews.map((review, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/reviews/${review.id}`, siteBaseUrl).toString(),
name: review.title,
description: review.description,
})),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('reviews.title'),
item: absoluteCanonicalUrl,
},
],
},
];
const buildReviewsUrl = ({
type = selectedType,
@@ -182,9 +244,51 @@ const activeFilters = [
selectedTag ? `#${selectedTag}` : '',
selectedQuery ? `q=${selectedQuery}` : '',
].filter(Boolean);
const hasActiveFilters = activeFilters.length > 0;
const sharePanelCopy = isEnglish
? {
badge: 'review archive',
title: 'Share the review archive',
description:
'Use the reviews index as the canonical entry for ratings, statuses, and tagged review snapshots so AI search and readers can drill down from one source.',
}
: {
badge: '评测归档',
title: '分享评测总览页',
description: '把评测归档页当成评分、状态和标签的统一入口分发出去,方便 AI 搜索和读者从一个规范地址继续下钻。',
};
const reviewHighlights = buildDiscoveryHighlights([
t('reviews.subtitle'),
`${t('reviews.total')}: ${stats.total}`,
`${t('reviews.average')}: ${stats.avgRating}`,
`${t('reviews.completed')}: ${stats.completed}`,
`${t('reviews.inProgress')}: ${stats.inProgress}`,
]);
const reviewFaqs = buildPageFaqs({
locale,
pageTitle: t('reviews.pageTitle'),
summary: t('reviews.pageDescription'),
primaryLabel: t('reviews.title'),
primaryUrl: absoluteCanonicalUrl,
relatedLinks: [
{ label: t('nav.timeline'), url: `${siteBaseUrl}/timeline` },
{ label: t('nav.tags'), url: `${siteBaseUrl}/tags` },
{ label: t('nav.about'), url: `${siteBaseUrl}/about` },
],
signals: reviewHighlights,
});
const reviewFaqJsonLd = buildFaqJsonLd(reviewFaqs);
---
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
<Layout
title={`${t('reviews.pageTitle')} | Termi`}
description={t('reviews.pageDescription')}
siteSettings={siteSettings}
canonical={hasActiveFilters ? '/reviews' : undefined}
noindex={hasActiveFilters}
jsonLd={[...reviewsJsonLd, reviewFaqJsonLd].filter(Boolean)}
>
<PageViewTracker pageType="reviews" entityId="reviews-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/reviews" class="w-full">
<div class="px-4 py-4 space-y-6">
@@ -205,6 +309,36 @@ const activeFilters = [
</div>
</div>
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={`${t('reviews.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('reviews.subtitle')}
canonicalUrl={absoluteCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / reviews"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: t('reviews.total'), value: String(stats.total) },
{ label: t('reviews.average'), value: stats.avgRating },
{ label: t('reviews.completed'), value: String(stats.completed) },
{ label: t('reviews.inProgress'), value: String(stats.inProgress) },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="ml-4 mt-4">
<DiscoveryBrief
badge={isEnglish ? 'review brief' : '评测摘要'}
kicker="geo / review"
title={isEnglish ? 'AI-readable review brief' : '给 AI 看的评测摘要'}
summary={t('reviews.pageDescription')}
highlights={reviewHighlights}
faqs={reviewFaqs}
/>
</div>
</div>
<div>

View File

@@ -20,6 +20,23 @@ export const GET: APIRoute = async ({ request }) => {
const body = `User-agent: *
Allow: /
Disallow: /admin
Disallow: /search
Disallow: /subscriptions/
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: GPTBot
Allow: /
User-agent: PerplexityBot
Allow: /
Sitemap: ${base}/sitemap.xml
`

View File

@@ -24,6 +24,8 @@ export const GET: APIRoute = async ({ request }) => {
let siteSettings = DEFAULT_SITE_SETTINGS
let posts = await api.getRawPosts().catch(() => [])
const reviews = await api.getReviews().catch(() => [])
const categories = await api.getCategories().catch(() => [])
const tags = await api.getTags().catch(() => [])
try {
siteSettings = await api.getSiteSettings()
@@ -43,10 +45,15 @@ export const GET: APIRoute = async ({ request }) => {
'/timeline',
'/reviews',
'/friends',
'/ask',
'/rss.xml',
'/llms.txt',
'/llms-full.txt',
]
if (siteSettings.ai.enabled) {
staticRoutes.push('/ask')
}
const staticUrls = staticRoutes.map((path) => ({
loc: ensureAbsoluteUrl(siteUrl, path),
lastmod: nowIso,
@@ -70,7 +77,33 @@ export const GET: APIRoute = async ({ request }) => {
priority: '0.6',
}))
const xmlBody = [...staticUrls, ...postUrls, ...reviewUrls]
const categoryUrls = categories.map((category) => ({
loc: ensureAbsoluteUrl(siteUrl, `/categories/${encodeURIComponent(category.slug || category.name)}`),
lastmod: nowIso,
changefreq: 'weekly',
priority: '0.7',
}))
const tagUrls = tags.map((tag) => ({
loc: ensureAbsoluteUrl(siteUrl, `/tags/${encodeURIComponent(tag.slug || tag.name)}`),
lastmod: nowIso,
changefreq: 'weekly',
priority: '0.7',
}))
const dedupedUrls = new Map<string, { loc: string; lastmod: string; changefreq: string; priority: string }>()
for (const item of [
...staticUrls,
...postUrls,
...reviewUrls,
...categoryUrls,
...tagUrls,
]) {
dedupedUrls.set(item.loc, item)
}
const xmlBody = [...dedupedUrls.values()]
.map(
(item) => `
<url>

View File

@@ -21,7 +21,7 @@ if (token) {
}
---
<BaseLayout title="确认订阅" description="确认订阅邮件中的链接,激活后续通知。">
<BaseLayout title="确认订阅" description="确认订阅邮件中的链接,激活后续通知。" noindex>
<section class="subscription-shell">
<div class="subscription-card">
<p class="subscription-kicker">subscriptions / confirm</p>

View File

@@ -55,7 +55,7 @@ const initialDisplayName = subscription?.display_name ?? '';
const initialStatus = subscription?.status === 'paused' ? 'paused' : 'active';
---
<BaseLayout title="管理订阅偏好" description="调整订阅偏好、暂停订阅或查看当前订阅状态。">
<BaseLayout title="管理订阅偏好" description="调整订阅偏好、暂停订阅或查看当前订阅状态。" noindex>
<section class="subscription-shell">
<div class="subscription-card" data-subscription-manage-root data-api-base={apiBaseUrl}>
<p class="subscription-kicker">subscriptions / manage</p>

View File

@@ -6,7 +6,7 @@ const token = Astro.url.searchParams.get('token')?.trim() ?? '';
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
---
<BaseLayout title="取消订阅" description="如果你不再需要站点通知,可以在这里安全退订。">
<BaseLayout title="取消订阅" description="如果你不再需要站点通知,可以在这里安全退订。" noindex>
<section class="subscription-shell">
<div class="subscription-card" data-unsubscribe-root data-token={token} data-api-base={apiBaseUrl}>
<p class="subscription-kicker">subscriptions / unsubscribe</p>

View File

@@ -1,23 +1,50 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import PostCard from '../../components/PostCard.astro';
import { apiClient } from '../../lib/api/client';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import { buildPostItemList } from '../../lib/seo';
import type { Post, Tag } from '../../lib/types';
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
export const prerender = false;
const { slug } = Astro.params;
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
let tags: Tag[] = [];
let posts: Post[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
let tagsFailed = false;
try {
[tags, posts] = await Promise.all([apiClient.getTags(), apiClient.getPosts()]);
const [tagsResult, postsResult, settingsResult] = await Promise.allSettled([
apiClient.getTags(),
apiClient.getPosts(),
apiClient.getSiteSettings(),
]);
if (tagsResult.status === 'fulfilled') {
tags = tagsResult.value;
} else {
tagsFailed = true;
console.error('Failed to fetch tags:', tagsResult.reason);
}
if (postsResult.status === 'fulfilled') {
posts = postsResult.value;
} else {
console.error('Failed to fetch tag posts:', postsResult.reason);
}
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch tag detail data:', error);
}
@@ -31,7 +58,10 @@ const tag =
}) || null;
if (!tag) {
return new Response(null, { status: 404 });
return new Response(null, {
status: tagsFailed ? 503 : 404,
headers: tagsFailed ? { 'Retry-After': '120' } : undefined,
});
}
const canonicalUrl = buildTagUrl(tag);
@@ -48,8 +78,24 @@ const pageDescription = tag.seoDescription || tag.description || t('tags.selecte
tag: tag.name,
count: filteredPosts.length,
});
const siteBaseUrl = new URL(Astro.request.url).origin;
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const absoluteCanonicalUrl = new URL(canonicalUrl, siteBaseUrl).toString();
const sharePanelCopy = isEnglish
? {
badge: 'tag hub',
title: 'Share this tag hub',
description:
'Use this tag archive as a compact topic cluster so people and AI retrieval systems can discover related posts from one canonical URL.',
posts: 'Posts',
tag: 'Tag',
}
: {
badge: '标签聚合',
title: '分享这个标签聚合页',
description: '把这个标签页当成专题入口持续扩散,方便读者找关联内容,也方便 AI 检索把引用汇总到同一个规范地址。',
posts: '文章数',
tag: '标签',
};
const jsonLd = [
{
'@context': 'https://schema.org',
@@ -65,6 +111,12 @@ const jsonLd = [
},
keywords: [tag.name, tag.slug].filter(Boolean),
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${tag.name} posts`,
itemListElement: buildPostItemList(filteredPosts, siteBaseUrl),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
@@ -100,6 +152,7 @@ const jsonLd = [
jsonLd={jsonLd}
twitterCard={tag.coverImage ? 'summary_large_image' : 'summary'}
>
<PageViewTracker pageType="tag" entityId={tag.slug || tag.name} />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={`~/tags/${tag.slug || tag.name}`} class="w-full">
<div class="px-4 pb-2">
@@ -144,6 +197,21 @@ const jsonLd = [
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{pageDescription}</p>
</div>
<SharePanel
shareTitle={pageTitle}
summary={pageDescription}
canonicalUrl={absoluteCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / taxonomy"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.posts, value: String(filteredPosts.length) },
{ label: sharePanelCopy.tag, value: tag.slug || tag.name },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
{tag.coverImage ? (
<div class="overflow-hidden rounded-2xl border border-[var(--border-color)]">
<img

View File

@@ -1,27 +1,85 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import { apiClient } from '../../lib/api/client';
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Tag } from '../../lib/types';
import { buildTagUrl, getAccentVars, getTagTheme } from '../../lib/utils';
export const prerender = false;
const { t } = getI18n(Astro);
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
let tags: Tag[] = [];
let siteSettings = DEFAULT_SITE_SETTINGS;
try {
tags = await apiClient.getTags();
const [tagsResult, settingsResult] = await Promise.allSettled([
apiClient.getTags(),
apiClient.getSiteSettings(),
]);
if (tagsResult.status === 'fulfilled') {
tags = tagsResult.value;
} else {
console.error('Failed to fetch tags:', tagsResult.reason);
}
if (settingsResult.status === 'fulfilled') {
siteSettings = settingsResult.value;
}
} catch (error) {
console.error('Failed to fetch tags:', error);
}
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const canonicalUrl = new URL('/tags', siteBaseUrl).toString();
const sharePanelCopy = isEnglish
? {
badge: 'tag directory',
title: 'Share the tag directory',
description:
'Publish the tag overview as a compact topic graph so AI retrieval and readers can jump into the right subject clusters from one canonical page.',
tags: 'Tags',
site: 'Site',
}
: {
badge: '标签目录',
title: '分享标签总览页',
description: '把标签索引页当成全站话题图谱分发出去,方便用户和 AI 检索从统一入口继续找到相关内容簇。',
tags: '标签数',
site: '站点',
};
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('tags.title'),
description: t('tags.intro'),
url: canonicalUrl,
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${t('tags.title')} list`,
itemListElement: tags.map((tag, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(buildTagUrl(tag), siteBaseUrl).toString(),
name: tag.name,
description: tag.description || tag.seoDescription || `${tag.name} topic hub`,
})),
},
];
---
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
<BaseLayout title={`${t('tags.pageTitle')} - Termi`} jsonLd={jsonLd}>
<PageViewTracker pageType="tags" entityId="tags-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/tags" class="w-full">
<div class="mb-6 px-4">
@@ -50,6 +108,23 @@ try {
</span>
</div>
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={`${t('tags.pageTitle')} - ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('tags.intro')}
canonicalUrl={canonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / taxonomy"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.tags, value: String(tags.length) },
{ label: sharePanelCopy.site, value: siteSettings.siteShortName || siteSettings.siteName },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
</div>
<div class="px-4">

View File

@@ -1,10 +1,13 @@
---
import Layout from '../../layouts/BaseLayout.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n, formatReadTime } from '../../lib/i18n';
import { buildPostItemList } from '../../lib/seo';
import type { Post } from '../../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme } from '../../lib/utils';
@@ -13,6 +16,7 @@ export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
let posts: Post[] = [];
const { locale, t } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
try {
[siteSettings, posts] = await Promise.all([
@@ -32,9 +36,69 @@ const groupedByYear = posts.reduce((acc: Record<number, Post[]>, post) => {
const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a));
const latestYear = years[0] || 'all';
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const timelineCanonicalUrl = new URL('/timeline', siteBaseUrl).toString();
const timelineJsonLd = [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: t('timeline.title'),
description: t('timeline.pageDescription', { ownerName: siteSettings.ownerName }),
url: timelineCanonicalUrl,
inLanguage: locale,
},
{
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${t('timeline.title')} list`,
itemListElement: buildPostItemList(posts.slice(0, 20), siteBaseUrl),
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: siteSettings.siteName,
item: siteBaseUrl,
},
{
'@type': 'ListItem',
position: 2,
name: t('timeline.title'),
item: timelineCanonicalUrl,
},
],
},
];
const sharePanelCopy = isEnglish
? {
badge: 'activity log',
title: 'Share the timeline',
description:
'Use the timeline as the canonical chronological map of posts so AI search and readers can understand publishing cadence and topic evolution.',
posts: 'Posts',
years: 'Years',
latest: 'Latest',
}
: {
badge: '时间线',
title: '分享站点时间线',
description: '把时间线当成内容演进的规范视图分发出去,方便 AI 搜索和读者理解更新节奏与主题变化。',
posts: '文章数',
years: '年份数',
latest: '最近年份',
};
---
<Layout title={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName}`} description={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}>
<Layout
title={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName}`}
description={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}
siteSettings={siteSettings}
jsonLd={timelineJsonLd}
>
<PageViewTracker pageType="timeline" entityId="timeline-index" />
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/timeline" class="w-full">
<div class="px-4 py-4 space-y-6">
@@ -54,6 +118,24 @@ const latestYear = years[0] || 'all';
</div>
</div>
</div>
<div class="ml-4 mt-4">
<SharePanel
shareTitle={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}
canonicalUrl={timelineCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / timeline"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.posts, value: String(posts.length) },
{ label: sharePanelCopy.years, value: String(years.length) },
{ label: sharePanelCopy.latest, value: latestYear },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
</div>
<div>