From 873da8b77c4713d03c3aab6dd4a8fbb260593fd1 Mon Sep 17 00:00:00 2001 From: MisakaAkio Date: Thu, 22 Oct 2026 19:01:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=8A=A0=E8=BD=BD=E5=B9=B6=E5=AE=8C=E5=96=84=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=20SEO=E3=80=81=E6=96=B0=E9=97=BB=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E3=80=81=E4=B8=BB=E9=A2=98=E9=A6=96=E5=B1=8F=E5=8F=8A?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/checks.yml | 25 ++ .gitignore | 4 + README.md | 16 + package.json | 10 +- playwright.config.ts | 18 ++ pnpm-lock.yaml | 277 +++++++++--------- public/init-theme.js | 12 +- scripts/finalize-export.mts | 32 ++ scripts/serve-export.mjs | 28 ++ src/app/(legacy)/403/page.tsx | 2 + src/app/(legacy)/500/page.tsx | 2 + src/app/(legacy)/502/page.tsx | 2 + src/app/(legacy)/503/page.tsx | 2 + src/app/(legacy)/504/page.tsx | 2 + src/app/(legacy)/page.tsx | 9 +- src/app/[locale]/403/page.tsx | 2 +- src/app/[locale]/500/page.tsx | 2 +- src/app/[locale]/502/page.tsx | 2 +- src/app/[locale]/503/page.tsx | 2 +- src/app/[locale]/504/page.tsx | 2 +- src/app/[locale]/about/page.tsx | 3 +- src/app/[locale]/city/page.tsx | 3 +- src/app/[locale]/join-us/page.tsx | 3 +- src/app/[locale]/layout.tsx | 12 +- src/app/[locale]/links/page.tsx | 3 +- src/app/[locale]/members/page.tsx | 3 +- src/app/[locale]/news/[slug]/page.tsx | 8 +- src/app/[locale]/news/page.tsx | 3 +- src/app/[locale]/page.tsx | 18 +- src/app/[locale]/policy/[slug]/page.tsx | 14 +- src/app/[locale]/railway/page.tsx | 3 +- src/app/robots.ts | 8 + src/app/sitemap.ts | 18 ++ src/components/home/home-page-content.tsx | 44 +-- src/components/layout/site-document.tsx | 4 +- src/components/layout/site-header.tsx | 42 +-- src/components/layout/theme-toggle.tsx | 42 +-- src/components/news/news-detail-client.tsx | 8 +- src/components/news/news-list.tsx | 4 +- src/components/policy/policy-page-content.tsx | 28 +- src/data/news/index.ts | 69 +++-- src/data/policy/index.ts | 19 +- src/lib/i18n.ts | 7 +- src/lib/seo.ts | 58 ++++ src/lib/theme.ts | 31 ++ tests/e2e/site.spec.ts | 106 +++++++ tests/export/assets.test.ts | 46 +++ tests/fixtures/news/en/two.md | 6 + tests/fixtures/news/zh-CN/four.md | 6 + tests/fixtures/news/zh-CN/one.md | 6 + tests/fixtures/news/zh-CN/three.md | 6 + tests/fixtures/news/zh-CN/two.md | 6 + tests/unit/content.test.ts | 48 +++ 53 files changed, 774 insertions(+), 362 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 playwright.config.ts create mode 100644 scripts/finalize-export.mts create mode 100644 scripts/serve-export.mjs create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/lib/seo.ts create mode 100644 src/lib/theme.ts create mode 100644 tests/e2e/site.spec.ts create mode 100644 tests/export/assets.test.ts create mode 100644 tests/fixtures/news/en/two.md create mode 100644 tests/fixtures/news/zh-CN/four.md create mode 100644 tests/fixtures/news/zh-CN/one.md create mode 100644 tests/fixtures/news/zh-CN/three.md create mode 100644 tests/fixtures/news/zh-CN/two.md create mode 100644 tests/unit/content.test.ts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..bdd72af --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,25 @@ +name: Website regression checks +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - run: npm install --global corepack@0.34.5 + - run: corepack enable + - run: corepack pnpm install --frozen-lockfile + - run: corepack pnpm exec playwright install --with-deps chromium + - run: corepack pnpm check + - run: git diff --check + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index d59cad2..e0aeb44 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ # TypeScript *.tsbuildinfo next-env.d.ts + +# Regression test output +/test-results/ +/playwright-report/ diff --git a/README.md b/README.md index 2e15956..50f4015 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ corepack pnpm dev ```bash corepack pnpm lint # 检查代码 corepack pnpm build # 构建静态站点到 out/ +corepack pnpm test # 内容回退和 URL/SEO 单元测试 +corepack pnpm test:export # 检查 out/ 的静态资源引用 +corepack pnpm test:e2e # 在 out/ 上运行浏览器回归测试 +corepack pnpm check # lint、单元测试、构建、产物与浏览器检查 corepack pnpm cache-images # 缓存新闻中的远程图片 corepack pnpm deploy # 使用 Wrangler 部署 ``` @@ -40,3 +44,15 @@ public/ 图片、图标与其他静态资源 ``` 公开语言路径为 `/zh-Hans/`、`/zh-Hant/`、`/en/` 和 `/jp/`。例如:`/zh-Hans/news/`、`/en/links/`。 + +## 静态导出与回归测试 + +首次运行浏览器测试前执行 `corepack pnpm exec playwright install chromium`。测试使用本机 `127.0.0.1:4173` 服务已构建的 `out/`,覆盖 SPA 导航、语言切换、主题持久化、移动菜单、SEO 与 404。CI 使用 `.nvmrc` 固定的 Node.js 版本执行 `corepack pnpm check`。 + +新闻列表仅传递摘要,详情按 slug 回退到中文;首页只读取当前语言的三条新闻。政策正文在构建时渲染,不向客户端传递四种语言的全文。 + +SEO 默认主域名为 `https://www.kivotos.cc`,可在构建时通过 `NEXT_PUBLIC_SITE_URL` 设置主域名(不带路径)。所有页面的 canonical、hreflang、Open Graph 和 sitemap 共用此配置。`jp` 是公开路由,HTML 的 `lang` 和 hreflang 使用 `ja`。 + +`pnpm build` 在 Next.js 静态导出后执行 `scripts/finalize-export.mts`,按路径设置 HTML 初始语言,并在 head 尾部加入同步主题初始化脚本,使保存的主题在正文首次绘制前生效。这不在 React 树中插入脚本,也不改变 SPA 路由。请使用完整构建命令;`next dev` 不执行导出后处理,首屏主题验证以构建产物为准。 + +资源检查明确记录现有 8 张缺失新闻缓存图,以及 20240917 新闻的相对封面路径(四种语言均存在),输出诊断信息;这些图片修复不在本轮范围内,任何新增缺失资源都会使检查失败。修复图片后应移除 `tests/export/assets.test.ts` 中的对应记录。 diff --git a/package.json b/package.json index 77ce0f7..40a2f3b 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,15 @@ "scripts": { "dev": "next dev", "cache-images": "node scripts/cache-images.mjs", - "build": "next build", + "build": "next build && tsx scripts/finalize-export.mts", "start": "next start", "lint": "eslint", "deploy": "wrangler deploy", - "preview": "wrangler dev" + "preview": "wrangler dev", + "test": "tsx --test tests/unit/*.test.ts", + "test:e2e": "playwright test", + "check": "corepack pnpm lint && corepack pnpm test && corepack pnpm build && corepack pnpm test:export && corepack pnpm test:e2e", + "test:export": "tsx --test tests/export/*.test.ts" }, "dependencies": { "@fontsource-variable/noto-sans": "5.3.0", @@ -24,11 +28,13 @@ "react-dom": "19.3.0" }, "devDependencies": { + "@playwright/test": "1.64.1", "@types/node": "22.20.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "eslint": "9.39.1", "eslint-config-next": "16.3.5", + "tsx": "4.23.15", "typescript": "5.9.3", "wrangler": "4.131.1" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f8b4f87 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + workers: process.env.CI ? 2 : 3, + retries: 0, + use: { + baseURL: "http://127.0.0.1:4173", + browserName: "chromium", + trace: "retain-on-failure", + }, + webServer: { + command: "node scripts/serve-export.mjs", + url: "http://127.0.0.1:4173/zh-Hans/", + reuseExistingServer: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4e4f5b..f42a549 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 18.0.11 next: specifier: 16.3.5 - version: 16.3.5(@babel/core@7.29.7(supports-color@10.2.2))(@types/node@22.20.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.5(@babel/core@7.29.7)(@playwright/test@1.64.1)(@types/node@22.20.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -27,6 +27,9 @@ importers: specifier: 19.3.0 version: 19.3.0(react@19.3.0) devDependencies: + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 '@types/node': specifier: 22.20.2 version: 22.20.2 @@ -38,10 +41,13 @@ importers: version: 19.3.0(@types/react@19.3.0) eslint: specifier: 9.39.1 - version: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + version: 9.39.1(jiti@2.7.0) eslint-config-next: specifier: 16.3.5 - version: 16.3.5(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + version: 16.3.5(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + tsx: + specifier: 4.23.15 + version: 4.23.15 typescript: specifier: 5.9.3 version: 5.9.3 @@ -438,105 +444,89 @@ packages: resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.3': resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.3': resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.3': resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.3': resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.3': resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.3.3': resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.3': resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.35.4': resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.35.4': resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.35.4': resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.35.4': resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.35.4': resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.35.4': resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.35.4': resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.35.4': resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.35.4': resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} @@ -614,28 +604,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.3.5': resolution: {integrity: sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.3.5': resolution: {integrity: sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.3.5': resolution: {integrity: sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.3.5': resolution: {integrity: sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==} @@ -665,6 +651,11 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -808,61 +799,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -1785,6 +1766,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2002,6 +1993,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2144,20 +2140,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@10.2.2)': + '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2182,19 +2178,19 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@10.2.2) + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -2219,7 +2215,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@10.2.2)': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -2227,7 +2223,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -2362,22 +2358,22 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.1(jiti@2.7.0))': dependencies: - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@2.7.0))': dependencies: - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2(supports-color@10.2.2)': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -2390,10 +2386,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.7(supports-color@10.2.2)': + '@eslint/eslintrc@3.3.7': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -2572,9 +2568,9 @@ snapshots: '@next/env@16.3.5': {} - '@next/eslint-plugin-next@16.3.5(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))': + '@next/eslint-plugin-next@16.3.5(eslint@9.39.1(jiti@2.7.0))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint @@ -2617,6 +2613,10 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -2662,15 +2662,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.67.0 - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2678,23 +2678,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3(supports-color@10.2.2) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) '@typescript-eslint/types': 8.67.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2708,13 +2708,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - debug: 4.4.3(supports-color@10.2.2) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2722,13 +2722,13 @@ snapshots: '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.67.0(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) '@typescript-eslint/types': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -2737,13 +2737,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + eslint: 9.39.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3020,17 +3020,13 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@3.2.7(supports-color@10.2.2): + debug@3.2.7: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 - debug@4.4.3(supports-color@10.2.2): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 deep-is@0.1.4: {} @@ -3208,18 +3204,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.3.5(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): + eslint-config-next@16.3.5(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.3.5(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) - eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)) - eslint-plugin-react-hooks: 7.1.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + '@next/eslint-plugin-next': 16.3.5(eslint@9.39.1(jiti@2.7.0)) + eslint: 9.39.1(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.7.0)) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.1(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + typescript-eslint: 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -3228,52 +3224,52 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-import-resolver-node@0.3.10(supports-color@10.2.2): + eslint-import-resolver-node@0.3.10: dependencies: - debug: 3.2.7(supports-color@10.2.2) + debug: 3.2.7 is-core-module: 2.16.2 resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@10.2.2) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) get-tsconfig: 4.14.2 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)): dependencies: - debug: 3.2.7(supports-color@10.2.2) + debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) - eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@10.2.2) + debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) - eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -3285,13 +3281,13 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@2.7.0)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -3301,7 +3297,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -3310,18 +3306,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.1(jiti@2.7.0)): dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 '@babel/parser': 7.29.8 - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.7.0)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -3329,7 +3325,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.4.0 - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + eslint: 9.39.1(jiti@2.7.0) estraverse: 5.3.0 hasown: 2.0.4 jsx-ast-utils: 3.3.5 @@ -3354,14 +3350,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2): + eslint@9.39.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@10.2.2) + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7(supports-color@10.2.2) + '@eslint/eslintrc': 3.3.7 '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -3371,7 +3367,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -3806,7 +3802,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.5(@babel/core@7.29.7(supports-color@10.2.2))(@types/node@22.20.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.3.5(@babel/core@7.29.7)(@playwright/test@1.64.1)(@types/node@22.20.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.3.5 '@swc/helpers': 0.5.23 @@ -3815,7 +3811,7 @@ snapshots: postcss: 8.5.23 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) optionalDependencies: '@next/swc-darwin-arm64': 16.3.5 '@next/swc-darwin-x64': 16.3.5 @@ -3825,6 +3821,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.5 '@next/swc-win32-arm64-msvc': 16.3.5 '@next/swc-win32-x64-msvc': 16.3.5 + '@playwright/test': 1.64.1 sharp: 0.35.4(@types/node@22.20.2) transitivePeerDependencies: - '@babel/core' @@ -3926,6 +3923,12 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + possible-typed-array-names@1.1.0: {} postcss@8.5.23: @@ -4172,12 +4175,12 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@10.2.2))(react@19.3.0): + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0): dependencies: client-only: 0.0.1 react: 19.3.0 optionalDependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/core': 7.29.7 supports-color@10.2.2: {} @@ -4209,6 +4212,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.15: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4246,13 +4255,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): + typescript-eslint@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.67.0(eslint@9.39.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - eslint: 9.39.1(jiti@2.7.0)(supports-color@10.2.2) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color diff --git a/public/init-theme.js b/public/init-theme.js index 7014bf2..d1bcf87 100644 --- a/public/init-theme.js +++ b/public/init-theme.js @@ -3,10 +3,18 @@ let preference = "system"; try { const cookieTheme = document.cookie.split(";").map((entry) => entry.trim()).find((entry) => entry.startsWith("kr-theme="))?.slice("kr-theme=".length); - const savedTheme = isTheme(cookieTheme) ? cookieTheme : localStorage.getItem("kr-theme"); - if (isTheme(savedTheme)) preference = savedTheme; + if (isTheme(cookieTheme)) preference = cookieTheme; + else { + try { + const saved = localStorage.getItem("kr-theme"); + if (isTheme(saved)) preference = saved; + } catch {} + } } catch {} const theme = preference === "system" ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : preference; document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; + const color = theme === "dark" ? "#10151d" : "#ffffff"; + document.documentElement.style.backgroundColor = color; + document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => meta.setAttribute("content", color)); })(); diff --git a/scripts/finalize-export.mts b/scripts/finalize-export.mts new file mode 100644 index 0000000..0cb2766 --- /dev/null +++ b/scripts/finalize-export.mts @@ -0,0 +1,32 @@ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { isLocale, localeLanguageTags } from "../src/lib/i18n"; + +const output = path.resolve("out"); +const bootstrap = (await readFile("public/init-theme.js", "utf8")).trim(); + +async function finalize(directory: string): Promise { + let count = 0; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { count += await finalize(file); continue; } + if (!entry.name.endsWith(".html")) continue; + const routeLocale = path.relative(output, file).split(path.sep)[0]; + const language = isLocale(routeLocale) ? localeLanguageTags[routeLocale] : "zh-Hans"; + const html = await readFile(file, "utf8"); + if (!html.includes("") || !/]*\blang="[^"]*"/.test(html)) { + throw new Error(`Unexpected exported HTML: ${file}`); + } + // Static-host boundary: no scripts in React layouts and no extra root layouts + // (which would turn language switching into document navigation). + const finalized = html + .replace(/(]*\blang=")[^"]*"/, `$1${language}"`) + .replace(/`); + await writeFile(file, finalized); + count += 1; + } + return count; +} + +console.log(`[finalize-export] Set document languages and pre-paint themes in ${await finalize(output)} HTML files.`); diff --git a/scripts/serve-export.mjs b/scripts/serve-export.mjs new file mode 100644 index 0000000..3125f7d --- /dev/null +++ b/scripts/serve-export.mjs @@ -0,0 +1,28 @@ +// Local-only static host for regression tests. Unknown URLs keep a real 404 status. +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve("out"); +const types = { ".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css", ".json": "application/json", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml", ".svg": "image/svg+xml", ".webp": "image/webp", ".png": "image/png", ".jpg": "image/jpeg", ".woff2": "font/woff2" }; +createServer(async (request, response) => { + try { + const url = new URL(request.url, "http://127.0.0.1"); + let file = path.resolve(root, `.${decodeURIComponent(url.pathname)}`); + if (file !== root && !file.startsWith(root + path.sep)) { + response.writeHead(403).end(); return; + } + if ((await stat(file)).isDirectory()) { + if (!url.pathname.endsWith("/")) { + response.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end(); return; + } + file = path.join(file, "index.html"); + } + const body = await readFile(file); + response.writeHead(200, { "Content-Type": types[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-store" }); + response.end(body); + } catch { + response.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + response.end(await readFile(path.join(root, "404.html"))); + } +}).listen(4173, "127.0.0.1", () => console.log("Static export: http://127.0.0.1:4173")); diff --git a/src/app/(legacy)/403/page.tsx b/src/app/(legacy)/403/page.tsx index 9680f05..cbab7a3 100644 --- a/src/app/(legacy)/403/page.tsx +++ b/src/app/(legacy)/403/page.tsx @@ -1,5 +1,7 @@ import { HttpStatusPage } from "@/components/status/http-status-page"; +export const metadata = { title: "403", robots: { index: false, follow: false } }; + export default function ForbiddenPage() { return ; } diff --git a/src/app/(legacy)/500/page.tsx b/src/app/(legacy)/500/page.tsx index 0d6bdbc..bf8eb63 100644 --- a/src/app/(legacy)/500/page.tsx +++ b/src/app/(legacy)/500/page.tsx @@ -1,5 +1,7 @@ import { HttpStatusPage } from "@/components/status/http-status-page"; +export const metadata = { title: "500", robots: { index: false, follow: false } }; + export default function InternalServerErrorPage() { return ; } diff --git a/src/app/(legacy)/502/page.tsx b/src/app/(legacy)/502/page.tsx index 8bdc814..c96eae6 100644 --- a/src/app/(legacy)/502/page.tsx +++ b/src/app/(legacy)/502/page.tsx @@ -1,5 +1,7 @@ import { HttpStatusPage } from "@/components/status/http-status-page"; +export const metadata = { title: "502", robots: { index: false, follow: false } }; + export default function BadGatewayPage() { return ; } diff --git a/src/app/(legacy)/503/page.tsx b/src/app/(legacy)/503/page.tsx index ab5525a..d4092ed 100644 --- a/src/app/(legacy)/503/page.tsx +++ b/src/app/(legacy)/503/page.tsx @@ -1,5 +1,7 @@ import { HttpStatusPage } from "@/components/status/http-status-page"; +export const metadata = { title: "503", robots: { index: false, follow: false } }; + export default function ServiceUnavailablePage() { return ; } diff --git a/src/app/(legacy)/504/page.tsx b/src/app/(legacy)/504/page.tsx index 0cb9b73..9cc4949 100644 --- a/src/app/(legacy)/504/page.tsx +++ b/src/app/(legacy)/504/page.tsx @@ -1,5 +1,7 @@ import { HttpStatusPage } from "@/components/status/http-status-page"; +export const metadata = { title: "504", robots: { index: false, follow: false } }; + export default function GatewayTimeoutPage() { return ; } diff --git a/src/app/(legacy)/page.tsx b/src/app/(legacy)/page.tsx index 657cd02..edc573a 100644 --- a/src/app/(legacy)/page.tsx +++ b/src/app/(legacy)/page.tsx @@ -1,8 +1,9 @@ import { HomePageContent } from "@/components/home/home-page-content"; -import { getNewsCatalog } from "@/data/news"; +import { getLatestNews } from "@/data/news"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata = pageMetadata("zh-Hans", "/"); export default async function Home() { - const newsByLocale = await getNewsCatalog(); - - return ; + return ; } diff --git a/src/app/[locale]/403/page.tsx b/src/app/[locale]/403/page.tsx index bb689c6..1081361 100644 --- a/src/app/[locale]/403/page.tsx +++ b/src/app/[locale]/403/page.tsx @@ -1 +1 @@ -export { default } from "@/app/(legacy)/403/page"; +export { default, metadata } from "@/app/(legacy)/403/page"; diff --git a/src/app/[locale]/500/page.tsx b/src/app/[locale]/500/page.tsx index f8856cb..08257b8 100644 --- a/src/app/[locale]/500/page.tsx +++ b/src/app/[locale]/500/page.tsx @@ -1 +1 @@ -export { default } from "@/app/(legacy)/500/page"; +export { default, metadata } from "@/app/(legacy)/500/page"; diff --git a/src/app/[locale]/502/page.tsx b/src/app/[locale]/502/page.tsx index 351a22a..b8d925d 100644 --- a/src/app/[locale]/502/page.tsx +++ b/src/app/[locale]/502/page.tsx @@ -1 +1 @@ -export { default } from "@/app/(legacy)/502/page"; +export { default, metadata } from "@/app/(legacy)/502/page"; diff --git a/src/app/[locale]/503/page.tsx b/src/app/[locale]/503/page.tsx index 3c6fa7b..63ddd7e 100644 --- a/src/app/[locale]/503/page.tsx +++ b/src/app/[locale]/503/page.tsx @@ -1 +1 @@ -export { default } from "@/app/(legacy)/503/page"; +export { default, metadata } from "@/app/(legacy)/503/page"; diff --git a/src/app/[locale]/504/page.tsx b/src/app/[locale]/504/page.tsx index 336c8ef..c2b89a8 100644 --- a/src/app/[locale]/504/page.tsx +++ b/src/app/[locale]/504/page.tsx @@ -1 +1 @@ -export { default } from "@/app/(legacy)/504/page"; +export { default, metadata } from "@/app/(legacy)/504/page"; diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx index 083544e..8a2029a 100644 --- a/src/app/[locale]/about/page.tsx +++ b/src/app/[locale]/about/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { AboutPage } from "@/components/about/about-page"; import { getLocaleMessages, isLocale } from "@/lib/i18n"; @@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).footer.columns[1].links[0] }; + return pageMetadata(locale, "/about", { title: getLocaleMessages(locale).footer.columns[1].links[0] }); } export default async function AboutRoute({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/city/page.tsx b/src/app/[locale]/city/page.tsx index eed6b52..44756cb 100644 --- a/src/app/[locale]/city/page.tsx +++ b/src/app/[locale]/city/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { SimpleTitlePage } from "@/components/pages/simple-title-page"; import { getLocaleMessages, isLocale } from "@/lib/i18n"; @@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).header.nav[3] }; + return pageMetadata(locale, "/city", { title: getLocaleMessages(locale).header.nav[3] }); } export default async function CityPage({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/join-us/page.tsx b/src/app/[locale]/join-us/page.tsx index 7faec22..e80754a 100644 --- a/src/app/[locale]/join-us/page.tsx +++ b/src/app/[locale]/join-us/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { SimpleTitlePage } from "@/components/pages/simple-title-page"; import { getLocaleMessages, isLocale } from "@/lib/i18n"; @@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).footer.columns[1].links[2] }; + return pageMetadata(locale, "/join-us", { title: getLocaleMessages(locale).footer.columns[1].links[2] }); } export default async function JoinUsPage({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 3a74dbb..fb8771c 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation"; import { SiteShell } from "@/components/layout/site-shell"; -import { getLocaleMessages, locales, matchLocale } from "@/lib/i18n"; +import { getLocaleMessages, locales, isLocale } from "@/lib/i18n"; export const dynamicParams = false; @@ -10,9 +10,8 @@ export function generateStaticParams() { export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; - const canonicalLocale = matchLocale(locale); - if (!canonicalLocale) notFound(); - const title = getLocaleMessages(canonicalLocale).common.siteTitle; + if (!isLocale(locale)) notFound(); + const title = getLocaleMessages(locale).common.siteTitle; return { title: { default: title, template: `%s | ${title}` }, icons: { icon: "/icon/icon.svg" } }; } @@ -21,7 +20,6 @@ export default async function LocaleLayout({ children, params }: { params: Promise<{ locale: string }>; }) { const { locale } = await params; - const canonicalLocale = matchLocale(locale); - if (!canonicalLocale) notFound(); - return {children}; + if (!isLocale(locale)) notFound(); + return {children}; } diff --git a/src/app/[locale]/links/page.tsx b/src/app/[locale]/links/page.tsx index afed8e8..b34ce85 100644 --- a/src/app/[locale]/links/page.tsx +++ b/src/app/[locale]/links/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { FriendlyLinksPage } from "@/components/links/friendly-links-page"; import { getLocaleMessages, isLocale } from "@/lib/i18n"; @@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).footer.legal[2] }; + return pageMetadata(locale, "/links", { title: getLocaleMessages(locale).footer.legal[2] }); } export default async function LinksPage({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/members/page.tsx b/src/app/[locale]/members/page.tsx index 7b863e5..14aa19a 100644 --- a/src/app/[locale]/members/page.tsx +++ b/src/app/[locale]/members/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { MembersPage } from "@/components/members/members-page"; import { getLocaleMessages, isLocale } from "@/lib/i18n"; @@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).footer.columns[1].links[1] }; + return pageMetadata(locale, "/members", { title: getLocaleMessages(locale).footer.columns[1].links[1] }); } export default async function MembersRoute({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/news/[slug]/page.tsx b/src/app/[locale]/news/[slug]/page.tsx index a594eb9..de6c145 100644 --- a/src/app/[locale]/news/[slug]/page.tsx +++ b/src/app/[locale]/news/[slug]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { getNewsBySlug, getNewsList } from "@/data/news"; import { NewsDetailClient } from "@/components/news/news-detail-client"; import { isLocale } from "@/lib/i18n"; +import { descriptionFromMarkdown, pageMetadata } from "@/lib/seo"; export async function generateStaticParams() { return (await getNewsList()).map((item) => ({ slug: item.slug })); @@ -11,11 +12,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str const { slug, locale } = await params; if (!isLocale(locale)) notFound(); const item = await getNewsBySlug(slug, locale); - return { title: item?.title ?? "新闻" }; + if (!item) notFound(); + return pageMetadata(locale, item.href, { title: item.title, description: descriptionFromMarkdown(item.body), article: true }); } export default async function NewsDetailPage({ params }: { params: Promise<{ slug: string; locale: string }> }) { const { slug, locale } = await params; if (!isLocale(locale)) notFound(); - return ; + const item = await getNewsBySlug(slug, locale); + if (!item) notFound(); + return ; } diff --git a/src/app/[locale]/news/page.tsx b/src/app/[locale]/news/page.tsx index aa8985a..5997be0 100644 --- a/src/app/[locale]/news/page.tsx +++ b/src/app/[locale]/news/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { getNewsList } from "@/data/news"; import { PageBreadcrumb } from "@/components/layout/page-breadcrumb"; @@ -8,7 +9,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).common.news }; + return pageMetadata(locale, "/news", { title: getLocaleMessages(locale).common.news }); } export default async function NewsPage({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index a1d4b8a..71f4b2f 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1 +1,17 @@ -export { default } from "@/app/(legacy)/page"; +import { notFound } from "next/navigation"; +import { HomePageContent } from "@/components/home/home-page-content"; +import { getLatestNews } from "@/data/news"; +import { isLocale } from "@/lib/i18n"; +import { pageMetadata } from "@/lib/seo"; + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + if (!isLocale(locale)) notFound(); + return pageMetadata(locale, "/"); +} + +export default async function Home({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + if (!isLocale(locale)) notFound(); + return ; +} diff --git a/src/app/[locale]/policy/[slug]/page.tsx b/src/app/[locale]/policy/[slug]/page.tsx index 1da78dd..246a5a1 100644 --- a/src/app/[locale]/policy/[slug]/page.tsx +++ b/src/app/[locale]/policy/[slug]/page.tsx @@ -1,8 +1,9 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { PolicyPageContent } from "@/components/policy/policy-page-content"; -import { getPolicyCatalog, getPolicySlugs } from "@/data/policy"; +import { getPolicyDocument, getPolicySlugs } from "@/data/policy"; import { isLocale } from "@/lib/i18n"; +import { descriptionFromMarkdown, pageMetadata } from "@/lib/seo"; export const dynamicParams = false; @@ -13,14 +14,15 @@ export function generateStaticParams() { export async function generateMetadata({ params }: { params: Promise<{ slug: string; locale: string }> }): Promise { const { slug, locale } = await params; if (!isLocale(locale)) notFound(); - const catalog = await getPolicyCatalog(slug); - return { title: catalog[locale]?.title ?? "法律信息" }; + const document = await getPolicyDocument(slug, locale); + if (!document) notFound(); + return pageMetadata(locale, `/policy/${slug}`, { title: document.title, description: descriptionFromMarkdown(document.body) }); } export default async function PolicyPage({ params }: { params: Promise<{ slug: string; locale: string }> }) { const { slug, locale } = await params; if (!isLocale(locale)) notFound(); - const catalog = await getPolicyCatalog(slug); - if (!catalog["zh-Hans"]) notFound(); - return ; + const document = await getPolicyDocument(slug, locale); + if (!document) notFound(); + return ; } diff --git a/src/app/[locale]/railway/page.tsx b/src/app/[locale]/railway/page.tsx index f88cba9..e913a6e 100644 --- a/src/app/[locale]/railway/page.tsx +++ b/src/app/[locale]/railway/page.tsx @@ -1,3 +1,4 @@ +import { pageMetadata } from "@/lib/seo"; import { notFound } from "next/navigation"; import { SimpleTitlePage } from "@/components/pages/simple-title-page"; import { getLocaleMessages, isLocale } from "@/lib/i18n"; @@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; if (!isLocale(locale)) notFound(); - return { title: getLocaleMessages(locale).header.nav[2] }; + return pageMetadata(locale, "/railway", { title: getLocaleMessages(locale).header.nav[2] }); } export default async function RailwayPage({ params }: { params: Promise<{ locale: string }> }) { diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..536915d --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,8 @@ +import type { MetadataRoute } from "next"; +import { siteOrigin } from "@/lib/seo"; + +export const dynamic = "force-static"; + +export default function robots(): MetadataRoute.Robots { + return { rules: { userAgent: "*", allow: "/" }, sitemap: `${siteOrigin}/sitemap.xml` }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..520a9dd --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,18 @@ +import type { MetadataRoute } from "next"; +import { getNewsList } from "@/data/news"; +import { getPolicySlugs } from "@/data/policy"; +import { locales } from "@/lib/i18n"; +import { languageAlternates, localizedUrl } from "@/lib/seo"; + +export const dynamic = "force-static"; + +export default async function sitemap(): Promise { + const paths = ["/", "/news", "/about", "/railway", "/city", "/join-us", "/members", "/links", + ...getPolicySlugs().map(({ slug }) => `/policy/${slug}`), + ...(await getNewsList()).map(({ href }) => href), + ]; + return paths.flatMap((href) => locales.map((locale) => ({ + url: localizedUrl(href, locale), + alternates: { languages: languageAlternates(href) }, + }))); +} diff --git a/src/components/home/home-page-content.tsx b/src/components/home/home-page-content.tsx index 7055536..6cb709e 100644 --- a/src/components/home/home-page-content.tsx +++ b/src/components/home/home-page-content.tsx @@ -1,12 +1,8 @@ -"use client"; - -import { useRouteLocale } from "@/components/layout/locale-context"; import { PageActionCards } from "@/components/layout/page-action-cards"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import type { NewsItem } from "@/data/news"; -import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; +import type { NewsSummary } from "@/data/news"; +import { withLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; const homeFeatureCopy: Record }) { - const [locale, setLocale] = useState(useRouteLocale()); - - useEffect(() => { - const syncLocale = (nextLocale: Locale) => setLocale(nextLocale); - - const currentLocale = getInitialLocale(); - syncLocale(currentLocale); - applyLocale(currentLocale); - - const onLocaleChange = (event: Event) => { - const detail = (event as CustomEvent<{ locale?: Locale }>).detail; - if (detail?.locale) { - setLocale(detail.locale); - } - }; - - const handleStorage = () => { - const nextLocale = getInitialLocale(); - syncLocale(nextLocale); - applyLocale(nextLocale); - }; - - window.addEventListener("kr-locale-change", onLocaleChange); - window.addEventListener("storage", handleStorage); - - return () => { - window.removeEventListener("kr-locale-change", onLocaleChange); - window.removeEventListener("storage", handleStorage); - }; - }, []); - +export function HomePageContent({ news, locale }: { news: NewsSummary[]; locale: Locale }) { const t = getLocaleMessages(locale); - const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-Hans"] ?? []; const features = homeFeatureCopy[locale]; return ( @@ -116,7 +80,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record
- {visibleNews.slice(0, 3).map((post) => ( + {news.map((post) => (
diff --git a/src/components/layout/site-document.tsx b/src/components/layout/site-document.tsx index 9ffdc28..614c5b9 100644 --- a/src/components/layout/site-document.tsx +++ b/src/components/layout/site-document.tsx @@ -1,11 +1,13 @@ import type { Metadata, Viewport } from "next"; +import { siteOrigin } from "@/lib/seo"; export const metadata: Metadata = { + metadataBase: new URL(siteOrigin), title: { default: "基沃托斯铁道公团 KivotosRailway", template: "%s | 基沃托斯铁道公团 KivotosRailway", }, - description: "Kivotos Railway website", + description: "基沃托斯铁道公团官方网站,提供 Minecraft 铁路与城市建设、新闻、团队和加入信息。", icons: { icon: "/icon/icon.svg" }, }; diff --git a/src/components/layout/site-header.tsx b/src/components/layout/site-header.tsx index f7630cb..ffaeaab 100644 --- a/src/components/layout/site-header.tsx +++ b/src/components/layout/site-header.tsx @@ -5,8 +5,8 @@ import { useRouteLocale } from "@/components/layout/locale-context"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { applyLocale, getInitialLocale, withLocale, withoutLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n"; +import { useState } from "react"; +import { withLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n"; export function SiteHeader() { const locale = useRouteLocale(); @@ -22,44 +22,6 @@ export function SiteHeader() { router.replace(withLocale(href, nextLocale)); }; - useEffect(() => { - const updateDocumentTitle = (nextLocale: Locale) => { - const messages = getLocaleMessages(nextLocale); - const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/"; - const currentTitle = document.title.split(" | ")[0]; - const pageLabel = pathname === "/" - ? "" - : pathname === "/news" - ? messages.common.news - : currentTitle; - - document.title = pageLabel ? `${pageLabel} | ${messages.common.siteTitle}` : messages.common.siteTitle; - }; - - const syncLocale = (nextLocale: Locale) => updateDocumentTitle(nextLocale); - - const handleLocaleChange = (event: Event) => { - const nextLocale = (event as CustomEvent<{ locale?: Locale }>).detail?.locale ?? getInitialLocale(); - syncLocale(nextLocale); - }; - - const handleStorage = () => { - syncLocale(getInitialLocale()); - }; - - const currentLocale = getInitialLocale(); - syncLocale(currentLocale); - applyLocale(currentLocale); - - window.addEventListener("kr-locale-change", handleLocaleChange); - window.addEventListener("storage", handleStorage); - - return () => { - window.removeEventListener("kr-locale-change", handleLocaleChange); - window.removeEventListener("storage", handleStorage); - }; - }, []); - const messages = getLocaleMessages(locale); return ( diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx index 9939d32..f6308e0 100644 --- a/src/components/layout/theme-toggle.tsx +++ b/src/components/layout/theme-toggle.tsx @@ -3,47 +3,13 @@ import { useRouteLocale } from "@/components/layout/locale-context"; import Image from "next/image"; +import { usePathname } from "next/navigation"; +import { applyTheme, getInitialThemePreference, type ThemePreference } from "@/lib/theme"; import { useEffect, useState } from "react"; import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; -type ThemePreference = "light" | "system" | "dark"; -type ResolvedTheme = "light" | "dark"; -const themeCookieName = "kr-theme"; - -function getThemeCookie(): ThemePreference | null { - const value = document.cookie - .split(";") - .map((entry) => entry.trim()) - .find((entry) => entry.startsWith(`${themeCookieName}=`)) - ?.slice(themeCookieName.length + 1); - - return value === "light" || value === "system" || value === "dark" ? value : null; -} - -function getInitialThemePreference(): ThemePreference { - if (typeof window === "undefined") return "system"; - - const cookieTheme = getThemeCookie(); - if (cookieTheme) return cookieTheme; - - const savedTheme = window.localStorage.getItem("kr-theme"); - return savedTheme === "light" || savedTheme === "system" || savedTheme === "dark" ? savedTheme : "system"; -} - -function resolveTheme(preference: ThemePreference): ResolvedTheme { - if (preference !== "system") return preference; - return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; -} - -function applyTheme(preference: ThemePreference) { - const theme = resolveTheme(preference); - document.documentElement.dataset.theme = theme; - document.documentElement.style.colorScheme = theme; - window.localStorage.setItem("kr-theme", preference); - document.cookie = `${themeCookieName}=${preference}; Path=/; Max-Age=31536000; SameSite=Lax`; -} - export function ThemeToggle() { + const pathname = usePathname(); const [themePreference, setThemePreference] = useState("system"); const [locale, setLocale] = useState(useRouteLocale()); @@ -70,7 +36,7 @@ export function ThemeToggle() { window.removeEventListener("storage", syncTheme); mediaQuery.removeEventListener("change", syncSystemTheme); }; - }, []); + }, [pathname]); const messages = getLocaleMessages(locale); const options: { theme: ThemePreference; label: string; icon: string }[] = [ diff --git a/src/components/news/news-detail-client.tsx b/src/components/news/news-detail-client.tsx index 2c8cef0..b8a74b1 100644 --- a/src/components/news/news-detail-client.tsx +++ b/src/components/news/news-detail-client.tsx @@ -5,13 +5,9 @@ import { markdownToHtml } from "@/lib/markdown"; import { PageBreadcrumb } from "@/components/layout/page-breadcrumb"; import { NewsShare } from "@/components/news/news-share"; -export function NewsDetailClient({ item, locale }: { item: NewsItem | null; locale: Locale }) { +export function NewsDetailClient({ item, locale }: { item: NewsItem; locale: Locale }) { const messages = getLocaleMessages(locale); - if (!item) { - return

未找到该文章。

{messages.common.backToNews}
; - } - return (
@@ -20,7 +16,7 @@ export function NewsDetailClient({ item, locale }: { item: NewsItem | null; loca

{item.category}

{item.title}

- {locale !== "zh-Hans" &&
{getAiTranslationNotice(locale)}
} + {item.contentLocale !== "zh-Hans" &&
{getAiTranslationNotice(locale)}
}
diff --git a/src/components/news/news-list.tsx b/src/components/news/news-list.tsx index 2d3d656..737c0db 100644 --- a/src/components/news/news-list.tsx +++ b/src/components/news/news-list.tsx @@ -4,12 +4,12 @@ import { useRouteLocale } from "@/components/layout/locale-context"; import Link from "next/link"; import { useEffect, useState } from "react"; -import type { NewsItem } from "@/data/news"; +import type { NewsSummary } from "@/data/news"; import { getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; const ITEMS_PER_PAGE = 12; -export function NewsList({ news }: { news: NewsItem[] }) { +export function NewsList({ news }: { news: NewsSummary[] }) { const [activeCategory, setActiveCategory] = useState(null); const [locale, setLocale] = useState(useRouteLocale()); const [currentPage, setCurrentPage] = useState(1); diff --git a/src/components/policy/policy-page-content.tsx b/src/components/policy/policy-page-content.tsx index 046c756..75de21b 100644 --- a/src/components/policy/policy-page-content.tsx +++ b/src/components/policy/policy-page-content.tsx @@ -1,34 +1,12 @@ -"use client"; - -import { useRouteLocale } from "@/components/layout/locale-context"; - -import { useEffect, useState } from "react"; -import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; +import { getAiTranslationNotice, getLocaleMessages, type Locale } from "@/lib/i18n"; import type { PolicyDocument } from "@/data/policy"; import { PageLocation } from "@/components/layout/page-location"; import { PageTitleHero } from "@/components/layout/page-title-hero"; -type PolicyCatalog = Partial>; - -export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) { - const [locale, setLocale] = useState(useRouteLocale()); - const document = catalog[locale] ?? catalog["zh-Hans"]; - const isTranslated = locale !== "zh-Hans" && Boolean(catalog[locale]); +export function PolicyPageContent({ document, locale }: { document: PolicyDocument; locale: Locale }) { + const isTranslated = document.contentLocale !== "zh-Hans"; const messages = getLocaleMessages(locale); - useEffect(() => { - const syncLocale = () => setLocale(getInitialLocale()); - syncLocale(); - window.addEventListener("kr-locale-change", syncLocale); - window.addEventListener("storage", syncLocale); - return () => { - window.removeEventListener("kr-locale-change", syncLocale); - window.removeEventListener("storage", syncLocale); - }; - }, []); - - if (!document) return null; - return ( <>
diff --git a/src/data/news/index.ts b/src/data/news/index.ts index d735d6b..12333d5 100644 --- a/src/data/news/index.ts +++ b/src/data/news/index.ts @@ -14,8 +14,16 @@ export type NewsItem = { tags: string[]; markdownFile: string; body: string; + contentLocale: Locale; }; +export type NewsSummary = Pick; + +function summarize(item: NewsItem): NewsSummary { + const { id, slug, title, date, cover, href, category } = item; + return { id, slug, title, date, cover, href, category }; +} + type NewsFrontmatter = { title?: string; translationLabel?: string; @@ -66,7 +74,7 @@ function stripFrontmatter(markdown: string): string { const DEFAULT_NEWS_COVER = "https://assets.kivotos.cc/image/2025/05/01/681397ba4e763.png"; -function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string): NewsItem | null { +function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string, locale: Locale): NewsItem | null { const title = raw.title?.trim(); const date = raw.date?.trim() || "未注明日期"; const category = (Array.isArray(raw.category) ? raw.category[0] : raw.category)?.trim() || "未分类"; @@ -90,11 +98,12 @@ function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string) tags, markdownFile: `src/data/news/posts/${fileName}`, body, + contentLocale: locale, }; } -async function readNewsList(locale: Locale): Promise { - const directory = path.join(process.cwd(), "src", "data", "news", "posts", localeContentDirectory[locale]); +async function readNewsList(root: string, locale: Locale): Promise { + const directory = path.join(root, localeContentDirectory[locale]); try { const files = (await fs.readdir(directory)) @@ -107,7 +116,7 @@ async function readNewsList(locale: Locale): Promise { const content = await fs.readFile(filePath, "utf-8"); const frontmatter = parseFrontmatter(content); const body = stripFrontmatter(content); - return normalizeNewsItem(fileName, frontmatter, body); + return normalizeNewsItem(fileName, frontmatter, body, locale); }), ); @@ -117,34 +126,36 @@ async function readNewsList(locale: Locale): Promise { const firstTime = Date.parse(first.date); return (Number.isNaN(secondTime) ? 0 : secondTime) - (Number.isNaN(firstTime) ? 0 : firstTime); }); - } catch { - return []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; } } -export async function getNewsList(locale: Locale = "zh-Hans"): Promise { - const items = await readNewsList(locale); - return locale === "zh-Hans" || items.length > 0 ? items : readNewsList("zh-Hans"); -} - -export async function getNewsCatalog(): Promise> { - const source = await readNewsList("zh-Hans"); - const entries = await Promise.all((["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => { - if (locale === "zh-Hans") return [locale, source] as const; - - const translated = await readNewsList(locale); +// The source catalog defines public slugs; a missing translation falls back per article. +export function createNewsReader(root: string) { + async function readLocalizedNews(locale: Locale) { + const [source, translated] = await Promise.all([ + readNewsList(root, "zh-Hans"), + locale === "zh-Hans" ? Promise.resolve([]) : readNewsList(root, locale), + ]); const bySlug = new Map(translated.map((item) => [item.slug, item])); - return [locale, source.map((item) => bySlug.get(item.slug) ?? item)] as const; - })); - return Object.fromEntries(entries) as Record; + return source.map((item) => bySlug.get(item.slug) ?? item); + } + + return { + async getNewsList(locale: Locale = "zh-Hans"): Promise { + return (await readLocalizedNews(locale)).map(summarize); + }, + async getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise { + return (await readLocalizedNews(locale)).find((item) => item.slug === slug) ?? null; + }, + async getLatestNews(locale: Locale = "zh-Hans"): Promise { + return (await readLocalizedNews(locale)).slice(0, 3).map(summarize); + }, + }; } -export async function getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise { - const items = await getNewsList(locale); - return items.find((item) => item.slug === slug) ?? null; -} - -export async function getLatestNews(locale: Locale = "zh-Hans") { - const items = await getNewsList(locale); - return items.slice(0, 3); -} +export const { getNewsList, getNewsBySlug, getLatestNews } = createNewsReader( + path.join(process.cwd(), "src", "data", "news", "posts"), +); diff --git a/src/data/policy/index.ts b/src/data/policy/index.ts index 88384e9..0e186e6 100644 --- a/src/data/policy/index.ts +++ b/src/data/policy/index.ts @@ -14,6 +14,7 @@ export type PolicyDocument = { body: string; html: string; markdownFile: string; + contentLocale: Locale; }; function isPolicySlug(slug: string): slug is PolicySlug { @@ -39,20 +40,18 @@ async function readPolicy(slug: PolicySlug, locale: Locale): Promise>> { - if (!isPolicySlug(slug)) return {}; - - const entries = await Promise.all( - (["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => [locale, await readPolicy(slug, locale)] as const), - ); - - return Object.fromEntries(entries.filter(([, document]) => document)) as Partial>; +export async function getPolicyDocument(slug: string, locale: Locale): Promise { + if (!isPolicySlug(slug)) return null; + const document = await readPolicy(slug, locale); + return document ?? (locale === "zh-Hans" ? null : readPolicy(slug, "zh-Hans")); } export function getPolicySlugs() { diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index c50dce7..fbd7657 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -6,6 +6,11 @@ import ja from "@/locales/ja.json"; export const locales = ["zh-Hans", "zh-Hant", "en", "jp"] as const; export type Locale = (typeof locales)[number]; +// Public route /jp/ is retained; HTML and hreflang use the BCP 47 language code. +export const localeLanguageTags: Record = { + "zh-Hans": "zh-Hans", "zh-Hant": "zh-Hant", en: "en", jp: "ja", +}; + // Content storage retains its original names; public URLs use script subtags. export const localeContentDirectory: Record = { "zh-Hans": "zh-CN", "zh-Hant": "zh-TW", en: "en", jp: "ja", @@ -80,7 +85,7 @@ export function applyLocale(locale: Locale) { window.location.replace(localizedHref); return; } - document.documentElement.lang = locale; + document.documentElement.lang = localeLanguageTags[locale]; document.documentElement.setAttribute("data-locale", locale); window.dispatchEvent(new CustomEvent("kr-locale-change", { detail: { locale } })); } diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..9863ac5 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { getLocaleMessages, localeLanguageTags, locales, withLocale, type Locale } from "@/lib/i18n"; + +export const siteOrigin = new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://www.kivotos.cc").origin; + +const descriptions: Record = { + "zh-Hans": "基沃托斯铁道公团官方网站。了解我们的 Minecraft 铁路与城市建设,阅读最新新闻,认识团队并获取加入信息。", + "zh-Hant": "基沃托斯鐵道公團官方網站。了解我們的 Minecraft 鐵路與城市建設,閱讀最新消息,認識團隊並取得加入資訊。", + en: "The official KivotosRailway website. Explore our Minecraft railways and cities, read the latest news, meet the team, and find information about joining us.", + jp: "キヴォトス鉄道公団の公式サイト。Minecraft の鉄道・都市開発、最新ニュース、メンバー紹介、参加に関する情報をお届けします。", +}; + +const openGraphLocales: Record = { + "zh-Hans": "zh_CN", "zh-Hant": "zh_TW", en: "en_US", jp: "ja_JP", +}; + +export function localizedUrl(href: string, locale: Locale) { + return new URL(withLocale(href, locale), siteOrigin).href; +} + +export function languageAlternates(href: string) { + return Object.fromEntries(locales.map((locale) => [localeLanguageTags[locale], localizedUrl(href, locale)])); +} + +export function descriptionFromMarkdown(markdown: string) { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/<[^>]+>/g, " ") + .replace(/[#*_`>|]/g, "") + .replace(/\s+/g, " ").trim().slice(0, 160); +} + +export function pageMetadata(locale: Locale, href: string, options: { + title?: string; + description?: string; + article?: boolean; +} = {}): Metadata { + const siteTitle = getLocaleMessages(locale).common.siteTitle; + const title = options.title ? `${options.title} | ${siteTitle}` : siteTitle; + const description = options.description || (options.title ? `${options.title} — ${descriptions[locale]}` : descriptions[locale]); + const url = localizedUrl(href, locale); + const image = new URL("/home-hero-scene.webp", siteOrigin).href; + return { + title: { absolute: title }, + description, + alternates: { canonical: url, languages: { ...languageAlternates(href), "x-default": localizedUrl(href, "zh-Hans") } }, + openGraph: { + type: options.article ? "article" : "website", + title, description, url, siteName: siteTitle, + locale: openGraphLocales[locale], + alternateLocale: locales.filter((item) => item !== locale).map((item) => openGraphLocales[item]), + images: [{ url: image, width: 1440, height: 743, alt: siteTitle }], + }, + twitter: { card: "summary_large_image", title, description, images: [image] }, + }; +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..9c4ce28 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,31 @@ +export type ThemePreference = "light" | "system" | "dark"; + +function isTheme(value: string | null | undefined): value is ThemePreference { + return value === "light" || value === "dark" || value === "system"; +} + +export function getInitialThemePreference(): ThemePreference { + try { + const cookie = document.cookie.split(";").map((entry) => entry.trim()) + .find((entry) => entry.startsWith("kr-theme="))?.slice("kr-theme=".length); + if (isTheme(cookie)) return cookie; + } catch { /* Storage may be disabled independently of cookies. */ } + try { + const saved = window.localStorage.getItem("kr-theme"); + if (isTheme(saved)) return saved; + } catch { /* System preference works without browser storage. */ } + return "system"; +} + +export function applyTheme(preference: ThemePreference) { + const theme = preference === "system" + ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + : preference; + const color = theme === "dark" ? "#10151d" : "#ffffff"; + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + document.documentElement.style.backgroundColor = color; + document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => meta.setAttribute("content", color)); + try { window.localStorage.setItem("kr-theme", preference); } catch { /* Keep the current theme usable. */ } + try { document.cookie = `kr-theme=${preference}; Path=/; Max-Age=31536000; SameSite=Lax`; } catch { /* Optional persistence. */ } +} diff --git a/tests/e2e/site.spec.ts b/tests/e2e/site.spec.ts new file mode 100644 index 0000000..686fc79 --- /dev/null +++ b/tests/e2e/site.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; +import { siteOrigin } from "../../src/lib/seo"; + +test.beforeEach(async ({ page }) => { + // Keep UI tests deterministic and independent of external image hosts. + await page.route(/^https?:\/\/(?!127\.0\.0\.1:4173)/, (route) => route.abort()); +}); + +test("exported HTML has correct languages, canonical URLs and social metadata before JavaScript", async ({ request }) => { + for (const [locale, language] of [["zh-Hans", "zh-Hans"], ["zh-Hant", "zh-Hant"], ["en", "en"], ["jp", "ja"]]) { + for (const suffix of ["", "news/", "policy/privacy-policy/"]) { + const response = await request.get(`/${locale}/${suffix}`); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).toContain(`lang="${language}"`); + expect(html).toContain(`rel="canonical" href="${siteOrigin}/${locale}/${suffix}"`); + expect(html).toContain('hrefLang="ja"'); + expect(html).toContain('property="og:title"'); + expect(html).toContain('name="twitter:card"'); + expect(html).toContain('id="kr-theme-init"'); + expect(html.indexOf('id="kr-theme-init"')).toBeLessThan(html.indexOf("")); + expect(html).not.toContain('\\"body\\":'); + if (!suffix) expect(Buffer.byteLength(html)).toBeLessThan(70_000); + } + } +}); + +test("sitemap URLs resolve, expose all languages and omit error routes", async ({ request }) => { + const robots = await request.get("/robots.txt"); + expect(await robots.text()).toContain(`Sitemap: ${siteOrigin}/sitemap.xml`); + const response = await request.get("/sitemap.xml"); + expect(response.status()).toBe(200); + const xml = await response.text(); + expect(xml).toContain('hreflang="ja"'); + const urls = [...xml.matchAll(/([^<]+)<\/loc>/g)].map((match) => match[1]); + expect(urls.length).toBeGreaterThan(200); + expect(urls.some((url) => /\/(403|404|500|502|503|504)\//.test(url))).toBe(false); + for (const url of urls) { + expect((await request.get(new URL(url).pathname)).status(), url).toBe(200); + } + expect((await request.get("/en/non-existent-page/")).status()).toBe(404); +}); + +test("footer navigation, language changes and back navigation stay in the same document", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/zh-Hans/"); + await page.locator('.theme-toggle button[aria-pressed="true"]').waitFor(); + await page.evaluate(() => { document.documentElement.dataset.navigationCheck = "same-document"; }); + await page.locator('.site-footer a[href="/zh-Hans/members/"]').click(); + await expect(page).toHaveURL(/\/zh-Hans\/members\/$/); + await page.locator('.site-locale button[aria-label="English"]').click(); + await expect(page).toHaveURL(/\/en\/members\/$/); + await expect(page.locator("html")).toHaveAttribute("lang", "en"); + await expect(page.locator('.site-footer a[href="/en/join-us/"]')).toBeVisible(); + await page.locator('.site-locale button[aria-label="日本語"]').click(); + await expect(page).toHaveURL(/\/jp\/members\/$/); + await expect(page.locator("html")).toHaveAttribute("lang", "ja"); + await page.goBack(); + await expect(page).toHaveURL(/\/zh-Hans\/$/); + await expect(page.locator("html")).toHaveAttribute("data-navigation-check", "same-document"); + expect(errors).toEqual([]); +}); + +for (const [system, saved] of [["light", "dark"], ["dark", "light"]] as const) { + test(`saved ${saved} theme applies before React on a ${system} system`, async ({ page, context }) => { + await page.emulateMedia({ colorScheme: system }); + await context.addCookies([{ name: "kr-theme", value: saved, url: "http://127.0.0.1:4173" }]); + await page.route("**/_next/**/*.js", (route) => route.abort()); + await page.goto("/en/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("data-theme", saved); + await expect(page.locator("body")).toHaveCSS("background-color", saved === "dark" ? "rgb(16, 21, 29)" : "rgb(255, 255, 255)"); + expect(await page.locator('meta[name="theme-color"]').first().getAttribute("content")).toBe(saved === "dark" ? "#10151d" : "#ffffff"); + }); +} + +test("theme switching persists through refresh, SPA navigation and mobile menu opening", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/en/"); + await page.locator('.theme-toggle button').first().click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.locator('.site-footer a[href="/en/members/"]').click(); + await expect(page).toHaveURL(/\/en\/members\/$/); + await page.locator(".site-menu-button").click(); + await expect(page.locator(".site-navigation")).toHaveCSS("background-color", "rgb(26, 34, 45)"); + await expect(page.locator('meta[name="theme-color"]').first()).toHaveAttribute("content", "#10151d"); + await page.locator('.theme-toggle button').last().click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); +}); + +test("themes still work when localStorage is blocked", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(Storage.prototype, "getItem", { value: () => { throw new Error("blocked"); } }); + Object.defineProperty(Storage.prototype, "setItem", { value: () => { throw new Error("blocked"); } }); + }); + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto("/en/"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.locator('.theme-toggle button').nth(1).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); +}); diff --git a/tests/export/assets.test.ts b/tests/export/assets.test.ts new file mode 100644 index 0000000..c76e911 --- /dev/null +++ b/tests/export/assets.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { readdir, readFile, access } from "node:fs/promises"; +import path from "node:path"; +import { test } from "node:test"; +import { locales, withLocale } from "../../src/lib/i18n"; + +// Existing issue #1 is outside this change. Keep the exact baseline visible; +// any newly missing asset fails this test. Remove entries when the cache is repaired. +const knownMissing = new Set([ + "/media/news/897d764a65897170.jpg", "/media/news/8ecb7a6b90f318e0.png", + "/media/news/852dd5b369bf377d.jpg", "/media/news/7f7c61ff4b22140d.png", + "/media/news/acd56aafe8b41f05.png", "/media/news/7ae2b8fba574195b.png", + "/media/news/faa9264b9cbf052b.png", "/media/news/02882b262ac53ec2.png", + // This relative cover already exists in all four source files in HEAD. + ...locales.map((locale) => `${withLocale("/news/20240917-mcnews", locale)}2024-09-18_00.24.45.png`), +]); + +async function files(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + return (await Promise.all(entries.map((entry) => entry.isDirectory() + ? files(path.join(directory, entry.name)) : [path.join(directory, entry.name)]))).flat(); +} + +test("exported HTML/CSS references no newly missing static assets", async (t) => { + const output = path.resolve("out"); + const assets = new Set(); + for (const file of (await files(output)).filter((name) => /\.(html|css)$/.test(name))) { + const text = (await readFile(file, "utf8")) + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replaceAll(""", '"').replaceAll("&", "&"); + const base = new URL(path.relative(output, file).replaceAll(path.sep, "/"), "https://export.invalid/"); + for (const match of text.matchAll(/(?:\bsrc=["']|url\(["']?)([^\s"'<>)]*)/g)) { + if (!match[1]) continue; + const url = new URL(match[1], base); + if (url.origin === base.origin) assets.add(decodeURIComponent(url.pathname)); + } + } + const missing: string[] = []; + for (const asset of assets) { + try { await access(path.join(output, asset)); } + catch { missing.push(asset); } + } + const baseline = missing.filter((asset) => knownMissing.has(asset)); + if (baseline.length) t.diagnostic(`Existing missing image URLs (excluded image repair): ${baseline.join(", ")}`); + assert.deepEqual(missing.filter((asset) => !knownMissing.has(asset)), []); +}); diff --git a/tests/fixtures/news/en/two.md b/tests/fixtures/news/en/two.md new file mode 100644 index 0000000..2bafd0d --- /dev/null +++ b/tests/fixtures/news/en/two.md @@ -0,0 +1,6 @@ +--- +title: The translated second article +date: 2026-10-03 +slug: two +--- +English translation body. diff --git a/tests/fixtures/news/zh-CN/four.md b/tests/fixtures/news/zh-CN/four.md new file mode 100644 index 0000000..5a9b683 --- /dev/null +++ b/tests/fixtures/news/zh-CN/four.md @@ -0,0 +1,6 @@ +--- +title: 第四篇中文文章 +date: 2026-10-01 +slug: four +--- +第四篇中文正文。 diff --git a/tests/fixtures/news/zh-CN/one.md b/tests/fixtures/news/zh-CN/one.md new file mode 100644 index 0000000..84acccd --- /dev/null +++ b/tests/fixtures/news/zh-CN/one.md @@ -0,0 +1,6 @@ +--- +title: 最新中文文章 +date: 2026-10-04 +slug: one +--- +只存在于中文的最新正文。 diff --git a/tests/fixtures/news/zh-CN/three.md b/tests/fixtures/news/zh-CN/three.md new file mode 100644 index 0000000..cb61242 --- /dev/null +++ b/tests/fixtures/news/zh-CN/three.md @@ -0,0 +1,6 @@ +--- +title: 第三篇中文文章 +date: 2026-10-02 +slug: three +--- +第三篇中文正文。 diff --git a/tests/fixtures/news/zh-CN/two.md b/tests/fixtures/news/zh-CN/two.md new file mode 100644 index 0000000..c3d72ab --- /dev/null +++ b/tests/fixtures/news/zh-CN/two.md @@ -0,0 +1,6 @@ +--- +title: 第二篇中文文章 +date: 2026-10-03 +slug: two +--- +第二篇中文正文。 diff --git a/tests/unit/content.test.ts b/tests/unit/content.test.ts new file mode 100644 index 0000000..9ffb57c --- /dev/null +++ b/tests/unit/content.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; +import { createNewsReader } from "../../src/data/news"; +import { isLocale, localeLanguageTags, withLocale } from "../../src/lib/i18n"; +import { languageAlternates, pageMetadata } from "../../src/lib/seo"; + +const news = createNewsReader(path.resolve("tests/fixtures/news")); + +test("a partially translated catalog keeps new source articles and uses available translations", async () => { + const list = await news.getNewsList("en"); + assert.deepEqual(list.map((item) => item.slug), ["one", "two", "three", "four"]); + assert.equal(list[0].title, "最新中文文章"); + assert.equal(list[1].title, "The translated second article"); + for (const item of list) { + assert.equal("body" in item, false); + assert.equal("markdownFile" in item, false); + } +}); + +test("detail fallback is per slug and records the real content language", async () => { + const fallback = await news.getNewsBySlug("one", "en"); + assert.equal(fallback?.contentLocale, "zh-Hans"); + assert.match(fallback?.body ?? "", /最新正文/); + const translated = await news.getNewsBySlug("two", "en"); + assert.equal(translated?.contentLocale, "en"); + assert.match(translated?.body ?? "", /English translation/); + assert.equal(await news.getNewsBySlug("missing", "en"), null); +}); + +test("a missing language directory falls back and the homepage receives only three summaries", async () => { + assert.equal((await news.getNewsList("jp")).length, 4); + const latest = await news.getLatestNews("en"); + assert.deepEqual(latest.map((item) => item.slug), ["one", "two", "three"]); + assert.ok(latest.every((item) => !("body" in item))); +}); + +test("locale URLs retain query/hash and SEO language tags do not change public routes", () => { + assert.equal(withLocale("/zh-Hans/news/?page=2&lang=zh-CN#recent", "jp"), "/jp/news/?page=2#recent"); + assert.equal(withLocale("mailto:connect@kivotos.cc", "en"), "mailto:connect@kivotos.cc"); + assert.equal(isLocale("ja"), false); + assert.equal(localeLanguageTags.jp, "ja"); + assert.match(languageAlternates("/news").ja, /\/jp\/news\/$/); + const metadata = pageMetadata("en", "/about", { title: "About" }); + assert.match(String(metadata.alternates?.canonical), /\/en\/about\/$/); + assert.ok(metadata.twitter && "card" in metadata.twitter); + assert.equal(metadata.twitter.card, "summary_large_image"); +});