mirror of
https://github.com/KivotosRailway/website.git
synced 2026-11-03 11:06:32 +09:00
优化页面数据加载并完善多语言 SEO、新闻回退、主题首屏及自动化回归测试
This commit is contained in:
@@ -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
|
||||
@@ -15,3 +15,7 @@
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Regression test output
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
@@ -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` 中的对应记录。
|
||||
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+143
-134
@@ -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
|
||||
|
||||
+10
-2
@@ -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));
|
||||
})();
|
||||
|
||||
@@ -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<number> {
|
||||
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("</head>") || !/<html\b[^>]*\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(/(<html\b[^>]*\blang=")[^"]*"/, `$1${language}"`)
|
||||
.replace(/<script id="kr-theme-init">[\s\S]*?<\/script>/g, "")
|
||||
.replace("</head>", `<script id="kr-theme-init">${bootstrap}</script></head>`);
|
||||
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.`);
|
||||
@@ -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"));
|
||||
@@ -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 <HttpStatusPage code={403} />;
|
||||
}
|
||||
|
||||
@@ -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 <HttpStatusPage code={500} />;
|
||||
}
|
||||
|
||||
@@ -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 <HttpStatusPage code={502} />;
|
||||
}
|
||||
|
||||
@@ -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 <HttpStatusPage code={503} />;
|
||||
}
|
||||
|
||||
@@ -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 <HttpStatusPage code={504} />;
|
||||
}
|
||||
|
||||
@@ -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 <HomePageContent newsByLocale={newsByLocale} />;
|
||||
return <HomePageContent locale="zh-Hans" news={await getLatestNews("zh-Hans")} />;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/403/page";
|
||||
export { default, metadata } from "@/app/(legacy)/403/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/500/page";
|
||||
export { default, metadata } from "@/app/(legacy)/500/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/502/page";
|
||||
export { default, metadata } from "@/app/(legacy)/502/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/503/page";
|
||||
export { default, metadata } from "@/app/(legacy)/503/page";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from "@/app/(legacy)/504/page";
|
||||
export { default, metadata } from "@/app/(legacy)/504/page";
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 <SiteShell locale={canonicalLocale}>{children}</SiteShell>;
|
||||
if (!isLocale(locale)) notFound();
|
||||
return <SiteShell locale={locale}>{children}</SiteShell>;
|
||||
}
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 <NewsDetailClient item={await getNewsBySlug(slug, locale)} locale={locale} />;
|
||||
const item = await getNewsBySlug(slug, locale);
|
||||
if (!item) notFound();
|
||||
return <NewsDetailClient item={item} locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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 <HomePageContent locale={locale} news={await getLatestNews(locale)} />;
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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 <PolicyPageContent catalog={catalog} />;
|
||||
const document = await getPolicyDocument(slug, locale);
|
||||
if (!document) notFound();
|
||||
return <PolicyPageContent document={document} locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -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 }> }) {
|
||||
|
||||
@@ -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` };
|
||||
}
|
||||
@@ -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<MetadataRoute.Sitemap> {
|
||||
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) },
|
||||
})));
|
||||
}
|
||||
@@ -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<Locale, {
|
||||
railway: { title: [string, string]; description: [string, string] };
|
||||
@@ -30,40 +26,8 @@ const homeFeatureCopy: Record<Locale, {
|
||||
},
|
||||
};
|
||||
|
||||
export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale, NewsItem[]> }) {
|
||||
const [locale, setLocale] = useState<Locale>(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<Locale,
|
||||
</div>
|
||||
|
||||
<div className="news-grid" aria-label={t.home.latest}>
|
||||
{visibleNews.slice(0, 3).map((post) => (
|
||||
{news.map((post) => (
|
||||
<article key={post.id} className="news-card">
|
||||
<Link href={withLocale(post.href, locale)} className="news-card-link" aria-label={post.title}>
|
||||
<div className="news-visual" style={{ backgroundImage: `url(${post.cover})` }} />
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<ThemePreference>("system");
|
||||
const [locale, setLocale] = useState<Locale>(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 }[] = [
|
||||
|
||||
@@ -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 <main className="news-detail-page"><div className="news-detail-inner"><p>未找到该文章。</p><Link href={withLocale("/news", locale)}>{messages.common.backToNews}</Link></div></main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="news-detail-page">
|
||||
<div className="news-detail-inner">
|
||||
@@ -20,7 +16,7 @@ export function NewsDetailClient({ item, locale }: { item: NewsItem | null; loca
|
||||
<p className="news-detail-tag">{item.category}</p>
|
||||
<h1>{item.title}</h1>
|
||||
<time>{item.date}</time>
|
||||
{locale !== "zh-Hans" && <div className="news-detail-ai-banner" aria-label="AI translated content">{getAiTranslationNotice(locale)}</div>}
|
||||
{item.contentLocale !== "zh-Hans" && <div className="news-detail-ai-banner" aria-label="AI translated content">{getAiTranslationNotice(locale)}</div>}
|
||||
<div className="news-detail-cover" style={{ backgroundImage: `url(${item.cover})` }} />
|
||||
<div className="news-detail-body" dangerouslySetInnerHTML={{ __html: markdownToHtml(item.body) }} />
|
||||
<NewsShare title={item.title} />
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [locale, setLocale] = useState<Locale>(useRouteLocale());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
@@ -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<Record<Locale, PolicyDocument>>;
|
||||
|
||||
export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) {
|
||||
const [locale, setLocale] = useState<Locale>(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 (
|
||||
<>
|
||||
<main className="title-page policy-page">
|
||||
|
||||
+38
-27
@@ -14,8 +14,16 @@ export type NewsItem = {
|
||||
tags: string[];
|
||||
markdownFile: string;
|
||||
body: string;
|
||||
contentLocale: Locale;
|
||||
};
|
||||
|
||||
export type NewsSummary = Pick<NewsItem, "id" | "slug" | "title" | "date" | "cover" | "href" | "category">;
|
||||
|
||||
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<NewsItem[]> {
|
||||
const directory = path.join(process.cwd(), "src", "data", "news", "posts", localeContentDirectory[locale]);
|
||||
async function readNewsList(root: string, locale: Locale): Promise<NewsItem[]> {
|
||||
const directory = path.join(root, localeContentDirectory[locale]);
|
||||
|
||||
try {
|
||||
const files = (await fs.readdir(directory))
|
||||
@@ -107,7 +116,7 @@ async function readNewsList(locale: Locale): Promise<NewsItem[]> {
|
||||
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<NewsItem[]> {
|
||||
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<NewsItem[]> {
|
||||
const items = await readNewsList(locale);
|
||||
return locale === "zh-Hans" || items.length > 0 ? items : readNewsList("zh-Hans");
|
||||
}
|
||||
|
||||
export async function getNewsCatalog(): Promise<Record<Locale, NewsItem[]>> {
|
||||
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<Locale, NewsItem[]>;
|
||||
return source.map((item) => bySlug.get(item.slug) ?? item);
|
||||
}
|
||||
|
||||
export async function getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise<NewsItem | null> {
|
||||
const items = await getNewsList(locale);
|
||||
return items.find((item) => item.slug === slug) ?? null;
|
||||
return {
|
||||
async getNewsList(locale: Locale = "zh-Hans"): Promise<NewsSummary[]> {
|
||||
return (await readLocalizedNews(locale)).map(summarize);
|
||||
},
|
||||
async getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise<NewsItem | null> {
|
||||
return (await readLocalizedNews(locale)).find((item) => item.slug === slug) ?? null;
|
||||
},
|
||||
async getLatestNews(locale: Locale = "zh-Hans"): Promise<NewsSummary[]> {
|
||||
return (await readLocalizedNews(locale)).slice(0, 3).map(summarize);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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"),
|
||||
);
|
||||
|
||||
@@ -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<PolicyDocum
|
||||
body: content,
|
||||
html: markdownToHtml(content),
|
||||
markdownFile: `src/data/policy/${localeContentDirectory[locale]}/${slug}.md`,
|
||||
contentLocale: locale,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Locale, PolicyDocument>>> {
|
||||
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<Record<Locale, PolicyDocument>>;
|
||||
export async function getPolicyDocument(slug: string, locale: Locale): Promise<PolicyDocument | null> {
|
||||
if (!isPolicySlug(slug)) return null;
|
||||
const document = await readPolicy(slug, locale);
|
||||
return document ?? (locale === "zh-Hans" ? null : readPolicy(slug, "zh-Hans"));
|
||||
}
|
||||
|
||||
export function getPolicySlugs() {
|
||||
|
||||
+6
-1
@@ -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<Locale, string> = {
|
||||
"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<Locale, string> = {
|
||||
"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 } }));
|
||||
}
|
||||
|
||||
@@ -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<Locale, string> = {
|
||||
"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<Locale, string> = {
|
||||
"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] },
|
||||
};
|
||||
}
|
||||
@@ -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. */ }
|
||||
}
|
||||
@@ -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("</head>"));
|
||||
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>([^<]+)<\/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");
|
||||
});
|
||||
@@ -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<string[]> {
|
||||
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<string>();
|
||||
for (const file of (await files(output)).filter((name) => /\.(html|css)$/.test(name))) {
|
||||
const text = (await readFile(file, "utf8"))
|
||||
.replace(/<script\b[^>]*>[\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)), []);
|
||||
});
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: The translated second article
|
||||
date: 2026-10-03
|
||||
slug: two
|
||||
---
|
||||
English translation body.
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 第四篇中文文章
|
||||
date: 2026-10-01
|
||||
slug: four
|
||||
---
|
||||
第四篇中文正文。
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 最新中文文章
|
||||
date: 2026-10-04
|
||||
slug: one
|
||||
---
|
||||
只存在于中文的最新正文。
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 第三篇中文文章
|
||||
date: 2026-10-02
|
||||
slug: three
|
||||
---
|
||||
第三篇中文正文。
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: 第二篇中文文章
|
||||
date: 2026-10-03
|
||||
slug: two
|
||||
---
|
||||
第二篇中文正文。
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user