From 12ace89619b5ea1dce50e76cb23e98e7d0380025 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:46:14 +0800 Subject: [PATCH 1/4] feat(seo): per-page structured data and share images, git-dated sitemap, all products in the changelog Structured data - The Enterprise SoftwareApplication is no longer injected on every page by Base.astro; it lives on /thinkwatch (and the home page) only. /license, /changelog and the docs carry WebPage, BreadcrumbList or TechArticle, the 404 page only the Organization. - src/lib/structured-data.ts describes each product once, for its own page and the home page: ThinkWatch Lite as a SoftwareApplication (operating systems, processors, version and installers of the latest release, install URL, screenshots, interface languages, feature list from the page copy, MIT, free) plus its SoftwareSourceCode; ThinkWatch Core as SoftwareSourceCode plus a SoftwareApplication for the twcore binary (Linux, macOS, Windows; version and binaries of the latest Core release); ThinkWatch Enterprise as before. Entities carry @ids and refer to the Organization, which gains the homebrew-tap in sameAs. - The WebSite entity is on the home page only, without the SearchAction: the docs never read ?q=, and Google no longer shows a sitelinks search box. - JSON-LD escapes "<" so no string can end its script element. Share images - One 1200x630 card per product and one for the site, rendered at build time by src/pages/og/[card].png.ts: the product name, the page's headline and the platforms. Text is drawn as outlines from the Geist fonts in node_modules (@fontsource/geist, fontkit), so the card no longer depends on the fonts of the machine that builds it; CI had been drawing the old one in DejaVu Sans. - Each page sets its own og:image and og:image:alt (in the page's language); docs pages are og:type article with published and modified times. No twitter:site: there is no ThinkWatch account on X. Indexing - The 404 page is noindex and has no canonical or hreflang links; its language choice leads to the home page instead of the missing /404/. - Sitemap is the last commit that touched the files a page is built from (src/lib/lastmod.ts), and for the changelog the newest release; without git history the dates are left out rather than set to the build time. The deploy workflow checks out the full history and passes GITHUB_TOKEN to the build. Changelog and feed - /changelog lists every release of the three products from the GitHub API (product, version, date, link to the release), filterable by product, with the Enterprise notes written for this site kept on their versions. When GitHub cannot be reached, the build uses src/data/releases.json (refreshed with `pnpm releases`). Both languages share ChangelogPage.astro. - The page and the RSS feed are titled "ThinkWatch release notes" and cover Enterprise, Lite and Core; feed items link to the GitHub release, or to the notes on this site. Also: per-screenshot alt text for the Lite page's features (both languages), the sitemap's change frequencies typed with ChangeFreqEnum. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 8 + astro.config.mjs | 27 +- package.json | 8 +- pnpm-lock.yaml | 103 ++++- public/og-image.png | Bin 77557 -> 0 bytes scripts/generate-og.mjs | 19 - scripts/og-image.svg | 78 ---- scripts/update-releases.mjs | 21 + src/components/Seo.astro | 122 +++--- src/components/SiteHeader.astro | 3 + src/components/pages/ChangelogPage.astro | 201 +++++++++ src/components/pages/CorePage.astro | 27 +- src/components/pages/HomePage.astro | 50 +-- src/components/pages/LicensePage.astro | 27 +- src/components/pages/LitePage.astro | 38 +- src/components/pages/ThinkWatchPage.astro | 5 +- src/data/releases.json | 488 ++++++++++++++++++++++ src/i18n/pages/changelog.ts | 48 +++ src/i18n/pages/home.ts | 10 + src/i18n/pages/lite.ts | 12 + src/layouts/Base.astro | 85 ++-- src/layouts/DocsLayout.astro | 35 +- src/lib/changelog.ts | 55 +++ src/lib/github.ts | 104 +++-- src/lib/lastmod.ts | 109 +++++ src/lib/og.ts | 226 ++++++++++ src/lib/releases.mjs | 63 +++ src/lib/structured-data.ts | 228 ++++++++++ src/pages/404.astro | 2 +- src/pages/changelog.xml.ts | 36 +- src/pages/changelog/index.astro | 169 +------- src/pages/docs/_DocArticle.astro | 24 +- src/pages/docs/_DocsHome.astro | 2 +- src/pages/og/[card].png.ts | 11 + src/pages/zh-CN/changelog/index.astro | 175 +------- 35 files changed, 1861 insertions(+), 758 deletions(-) delete mode 100644 public/og-image.png delete mode 100644 scripts/generate-og.mjs delete mode 100644 scripts/og-image.svg create mode 100644 scripts/update-releases.mjs create mode 100644 src/components/pages/ChangelogPage.astro create mode 100644 src/data/releases.json create mode 100644 src/i18n/pages/changelog.ts create mode 100644 src/lib/changelog.ts create mode 100644 src/lib/lastmod.ts create mode 100644 src/lib/og.ts create mode 100644 src/lib/releases.mjs create mode 100644 src/lib/structured-data.ts create mode 100644 src/pages/og/[card].png.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a6af808..a23a3f0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + # The sitemap's and the dates on doc pages come from git + # history; a shallow clone would leave them out. + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -42,6 +46,10 @@ jobs: - name: Build site run: pnpm build + env: + # The build reads versions and releases from the GitHub API; with a + # token the anonymous rate limit does not apply. + GITHUB_TOKEN: ${{ github.token }} - name: Upload artifact uses: actions/upload-pages-artifact@v3 diff --git a/astro.config.mjs b/astro.config.mjs index cf1e606..52ac24d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,10 +1,12 @@ // @ts-check import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; -import sitemap from '@astrojs/sitemap'; +import sitemap, { ChangeFreqEnum } from '@astrojs/sitemap'; import tailwindcss from '@tailwindcss/vite'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; +import { lastModified, pageSources } from './src/lib/lastmod.ts'; +import { getReleases } from './src/lib/github.ts'; // https://astro.build/config export default defineConfig({ @@ -24,7 +26,7 @@ export default defineConfig({ locales: { en: 'en', 'zh-CN': 'zh-CN' }, }, filter: (page) => !page.includes('/404'), - serialize(item) { + async serialize(item) { const url = new URL(item.url); const pathname = url.pathname.replace(/\/$/, '') || '/'; const isHome = pathname === '/' || pathname === '/zh-CN'; @@ -38,29 +40,34 @@ export default defineConfig({ if (isHome) { item.priority = 1.0; - item.changefreq = 'weekly'; + item.changefreq = ChangeFreqEnum.WEEKLY; } else if (isProduct) { item.priority = 0.9; - item.changefreq = 'weekly'; + item.changefreq = ChangeFreqEnum.WEEKLY; } else if (isDocsIndex) { item.priority = 0.9; - item.changefreq = 'weekly'; + item.changefreq = ChangeFreqEnum.WEEKLY; } else if (isDoc) { // Includes the product doc sets under /docs/lite/ and /docs/core/. item.priority = 0.8; - item.changefreq = 'monthly'; + item.changefreq = ChangeFreqEnum.MONTHLY; } else if (isChangelog) { item.priority = 0.7; - item.changefreq = 'weekly'; + item.changefreq = ChangeFreqEnum.WEEKLY; } else if (isLicense) { item.priority = 0.6; - item.changefreq = 'monthly'; + item.changefreq = ChangeFreqEnum.MONTHLY; } else { item.priority = 0.5; - item.changefreq = 'monthly'; + item.changefreq = ChangeFreqEnum.MONTHLY; } - item.lastmod = new Date().toISOString(); + // When the page's own files last changed in git; the changelog also + // changes whenever a product is released. Left out when unknown. + const dates = [lastModified(pageSources(pathname))]; + if (isChangelog) dates.push((await getReleases())[0]?.date); + const times = dates.flatMap((d) => (d ? [Date.parse(d)] : [])); + if (times.length) item.lastmod = new Date(Math.max(...times)).toISOString(); return item; }, }), diff --git a/package.json b/package.json index d58a36d..4ac8384 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "prebuild": "node scripts/generate-og.mjs && node scripts/generate-favicons.mjs", + "prebuild": "node scripts/generate-favicons.mjs", "build": "astro build && pnpm run pagefind", "pagefind": "pagefind --site dist --output-path dist/pagefind", "preview": "astro preview", - "og": "node scripts/generate-og.mjs", + "releases": "node scripts/update-releases.mjs", "favicons": "node scripts/generate-favicons.mjs", "astro": "astro" }, @@ -28,9 +28,13 @@ "three": "^0.183.2" }, "devDependencies": { + "@fontsource/geist": "^5.3.0", + "@fontsource/geist-mono": "^5.3.0", + "@types/fontkit": "^2.0.9", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/three": "^0.183.1", + "fontkit": "^2.0.4", "pagefind": "^1.5.0", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa4b33f..e2f63c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,15 @@ importers: specifier: ^0.183.2 version: 0.183.2 devDependencies: + '@fontsource/geist': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/geist-mono': + specifier: ^5.3.0 + version: 5.3.0 + '@types/fontkit': + specifier: ^2.0.9 + version: 2.0.9 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -51,6 +60,9 @@ importers: '@types/three': specifier: ^0.183.1 version: 0.183.1 + fontkit: + specifier: ^2.0.4 + version: 2.0.4 pagefind: specifier: ^1.5.0 version: 1.5.0 @@ -363,6 +375,12 @@ packages: cpu: [x64] os: [win32] + '@fontsource/geist-mono@5.3.0': + resolution: {integrity: sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==} + + '@fontsource/geist@5.3.0': + resolution: {integrity: sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -795,6 +813,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -913,6 +934,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/fontkit@2.0.9': + resolution: {integrity: sha512-qNYerFky3muCmZPq+R+B3cUDRA5OONw/oh6aGGFxx2LOBz6yu8eamKusrhkHnC6rc2fm76+G9z9QoWSB2SaQaw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1022,6 +1046,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1059,6 +1086,10 @@ packages: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1153,6 +1184,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -1220,6 +1254,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-string-truncated-width@1.2.1: resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==} @@ -1258,6 +1295,9 @@ packages: fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + fontkitten@1.0.3: resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} engines: {node: '>=20'} @@ -1698,6 +1738,9 @@ packages: resolution: {integrity: sha512-7vQ2xh0ZmjPjsuWONR68nqzb+QNfpPh7pdT6n6YDAthWAQiUkSACVegSswY5zPNONGYFWebFVgdnS5/m/Qqn+w==} hasBin: true + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parse-latin@7.0.0: resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} @@ -1819,6 +1862,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -2002,6 +2048,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2542,6 +2594,10 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fontsource/geist-mono@5.3.0': {} + + '@fontsource/geist@5.3.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2865,6 +2921,10 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2964,6 +3024,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/fontkit@2.0.9': + dependencies: + '@types/node': 24.12.2 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -3160,6 +3224,10 @@ snapshots: boolbase@1.0.0: {} + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.15 @@ -3193,6 +3261,8 @@ snapshots: ci-info@4.4.0: {} + clone@2.1.2: {} + clsx@2.1.1: {} comma-separated-tokens@2.0.3: {} @@ -3273,6 +3343,8 @@ snapshots: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + diff@8.0.4: {} dlv@1.1.3: {} @@ -3351,6 +3423,8 @@ snapshots: extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-string-truncated-width@1.2.1: {} fast-string-width@1.1.0: @@ -3385,6 +3459,18 @@ snapshots: dependencies: fontkitten: 1.0.3 + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + fontkitten@1.0.3: dependencies: tiny-inflate: 1.0.3 @@ -4020,6 +4106,8 @@ snapshots: '@pagefind/windows-arm64': 1.5.0 '@pagefind/windows-x64': 1.5.0 + pako@0.2.9: {} + parse-latin@7.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -4176,6 +4264,8 @@ snapshots: require-from-string@2.0.2: {} + restructure@3.0.2: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -4386,8 +4476,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tunnel-rat@0.1.2(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -4407,6 +4496,16 @@ snapshots: undici-types@7.16.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unified@11.0.5: dependencies: '@types/unist': 3.0.3 diff --git a/public/og-image.png b/public/og-image.png deleted file mode 100644 index 1b6a9cad9b728cc977755fda3b7fdc015149148e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77557 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 diff --git a/scripts/generate-og.mjs b/scripts/generate-og.mjs deleted file mode 100644 index 310f882..0000000 --- a/scripts/generate-og.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Render scripts/og-image.svg → public/og-image.png (1200x630). -// Run with: node scripts/generate-og.mjs -import sharp from "sharp"; -import { readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; - -const here = dirname(fileURLToPath(import.meta.url)); -const svgPath = resolve(here, "og-image.svg"); -const outPath = resolve(here, "..", "public", "og-image.png"); - -const svg = await readFile(svgPath); -const png = await sharp(svg, { density: 300 }) - .resize(1200, 630, { fit: "cover" }) - .png({ compressionLevel: 9 }) - .toBuffer(); - -await writeFile(outPath, png); -console.log(`✓ Wrote ${outPath} (${(png.length / 1024).toFixed(1)} kB)`); diff --git a/scripts/og-image.svg b/scripts/og-image.svg deleted file mode 100644 index 305d4dd..0000000 --- a/scripts/og-image.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ThinkWatch - - - - AI gateways for organizations and developers - - - ThinkWatch · ThinkWatch Lite · ThinkWatch Core - - - - - - Self-hosted - - - Desktop app - - - MIT core - - diff --git a/scripts/update-releases.mjs b/scripts/update-releases.mjs new file mode 100644 index 0000000..bf18f8f --- /dev/null +++ b/scripts/update-releases.mjs @@ -0,0 +1,21 @@ +// Refresh src/data/releases.json, the copy of the GitHub releases that the +// build uses when GitHub cannot be reached (rate limit, no network). The build +// itself always asks GitHub first, so this file only needs refreshing now and +// then. Run with: pnpm releases (GITHUB_TOKEN is used when set). +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { fetchReleases, productRepos } from "../src/lib/releases.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const outPath = resolve(here, "..", "src", "data", "releases.json"); + +/** @type {import("../src/lib/releases.mjs").Release[]} */ +const releases = []; +for (const product of /** @type {(keyof typeof productRepos)[]} */ (Object.keys(productRepos))) { + releases.push(...(await fetchReleases(product))); +} +releases.sort((a, b) => b.date.localeCompare(a.date) || a.product.localeCompare(b.product)); + +await writeFile(outPath, `${JSON.stringify(releases, null, 2)}\n`); +console.log(`✓ Wrote ${outPath} (${releases.length} releases)`); diff --git a/src/components/Seo.astro b/src/components/Seo.astro index e2e4ce1..d5a49f4 100644 --- a/src/components/Seo.astro +++ b/src/components/Seo.astro @@ -1,112 +1,104 @@ --- import type { Lang } from "~/i18n"; - -type JsonLdItem = Record; +import { OG_HEIGHT, OG_WIDTH } from "~/lib/og"; +import { organization, type JsonLd } from "~/lib/structured-data"; interface Props { title: string; description: string; - ogImage: string; - canonical: string; + /** Left out on pages kept out of search results */ + canonical?: string; lang: Lang; - /** Extra JSON-LD items to inject (Organization, WebSite, BreadcrumbList, etc.) */ - jsonLd?: JsonLdItem[]; + /** Site-relative path of the Open Graph image, and its description */ + ogImage: string; + ogImageAlt: string; + ogType: "website" | "article"; + /** For articles: first published and last changed, as ISO timestamps */ + published?: string; + modified?: string; + /** Keep the page out of search results (the 404 page) */ + noindex?: boolean; + /** The page's own JSON-LD items; the Organization is added on every page */ + jsonLd?: JsonLd[]; } -const { title, description, ogImage, canonical, lang, jsonLd = [] } = Astro.props; +const { + title, + description, + canonical, + lang, + ogImage, + ogImageAlt, + ogType, + published, + modified, + noindex = false, + jsonLd = [], +} = Astro.props; // Build the cross-locale alternate URLs from the canonical path. // Example: canonical = "https://thinkwat.ch/docs/architecture/" // → enHref = "https://thinkwat.ch/docs/architecture/" // → zhHref = "https://thinkwat.ch/zh-CN/docs/architecture/" -const canonicalUrl = new URL(canonical); -const rawPath = canonicalUrl.pathname.replace(/^\/zh-CN/, "") || "/"; -const enHref = new URL(rawPath, Astro.site).toString(); -const zhHref = new URL(`/zh-CN${rawPath === "/" ? "/" : rawPath}`, Astro.site).toString(); +const rawPath = canonical ? new URL(canonical).pathname.replace(/^\/zh-CN/, "") || "/" : undefined; +const enHref = rawPath && new URL(rawPath, Astro.site).toString(); +const zhHref = rawPath && new URL(`/zh-CN${rawPath === "/" ? "/" : rawPath}`, Astro.site).toString(); const absoluteOgImage = new URL(ogImage, Astro.site).toString(); const ogLocale = lang === "zh-CN" ? "zh_CN" : "en_US"; const ogLocaleAlternate = lang === "zh-CN" ? "en_US" : "zh_CN"; +const robots = noindex ? "noindex" : "index, follow, max-image-preview:large, max-snippet:-1"; -// Global site-wide JSON-LD (injected on every page, in addition to per-page items). -const siteJsonLd: JsonLdItem[] = [ - { - "@context": "https://schema.org", - "@type": "Organization", - name: "ThinkWatch", - url: "https://thinkwat.ch", - logo: "https://thinkwat.ch/icon.svg", - sameAs: [ - "https://github.com/ThinkWatchProject", - "https://github.com/ThinkWatchProject/ThinkWatch", - "https://github.com/ThinkWatchProject/ThinkWatch-Lite", - "https://github.com/ThinkWatchProject/ThinkWatch-Core", - ], - description: - "ThinkWatch builds AI gateways: ThinkWatch for teams and enterprises, ThinkWatch Lite for individual developers, and the shared MIT-licensed ThinkWatch Core.", - }, - { - "@context": "https://schema.org", - "@type": "WebSite", - name: "ThinkWatch", - url: "https://thinkwat.ch", - inLanguage: ["en", "zh-CN"], - potentialAction: { - "@type": "SearchAction", - target: { - "@type": "EntryPoint", - urlTemplate: "https://thinkwat.ch/docs?q={search_term_string}", - }, - "query-input": "required name=search_term_string", - }, - }, -]; - -const allJsonLd = [...siteJsonLd, ...jsonLd]; +// `<` is escaped so that no string can close the script element early. +const allJsonLd = [organization, ...jsonLd].map((item) => JSON.stringify(item).replace(/{title} - - - - - - +{/* The canonical URL and its hreflang alternates */} +{canonical && ( + + + + + + +)} - + - + - +{canonical && } - - - + + + + +{ogType === "article" && published && } +{ogType === "article" && modified && } - + - + - - + + -{allJsonLd.map((item) => ( - diff --git a/src/components/pages/CorePage.astro b/src/components/pages/CorePage.astro index f8e4c51..40f8505 100644 --- a/src/components/pages/CorePage.astro +++ b/src/components/pages/CorePage.astro @@ -1,11 +1,12 @@ --- -// The /core page: ThinkWatch Core, the MIT-licensed Rust crates shared by -// ThinkWatch Lite and the server edition. +// The /core page: ThinkWatch Core, the MIT-licensed Rust crates and the twcore +// binary shared by ThinkWatch Lite and ThinkWatch Enterprise. import Base from "~/layouts/Base.astro"; import SiteHeader from "~/components/SiteHeader.astro"; import SiteFooter from "~/components/SiteFooter.astro"; import { getLang, localePath } from "~/i18n"; import { coreCopy } from "~/i18n/pages/core"; +import { coreLd } from "~/lib/structured-data"; const lang = getLang(Astro); const c = coreCopy[lang]; @@ -13,25 +14,7 @@ const zh = lang === "zh-CN"; const repo = "https://github.com/ThinkWatchProject/ThinkWatch-Core"; const docsHref = localePath(lang, "/docs/core"); -const pageUrl = new URL(Astro.url.pathname, Astro.site).toString(); - -const jsonLd = [ - { - "@context": "https://schema.org", - "@type": "SoftwareSourceCode", - name: "ThinkWatch Core", - description: c.meta.description, - codeRepository: repo, - programmingLanguage: "Rust", - license: "https://opensource.org/licenses/MIT", - url: pageUrl, - author: { - "@type": "Organization", - name: "ThinkWatch Project", - url: "https://github.com/ThinkWatchProject", - }, - }, -]; +const jsonLd = await coreLd(lang); const delay = (i: number) => `animation-delay: ${i * 90}ms;`; const eyebrow = "font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted)] sm:text-[13px]"; @@ -42,7 +25,7 @@ const textLink = "inline-flex min-h-11 items-center text-[17px] font-medium text-[var(--color-brand-1)] transition-colors hover:text-[var(--color-brand-2)]"; --- - +
diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro index 502df9e..12efc73 100644 --- a/src/components/pages/HomePage.astro +++ b/src/components/pages/HomePage.astro @@ -10,52 +10,24 @@ import LiteBand from "~/components/home/LiteBand.astro"; import CoreStrip from "~/components/home/CoreStrip.astro"; import CompareEditions from "~/components/home/CompareEditions.astro"; import { getLang } from "~/i18n"; +import { homeCopy } from "~/i18n/pages/home"; +import { coreLd, enterpriseLd, liteLd, webPage, website } from "~/lib/structured-data"; const lang = getLang(Astro); -const home = new URL(Astro.url.pathname, Astro.site).toString(); +const c = homeCopy[lang]; -// The home page introduces all three products, so it describes each of them -// instead of the default ThinkWatch-only SoftwareApplication. +// The home page introduces all three products, so it describes each of them, +// the same way their own pages do. const jsonLd = [ - { - "@context": "https://schema.org", - "@type": "SoftwareApplication", - name: "ThinkWatch", - applicationCategory: "DeveloperApplication", - operatingSystem: "Linux, macOS, Windows (via Docker / Kubernetes)", - url: "https://thinkwat.ch/thinkwatch", - downloadUrl: "https://github.com/ThinkWatchProject/ThinkWatch", - license: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSE", - offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, - }, - { - "@context": "https://schema.org", - "@type": "SoftwareSourceCode", - name: "ThinkWatch Lite", - url: "https://thinkwat.ch/lite", - codeRepository: "https://github.com/ThinkWatchProject/ThinkWatch-Lite", - programmingLanguage: ["TypeScript", "Rust"], - license: "https://opensource.org/licenses/MIT", - }, - { - "@context": "https://schema.org", - "@type": "SoftwareSourceCode", - name: "ThinkWatch Core", - url: "https://thinkwat.ch/core", - codeRepository: "https://github.com/ThinkWatchProject/ThinkWatch-Core", - programmingLanguage: "Rust", - license: "https://opensource.org/licenses/MIT", - }, - { - "@context": "https://schema.org", - "@type": "WebPage", - url: home, - inLanguage: lang, - }, + website, + webPage(lang, "/", c.meta.title, c.meta.description), + ...(await enterpriseLd(lang)), + ...(await liteLd(lang)), + ...(await coreLd(lang)), ]; --- - +
diff --git a/src/components/pages/LicensePage.astro b/src/components/pages/LicensePage.astro index e32dfc8..6481a69 100644 --- a/src/components/pages/LicensePage.astro +++ b/src/components/pages/LicensePage.astro @@ -3,39 +3,22 @@ import Base from "~/layouts/Base.astro"; import SiteHeader from "~/components/SiteHeader.astro"; import SiteFooter from "~/components/SiteFooter.astro"; -import { getLang, localePath } from "~/i18n"; +import { getLang } from "~/i18n"; import { licenseCopy } from "~/i18n/pages/license"; +import { breadcrumbs, webPage } from "~/lib/structured-data"; const lang = getLang(Astro); const c = licenseCopy[lang]; -const pageUrl = new URL(localePath(lang, "/license"), Astro.site).toString(); -const homeUrl = new URL(localePath(lang, "/"), Astro.site).toString(); - const jsonLd = [ - { - "@context": "https://schema.org", - "@type": "WebPage", - name: c.meta.title, - description: c.meta.description, - url: pageUrl, - inLanguage: lang, - isPartOf: { "@type": "WebSite", name: "ThinkWatch", url: "https://thinkwat.ch" }, - }, - { - "@context": "https://schema.org", - "@type": "BreadcrumbList", - itemListElement: [ - { "@type": "ListItem", position: 1, name: c.meta.breadcrumbHome, item: homeUrl }, - { "@type": "ListItem", position: 2, name: c.meta.breadcrumbPage, item: pageUrl }, - ], - }, + webPage(lang, "/license", c.meta.title, c.meta.description), + breadcrumbs(lang, "/license", c.meta.breadcrumbHome, c.meta.breadcrumbPage), ]; const eyebrow = "font-mono text-xs sm:text-[13px] uppercase tracking-[0.08em]"; --- - +
diff --git a/src/components/pages/LitePage.astro b/src/components/pages/LitePage.astro index 36d4096..010f8b8 100644 --- a/src/components/pages/LitePage.astro +++ b/src/components/pages/LitePage.astro @@ -12,6 +12,7 @@ import SiteFooter from "~/components/SiteFooter.astro"; import { getLang, localePath, t } from "~/i18n"; import { liteCopy } from "~/i18n/pages/lite"; import { getLatestLiteRelease } from "~/lib/github"; +import { liteLd } from "~/lib/structured-data"; const lang = getLang(Astro); const c = liteCopy[lang]; @@ -51,38 +52,7 @@ const dmg = { url: assetUrl("-arm64.dmg") ?? latestPage, sha: assetUrl("-arm64.d const liteVersion = liteRelease?.tag.replace(/^v/, "") ?? null; const installDocsHref = localePath(lang, "/docs/lite/install"); const sourceDocsHref = localePath(lang, "/docs/lite/run-from-source"); -const pageUrl = new URL(Astro.url.pathname, Astro.site).toString(); - -const jsonLd = [ - { - "@context": "https://schema.org", - "@type": "SoftwareApplication", - name: "ThinkWatch Lite", - description: c.meta.description, - applicationCategory: "DeveloperApplication", - operatingSystem: - "macOS 12 or later (Apple Silicon); Windows 10 21H2 or later (x64, ARM64); Linux: Ubuntu 22.04, Debian 12, Fedora 36 or later (x86_64, aarch64)", - url: pageUrl, - downloadUrl: latestPage, - license: "https://opensource.org/licenses/MIT", - offers: { "@type": "Offer", price: 0, priceCurrency: "USD" }, - author: { - "@type": "Organization", - name: "ThinkWatch Project", - url: "https://github.com/ThinkWatchProject", - }, - }, - { - "@context": "https://schema.org", - "@type": "SoftwareSourceCode", - name: "ThinkWatch Lite", - description: c.meta.description, - codeRepository: repo, - programmingLanguage: ["TypeScript", "Rust"], - license: "https://opensource.org/licenses/MIT", - url: pageUrl, - }, -]; +const jsonLd = await liteLd(lang); const delay = (i: number) => `animation-delay: ${i * 90}ms;`; const eyebrow = "font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-muted)] sm:text-[13px]"; @@ -98,7 +68,7 @@ const platformLabel = "m-0 font-mono text-[13px] text-[var(--color-muted)]"; const archLabel = { x64: "x64", arm64: "ARM64" } as const; --- - +
@@ -174,7 +144,7 @@ const archLabel = { x64: "x64", arm64: "ARM64" } as const; loading="lazy" decoding="async" class={shotClass} - alt={item.title} + alt={item.alt} /> ))} diff --git a/src/components/pages/ThinkWatchPage.astro b/src/components/pages/ThinkWatchPage.astro index 0917c27..e915e7f 100644 --- a/src/components/pages/ThinkWatchPage.astro +++ b/src/components/pages/ThinkWatchPage.astro @@ -15,6 +15,7 @@ import StarHistory from "~/components/sections/StarHistory.astro"; import LogExplorerMock from "~/components/mocks/LogExplorerMock.tsx"; import { getLang, localePath } from "~/i18n"; import { getLatestRelease } from "~/lib/github"; +import { enterpriseLd } from "~/lib/structured-data"; import { thinkwatchCopy } from "~/i18n/pages/thinkwatch"; const lang = getLang(Astro); @@ -30,10 +31,12 @@ const pills: { label: string; title?: string }[] = [ { label: c.hero.license }, ]; +const jsonLd = await enterpriseLd(lang); + const delay = (i: number) => `animation-delay: ${i * 90}ms;`; --- - +
diff --git a/src/data/releases.json b/src/data/releases.json new file mode 100644 index 0000000..389edd7 --- /dev/null +++ b/src/data/releases.json @@ -0,0 +1,488 @@ +[ + { + "product": "lite", + "tag": "v2026.9.16", + "date": "2026-09-24T18:48:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.16" + }, + { + "product": "core", + "tag": "v0.47.0", + "date": "2026-09-24T17:48:15Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.47.0" + }, + { + "product": "lite", + "tag": "v2026.9.15", + "date": "2026-09-24T14:40:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.15" + }, + { + "product": "enterprise", + "tag": "v2.0.0", + "date": "2026-09-24T14:11:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v2.0.0" + }, + { + "product": "core", + "tag": "v0.46.0", + "date": "2026-09-24T13:10:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.46.0" + }, + { + "product": "core", + "tag": "v0.45.0", + "date": "2026-09-24T11:58:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.45.0" + }, + { + "product": "core", + "tag": "v0.44.0", + "date": "2026-09-24T10:57:03Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.44.0" + }, + { + "product": "core", + "tag": "v0.43.0", + "date": "2026-09-24T08:35:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.43.0" + }, + { + "product": "core", + "tag": "v0.42.0", + "date": "2026-09-24T07:11:05Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.42.0" + }, + { + "product": "enterprise", + "tag": "v1.1.0", + "date": "2026-09-24T06:25:27Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.1.0" + }, + { + "product": "core", + "tag": "v0.41.0", + "date": "2026-09-24T06:11:00Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.41.0" + }, + { + "product": "lite", + "tag": "v2026.9.14", + "date": "2026-09-24T05:16:32Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.14" + }, + { + "product": "lite", + "tag": "v2026.9.13", + "date": "2026-09-24T03:46:59Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.13" + }, + { + "product": "core", + "tag": "v0.40.0", + "date": "2026-09-24T03:17:05Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.40.0" + }, + { + "product": "lite", + "tag": "v2026.9.12", + "date": "2026-09-24T03:13:01Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.12" + }, + { + "product": "core", + "tag": "v0.39.0", + "date": "2026-09-24T02:19:05Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.39.0" + }, + { + "product": "core", + "tag": "v0.38.0", + "date": "2026-09-23T20:45:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.38.0" + }, + { + "product": "core", + "tag": "v0.37.0", + "date": "2026-09-23T19:48:04Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.37.0" + }, + { + "product": "lite", + "tag": "v2026.9.11", + "date": "2026-09-23T18:31:28Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.11" + }, + { + "product": "core", + "tag": "v0.36.0", + "date": "2026-09-23T17:39:14Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.36.0" + }, + { + "product": "core", + "tag": "v0.35.0", + "date": "2026-09-23T16:49:20Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.35.0" + }, + { + "product": "core", + "tag": "v0.34.0", + "date": "2026-09-23T15:44:49Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.34.0" + }, + { + "product": "lite", + "tag": "v2026.9.10", + "date": "2026-09-23T14:36:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.10" + }, + { + "product": "core", + "tag": "v0.33.0", + "date": "2026-09-23T13:25:33Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.33.0" + }, + { + "product": "core", + "tag": "v0.32.0", + "date": "2026-09-23T11:20:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.32.0" + }, + { + "product": "core", + "tag": "v0.31.0", + "date": "2026-09-23T10:18:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.31.0" + }, + { + "product": "core", + "tag": "v0.30.0", + "date": "2026-09-23T08:54:25Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.30.0" + }, + { + "product": "core", + "tag": "v0.29.0", + "date": "2026-09-23T03:48:27Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.29.0" + }, + { + "product": "lite", + "tag": "v2026.9.9", + "date": "2026-09-23T02:41:00Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.9" + }, + { + "product": "core", + "tag": "v0.28.0", + "date": "2026-09-23T02:11:02Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.28.0" + }, + { + "product": "lite", + "tag": "v2026.9.8", + "date": "2026-09-22T15:24:13Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.8" + }, + { + "product": "core", + "tag": "v0.27.0", + "date": "2026-09-22T09:50:30Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.27.0" + }, + { + "product": "core", + "tag": "v0.26.0", + "date": "2026-09-22T08:37:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.26.0" + }, + { + "product": "core", + "tag": "v0.25.0", + "date": "2026-09-22T06:43:47Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.25.0" + }, + { + "product": "core", + "tag": "v0.24.1", + "date": "2026-09-22T05:49:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.24.1" + }, + { + "product": "core", + "tag": "v0.24.0", + "date": "2026-09-22T05:31:02Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.24.0" + }, + { + "product": "core", + "tag": "v0.23.0", + "date": "2026-09-22T04:26:53Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.23.0" + }, + { + "product": "core", + "tag": "v0.22.0", + "date": "2026-09-22T03:54:58Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.22.0" + }, + { + "product": "core", + "tag": "v0.21.0", + "date": "2026-09-22T03:32:46Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.21.0" + }, + { + "product": "core", + "tag": "v0.20.0", + "date": "2026-09-22T02:58:22Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.20.0" + }, + { + "product": "core", + "tag": "v0.19.0", + "date": "2026-09-21T18:23:52Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.19.0" + }, + { + "product": "core", + "tag": "v0.18.0", + "date": "2026-09-21T17:22:50Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.18.0" + }, + { + "product": "core", + "tag": "v0.17.2", + "date": "2026-09-21T14:11:09Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.17.2" + }, + { + "product": "core", + "tag": "v0.17.1", + "date": "2026-09-21T13:13:12Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.17.1" + }, + { + "product": "core", + "tag": "v0.17.0", + "date": "2026-09-21T12:46:51Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.17.0" + }, + { + "product": "core", + "tag": "v0.16.0", + "date": "2026-09-21T11:48:52Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.16.0" + }, + { + "product": "core", + "tag": "v0.15.0", + "date": "2026-09-21T09:19:01Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.15.0" + }, + { + "product": "core", + "tag": "v0.14.1", + "date": "2026-09-21T08:22:41Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.14.1" + }, + { + "product": "core", + "tag": "v0.14.0", + "date": "2026-09-21T06:31:56Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.14.0" + }, + { + "product": "core", + "tag": "v0.13.0", + "date": "2026-09-21T04:24:25Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.13.0" + }, + { + "product": "core", + "tag": "v0.12.0", + "date": "2026-09-21T02:16:08Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.12.0" + }, + { + "product": "core", + "tag": "v0.11.1", + "date": "2026-09-20T10:09:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.11.1" + }, + { + "product": "core", + "tag": "v0.11.0", + "date": "2026-09-20T09:03:14Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.11.0" + }, + { + "product": "core", + "tag": "v0.10.0", + "date": "2026-09-20T08:01:57Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.10.0" + }, + { + "product": "core", + "tag": "v0.9.1", + "date": "2026-09-20T02:06:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.9.1" + }, + { + "product": "core", + "tag": "v0.9.0", + "date": "2026-09-19T12:16:58Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.9.0" + }, + { + "product": "core", + "tag": "v0.8.2", + "date": "2026-09-19T11:07:09Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.8.2" + }, + { + "product": "core", + "tag": "v0.8.1", + "date": "2026-09-19T05:55:34Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.8.1" + }, + { + "product": "core", + "tag": "v0.8.0", + "date": "2026-09-19T05:01:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.8.0" + }, + { + "product": "core", + "tag": "v0.7.0", + "date": "2026-09-18T16:13:09Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.7.0" + }, + { + "product": "lite", + "tag": "v2026.9.7", + "date": "2026-09-18T14:25:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.7" + }, + { + "product": "lite", + "tag": "v2026.9.6", + "date": "2026-09-18T13:01:10Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.6" + }, + { + "product": "core", + "tag": "v0.6.0", + "date": "2026-09-17T19:18:41Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.6.0" + }, + { + "product": "core", + "tag": "v0.5.0", + "date": "2026-09-17T18:19:50Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.5.0" + }, + { + "product": "core", + "tag": "v0.4.0", + "date": "2026-09-17T14:04:48Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.4.0" + }, + { + "product": "core", + "tag": "v0.3.0", + "date": "2026-09-17T11:13:16Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.3.0" + }, + { + "product": "core", + "tag": "v0.2.2", + "date": "2026-09-17T07:42:56Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.2.2" + }, + { + "product": "core", + "tag": "v0.2.1", + "date": "2026-09-17T05:33:18Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.2.1" + }, + { + "product": "core", + "tag": "v0.2.0", + "date": "2026-09-17T05:07:12Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.2.0" + }, + { + "product": "core", + "tag": "v0.1.2", + "date": "2026-09-16T15:06:02Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.1.2" + }, + { + "product": "lite", + "tag": "v2026.9.5", + "date": "2026-09-16T14:44:25Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.5" + }, + { + "product": "lite", + "tag": "v2026.9.4", + "date": "2026-09-16T13:09:49Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.4" + }, + { + "product": "lite", + "tag": "v2026.9.3", + "date": "2026-09-16T11:32:59Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.3" + }, + { + "product": "lite", + "tag": "v2026.9.2", + "date": "2026-09-16T10:43:59Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.2" + }, + { + "product": "core", + "tag": "v0.1.1", + "date": "2026-09-16T10:36:40Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.1.1" + }, + { + "product": "lite", + "tag": "v2026.9.1", + "date": "2026-09-16T08:56:34Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.1" + }, + { + "product": "lite", + "tag": "v2026.9.0", + "date": "2026-09-16T07:58:13Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/tag/v2026.9.0" + }, + { + "product": "core", + "tag": "v0.1.0", + "date": "2026-09-16T06:44:51Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch-Core/releases/tag/v0.1.0" + }, + { + "product": "enterprise", + "tag": "v1.0.2", + "date": "2026-09-12T18:16:17Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.0.2" + }, + { + "product": "enterprise", + "tag": "v1.0.1", + "date": "2026-05-27T09:28:23Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.0.1" + }, + { + "product": "enterprise", + "tag": "v1.0.0", + "date": "2026-05-27T08:30:03Z", + "url": "https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v1.0.0" + } +] diff --git a/src/i18n/pages/changelog.ts b/src/i18n/pages/changelog.ts new file mode 100644 index 0000000..272e0c3 --- /dev/null +++ b/src/i18n/pages/changelog.ts @@ -0,0 +1,48 @@ +// Copy for the /changelog page, which lists the releases of all three products. + +export const changelogCopy = { + en: { + meta: { + title: "ThinkWatch release notes · Changelog", + description: + "Release notes for each version of ThinkWatch Enterprise, ThinkWatch Lite and ThinkWatch Core, listed from newest to oldest.", + breadcrumbHome: "Home", + breadcrumbPage: "Changelog", + }, + eyebrow: "Changelog", + titleA: "ThinkWatch ", + titleHighlight: "release notes", + intro: + "Release notes for each version of ThinkWatch Enterprise, ThinkWatch Lite and ThinkWatch Core, listed from newest to oldest. Subscribe via ", + rss: "RSS", + introEnd: ".", + filterLabel: "Filter release notes by product", + all: "All", + filters: { enterprise: "Enterprise", lite: "Lite", core: "Core" }, + github: "View on GitHub", + linkTo: "Link to", + dateLocale: "en-US", + dateFormat: { year: "numeric", month: "short", day: "numeric" }, + }, + "zh-CN": { + meta: { + title: "ThinkWatch 发布说明 · 更新日志", + description: "ThinkWatch 企业版、ThinkWatch Lite 与 ThinkWatch Core 各版本的发布说明,按时间倒序排列。", + breadcrumbHome: "首页", + breadcrumbPage: "更新日志", + }, + eyebrow: "更新日志", + titleA: "ThinkWatch ", + titleHighlight: "发布说明", + intro: "ThinkWatch 企业版、ThinkWatch Lite 与 ThinkWatch Core 各版本的发布说明,按时间倒序排列。可通过 ", + rss: "RSS", + introEnd: " 订阅。", + filterLabel: "按产品筛选发布说明", + all: "全部", + filters: { enterprise: "企业版", lite: "Lite", core: "Core" }, + github: "在 GitHub 上查看", + linkTo: "链接到", + dateLocale: "zh-CN", + dateFormat: { year: "numeric", month: "long", day: "numeric" }, + }, +} as const; diff --git a/src/i18n/pages/home.ts b/src/i18n/pages/home.ts index b7dac5a..ae5a7e3 100644 --- a/src/i18n/pages/home.ts +++ b/src/i18n/pages/home.ts @@ -3,6 +3,11 @@ export const homeCopy = { en: { + meta: { + title: "ThinkWatch — AI Gateways for Organizations and Individual Developers", + description: + "ThinkWatch provides AI gateways that route, inspect, and meter model requests and MCP tool calls: ThinkWatch, a self-hosted server for organizations, and ThinkWatch Lite, a desktop application for individual developers. Both are built on the MIT-licensed ThinkWatch Core.", + }, eyebrow: "AI API and MCP gateways", h1a: "AI gateways for", h1b: "organizations and individual developers", @@ -119,6 +124,11 @@ export const homeCopy = { }, }, "zh-CN": { + meta: { + title: "ThinkWatch — 面向组织与个人开发者的 AI 网关", + description: + "ThinkWatch 提供对模型请求与 MCP 工具调用进行路由、检查和计量的 AI 网关,包括面向组织的自托管服务端 ThinkWatch 和面向个人开发者的桌面应用 ThinkWatch Lite,二者均基于 MIT 许可证的 ThinkWatch Core。", + }, eyebrow: "AI API 与 MCP 网关", h1a: "面向组织与个人开发者的", h1b: "AI 网关", diff --git a/src/i18n/pages/lite.ts b/src/i18n/pages/lite.ts index 051eacc..40f9edd 100644 --- a/src/i18n/pages/lite.ts +++ b/src/i18n/pages/lite.ts @@ -30,31 +30,37 @@ export const liteCopy = { id: "overview", title: "Usage and cost", body: "Tokens, cost and requests over any period, broken down by model, with the cache hit rate, the net savings from caching and latency percentiles. Measured costs, estimated costs and unpriced requests stay separate and are never added together, and usage served by a subscription is counted apart from billed usage.", + alt: "The usage overview: tokens, cost and requests, a 24-hour trend stacked by model, the leaderboard by model and the cache hit rate", }, { id: "requests", title: "Routing and failover", body: "Every request records the rule it matched, the group it went through and each attempt with its status and duration. When a client and an upstream speak different API formats, the request is converted between Anthropic Messages, OpenAI Chat Completions, OpenAI Responses and Gemini, and the fields that could not be carried over are listed.", + alt: "The traffic page with a request open: it went to openrouter and was converted from Anthropic Messages to OpenAI Chat Completions, and the field that could not be carried over, messages.content.thinking, is listed as dropped", }, { id: "dry-run", title: "A dry run before the traffic", body: "The dry run evaluates the rules for a given request and shows where it would go and why — which rules did not match and for what reason — without sending anything and without cost.", + alt: "A routing dry run: the first two rules did not match and say why, the third one did, and the request goes to a group that tries two upstreams in order", }, { id: "upstreams", title: "Upstreams of every kind", body: "API keys, a ChatGPT account signed in from the app with its usage limits, relays such as OpenRouter, and local models. Upstreams can be reached through an outbound proxy and priced with a custom price sheet.", + alt: "The upstream list: API-key upstreams, a ChatGPT account on Plus with 34% of its 5-hour quota used, OpenRouter through a proxy, DeepSeek, Gemini and a local Ollama, each with its 24-hour requests, cost and time to first byte", }, { id: "findings", title: "What leaves the machine", body: "Secrets are replaced before a request reaches an untrusted upstream and restored in the response; tool calls that would grant code execution are cut off mid-stream; client configuration files are scanned for hidden characters and dangerous commands. Each defense runs in off, observe or enforce mode.", + alt: "Scan results: a hook that downloads and runs a remote script, zero-width characters hidden in a skill, an upstream whose share of high-risk responses is rising, and the MCP servers each client has configured", }, { id: "clients", title: "Clients in one click", body: "Claude Code, Codex CLI, opencode, Zed and Aider are pointed at the gateway from the app. The change is shown as a diff before anything is written, the original file is backed up, and it can be restored at any time.", + alt: "The clients page: Claude Code and Codex CLI pointed at the gateway with requests already received from both, opencode not yet connected, and the clients that have to be configured by hand", }, ], }, @@ -147,31 +153,37 @@ export const liteCopy = { id: "overview", title: "用量与费用", body: "按任意时间范围统计 token、费用与请求数,按模型分层,并给出缓存命中率、缓存带来的净节省与延迟分位。实测费用、估算费用与无法计价的请求分别列出,从不相加;订阅制上游的用量单独统计。", + alt: "ThinkWatch Lite 的用量概览:token、费用与请求数,按模型分层的 24 小时趋势,模型排行与缓存命中率", }, { id: "requests", title: "路由与故障转移", body: "每个请求都记录命中的规则、经过的策略组,以及每一次尝试的状态与耗时。客户端与上游的 API 格式不同时,请求在 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 与 Gemini 之间自动转换,无法转换的字段会逐一列出。", + alt: "流量页与一条请求的详情:请求转发至 openrouter,由 Anthropic Messages 转换为 OpenAI Chat Completions,无法转换的字段 messages.content.thinking 列为已丢弃", }, { id: "dry-run", title: "改规则之前先试算", body: "试算按给定的请求条件逐条匹配规则,说明请求会交给哪个上游、哪些规则未命中及其原因。不发出请求,也不产生费用。", + alt: "路由试算:前两条规则未命中及其原因,第三条规则命中,请求转发至策略组并按顺序尝试两个上游", }, { id: "upstreams", title: "各种形态的上游", body: "支持 API 密钥、在应用内登录并显示订阅额度的 ChatGPT 账号、OpenRouter 等中转服务,以及本机模型。上游可以经出站代理访问,也可以按自定义价目表计价。", + alt: "上游列表:API 密钥上游、ChatGPT 账号(Plus,5 小时额度已用 34%)、经代理访问的 OpenRouter、DeepSeek、Gemini 与本机 Ollama,以及各自 24 小时的请求数、费用与首字节延迟", }, { id: "findings", title: "出去的是什么", body: "请求发往不受信任的上游之前替换其中的密钥,并在响应中还原;上游返回的危险工具调用在流中被切断;客户端配置文件中的隐藏字符与危险命令会被扫描出来。三项防护各有关闭、观察、拦截三档。", + alt: "扫描结果:hook 中下载并执行远程脚本的命令、skill 中隐藏的零宽字符、某上游高危响应的比例上升,以及各客户端配置的 MCP server", }, { id: "clients", title: "客户端一键接管", body: "Claude Code、Codex CLI、opencode、Zed 与 Aider 可以在应用内一键指向网关。写入前先显示改动差异并完整备份原文件,随时可以还原。", + alt: "客户端页:已接管并收到请求的 Claude Code 与 Codex CLI、尚未接管的 opencode,以及需手动配置的客户端", }, ], }, diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 6bb5f54..45bb836 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -2,70 +2,40 @@ import "~/styles/global.css"; import { getLang, t } from "~/i18n"; import Seo from "~/components/Seo.astro"; -import { getLatestRelease } from "~/lib/github"; +import { ogImageAlt, ogImagePath, type OgCard } from "~/lib/og"; +import type { JsonLd } from "~/lib/structured-data"; const lang = getLang(Astro); const i18n = t(lang); -type JsonLdItem = Record; - interface Props { - title?: string; - description?: string; - ogImage?: string; - /** Suppress the default SoftwareApplication JSON-LD (e.g. on subpages where it would be redundant). */ - noJsonLd?: boolean; - /** Additional JSON-LD items to inject (BreadcrumbList, TechArticle, etc.) */ - jsonLd?: JsonLdItem[]; + title: string; + description: string; + /** The Open Graph card shared for this page (see src/lib/og.ts); the site card by default */ + ogCard?: OgCard; + /** "article" for documentation pages */ + ogType?: "website" | "article"; + /** For articles: first published and last changed, as ISO timestamps */ + published?: string; + modified?: string; + /** Keep the page out of search results (the 404 page) */ + noindex?: boolean; + /** JSON-LD items describing the page (a product, an article, breadcrumbs). The Organization is added on every page. */ + jsonLd?: JsonLd[]; } -const DEFAULT_TITLE = - lang === "zh-CN" - ? "ThinkWatch — 面向组织与个人开发者的 AI 网关" - : "ThinkWatch — AI Gateways for Organizations and Individual Developers"; - -const DEFAULT_DESCRIPTION = - lang === "zh-CN" - ? "ThinkWatch 提供对模型请求与 MCP 工具调用进行路由、检查和计量的 AI 网关,包括面向组织的自托管服务端 ThinkWatch 和面向个人开发者的桌面应用 ThinkWatch Lite,二者均基于 MIT 许可证的 ThinkWatch Core。" - : "ThinkWatch provides AI gateways that route, inspect, and meter model requests and MCP tool calls: ThinkWatch, a self-hosted server for organizations, and ThinkWatch Lite, a desktop application for individual developers. Both are built on the MIT-licensed ThinkWatch Core."; - const { - title = DEFAULT_TITLE, - description = DEFAULT_DESCRIPTION, - ogImage = "/og-image.png", - noJsonLd = false, - jsonLd: extraJsonLd = [], -} = Astro.props; - -const canonical = new URL(Astro.url.pathname, Astro.site).toString(); -const latestRelease = await getLatestRelease(); - -const softwareApplicationLd: JsonLdItem = { - "@context": "https://schema.org", - "@type": "SoftwareApplication", - name: "ThinkWatch", - applicationCategory: "DeveloperApplication", - operatingSystem: "Linux, macOS, Windows (via Docker / Kubernetes)", + title, description, - url: "https://thinkwat.ch", - downloadUrl: "https://github.com/ThinkWatchProject/ThinkWatch", - ...(latestRelease ? { softwareVersion: latestRelease.replace(/^v/, "") } : {}), - license: "https://github.com/ThinkWatchProject/ThinkWatch/blob/main/LICENSE", - offers: { - "@type": "Offer", - price: "0", - priceCurrency: "USD", - }, - author: { - "@type": "Organization", - name: "ThinkWatch Project", - url: "https://github.com/ThinkWatchProject", - }, -}; + ogCard = "home", + ogType = "website", + published, + modified, + noindex = false, + jsonLd = [], +} = Astro.props; -const jsonLd: JsonLdItem[] = noJsonLd - ? [...extraJsonLd] - : [softwareApplicationLd, ...extraJsonLd]; +const canonical = noindex ? undefined : new URL(Astro.url.pathname, Astro.site).toString(); const GA_ID = "G-R8LX8NCHCJ"; --- @@ -117,9 +87,14 @@ const GA_ID = "G-R8LX8NCHCJ"; diff --git a/src/layouts/DocsLayout.astro b/src/layouts/DocsLayout.astro index e203c6f..4a86ad3 100644 --- a/src/layouts/DocsLayout.astro +++ b/src/layouts/DocsLayout.astro @@ -14,12 +14,13 @@ import { type ProductId, } from "~/content/docs/_meta"; import { getLang } from "~/i18n"; +import { docsOgCard, type OgCard } from "~/lib/og"; type Heading = { depth: number; slug: string; text: string }; interface Props { title: string; - description?: string; + description: string; /** Product whose docs are shown in the sidebar and switcher */ product: ProductId; /** Slug of the currently displayed doc, e.g. "architecture"; "" for the product's docs home */ @@ -31,9 +32,29 @@ interface Props { next?: DocMeta; /** Extra JSON-LD items (BreadcrumbList, TechArticle, etc.) */ jsonLd?: Record[]; + /** Shared as an article (a doc) or as a website page (the documentation home) */ + ogType?: "website" | "article"; + /** The Open Graph card; the product's card by default */ + ogCard?: OgCard; + /** For articles: first published and last changed, as ISO timestamps */ + published?: string; + modified?: string; } -const { title, description, product, current, headings = [], prev, next, jsonLd = [] } = Astro.props; +const { + title, + description, + product, + current, + headings = [], + prev, + next, + jsonLd = [], + ogType = "article", + ogCard = docsOgCard[product], + published, + modified, +} = Astro.props; const lang = getLang(Astro); const zh = lang === "zh-CN"; const p = getProduct(product); @@ -61,7 +82,15 @@ const itemActive = "text-[var(--color-brand-1)] font-medium"; const itemIdle = "text-[var(--color-text)]/80 hover:text-[var(--color-text)]"; --- - + diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts new file mode 100644 index 0000000..7aaf5d3 --- /dev/null +++ b/src/lib/changelog.ts @@ -0,0 +1,55 @@ +// The changelog: every published release of the three products, read from +// GitHub at build time, with the release notes written for this site (the +// Enterprise releases up to v0.4.0, in src/content/changelog) attached to +// their version. Releases without notes here link to their page on GitHub. +import { getCollection, type CollectionEntry } from "astro:content"; +import { getProduct, productName } from "~/content/docs/_meta"; +import type { Lang } from "~/i18n"; +import { getReleases, type Product } from "~/lib/github"; + +export interface ChangelogEntry { + /** Anchor on the changelog page, e.g. "core-v0.47.0" */ + id: string; + product: Product; + /** The git tag, e.g. "v0.47.0" */ + tag: string; + date: Date; + /** The release on GitHub, when there is one */ + url?: string; + /** Release notes written for this site */ + notes?: CollectionEntry<"changelog"> | CollectionEntry<"changelog_zh">; +} + +/** "ThinkWatch Enterprise" / "ThinkWatch 企业版", "ThinkWatch Lite", "ThinkWatch Core" */ +export function releaseProductName(product: Product, lang: Lang): string { + return productName(getProduct(product === "enterprise" ? "thinkwatch" : product), lang); +} + +/** Newest first. Chinese notes where they exist, English otherwise. */ +export async function getChangelog(lang: Lang): Promise { + const notes = new Map(); + for (const entry of await getCollection("changelog")) notes.set(entry.data.version, entry); + if (lang === "zh-CN") { + for (const entry of await getCollection("changelog_zh")) notes.set(entry.data.version, entry); + } + + const releases = await getReleases(); + const entries: ChangelogEntry[] = releases.map((r) => ({ + id: `${r.product}-${r.tag}`, + product: r.product, + tag: r.tag, + date: new Date(r.date), + url: r.url, + notes: r.product === "enterprise" ? notes.get(r.tag.replace(/^v/, "")) : undefined, + })); + + // Notes for Enterprise versions that were never published as GitHub releases. + const onGitHub = new Set(releases.filter((r) => r.product === "enterprise").map((r) => r.tag.replace(/^v/, ""))); + for (const [version, entry] of notes) { + if (entry && !onGitHub.has(version)) { + entries.push({ id: `enterprise-v${version}`, product: "enterprise", tag: `v${version}`, date: entry.data.date, notes: entry }); + } + } + + return entries.sort((a, b) => b.date.getTime() - a.date.getTime()); +} diff --git a/src/lib/github.ts b/src/lib/github.ts index 9af672e..a3b03af 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -1,20 +1,20 @@ +import fallbackReleases from "../data/releases.json"; +import { fetchReleases, githubHeaders, productRepos, type Product, type Release } from "./releases.mjs"; + +export type { Product, Release }; + // Cached at module level so all imports during one build share the same fetch. // Falls back to null on failure (rate limit, network, repo missing) so callers // can render a graceful "—" instead of breaking the build. -const REPO = "ThinkWatchProject/ThinkWatch"; +const REPO = productRepos.enterprise; let cached: number | null | undefined; export async function getStarCount(): Promise { if (cached !== undefined) return cached; try { - const res = await fetch(`https://api.github.com/repos/${REPO}`, { - headers: { - "User-Agent": "thinkwatch-site-build", - Accept: "application/vnd.github+json", - }, - }); + const res = await fetch(`https://api.github.com/repos/${REPO}`, { headers: githubHeaders() }); if (!res.ok) { cached = null; return null; @@ -42,12 +42,7 @@ let releaseCached: string | null | undefined; export async function getLatestRelease(): Promise { if (releaseCached !== undefined) return releaseCached; - const headers: Record = { - "User-Agent": "thinkwatch-site-build", - Accept: "application/vnd.github+json", - }; - const token = typeof process !== "undefined" ? process.env.GITHUB_TOKEN : undefined; - if (token) headers.Authorization = `Bearer ${token}`; + const headers = githubHeaders(); try { const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers }); if (res.ok) { @@ -79,44 +74,61 @@ export async function getLatestRelease(): Promise { } } -// The latest ThinkWatch Lite release, fetched once per build. Asset names carry -// the version, so the download links are taken from this release's own asset -// list rather than built from a pattern. Returns null on any failure; the Lite -// page then links to the releases page instead. -export interface LiteRelease { +// The latest release of ThinkWatch Lite or ThinkWatch Core, fetched once per +// build. Asset names carry the version, so download links are taken from the +// release's own asset list rather than built from a pattern. Returns null on +// any failure; the pages then link to the releases page instead and leave the +// version out. +export interface ProductRelease { tag: string; /** Asset file name → its download URL */ assets: Record; } -let liteCached: LiteRelease | null | undefined; +const latestCache = new Map>(); -export async function getLatestLiteRelease(): Promise { - if (liteCached !== undefined) return liteCached; - const headers: Record = { - "User-Agent": "thinkwatch-site-build", - Accept: "application/vnd.github+json", - }; - const token = typeof process !== "undefined" ? process.env.GITHUB_TOKEN : undefined; - if (token) headers.Authorization = `Bearer ${token}`; - try { - const res = await fetch("https://api.github.com/repos/ThinkWatchProject/ThinkWatch-Lite/releases/latest", { - headers, - }); - if (!res.ok) { - liteCached = null; - return null; - } - const data: { tag_name?: string; assets?: { name?: string; browser_download_url?: string }[] } = - await res.json(); - const assets: Record = {}; - for (const a of data.assets ?? []) { - if (a.name && a.browser_download_url) assets[a.name] = a.browser_download_url; - } - liteCached = data.tag_name ? { tag: data.tag_name, assets } : null; - return liteCached; - } catch { - liteCached = null; - return null; +function getLatestProductRelease(repo: string): Promise { + let cached = latestCache.get(repo); + if (!cached) { + cached = (async () => { + try { + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { headers: githubHeaders() }); + if (!res.ok) return null; + const data: { tag_name?: string; assets?: { name?: string; browser_download_url?: string }[] } = + await res.json(); + const assets: Record = {}; + for (const a of data.assets ?? []) { + if (a.name && a.browser_download_url) assets[a.name] = a.browser_download_url; + } + return data.tag_name ? { tag: data.tag_name, assets } : null; + } catch { + return null; + } + })(); + latestCache.set(repo, cached); } + return cached; +} + +export const getLatestLiteRelease = () => getLatestProductRelease(productRepos.lite); +export const getLatestCoreRelease = () => getLatestProductRelease(productRepos.core); + +// Every published release of the three products, newest first, for the +// changelog and its feed. A product whose releases cannot be fetched falls back +// to src/data/releases.json (refreshed with `pnpm releases`), so a rate limit or +// an offline build still lists what was known when that file was written. +let releasesCached: Promise | undefined; + +export function getReleases(): Promise { + releasesCached ??= Promise.all( + (Object.keys(productRepos) as Product[]).map((product) => + fetchReleases(product).catch((err: unknown) => { + console.warn( + `[releases] ${err instanceof Error ? err.message : err}; using src/data/releases.json for ${product}`, + ); + return (fallbackReleases as Release[]).filter((r) => r.product === product); + }), + ), + ).then((lists) => lists.flat().sort((a, b) => b.date.localeCompare(a.date))); + return releasesCached; } diff --git a/src/lib/lastmod.ts b/src/lib/lastmod.ts new file mode 100644 index 0000000..f886b31 --- /dev/null +++ b/src/lib/lastmod.ts @@ -0,0 +1,109 @@ +// When a page last changed, taken from git history: the last commit that +// touched any of the files the page is built from. Used for in the +// sitemap and for the dates on documentation articles. +// +// A build time would claim that every page changed on every build, so without +// history (a shallow clone, no git) the dates are left out instead. The deploy +// workflow checks out the full history for this. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +interface FileDates { + /** First commit that touched the file, as an ISO timestamp */ + first: string; + /** Last commit that touched the file, as an ISO timestamp */ + last: string; +} + +let history: Map | undefined; + +function readHistory(): Map { + const files = new Map(); + try { + const git = (...args: string[]) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (git("rev-parse", "--is-shallow-repository").trim() === "true") { + console.warn("[lastmod] shallow clone: page dates are left out"); + return files; + } + // Newest commit first: the first date seen for a file is its last change, + // and each older one moves its first change back. + for (const commit of git("log", "--format=%x00%cI", "--name-only").split("\0").slice(1)) { + const [date, ...paths] = commit.trim().split("\n"); + const iso = new Date(date).toISOString(); + for (const path of paths) { + if (!path) continue; + const known = files.get(path); + if (known) known.first = iso; + else files.set(path, { first: iso, last: iso }); + } + } + } catch (err) { + console.warn(`[lastmod] no git history: ${err instanceof Error ? err.message : err}`); + } + return files; +} + +/** Dates of every committed file under the given files or folders, relative to the project root. */ +function datesOf(paths: string[]): FileDates[] { + history ??= readHistory(); + const found: FileDates[] = []; + for (const [file, dates] of history) { + if (paths.some((p) => file === p || file.startsWith(`${p}/`))) found.push(dates); + } + return found; +} + +/** When any of the given files or folders last changed, or undefined without history. */ +export function lastModified(paths: string[]): string | undefined { + return datesOf(paths).reduce((max, d) => (!max || d.last > max ? d.last : max), undefined); +} + +/** When the first of the given files or folders was added, or undefined without history. */ +export function firstPublished(paths: string[]): string | undefined { + return datesOf(paths).reduce((min, d) => (!min || d.first < min ? d.first : min), undefined); +} + +/** + * The files a page is built from, by its path (without a trailing slash). Only + * the content counts: the page's copy and its own components, not the shared + * layout, header or footer. + */ +export function pageSources(pathname: string): string[] { + const zh = pathname === "/zh-CN" || pathname.startsWith("/zh-CN/"); + const lang = zh ? "zh-CN" : "en"; + const path = zh ? pathname.slice("/zh-CN".length) || "/" : pathname; + // A doc without a translation is shown in English. + const doc = (dir: string, slug: string) => { + const own = `src/content/${dir}/${lang}/${slug}.md`; + return [existsSync(own) ? own : `src/content/${dir}/en/${slug}.md`]; + }; + + switch (path) { + case "/": + return ["src/components/pages/HomePage.astro", "src/components/home", "src/i18n/pages/home.ts"]; + case "/lite": + return ["src/components/pages/LitePage.astro", "src/i18n/pages/lite.ts", "src/components/LiteDownload.astro", "public/lite"]; + case "/core": + return ["src/components/pages/CorePage.astro", "src/i18n/pages/core.ts"]; + case "/thinkwatch": + return [ + "src/components/pages/ThinkWatchPage.astro", + "src/i18n/pages/thinkwatch.ts", + "src/components/sections", + "src/components/mocks", + // The sections below the hero still read their copy from the shared dictionary. + "src/i18n/index.ts", + ]; + case "/license": + return ["src/components/pages/LicensePage.astro", "src/i18n/pages/license.ts"]; + case "/changelog": + return ["src/components/pages/ChangelogPage.astro", "src/i18n/pages/changelog.ts", "src/content/changelog"]; + case "/docs": + return ["src/pages/docs/_DocsHome.astro", "src/content/docs/_meta.ts"]; + } + const product = path.match(/^\/docs\/(lite|core)(?:\/([^/]+))?$/); + if (product) return doc(`docs-${product[1]}`, product[2] ?? "overview"); + const guide = path.match(/^\/docs\/([^/]+)$/); + if (guide) return doc("docs", guide[1]); + return []; +} diff --git a/src/lib/og.ts b/src/lib/og.ts new file mode 100644 index 0000000..5889b88 --- /dev/null +++ b/src/lib/og.ts @@ -0,0 +1,226 @@ +// Open Graph images: a 1200×630 card for the site and one for each product, +// rendered at build time by src/pages/og/[card].png.ts. +// +// A card carries the product name, the headline of the product's page and the +// platforms the product runs on, and nothing else. The text comes from the page +// copy, so a card changes when its page does. Cards are drawn in English (the +// name and platforms are the same in both languages); og:image:alt gives the +// same text in the page's language. +// +// Text is drawn as outlines from the Geist font files in node_modules, so the +// image does not depend on the fonts installed on the machine that builds it: +// the same commit gives the same card locally and in CI. +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { create, type Font } from "fontkit"; +import sharp from "sharp"; +import { getProduct, productName, type ProductId } from "~/content/docs/_meta"; +import type { Lang } from "~/i18n"; +import { coreCopy } from "~/i18n/pages/core"; +import { homeCopy } from "~/i18n/pages/home"; +import { liteCopy } from "~/i18n/pages/lite"; +import { thinkwatchCopy } from "~/i18n/pages/thinkwatch"; + +export const ogCards = ["home", "lite", "core", "enterprise"] as const; +export type OgCard = (typeof ogCards)[number]; + +export const OG_WIDTH = 1200; +export const OG_HEIGHT = 630; + +export const ogImagePath = (card: OgCard) => `/og/${card}.png`; + +/** The card each product's documentation is shared with */ +export const docsOgCard: Record = { thinkwatch: "enterprise", lite: "lite", core: "core" }; + +interface CardText { + name: string; + line: string; + labels: string[]; +} + +/** Where each product runs. The site card lists the products instead. */ +const platforms: Record, Record> = { + lite: { en: ["macOS", "Windows", "Linux"], "zh-CN": ["macOS", "Windows", "Linux"] }, + core: { en: ["Linux server", "macOS", "Windows"], "zh-CN": ["Linux 服务器", "macOS", "Windows"] }, + enterprise: { en: ["Docker Compose", "Kubernetes"], "zh-CN": ["Docker Compose", "Kubernetes"] }, +}; + +export function cardText(card: OgCard, lang: Lang): CardText { + switch (card) { + case "home": { + const c = homeCopy[lang]; + return { + name: "ThinkWatch", + line: `${c.h1a} ${c.h1b}`, + labels: (["thinkwatch", "lite", "core"] as const).map((id) => productName(getProduct(id), lang)), + }; + } + case "lite": + return { name: "ThinkWatch Lite", line: headline(liteCopy[lang].hero), labels: platforms.lite[lang] }; + case "core": + return { name: "ThinkWatch Core", line: headline(coreCopy[lang].hero), labels: platforms.core[lang] }; + case "enterprise": + return { + name: productName(getProduct("thinkwatch"), lang), + line: headline(thinkwatchCopy[lang].hero), + labels: platforms.enterprise[lang], + }; + } +} + +/** A product page's h1, which is split in two for the highlight */ +const headline = (hero: { titleA: string; titleHighlight: string }) => `${hero.titleA}${hero.titleHighlight}`; + +/** og:image:alt: the card's text, in the page's language */ +export function ogImageAlt(card: OgCard, lang: Lang): string { + const { name, line, labels } = cardText(card, lang); + return lang === "zh-CN" ? `${name}:${line}(${labels.join("、")})` : `${name}: ${line} (${labels.join(", ")})`; +} + +// ---------- Rendering ---------- + +// Resolved from the project root: the endpoint runs from a bundled chunk. +const require = createRequire(join(process.cwd(), "package.json")); +const fonts = new Map(); + +function font(file: string): Font { + let f = fonts.get(file); + if (!f) { + f = create(readFileSync(require.resolve(file))) as Font; + fonts.set(file, f); + } + return f; +} + +const sans = (weight: 400 | 600) => font(`@fontsource/geist/files/geist-latin-${weight}-normal.woff2`); +const mono = () => font("@fontsource/geist-mono/files/geist-mono-latin-400-normal.woff2"); + +/** Width of a run of text in pixels, with `tracking` pixels added between glyphs */ +function measure(f: Font, text: string, size: number, tracking = 0): number { + const run = f.layout(text); + const scale = size / f.unitsPerEm; + const advance = run.positions.reduce((sum, p) => sum + p.xAdvance * scale, 0); + return advance + tracking * Math.max(0, run.glyphs.length - 1); +} + +/** SVG path data for a run of text whose baseline starts at (x, y) */ +function outline(f: Font, text: string, size: number, x: number, y: number, tracking = 0): string { + const run = f.layout(text); + const scale = size / f.unitsPerEm; + let d = ""; + run.glyphs.forEach((glyph, i) => { + const p = run.positions[i]; + d += glyph.path.scale(scale, -scale).translate(x + p.xOffset * scale, y - p.yOffset * scale).toSVG(); + x += p.xAdvance * scale + tracking; + }); + return d; +} + +/** Greedy word wrap */ +function wrap(f: Font, text: string, size: number, width: number): string[] { + const lines: string[] = []; + let line = ""; + for (const word of text.split(" ")) { + const next = line ? `${line} ${word}` : word; + if (line && measure(f, next, size) > width) { + lines.push(line); + line = word; + } else { + line = next; + } + } + if (line) lines.push(line); + return lines; +} + +const colors = { + bg: "#0b1220", + text: "#e6ebf2", + sub: "#cbd5e1", + brand: "#3ddbd9", + dim: "#8a96ab", + borderStrong: "#2a3a63", + grid: "#94a3b8", +}; + +/** The logo, placed as a nested SVG */ +function logo(x: number, y: number, size: number): string { + const svg = readFileSync(join(process.cwd(), "public", "favicon.svg"), "utf8"); + return svg.replace(" { + const { name, line, labels } = cardText(card, "en"); + const W = OG_WIDTH; + const H = OG_HEIGHT; + const X = 80; + const width = W - 2 * X; + + // Name: shrink only if a longer name ever needs it. + let nameSize = 88; + while (measure(sans(600), name, nameSize, -0.025 * nameSize) > width) nameSize -= 2; + const nameY = 304; + + // Headline: at most two lines, smaller type before a third line. + let lineSize = 38; + let lines = wrap(sans(400), line, lineSize, width); + while (lines.length > 2 && lineSize > 30) { + lineSize -= 2; + lines = wrap(sans(400), line, lineSize, width); + } + if (lines.length > 2) throw new Error(`[og] the headline of "${card}" does not fit on the card: ${line}`); + const lineY = nameY + 70; + const lineGap = Math.round(lineSize * 1.32); + + // Labels along the bottom, separated by middle dots. + const labelSize = 26; + const labelY = H - 76; + const sep = " · "; + let labelX = X; + let labelPath = ""; + let sepPath = ""; + labels.forEach((label, i) => { + if (i > 0) { + sepPath += outline(mono(), sep, labelSize, labelX, labelY); + labelX += measure(mono(), sep, labelSize); + } + labelPath += outline(mono(), label, labelSize, labelX, labelY); + labelX += measure(mono(), label, labelSize); + }); + if (labelX > X + width) throw new Error(`[og] the labels of "${card}" do not fit on the card`); + + // Background as on the site: the brand navy, a teal glow from the top left, + // and the faint grid fading out towards the edges. + const svg = ` + + + + + + + + + + + + + + + + + + + + + + + ${logo(X, 72, 72)} + + ${lines.map((l, i) => ``).join("\n ")} + + +`; + + return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer(); +} diff --git a/src/lib/releases.mjs b/src/lib/releases.mjs new file mode 100644 index 0000000..13e5d74 --- /dev/null +++ b/src/lib/releases.mjs @@ -0,0 +1,63 @@ +// GitHub releases of the three products, read at build time for the changelog +// and its RSS feed. Plain JavaScript so that scripts/update-releases.mjs can +// use it outside the site build to refresh src/data/releases.json, the copy the +// build falls back to when GitHub cannot be reached (see getReleases() in +// ./github.ts). + +/** @typedef {"enterprise" | "lite" | "core"} Product */ + +/** + * @typedef {object} Release + * @property {Product} product + * @property {string} tag The git tag, e.g. "v0.47.0" + * @property {string} date When the release was published, as an ISO timestamp + * @property {string} url The release page on GitHub + */ + +/** @type {Record} */ +export const productRepos = { + enterprise: "ThinkWatchProject/ThinkWatch", + lite: "ThinkWatchProject/ThinkWatch-Lite", + core: "ThinkWatchProject/ThinkWatch-Core", +}; + +/** Request headers for the GitHub API; GITHUB_TOKEN (set in CI) lifts the anonymous rate limit. */ +export function githubHeaders() { + /** @type {Record} */ + const headers = { + "User-Agent": "thinkwatch-site-build", + Accept: "application/vnd.github+json", + }; + const token = typeof process !== "undefined" ? process.env.GITHUB_TOKEN : undefined; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +/** + * Every published release of one product, newest first. Drafts and + * pre-releases are left out. Throws when GitHub cannot be reached or answers + * with an error, so that the caller can fall back to the committed copy. + * + * @param {Product} product + * @returns {Promise} + */ +export async function fetchReleases(product) { + const repo = productRepos[product]; + /** @type {Release[]} */ + const releases = []; + for (let page = 1; ; page++) { + const res = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, { + headers: githubHeaders(), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`GitHub answered ${res.status} for the releases of ${repo}`); + /** @type {{ tag_name: string, html_url: string, published_at: string | null, draft: boolean, prerelease: boolean }[]} */ + const list = await res.json(); + for (const r of list) { + if (r.draft || r.prerelease || !r.published_at) continue; + releases.push({ product, tag: r.tag_name, date: r.published_at, url: r.html_url }); + } + if (list.length < 100) break; + } + return releases.sort((a, b) => b.date.localeCompare(a.date)); +} diff --git a/src/lib/structured-data.ts b/src/lib/structured-data.ts new file mode 100644 index 0000000..7aa4069 --- /dev/null +++ b/src/lib/structured-data.ts @@ -0,0 +1,228 @@ +// Schema.org descriptions (JSON-LD) of the organization and the three products. +// The product pages and the home page build theirs from here, so a product is +// described the same way wherever it appears. Descriptions and feature lists +// come from the page copy; versions and download links from the latest GitHub +// release, read at build time (left out when GitHub cannot be reached). +// +// Every entity has an @id, the same on the English and the Chinese page, so +// that entities can refer to each other (author → the organization, source +// code → the application built from it). +import { localePath, type Lang } from "~/i18n"; +import { coreCopy } from "~/i18n/pages/core"; +import { liteCopy } from "~/i18n/pages/lite"; +import { thinkwatchCopy } from "~/i18n/pages/thinkwatch"; +import { getLatestCoreRelease, getLatestLiteRelease, getLatestRelease } from "~/lib/github"; +import { ogImagePath } from "~/lib/og"; + +export type JsonLd = Record; + +const SITE = "https://thinkwat.ch"; +const GITHUB = "https://github.com/ThinkWatchProject"; +const MIT = "https://opensource.org/licenses/MIT"; +const CONTEXT = "https://schema.org"; + +const abs = (path: string) => new URL(path, SITE).toString(); +/** A page's canonical URL, with the trailing slash the site is served with */ +const pageUrl = (lang: Lang, path: string) => abs(localePath(lang, path).replace(/\/?$/, "/")); + +const ids = { + organization: `${SITE}/#organization`, + website: `${SITE}/#website`, + enterprise: `${SITE}/thinkwatch/#software`, + lite: `${SITE}/lite/#software`, + liteSource: `${SITE}/lite/#source-code`, + twcore: `${SITE}/core/#twcore`, + coreSource: `${SITE}/core/#source-code`, +}; +/** The organization as an author or publisher: a reference, named for readers that do not follow @id. */ +export const organizationRef: JsonLd = { "@type": "Organization", "@id": ids.organization, name: "ThinkWatch", url: SITE }; +const byOrganization = { author: organizationRef, publisher: organizationRef }; +const free = { "@type": "Offer", price: 0, priceCurrency: "USD" }; + +/** Platforms of the Lite downloads and the prebuilt twcore binaries, which match. */ +const processors: Record = { + en: "Apple Silicon (arm64) on macOS; x64 or ARM64 on Windows; x86_64 or aarch64 on Linux", + "zh-CN": "macOS:Apple Silicon(arm64);Windows:x64 或 ARM64;Linux:x86_64 或 aarch64", +}; + +/** On every page. */ +export const organization: JsonLd = { + "@context": CONTEXT, + "@type": "Organization", + "@id": ids.organization, + name: "ThinkWatch", + url: SITE, + logo: `${SITE}/icon.svg`, + sameAs: [ + GITHUB, + `${GITHUB}/ThinkWatch`, + `${GITHUB}/ThinkWatch-Lite`, + `${GITHUB}/ThinkWatch-Core`, + `${GITHUB}/homebrew-tap`, + ], + description: + "ThinkWatch builds AI gateways: ThinkWatch Enterprise for organizations, ThinkWatch Lite for individual developers, and the shared MIT-licensed ThinkWatch Core.", +}; + +/** On the home page. */ +export const website: JsonLd = { + "@context": CONTEXT, + "@type": "WebSite", + "@id": ids.website, + name: "ThinkWatch", + url: SITE, + inLanguage: ["en", "zh-CN"], + publisher: organizationRef, +}; + +/** A page that is not about one product: its title, description and place in the site. */ +export function webPage(lang: Lang, path: string, name: string, description: string): JsonLd { + return { + "@context": CONTEXT, + "@type": "WebPage", + name, + description, + url: pageUrl(lang, path), + inLanguage: lang, + isPartOf: { "@id": ids.website, "@type": "WebSite", name: "ThinkWatch", url: SITE }, + }; +} + +/** ThinkWatch Enterprise, the self-hosted server. */ +export async function enterpriseLd(lang: Lang): Promise { + const tag = await getLatestRelease(); + const url = pageUrl(lang, "/thinkwatch"); + return [ + { + "@context": CONTEXT, + "@type": "SoftwareApplication", + "@id": ids.enterprise, + name: "ThinkWatch Enterprise", + alternateName: "ThinkWatch", + description: thinkwatchCopy[lang].meta.description, + applicationCategory: "DeveloperApplication", + operatingSystem: "Linux, macOS, Windows (via Docker / Kubernetes)", + url, + installUrl: `${url}#quickstart`, + downloadUrl: `${GITHUB}/ThinkWatch/releases/latest`, + ...(tag ? { softwareVersion: tag.replace(/^v/, "") } : {}), + image: abs(ogImagePath("enterprise")), + license: `${GITHUB}/ThinkWatch/blob/main/LICENSE`, + offers: free, + ...byOrganization, + }, + ]; +} + +/** ThinkWatch Lite: the desktop application, and its source code. */ +export async function liteLd(lang: Lang): Promise { + const c = liteCopy[lang]; + const release = await getLatestLiteRelease(); + const url = pageUrl(lang, "/lite"); + const repo = `${GITHUB}/ThinkWatch-Lite`; + // The installers of the latest release: disk image, Windows setup, AppImage. + const installers = Object.entries(release?.assets ?? {}) + .filter(([name]) => /(\.dmg|-setup\.exe|\.AppImage)$/.test(name)) + .map(([, href]) => href); + // Screenshots in the page's language; the English ones carry "-en", as on LitePage.astro. + const screenshot = c.features.items.map((item) => ({ + "@type": "ImageObject", + url: abs(`/lite/${item.id}${lang === "zh-CN" ? "" : "-en"}.webp`), + caption: item.alt, + })); + return [ + { + "@context": CONTEXT, + "@type": "SoftwareApplication", + "@id": ids.lite, + name: "ThinkWatch Lite", + description: c.meta.description, + applicationCategory: "DeveloperApplication", + operatingSystem: "macOS, Windows, Linux", + processorRequirements: processors[lang], + ...(release ? { softwareVersion: release.tag.replace(/^v/, "") } : {}), + url, + installUrl: `${url}#install`, + downloadUrl: installers.length ? installers : `${repo}/releases/latest`, + image: abs(ogImagePath("lite")), + screenshot, + // The languages of the interface. + inLanguage: ["en", "zh-CN"], + featureList: c.features.items.map((item) => item.title), + license: MIT, + offers: free, + ...byOrganization, + }, + { + "@context": CONTEXT, + "@type": "SoftwareSourceCode", + "@id": ids.liteSource, + name: "ThinkWatch Lite", + description: c.meta.description, + codeRepository: repo, + programmingLanguage: ["TypeScript", "Rust"], + license: MIT, + url, + targetProduct: { "@id": ids.lite }, + author: organizationRef, + }, + ]; +} + +/** ThinkWatch Core: the crates, and the twcore binary built from them. */ +export async function coreLd(lang: Lang): Promise { + const c = coreCopy[lang]; + const release = await getLatestCoreRelease(); + const url = pageUrl(lang, "/core"); + const repo = `${GITHUB}/ThinkWatch-Core`; + // The prebuilt binaries of the latest release, without checksums and tarballs. + const binaries = Object.entries(release?.assets ?? {}) + .filter(([name]) => name.startsWith("twcore-") && !/\.(sha256|tar\.gz)$/.test(name)) + .map(([, href]) => href); + // "twcore · a complete, self-contained gateway binary" → the part after the name. + const binary = c.hero.cardTitle.split(" · ").pop() ?? c.hero.cardTitle; + return [ + { + "@context": CONTEXT, + "@type": "SoftwareSourceCode", + "@id": ids.coreSource, + name: "ThinkWatch Core", + description: c.meta.description, + codeRepository: repo, + programmingLanguage: "Rust", + license: MIT, + url, + targetProduct: { "@id": ids.twcore }, + author: organizationRef, + }, + { + "@context": CONTEXT, + "@type": "SoftwareApplication", + "@id": ids.twcore, + name: "twcore", + description: binary.charAt(0).toUpperCase() + binary.slice(1), + applicationCategory: "DeveloperApplication", + operatingSystem: "Linux, macOS, Windows", + processorRequirements: processors[lang], + ...(release ? { softwareVersion: release.tag.replace(/^v/, "") } : {}), + url, + downloadUrl: binaries.length ? binaries : `${repo}/releases/latest`, + image: abs(ogImagePath("core")), + license: MIT, + offers: free, + ...byOrganization, + }, + ]; +} + +/** Home › page, for pages one level below the home page */ +export function breadcrumbs(lang: Lang, path: string, home: string, name: string): JsonLd { + return { + "@context": CONTEXT, + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: home, item: pageUrl(lang, "/") }, + { "@type": "ListItem", position: 2, name, item: pageUrl(lang, path) }, + ], + }; +} diff --git a/src/pages/404.astro b/src/pages/404.astro index eedbf8d..395504d 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -19,7 +19,7 @@ const links = [ ]; --- - +
b.data.date.getTime() - a.data.date.getTime(), - ); + const entries = await getChangelog("en"); + const page = new URL("/changelog/", context.site).href; return rss({ - title: "ThinkWatch Changelog", - description: "Release notes and updates for ThinkWatch.", - site: context.site!, - items: entries.map((entry) => ({ - title: `v${entry.data.version} — ${entry.data.title}`, - pubDate: entry.data.date, - description: entry.data.highlights?.join(" · ") ?? entry.data.title, - link: `/changelog#v${entry.data.version}`, - })), + title: "ThinkWatch release notes", + description: changelogCopy.en.meta.description, + // The channel links to the changelog page. + site: page, + customData: "en", + items: entries.map((entry) => { + const name = releaseProductName(entry.product, "en"); + const notes = entry.notes?.data; + return { + title: notes ? `${name} ${entry.tag} — ${notes.title}` : `${name} ${entry.tag}`, + pubDate: entry.date, + link: notes ? `${page}#${entry.id}` : entry.url!, + ...(notes ? { description: notes.highlights?.join(" · ") ?? notes.title } : {}), + categories: [name], + }; + }), }); } diff --git a/src/pages/changelog/index.astro b/src/pages/changelog/index.astro index 4126811..70e135e 100644 --- a/src/pages/changelog/index.astro +++ b/src/pages/changelog/index.astro @@ -1,169 +1,6 @@ --- -// ThinkWatch (server edition) release notes, English. -// Keep the markup in sync with src/pages/zh-CN/changelog/index.astro. -import { getCollection, render } from "astro:content"; -import Base from "~/layouts/Base.astro"; -import SiteHeader from "~/components/SiteHeader.astro"; -import SiteFooter from "~/components/SiteFooter.astro"; - -const entries = (await getCollection("changelog")).sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime(), -); - -const typeStyles: Record = { - release: "border-brand-1/40 bg-brand-1/10 text-brand-1", - feature: "border-white/15 bg-white/5 text-[var(--color-text)]", - fix: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300", - security: "border-accent/40 bg-accent/10 text-accent", -}; - -const fmt = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }); - -const filterTypes = [ - { id: "all", label: "All" }, - { id: "release", label: "Release" }, - { id: "feature", label: "Feature" }, - { id: "fix", label: "Fix" }, - { id: "security", label: "Security" }, -]; +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ChangelogPage from "~/components/pages/ChangelogPage.astro"; --- - - -
-
-
- Changelog -
-

- ThinkWatch release notes -

-

- Release notes for each ThinkWatch version, the self-hosted gateway for organizations, listed from newest to oldest. Subscribe via - RSS. -

-
- -
- {filterTypes.map((f, i) => ( - - ))} -
- -
    - {entries.map(async (entry) => { - const { Content } = await render(entry); - return ( -
  1. - - -
    - v{entry.data.version} - - {entry.data.type} - - -
    - -

    {entry.data.title}

    - - {entry.data.highlights && ( -
      - {entry.data.highlights.map((h) => ( -
    • - - {h} -
    • - ))} -
    - )} - -
    - -
    -
  2. - ); - })} -
-
- - - - - - + diff --git a/src/pages/docs/_DocArticle.astro b/src/pages/docs/_DocArticle.astro index baf6ba4..f48023a 100644 --- a/src/pages/docs/_DocArticle.astro +++ b/src/pages/docs/_DocArticle.astro @@ -6,6 +6,9 @@ import { render } from "astro:content"; import DocsLayout from "~/layouts/DocsLayout.astro"; import { docHref, getNeighbours, getProduct, productHomeHref, type ProductId } from "~/content/docs/_meta"; import { localePath, type Lang } from "~/i18n"; +import { firstPublished, lastModified } from "~/lib/lastmod"; +import { docsOgCard, ogImagePath } from "~/lib/og"; +import { organizationRef } from "~/lib/structured-data"; import type { DocsEntry } from "./_lib"; interface Props { @@ -27,6 +30,11 @@ const docLabel = meta?.label[lang] ?? slug; const title = `${docLabel} · ${productName(p, lang)} ${zh ? "文档" : "Docs"}`; const description = meta?.summary?.[lang] ?? p.tagline[lang]; +// From git history: when the markdown file was added and last changed. +const sources = entry.filePath ? [entry.filePath] : []; +const published = firstPublished(sources); +const modified = lastModified(sources); + const site = Astro.site ?? new URL("https://thinkwat.ch"); const abs = (path: string) => new URL(path.endsWith("/") ? path : `${path}/`, site).toString(); const pageUrl = new URL(Astro.url.pathname, site).toString(); @@ -51,13 +59,13 @@ const jsonLd: Record[] = [ description, inLanguage: lang, url: pageUrl, - about: { "@type": "SoftwareApplication", name: productName(p, lang) }, - author: { "@type": "Organization", name: "ThinkWatch Project", url: "https://github.com/ThinkWatchProject" }, - publisher: { - "@type": "Organization", - name: "ThinkWatch", - logo: { "@type": "ImageObject", url: "https://thinkwat.ch/icon.svg" }, - }, + ...(published ? { datePublished: published } : {}), + ...(modified ? { dateModified: modified } : {}), + image: new URL(ogImagePath(docsOgCard[product]), site).toString(), + // The product pages are /thinkwatch, /lite and /core. + about: { "@type": "SoftwareApplication", name: productName(p, lang), url: abs(localePath(lang, `/${p.id}`)) }, + author: organizationRef, + publisher: organizationRef, }, ]; @@ -74,6 +82,8 @@ const homeCards = slug ? [] : p.docs.filter((d) => d.slug); prev={prev} next={next} jsonLd={jsonLd} + published={published} + modified={modified} > diff --git a/src/pages/docs/_DocsHome.astro b/src/pages/docs/_DocsHome.astro index 39af6cc..5b92004 100644 --- a/src/pages/docs/_DocsHome.astro +++ b/src/pages/docs/_DocsHome.astro @@ -36,7 +36,7 @@ const copy = zh }; --- - +

{copy.h1}

{copy.intro}

diff --git a/src/pages/og/[card].png.ts b/src/pages/og/[card].png.ts new file mode 100644 index 0000000..d4da3e5 --- /dev/null +++ b/src/pages/og/[card].png.ts @@ -0,0 +1,11 @@ +// The Open Graph images, one PNG per card, rendered when the site is built. +// What goes on a card, and how it is drawn, is in src/lib/og.ts. +import type { APIRoute, GetStaticPaths } from "astro"; +import { ogCards, renderOgImage, type OgCard } from "~/lib/og"; + +export const getStaticPaths = (() => ogCards.map((card) => ({ params: { card } }))) satisfies GetStaticPaths; + +export const GET: APIRoute = async ({ params }) => { + const png = await renderOgImage(params.card as OgCard); + return new Response(new Uint8Array(png), { headers: { "Content-Type": "image/png" } }); +}; diff --git a/src/pages/zh-CN/changelog/index.astro b/src/pages/zh-CN/changelog/index.astro index b628c18..70e135e 100644 --- a/src/pages/zh-CN/changelog/index.astro +++ b/src/pages/zh-CN/changelog/index.astro @@ -1,175 +1,6 @@ --- -// ThinkWatch (server edition) release notes, Chinese. -// Keep the markup in sync with src/pages/changelog/index.astro. -import { getCollection, render } from "astro:content"; -import Base from "~/layouts/Base.astro"; -import SiteHeader from "~/components/SiteHeader.astro"; -import SiteFooter from "~/components/SiteFooter.astro"; - -// Use zh-CN entries where available, fall back to English for missing ones. -const zhEntries = await getCollection("changelog_zh"); -const enEntries = await getCollection("changelog"); -const zhVersions = new Set(zhEntries.map((e) => e.data.version)); -const fallback = enEntries.filter((e) => !zhVersions.has(e.data.version)); -const entries = [...zhEntries, ...fallback].sort( - (a, b) => b.data.date.getTime() - a.data.date.getTime(), -); - -const typeStyles: Record = { - release: "border-brand-1/40 bg-brand-1/10 text-brand-1", - feature: "border-white/15 bg-white/5 text-[var(--color-text)]", - fix: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300", - security: "border-accent/40 bg-accent/10 text-accent", -}; - -const fmt = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long", day: "numeric" }); - -const filterTypes = [ - { id: "all", label: "全部" }, - { id: "release", label: "发布" }, - { id: "feature", label: "新功能" }, - { id: "fix", label: "修复" }, - { id: "security", label: "安全" }, -]; +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ChangelogPage from "~/components/pages/ChangelogPage.astro"; --- - - -
-
-
- 更新日志 -
-

- ThinkWatch 发布说明 -

-

- ThinkWatch(面向组织的自托管网关)各已发布版本的说明,按时间倒序排列。可通过 - RSS - 订阅。 -

-
- -
- {filterTypes.map((f, i) => ( - - ))} -
- -
    - {entries.map(async (entry) => { - const { Content } = await render(entry); - return ( -
  1. - - -
    - v{entry.data.version} - - {entry.data.type} - - -
    - -

    {entry.data.title}

    - - {entry.data.highlights && ( -
      - {entry.data.highlights.map((h) => ( -
    • - - {h} -
    • - ))} -
    - )} - -
    - -
    -
  2. - ); - })} -
-
- - - - - - + From 6b963b6aa1cd29b9980853977722f8daa68a2332 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:59:53 +0800 Subject: [PATCH 2/4] ci: build the site for every pull request The deploy workflow only runs on main, so a change that breaks the build was first seen when it failed to deploy. The share images now fail the build when a headline no longer fits, which is worth catching in review. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5e1a2a5 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,45 @@ +name: Check + +# Builds the site for every pull request, the same way the deploy workflow does, +# so that a change that breaks the build (a share-image headline that no longer +# fits, a page that fails to render) shows up before it reaches main. + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # The sitemap's and the dates on doc pages come from git history. + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build site + run: pnpm build + env: + GITHUB_TOKEN: ${{ github.token }} From a26ebc77d51cacacc3e5b26cd223440ca5b5c8e1 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:57:28 +0800 Subject: [PATCH 3/4] fix(seo): keep the old changelog anchors and feed guids, date pages by their releases - The changelog keeps the anchors it had when it listed Enterprise alone (#v0.4.0): each Enterprise entry carries one next to its new #enterprise-v0.4.0 anchor. The four Enterprise items that were already in the feed keep their old link and guid (/changelog#v0.4.0), so feed readers do not show them again. - A page's also counts the latest release of the products it shows: Lite for /lite, Core for /core, Enterprise for /thinkwatch, and all three for the home page (the Lite download, and the structured data of every product) and for the changelog. The home page's sources now include the hero, the terminal, the console mockups and the Lite download button. - deploy.yml: the build job, which runs npm packages with GITHUB_TOKEN set, gets contents: read only; pages: write and id-token: write move to the deploy job. - og:image:alt leaves out labels the headline already names, so the Lite alt no longer lists the platforms twice. - The twcore description in the structured data has its own copy field instead of being cut out of the Core card title. - The breadcrumbs of the Enterprise guides no longer list /docs twice (Documentation and ThinkWatch Enterprise are the same page). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 13 +++++-- astro.config.mjs | 13 +++++-- src/components/pages/ChangelogPage.astro | 2 + src/i18n/pages/core.ts | 4 ++ src/lib/changelog.ts | 36 ++++++++++++----- src/lib/lastmod.ts | 49 +++++++++++++++++++++--- src/lib/og.ts | 14 +++++-- src/lib/structured-data.ts | 4 +- src/pages/changelog.xml.ts | 7 +++- src/pages/docs/_DocArticle.astro | 5 ++- 10 files changed, 117 insertions(+), 30 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a23a3f0..22232ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,10 +10,10 @@ on: schedule: - cron: "41 */3 * * *" -permissions: - contents: read - pages: write - id-token: write +# Each job gets only what it needs (below). The build runs third-party code from +# npm with GITHUB_TOKEN in its environment, so it may only read; the Pages +# permissions belong to the deploy job alone. +permissions: {} concurrency: group: pages @@ -22,6 +22,8 @@ concurrency: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 @@ -59,6 +61,9 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + permissions: + pages: write # to deploy to GitHub Pages + id-token: write # to verify that the deployment comes from this workflow environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/astro.config.mjs b/astro.config.mjs index 52ac24d..86da13a 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,7 +5,7 @@ import sitemap, { ChangeFreqEnum } from '@astrojs/sitemap'; import tailwindcss from '@tailwindcss/vite'; import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; -import { lastModified, pageSources } from './src/lib/lastmod.ts'; +import { lastModified, pageReleases, pageSources } from './src/lib/lastmod.ts'; import { getReleases } from './src/lib/github.ts'; // https://astro.build/config @@ -62,10 +62,15 @@ export default defineConfig({ item.changefreq = ChangeFreqEnum.MONTHLY; } - // When the page's own files last changed in git; the changelog also - // changes whenever a product is released. Left out when unknown. + // When the page's own files last changed in git, or the latest release + // of a product the page shows came out, whichever is later. Left out + // when unknown. const dates = [lastModified(pageSources(pathname))]; - if (isChangelog) dates.push((await getReleases())[0]?.date); + const products = pageReleases(pathname); + if (products.length) { + // Newest first: the first match is the latest release among them. + dates.push((await getReleases()).find((r) => products.includes(r.product))?.date); + } const times = dates.flatMap((d) => (d ? [Date.parse(d)] : [])); if (times.length) item.lastmod = new Date(Math.max(...times)).toISOString(); return item; diff --git a/src/components/pages/ChangelogPage.astro b/src/components/pages/ChangelogPage.astro index ac6780b..c82cf6d 100644 --- a/src/components/pages/ChangelogPage.astro +++ b/src/components/pages/ChangelogPage.astro @@ -80,6 +80,8 @@ const filters = [ notes ? "pb-12" : "pb-6", ]} > + {/* The anchor the release had while the page listed Enterprise alone (#v0.4.0). */} + {entry.legacyId && } { } const releases = await getReleases(); - const entries: ChangelogEntry[] = releases.map((r) => ({ - id: `${r.product}-${r.tag}`, - product: r.product, - tag: r.tag, - date: new Date(r.date), - url: r.url, - notes: r.product === "enterprise" ? notes.get(r.tag.replace(/^v/, "")) : undefined, - })); + const entries: ChangelogEntry[] = releases.map((r) => { + const enterprise = r.product === "enterprise"; + const version = r.tag.replace(/^v/, ""); + return { + id: `${r.product}-${r.tag}`, + legacyId: enterprise ? `v${version}` : undefined, + product: r.product, + tag: r.tag, + date: new Date(r.date), + url: r.url, + notes: enterprise ? notes.get(version) : undefined, + }; + }); // Notes for Enterprise versions that were never published as GitHub releases. const onGitHub = new Set(releases.filter((r) => r.product === "enterprise").map((r) => r.tag.replace(/^v/, ""))); for (const [version, entry] of notes) { if (entry && !onGitHub.has(version)) { - entries.push({ id: `enterprise-v${version}`, product: "enterprise", tag: `v${version}`, date: entry.data.date, notes: entry }); + entries.push({ + id: `enterprise-v${version}`, + legacyId: `v${version}`, + product: "enterprise", + tag: `v${version}`, + date: entry.data.date, + notes: entry, + }); } } diff --git a/src/lib/lastmod.ts b/src/lib/lastmod.ts index f886b31..27413ca 100644 --- a/src/lib/lastmod.ts +++ b/src/lib/lastmod.ts @@ -1,12 +1,15 @@ // When a page last changed, taken from git history: the last commit that // touched any of the files the page is built from. Used for in the -// sitemap and for the dates on documentation articles. +// sitemap and for the dates on documentation articles. Pages that show a +// product's latest release also change when it is released; the sitemap adds +// those dates (see pageReleases() and astro.config.mjs). // // A build time would claim that every page changed on every build, so without // history (a shallow clone, no git) the dates are left out instead. The deploy // workflow checks out the full history for this. import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; +import type { Product } from "./releases.mjs"; interface FileDates { /** First commit that touched the file, as an ISO timestamp */ @@ -63,15 +66,19 @@ export function firstPublished(paths: string[]): string | undefined { return datesOf(paths).reduce((min, d) => (!min || d.first < min ? d.first : min), undefined); } +/** A page's language and its path without the language prefix, e.g. "/zh-CN/lite" → "/lite" */ +function splitPath(pathname: string): { lang: "en" | "zh-CN"; path: string } { + const zh = pathname === "/zh-CN" || pathname.startsWith("/zh-CN/"); + return { lang: zh ? "zh-CN" : "en", path: zh ? pathname.slice("/zh-CN".length) || "/" : pathname }; +} + /** * The files a page is built from, by its path (without a trailing slash). Only * the content counts: the page's copy and its own components, not the shared * layout, header or footer. */ export function pageSources(pathname: string): string[] { - const zh = pathname === "/zh-CN" || pathname.startsWith("/zh-CN/"); - const lang = zh ? "zh-CN" : "en"; - const path = zh ? pathname.slice("/zh-CN".length) || "/" : pathname; + const { lang, path } = splitPath(pathname); // A doc without a translation is shown in English. const doc = (dir: string, slug: string) => { const own = `src/content/${dir}/${lang}/${slug}.md`; @@ -80,7 +87,16 @@ export function pageSources(pathname: string): string[] { switch (path) { case "/": - return ["src/components/pages/HomePage.astro", "src/components/home", "src/i18n/pages/home.ts"]; + return [ + "src/components/pages/HomePage.astro", + "src/components/home", + "src/i18n/pages/home.ts", + // Components the home sections are built from. + "src/components/hero", + "src/components/ui/Terminal.tsx", + "src/components/mocks", + "src/components/LiteDownload.astro", + ]; case "/lite": return ["src/components/pages/LitePage.astro", "src/i18n/pages/lite.ts", "src/components/LiteDownload.astro", "public/lite"]; case "/core": @@ -107,3 +123,26 @@ export function pageSources(pathname: string): string[] { if (guide) return doc("docs", guide[1]); return []; } + +/** + * The products whose releases a page is built from, by its path (without a + * trailing slash): the version and downloads of the latest release, in the + * page itself or in its structured data, or the list of releases on the + * changelog. A release changes such a page as much as a commit does. + */ +export function pageReleases(pathname: string): Product[] { + switch (splitPath(pathname).path) { + case "/": + // The Lite download button, and the structured data of all three products. + return ["enterprise", "lite", "core"]; + case "/lite": + return ["lite"]; + case "/core": + return ["core"]; + case "/thinkwatch": + return ["enterprise"]; + case "/changelog": + return ["enterprise", "lite", "core"]; + } + return []; +} diff --git a/src/lib/og.ts b/src/lib/og.ts index 5889b88..91b830d 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -5,7 +5,7 @@ // platforms the product runs on, and nothing else. The text comes from the page // copy, so a card changes when its page does. Cards are drawn in English (the // name and platforms are the same in both languages); og:image:alt gives the -// same text in the page's language. +// same text in the page's language, without repeating what the headline says. // // Text is drawn as outlines from the Geist font files in node_modules, so the // image does not depend on the fonts installed on the machine that builds it: @@ -72,10 +72,18 @@ export function cardText(card: OgCard, lang: Lang): CardText { /** A product page's h1, which is split in two for the highlight */ const headline = (hero: { titleA: string; titleHighlight: string }) => `${hero.titleA}${hero.titleHighlight}`; -/** og:image:alt: the card's text, in the page's language */ +/** + * og:image:alt: the card's text, in the page's language. Labels the headline + * already names are said once: the Lite headline lists the platforms itself. + */ export function ogImageAlt(card: OgCard, lang: Lang): string { const { name, line, labels } = cardText(card, lang); - return lang === "zh-CN" ? `${name}:${line}(${labels.join("、")})` : `${name}: ${line} (${labels.join(", ")})`; + const named = line.toLowerCase(); + const rest = labels.filter((label) => !named.includes(label.toLowerCase())); + const zh = lang === "zh-CN"; + const text = zh ? `${name}:${line}` : `${name}: ${line}`; + if (!rest.length) return text; + return zh ? `${text}(${rest.join("、")})` : `${text} (${rest.join(", ")})`; } // ---------- Rendering ---------- diff --git a/src/lib/structured-data.ts b/src/lib/structured-data.ts index 7aa4069..99279f3 100644 --- a/src/lib/structured-data.ts +++ b/src/lib/structured-data.ts @@ -179,8 +179,6 @@ export async function coreLd(lang: Lang): Promise { const binaries = Object.entries(release?.assets ?? {}) .filter(([name]) => name.startsWith("twcore-") && !/\.(sha256|tar\.gz)$/.test(name)) .map(([, href]) => href); - // "twcore · a complete, self-contained gateway binary" → the part after the name. - const binary = c.hero.cardTitle.split(" · ").pop() ?? c.hero.cardTitle; return [ { "@context": CONTEXT, @@ -200,7 +198,7 @@ export async function coreLd(lang: Lang): Promise { "@type": "SoftwareApplication", "@id": ids.twcore, name: "twcore", - description: binary.charAt(0).toUpperCase() + binary.slice(1), + description: c.meta.twcoreDescription, applicationCategory: "DeveloperApplication", operatingSystem: "Linux, macOS, Windows", processorRequirements: processors[lang], diff --git a/src/pages/changelog.xml.ts b/src/pages/changelog.xml.ts index c6fb158..ee3a02e 100644 --- a/src/pages/changelog.xml.ts +++ b/src/pages/changelog.xml.ts @@ -9,6 +9,11 @@ import { getChangelog, releaseProductName } from "~/lib/changelog"; export async function GET(context: APIContext) { const entries = await getChangelog("en"); const page = new URL("/changelog/", context.site).href; + // The releases written up here (Enterprise v0.1.0–v0.4.0) were in the feed + // before it covered every product, linked as /changelog#v0.4.0. They keep + // that link, and with it their guid, so that readers do not show them again + // as new items; the page still has those anchors. + const writtenUp = new URL("/changelog", context.site).href; return rss({ title: "ThinkWatch release notes", @@ -22,7 +27,7 @@ export async function GET(context: APIContext) { return { title: notes ? `${name} ${entry.tag} — ${notes.title}` : `${name} ${entry.tag}`, pubDate: entry.date, - link: notes ? `${page}#${entry.id}` : entry.url!, + link: notes ? `${writtenUp}#${entry.legacyId!}` : entry.url!, ...(notes ? { description: notes.highlights?.join(" · ") ?? notes.title } : {}), categories: [name], }; diff --git a/src/pages/docs/_DocArticle.astro b/src/pages/docs/_DocArticle.astro index f48023a..3ab6231 100644 --- a/src/pages/docs/_DocArticle.astro +++ b/src/pages/docs/_DocArticle.astro @@ -39,12 +39,15 @@ const site = Astro.site ?? new URL("https://thinkwat.ch"); const abs = (path: string) => new URL(path.endsWith("/") ? path : `${path}/`, site).toString(); const pageUrl = new URL(Astro.url.pathname, site).toString(); +// Home › Documentation › product › doc. ThinkWatch Enterprise's docs home is +// the documentation home itself (/docs), so its guides skip the product step +// rather than list the same page twice. const crumbs = [ { name: zh ? "首页" : "Home", item: abs(localePath(lang, "/")) }, { name: zh ? "文档" : "Documentation", item: abs(localePath(lang, "/docs")) }, { name: productName(p, lang), item: slug ? abs(productHomeHref(lang, product)) : pageUrl }, ...(slug ? [{ name: docLabel, item: pageUrl }] : []), -]; +].filter((crumb, i, all) => i === 0 || crumb.item !== all[i - 1].item); const jsonLd: Record[] = [ { From e12145a84e72bc38aef566ff91efc06d94fb07a6 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:03:32 +0800 Subject: [PATCH 4/4] fix(seo): leave out without git history, also on pages dated by releases A release date alone does not say when a page last changed, so a build without history (a shallow clone) emits no at all, as before. Co-Authored-By: Claude Opus 5.5 --- astro.config.mjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 86da13a..a2e204b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -64,15 +64,19 @@ export default defineConfig({ // When the page's own files last changed in git, or the latest release // of a product the page shows came out, whichever is later. Left out - // when unknown. - const dates = [lastModified(pageSources(pathname))]; - const products = pageReleases(pathname); - if (products.length) { - // Newest first: the first match is the latest release among them. - dates.push((await getReleases()).find((r) => products.includes(r.product))?.date); + // without git history: a release date alone does not say when the + // page itself last changed. + const committed = lastModified(pageSources(pathname)); + if (committed) { + let latest = Date.parse(committed); + const products = pageReleases(pathname); + if (products.length) { + // Newest first: the first match is the latest release among them. + const release = (await getReleases()).find((r) => products.includes(r.product)); + if (release) latest = Math.max(latest, Date.parse(release.date)); + } + item.lastmod = new Date(latest).toISOString(); } - const times = dates.flatMap((d) => (d ? [Date.parse(d)] : [])); - if (times.length) item.lastmod = new Date(Math.max(...times)).toISOString(); return item; }, }),