feat(docs): add bilingual package documentation site

This commit is contained in:
Maofeng
2026-09-20 15:53:28 +08:00
parent 60f6f1fb1a
commit dc80bd8ae0
111 changed files with 6531 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+35
View File
@@ -0,0 +1,35 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
You can also try [the experimental native React Compiler support in plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md#rust-react-compiler) by using `compiler: true` in the plugin options instead of using the Babel plugin.
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+255
View File
@@ -0,0 +1,255 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "docs",
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@rolldown/plugin-babel": "^0.2.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.83.0", "", { "os": "android", "cpu": "arm" }, "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.83.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.83.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.83.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.83.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.83.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.83.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.83.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.83.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.83.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.83.0", "", { "os": "linux", "cpu": "none" }, "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.83.0", "", { "os": "linux", "cpu": "none" }, "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.83.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.83.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.83.0", "", { "os": "linux", "cpu": "x64" }, "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.83.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.83.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.83.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.83.0", "", { "os": "win32", "cpu": "x64" }, "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.8", "", { "os": "android", "cpu": "arm" }, "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.8", "", { "os": "android", "cpu": "arm64" }, "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.8", "", { "os": "linux", "cpu": "arm" }, "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.8", "", { "os": "none", "cpu": "arm64" }, "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng=="],
"@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.4", "", { "dependencies": { "picomatch": "^4.0.7" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/node": ["@types/node@24.13.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="],
"browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.430", "", {}, "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="],
"oxlint": ["oxlint@1.83.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.83.0", "@oxlint/binding-android-arm64": "1.83.0", "@oxlint/binding-darwin-arm64": "1.83.0", "@oxlint/binding-darwin-x64": "1.83.0", "@oxlint/binding-freebsd-x64": "1.83.0", "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", "@oxlint/binding-linux-arm-musleabihf": "1.83.0", "@oxlint/binding-linux-arm64-gnu": "1.83.0", "@oxlint/binding-linux-arm64-musl": "1.83.0", "@oxlint/binding-linux-ppc64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-musl": "1.83.0", "@oxlint/binding-linux-s390x-gnu": "1.83.0", "@oxlint/binding-linux-x64-gnu": "1.83.0", "@oxlint/binding-linux-x64-musl": "1.83.0", "@oxlint/binding-openharmony-arm64": "1.83.0", "@oxlint/binding-win32-arm64-msvc": "1.83.0", "@oxlint/binding-win32-ia32-msvc": "1.83.0", "@oxlint/binding-win32-x64-msvc": "1.83.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"rolldown": ["rolldown@1.2.8", "", { "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-x64-msvc": "1.2.8" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>docs</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"name": "docs",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@icones/react": "^0.0.1",
"@workspace/i18n": "workspace:*",
"@workspace/preferences": "workspace:*",
"@workspace/ui": "workspace:*",
"@workspace/search": "workspace:*",
"clsx": "^2.1.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwind-merge": "^3.7.0"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@icones/vite": "^0.0.1",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"oxlint": "^1.81.0",
"satteri": "^0.10.5",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0",
"vite-plugin-satteri": "^0.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+145
View File
@@ -0,0 +1,145 @@
import {
Suspense,
lazy,
startTransition,
useEffect,
useState,
type ComponentType,
} from "react"
import { rootCSSVariables } from "./components/layout"
import { LocaleProvider } from "./components/locale-provider"
import { DocsPreferencesProvider } from "./components/preferences-provider"
import { DocsSearchProvider } from "./components/docs-search-provider"
import { resolveDocLocale } from "./content/registry"
import { useTranslate } from "./lib/locale"
const Index = lazy(() => import("./pages/index"))
const Article = lazy(() => import("./pages/article"))
const Settings = lazy(() => import("./pages/settings"))
type RouteDefinition = {
Component: ComponentType
matches: (pathname: string) => boolean
}
const routes: RouteDefinition[] = [
{
Component: Index,
matches: (pathname) =>
pathname === "/" || /^\/(?:en-US|zh-Hans)\/?$/.test(pathname),
},
{
Component: Article,
matches: (pathname) =>
/^(?:\/(?:en-US|zh-Hans))?\/articles?(?:\/|$)/.test(pathname),
},
{
Component: Settings,
matches: (pathname) =>
/^(?:\/(?:en-US|zh-Hans))?\/settings\/?$/.test(pathname),
},
]
function App() {
const [currentUrl, setCurrentUrl] = useState(() => window.location.href)
useEffect(() => {
function updateRoute() {
startTransition(() => setCurrentUrl(window.location.href))
}
function navigate(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
const target = event.target
if (!(target instanceof Element)) return
const anchor = target.closest<HTMLAnchorElement>("a[href]")
if (
!anchor ||
anchor.hasAttribute("download") ||
(anchor.target && anchor.target !== "_self")
) {
return
}
const destination = new URL(anchor.href, window.location.href)
if (destination.origin !== window.location.origin) return
const current = new URL(window.location.href)
if (
destination.pathname === current.pathname &&
destination.search === current.search &&
destination.hash
) {
return
}
event.preventDefault()
window.history.pushState(null, "", destination)
window.scrollTo({ left: 0, top: 0 })
updateRoute()
}
window.addEventListener("popstate", updateRoute)
document.addEventListener("click", navigate)
return () => {
window.removeEventListener("popstate", updateRoute)
document.removeEventListener("click", navigate)
}
}, [])
const { pathname } = new URL(currentUrl)
const Route = routes.find((route) => route.matches(pathname))?.Component
const locale = resolveDocLocale(pathname)
return (
<DocsPreferencesProvider routeLocale={locale}>
<LocaleProvider locale={locale}>
<DocsSearchProvider>
<div style={rootCSSVariables}>
<Suspense
fallback={
<p className="p-6 text-slate-500 dark:text-slate-400">
Loading
</p>
}
>
{Route ? <Route key={pathname} /> : <NotFound />}
</Suspense>
</div>
</DocsSearchProvider>
</LocaleProvider>
</DocsPreferencesProvider>
)
}
function NotFound() {
const t = useTranslate()
return (
<main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center px-6">
<h1 className="text-4xl">
{t({ zh: "页面不存在", us: "Page not found" })}
</h1>
<a
className="mt-4 w-fit text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
href="/"
>
{t({ zh: "返回首页", us: "Back to home" })}
</a>
</main>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File
@@ -0,0 +1,179 @@
import { I18nProvider } from "@workspace/i18n"
import {
SearchDialog,
SearchProvider,
type SearchAdapter,
type SearchResultItem,
} from "@workspace/search"
import { messages as searchMessagesEnUS } from "@workspace/search/locales/en-US"
import { messages as searchMessagesZhHans } from "@workspace/search/locales/zh-Hans"
import React from "react"
import {
getDocHref,
getDocPackages,
getDocSearchText,
getDocSectionLabel,
} from "../content/registry"
import type {
DocLocale,
DocPackage,
DocPage,
DocTocItem,
} from "../content/types"
import { useLocale } from "../lib/locale"
const searchCatalogs = {
"en-US": searchMessagesEnUS,
"zh-Hans": searchMessagesZhHans,
} as const
type DocsSearchPayload = {
href: string
}
export function DocsSearchProvider({
children,
}: {
children: React.ReactNode
}) {
const locale = useLocale()
const adapter = React.useMemo(() => createDocsSearchAdapter(locale), [locale])
return (
<I18nProvider catalogs={searchCatalogs} locale={locale}>
<SearchProvider
adapter={adapter}
historyStorageKey={`docs-search-history:${locale}`}
onSelect={({ item }) => {
if (isDocsSearchPayload(item.payload)) navigate(item.payload.href)
}}
>
{children}
<SearchDialog className="font-sans" />
</SearchProvider>
</I18nProvider>
)
}
function createDocsSearchAdapter(locale: DocLocale): SearchAdapter {
const docPackages = getDocPackages(locale)
return {
async search({ query, signal }) {
const normalizedQuery = normalize(query)
if (!normalizedQuery) return { groups: [] }
const terms = normalizedQuery.split(/\s+/).filter(Boolean)
const groups = docPackages.flatMap((docPackage) => {
const items = docPackage.pages
.map((page) => ({
item: createResultItem(docPackage, page, locale),
score: getMatchScore(
normalizedQuery,
terms,
docPackage.label,
page
),
}))
.filter((result) => result.score >= 0)
.sort(
(left, right) =>
right.score - left.score ||
left.item.title.localeCompare(right.item.title, locale)
)
.map((result) => result.item)
return items.length > 0
? [
{
id: docPackage.key,
label: docPackage.label,
items,
total: items.length,
},
]
: []
})
if (signal.aborted) return { groups: [] }
return { groups }
},
}
}
function createResultItem(
docPackage: DocPackage,
page: DocPage,
locale: DocLocale
): SearchResultItem {
return {
id: `${page.section}/${page.slug}`,
title: page.title,
description: page.description,
keywords: [
docPackage.label,
getDocSectionLabel(page.section, locale),
...page.toc.flatMap(flattenTocTitles),
getDocSearchText(page),
],
meta: [getDocSectionLabel(page.section, locale)],
payload: {
href: getDocHref(docPackage, page),
} satisfies DocsSearchPayload,
}
}
function getMatchScore(
query: string,
terms: readonly string[],
packageLabel: string,
page: DocPage
) {
const title = normalize(page.title)
const description = normalize(page.description)
const packageName = normalize(packageLabel)
const toc = normalize(page.toc.flatMap(flattenTocTitles).join(" "))
const body = normalize(getDocSearchText(page))
const searchableText = [packageName, title, description, toc, body].join(" ")
if (!terms.every((term) => searchableText.includes(term))) return -1
let score = 0
if (title === query) score += 100
else if (title.startsWith(query)) score += 70
else if (title.includes(query)) score += 50
if (packageName === query) score += 40
else if (packageName.includes(query)) score += 20
if (description.includes(query)) score += 15
if (toc.includes(query)) score += 10
if (page.section === "overview") score += 2
return score
}
function flattenTocTitles(item: DocTocItem): readonly string[] {
return [item.title, ...(item.items?.flatMap(flattenTocTitles) ?? [])]
}
function normalize(value: string) {
return value.normalize("NFKC").toLocaleLowerCase().trim()
}
function isDocsSearchPayload(value: unknown): value is DocsSearchPayload {
return (
typeof value === "object" &&
value !== null &&
"href" in value &&
typeof value.href === "string"
)
}
function navigate(href: string) {
const destination = new URL(href, window.location.href)
if (destination.href === window.location.href) return
window.history.pushState(null, "", destination)
window.scrollTo({ left: 0, top: 0 })
window.dispatchEvent(new PopStateEvent("popstate"))
}
+264
View File
@@ -0,0 +1,264 @@
import { Icon } from "@icones/react"
import { usePreference } from "@workspace/preferences"
import { useEffect, useId, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { LocaleFlag } from "./locale-flag"
import { getLocalizedHref } from "../content/registry"
import type { DocLocale } from "../content/types"
import { useLocale, useTranslate } from "../lib/locale"
import { useIsMobile } from "../lib/use-mobile"
type LocaleOption = {
label: string
locale: DocLocale
}
const localeOptions: readonly LocaleOption[] = [
{ label: "简体中文", locale: "zh-Hans" },
{ label: "English", locale: "en-US" },
]
export function LanguageSwitcher() {
const locale = useLocale()
const t = useTranslate()
const isMobile = useIsMobile()
const [, setLocaleMode] = usePreference("locale-mode")
const [, setPreferredLocale] = usePreference("locale")
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const selectedLocale = getLocaleOption(locale)
useEffect(() => {
if (!open) return
function isInsideSwitcher(target: EventTarget | null) {
return (
target instanceof Node &&
(rootRef.current?.contains(target) || menuRef.current?.contains(target))
)
}
function closeAndRestoreFocus() {
setOpen(false)
window.requestAnimationFrame(() => triggerRef.current?.focus())
}
function handlePointerDown(event: PointerEvent) {
if (isInsideSwitcher(event.target)) return
if (isMobile) {
closeAndRestoreFocus()
} else {
setOpen(false)
}
}
function handleFocusIn(event: FocusEvent) {
if (!isMobile && !isInsideSwitcher(event.target)) setOpen(false)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault()
closeAndRestoreFocus()
return
}
if (!isMobile || event.key !== "Tab") return
const items = getMenuItems(menuRef.current)
const firstItem = items[0]
const lastItem = items.at(-1)
if (!firstItem || !lastItem) return
if (event.shiftKey && document.activeElement === firstItem) {
event.preventDefault()
lastItem.focus()
} else if (!event.shiftKey && document.activeElement === lastItem) {
event.preventDefault()
firstItem.focus()
}
}
const previousOverflow = document.body.style.overflow
if (isMobile) {
document.body.style.overflow = "hidden"
window.requestAnimationFrame(() => {
menuRef.current
?.querySelector<HTMLElement>('[aria-checked="true"]')
?.focus()
})
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("focusin", handleFocusIn)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("focusin", handleFocusIn)
document.removeEventListener("keydown", handleKeyDown)
if (isMobile) document.body.style.overflow = previousOverflow
}
}, [isMobile, open])
function focusMenuItem(position: "first" | "last") {
setOpen(true)
window.requestAnimationFrame(() => {
const items = getMenuItems(menuRef.current)
const item = position === "first" ? items[0] : items.at(-1)
item?.focus()
})
}
function selectLocale(nextLocale: DocLocale) {
setPreferredLocale(nextLocale)
setLocaleMode("manual")
setOpen(false)
window.requestAnimationFrame(() => triggerRef.current?.focus())
}
const menu = (
<div
ref={menuRef}
id={menuId}
role={isMobile ? "dialog" : undefined}
aria-label={t({ zh: "语言", us: "Language" })}
aria-modal={isMobile || undefined}
className="w-[min(80vw,20rem)] rounded-xl border border-slate-200 bg-white/95 p-4 shadow-2xl backdrop-blur-md md:w-auto md:min-w-40 md:p-1 dark:border-slate-800 dark:bg-slate-950/95 dark:shadow-black/60"
>
<p className="px-2 py-2 text-sm text-slate-500 md:text-xs dark:text-slate-400">
{t({ zh: "语言", us: "Language" })}
</p>
<ul
role="menu"
aria-label={t({ zh: "语言", us: "Languages" })}
onKeyDown={(event) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "Home" &&
event.key !== "End"
) {
return
}
event.preventDefault()
const items = getMenuItems(event.currentTarget)
const currentIndex = items.indexOf(
document.activeElement as HTMLButtonElement
)
let nextIndex = currentIndex
if (event.key === "Home") nextIndex = 0
if (event.key === "End") nextIndex = items.length - 1
if (event.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % items.length
}
if (event.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + items.length) % items.length
}
items[nextIndex]?.focus()
}}
>
{localeOptions.map((option) => {
const selected = option.locale === locale
return (
<li role="none" key={option.locale}>
<a
role="menuitemradio"
aria-checked={selected}
lang={option.locale}
href={getLocalizedHref(window.location.pathname, option.locale)}
className="flex h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-left whitespace-nowrap transition-colors hover:bg-slate-100 focus:bg-slate-100 focus:outline-none aria-checked:text-indigo-600 dark:hover:bg-slate-900 dark:focus:bg-slate-900 dark:aria-checked:text-indigo-400"
onClick={() => selectLocale(option.locale)}
>
<LocaleFlag
locale={option.locale}
className="size-5 shrink-0"
/>
<span>{option.label}</span>
{selected && (
<Icon
name="tabler:check"
className="ml-auto size-4 shrink-0"
/>
)}
</a>
</li>
)
})}
</ul>
</div>
)
return (
<div ref={rootRef} className="flex items-center md:relative">
<button
ref={triggerRef}
type="button"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup={isMobile ? "dialog" : "menu"}
aria-label={t({ zh: "选择语言", us: "Choose language" })}
title={t({ zh: "选择语言", us: "Choose language" })}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors outline-none hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-950"
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault()
focusMenuItem("first")
} else if (event.key === "ArrowUp") {
event.preventDefault()
focusMenuItem("last")
}
}}
>
<LocaleFlag locale={selectedLocale.locale} className="size-7" />
</button>
{open &&
(isMobile ? (
createPortal(
<div className="fixed inset-0 z-100 flex items-center justify-center p-4">
<button
type="button"
tabIndex={-1}
aria-label={t({
zh: "关闭语言菜单",
us: "Close language menu",
})}
className="absolute inset-0 cursor-default bg-black/60 backdrop-blur-sm"
onClick={() => {
setOpen(false)
window.requestAnimationFrame(() =>
triggerRef.current?.focus()
)
}}
/>
<div className="relative">{menu}</div>
</div>,
document.body
)
) : (
<div className="absolute top-full right-0 z-100 pt-2">{menu}</div>
))}
</div>
)
}
function getMenuItems(root: ParentNode | null) {
return Array.from(
root?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []
)
}
function getLocaleOption(locale: DocLocale) {
return localeOptions.find((option) => option.locale === locale)!
}
+94
View File
@@ -0,0 +1,94 @@
import { Icon } from "@icones/react"
import { cn } from "../lib/utils"
import { useTranslate } from "../lib/locale"
import React from "react"
import { SearchTrigger } from "./search-trigger"
import { SettingsLink } from "./settings-link"
// oxlint-disable-next-line react/only-export-components -- Shared layout tokens are consumed by App.
export const rootCSSVariables = {
"--cw": "60rem",
"--max-lw": "18rem",
"--max-rw": "15rem",
"--min-lw": "12rem",
"--min-rw": "10rem",
"--w": "calc(var(--max-lw) + var(--cw) + var(--max-rw))",
"--page-pl": "calc(2rem + max(0px, (100vw - var(--w)) * 6 / 11 - 1px))",
"--page-pr": "calc(2rem + max(0px, 100vw - var(--w)) * 5 / 11)",
} as const as React.CSSProperties
export function Container({ className, ...rest }: React.ComponentProps<"div">) {
return (
<div
className={cn("px-4 md:pr-(--page-pr) md:pl-(--page-pl)", className)}
{...rest}
/>
)
}
export function HomeButton({
href = "/",
label,
}: {
href?: string
label?: string
}) {
const t = useTranslate()
return (
<a
href={href}
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
>
<Icon name="tabler:smart-home" className="size-5" />
{label ?? t({ zh: "首页", us: "Home" })}
</a>
)
}
export function BackButton({
href,
children,
}: {
href?: string
children?: React.ReactNode
}) {
const t = useTranslate()
function handleClick(e: React.MouseEvent) {
if (!href) {
e.preventDefault()
history.back()
}
}
return (
<a
href={href ?? "/"}
onClick={href ? undefined : handleClick}
className="flex items-center gap-2 py-1 transition-colors hover:text-indigo-500 dark:hover:text-indigo-400"
>
<span className="inline-flex h-6 w-4 items-center justify-center rounded-xl bg-slate-200 dark:bg-slate-800">
<Icon name="tabler:chevron-compact-left" className="size-4" />
</span>
{children ?? <span>{t({ zh: "返回", us: "Back" })}</span>}
</a>
)
}
export function SlashLine({ className }: { className?: string }) {
return (
<div
className={cn("mx-2 h-4 w-px rotate-30 bg-current opacity-35", className)}
/>
)
}
export function Actions({ className }: { className?: string }) {
return (
<div className={cn("inline-flex items-center gap-2", className)}>
<SearchTrigger />
<SettingsLink />
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { Icon } from "@icones/react"
import type { DocLocale } from "../content/types"
export function LocaleFlag({
className,
locale,
}: {
className?: string
locale: DocLocale
}) {
return locale === "zh-Hans" ? (
<Icon name="flag:cn-circle" className={className} />
) : (
<Icon name="flag:us-circle" className={className} />
)
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, type ReactNode } from "react"
import type { DocLocale } from "../content/types"
import { LocaleContext } from "../lib/locale"
export function LocaleProvider({
children,
locale,
}: {
children: ReactNode
locale: DocLocale
}) {
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
return (
<LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
)
}
+93
View File
@@ -0,0 +1,93 @@
import type { MdxComponents } from "../content/types"
/* oxlint-disable react/only-export-components -- This module exports an MDX component registry. */
export const mdxComponents: MdxComponents = {
a: MdxAnchor,
blockquote: MdxBlockquote,
code: MdxCode,
h2: MdxHeading2,
h3: MdxHeading3,
pre: MdxPre,
}
function MdxHeading2({ children, id, ...props }: React.ComponentProps<"h2">) {
return (
<h2
id={id}
className="group scroll-mt-24 pt-7 text-2xl font-semibold tracking-tight text-slate-950 first:pt-0 dark:text-slate-100"
{...props}
>
{id ? (
<a className="text-inherit no-underline" href={`#${id}`}>
{children}
<span className="ml-2 text-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 dark:text-indigo-400">
#
</span>
</a>
) : (
children
)}
</h2>
)
}
function MdxHeading3({ children, id, ...props }: React.ComponentProps<"h3">) {
return (
<h3
id={id}
className="group scroll-mt-24 pt-5 text-xl font-semibold tracking-tight text-slate-950 dark:text-slate-100"
{...props}
>
{id ? (
<a className="text-inherit no-underline" href={`#${id}`}>
{children}
<span className="ml-2 text-indigo-500 opacity-0 transition-opacity group-hover:opacity-100 dark:text-indigo-400">
#
</span>
</a>
) : (
children
)}
</h3>
)
}
function MdxAnchor({ className, ...props }: React.ComponentProps<"a">) {
return (
<a
className={`font-medium text-indigo-600 underline decoration-indigo-300 underline-offset-4 transition-colors hover:text-indigo-500 dark:text-indigo-400 dark:decoration-indigo-800 dark:hover:text-indigo-300 ${className ?? ""}`}
{...props}
/>
)
}
function MdxBlockquote({
className,
...props
}: React.ComponentProps<"blockquote">) {
return (
<blockquote
className={`rounded-r-lg border-l-2 border-indigo-500 bg-indigo-50 px-4 py-3 text-slate-700 dark:border-indigo-400 dark:bg-indigo-950/30 dark:text-slate-300 ${className ?? ""}`}
{...props}
/>
)
}
function MdxCode({ className, ...props }: React.ComponentProps<"code">) {
return (
<code
className={`rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.9em] text-slate-900 dark:bg-slate-900 dark:text-slate-100 ${className ?? ""}`}
{...props}
/>
)
}
function MdxPre({ className, ...props }: React.ComponentProps<"pre">) {
return (
<pre
className={`overflow-x-auto rounded-xl border border-slate-200 bg-slate-950 p-4 font-mono text-sm leading-6 text-slate-100 dark:border-slate-800 dark:bg-black/40 [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit ${className ?? ""}`}
{...props}
/>
)
}
/* oxlint-enable react/only-export-components */
@@ -0,0 +1,228 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from "react"
import {
PreferencesProvider,
usePreference,
usePreferences,
type PreferenceChangeHandler,
type PreferenceUpdate,
type Preferences,
} from "@workspace/preferences"
import type { DocLocale } from "../content/types"
import { navigateToLocale } from "../lib/locale-navigation"
import {
detectLocale,
docsPreferenceEffects,
isDocsPreferenceUpdate,
parseDocsPreferences,
persistDocsPreference,
readDocsPreferences,
} from "../lib/preferences"
const preferencesChannelName = "docs-preferences-sync"
type PreferencesMessage =
| { type: "request" }
| { preferences: unknown; type: "snapshot" }
| { type: "update"; update: unknown }
export function DocsPreferencesProvider({
children,
routeLocale,
}: {
children: ReactNode
routeLocale: DocLocale
}) {
const [initialPreferences] = useState(() => readDocsPreferences(routeLocale))
const channelRef = useRef<BroadcastChannel>(null)
const applyingExternalUpdateRef = useRef(false)
const handlePreferenceChange = useCallback<PreferenceChangeHandler>(
(update) => {
persistDocsPreference(update)
if (!applyingExternalUpdateRef.current) {
channelRef.current?.postMessage({ type: "update", update })
}
},
[]
)
return (
<PreferencesProvider
effects={docsPreferenceEffects}
initialPreferences={initialPreferences}
onPreferenceChange={handlePreferenceChange}
>
<PreferencesRuntime
applyingExternalUpdateRef={applyingExternalUpdateRef}
channelRef={channelRef}
routeLocale={routeLocale}
/>
{children}
</PreferencesProvider>
)
}
function PreferencesRuntime({
applyingExternalUpdateRef,
channelRef,
routeLocale,
}: {
applyingExternalUpdateRef: MutableRefObject<boolean>
channelRef: MutableRefObject<BroadcastChannel | null>
routeLocale: DocLocale
}) {
const preferences = usePreferences()
const preferencesRef = useRef(preferences)
const [, setAccentColor] = usePreference("accent-color")
const [, setBackgroundShade] = usePreference("background-shade")
const [, setForegroundShade] = usePreference("foreground-shade")
const [, setLocaleMode] = usePreference("locale-mode")
const [, setLocale] = usePreference("locale")
const [, setThemeMode] = usePreference("theme-mode")
useLayoutEffect(() => {
preferencesRef.current = preferences
}, [preferences])
useEffect(() => {
if (!("BroadcastChannel" in window)) return
const channel = new BroadcastChannel(preferencesChannelName)
channelRef.current = channel
function applyUpdate(update: PreferenceUpdate) {
applyingExternalUpdateRef.current = true
try {
switch (update.key) {
case "accent-color":
setAccentColor(update.value)
break
case "background-shade":
setBackgroundShade(update.value)
break
case "foreground-shade":
setForegroundShade(update.value)
break
case "locale-mode":
setLocaleMode(update.value)
break
case "locale":
setLocale(update.value)
break
case "theme-mode":
setThemeMode(update.value)
break
}
} finally {
applyingExternalUpdateRef.current = false
}
}
function applySnapshot(nextPreferences: Preferences) {
preferencesRef.current = nextPreferences
for (const [key, value] of Object.entries(nextPreferences)) {
const update = { key, value }
if (isDocsPreferenceUpdate(update)) applyUpdate(update)
}
}
function synchronizeRoute(nextPreferences: Preferences) {
const locale =
nextPreferences["locale-mode"] === "auto"
? detectLocale()
: nextPreferences.locale
navigateToLocale(locale, true)
}
function handleMessage(event: MessageEvent<unknown>) {
const message = event.data as Partial<PreferencesMessage> | undefined
if (!message || typeof message !== "object") return
if (message.type === "request") {
channel.postMessage({
type: "snapshot",
preferences: preferencesRef.current,
} satisfies PreferencesMessage)
return
}
if (message.type === "update" && isDocsPreferenceUpdate(message.update)) {
const nextPreferences = {
...preferencesRef.current,
[message.update.key]: message.update.value,
}
preferencesRef.current = nextPreferences
applyUpdate(message.update)
synchronizeRoute(nextPreferences)
return
}
if (message.type === "snapshot") {
const nextPreferences = parseDocsPreferences(message.preferences)
if (!nextPreferences) return
applySnapshot(nextPreferences)
synchronizeRoute(nextPreferences)
}
}
channel.addEventListener("message", handleMessage)
channel.postMessage({ type: "request" } satisfies PreferencesMessage)
return () => {
channelRef.current = null
channel.close()
}
}, [
applyingExternalUpdateRef,
channelRef,
setAccentColor,
setBackgroundShade,
setForegroundShade,
setLocale,
setLocaleMode,
setThemeMode,
])
useEffect(() => {
if (preferences["locale-mode"] !== "auto") return
function synchronizeDetectedLocale() {
navigateToLocale(detectLocale(), true)
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") synchronizeDetectedLocale()
}
synchronizeDetectedLocale()
window.addEventListener("focus", synchronizeDetectedLocale)
window.addEventListener("languagechange", synchronizeDetectedLocale)
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
window.removeEventListener("focus", synchronizeDetectedLocale)
window.removeEventListener("languagechange", synchronizeDetectedLocale)
document.removeEventListener("visibilitychange", handleVisibilityChange)
}
}, [preferences])
useEffect(() => {
if (
preferences["locale-mode"] === "manual" &&
preferences.locale !== routeLocale
) {
setLocale(routeLocale)
}
}, [preferences, routeLocale, setLocale])
return null
}
+24
View File
@@ -0,0 +1,24 @@
import { Icon } from "@icones/react"
import { SearchTrigger as RawSearchTrigger } from "@workspace/search"
import { useTranslate } from "../lib/locale"
export function SearchTrigger() {
const t = useTranslate()
const label = t({ zh: "搜索文档", us: "Search documentation" })
return (
<RawSearchTrigger
type="button"
aria-label={label}
title={label}
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-2 text-slate-500 transition-colors sm:hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 sm:px-3 dark:text-slate-400 sm:dark:hover:bg-slate-900 dark:hover:text-slate-100"
>
<Icon name="tabler:search" className="size-5" />
<span className="hidden sm:inline">{label}</span>
<kbd className="hidden rounded border border-slate-200 bg-white px-1.5 py-0.5 font-sans text-[0.6875rem] leading-none text-slate-400 lg:inline dark:border-slate-700 dark:bg-slate-950 dark:text-slate-500">
K
</kbd>
</RawSearchTrigger>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Icon } from "@icones/react"
import type { ComponentProps } from "react"
import { getLocalizedHref } from "../content/registry"
import { useLocale, useTranslate } from "../lib/locale"
import { cn } from "../lib/utils"
export type SettingsLinkProps = Omit<
ComponentProps<"a">,
"children" | "href"
> & {
label?: string
}
export function SettingsLink({
className,
label,
...props
}: SettingsLinkProps) {
const locale = useLocale()
const t = useTranslate()
const accessibleLabel = label ?? t({ zh: "设置", us: "Settings" })
return (
<a
href={getLocalizedHref("/settings", locale)}
aria-label={accessibleLabel}
title={accessibleLabel}
className={cn(
"inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:text-slate-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:text-slate-400 dark:hover:text-slate-100",
className
)}
{...props}
>
<Icon name="tabler:settings" className="size-5" />
<span className="sr-only">{accessibleLabel}</span>
</a>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { Icon } from "@icones/react"
import type { ComponentProps } from "react"
import { cn } from "../lib/utils"
export type SwatchProps = Omit<ComponentProps<"button">, "children"> & {
light: string
dark?: string
selected?: boolean
}
export function Swatch({
className,
light,
dark = light,
selected = false,
...props
}: SwatchProps) {
return (
<button
type="button"
aria-pressed={selected}
className={cn(
"relative inline-flex flex-col size-10 shrink-0 cursor-pointer gap-px overflow-hidden rounded-full border border-slate-200 transition-transform outline-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 active:scale-95 aria-pressed:ring-2 aria-pressed:ring-indigo-500 aria-pressed:ring-offset-2 dark:border-slate-700 dark:ring-offset-(--docs-background-dark) text-xs text-center",
className
)}
{...props}
>
<span
aria-hidden="true"
className="block size-full dark:hidden"
style={{ backgroundColor: light }}
/>
<span
aria-hidden="true"
className="hidden dark:block size-full"
style={{ backgroundColor: dark }}
/>
{selected && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex size-5 items-center justify-center rounded-full bg-white/90 text-slate-950 shadow-sm ring-1 ring-black/10">
<Icon name="tabler:check" className="size-3.5" />
</span>
</span>
)}
</button>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { Icon } from "@icones/react"
import { usePreference } from "@workspace/preferences"
import { useResolvedTheme } from "../lib/appearance"
import { useTranslate } from "../lib/locale"
export function ThemeSwitch({ className }: { className?: string }) {
const t = useTranslate()
const [, setThemeMode] = usePreference("theme-mode")
const resolvedTheme = useResolvedTheme()
const dark = resolvedTheme === "dark"
const label = dark
? t({ zh: "切换到浅色主题", us: "Switch to light theme" })
: t({ zh: "切换到深色主题", us: "Switch to dark theme" })
return (
<button
type="button"
role="switch"
aria-checked={dark}
aria-label={label}
title={label}
className={[
"relative inline-flex h-7 w-13 shrink-0 cursor-pointer items-center rounded-full border transition-colors duration-200",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 motion-reduce:transition-none",
dark
? "border-zinc-700 bg-zinc-900 shadow-inner hover:border-zinc-600"
: "border-slate-300 bg-slate-100 shadow-inner hover:border-slate-400",
className,
]
.filter(Boolean)
.join(" ")}
onClick={() => {
setThemeMode(dark ? "light" : "dark")
}}
>
<span
aria-hidden="true"
className={[
"absolute top-px left-px inline-flex size-6 items-center justify-center rounded-full ring-1 shadow-sm transition-transform duration-200 ease-out motion-reduce:transition-none",
dark
? "translate-x-6 bg-black text-zinc-100 ring-zinc-700"
: "translate-x-0 bg-white text-slate-500 ring-slate-200",
].join(" ")}
>
<Icon
name="tabler:sun-high"
altName="tabler:moon"
showAlt={dark}
className="z-10 size-4"
/>
</span>
</button>
)
}
+355
View File
@@ -0,0 +1,355 @@
import {
defaultDocLocale,
docLocales,
docSections,
type DocLocale,
type DocLocation,
type DocPackage,
type DocPage,
type DocSection,
type DocTocItem,
type MdxComponents,
} from "./types"
import { trans, type Translation } from "../lib/locale"
type DocModule = {
default: React.ComponentType<{ components?: MdxComponents }>
frontmatter: Record<string, unknown>
}
type LocalizedText = Translation<string>
type PackageDefinition = {
key: string
label: string
description: LocalizedText
}
type ParsedDocPage = DocPage & {
packageKey: string
searchText: string
}
const packageDefinitions: readonly PackageDefinition[] = [
{
key: "blocks",
label: "Blocks",
description: {
zh: "构建应用外壳,并接入导航、通知、聊天、媒体和外观控制。",
us: "Build an application shell and add navigation, notifications, chat, media, and appearance controls.",
},
},
{
key: "i18n",
label: "I18n",
description: {
zh: "接入应用自有词典、本地化运行时、开发工具和完整翻译工作流。",
us: "Add application-owned catalogs, localized runtime rendering, development tools, and translation workflows.",
},
},
{
key: "lexical",
label: "Lexical",
description: {
zh: "从根节点和内容区域开始,逐步构建支持自定义操作与媒体的富文本编辑器。",
us: "Build a rich-text editor progressively from its root and content surface to custom actions and media.",
},
},
{
key: "preferences",
label: "Preferences",
description: {
zh: "使用 SSR 快照、持久化回调和可组合副作用管理类型安全且可扩展的应用偏好。",
us: "Manage typed, extensible application preferences with SSR snapshots, persistence callbacks, and composable effects.",
},
},
{
key: "search",
label: "Search",
description: {
zh: "通过可替换的 Adapter、分组结果、分页和搜索历史构建应用级搜索体验。",
us: "Build application search with a replaceable adapter, grouped results, pagination, and search history.",
},
},
{
key: "ui",
label: "UI",
description: {
zh: "配置共享 UI 层,并学习组合组件、Hooks、图标和本地化词典。",
us: "Set up the shared UI layer and learn how to compose its components, hooks, icons, and locale catalogs.",
},
},
]
const docModules = import.meta.glob<DocModule>(
"../../../packages/*/docs/*/**/*.mdx",
{ eager: true }
)
const docSources = import.meta.glob<string>(
"../../../packages/*/docs/*/**/*.mdx",
{ eager: true, import: "default", query: "?docs-search-raw" }
)
const parsedPages = Object.entries(docModules).map(([path, module]) =>
parseDocPage(path, module, docSources[path] ?? "")
)
const packagesByLocale: Record<DocLocale, readonly DocPackage[]> = {
"zh-Hans": packageDefinitions.map((definition) =>
createDocPackage(definition, "zh-Hans")
),
"en-US": packageDefinitions.map((definition) =>
createDocPackage(definition, "en-US")
),
}
validateLocalizedPagePairs(packagesByLocale)
export function getDocPackages(locale: DocLocale = defaultDocLocale) {
return packagesByLocale[locale]
}
export function getDocHref(docPackage: DocPackage, page?: DocPage) {
const prefix = getLocalePrefix(docPackage.locale)
const target = page ?? getOverviewPage(docPackage)
const pagePath =
target.section === "overview"
? ""
: `/${target.section}/${encodeURIComponent(target.slug)}`
return `${prefix}/articles/${docPackage.key}${pagePath}`
}
export function getDocsHomeHref(locale: DocLocale) {
return getLocalePrefix(locale) || "/"
}
export function getLocalizedHref(pathname: string, locale: DocLocale) {
if (/^(?:\/(?:en-US|zh-Hans))?\/settings\/?$/.test(pathname)) {
return `${getLocalePrefix(locale)}/settings`
}
const location = resolveDocLocation(pathname)
if (!location) return getDocsHomeHref(locale)
const docPackage = getDocPackages(locale).find(
(item) => item.key === location.package.key
)
const page = docPackage?.pages.find(
(item) =>
item.section === location.page.section && item.slug === location.page.slug
)
return docPackage && page
? getDocHref(docPackage, page)
: getDocsHomeHref(locale)
}
export function resolveDocLocale(pathname: string): DocLocale {
const firstSegment = pathname.split("/").filter(Boolean)[0]
return isDocLocale(firstSegment) ? firstSegment : defaultDocLocale
}
export function resolveDocLocation(pathname: string): DocLocation | undefined {
const segments = pathname.split("/").filter(Boolean)
let locale: DocLocale = defaultDocLocale
if (isDocLocale(segments[0])) {
locale = segments.shift() as DocLocale
}
const [resource, packageKey, sectionValue, pageSlug, ...extra] = segments
if (resource !== "articles" || extra.length > 0) return undefined
const docPackage = getDocPackages(locale).find(
(item) => item.key === packageKey
)
if (!docPackage) return undefined
if (sectionValue === undefined) {
const page = getOverviewPage(docPackage)
return { locale, package: docPackage, page }
}
if (sectionValue === "overview" || !isDocSection(sectionValue) || !pageSlug) {
return undefined
}
const page = docPackage.pages.find(
(item) => item.section === sectionValue && item.slug === pageSlug
)
return page ? { locale, package: docPackage, page } : undefined
}
export function getDocSectionLabel(section: DocSection, locale: DocLocale) {
const labels: Record<DocSection, Translation> = {
overview: { zh: "概览", us: "Overview" },
guide: { zh: "指南", us: "Guide" },
advanced: { zh: "进阶", us: "Advanced" },
examples: { zh: "示例", us: "Examples" },
}
return trans(locale, labels[section])
}
export function getDocSearchText(page: DocPage) {
return (page as ParsedDocPage).searchText
}
function createDocPackage(
definition: PackageDefinition,
locale: DocLocale
): DocPackage {
const pages = parsedPages
.filter(
(page) => page.packageKey === definition.key && page.locale === locale
)
.sort(
(left, right) =>
docSections.indexOf(left.section) -
docSections.indexOf(right.section) ||
left.order - right.order ||
left.slug.localeCompare(right.slug)
)
const overviewCount = pages.filter(
(page) => page.section === "overview"
).length
if (overviewCount !== 1) {
throw new Error(
`${definition.key}/${locale} must contain exactly one docs/${locale}/overview.mdx file.`
)
}
for (const section of docSections.slice(1)) {
if (!pages.some((page) => page.section === section)) {
throw new Error(
`${definition.key}/${locale} must contain at least one ${section} document.`
)
}
}
return {
key: definition.key,
label: definition.label,
description: trans(locale, definition.description),
locale,
pages,
}
}
function parseDocPage(
path: string,
module: DocModule,
source: string
): ParsedDocPage {
const match = path.match(
/\/packages\/([^/]+)\/docs\/([^/]+)\/(overview|guide|advanced|examples)(?:\/([^/]+))?\.mdx$/
)
if (!match) {
throw new Error(
`Invalid documentation path "${path}". Expected docs/<locale>/overview.mdx or docs/<locale>/<guide|advanced|examples>/*.mdx.`
)
}
const [, packageKey, localeValue, sectionValue, nestedSlug] = match
if (!isDocLocale(localeValue)) {
throw new Error(
`Unsupported documentation locale "${localeValue}" in "${path}".`
)
}
if (!isDocSection(sectionValue)) {
throw new Error(`Unsupported documentation section in "${path}".`)
}
if (
(sectionValue === "overview" && nestedSlug !== undefined) ||
(sectionValue !== "overview" && nestedSlug === undefined)
) {
throw new Error(`Documentation file is in the wrong directory: "${path}".`)
}
const frontmatter = module.frontmatter
return {
Content: module.default,
description: readString(frontmatter, "description"),
locale: localeValue,
order: readNumber(frontmatter, "order"),
packageKey,
searchText: source,
section: sectionValue,
slug: nestedSlug ?? "overview",
title: readString(frontmatter, "title"),
toc: readToc(frontmatter.toc),
}
}
function validateLocalizedPagePairs(
packages: Record<DocLocale, readonly DocPackage[]>
) {
for (const definition of packageDefinitions) {
const keysByLocale = docLocales.map((locale) => {
const docPackage = packages[locale].find(
(item) => item.key === definition.key
)!
return docPackage.pages.map((page) => `${page.section}/${page.slug}`)
})
if (keysByLocale[0]?.join("\n") !== keysByLocale[1]?.join("\n")) {
throw new Error(
`${definition.key} documentation must contain matching zh-Hans and en-US page paths.`
)
}
}
}
function getOverviewPage(docPackage: DocPackage) {
return docPackage.pages.find((page) => page.section === "overview")!
}
function getLocalePrefix(locale: DocLocale) {
return locale === defaultDocLocale ? "" : `/${locale}`
}
function isDocLocale(value: unknown): value is DocLocale {
return docLocales.some((locale) => locale === value)
}
function isDocSection(value: unknown): value is DocSection {
return docSections.some((section) => section === value)
}
function readString(frontmatter: Record<string, unknown>, key: string) {
const value = frontmatter[key]
if (typeof value === "string" && value.trim()) return value
throw new Error(`Documentation frontmatter requires a string "${key}".`)
}
function readNumber(frontmatter: Record<string, unknown>, key: string) {
const value = frontmatter[key]
if (typeof value === "number" && Number.isFinite(value)) return value
throw new Error(`Documentation frontmatter requires a number "${key}".`)
}
function readToc(value: unknown): readonly DocTocItem[] {
if (value === undefined) return []
if (!Array.isArray(value)) {
throw new Error('Documentation frontmatter "toc" must be an array.')
}
return value.map((item) => {
if (!isRecord(item)) {
throw new Error('Each documentation "toc" entry must be an object.')
}
return {
id: readString(item, "id"),
title: readString(item, "title"),
items: item.items === undefined ? undefined : readToc(item.items),
}
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
+45
View File
@@ -0,0 +1,45 @@
import type { ComponentType, ElementType } from "react"
export const docLocales = ["zh-Hans", "en-US"] as const
export const defaultDocLocale = "zh-Hans" as const
export const docSections = [
"overview",
"guide",
"advanced",
"examples",
] as const
export type DocLocale = (typeof docLocales)[number]
export type DocSection = (typeof docSections)[number]
export type MdxComponents = Record<string, ElementType>
export type DocTocItem = {
id: string
title: string
items?: readonly DocTocItem[]
}
export type DocPage = {
Content: ComponentType<{ components?: MdxComponents }>
description: string
locale: DocLocale
order: number
section: DocSection
slug: string
title: string
toc: readonly DocTocItem[]
}
export type DocPackage = {
key: string
label: string
description: string
locale: DocLocale
pages: readonly DocPage[]
}
export type DocLocation = {
locale: DocLocale
package: DocPackage
page: DocPage
}
+86
View File
@@ -0,0 +1,86 @@
@import "@workspace/ui/globals.css";
@import "@workspace/search/globals.css";
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
*,
*::before,
*::after {
@apply border-slate-200 dark:border-slate-800;
}
html {
@apply antialiased;
background-color: var(--docs-background-light, var(--color-white));
color: var(--docs-foreground-light, var(--color-slate-950));
font-family: "DM Serif Display", Georgia, serif;
}
html.dark {
background-color: var(--docs-background-dark, var(--color-slate-950));
color: var(--docs-foreground-dark, var(--color-slate-100));
}
:root {
--background: var(--docs-background-light, var(--color-white));
--foreground: var(--docs-foreground-light, var(--color-slate-950));
--card: var(--docs-background-light, var(--color-white));
--card-foreground: var(--docs-foreground-light, var(--color-slate-950));
--popover: var(--docs-background-light, var(--color-white));
--popover-foreground: var(--docs-foreground-light, var(--color-slate-950));
--primary: var(--color-indigo-600);
--primary-foreground: var(--color-white);
--secondary: var(--docs-surface-light, var(--color-slate-100));
--secondary-foreground: var(
--docs-foreground-light,
var(--color-slate-950)
);
--muted: var(--docs-surface-light, var(--color-slate-100));
--muted-foreground: var(--docs-muted-light, var(--color-slate-600));
--accent: var(--docs-surface-light, var(--color-slate-100));
--accent-foreground: var(--docs-foreground-light, var(--color-slate-950));
--border: var(--color-slate-200);
--input: var(--color-slate-200);
--ring: var(--color-indigo-500);
--sidebar: var(--docs-background-light, var(--color-white));
--sidebar-foreground: var(--docs-foreground-light, var(--color-slate-950));
--sidebar-border: var(--color-slate-200);
}
html.dark {
--background: var(--docs-background-dark, var(--color-slate-950));
--foreground: var(--docs-foreground-dark, var(--color-slate-100));
--card: var(--docs-surface-dark, var(--color-slate-900));
--card-foreground: var(--docs-foreground-dark, var(--color-slate-100));
--popover: var(--docs-surface-dark, var(--color-slate-900));
--popover-foreground: var(--docs-foreground-dark, var(--color-slate-100));
--primary: var(--color-indigo-400);
--primary-foreground: var(--color-slate-950);
--secondary: var(--color-slate-800);
--secondary-foreground: var(--docs-foreground-dark, var(--color-slate-100));
--muted: var(--color-slate-800);
--muted-foreground: var(--docs-muted-dark, var(--color-slate-400));
--accent: var(--color-slate-800);
--accent-foreground: var(--docs-foreground-dark, var(--color-slate-100));
--border: var(--color-slate-800);
--input: var(--color-slate-700);
--ring: var(--color-indigo-400);
--sidebar: var(--docs-surface-dark, var(--color-slate-900));
--sidebar-foreground: var(--docs-foreground-dark, var(--color-slate-100));
--sidebar-border: var(--color-slate-800);
}
body,
#root {
@apply min-h-dvh;
}
body {
@apply bg-inherit text-inherit;
}
::selection {
@apply bg-indigo-200 text-indigo-950 dark:bg-indigo-500/40 dark:text-indigo-50;
}
}
+155
View File
@@ -0,0 +1,155 @@
import { useCallback, useSyncExternalStore } from "react"
import { usePreference } from "@workspace/preferences"
import colors from "tailwindcss/colors"
export const themeModes = ["light", "dark", "system"] as const
export const accentColors = [
"neutral",
"blue",
"cyan",
"emerald",
"fuchsia",
"green",
"indigo",
"lime",
"orange",
"pink",
"purple",
"red",
"rose",
"sky",
"teal",
"violet",
"yellow",
] as const
export const backgroundShades = [
"neutral",
"stone",
"zinc",
"mauve",
"olive",
"mist",
"taupe",
] as const
export const foregroundShades = ["slate", "neutral", "stone"] as const
export type ThemeMode = (typeof themeModes)[number]
export type AccentColor = (typeof accentColors)[number]
export type BackgroundShade = (typeof backgroundShades)[number]
export type ForegroundShade = (typeof foregroundShades)[number]
export type ResolvedTheme = Exclude<ThemeMode, "system">
export type Appearance = {
accentColor: AccentColor
backgroundShade: BackgroundShade
foregroundShade: ForegroundShade
themeMode: ThemeMode
}
export type AppearanceController = Appearance & {
updateAppearance: (update: Partial<Appearance>) => void
}
const systemThemeQuery = "(prefers-color-scheme: dark)"
const colorSteps = [
50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
] as const
type ColorStep = (typeof colorSteps)[number]
type ColorScale = Record<ColorStep, string>
export function useAppearance(): AppearanceController {
const [accentColor, setAccentColor] = usePreference("accent-color")
const [backgroundShade, setBackgroundShade] =
usePreference("background-shade")
const [foregroundShade, setForegroundShade] =
usePreference("foreground-shade")
const [themeMode, setThemeMode] = usePreference("theme-mode")
const updateAppearance = useCallback(
(update: Partial<Appearance>) => {
if (update.accentColor !== undefined) {
setAccentColor(update.accentColor)
}
if (update.backgroundShade !== undefined) {
setBackgroundShade(update.backgroundShade)
}
if (update.foregroundShade !== undefined) {
setForegroundShade(update.foregroundShade)
}
if (update.themeMode !== undefined) setThemeMode(update.themeMode)
},
[setAccentColor, setBackgroundShade, setForegroundShade, setThemeMode]
)
return {
accentColor,
backgroundShade,
foregroundShade,
themeMode,
updateAppearance,
}
}
export function useResolvedTheme() {
const [themeMode] = usePreference("theme-mode")
const systemTheme = useSyncExternalStore(
subscribeToSystemTheme,
getSystemTheme,
() => "light" as const
)
return themeMode === "system" ? systemTheme : themeMode
}
export function getPaletteColor(
color: AccentColor | BackgroundShade | ForegroundShade,
step: ColorStep = 500
) {
return getColorScale(color)[step]
}
export function applyAppearance(appearance: Appearance) {
if (typeof document === "undefined") return
const root = document.documentElement
const resolvedTheme =
appearance.themeMode === "system" ? getSystemTheme() : appearance.themeMode
const accentScale = getColorScale(appearance.accentColor)
const backgroundScale = getColorScale(appearance.backgroundShade)
const foregroundScale = getColorScale(appearance.foregroundShade)
root.classList.toggle("dark", resolvedTheme === "dark")
root.style.colorScheme = resolvedTheme
root.dataset.themeMode = appearance.themeMode
root.dataset.accentColor = appearance.accentColor
root.dataset.backgroundShade = appearance.backgroundShade
root.dataset.foregroundShade = appearance.foregroundShade
for (const step of colorSteps) {
root.style.setProperty(`--color-indigo-${step}`, accentScale[step])
}
root.style.setProperty("--docs-background-light", backgroundScale[50])
root.style.setProperty("--docs-background-dark", backgroundScale[950])
root.style.setProperty("--docs-surface-light", backgroundScale[100])
root.style.setProperty("--docs-surface-dark", backgroundScale[900])
root.style.setProperty("--docs-foreground-light", foregroundScale[950])
root.style.setProperty("--docs-foreground-dark", foregroundScale[50])
root.style.setProperty("--docs-muted-light", foregroundScale[600])
root.style.setProperty("--docs-muted-dark", foregroundScale[400])
}
export function subscribeToSystemTheme(listener: VoidFunction) {
const mediaQuery = window.matchMedia(systemThemeQuery)
mediaQuery.addEventListener("change", listener)
return () => mediaQuery.removeEventListener("change", listener)
}
export function getSystemTheme(): ResolvedTheme {
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
}
function getColorScale(color: string) {
return colors[color as keyof typeof colors] as ColorScale
}
+14
View File
@@ -0,0 +1,14 @@
import { getLocalizedHref } from "../content/registry"
import type { DocLocale } from "../content/types"
export function navigateToLocale(locale: DocLocale, replace = false) {
const href = getLocalizedHref(window.location.pathname, locale)
if (href === window.location.pathname) return
if (replace) {
window.history.replaceState(null, "", href)
} else {
window.history.pushState(null, "", href)
}
window.dispatchEvent(new PopStateEvent("popstate"))
}
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useCallback, useContext } from "react"
import { defaultDocLocale, type DocLocale } from "../content/types"
export type Translation<Value = string> = Readonly<{
zh: Value
us: Value
}>
export type Translate = <Value>(translation: Translation<Value>) => Value
export const LocaleContext = createContext<DocLocale>(defaultDocLocale)
export function useLocale() {
return useContext(LocaleContext)
}
export function useTranslate(): Translate {
const locale = useLocale()
return useCallback(
<Value>(translation: Translation<Value>) => trans(locale, translation),
[locale]
)
}
export function trans<Value>(
locale: DocLocale,
translation: Translation<Value>
): Value {
return locale === "zh-Hans" ? translation.zh : translation.us
}
+188
View File
@@ -0,0 +1,188 @@
import {
createPreferenceUpdateGuard,
type PreferenceDefinitions,
type PreferenceEffect,
type PreferenceUpdate,
type Preferences,
} from "@workspace/preferences"
import { defaultDocLocale, docLocales, type DocLocale } from "../content/types"
import {
accentColors,
applyAppearance,
backgroundShades,
foregroundShades,
subscribeToSystemTheme,
themeModes,
type AccentColor,
type Appearance,
type BackgroundShade,
type ForegroundShade,
type ThemeMode,
} from "./appearance"
export type LocaleMode = "auto" | "manual"
export type DocsPreferences = {
"accent-color": AccentColor
"background-shade": BackgroundShade
"foreground-shade": ForegroundShade
"locale-mode": LocaleMode
locale: DocLocale
"theme-mode": ThemeMode
}
declare module "@workspace/preferences" {
interface PreferencesCustom extends DocsPreferences {}
}
export const docsPreferenceDefinitions = {
"accent-color": createStringPreference(
"docs-accent-color",
"indigo",
accentColors
),
"background-shade": createStringPreference(
"docs-background-shade",
"neutral",
backgroundShades
),
"foreground-shade": createStringPreference(
"docs-foreground-shade",
"slate",
foregroundShades
),
"locale-mode": {
cookie: "docs-locale-auto-detect",
defaultValue: "manual",
is: (value): value is LocaleMode => value === "auto" || value === "manual",
parse: (value) => (value === "1" || value === "auto" ? "auto" : "manual"),
serialize: (value) => (value === "auto" ? "1" : "0"),
},
locale: createStringPreference("docs-locale", defaultDocLocale, docLocales),
"theme-mode": createStringPreference("docs-theme", "system", themeModes),
} satisfies PreferenceDefinitions<DocsPreferences>
export const isDocsPreferenceUpdate = createPreferenceUpdateGuard(
docsPreferenceDefinitions
)
export const docsPreferenceEffects = [
{
layoutEffect: ({ preferences }) => {
applyAppearance(toAppearance(preferences))
},
effect: ({ preferences, store }) => {
if (preferences["theme-mode"] !== "system") return
return subscribeToSystemTheme(() => {
applyAppearance(toAppearance(store.getSnapshot()))
})
},
},
] satisfies readonly PreferenceEffect[]
export function initializeDocsPreferences() {
applyAppearance(toAppearance(readDocsPreferences()))
}
export function readDocsPreferences(
fallbackLocale: DocLocale = defaultDocLocale
): Preferences {
const preferences = Object.fromEntries(
(
Object.keys(docsPreferenceDefinitions) as Array<keyof DocsPreferences>
).map((key) => {
const definition = docsPreferenceDefinitions[key]
return [key, readStoredValue(definition.cookie, definition.parse)]
})
) as DocsPreferences
if (!readStoredString(docsPreferenceDefinitions.locale.cookie)) {
preferences.locale = fallbackLocale
}
return preferences
}
export function persistDocsPreference(update: PreferenceUpdate) {
const definition = docsPreferenceDefinitions[update.key]
const serializedValue =
update.key === "locale-mode"
? docsPreferenceDefinitions["locale-mode"].serialize(update.value)
: update.value
writeStoredString(definition.cookie, serializedValue)
}
export function parseDocsPreferences(value: unknown): Preferences | undefined {
if (!value || typeof value !== "object") return undefined
const candidate = value as Partial<Record<keyof DocsPreferences, unknown>>
for (const key of Object.keys(docsPreferenceDefinitions) as Array<
keyof DocsPreferences
>) {
if (!docsPreferenceDefinitions[key].is(candidate[key])) return undefined
}
return candidate as DocsPreferences
}
export function toAppearance(preferences: Preferences): Appearance {
return {
accentColor: preferences["accent-color"],
backgroundShade: preferences["background-shade"],
foregroundShade: preferences["foreground-shade"],
themeMode: preferences["theme-mode"],
}
}
export function detectLocale(): DocLocale {
const languages = navigator.languages.length
? navigator.languages
: [navigator.language]
return languages.some((language) => language.toLowerCase().startsWith("zh"))
? "zh-Hans"
: "en-US"
}
function createStringPreference<const Options extends readonly string[]>(
cookie: string,
defaultValue: Options[number],
options: Options
) {
const is = (value: unknown): value is Options[number] =>
typeof value === "string" && options.some((option) => option === value)
return {
cookie,
defaultValue,
is,
parse: (value: string | undefined) => (is(value) ? value : defaultValue),
serialize: (value: Options[number]) => value,
}
}
function readStoredValue<Value>(
key: string,
parse: (value: string | undefined) => Value
) {
return parse(readStoredString(key))
}
function readStoredString(key: string) {
if (typeof window === "undefined") return undefined
try {
return window.sessionStorage.getItem(key) ?? undefined
} catch {
return undefined
}
}
function writeStoredString(key: string, value: string) {
try {
window.sessionStorage.setItem(key, value)
} catch {
// The in-memory preference remains active when storage is unavailable.
}
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+8
View File
@@ -0,0 +1,8 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export type { ClassValue }
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App.tsx"
import "./index.css"
import { initializeDocsPreferences } from "./lib/preferences"
document.documentElement.lang = window.location.pathname.startsWith("/en-US")
? "en-US"
: "zh-Hans"
initializeDocsPreferences()
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
)
+704
View File
@@ -0,0 +1,704 @@
import * as React from "react"
import { createPortal } from "react-dom"
import { Icon } from "@icones/react"
import {
getDocHref,
getDocPackages,
getDocSectionLabel,
getDocsHomeHref,
resolveDocLocation,
} from "../content/registry"
import type {
DocLocation,
DocPage,
DocTocItem,
} from "../content/types"
import { Actions, Container, HomeButton, SlashLine } from "../components/layout"
import { mdxComponents } from "../components/mdx"
import { useLocale, useTranslate } from "../lib/locale"
const noTocItems: readonly DocTocItem[] = []
export default function ArticlePage() {
const t = useTranslate()
const docLocation = resolveDocLocation(window.location.pathname)
const visibleTocIds = useVisibleTocIds(docLocation?.page.toc ?? noTocItems)
const [mobilePanel, setMobilePanel] = React.useState<
"navigation" | "toc" | null
>(null)
const mobilePanelRef = React.useRef<HTMLDivElement>(null)
const navigationTriggerRef = React.useRef<HTMLButtonElement>(null)
const tocTriggerRef = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (!mobilePanel) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
const unavailableAt = window.matchMedia(
mobilePanel === "navigation" ? "(min-width: 48rem)" : "(min-width: 64rem)"
)
const animationFrame = window.requestAnimationFrame(() => {
mobilePanelRef.current
?.querySelector<HTMLElement>(
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
)
?.focus()
})
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault()
setMobilePanel(null)
const trigger =
mobilePanel === "navigation"
? navigationTriggerRef.current
: tocTriggerRef.current
trigger?.focus()
return
}
if (event.key !== "Tab") return
const focusableElements = Array.from(
mobilePanelRef.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'
) ?? []
)
const firstElement = focusableElements[0]
const lastElement = focusableElements.at(-1)
if (!firstElement || !lastElement) return
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
function handleBreakpointChange(event: MediaQueryListEvent) {
if (event.matches) setMobilePanel(null)
}
document.addEventListener("keydown", handleKeyDown)
unavailableAt.addEventListener("change", handleBreakpointChange)
return () => {
window.cancelAnimationFrame(animationFrame)
document.removeEventListener("keydown", handleKeyDown)
unavailableAt.removeEventListener("change", handleBreakpointChange)
document.body.style.overflow = previousOverflow
}
}, [mobilePanel])
if (!docLocation) return <ArticleNotFound />
const currentIndex = docLocation.package.pages.indexOf(docLocation.page)
const previousPage = docLocation.package.pages[currentIndex - 1]
const nextPage = docLocation.package.pages[currentIndex + 1]
const previous = previousPage
? {
locale: docLocation.locale,
package: docLocation.package,
page: previousPage,
}
: undefined
const next = nextPage
? {
locale: docLocation.locale,
package: docLocation.package,
page: nextPage,
}
: undefined
return (
<>
<header
data-article-sticky-header
className="z-10 h-16 border-b bg-white/70 backdrop-blur-sm md:sticky md:top-0 dark:border-slate-800 dark:bg-slate-950/80"
>
<Container className="flex size-full items-center gap-4">
<HomeButton href={getDocsHomeHref(docLocation.locale)} />
<SlashLine />
<h1 className="truncate">{docLocation.package.label}</h1>
<Actions className="ml-auto" />
</Container>
</header>
<section
data-article-sticky-header
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 backdrop-blur-sm md:hidden dark:border-slate-800 dark:bg-slate-950/80"
>
<button
ref={navigationTriggerRef}
type="button"
className="inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
aria-controls="article-mobile-navigation"
aria-expanded={mobilePanel === "navigation"}
onClick={() =>
setMobilePanel((current) =>
current === "navigation" ? null : "navigation"
)
}
>
<Icon name="tabler:menu" className="size-5" />
<span>{t({ zh: "文档菜单", us: "Docs Menu" })}</span>
</button>
<button
ref={tocTriggerRef}
type="button"
className="ml-auto inline-flex h-12 cursor-pointer items-center gap-2 px-4 transition-colors"
aria-controls="article-mobile-toc"
aria-expanded={mobilePanel === "toc"}
onClick={() =>
setMobilePanel((current) => (current === "toc" ? null : "toc"))
}
>
<span>{t({ zh: "本页内容", us: "On this page" })}</span>
<Icon name="tabler:list" className="size-5 rotate-180" />
</button>
</section>
{mobilePanel &&
typeof document !== "undefined" &&
createPortal(
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-slate-950/50 backdrop-blur-[1px] dark:bg-black/70"
aria-hidden="true"
onClick={() => {
const trigger =
mobilePanel === "navigation"
? navigationTriggerRef.current
: tocTriggerRef.current
setMobilePanel(null)
window.requestAnimationFrame(() => trigger?.focus())
}}
/>
<div
ref={mobilePanelRef}
id={
mobilePanel === "navigation"
? "article-mobile-navigation"
: "article-mobile-toc"
}
role="dialog"
aria-modal="true"
aria-label={
mobilePanel === "navigation"
? t({ zh: "文档导航", us: "Documentation navigation" })
: t({ zh: "本页内容", us: "On this page" })
}
className={[
"absolute inset-y-0 w-[min(88vw,35rem)] overflow-y-auto overscroll-contain bg-white p-7 shadow-2xl dark:bg-slate-950 dark:shadow-black/60",
mobilePanel === "navigation" ? "left-0" : "right-0",
].join(" ")}
onClick={(event) => {
if (
event.target instanceof Element &&
event.target.closest("a[href]")
) {
setMobilePanel(null)
}
}}
>
{mobilePanel === "navigation" ? (
<nav aria-label="Documentation navigation">
<Sidebar current={docLocation} />
</nav>
) : (
<TableOfContents
activeIds={visibleTocIds}
items={docLocation.page.toc}
/>
)}
</div>
</div>,
document.body
)}
<div className="flex w-full">
<div
data-slot="left"
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-lw) flex-[6_1_var(--min-lw)] overflow-y-auto border-r bg-slate-50 md:block dark:border-slate-800 dark:bg-slate-900/50"
>
<aside className="ml-auto w-full max-w-(--max-lw) p-8">
<Sidebar current={docLocation} />
</aside>
</div>
<div
data-slot="center"
className="min-w-0 flex-1 sm:flex-[0_1_var(--cw)]"
>
<main className="w-full max-w-(--cw) px-4 pt-8 pb-16 md:px-22">
<Header docLocation={docLocation} />
<ArticleContent page={docLocation.page} />
<Footer next={next} previous={previous} />
</main>
</div>
<div
data-slot="right"
className="sticky top-16 hidden h-[calc(100svh-(--spacing(16)))] min-w-(--min-rw) flex-[5_1_var(--min-rw)] lg:block"
>
<div className="h-full w-full max-w-(--max-rw)">
<TableOfContents
activeIds={visibleTocIds}
items={docLocation.page.toc}
/>
</div>
</div>
</div>
<footer className="border-t py-8 text-slate-500 dark:border-slate-800 dark:text-slate-400">
<Container className="flex h-full w-full items-center">
Copyright © 2026
</Container>
</footer>
</>
)
}
function Sidebar({ current }: { current: DocLocation }) {
const overview = current.package.pages.find(
(page) => page.section === "overview"
)
const groups = current.package.pages
.filter((page) => page.section !== "overview")
.reduce<Array<{ section: DocPage["section"]; pages: DocPage[] }>>(
(result, page) => {
const group = result.at(-1)
if (group?.section === page.section) {
group.pages.push(page)
} else {
result.push({ section: page.section, pages: [page] })
}
return result
},
[]
)
return (
<ul>
{overview && (
<li>
<SidebarPageLink current={current} page={overview} prominent />
</li>
)}
{groups.map((group) => (
<li className="not-first:mt-8" key={group.section}>
<h2 className="font-semibold">
{getDocSectionLabel(group.section, current.locale)}
</h2>
<ul className="mt-1.5">
{group.pages.map((page) => (
<li key={`${page.section}/${page.slug}`}>
<SidebarPageLink current={current} page={page} />
</li>
))}
</ul>
</li>
))}
</ul>
)
}
function SidebarPageLink({
current,
page,
prominent = false,
}: {
current: DocLocation
page: DocPage
prominent?: boolean
}) {
return (
<a
className={`block py-1 transition-colors hover:text-indigo-500 aria-[current=page]:text-indigo-500 dark:hover:text-indigo-300 dark:aria-[current=page]:text-indigo-400 ${prominent ? "font-semibold text-slate-950 dark:text-slate-100" : "text-gray-600 aria-[current=page]:font-medium dark:text-slate-400"}`}
href={getDocHref(current.package, page)}
aria-current={
current.page.section === page.section && current.page.slug === page.slug
? "page"
: undefined
}
>
{page.title}
</a>
)
}
function Header({ docLocation }: { docLocation: DocLocation }) {
return (
<header className="mb-12 border-b pb-12">
<ul className="flex items-center gap-2">
<li>
<a
href={getDocHref(docLocation.package)}
className="transition-colors hover:text-indigo-500 dark:hover:text-indigo-300"
>
{docLocation.package.label}
</a>
</li>
<li>
<span className="text-slate-200 dark:text-slate-700">/</span>
</li>
<li>
<span className="text-indigo-500 dark:text-indigo-400">
{docLocation.page.title}
</span>
</li>
</ul>
<h1 className="mt-4 text-4xl">{docLocation.page.title}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{docLocation.page.description}
</p>
</header>
)
}
function ArticleContent({ page }: { page: DocPage }) {
const Content = page.Content
return (
<article
data-article-content
className="space-y-5 text-slate-700 dark:text-slate-300 [&_hr]:my-12 [&_hr]:border-slate-200 dark:[&_hr]:border-slate-800 [&_li]:leading-7 [&_ol]:list-decimal [&_ol]:space-y-2 [&_ol]:pl-6 [&_p]:leading-7 [&_strong]:font-semibold [&_strong]:text-slate-950 dark:[&_strong]:text-slate-100 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-slate-200 [&_td]:p-3 dark:[&_td]:border-slate-800 [&_th]:border [&_th]:border-slate-200 [&_th]:bg-slate-50 [&_th]:p-3 [&_th]:text-left dark:[&_th]:border-slate-800 dark:[&_th]:bg-slate-900 [&_ul]:list-disc [&_ul]:space-y-2 [&_ul]:pl-6"
>
<Content components={mdxComponents} />
</article>
)
}
function TableOfContents({
activeIds,
items,
}: {
activeIds: readonly string[]
items: readonly DocTocItem[]
}) {
const t = useTranslate()
const label = t({ zh: "本页内容", us: "On this page" })
const rootRef = React.useRef<HTMLElement>(null)
const railRef = React.useRef<HTMLDivElement>(null)
const [marker, setMarker] = React.useState<{
height: number
top: number
} | null>(null)
const activeKey = activeIds.join("\n")
React.useLayoutEffect(() => {
const root = rootRef.current
const rail = railRef.current
if (!root || !rail) return
function measureMarker() {
const activeIdSet = new Set(activeIds)
const activeRows = Array.from(
root!.querySelectorAll<HTMLElement>("[data-toc-id]")
).filter((row) => activeIdSet.has(row.dataset.tocId ?? ""))
if (activeRows.length === 0 || rail!.offsetHeight === 0) {
setMarker(null)
return
}
const railRect = rail!.getBoundingClientRect()
const firstRect = activeRows[0]!.getBoundingClientRect()
const lastRect = activeRows.at(-1)!.getBoundingClientRect()
setMarker({
top: firstRect.top - railRect.top,
height: lastRect.bottom - firstRect.top,
})
}
measureMarker()
const resizeObserver = new ResizeObserver(measureMarker)
resizeObserver.observe(root)
window.addEventListener("resize", measureMarker)
return () => {
resizeObserver.disconnect()
window.removeEventListener("resize", measureMarker)
}
}, [activeKey, activeIds])
return (
<aside
ref={rootRef}
aria-label={label}
className="relative max-h-full overflow-y-auto p-8"
>
<div
ref={railRef}
className="absolute top-8 bottom-6 left-8 hidden w-px bg-slate-200 md:block dark:bg-slate-800"
>
<div
className="absolute left-0 w-px bg-indigo-500 transition-[height,transform,opacity] duration-200 dark:bg-indigo-400"
style={{
height: marker?.height ?? 0,
opacity: marker ? 1 : 0,
transform: `translateY(${marker?.top ?? 0}px)`,
}}
/>
</div>
<div className="md:ps-4">
<h2 className="shrink-0">{label}</h2>
<ul className="mt-2">
{items.map((item) => (
<TocItem activeIds={activeIds} item={item} key={item.id} />
))}
</ul>
</div>
</aside>
)
}
function TocItem({
activeIds,
item,
}: {
activeIds: readonly string[]
item: DocTocItem
}) {
const active = activeIds.includes(item.id)
return (
<li>
<TocAnchor
active={active}
href={`#${item.id}`}
itemId={item.id}
title={item.title}
/>
{item.items && item.items.length > 0 && (
<ul className="ps-4">
{item.items.map((child) => (
<TocItem activeIds={activeIds} item={child} key={child.id} />
))}
</ul>
)}
</li>
)
}
function TocAnchor({
active,
href,
itemId,
title,
}: {
active: boolean
href: string
itemId: string
title: string
}) {
return (
<div className="py-1" data-toc-id={itemId}>
<a
href={href}
data-active={active || undefined}
className="text-slate-600 transition-colors hover:text-indigo-500 data-active:font-medium data-active:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 dark:data-active:text-indigo-400"
>
{title}
</a>
</div>
)
}
type TocVisibilityEntry = {
id: string
parentIds: readonly string[]
}
function useVisibleTocIds(items: readonly DocTocItem[]) {
const entries = React.useMemo(() => flattenTocItems(items), [items])
const [activeIds, setActiveIds] = React.useState<readonly string[]>([])
React.useEffect(() => {
if (entries.length === 0) {
setActiveIds([])
return
}
let animationFrame: number | undefined
const articleElement = document.querySelector<HTMLElement>(
"[data-article-content]"
)
if (!articleElement) return
const article = articleElement
function update() {
animationFrame = undefined
const headings = entries.flatMap((entry) => {
const element = document.getElementById(entry.id)
return element ? [{ element, entry }] : []
})
const viewportTop = getVisibleViewportTop()
const viewportBottom = window.innerHeight
const articleBottom = article.getBoundingClientRect().bottom
const visibleIds = new Set<string>()
headings.forEach(({ element, entry }, index) => {
const sectionTop = element.getBoundingClientRect().top
const sectionBottom =
headings[index + 1]?.element.getBoundingClientRect().top ??
articleBottom
if (sectionBottom > viewportTop && sectionTop < viewportBottom) {
entry.parentIds.forEach((id) => visibleIds.add(id))
visibleIds.add(entry.id)
}
})
const nextActiveIds = entries
.map((entry) => entry.id)
.filter((id) => visibleIds.has(id))
setActiveIds((current) =>
arraysEqual(current, nextActiveIds) ? current : nextActiveIds
)
}
function scheduleUpdate() {
if (animationFrame !== undefined) return
animationFrame = window.requestAnimationFrame(update)
}
scheduleUpdate()
const resizeObserver = new ResizeObserver(scheduleUpdate)
resizeObserver.observe(article)
window.addEventListener("scroll", scheduleUpdate, { passive: true })
window.addEventListener("resize", scheduleUpdate)
return () => {
if (animationFrame !== undefined) {
window.cancelAnimationFrame(animationFrame)
}
resizeObserver.disconnect()
window.removeEventListener("scroll", scheduleUpdate)
window.removeEventListener("resize", scheduleUpdate)
}
}, [entries])
return activeIds
}
function flattenTocItems(
items: readonly DocTocItem[],
parentIds: readonly string[] = []
): readonly TocVisibilityEntry[] {
return items.flatMap((item) => [
{ id: item.id, parentIds },
...flattenTocItems(item.items ?? [], [...parentIds, item.id]),
])
}
function getVisibleViewportTop() {
return Array.from(
document.querySelectorAll<HTMLElement>("[data-article-sticky-header]")
).reduce((bottom, element) => {
const rect = element.getBoundingClientRect()
return rect.top <= 0 && rect.bottom > 0
? Math.max(bottom, rect.bottom)
: bottom
}, 0)
}
function arraysEqual(left: readonly string[], right: readonly string[]) {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
)
}
function Footer({
next,
previous,
}: {
next?: DocLocation
previous?: DocLocation
}) {
return (
<footer className="mt-12 flex flex-col gap-4 border-t border-dashed pt-12 md:grid md:grid-cols-2 md:gap-8">
{previous && (
<Surround
href={getDocHref(previous.package, previous.page)}
title={previous.page.title}
description={previous.page.description}
align="left"
/>
)}
{next && (
<Surround
href={getDocHref(next.package, next.page)}
title={next.page.title}
description={next.page.description}
align="right"
/>
)}
</footer>
)
}
function Surround({
href,
title,
description,
align = "right",
}: {
title: string
description: string
align?: "left" | "right"
href: string
}) {
return (
<a
href={href}
className={[
"group flex min-w-0 flex-col gap-1 rounded-xl border p-4",
"transition-colors hover:bg-slate-50 dark:hover:bg-slate-900",
align === "left"
? "col-start-1 items-start text-left"
: "col-start-2 items-end text-right",
].join(" ")}
>
<div className="flex size-8 items-center justify-center rounded-full border bg-slate-50 text-sm font-bold transition-colors group-hover:border-indigo-500 group-hover:bg-indigo-100 group-hover:text-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:group-hover:border-indigo-400 dark:group-hover:bg-indigo-950/70 dark:group-hover:text-indigo-300">
{align === "left" ? (
<Icon name="tabler:arrow-left" />
) : (
<Icon name="tabler:arrow-right" />
)}
</div>
<h2 className="mt-4 w-full min-w-0 truncate text-lg">{title}</h2>
<p className="text-md mt-1 line-clamp-2 w-full min-w-0 break-all text-slate-500 dark:text-slate-400">
{description}
</p>
</a>
)
}
function ArticleNotFound() {
const locale = useLocale()
const t = useTranslate()
return (
<main className="mx-auto flex min-h-dvh max-w-xl flex-col justify-center px-6">
<h1 className="text-4xl">
{t({ zh: "未找到文档", us: "Document not found" })}
</h1>
<p className="mt-3 text-slate-500 dark:text-slate-400">
{t({
zh: "请求的文档包或页面不存在。",
us: "The requested package or document does not exist.",
})}
</p>
<a
className="mt-6 w-fit text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
href={getDocHref(getDocPackages(locale)[0]!)}
>
{t({ zh: "打开文档", us: "Open the documentation" })}
</a>
</main>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { Actions, Container } from "../components/layout"
import { getDocHref, getDocPackages } from "../content/registry"
import { useLocale, useTranslate } from "../lib/locale"
export default function IndexPage() {
const locale = useLocale()
const t = useTranslate()
const docPackages = getDocPackages(locale)
return (
<>
<Container className="flex items-center justify-between gap-4 pt-8 pb-10 sm:pt-12 sm:pb-14 lg:pb-16">
<h1 className="min-w-0 text-2xl font-semibold capitalize sm:text-3xl">
my-shadcn-ui
</h1>
<Actions className="ml-auto" />
</Container>
<Container
className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8"
aria-label={t({ zh: "文档导航", us: "Documentation" })}
>
{docPackages.map((docPackage, index) => (
<a
key={docPackage.key}
href={getDocHref(docPackage)}
className="block overflow-hidden rounded-xl border border-slate-200 bg-indigo-50 outline-0 outline-offset-2 transition-colors hover:border-indigo-500 hover:bg-indigo-50 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-400 dark:hover:bg-slate-800/80 dark:focus-visible:outline-indigo-400"
>
<div className="min-h-fit p-4 sm:min-h-40 lg:min-h-46">
<div className="text-slate-400 dark:text-slate-500">
{index > 9 ? `${index + 1}` : `0${index + 1}`}
</div>
<h2 className="mt-2 mb-4 text-xl font-semibold text-slate-950 dark:text-slate-100">
{docPackage.label}
</h2>
<p className="text-slate-700 dark:text-slate-300">
{docPackage.description}
</p>
</div>
</a>
))}
</Container>
<footer className="py-8 sm:py-12">
<Container className="text-center text-slate-500 dark:text-slate-400">
<div>Copyright © 2026</div>
</Container>
</footer>
</>
)
}
+365
View File
@@ -0,0 +1,365 @@
import { Icon } from "@icones/react"
import { usePreference } from "@workspace/preferences"
import { BackButton, Container, SlashLine } from "../components/layout"
import { LocaleFlag } from "../components/locale-flag"
import { SearchTrigger } from "../components/search-trigger"
import { Swatch } from "../components/swatch"
import { getLocalizedHref } from "../content/registry"
import type { DocLocale } from "../content/types"
import {
accentColors,
backgroundShades,
getPaletteColor,
themeModes,
useAppearance,
type AccentColor,
type BackgroundShade,
type ForegroundShade,
type ThemeMode,
} from "../lib/appearance"
import { useLocale, useTranslate, type Translation } from "../lib/locale"
import { navigateToLocale } from "../lib/locale-navigation"
import { detectLocale } from "../lib/preferences"
const foregroundOptions: ReadonlyArray<{
label: Translation
value: ForegroundShade
}> = [
{ value: "slate", label: { zh: "冷色", us: "Cool" } },
{ value: "neutral", label: { zh: "中性", us: "Neutral" } },
{ value: "stone", label: { zh: "暖色", us: "Warm" } },
]
const messages = {
accent: { zh: "强调色", us: "Accent color" },
appearance: { zh: "外观", us: "Appearance" },
autoDetect: { zh: "自动检测", us: "Automatic detection" },
autoDetectDescription: {
zh: "跟随浏览器或操作系统语言设置。",
us: "Follow the browser or operating system language.",
},
background: { zh: "背景色调", us: "Background shade" },
enable: { zh: "启用", us: "Enable" },
foreground: { zh: "前景色调", us: "Foreground shade" },
language: { zh: "语言", us: "Language" },
settings: { zh: "设置", us: "Settings" },
theme: { zh: "主题", us: "Theme" },
themeModes: {
light: { zh: "浅色", us: "Light" },
dark: { zh: "深色", us: "Dark" },
system: { zh: "跟随系统", us: "System" },
},
} as const
export default function SettingsPage() {
const locale = useLocale()
const t = useTranslate()
const appearance = useAppearance()
const [localeMode, setLocaleMode] = usePreference("locale-mode")
const [, setPreferredLocale] = usePreference("locale")
const autoDetectLocale = localeMode === "auto"
function selectLocale(nextLocale: DocLocale) {
setPreferredLocale(nextLocale)
setLocaleMode("manual")
navigateToLocale(nextLocale)
}
function setAutomaticLocale(enabled: boolean) {
if (enabled) {
setLocaleMode("auto")
navigateToLocale(detectLocale())
} else {
setPreferredLocale(locale)
setLocaleMode("manual")
}
}
return (
<>
<header className="border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80">
<Container className="flex h-16 items-center gap-4">
<BackButton />
<SlashLine />
<h1 className="text-xl font-semibold">{t(messages.settings)}</h1>
<div className="ml-auto">
<SearchTrigger />
</div>
</Container>
</header>
<main>
<Container className="py-8 sm:py-12">
<section aria-labelledby="appearance-heading">
<h2 id="appearance-heading" className="mb-4 text-xl font-semibold">
{t(messages.appearance)}
</h2>
<div className="space-y-8 rounded-xl border bg-white/50 p-5 sm:p-8 dark:border-slate-800 dark:bg-slate-900/30">
<fieldset>
<legend className="mb-3">{t(messages.theme)}</legend>
<div
role="radiogroup"
aria-label={t(messages.theme)}
className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-56 dark:border-slate-700"
onKeyDown={(event) => {
if (
event.key !== "ArrowDown" &&
event.key !== "ArrowUp" &&
event.key !== "ArrowRight" &&
event.key !== "ArrowLeft" &&
event.key !== "Home" &&
event.key !== "End"
) {
return
}
event.preventDefault()
const options = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[role="radio"]'
)
)
const currentIndex = options.indexOf(
document.activeElement as HTMLButtonElement
)
const moveForward =
event.key === "ArrowDown" || event.key === "ArrowRight"
let nextIndex = currentIndex
if (event.key === "Home") nextIndex = 0
else if (event.key === "End") nextIndex = options.length - 1
else if (moveForward) {
nextIndex = (currentIndex + 1) % options.length
} else {
nextIndex =
(currentIndex - 1 + options.length) % options.length
}
options[nextIndex]?.focus()
options[nextIndex]?.click()
}}
>
{themeModes.map((mode) => {
const selected = appearance.themeMode === mode
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
className="flex h-11 w-full cursor-pointer items-center gap-3 rounded-lg px-3 text-left transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-indigo-500 aria-checked:bg-indigo-100 aria-checked:text-indigo-700 dark:hover:bg-slate-800 dark:aria-checked:bg-indigo-950 dark:aria-checked:text-indigo-300"
onClick={() =>
appearance.updateAppearance({ themeMode: mode })
}
>
<ThemeIcon mode={mode} />
<span>{t(messages.themeModes[mode])}</span>
{selected && (
<Icon
name="tabler:check"
className="ml-auto size-4"
/>
)}
</button>
)
})}
</div>
</fieldset>
<ColorField
label={t(messages.accent)}
value={appearance.accentColor}
options={accentColors}
getColors={(accentColor) => ({
light: getPaletteColor(accentColor, 600),
dark: getPaletteColor(accentColor, 400),
})}
onChange={(accentColor) =>
appearance.updateAppearance({ accentColor })
}
/>
<ColorField
label={t(messages.background)}
value={appearance.backgroundShade}
options={backgroundShades}
getColors={(backgroundShade) => ({
light: getPaletteColor(backgroundShade, 50),
dark: getPaletteColor(backgroundShade, 950),
})}
onChange={(backgroundShade) =>
appearance.updateAppearance({ backgroundShade })
}
/>
<fieldset>
<legend className="mb-3">{t(messages.foreground)}</legend>
<div className="flex flex-wrap items-center gap-4">
{foregroundOptions.map((option) => (
<Swatch
key={option.value}
light={getPaletteColor(option.value, 950)}
dark={getPaletteColor(option.value, 50)}
aria-label={t(option.label)}
title={t(option.label)}
selected={appearance.foregroundShade === option.value}
onClick={() =>
appearance.updateAppearance({
foregroundShade: option.value,
})
}
/>
))}
</div>
</fieldset>
</div>
</section>
<section
className="mt-10 sm:mt-12"
aria-labelledby="language-heading"
>
<h2 id="language-heading" className="mb-4 text-xl font-semibold">
{t(messages.language)}
</h2>
<div className="space-y-8 rounded-xl border bg-white/50 p-5 sm:p-8 dark:border-slate-800 dark:bg-slate-900/30">
<fieldset>
<legend className="mb-3">{t(messages.language)}</legend>
<div className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-48 dark:border-slate-700">
<LocaleLink
disabled={autoDetectLocale}
href={getLocalizedHref(window.location.pathname, "zh-Hans")}
label="简体中文"
locale="zh-Hans"
selected={locale === "zh-Hans"}
onClick={() => selectLocale("zh-Hans")}
/>
<LocaleLink
disabled={autoDetectLocale}
href={getLocalizedHref(window.location.pathname, "en-US")}
label="English"
locale="en-US"
selected={locale === "en-US"}
onClick={() => selectLocale("en-US")}
/>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<div>{t(messages.autoDetect)}</div>
<label className="inline-flex w-fit cursor-pointer items-center gap-3">
<input
type="checkbox"
className="size-4 accent-indigo-600"
checked={autoDetectLocale}
onChange={(event) =>
setAutomaticLocale(event.currentTarget.checked)
}
/>
<span>{t(messages.enable)}</span>
</label>
<p className="text-sm text-(--docs-muted-light) dark:text-(--docs-muted-dark)">
{t(messages.autoDetectDescription)}
</p>
</div>
</div>
</section>
</Container>
</main>
</>
)
}
function ColorField<T extends AccentColor | BackgroundShade>({
getColors,
label,
onChange,
options,
value,
}: {
getColors: (value: T) => { dark: string; light: string }
label: string
onChange: (value: T) => void
options: readonly T[]
value: T
}) {
return (
<fieldset>
<legend className="mb-3">{label}</legend>
<div className="flex flex-wrap items-center gap-4">
{options.map((option) => {
const colors = getColors(option)
const optionLabel = capitalize(option)
return (
<Swatch
key={option}
light={colors.light}
dark={colors.dark}
aria-label={optionLabel}
title={optionLabel}
selected={option === value}
onClick={() => onChange(option)}
/>
)
})}
</div>
</fieldset>
)
}
function ThemeIcon({ mode }: { mode: ThemeMode }) {
if (mode === "light") {
return <Icon name="tabler:sun-high" className="size-5" />
}
if (mode === "dark") {
return <Icon name="tabler:moon" className="size-5" />
}
return <Icon name="tabler:device-desktop" className="size-5" />
}
function LocaleLink({
disabled,
href,
label,
locale,
onClick,
selected,
}: {
disabled: boolean
href: string
label: string
locale: DocLocale
onClick: () => void
selected: boolean
}) {
return (
<a
href={href}
hrefLang={locale}
lang={locale}
aria-disabled={disabled}
aria-current={selected ? "page" : undefined}
tabIndex={disabled ? -1 : undefined}
className="flex h-11 items-center gap-2 rounded-lg px-3 transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-indigo-500 aria-disabled:cursor-not-allowed aria-disabled:opacity-45 aria-disabled:hover:bg-transparent aria-[current=page]:bg-indigo-100 aria-[current=page]:text-indigo-700 dark:hover:bg-slate-800 dark:aria-disabled:hover:bg-transparent dark:aria-[current=page]:bg-indigo-950 dark:aria-[current=page]:text-indigo-300"
onClick={(event) => {
if (disabled) {
event.preventDefault()
return
}
onClick()
}}
>
<LocaleFlag locale={locale} className="size-5" />
<span>{label}</span>
{selected && <Icon name="tabler:check" className="ml-auto size-4" />}
</a>
)
}
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
+9
View File
@@ -0,0 +1,9 @@
declare module "*.mdx" {
import type { ComponentType, ElementType } from "react"
const MDXContent: ComponentType<{
components?: Record<string, ElementType>
}>
export const frontmatter: Record<string, unknown>
export default MDXContent
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+81
View File
@@ -0,0 +1,81 @@
import react, { reactCompilerPreset } from "@vitejs/plugin-react"
import babel from "@rolldown/plugin-babel"
import tailwindcss from "@tailwindcss/vite"
import { icones } from "@icones/vite"
import { readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import { defineConfig, type Plugin } from "vite"
import satteri from "vite-plugin-satteri"
const packagesDirectory = fileURLToPath(new URL("../packages", import.meta.url))
/**
* Vite 默认监听 docs 根目录。现有的外部 MDX 会作为模块被监听,但新建 package
* 或新建 MDX 文件在首次进入模块图之前不会触发 import.meta.glob 重新计算。
*/
function watchPackageDocs(): Plugin {
return {
name: "watch-package-docs",
configureServer(server) {
server.watcher.add(packagesDirectory)
},
}
}
function loadDocSearchSources(): Plugin {
const suffix = "?docs-search-raw"
const virtualPrefix = "\0docs-search-raw:"
return {
name: "load-doc-search-sources",
enforce: "pre",
async resolveId(id, importer) {
if (!id.endsWith(suffix)) return
const sourceId = id.slice(0, -suffix.length)
const resolved = await this.resolve(sourceId, importer, {
skipSelf: true,
})
const filename = resolved?.id ?? sourceId
return `${virtualPrefix}${encodeURIComponent(filename)}.js`
},
async load(id) {
if (!id.startsWith(virtualPrefix)) return
const sourceId = decodeURIComponent(
id.slice(virtualPrefix.length, -".js".length)
)
const filename = sourceId.startsWith("/@fs/")
? sourceId.slice("/@fs".length)
: sourceId
this.addWatchFile(filename)
const source = await readFile(filename, "utf8")
return `export default ${JSON.stringify(source)}`
},
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
watchPackageDocs(),
loadDocSearchSources(),
satteri({
features: {
frontmatter: true,
gfm: true,
headingAttributes: true,
},
mdx: {
jsxImportSource: "react",
},
}),
tailwindcss(),
icones({
mode: "symbol",
emitData: false,
}),
react(),
babel({ presets: [reactCompilerPreset()] }),
],
})