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()] }),
],
})
+1
View File
@@ -23,6 +23,7 @@
}, },
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"docs",
"packages/*" "packages/*"
] ]
} }
@@ -0,0 +1,40 @@
---
title: Appearance
description: Finish the application with persistent theme, color, and compact-layout preferences.
order: 22
toc:
- id: provide-ui-state
title: Provide UI state
- id: apply-preferences
title: Apply preferences
- id: add-locales
title: Add locales
---
## Provide UI state {#provide-ui-state}
Mount `UiStateProvider` near the application root. A controlled provider can persist every state update through its change handler.
```tsx
<UiStateProvider>
<AppearanceController />
<App />
<ThemeToggleButton />
</UiStateProvider>
```
## Apply preferences {#apply-preferences}
Render `AppearanceController` once so the current state becomes document classes and theme variables. `useUiState` reads or updates individual preferences, while `useResolvedTheme` returns the effective light or dark scheme after resolving system mode.
## Add locales {#add-locales}
Add the appearance catalog only when the application uses these controls:
```json
{
"catalogSources": ["@workspace/blocks/appearance/locales/{locale}"]
}
```
The application shell is now complete. Add only the other block-specific catalogs used by the product.
@@ -0,0 +1,34 @@
---
title: Media library
description: Supply a storage adapter and add browsing, upload, folders, and asset selection.
order: 21
toc:
- id: implement-the-adapter
title: Implement the adapter
- id: provide-media
title: Provide media
- id: selection-flows
title: Selection flows
---
## Implement the adapter {#implement-the-adapter}
`MediaAdapter` is the media feature's complete persistence boundary. Implement its query, upload, folder, update, and delete operations with the product's storage service.
## Provide media {#provide-media}
Mount the adapter once around the media surfaces:
```tsx
import { MediaLibrary, MediaProvider } from "@workspace/blocks/media"
;<MediaProvider adapter={mediaAdapter} notify={showNotice}>
<MediaLibrary />
</MediaProvider>
```
The optional `notify` callback translates operational results into the application's toast or notification system.
## Selection flows {#selection-flows}
Use `MediaPickerDialog` for single or multiple asset selection. `readMediaDimensions`, `formatMediaFileSize`, and `defaultMediaReference` cover the common presentation work around uploads and previews.
@@ -0,0 +1,21 @@
---
title: Notifications
description: Connect notification state without coupling the UI to a backend.
order: 20
toc:
- id: notifications-query
title: Notifications query
---
## Notifications query {#notifications-query}
Notifications use TanStack Query as their data boundary:
```tsx
const notifications = useNotifications({
queryKey: ["notifications"],
queryFn: loadNotifications,
})
```
The result includes unread counts, optimistic read-state updates, action execution, refetching, and pending or error status. `AppLayout` can create the sheet directly from the same query options.
@@ -0,0 +1,35 @@
---
title: Chats
description: Add a responsive conversation workspace and connect its events to application services.
order: 30
toc:
- id: render-the-workspace
title: Render the workspace
- id: controlled-state
title: Controlled state
- id: media-and-pagination
title: Media and pagination
---
## Render the workspace {#render-the-workspace}
`ChatWorkspace` renders conversation discovery, the active thread, and the composer:
```tsx
import { ChatWorkspace } from "@workspace/blocks/chats"
;<ChatWorkspace
conversations={conversations}
onSend={({ conversation, message }) => sendMessage(conversation, message)}
/>
```
## Controlled state {#controlled-state}
The active conversation, draft, search query, and Enter-to-send preference can be controlled individually. Leave a value undefined when the workspace should own that state.
The UI emits events but does not select a transport or persistence format. Map API responses into `ChatConversation` and the exported message variants at the application boundary.
## Media and pagination {#media-and-pagination}
Provide `resolveMediaUrl` when stored media references need signed or transformed URLs. Use `onLoadEarlierMessages` with `hasMoreMessages` and `isLoadingMoreMessages` to connect historical pagination.
@@ -0,0 +1,45 @@
---
title: Application shell
description: Assemble the shared header, sidebar, breadcrumbs, chat, and notification surfaces.
order: 11
toc:
- id: render-app-layout
title: Render AppLayout
- id: layout-inputs
title: Layout inputs
- id: optional-preset
title: Optional preset
---
## Render AppLayout {#render-app-layout}
`AppLayout` is the integration point for the rest of this tutorial:
```tsx
import { AppLayout } from "@workspace/blocks/layout"
export function WorkspaceLayout() {
return (
<AppLayout
navigationGroups={navigationGroups}
notifications={{
queryKey: ["notifications"],
queryFn: loadNotifications,
}}
chatThreads={chatThreads}
>
<Outlet />
</AppLayout>
)
}
```
## Layout inputs {#layout-inputs}
The shell composes `AppSidebar`, `AppBreadcrumb`, `AppQueryIndicator`, `UserMenu`, a notification sheet, and chat threads. Pass `headerActions` when the product needs additional global controls.
The route content remains the layout's `children`, so the package does not constrain the router used by the application.
## Optional preset {#optional-preset}
`@workspace/blocks/layouts/vega` provides an optional visual shell. It lives on a separate entry point so the standard layout does not include preset-specific code.
@@ -0,0 +1,40 @@
---
title: Installation
description: Add the package, its styles, and the providers required by the tutorial.
order: 10
toc:
- id: add-the-package
title: Add the package
- id: import-styles
title: Import styles
- id: application-providers
title: Application providers
---
## Add the package {#add-the-package}
Add the workspace dependency to the consuming application:
```json
{
"dependencies": {
"@workspace/blocks": "workspace:*"
}
}
```
Blocks use `@workspace/ui`, React Query, and the workspace internationalization runtime. The workspace package manager resolves those dependencies for local applications.
## Import styles {#import-styles}
Import the block stylesheet once from the application entry:
```ts
import "@workspace/blocks/globals.css"
```
Keep feature imports on their explicit subpaths. For example, importing `@workspace/blocks/media` does not pull chat into the same module graph.
## Application providers {#application-providers}
The full shell expects React Query and internationalization to be available above it. Add those providers before the route tree, then continue to the application-shell chapter.
@@ -0,0 +1,46 @@
---
title: Navigation
description: Define navigation groups once and connect them to desktop and mobile surfaces.
order: 12
toc:
- id: define-groups
title: Define groups
- id: provide-navigation
title: Provide navigation
- id: route-state
title: Route state
---
## Define groups {#define-groups}
Start with typed `NavigationGroup` values. Items may represent direct routes or nested navigation branches.
```ts
import type { NavigationGroup } from "@workspace/blocks/navigation"
export const navigationGroups: NavigationGroup[] = [
{
id: "workspace",
label: "Workspace",
items: [
{ id: "dashboard", label: "Dashboard", to: "/dashboard" },
{ id: "media", label: "Media", to: "/media" },
],
},
]
```
## Provide navigation {#provide-navigation}
`AppLayout` already mounts the provider and the responsive navigation surfaces. For a custom shell, compose them directly:
```tsx
<NavigationProvider groups={navigationGroups}>
<PrimaryNavigation groups={navigationGroups} />
<MobileNavigationSheet groups={navigationGroups} />
</NavigationProvider>
```
## Route state {#route-state}
Use `resolveNavigationRouteState` for the standard route model. Applications with custom routing can supply a `GetNavigationRouteState` implementation while keeping the same navigation UI.
+33
View File
@@ -0,0 +1,33 @@
---
title: Overview
description: Understand how the Blocks package turns UI primitives into complete application features.
order: 1
toc:
- id: what-you-will-build
title: What you will build
- id: design-boundary
title: Design boundary
- id: tutorial-map
title: Tutorial map
---
## What you will build {#what-you-will-build}
This tutorial builds an application shell step by step. You will begin with the shared layout, connect navigation, then add notifications, chat, media, and appearance preferences.
By the end, the application owns its data and business rules while `@workspace/blocks` owns the reusable presentation and interaction patterns.
## Design boundary {#design-boundary}
Blocks sit above `@workspace/ui`. They combine low-level components into features, but they do not choose your API, database, router configuration, or storage provider.
> Keep data access in the host application. Pass it into a block through props, query functions, providers, or adapters.
## Tutorial map {#tutorial-map}
1. Install the package and global styles.
2. Create the application shell.
3. Define navigation and route state.
4. Connect notifications.
5. Add chat and media workflows.
6. Finish with persistent appearance controls.
@@ -0,0 +1,40 @@
---
title: 外观设置
description: 使用可持久化的主题、颜色和紧凑布局偏好完成应用。
order: 22
toc:
- id: provide-ui-state
title: 提供 UI 状态
- id: apply-preferences
title: 应用偏好
- id: add-locales
title: 添加词典
---
## 提供 UI 状态 {#provide-ui-state}
在应用根节点附近挂载 `UiStateProvider`。受控 Provider 可以通过变更处理器持久化每次状态更新。
```tsx
<UiStateProvider>
<AppearanceController />
<App />
<ThemeToggleButton />
</UiStateProvider>
```
## 应用偏好 {#apply-preferences}
渲染一次 `AppearanceController`,使当前状态转换为文档类名和主题变量。`useUiState` 读取或更新单个偏好,`useResolvedTheme` 在解析系统模式后返回最终的亮色或暗色主题。
## 添加词典 {#add-locales}
只有应用使用这些控件时才添加外观词典:
```json
{
"catalogSources": ["@workspace/blocks/appearance/locales/{locale}"]
}
```
至此应用外壳已经完成。其他功能也只需添加产品实际使用的 Block 词典。
@@ -0,0 +1,34 @@
---
title: 媒体库
description: 提供存储 Adapter,并加入浏览、上传、文件夹和资源选择能力。
order: 21
toc:
- id: implement-the-adapter
title: 实现 Adapter
- id: provide-media
title: 提供媒体能力
- id: selection-flows
title: 选择流程
---
## 实现 Adapter {#implement-the-adapter}
`MediaAdapter` 是媒体功能完整的持久化边界。通过产品存储服务实现查询、上传、文件夹、更新和删除操作。
## 提供媒体能力 {#provide-media}
在媒体界面外挂载一次 Adapter:
```tsx
import { MediaLibrary, MediaProvider } from "@workspace/blocks/media"
;<MediaProvider adapter={mediaAdapter} notify={showNotice}>
<MediaLibrary />
</MediaProvider>
```
可选的 `notify` 回调把操作结果转换为应用的 Toast 或通知。
## 选择流程 {#selection-flows}
单选或多选资源使用 `MediaPickerDialog`。`readMediaDimensions`、`formatMediaFileSize` 和 `defaultMediaReference` 覆盖上传与预览相关的常用展示工作。
@@ -0,0 +1,21 @@
---
title: 通知
description: 接入通知状态,同时避免让 UI 与后端耦合。
order: 20
toc:
- id: notifications-query
title: 通知查询
---
## 通知查询 {#notifications-query}
通知以 TanStack Query 作为数据边界:
```tsx
const notifications = useNotifications({
queryKey: ["notifications"],
queryFn: loadNotifications,
})
```
返回结果包含未读数量、乐观已读更新、操作执行、重新请求以及等待和错误状态。`AppLayout` 可以直接通过相同查询选项创建通知 Sheet。
@@ -0,0 +1,35 @@
---
title: 聊天示例
description: 添加响应式会话工作区,并将事件连接到应用服务。
order: 30
toc:
- id: render-the-workspace
title: 渲染工作区
- id: controlled-state
title: 受控状态
- id: media-and-pagination
title: 媒体与分页
---
## 渲染工作区 {#render-the-workspace}
`ChatWorkspace` 渲染会话查找、当前线程和消息编辑器:
```tsx
import { ChatWorkspace } from "@workspace/blocks/chats"
;<ChatWorkspace
conversations={conversations}
onSend={({ conversation, message }) => sendMessage(conversation, message)}
/>
```
## 受控状态 {#controlled-state}
当前会话、草稿、搜索词和回车发送偏好都可以单独受控。当工作区应自行管理某项状态时,不传对应值即可。
UI 只发送事件,不选择传输方式或持久化格式。在应用边界将 API 响应转换为 `ChatConversation` 和导出的消息类型。
## 媒体与分页 {#media-and-pagination}
当已存储媒体需要签名或转换 URL 时,提供 `resolveMediaUrl`。使用 `onLoadEarlierMessages`、`hasMoreMessages` 和 `isLoadingMoreMessages` 接入历史消息分页。
@@ -0,0 +1,45 @@
---
title: 应用外壳
description: 组装共享 Header、Sidebar、面包屑、聊天和通知界面。
order: 11
toc:
- id: render-app-layout
title: 渲染 AppLayout
- id: layout-inputs
title: 布局输入
- id: optional-preset
title: 可选预设
---
## 渲染 AppLayout {#render-app-layout}
`AppLayout` 是本教程其余部分的集成点:
```tsx
import { AppLayout } from "@workspace/blocks/layout"
export function WorkspaceLayout() {
return (
<AppLayout
navigationGroups={navigationGroups}
notifications={{
queryKey: ["notifications"],
queryFn: loadNotifications,
}}
chatThreads={chatThreads}
>
<Outlet />
</AppLayout>
)
}
```
## 布局输入 {#layout-inputs}
应用外壳组合了 `AppSidebar`、`AppBreadcrumb`、`AppQueryIndicator`、`UserMenu`、通知 Sheet 和聊天线程。产品需要更多全局控件时可传入 `headerActions`。
路由内容仍作为布局的 `children`,因此该包不会限制应用使用的路由器。
## 可选预设 {#optional-preset}
`@workspace/blocks/layouts/vega` 提供可选的视觉外壳。它位于独立入口,因此标准布局不会包含预设专属代码。
@@ -0,0 +1,40 @@
---
title: 安装
description: 添加教程所需的包、样式和 Providers。
order: 10
toc:
- id: add-the-package
title: 添加包
- id: import-styles
title: 导入样式
- id: application-providers
title: 应用 Providers
---
## 添加包 {#add-the-package}
在使用方应用中添加工作区依赖:
```json
{
"dependencies": {
"@workspace/blocks": "workspace:*"
}
}
```
Blocks 使用 `@workspace/ui`、React Query 和工作区国际化运行时。工作区包管理器会为本地应用解析这些依赖。
## 导入样式 {#import-styles}
在应用入口导入一次 Block 样式:
```ts
import "@workspace/blocks/globals.css"
```
功能模块应始终从显式子路径导入。例如,导入 `@workspace/blocks/media` 不会把聊天功能加入同一个模块图。
## 应用 Providers {#application-providers}
完整应用外壳要求上层已经提供 React Query 和国际化。在路由树之前加入这些 Providers,然后继续应用外壳章节。
@@ -0,0 +1,46 @@
---
title: 导航
description: 统一定义导航分组,并将其连接到桌面和移动端界面。
order: 12
toc:
- id: define-groups
title: 定义分组
- id: provide-navigation
title: 提供导航
- id: route-state
title: 路由状态
---
## 定义分组 {#define-groups}
从类型安全的 `NavigationGroup` 开始。导航项可以是直接路由,也可以是嵌套导航分支。
```ts
import type { NavigationGroup } from "@workspace/blocks/navigation"
export const navigationGroups: NavigationGroup[] = [
{
id: "workspace",
label: "工作区",
items: [
{ id: "dashboard", label: "仪表盘", to: "/dashboard" },
{ id: "media", label: "媒体", to: "/media" },
],
},
]
```
## 提供导航 {#provide-navigation}
`AppLayout` 已经挂载 Provider 和响应式导航界面。自定义外壳可以直接组合它们:
```tsx
<NavigationProvider groups={navigationGroups}>
<PrimaryNavigation groups={navigationGroups} />
<MobileNavigationSheet groups={navigationGroups} />
</NavigationProvider>
```
## 路由状态 {#route-state}
标准路由模型使用 `resolveNavigationRouteState`。使用自定义路由的应用可以提供 `GetNavigationRouteState` 实现,同时复用相同导航 UI。
+33
View File
@@ -0,0 +1,33 @@
---
title: 概览
description: 了解 Blocks 包如何把 UI 基础组件组合成完整应用功能。
order: 1
toc:
- id: what-you-will-build
title: 将要构建的内容
- id: design-boundary
title: 设计边界
- id: tutorial-map
title: 教程路线
---
## 将要构建的内容 {#what-you-will-build}
本教程会逐步构建一个应用外壳。你将从共享布局开始,接入导航,再添加通知、聊天、媒体和外观偏好。
完成后,应用负责数据和业务规则,`@workspace/blocks` 负责可复用的展示与交互模式。
## 设计边界 {#design-boundary}
Blocks 位于 `@workspace/ui` 之上。它们将底层组件组合成完整功能,但不会替你选择 API、数据库、路由配置或存储服务。
> 将数据访问保留在宿主应用中,通过属性、查询函数、Provider 或 Adapter 传给 Block。
## 教程路线 {#tutorial-map}
1. 安装包和全局样式。
2. 创建应用外壳。
3. 定义导航和路由状态。
4. 接入通知。
5. 添加聊天和媒体工作流。
6. 使用持久化外观控件完成应用。
@@ -0,0 +1,33 @@
---
title: Production catalogs
description: Understand merging, compact message keys, asset generation, and SSR behavior.
order: 20
toc:
- id: merge-order
title: Merge order
- id: build-output
title: Build output
- id: server-rendering
title: Server rendering
---
## Merge order {#merge-order}
Package sources are merged in configured order, followed by the application catalog. An application therefore translates only its own messages unless it intentionally customizes package copy.
> Import only the locale sources used by the application. Block-specific entries do not import catalogs from unrelated features.
## Build output {#build-output}
During production builds, the plugin:
1. merges configured catalogs for every locale;
2. creates one shared schema from semantic message IDs;
3. replaces those IDs with deterministic compact keys;
4. emits one content-hashed JSON asset per locale.
The private schema is diagnostic build state and is not shipped to the browser.
## Server rendering {#server-rendering}
The SSR bundle embeds the matching compact catalog. Browser navigation loads only the selected locale asset, so adding languages does not duplicate every translation in the main application bundle.
+44
View File
@@ -0,0 +1,44 @@
---
title: CLI and Message Studio
description: Add locales, extract messages, compile catalogs, and run the translation interface.
order: 21
toc:
- id: scripts
title: Scripts
- id: commands
title: Commands
- id: daily-workflow
title: Daily workflow
---
## Scripts {#scripts}
```json
{
"scripts": {
"i18n": "workspace-i18n",
"i18n:extract": "workspace-i18n extract",
"i18n:compile": "workspace-i18n compile",
"i18n:ui": "workspace-i18n ui"
}
}
```
## Commands {#commands}
- `new <locale>` validates a BCP 47 tag, updates configuration, and creates the catalog.
- `extract` finds application messages and updates catalogs.
- `compile` produces TypeScript catalog modules.
- `ui` starts Message Studio with Extract and Compile actions.
Every command accepts `--project <path>` when it is invoked outside the application directory.
## Daily workflow {#daily-workflow}
```sh
bun run i18n extract
bun run i18n ui
bun run i18n compile
```
Commit the application configuration and the `en-US` and `zh-Hans` catalogs. Generated private schema data remains build output.
@@ -0,0 +1,36 @@
---
title: Devtool
description: Inspect and edit catalogs from a floating development interface.
order: 30
toc:
- id: mount-the-devtool
title: Mount the Devtool
- id: theme-and-language
title: Theme and language
- id: custom-surfaces
title: Custom surfaces
---
## Mount the Devtool {#mount-the-devtool}
Mount the Devtool inside `I18nProvider` and only during development:
```tsx
import { I18nDevtool } from "@workspace/i18n/devtool"
{
import.meta.env.DEV && (
<I18nDevtool locale="zh-Hans" dark={'[data-theme="dark"]'} />
)
}
```
The component renders through a portal, so application overflow and stacking contexts do not clip it.
## Theme and language {#theme-and-language}
The `dark` option accepts a class name or a CSS selector. Control-panel language is independent from the translated application locale, supports `en-US` and `zh-Hans`, and falls back from `navigator.languages` to `en-US`.
## Custom surfaces {#custom-surfaces}
Use `MessagePanel`, `MessageRepositoryProvider`, and `createHttpMessageRepository` when the application needs a custom development interface instead of the default floating panel.
@@ -0,0 +1,44 @@
---
title: Runtime
description: Load a locale catalog and consume messages through React components and hooks.
order: 11
toc:
- id: load-the-catalog
title: Load the catalog
- id: provide-the-locale
title: Provide the locale
- id: translate-content
title: Translate content
---
## Load the catalog {#load-the-catalog}
The generated catalog loader works in development, production, and SSR builds:
```tsx
import { use } from "react"
import { loadMessageCatalog } from "@workspace/i18n/catalogs"
const messages = use(loadMessageCatalog(locale))
```
## Provide the locale {#provide-the-locale}
```tsx
<I18nProvider
locale={locale}
locales={[
{ locale: "en-US", label: "English" },
{ locale: "zh-Hans", label: "简体中文" },
]}
catalogs={{ [locale]: messages }}
>
{children}
</I18nProvider>
```
## Translate content {#translate-content}
Use `Translate` for JSX content, `useTranslate` for an imperative translation function, and `useMessage` for a single descriptor. `useLocale`, `useLocales`, and `useFormatters` expose locale state and `Intl` formatters.
`Translate` delegates to Lingui without adding a wrapper DOM element.
+48
View File
@@ -0,0 +1,48 @@
---
title: Project setup
description: Create the application configuration and mount the Vite integration.
order: 10
toc:
- id: configuration
title: Configuration
- id: catalog-sources
title: Catalog sources
- id: vite-plugin
title: Vite plugin
---
## Configuration {#configuration}
Create `i18n.config.json` in the consuming application:
```json
{
"sourceLocale": "en-US",
"locales": ["en-US", "zh-Hans"],
"catalogPath": "src/locales/{locale}/messages",
"catalogSources": ["@workspace/ui/locales/{locale}"],
"include": ["src", "../../packages/ui/src"],
"exclude": ["**/*.test.{ts,tsx}"]
}
```
This file is the application's single persistent internationalization configuration. CLI commands create temporary Lingui configuration only for the child process.
The workspace ships exactly two locales: American English (`en-US`) and Simplified Chinese (`zh-Hans`). Use those same locale keys for application catalogs and package catalog sources.
## Catalog sources {#catalog-sources}
Sources are merged from left to right, and the application catalog is always last. Put reusable package catalogs first so product-specific translations can override them.
## Vite plugin {#vite-plugin}
```ts
import { i18n } from "@workspace/i18n/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [i18n()],
})
```
The plugin serves development catalog APIs and exposes the generated loader through `@workspace/i18n/catalogs`.
+35
View File
@@ -0,0 +1,35 @@
---
title: Overview
description: Follow the complete path from application configuration to translated production catalogs.
order: 1
toc:
- id: ownership-model
title: Ownership model
- id: workflow
title: Workflow
- id: package-entries
title: Package entries
---
## Ownership model {#ownership-model}
Every application owns its `i18n.config.json` and application catalogs. Reusable packages may ship built-in catalogs, but the application chooses which ones to merge and may override their messages.
This keeps product copy under application control without duplicating translations from shared packages.
## Workflow {#workflow}
1. Configure locales and catalog sources.
2. Install the Vite plugin.
3. Load one catalog into `I18nProvider`.
4. Extract and translate application messages.
5. Use the Devtool or Message Studio during development.
6. Compile compact, cacheable production assets.
## Package entries {#package-entries}
- `@workspace/i18n` provides the React runtime.
- `@workspace/i18n/catalogs` loads generated catalogs.
- `@workspace/i18n/devtool` provides development surfaces.
- `@workspace/i18n/vite` configures Vite.
- `workspace-i18n` exposes the CLI.
@@ -0,0 +1,33 @@
---
title: 生产环境词典
description: 理解词典合并、紧凑消息键、资源生成和 SSR 行为。
order: 20
toc:
- id: merge-order
title: 合并顺序
- id: build-output
title: 构建产物
- id: server-rendering
title: 服务端渲染
---
## 合并顺序 {#merge-order}
包词典按配置顺序合并,应用词典最后合并。因此应用默认只需翻译自身消息,除非它有意自定义包内文案。
> 只导入应用实际使用的词典来源。特定 Block 的入口不会导入无关功能的词典。
## 构建产物 {#build-output}
生产构建期间,插件会:
1. 为每种语言合并配置的词典;
2. 根据语义消息 ID 创建共享 Schema;
3. 将这些 ID 替换为确定的紧凑键;
4. 为每种语言输出一个带内容哈希的 JSON 资源。
私有 Schema 仅用于构建诊断,不会发送到浏览器。
## 服务端渲染 {#server-rendering}
SSR Bundle 会嵌入匹配的紧凑词典。浏览器导航只加载选中语言的资源,因此增加语言不会让主应用 Bundle 重复包含所有翻译。
@@ -0,0 +1,44 @@
---
title: CLI 与 Message Studio
description: 添加语言、提取消息、编译词典并运行翻译界面。
order: 21
toc:
- id: scripts
title: Scripts
- id: commands
title: 命令
- id: daily-workflow
title: 日常工作流
---
## Scripts {#scripts}
```json
{
"scripts": {
"i18n": "workspace-i18n",
"i18n:extract": "workspace-i18n extract",
"i18n:compile": "workspace-i18n compile",
"i18n:ui": "workspace-i18n ui"
}
}
```
## 命令 {#commands}
- `new <locale>` 校验 BCP 47 标签、更新配置并创建词典。
- `extract` 查找应用消息并更新词典。
- `compile` 生成 TypeScript 词典模块。
- `ui` 启动带有 Extract 和 Compile 操作的 Message Studio。
在应用目录外执行时,每个命令都接受 `--project <path>`。
## 日常工作流 {#daily-workflow}
```sh
bun run i18n extract
bun run i18n ui
bun run i18n compile
```
提交应用配置以及 `en-US`、`zh-Hans` 词典。生成的私有 Schema 数据仍属于构建产物。
@@ -0,0 +1,36 @@
---
title: Devtool
description: 通过浮动开发界面检查和编辑词典。
order: 30
toc:
- id: mount-the-devtool
title: 挂载 Devtool
- id: theme-and-language
title: 主题与语言
- id: custom-surfaces
title: 自定义界面
---
## 挂载 Devtool {#mount-the-devtool}
将 Devtool 挂载在 `I18nProvider` 内,并且只在开发环境启用:
```tsx
import { I18nDevtool } from "@workspace/i18n/devtool"
{
import.meta.env.DEV && (
<I18nDevtool locale="zh-Hans" dark={'[data-theme="dark"]'} />
)
}
```
组件通过 Portal 渲染,因此不会被应用的 overflow 或层叠上下文裁剪。
## 主题与语言 {#theme-and-language}
`dark` 选项接受类名或 CSS 选择器。控制面板语言独立于被翻译的应用语言,支持 `en-US` 和 `zh-Hans`,并会从 `navigator.languages` 回退到 `en-US`。
## 自定义界面 {#custom-surfaces}
当应用需要替换默认浮动面板时,可使用 `MessagePanel`、`MessageRepositoryProvider` 和 `createHttpMessageRepository` 构建自定义开发界面。
@@ -0,0 +1,44 @@
---
title: 运行时
description: 加载语言词典,并通过 React 组件和 Hooks 使用消息。
order: 11
toc:
- id: load-the-catalog
title: 加载词典
- id: provide-the-locale
title: 提供语言
- id: translate-content
title: 翻译内容
---
## 加载词典 {#load-the-catalog}
生成的词典加载器同时支持开发、生产和 SSR 构建:
```tsx
import { use } from "react"
import { loadMessageCatalog } from "@workspace/i18n/catalogs"
const messages = use(loadMessageCatalog(locale))
```
## 提供语言 {#provide-the-locale}
```tsx
<I18nProvider
locale={locale}
locales={[
{ locale: "en-US", label: "English" },
{ locale: "zh-Hans", label: "简体中文" },
]}
catalogs={{ [locale]: messages }}
>
{children}
</I18nProvider>
```
## 翻译内容 {#translate-content}
JSX 内容使用 `Translate`,命令式翻译函数使用 `useTranslate`,单个描述符使用 `useMessage`。`useLocale`、`useLocales` 和 `useFormatters` 提供语言状态与 `Intl` 格式化器。
`Translate` 会直接委托给 Lingui,不会增加额外的 DOM 包装元素。
@@ -0,0 +1,48 @@
---
title: 项目配置
description: 创建应用配置并接入 Vite 插件。
order: 10
toc:
- id: configuration
title: 配置文件
- id: catalog-sources
title: 词典来源
- id: vite-plugin
title: Vite 插件
---
## 配置文件 {#configuration}
在使用方应用中创建 `i18n.config.json`
```json
{
"sourceLocale": "en-US",
"locales": ["en-US", "zh-Hans"],
"catalogPath": "src/locales/{locale}/messages",
"catalogSources": ["@workspace/ui/locales/{locale}"],
"include": ["src", "../../packages/ui/src"],
"exclude": ["**/*.test.{ts,tsx}"]
}
```
该文件是应用唯一的持久国际化配置。CLI 命令只为子进程创建临时 Lingui 配置。
工作区只提供简体中文(`zh-Hans`)和美式英语(`en-US`)。应用词典和包词典来源应使用相同的语言键。
## 词典来源 {#catalog-sources}
词典来源按从左到右的顺序合并,应用词典始终位于最后。将可复用包词典放在前面,使产品特有翻译可以覆盖它们。
## Vite 插件 {#vite-plugin}
```ts
import { i18n } from "@workspace/i18n/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [i18n()],
})
```
插件会提供开发词典 API,并通过 `@workspace/i18n/catalogs` 暴露生成的加载器。
+35
View File
@@ -0,0 +1,35 @@
---
title: 概览
description: 了解从应用配置到生产环境翻译词典的完整流程。
order: 1
toc:
- id: ownership-model
title: 所有权模型
- id: workflow
title: 工作流
- id: package-entries
title: 包入口
---
## 所有权模型 {#ownership-model}
每个应用都拥有自己的 `i18n.config.json` 和应用词典。可复用包可以附带内置词典,但由应用决定合并哪些词典,也可以覆盖其中的消息。
这样既能让产品文案始终由应用控制,也不会重复维护共享包的翻译。
## 工作流 {#workflow}
1. 配置语言和词典来源。
2. 安装 Vite 插件。
3. 将一个语言词典加载到 `I18nProvider`。
4. 提取并翻译应用消息。
5. 开发时使用 Devtool 或 Message Studio。
6. 编译紧凑且可缓存的生产资源。
## 包入口 {#package-entries}
- `@workspace/i18n` 提供 React 运行时。
- `@workspace/i18n/catalogs` 加载生成的词典。
- `@workspace/i18n/devtool` 提供开发界面。
- `@workspace/i18n/vite` 配置 Vite。
- `workspace-i18n` 提供命令行工具。
@@ -0,0 +1,44 @@
---
title: Define an action
description: Add product-specific behavior while preserving automatic dependency collection.
order: 20
toc:
- id: declare-the-action
title: Declare the action
- id: custom-control
title: Custom control
- id: typed-values
title: Typed values
---
## Declare the action {#declare-the-action}
Keep the command and its editor dependencies in one definition:
```tsx
const Mention = defineLexicalAction({
name: "mention",
label: "Insert mention",
nodes: [MentionNode],
plugins: [MentionPopoverPlugin],
execute: ({ editor }) => openMentionPicker(editor),
})
```
The node and plugin are enabled whenever `<Mention />` appears inside `LexicalActions`.
## Custom control {#custom-control}
```tsx
<Mention>
{({ disabled, execute }) => (
<MentionButton disabled={disabled} onSelect={execute} />
)}
</Mention>
```
Replacing the visible control does not change dependency collection.
## Typed values {#typed-values}
The render context exposes `execute(value?)` with the action's value type. `onClick` is the no-argument shortcut for ordinary buttons.
@@ -0,0 +1,30 @@
---
title: Localize the editor
description: Connect built-in labels and custom actions to the workspace internationalization runtime.
order: 21
toc:
- id: add-the-catalog
title: Add the catalog
- id: fallback
title: Fallback
- id: custom-labels
title: Custom labels
---
## Add the catalog {#add-the-catalog}
```json
{
"catalogSources": ["@workspace/lexical/locales/{locale}"]
}
```
The package ships American English (`en-US`) and Simplified Chinese (`zh-Hans`) editor catalogs.
## Fallback {#fallback}
Built-in controls follow the active `@workspace/i18n` provider. Outside a provider, they fall back to English so the editor remains usable in isolated previews and tests.
## Custom labels {#custom-labels}
An external action label can be a plain string or a message descriptor containing `id` and `message`. Descriptors participate in the same extraction workflow as application copy.
@@ -0,0 +1,46 @@
---
title: Add media
description: Insert uploaded media and resolve images pasted or dragged into the editor.
order: 30
toc:
- id: image-and-video
title: Image and video
- id: clipboard-images
title: Clipboard images
- id: editing-media
title: Editing media
---
## Image and video {#image-and-video}
The default `Image` and `Video` actions open built-in input dialogs. A custom uploader can hand the completed payload directly to the action:
```tsx
<Image>
{({ execute }) => (
<ImageUploader
onUploaded={({ src, alt, caption }) => execute({ src, alt, caption })}
/>
)}
</Image>
```
## Clipboard images {#clipboard-images}
The full preset includes `ClipboardImages`. For production, resolve pasted and dropped files through object storage:
```tsx
<ClipboardImages
resolveImage={async (file, { reportProgress, signal }) => {
const uploaded = await uploadImage(file, {
signal,
onProgress: reportProgress,
})
return { alt: file.name, src: uploaded.url }
}}
/>
```
## Editing media {#editing-media}
Selected images support resizing, captions, alignment, and visible or keyboard deletion. Non-image clipboard files remain under normal browser handling.
@@ -0,0 +1,42 @@
---
title: Arrange actions
description: Replace the preset with explicit actions and place controls in the correct editing surfaces.
order: 11
toc:
- id: explicit-actions
title: Explicit actions
- id: placement
title: Placement
- id: action-groups
title: Action groups
---
## Explicit actions {#explicit-actions}
Switch from a preset to children when the product needs exact feature and ordering control:
```tsx
<LexicalActions>
<Undo />
<Redo />
<Bold />
<Italic />
<Link />
</LexicalActions>
```
## Placement {#placement}
Actions default to the fixed toolbar. The `in` prop accepts one area or several:
```tsx
<Bold in={["toolbar", "bubble"]} />
<Date in="bubble" />
<ClearFormatting in="footer" />
```
Hidden actions provide editor behavior without rendering a control. `DraggableBlocks` uses this pattern.
## Action groups {#action-groups}
`ActionGroup` can be a logical group or a visible menu. Use a menu for mutually related choices such as normal text, headings, quotes, and list styles.
@@ -0,0 +1,47 @@
---
title: Create the editor
description: Render the root, content surface, toolbars, and a complete default action set.
order: 10
toc:
- id: styles
title: Styles
- id: editor-structure
title: Editor structure
- id: presets
title: Presets
---
## Styles {#styles}
Import the editor stylesheet once in the application:
```ts
import "@workspace/lexical/globals.css"
```
## Editor structure {#editor-structure}
```tsx
import {
LexicalActions,
LexicalBubbleToolbar,
LexicalContent,
LexicalFixedToolbar,
LexicalFooter,
LexicalRoot,
} from "@workspace/lexical"
;<LexicalRoot value="" onChange={setHtml}>
<LexicalActions useDefaults="full" />
<LexicalFixedToolbar />
<LexicalContent placeholder="Start writing…" />
<LexicalBubbleToolbar />
<LexicalFooter />
</LexicalRoot>
```
Toolbars and the footer render no DOM when their region has no actions and no custom children.
## Presets {#presets}
Choose `minimal` for basic text editing or `full` for the complete built-in feature set. Preset mode intentionally does not accept manual action children.
+26
View File
@@ -0,0 +1,26 @@
---
title: Overview
description: Learn the editor's declarative action model before assembling a complete editing surface.
order: 1
toc:
- id: mental-model
title: Mental model
- id: tutorial-map
title: Tutorial map
- id: stable-capabilities
title: Stable capabilities
---
## Mental model {#mental-model}
`@workspace/lexical` treats editor actions as declarations. Each action describes its behavior and the nodes, plugins, or embeds it requires. `LexicalRoot` collects those requirements before creating the editor.
Toolbars decide where an action appears; they do not separately register editor capabilities.
## Tutorial map {#tutorial-map}
You will create the root and content surface, choose a preset, customize action placement, define an application action, add media uploads, and finish with localized labels.
## Stable capabilities {#stable-capabilities}
Actions, nodes, and plugins are fixed when an editor instance is created. Give `LexicalRoot` a new React `key` when the application needs to replace the entire capability set.
@@ -0,0 +1,44 @@
---
title: 定义 Action
description: 在保留自动依赖收集能力的同时添加产品专属行为。
order: 20
toc:
- id: declare-the-action
title: 声明 Action
- id: custom-control
title: 自定义控件
- id: typed-values
title: 类型化参数
---
## 声明 Action {#declare-the-action}
将命令与其编辑器依赖放在同一个定义中:
```tsx
const Mention = defineLexicalAction({
name: "mention",
label: "插入提及",
nodes: [MentionNode],
plugins: [MentionPopoverPlugin],
execute: ({ editor }) => openMentionPicker(editor),
})
```
只要 `<Mention />` 出现在 `LexicalActions` 内,它需要的节点和插件就会自动启用。
## 自定义控件 {#custom-control}
```tsx
<Mention>
{({ disabled, execute }) => (
<MentionButton disabled={disabled} onSelect={execute} />
)}
</Mention>
```
替换可见控件不会改变依赖收集结果。
## 类型化参数 {#typed-values}
渲染上下文会公开带有 Action 参数类型的 `execute(value?)`。普通按钮可以使用无参数快捷方式 `onClick`。
@@ -0,0 +1,30 @@
---
title: 编辑器本地化
description: 将内置标签和自定义 Actions 接入工作区国际化运行时。
order: 21
toc:
- id: add-the-catalog
title: 添加词典
- id: fallback
title: 回退行为
- id: custom-labels
title: 自定义标签
---
## 添加词典 {#add-the-catalog}
```json
{
"catalogSources": ["@workspace/lexical/locales/{locale}"]
}
```
该包提供简体中文(`zh-Hans`)和美式英语(`en-US`)编辑器词典。
## 回退行为 {#fallback}
内置控件跟随当前 `@workspace/i18n` Provider。在 Provider 外部会回退为英文,使编辑器在独立预览和测试中仍可使用。
## 自定义标签 {#custom-labels}
外部 Action 标签可以是普通字符串,也可以是包含 `id` 和 `message` 的消息描述符。描述符会参与和应用文案相同的提取流程。
@@ -0,0 +1,46 @@
---
title: 添加媒体
description: 插入已上传媒体,并处理粘贴或拖入编辑器的图片。
order: 30
toc:
- id: image-and-video
title: 图片和视频
- id: clipboard-images
title: 剪贴板图片
- id: editing-media
title: 编辑媒体
---
## 图片和视频 {#image-and-video}
默认的 `Image` 和 `Video` Actions 会打开内置输入对话框。自定义上传器可以把完成后的数据直接交给 Action:
```tsx
<Image>
{({ execute }) => (
<ImageUploader
onUploaded={({ src, alt, caption }) => execute({ src, alt, caption })}
/>
)}
</Image>
```
## 剪贴板图片 {#clipboard-images}
完整预设包含 `ClipboardImages`。生产环境应通过对象存储解析粘贴和拖放的文件:
```tsx
<ClipboardImages
resolveImage={async (file, { reportProgress, signal }) => {
const uploaded = await uploadImage(file, {
signal,
onProgress: reportProgress,
})
return { alt: file.name, src: uploaded.url }
}}
/>
```
## 编辑媒体 {#editing-media}
选中的图片支持调整尺寸、编辑说明、对齐,以及可见按钮或键盘删除。非图片剪贴板文件继续交由浏览器的默认行为处理。
@@ -0,0 +1,42 @@
---
title: 编排操作
description: 用显式 Actions 替换预设,并将控件放入正确的编辑区域。
order: 11
toc:
- id: explicit-actions
title: 显式 Actions
- id: placement
title: 放置位置
- id: action-groups
title: Action 分组
---
## 显式 Actions {#explicit-actions}
当产品需要精确控制功能和顺序时,将预设切换为子元素:
```tsx
<LexicalActions>
<Undo />
<Redo />
<Bold />
<Italic />
<Link />
</LexicalActions>
```
## 放置位置 {#placement}
Actions 默认显示在固定工具栏中。`in` 属性接受一个或多个区域:
```tsx
<Bold in={["toolbar", "bubble"]} />
<Date in="bubble" />
<ClearFormatting in="footer" />
```
隐藏 Action 可以提供编辑器行为而不渲染控件,`DraggableBlocks` 就使用了这种模式。
## Action 分组 {#action-groups}
`ActionGroup` 可以是逻辑分组,也可以是可见菜单。普通文本、标题、引用和列表样式等相互关联的选择适合放入菜单。
@@ -0,0 +1,47 @@
---
title: 创建编辑器
description: 渲染根节点、内容区域、工具栏和完整的默认 Action 集合。
order: 10
toc:
- id: styles
title: 样式
- id: editor-structure
title: 编辑器结构
- id: presets
title: 预设
---
## 样式 {#styles}
在应用中导入一次编辑器样式:
```ts
import "@workspace/lexical/globals.css"
```
## 编辑器结构 {#editor-structure}
```tsx
import {
LexicalActions,
LexicalBubbleToolbar,
LexicalContent,
LexicalFixedToolbar,
LexicalFooter,
LexicalRoot,
} from "@workspace/lexical"
;<LexicalRoot value="" onChange={setHtml}>
<LexicalActions useDefaults="full" />
<LexicalFixedToolbar />
<LexicalContent placeholder="开始输入…" />
<LexicalBubbleToolbar />
<LexicalFooter />
</LexicalRoot>
```
当某个区域没有 Actions 或自定义子元素时,对应工具栏和 Footer 不会渲染 DOM。
## 预设 {#presets}
基础文本编辑选择 `minimal`,完整内置功能选择 `full`。预设模式有意不接受手动 Action 子元素。
@@ -0,0 +1,26 @@
---
title: 概览
description: 在组装完整编辑界面之前,先理解编辑器的声明式 Action 模型。
order: 1
toc:
- id: mental-model
title: 心智模型
- id: tutorial-map
title: 教程路线
- id: stable-capabilities
title: 稳定的能力集合
---
## 心智模型 {#mental-model}
`@workspace/lexical` 将编辑器操作视为声明。每个 Action 描述自身行为及其需要的节点、插件或嵌入内容。`LexicalRoot` 会在创建编辑器前收集这些依赖。
工具栏只决定 Action 出现的位置,不负责单独注册编辑器能力。
## 教程路线 {#tutorial-map}
你将创建根节点和内容区域、选择预设、自定义 Action 位置、定义应用专属 Action、添加媒体上传,最后接入本地化标签。
## 稳定的能力集合 {#stable-capabilities}
编辑器实例创建后,其 Actions、节点和插件保持固定。当应用需要替换整套能力时,请为 `LexicalRoot` 提供新的 React `key`。
@@ -0,0 +1,57 @@
---
title: Lifecycle and persistence
description: Coordinate SSR, asynchronous persistence, browser synchronization, and external input validation.
order: 20
toc:
- id: server-snapshots
title: Server snapshots
- id: persist-updates
title: Persist updates
- id: preference-effects
title: Preference effects
- id: validate-updates
title: Validate updates
---
## Server snapshots {#server-snapshots}
`initialPreferences` is captured when the provider creates its store. It remains the server and hydration snapshot; changing that prop later does not reset local preferences. Remount the provider when the application intentionally switches to a different preference identity.
## Persist updates {#persist-updates}
`onPreferenceChange` runs only when a value actually changes. It may return a promise, but local updates are not blocked while persistence completes. Handle retries, errors, and rollback behavior in the application boundary.
## Preference effects {#preference-effects}
Effects connect preferences to APIs outside React:
```tsx
const documentEffects = [
{
layoutEffect: ({ preferences }) => {
document.documentElement.dataset.theme = preferences["theme-mode"]
},
effect: ({ store }) => {
const media = window.matchMedia("(prefers-color-scheme: dark)")
const listener = () => synchronizeTheme(store.getSnapshot(), media)
media.addEventListener("change", listener)
return () => media.removeEventListener("change", listener)
},
},
] satisfies readonly PreferenceEffect[]
```
Use `layoutEffect` for DOM changes that must happen before paint. Use `effect` for subscriptions. Keep the effects array reference stable because callbacks and cleanup functions run again whenever the snapshot or array changes.
## Validate updates {#validate-updates}
Create a type guard from the same definitions before accepting data from an untyped boundary:
```ts
const isPreferenceUpdate = createPreferenceUpdateGuard(definitions)
if (isPreferenceUpdate(payload)) {
await savePreference(payload)
}
```
@@ -0,0 +1,67 @@
---
title: Theme preference
description: Build a complete light, dark, and system theme preference flow.
order: 30
toc:
- id: define-theme-mode
title: Define theme mode
- id: apply-the-theme
title: Apply the theme
- id: render-a-control
title: Render a control
---
## Define theme mode {#define-theme-mode}
```ts
import "@workspace/preferences"
declare module "@workspace/preferences" {
interface PreferencesCustom {
"theme-mode": "light" | "dark" | "system"
}
}
```
Load the cookie on the server and pass the parsed value through `initialPreferences` so server markup and hydration share the same snapshot.
## Apply the theme {#apply-the-theme}
```tsx
const themeEffects = [
{
layoutEffect: ({ preferences }) => {
const systemDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
const dark =
preferences["theme-mode"] === "dark" ||
(preferences["theme-mode"] === "system" && systemDark)
document.documentElement.classList.toggle("dark", dark)
document.documentElement.style.colorScheme = dark ? "dark" : "light"
},
},
] satisfies readonly PreferenceEffect[]
```
## Render a control {#render-a-control}
```tsx
function ThemeModeSelect() {
const [themeMode, setThemeMode] = usePreference("theme-mode")
return (
<select
value={themeMode}
onChange={(event) =>
setThemeMode(event.currentTarget.value as typeof themeMode)
}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
)
}
```
@@ -0,0 +1,74 @@
---
title: Define and provide preferences
description: Declare the application preference shape and mount the provider with an SSR-safe snapshot.
order: 10
toc:
- id: declare-preferences
title: Declare preferences
- id: define-defaults
title: Define defaults
- id: mount-the-provider
title: Mount the provider
---
## Declare preferences {#declare-preferences}
Use module augmentation in an application-owned TypeScript module:
```ts
import "@workspace/preferences"
declare module "@workspace/preferences" {
interface PreferencesCustom {
"navigation-density": "comfortable" | "compact"
"theme-mode": "light" | "dark" | "system"
}
}
```
The declaration updates `Preferences`, `PreferenceKey`, `PreferenceValue`, `PreferenceUpdate`, and both preference hooks.
## Define defaults {#define-defaults}
Definitions keep parsing, serialization, defaults, and runtime validation together:
```ts
const definitions = {
"navigation-density": {
cookie: "navigation-density",
defaultValue: "comfortable",
is: (value) => value === "comfortable" || value === "compact",
parse: (value) => (value === "compact" ? "compact" : "comfortable"),
serialize: (value) => value,
},
"theme-mode": {
cookie: "theme-mode",
defaultValue: "system",
is: (value) => value === "light" || value === "dark" || value === "system",
parse: (value) =>
value === "light" || value === "dark" ? value : "system",
serialize: (value) => value,
},
} satisfies PreferenceDefinitions<Preferences>
const defaultPreferences = createDefaultPreferences(definitions)
```
## Mount the provider {#mount-the-provider}
Pass the complete server-derived snapshot to the provider:
```tsx
<PreferencesProvider
initialPreferences={preferences}
onPreferenceChange={(update) => savePreference(update)}
>
<App />
</PreferencesProvider>
```
Read one preference with an API matching `useState`:
```tsx
const [themeMode, setThemeMode] = usePreference("theme-mode")
```
@@ -0,0 +1,28 @@
---
title: Overview
description: Understand the role and boundaries of the Preferences package.
order: 1
toc:
- id: package-role
title: Package role
- id: capabilities
title: Capabilities
- id: design-boundary
title: Design boundary
---
## Package role {#package-role}
`@workspace/preferences` is a small React store for application preferences such as theme mode, navigation density, and compact layouts. It keeps the preference vocabulary owned by the consuming application.
## Capabilities {#capabilities}
- Extend the preference shape through TypeScript module augmentation.
- Hydrate a deterministic server snapshot with `useSyncExternalStore`.
- Persist typed `{ key, value }` updates through an application callback.
- Synchronize preferences with browser APIs through composable effects.
- Validate external updates from cookies, requests, forms, or message channels.
## Design boundary {#design-boundary}
The package does not define product-specific keys and does not select a persistence service. The application supplies its complete initial snapshot and decides how accepted changes are stored.
@@ -0,0 +1,57 @@
---
title: 生命周期与持久化
description: 协调 SSR、异步持久化、浏览器同步和外部输入校验。
order: 20
toc:
- id: server-snapshots
title: 服务端快照
- id: persist-updates
title: 持久化更新
- id: preference-effects
title: 偏好副作用
- id: validate-updates
title: 校验更新
---
## 服务端快照 {#server-snapshots}
Provider 创建 Store 时会捕获 `initialPreferences`。它会一直作为服务端和水合快照;之后改变这个属性不会重置本地偏好。当应用有意切换到另一套偏好身份时,应重新挂载 Provider。
## 持久化更新 {#persist-updates}
`onPreferenceChange` 只在值确实发生变化时运行。它可以返回 Promise,但持久化过程不会阻塞本地更新。重试、错误和回滚行为应在应用边界处理。
## 偏好副作用 {#preference-effects}
副作用用于连接偏好与 React 之外的 API:
```tsx
const documentEffects = [
{
layoutEffect: ({ preferences }) => {
document.documentElement.dataset.theme = preferences["theme-mode"]
},
effect: ({ store }) => {
const media = window.matchMedia("(prefers-color-scheme: dark)")
const listener = () => synchronizeTheme(store.getSnapshot(), media)
media.addEventListener("change", listener)
return () => media.removeEventListener("change", listener)
},
},
] satisfies readonly PreferenceEffect[]
```
必须在绘制前完成的 DOM 变更使用 `layoutEffect`,订阅使用 `effect`。副作用数组的引用应保持稳定,因为快照或数组变化时,回调和清理函数都会重新运行。
## 校验更新 {#validate-updates}
在接受无类型边界的数据前,使用相同的 Definitions 创建类型守卫:
```ts
const isPreferenceUpdate = createPreferenceUpdateGuard(definitions)
if (isPreferenceUpdate(payload)) {
await savePreference(payload)
}
```
@@ -0,0 +1,67 @@
---
title: 主题偏好示例
description: 构建完整的亮色、暗色和跟随系统主题偏好流程。
order: 30
toc:
- id: define-theme-mode
title: 定义主题模式
- id: apply-the-theme
title: 应用主题
- id: render-a-control
title: 渲染控件
---
## 定义主题模式 {#define-theme-mode}
```ts
import "@workspace/preferences"
declare module "@workspace/preferences" {
interface PreferencesCustom {
"theme-mode": "light" | "dark" | "system"
}
}
```
在服务端读取 Cookie,并通过 `initialPreferences` 传入解析后的值,使服务端标记和水合过程使用同一个快照。
## 应用主题 {#apply-the-theme}
```tsx
const themeEffects = [
{
layoutEffect: ({ preferences }) => {
const systemDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
const dark =
preferences["theme-mode"] === "dark" ||
(preferences["theme-mode"] === "system" && systemDark)
document.documentElement.classList.toggle("dark", dark)
document.documentElement.style.colorScheme = dark ? "dark" : "light"
},
},
] satisfies readonly PreferenceEffect[]
```
## 渲染控件 {#render-a-control}
```tsx
function ThemeModeSelect() {
const [themeMode, setThemeMode] = usePreference("theme-mode")
return (
<select
value={themeMode}
onChange={(event) =>
setThemeMode(event.currentTarget.value as typeof themeMode)
}
>
<option value="light">亮色</option>
<option value="dark">暗色</option>
<option value="system">跟随系统</option>
</select>
)
}
```
@@ -0,0 +1,74 @@
---
title: 定义并提供偏好设置
description: 声明应用偏好类型,并使用 SSR 安全快照挂载 Provider。
order: 10
toc:
- id: declare-preferences
title: 声明偏好设置
- id: define-defaults
title: 定义默认值
- id: mount-the-provider
title: 挂载 Provider
---
## 声明偏好设置 {#declare-preferences}
在应用拥有的 TypeScript 模块中使用模块扩充:
```ts
import "@workspace/preferences"
declare module "@workspace/preferences" {
interface PreferencesCustom {
"navigation-density": "comfortable" | "compact"
"theme-mode": "light" | "dark" | "system"
}
}
```
该声明会同时更新 `Preferences`、`PreferenceKey`、`PreferenceValue`、`PreferenceUpdate` 和两个偏好 Hooks 的类型。
## 定义默认值 {#define-defaults}
Definition 将解析、序列化、默认值和运行时校验放在一起:
```ts
const definitions = {
"navigation-density": {
cookie: "navigation-density",
defaultValue: "comfortable",
is: (value) => value === "comfortable" || value === "compact",
parse: (value) => (value === "compact" ? "compact" : "comfortable"),
serialize: (value) => value,
},
"theme-mode": {
cookie: "theme-mode",
defaultValue: "system",
is: (value) => value === "light" || value === "dark" || value === "system",
parse: (value) =>
value === "light" || value === "dark" ? value : "system",
serialize: (value) => value,
},
} satisfies PreferenceDefinitions<Preferences>
const defaultPreferences = createDefaultPreferences(definitions)
```
## 挂载 Provider {#mount-the-provider}
将服务端得到的完整快照传给 Provider:
```tsx
<PreferencesProvider
initialPreferences={preferences}
onPreferenceChange={(update) => savePreference(update)}
>
<App />
</PreferencesProvider>
```
通过类似 `useState` 的 API 读取单个偏好:
```tsx
const [themeMode, setThemeMode] = usePreference("theme-mode")
```
@@ -0,0 +1,28 @@
---
title: 概览
description: 了解 Preferences 包的职责和边界。
order: 1
toc:
- id: package-role
title: 包的职责
- id: capabilities
title: 主要能力
- id: design-boundary
title: 设计边界
---
## 包的职责 {#package-role}
`@workspace/preferences` 是一个用于管理应用偏好的轻量 React Store,例如主题模式、导航密度和紧凑布局。偏好字段仍由使用它的应用定义。
## 主要能力 {#capabilities}
- 通过 TypeScript 模块扩充扩展偏好类型。
- 使用 `useSyncExternalStore` 水合确定的服务端快照。
- 通过应用回调持久化类型安全的 `{ key, value }` 更新。
- 通过可组合副作用与浏览器 API 同步偏好。
- 校验来自 Cookie、请求、表单或消息通道的外部更新。
## 设计边界 {#design-boundary}
该包不定义产品特有字段,也不指定持久化服务。应用负责提供完整的初始快照,并决定如何保存已接受的变更。
@@ -0,0 +1,42 @@
---
title: Adapter and pagination
description: Implement cancellable search requests and return mergeable grouped pages.
order: 20
toc:
- id: implement-adapter
title: Implement the adapter
- id: group-results
title: Group results
- id: paginate
title: Paginate
---
## Implement the adapter {#implement-adapter}
`SearchAdapter` is the package's only data dependency:
```ts
const searchAdapter: SearchAdapter = {
async search({ cursor, query, signal }) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}&cursor=${cursor ?? ""}`,
{ signal }
)
if (!response.ok) throw new Error("Search request failed")
return response.json()
},
}
```
Pass `signal` to the underlying request so an older response cannot replace a newer query.
## Group results {#group-results}
Each `SearchPage` contains `SearchResultGroup` values. Keep a group's `id` stable between pages and each item's `id` stable inside its group. Search uses both identifiers to merge results and remove duplicates.
Use `payload` for application data and `icon` or `image` to customize result presentation.
## Paginate {#paginate}
Return `nextCursor` when another page is available; return `null` or omit it at the end. The surface only displays “Load more” while a cursor exists. A cursor can be a string or number and is passed back to the adapter unchanged.
@@ -0,0 +1,58 @@
---
title: Global search example
description: Compose a trigger, dialog, recent searches, and result navigation.
order: 30
toc:
- id: define-results
title: Define results
- id: handle-selection
title: Handle selection
- id: control-history
title: Control history
---
## Define results {#define-results}
```ts
const searchAdapter: SearchAdapter = {
async search({ query }) {
return {
groups: [
{
id: "docs",
label: "Documentation",
items: documents
.filter((document) => document.title.includes(query))
.map((document) => ({
id: document.slug,
title: document.title,
description: document.description,
payload: { href: `/docs/${document.slug}` },
})),
},
],
}
},
}
```
## Handle selection {#handle-selection}
```tsx
<SearchProvider
adapter={searchAdapter}
onSelect={({ item }) => {
const payload = item.payload as { href: string }
router.navigate({ to: payload.href })
}}
>
<HeaderSearchButton />
<SearchDialog />
</SearchProvider>
```
Code that cannot render `SearchTrigger` can call `searchDialogHandle.open(null)`.
## Control history {#control-history}
Recent searches use `workspace-search-history` by default. Supply a product-specific `historyStorageKey`, or set it to `false` to keep history in memory for the current session only.
@@ -0,0 +1,47 @@
---
title: Installation and setup
description: Add Search, register its catalog, and mount the search surface.
order: 10
toc:
- id: add-the-package
title: Add the package
- id: import-styles
title: Import styles
- id: mount-search
title: Mount search
---
## Add the package {#add-the-package}
Add Search to the consuming application:
```json
{
"dependencies": {
"@workspace/search": "workspace:*"
}
}
```
## Import styles {#import-styles}
Import the Search stylesheet once from the application's global stylesheet so Tailwind scans the utilities used by the package:
```css
@import "@workspace/search/globals.css";
```
Also add `@workspace/search/locales/{locale}` to the application's internationalization catalog sources.
## Mount search {#mount-search}
```tsx
import { SearchDialog, SearchProvider, SearchTrigger } from "@workspace/search"
;<SearchProvider adapter={searchAdapter} onSelect={openResult}>
<SearchTrigger>Search</SearchTrigger>
<SearchDialog />
</SearchProvider>
```
`SearchDialog` registers `Mod+K` by default. Pass `hotkey={false}` when the application manages shortcuts centrally.
+29
View File
@@ -0,0 +1,29 @@
---
title: Overview
description: Understand the Search package data boundary, interactions, and use cases.
order: 1
toc:
- id: package-role
title: Package role
- id: capabilities
title: Capabilities
- id: design-boundary
title: Design boundary
---
## Package role {#package-role}
`@workspace/search` provides an application-search surface and its state management. The host supplies data through `SearchAdapter`; Search owns the query lifecycle, pagination, result presentation, selection interactions, and recent searches.
## Capabilities {#capabilities}
- Connect any HTTP API, database, command registry, or local index through an adapter.
- Present results in domain groups and merge paginated data.
- Cancel stale requests with `AbortSignal`.
- Keep recent searches with configurable local persistence.
- Provide a `Mod+K` shortcut plus empty, loading, and error states.
- Ship `en-US` and `zh-Hans` message catalogs.
## Design boundary {#design-boundary}
Search does not choose the data source or perform navigation. The application implements the adapter and interprets each result's `payload` in `onSelect`.
@@ -0,0 +1,42 @@
---
title: Adapter 与分页
description: 实现可取消的搜索请求,并返回可合并的分组分页结果。
order: 20
toc:
- id: implement-adapter
title: 实现 Adapter
- id: group-results
title: 组织结果
- id: paginate
title: 分页
---
## 实现 Adapter {#implement-adapter}
`SearchAdapter` 是 Search 唯一的数据依赖:
```ts
const searchAdapter: SearchAdapter = {
async search({ cursor, query, signal }) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}&cursor=${cursor ?? ""}`,
{ signal }
)
if (!response.ok) throw new Error("Search request failed")
return response.json()
},
}
```
必须把 `signal` 传给底层请求,避免较早的响应覆盖较新的查询。
## 组织结果 {#group-results}
每个 `SearchPage` 包含若干 `SearchResultGroup`。Group 的 `id` 在分页之间应保持稳定;Item 的 `id` 在所属 Group 内应保持稳定。Search 会用这两个标识合并结果并消除重复项。
可以通过 `payload` 携带应用数据,通过 `icon` 或 `image` 自定义结果外观。
## 分页 {#paginate}
还有后续数据时返回 `nextCursor`;没有更多结果时返回 `null` 或省略。界面只在存在游标时显示“加载更多”。游标可以是字符串或数字,并会原样传回 Adapter。
@@ -0,0 +1,58 @@
---
title: 全局搜索示例
description: 组合触发器、对话框、历史记录和结果导航。
order: 30
toc:
- id: define-results
title: 定义结果
- id: handle-selection
title: 处理选择
- id: control-history
title: 控制历史记录
---
## 定义结果 {#define-results}
```ts
const searchAdapter: SearchAdapter = {
async search({ query }) {
return {
groups: [
{
id: "docs",
label: "文档",
items: documents
.filter((document) => document.title.includes(query))
.map((document) => ({
id: document.slug,
title: document.title,
description: document.description,
payload: { href: `/docs/${document.slug}` },
})),
},
],
}
},
}
```
## 处理选择 {#handle-selection}
```tsx
<SearchProvider
adapter={searchAdapter}
onSelect={({ item }) => {
const payload = item.payload as { href: string }
router.navigate({ to: payload.href })
}}
>
<HeaderSearchButton />
<SearchDialog />
</SearchProvider>
```
不方便渲染 `SearchTrigger` 的位置可以调用 `searchDialogHandle.open(null)`。
## 控制历史记录 {#control-history}
历史记录默认保存在 `workspace-search-history`。为不同产品传入独立的 `historyStorageKey`,或者设置为 `false`,让历史只存在于当前内存会话中。
@@ -0,0 +1,47 @@
---
title: 安装与接入
description: 添加 Search、注册翻译目录并挂载搜索界面。
order: 10
toc:
- id: add-the-package
title: 添加包
- id: import-styles
title: 导入样式
- id: mount-search
title: 挂载搜索
---
## 添加包 {#add-the-package}
将 Search 加入应用依赖:
```json
{
"dependencies": {
"@workspace/search": "workspace:*"
}
}
```
## 导入样式 {#import-styles}
在应用的全局样式入口导入一次 Search 样式,使 Tailwind 扫描包内使用的 utilities
```css
@import "@workspace/search/globals.css";
```
同时将 `@workspace/search/locales/{locale}` 加入应用的国际化目录来源。
## 挂载搜索 {#mount-search}
```tsx
import { SearchDialog, SearchProvider, SearchTrigger } from "@workspace/search"
;<SearchProvider adapter={searchAdapter} onSelect={openResult}>
<SearchTrigger>搜索</SearchTrigger>
<SearchDialog />
</SearchProvider>
```
`SearchDialog` 默认注册 `Mod+K`。如果应用已经统一管理快捷键,可以传入 `hotkey={false}`。
+29
View File
@@ -0,0 +1,29 @@
---
title: 概览
description: 了解 Search 包的数据边界、交互能力和适用场景。
order: 1
toc:
- id: package-role
title: 包的职责
- id: capabilities
title: 主要能力
- id: design-boundary
title: 设计边界
---
## 包的职责 {#package-role}
`@workspace/search` 提供应用级搜索界面与状态管理。宿主应用通过 `SearchAdapter` 提供数据,Search 负责查询生命周期、分页、结果展示、选择交互和搜索历史。
## 主要能力 {#capabilities}
- 使用 Adapter 连接任意 HTTP API、数据库、命令注册表或本地索引。
- 按业务来源分组展示结果,并合并分页数据。
- 使用 `AbortSignal` 取消已经过期的查询。
- 提供最近搜索记录和可配置的本地持久化。
- 内置 `Mod+K` 快捷键、无结果、加载与错误状态。
- 提供 `en-US` 和 `zh-Hans` 消息目录。
## 设计边界 {#design-boundary}
Search 不决定数据从哪里获取,也不负责路由跳转。应用实现 Adapter,并在 `onSelect` 中解释结果的 `payload`。
+31
View File
@@ -0,0 +1,31 @@
---
title: Add responsive behavior
description: Use shared breakpoint and interaction hooks when CSS alone cannot express the behavior.
order: 20
toc:
- id: breakpoints
title: Breakpoints
- id: server-rendering
title: Server rendering
- id: ripple
title: Ripple interaction
---
## Breakpoints {#breakpoints}
```tsx
import { useBreakpoint, useIsMobile } from "@workspace/ui/hooks/use-breakpoint"
const breakpoint = useBreakpoint()
const isMobile = useIsMobile()
```
Prefer CSS responsive variants for visual changes. Use these hooks only when component behavior or mounted content must change.
## Server rendering {#server-rendering}
The breakpoint store uses a deterministic desktop server snapshot. `getBreakpointInitializationScript` can assign the matching root class before hydration when the application needs CSS and behavioral breakpoints to agree immediately.
## Ripple interaction {#ripple}
`useRipple` returns a ref callback that adds pointer-driven feedback to an interactive element. The element should establish a positioned containing block so the generated overlay uses the correct bounds.

Some files were not shown because too many files have changed in this diff Show More