From 5ad2e331cc8f28db4ba3277769c7fd175732f380 Mon Sep 17 00:00:00 2001 From: oiwn Date: Mon, 13 Jul 2026 01:46:21 +0700 Subject: [PATCH 1/2] cargo.lock --- AGENTS.md | 39 +++ Cargo.lock | 270 ++----------------- Cargo.toml | 11 +- Justfile | 22 +- docs/i18n/ja/index.md | 9 + docs/i18n/zh-CN/index.md | 9 + docs/index.md | 22 +- locales/en.json | 99 +++++++ locales/ja.json | 99 +++++++ locales/zh-CN.json | 99 +++++++ specs/ctx.md | 372 ++++++--------------------- specs/web-frontend.md | 13 + src/app.rs | 69 +++-- src/components/footer.rs | 9 +- src/components/homepage_manual.rs | 140 +++++----- src/components/navbar.rs | 128 ++++++--- src/components/overview.rs | 77 +++--- src/i18n.rs | 5 + src/lib.rs | 2 + src/locale.rs | 115 +++++++++ src/server/handlers.rs | 2 + src/server/tests.rs | 4 + style/components/homepage-manual.css | 8 + 23 files changed, 907 insertions(+), 716 deletions(-) create mode 100644 docs/i18n/ja/index.md create mode 100644 docs/i18n/zh-CN/index.md create mode 100644 locales/en.json create mode 100644 locales/ja.json create mode 100644 locales/zh-CN.json create mode 100644 src/i18n.rs create mode 100644 src/locale.rs diff --git a/AGENTS.md b/AGENTS.md index 1ff8196..5713b68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,45 @@ This file defines agent workflow and points to project specifications. Implement 4. When adding a new subsystem or feature, add/update the relevant spec in `specs/`. 5. Keep AGENTS.md high-level; do not duplicate implementation details from specs. +## Updating NASA Data Files + +Use the Justfile workflow; do not reconstruct the TAP URLs or deployment steps +from memory. + +```bash +# 1. Download both NASA VOTables. +just download-data + +# 2. Convert both VOTables to Parquet and generate both metadata TOML files. +just convert-raw-files + +# 3. Confirm all generated runtime files exist and are non-empty. +just verify-data + +# 4. Upload Parquet and metadata TOML files through Ansible. +just ansible-upload-data + +# 5. Restart the deployed application so it loads the new files. +just ansible-deploy +``` + +If the VOTable files have already been downloaded, start with +`just convert-raw-files`. Ansible commands require +`infrastructure/ansible/.env` with `DROPLET_IP` configured. + +The expected source files are `data/stellarhosts.vot` and +`data/exoplanets.vot`. Conversion generates and overwrites: + +- `data/stellarhosts.parquet` +- `data/exoplanets.parquet` +- `data/stellarhosts-metadata.toml` +- `data/exoplanets-metadata.toml` + +`convert-raw-files` processes every `.vot` file in `data/`; remove temporary or +old VOTables before running it. Upload only the generated Parquet/TOML files, +then restart/deploy because the application loads them at startup. See +`specs/data-management.md` and `DEPLOY.md` for technical details. + ## Agent Rules 1. **Explicit Instruction Compliance:** Do not perform actions (file edits or command execution) without explicit user request. 2. **Confidence Threshold:** If below ~70% confidence about a request or outcome, stop and ask for clarification. diff --git a/Cargo.lock b/Cargo.lock index fd4222b..0a84161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -532,16 +532,6 @@ dependencies = [ "serde", ] -[[package]] -name = "calendrical_calculations" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" -dependencies = [ - "core_maths", - "displaydoc", -] - [[package]] name = "camino" version = "1.2.2" @@ -841,15 +831,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1294,7 +1275,7 @@ version = "0.1.0" [[package]] name = "exoplanets-catalog" -version = "0.3.9" +version = "0.3.10" dependencies = [ "axum", "console_error_panic_hook", @@ -1879,50 +1860,6 @@ dependencies = [ "cc", ] -[[package]] -name = "icu_calendar" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_calendar_data", - "icu_locale", - "icu_locale_core", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_calendar_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" - -[[package]] -name = "icu_casemap" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "070f98b5b82798fcb93654bf96ed9f40064fc44c86f51a09ea711092cd5cc5be" -dependencies = [ - "icu_casemap_data", - "icu_collections", - "icu_locale_core", - "icu_properties", - "icu_provider", - "potential_utf", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_casemap_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "846b0857ca091204be3c874bc93daaf89d4777e8d2d20b0d3ffe8f671d98014b" - [[package]] name = "icu_collections" version = "2.2.0" @@ -1931,125 +1868,12 @@ checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", - "serde", "utf8_iter", "yoke", "zerofrom", "zerovec", ] -[[package]] -name = "icu_datetime" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989d56ea5bbc43ae2b4e0388874b002884eaf4ed3a76c84a6c8c5ad575e04d72" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_calendar", - "icu_datetime_data", - "icu_decimal", - "icu_locale", - "icu_locale_core", - "icu_pattern", - "icu_plurals", - "icu_provider", - "icu_time", - "potential_utf", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_datetime_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d3cc1b690d9703202bc319692ac8a1f3a6390686f0930ff40542450fa34f0b" - -[[package]] -name = "icu_decimal" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_decimal_data", - "icu_locale", - "icu_locale_core", - "icu_pattern", - "icu_plurals", - "icu_provider", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_decimal_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" - -[[package]] -name = "icu_experimental" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a881116e620fd635f564fd9cb9bc36c256b9da2221df8b3f55643d8ef32140f" -dependencies = [ - "displaydoc", - "either", - "fixed_decimal", - "icu_casemap", - "icu_collections", - "icu_decimal", - "icu_experimental_data", - "icu_list", - "icu_locale", - "icu_locale_core", - "icu_normalizer", - "icu_pattern", - "icu_plurals", - "icu_properties", - "icu_provider", - "litemap", - "num-bigint", - "num-rational", - "num-traits", - "potential_utf", - "smallvec", - "tinystr", - "writeable", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_experimental_data" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f72090d4f08a2bc94565cb02de6d5b87939424e462d9927d73a34f6f8e5d1232" - -[[package]] -name = "icu_list" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeeaf517689324395bed4767f7c65504f5455942ed4c14ee54c2087ca00b816e" -dependencies = [ - "icu_list_data", - "icu_locale", - "icu_provider", - "regex-automata", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_list_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed62dbf114db9a4163481ed071509c4cd52cbcef9cb85979eba08a95549d73f3" - [[package]] name = "icu_locale" version = "2.2.0" @@ -2105,20 +1929,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" -[[package]] -name = "icu_pattern" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c4c568054ffe735398a9f4c55aec37ad7c768844553cc0978f09cc9b933a1fb" -dependencies = [ - "displaydoc", - "either", - "serde", - "writeable", - "yoke", - "zerovec", -] - [[package]] name = "icu_plurals" version = "2.2.0" @@ -2148,7 +1958,6 @@ dependencies = [ "icu_locale_core", "icu_properties_data", "icu_provider", - "serde", "zerotrie", "zerovec", ] @@ -2176,29 +1985,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_time" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3af0c141da0a61d4f6970cd1d5f4b388b17ea22f8124f8f6049d3d5147586a" -dependencies = [ - "calendrical_calculations", - "displaydoc", - "icu_calendar", - "icu_locale_core", - "icu_provider", - "icu_time_data", - "serde", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_time_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f2f8aeca682d874a5247084aa4fb7d1cef9ba45d889c21209a8818dcaaa0ec9" - [[package]] name = "id-arena" version = "2.3.0" @@ -2410,9 +2196,11 @@ dependencies = [ "codee", "cookie", "default-struct-builder", + "http", "js-sys", "lazy_static", "leptos", + "leptos_axum", "paste", "send_wrapper", "thiserror 2.0.18", @@ -2498,18 +2286,14 @@ checksum = "efc04f323545e008d9b6fe696d733557846ee17024af65b86b16c44a5c724d7b" dependencies = [ "codee", "default-struct-builder", - "icu_calendar", - "icu_datetime", - "icu_decimal", - "icu_experimental", - "icu_list", "icu_locale", - "icu_plurals", + "js-sys", "leptos", "leptos-use", "leptos_i18n_macro", "leptos_meta", "serde", + "serde-wasm-bindgen", "typed-builder", "wasm-bindgen", "writeable", @@ -2916,16 +2700,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -2943,26 +2717,6 @@ dependencies = [ "syn", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -4615,6 +4369,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6409,9 +6174,6 @@ name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -dependencies = [ - "either", -] [[package]] name = "wyz" diff --git a/Cargo.toml b/Cargo.toml index 55a5185..2cd0ebe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoplanets-catalog" -version = "0.3.9" +version = "0.3.10" edition = "2024" [workspace] @@ -49,7 +49,11 @@ utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true } sqlparser = { version = "0.60", optional = true } moka = { version = "0.12", features = ["future"] } rmcp = { version = "1.6.0", features = ["server", "macros", "transport-streamable-http-server", "schemars"], optional = true } -leptos_i18n = "0.6.2" +leptos_i18n = { version = "0.6.2", default-features = false } + +[package.metadata.leptos-i18n] +default = "en" +locales = ["en", "zh-CN", "ja"] # [build-dependencies] # markdown = "1.0.0-alpha" @@ -60,7 +64,7 @@ serde_json = { workspace = true } rand = "0.9" [features] -hydrate = ["leptos/hydrate", "dep:web-sys"] +hydrate = ["leptos/hydrate", "leptos_i18n/hydrate", "dep:web-sys"] ssr = [ "dep:axum", "dep:tokio", @@ -70,6 +74,7 @@ ssr = [ "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", + "leptos_i18n/axum", "dep:tracing", "dep:tracing-subscriber", # data processing diff --git a/Justfile b/Justfile index 5e79aff..c18fbd1 100644 --- a/Justfile +++ b/Justfile @@ -2,18 +2,22 @@ clippy: cargo clippy --all-targets --all-features -- -D warnings -# Data download commands -download-stellarhosts: - curl "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+*+from+stellarhosts" -L --max-time 3000 > data/stellarhosts.vot - -download-exoplanets: - curl "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+*+from+ps" -L --max-time 3000 > data/exoplanets.vot +# Download both NASA VOTable sources. +download-data: + mkdir -p data + curl --fail --location --remove-on-error --max-time 3000 --output data/stellarhosts.vot "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+*+from+stellarhosts&format=votable" + curl --fail --location --remove-on-error --max-time 3000 --output data/exoplanets.vot "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+*+from+pscomppars&format=votable" +# Convert VOTables to Parquet and generate matching metadata TOML files. convert-raw-files: cargo run -p exodata -- dev convert-raw-files --data-dir data -stellarhosts-metadata: - cargo run -p exodata -- dev view-metadata --path data/stellarhosts.vot +# Confirm all runtime files exist and are non-empty. +verify-data: + test -s data/stellarhosts.parquet + test -s data/exoplanets.parquet + test -s data/stellarhosts-metadata.toml + test -s data/exoplanets-metadata.toml # ============================================================================= # Ansible Deployment Commands @@ -44,7 +48,7 @@ ansible-deploy: ansible-ssl: cd {{ansible_dir}} && ansible-playbook {{ansible_args}} playbooks/ssl.yml -# Upload parquet data files +# Upload Parquet data and metadata TOML files ansible-upload-data: cd {{ansible_dir}} && ansible-playbook {{ansible_args}} playbooks/upload-data.yml diff --git a/docs/i18n/ja/index.md b/docs/i18n/ja/index.md new file mode 100644 index 0000000..a36c07e --- /dev/null +++ b/docs/i18n/ja/index.md @@ -0,0 +1,9 @@ +# [精密な系外惑星データを使って推論する](#mcp-exoplanet-data) + +Exodata は、ホスト型 MCP サーバーを通じて、NASA Exoplanet Archive の精密なデータへの構造化アクセスを AI エージェントに提供します。 + +エージェントは一般的なモデル知識に頼ったり大規模なテーブルを手作業で調べたりせず、カタログのスキーマを確認し、SQL クエリを組み立て、返されたレコードを基に推論できます。 + +利用可能なテーブルと列を実行時に確認するため、回答をカタログの結果に基づかせることができます。正確な値が重要な比較、絞り込み、ランキング、追加質問に適しています。 + +互換性のある MCP クライアントを接続すると、カタログの現在の構造化データに基づく質問ができます。 diff --git a/docs/i18n/zh-CN/index.md b/docs/i18n/zh-CN/index.md new file mode 100644 index 0000000..4ce6f59 --- /dev/null +++ b/docs/i18n/zh-CN/index.md @@ -0,0 +1,9 @@ +# [基于精确的系外行星数据进行推理](#mcp-exoplanet-data) + +Exodata 通过托管的 MCP 服务器,为 AI 智能体提供对 NASA 系外行星档案精确数据的结构化访问。 + +智能体可以检查目录模式、编写 SQL 查询并根据返回的记录进行推理,而不是依赖模型的一般知识或手动浏览大型数据表。 + +由于智能体会在运行时发现可用的数据表和字段,其回答能够以目录查询结果为依据。这适合需要精确数值的比较、筛选、排名和后续问题。 + +连接兼容的 MCP 客户端,即可提出由目录当前结构化数据支持的问题。 diff --git a/docs/index.md b/docs/index.md index 550829b..b97cfe1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,15 @@ -# Use the catalog, not just the table +# [Reason over precise exoplanet data](#mcp-exoplanet-data) -Exodata represents NASA Exoplanet Archive tables as a practical set of access -paths for people, scripts, and AI-enabled tools. +Exodata gives AI agents structured access to precise NASA Exoplanet Archive +data through a hosted MCP server. -Large scientific tables are powerful, but they are hard to scan by hand. This -site keeps the table view available while adding shareable routes, focused -JSON/CSV exports, REST and SQL endpoints, a local CLI, and an MCP server for -natural-language-assisted catalog queries. +Agents can inspect the catalog schema, compose SQL queries, and reason over the +returned records instead of relying on general model knowledge or manually +scanning large tables. -Start with the website when you want to browse. Use exports when you need a -single object record. Use the API, SQL endpoint, CLI, or MCP server when you -want the dataset inside code, notebooks, local tools, or an agent workflow. +Because the agent discovers the available tables and columns at runtime, its +answers can stay grounded in catalog results. This is useful for comparisons, +filtered searches, rankings, and follow-up questions where exact values matter. + +Connect a compatible MCP client to ask questions backed by the catalog's +current structured data. diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..38c769a --- /dev/null +++ b/locales/en.json @@ -0,0 +1,99 @@ +{ + "nav": { + "overview": "Overview", + "stellar_hosts": "Stellar Hosts", + "exoplanets": "Exoplanets", + "insights": "Insights", + "docs": "Docs", + "api": "API", + "github_repository": "GitHub repository", + "open_menu": "Open navigation menu", + "close_menu": "Close navigation menu", + "mobile_navigation": "Mobile navigation", + "language": "Language" + }, + "home": { + "title": "Exoplanets Catalog", + "description": "Explore confirmed exoplanets and their host stars with searchable tables, discovery stats, and server-rendered detail pages.", + "hero_title": "🌌 Exoplanet Archive", + "hero_subtitle": "Exploring the cosmos: A comprehensive catalog of confirmed exoplanets and their host stars", + "loading": "Loading catalog data...", + "connection_error": "Connection Error", + "error_loading": "Error loading data", + "stellar_systems": "Stellar Systems", + "host_stars_catalogued": "Host stars catalogued", + "exoplanets": "Exoplanets", + "distinct_planets": "Distinct planets in the catalog", + "average_temperature": "Avg Stellar Temp", + "mean_temperature": "Mean effective temperature", + "average_distance": "Avg Distance", + "mean_distance": "Mean system distance", + "live": "LIVE", + "planet_classifications": "Planet Classifications", + "planet_classifications_subtitle": "Distinct planets grouped by canonical radius", + "orbital_periods": "Orbital Periods", + "orbital_periods_subtitle": "Distinct planets grouped by canonical period", + "discovery_methods": "Discovery Methods", + "discovery_methods_subtitle": "Distinct planets grouped by canonical method", + "discovery_years": "Discovery Years", + "discovery_years_subtitle": "Distinct planets grouped by earliest discovery year", + "temperature_bands": "Planet Temperature Bands", + "temperature_bands_subtitle": "Distinct planets grouped by canonical equilibrium temperature", + "detection_sources": "Detection Sources", + "detection_sources_subtitle": "Distinct planets grouped by discovery facility" + }, + "manual": { + "examples_title": "Open a real catalog record", + "examples_subtitle": "Stable routes make pages, exports, API docs, and tool setup easy to share.", + "stellar_profile": "Stellar host profile", + "stellar_profile_description": "Open the TRAPPIST-1 host-system detail page.", + "planet_profile": "Planet profile", + "planet_profile_description": "Open the Kepler-22 b exoplanet detail page.", + "host_json": "Host JSON", + "host_json_description": "Download the full host detail payload.", + "host_csv": "Host CSV", + "host_csv_description": "Download matching host source rows.", + "planet_json": "Planet JSON", + "planet_json_description": "Download the full planet detail payload.", + "planet_csv": "Planet CSV", + "planet_csv_description": "Download matching planet source rows.", + "rest_api": "REST API", + "rest_api_description": "Query tables, schemas, details, insights, and SQL endpoints.", + "mcp_server": "MCP server", + "mcp_server_description": "Connect an AI client to inspect schema and query the catalog.", + "cli": "CLI", + "cli_description": "Query live or local data from the terminal.", + "swagger": "Swagger UI", + "swagger_description": "Explore the OpenAPI schema interactively.", + "connect_title": "Connect an MCP client", + "hosted_endpoint": "The hosted endpoint is", + "read": "Read the", + "mcp_introduction": "MCP introduction", + "or": "or the", + "mcp_docs": "Exodata MCP docs", + "copy": "Copy", + "copied": "Copied", + "ask": "Ask", + "tool": "Tool", + "sql": "SQL", + "result": "Result", + "ask_example": "nearest known planet-hosting systems with distance and planet count", + "result_example": "structured rows ready for a table, notebook, or follow-up query", + "cli_docs": "CLI docs", + "short_mcp_docs": "MCP docs", + "codex_config": "Codex config fallback", + "opencode_config": "OpenCode config", + "inspector_note": "Open the Inspector UI, then connect to the hosted endpoint with Streamable HTTP transport.", + "interaction_label": "Example catalog tool interaction" + }, + "language": { + "english": "English", + "chinese": "Simplified Chinese", + "japanese": "Japanese" + }, + "footer": { + "version": "Version", + "updated": "Updated", + "developed_by": "Developed by imscraping.ninja" + } +} diff --git a/locales/ja.json b/locales/ja.json new file mode 100644 index 0000000..6a9df47 --- /dev/null +++ b/locales/ja.json @@ -0,0 +1,99 @@ +{ + "nav": { + "overview": "概要", + "stellar_hosts": "恒星ホスト", + "exoplanets": "系外惑星", + "insights": "データ分析", + "docs": "ドキュメント", + "api": "API", + "github_repository": "GitHub リポジトリ", + "open_menu": "ナビゲーションメニューを開く", + "close_menu": "ナビゲーションメニューを閉じる", + "mobile_navigation": "モバイルナビゲーション", + "language": "言語" + }, + "home": { + "title": "系外惑星カタログ", + "description": "検索可能なテーブル、発見統計、サーバー描画の詳細ページから、確認済み系外惑星とその主星を探索できます。", + "hero_title": "🌌 系外惑星アーカイブ", + "hero_subtitle": "宇宙を探索する:確認済み系外惑星とその主星の精密なカタログ", + "loading": "カタログデータを読み込んでいます…", + "connection_error": "接続エラー", + "error_loading": "データの読み込みエラー", + "stellar_systems": "恒星系", + "host_stars_catalogued": "収録済みの主星", + "exoplanets": "系外惑星", + "distinct_planets": "カタログ内の個別の惑星", + "average_temperature": "恒星の平均温度", + "mean_temperature": "平均有効温度", + "average_distance": "平均距離", + "mean_distance": "恒星系の平均距離", + "live": "ライブ", + "planet_classifications": "惑星の分類", + "planet_classifications_subtitle": "標準半径で分類した個別の惑星", + "orbital_periods": "公転周期", + "orbital_periods_subtitle": "標準周期で分類した個別の惑星", + "discovery_methods": "発見方法", + "discovery_methods_subtitle": "標準的な発見方法で分類した個別の惑星", + "discovery_years": "発見年", + "discovery_years_subtitle": "最初の発見年で分類した個別の惑星", + "temperature_bands": "惑星の温度帯", + "temperature_bands_subtitle": "標準平衡温度で分類した個別の惑星", + "detection_sources": "検出元", + "detection_sources_subtitle": "発見施設で分類した個別の惑星" + }, + "manual": { + "examples_title": "実際のカタログレコードを開く", + "examples_subtitle": "安定したルートにより、ページ、エクスポート、API 文書、ツール設定を簡単に共有できます。", + "stellar_profile": "恒星ホストのプロフィール", + "stellar_profile_description": "TRAPPIST-1 ホスト系の詳細ページを開きます。", + "planet_profile": "惑星のプロフィール", + "planet_profile_description": "Kepler-22 b 系外惑星の詳細ページを開きます。", + "host_json": "ホスト JSON", + "host_json_description": "ホスト詳細の完全なデータをダウンロードします。", + "host_csv": "ホスト CSV", + "host_csv_description": "一致するホストのソース行をダウンロードします。", + "planet_json": "惑星 JSON", + "planet_json_description": "惑星詳細の完全なデータをダウンロードします。", + "planet_csv": "惑星 CSV", + "planet_csv_description": "一致する惑星のソース行をダウンロードします。", + "rest_api": "REST API", + "rest_api_description": "テーブル、スキーマ、詳細、分析、SQL エンドポイントを照会します。", + "mcp_server": "MCP サーバー", + "mcp_server_description": "AI クライアントを接続してスキーマを確認し、カタログを照会します。", + "cli": "CLI", + "cli_description": "ターミナルからオンラインまたはローカルのデータを照会します。", + "swagger": "Swagger UI", + "swagger_description": "OpenAPI スキーマを対話的に確認します。", + "connect_title": "MCP クライアントを接続", + "hosted_endpoint": "ホスト済みエンドポイント:", + "read": "参照:", + "mcp_introduction": "MCP 入門", + "or": "または", + "mcp_docs": "Exodata MCP ドキュメント", + "copy": "コピー", + "copied": "コピー済み", + "ask": "質問", + "tool": "ツール", + "sql": "SQL", + "result": "結果", + "ask_example": "既知の惑星ホスト系を距離が近い順に、距離と惑星数を含めて表示", + "result_example": "表、ノートブック、追加クエリに使える構造化された行", + "cli_docs": "CLI ドキュメント", + "short_mcp_docs": "MCP ドキュメント", + "codex_config": "Codex 代替設定", + "opencode_config": "OpenCode 設定", + "inspector_note": "Inspector UI を開き、Streamable HTTP トランスポートでホスト済みエンドポイントに接続します。", + "interaction_label": "カタログツール操作の例" + }, + "language": { + "english": "英語", + "chinese": "簡体字中国語", + "japanese": "日本語" + }, + "footer": { + "version": "バージョン", + "updated": "更新日時", + "developed_by": "imscraping.ninja が開発" + } +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json new file mode 100644 index 0000000..af82583 --- /dev/null +++ b/locales/zh-CN.json @@ -0,0 +1,99 @@ +{ + "nav": { + "overview": "概览", + "stellar_hosts": "恒星宿主", + "exoplanets": "系外行星", + "insights": "数据洞察", + "docs": "文档", + "api": "API", + "github_repository": "GitHub 代码仓库", + "open_menu": "打开导航菜单", + "close_menu": "关闭导航菜单", + "mobile_navigation": "移动端导航", + "language": "语言" + }, + "home": { + "title": "系外行星目录", + "description": "通过可搜索的数据表、发现统计和服务端渲染的详情页,探索已确认的系外行星及其宿主恒星。", + "hero_title": "🌌 系外行星档案", + "hero_subtitle": "探索宇宙:已确认系外行星及其宿主恒星的精确目录", + "loading": "正在加载目录数据……", + "connection_error": "连接错误", + "error_loading": "加载数据时出错", + "stellar_systems": "恒星系统", + "host_stars_catalogued": "已收录的宿主恒星", + "exoplanets": "系外行星", + "distinct_planets": "目录中的不同系外行星", + "average_temperature": "恒星平均温度", + "mean_temperature": "平均有效温度", + "average_distance": "平均距离", + "mean_distance": "恒星系统平均距离", + "live": "实时", + "planet_classifications": "行星分类", + "planet_classifications_subtitle": "按标准半径分组的不同系外行星", + "orbital_periods": "公转周期", + "orbital_periods_subtitle": "按标准周期分组的不同系外行星", + "discovery_methods": "发现方法", + "discovery_methods_subtitle": "按标准发现方法分组的不同系外行星", + "discovery_years": "发现年份", + "discovery_years_subtitle": "按最早发现年份分组的不同系外行星", + "temperature_bands": "行星温度区间", + "temperature_bands_subtitle": "按标准平衡温度分组的不同系外行星", + "detection_sources": "探测来源", + "detection_sources_subtitle": "按发现设施分组的不同系外行星" + }, + "manual": { + "examples_title": "打开真实的目录记录", + "examples_subtitle": "稳定的路由让页面、导出文件、API 文档和工具配置易于分享。", + "stellar_profile": "恒星宿主资料", + "stellar_profile_description": "打开 TRAPPIST-1 宿主系统详情页。", + "planet_profile": "行星资料", + "planet_profile_description": "打开 Kepler-22 b 系外行星详情页。", + "host_json": "宿主 JSON", + "host_json_description": "下载完整的宿主详情数据。", + "host_csv": "宿主 CSV", + "host_csv_description": "下载匹配的宿主源数据行。", + "planet_json": "行星 JSON", + "planet_json_description": "下载完整的行星详情数据。", + "planet_csv": "行星 CSV", + "planet_csv_description": "下载匹配的行星源数据行。", + "rest_api": "REST API", + "rest_api_description": "查询数据表、模式、详情、洞察和 SQL 端点。", + "mcp_server": "MCP 服务器", + "mcp_server_description": "连接 AI 客户端以检查模式并查询目录。", + "cli": "CLI", + "cli_description": "从终端查询在线或本地数据。", + "swagger": "Swagger UI", + "swagger_description": "以交互方式浏览 OpenAPI 模式。", + "connect_title": "连接 MCP 客户端", + "hosted_endpoint": "托管端点为", + "read": "阅读", + "mcp_introduction": "MCP 入门", + "or": "或", + "mcp_docs": "Exodata MCP 文档", + "copy": "复制", + "copied": "已复制", + "ask": "提问", + "tool": "工具", + "sql": "SQL", + "result": "结果", + "ask_example": "距离最近的已知行星宿主系统,以及各自的距离和行星数量", + "result_example": "可用于表格、笔记本或后续查询的结构化数据行", + "cli_docs": "CLI 文档", + "short_mcp_docs": "MCP 文档", + "codex_config": "Codex 备用配置", + "opencode_config": "OpenCode 配置", + "inspector_note": "打开 Inspector 界面,然后使用 Streamable HTTP 传输连接到托管端点。", + "interaction_label": "目录工具交互示例" + }, + "language": { + "english": "英语", + "chinese": "简体中文", + "japanese": "日语" + }, + "footer": { + "version": "版本", + "updated": "更新时间", + "developed_by": "由 imscraping.ninja 开发" + } +} diff --git a/specs/ctx.md b/specs/ctx.md index 2ff3542..431c8c7 100644 --- a/specs/ctx.md +++ b/specs/ctx.md @@ -1,302 +1,74 @@ # Current Task Context -## Website Content and Multilingual Layout Plan - -This is planning context for a future implementation session. Do not implement -the website changes from this file until explicitly requested. - -Primary direction: - -1. First improve the English website content and information architecture. -2. Then add Chinese and Japanese interface localization on top of the improved - English text. - -The multilingual work should not start by translating weak or temporary English -copy. English content becomes the source text, then localized interface strings -follow. - -## English Content First - -### Homepage Manual - -Implemented on 2026-06-28. - -- Added a distinct rounded homepage manual section in - `src/components/homepage_manual.rs`, rendered after the detailed overview - statistics and reachable from the hero subtitle link. -- Source prose now lives in `docs/index.md` and is rendered with the existing - docs Markdown renderer. -- The section includes stable catalog example links, hosted MCP setup snippets - for Codex, Claude Code, OpenCode, and MCP Inspector, copy buttons, and a - compact CLI/MCP interaction card. -- `docs/mcp.md` now documents only the hosted MCP URL in the connection summary. - -### Docs and Public Copy Cleanup - -Before translation, tighten the English source copy: - -- Make homepage/manual text concise and stable. -- Make docs index copy suitable as the English source for translation. -- Keep API/MCP/CLI examples exact and technical. -- Separate prose that should be translated from command examples, URLs, JSON - keys, SQL, and dataset field names that should remain unchanged. - -### Blog and Changelog Ideas - -Do not add a blog route in the first content pass. Keep these as future content -ideas: - -- story posts about why tables are not enough for exoplanet catalogs -- agent/MCP usage stories -- small dataset stories based on insights pages -- human-readable changelog summaries for major project milestones -- technical notes about VOTable to Parquet to web/API/CLI flow - -`CHANGELOG.md` should remain technical. A future public notes page can summarize -selected changes in a more narrative format. - -### AI Test-Generation Research Idea - -Keep this as a research spike, not production automation: - -- Explore using `rig.rs` with a DeepSeek Flash model to generate small candidate - tests for distinct stellar-system and planet-system features. -- Generated tests must be reviewed manually before committing. -- Do not wire model calls into CI in the first experiment. -- Do not infer behavior from exact fixture numbers when those numbers are only a - sampled snapshot of the live dataset. - -## Multilingual Interface Plan - -Goal: add Chinese and Japanese interface support after the English content pass, -without turning the first iteration into a full content-management platform. - -### Target Languages - -- Default: English (`en`) -- Add: Simplified Chinese (`zh-CN`) -- Add: Japanese (`ja`) - -UI labels: - -| Locale | Switcher label | Native label | -|---|---|---| -| `en` | EN | English | -| `zh-CN` | 中文 | 简体中文 | -| `ja` | 日本語 | 日本語 | - -Traditional Chinese is out of scope unless explicitly added later. - -### URL Layout - -Use prefixed locale routes for localized website pages: - -- `/` remains the canonical English homepage. -- `/zh-CN` renders the Chinese homepage. -- `/ja` renders the Japanese homepage. -- English routes remain valid without prefixes: - - `/stellarhosts` - - `/exoplanets` - - `/insights` - - `/docs` - - detail routes -- Localized interface routes use the locale prefix: - - `/zh-CN/stellarhosts` - - `/ja/stellarhosts` - - `/zh-CN/exoplanets` - - `/ja/exoplanets` - - `/zh-CN/insights` - - `/ja/insights` - -Do not move non-website utility surfaces under locale prefixes: - -- keep `/rest/...` -- keep `/mcp` -- keep `/swagger-ui` -- keep `/sitemap-index.xml` and child sitemap routes -- keep current JSON/CSV export URL behavior - -Rationale: prefixed routes are clear for users and SEO, while unprefixed English -routes preserve existing URLs. - -### Locale Detection and Rendering - -- Derive the active locale from the first path segment. -- If the first path segment is `zh-CN` or `ja`, render interface strings in that - locale. -- Otherwise render English. -- Data rows, entity names, API paths, SQL, JSON keys, CSV headers, and NASA field - keys remain unchanged. -- Detail route identifiers remain original catalog names and are not translated. - -The routing layer should avoid duplicating page implementations. Prefer a shared -locale context/provider that all components can read. - -### Language Picker Component - -Plan a dedicated language picker component rather than scattering language links -through the navbar. - -Behavior: - -- Render compact options: `EN`, `中文`, `日本語`. -- Show the active locale visually. -- Map the current page to the same page in the selected locale where possible: - - `/exoplanets` -> `/ja/exoplanets` - - `/ja/exoplanets` -> `/zh-CN/exoplanets` - - `/stellarhosts/TRAPPIST-1` -> `/zh-CN/stellarhosts/TRAPPIST-1` -- Preserve query strings for table pages when switching languages. -- Do not prefix external links or utility routes. - -Placement: - -- Desktop: navbar, compact control near the right side. -- Mobile: inside the mobile menu, preferably as a segmented row near the bottom. - -Accessibility: - -- Use a clear accessible label such as `Language`. -- Each option should expose the native language name, not only the compact label. -- Keyboard focus and current-state styling should be visible. - -### Translation Technology - -Use a real i18n crate instead of hand-written string tables if it works cleanly -with the current Leptos version, SSR, and hydration. - -Preferred candidate for the future implementation spike: - -- `leptos_i18n` - -Why: - -- It is designed for Leptos applications. -- It supports compile-time translation resources and component-level usage. -- It is a better long-term fit than ad hoc match statements once interface copy - spans navbar, tables, details, insights, docs chrome, metadata, and aria labels. - -Spike requirements before committing to it: - -- Confirm compatibility with the repository's Leptos version. -- Confirm SSR and hydrate builds both work. -- Confirm translations can be selected from URL-derived locale state. -- Confirm metadata/head strings can use localized values. -- Confirm the generated code does not make simple UI text awkward to maintain. - -Fallback if `leptos_i18n` does not fit: - -- Use a small internal translation module with semantic keys for v1. -- Keep the API shaped so it can be replaced by an i18n crate later. - -Do not translate directly by using English strings as lookup keys. Use semantic -keys or generated typed accessors so English copy can be edited without breaking -translations. - -### Translation Surface - -Translate interface chrome and project-authored prose first: - -- navbar labels, mobile menu labels, aria labels -- language picker labels -- homepage hero/manual text and stat labels -- loading, error, empty, and not-found states -- table page headings and pagination text -- column selector controls -- insight page headings, cards, empty states, and explanatory text -- detail page section headings, provenance/download controls, and summary labels -- docs registry titles/descriptions and short localized docs summaries - -Do not translate source data values in v1: - -- planet names, stellar host names, system names -- discovery methods, facilities, references, aliases, and record values -- raw column keys such as `pl_name`, `hostname`, `st_teff`, and `sy_dist` -- SQL examples, API paths, CLI commands, JSON keys, CSV headers - -Scientific units stay unchanged in v1: `K`, `pc`, `R⊕`, `M⊕`, `R☉`. - -### Docs Content - -Use separate Markdown files only for prose-heavy translated docs. - -Suggested future layout: - -```text -docs/ -├── about.md -├── api.md -├── cli.md -├── mcp.md -└── i18n/ - ├── zh-CN/ - │ ├── about.md - │ ├── api.md - │ ├── cli.md - │ └── mcp.md - └── ja/ - ├── about.md - ├── api.md - ├── cli.md - └── mcp.md -``` - -For the first localized docs pass, short localized summaries are enough. They -can link to full English technical docs until full translations are ready. - -### SEO and Metadata - -- Set `` from the active locale. -- Canonical URLs should point to the active localized page. -- Add `hreflang` alternates for English, Chinese, and Japanese equivalents. -- Include localized static routes in the sitemap after route behavior is stable. -- Do not add localized copies of every detail route to the sitemap in v1. -- Keep existing English detail routes as the primary entity URLs. - -### Layout Requirements - -- Use compact labels in the language picker to avoid crowding the navbar. -- Avoid fixed-width text containers for translated copy. -- Check Chinese and Japanese line wrapping in: - - homepage cards/manual section - - table controls and pagination - - column selector - - insight cards - - detail-page download buttons -- Preserve readable density. The localized interface should not become a separate - visual design. - -### Suggested Rollout - -1. Polish English homepage/manual/docs source copy. -2. Add locale route parsing, locale context, language picker, and localized href - generation while still rendering English strings. -3. Integrate the chosen i18n approach and translate global navigation plus common - states. -4. Translate homepage manual, table controls, insight chrome, and detail chrome. -5. Add localized docs summaries. -6. Add localized metadata, `hreflang`, and sitemap entries. - -### Verification - -Unit tests: - -- locale parsing from path -- localized href generation with query-string preservation -- utility routes remain unprefixed -- canonical and alternate URL generation -- docs link rewriting with locale prefixes - -SSR/manual checks: - -- `/`, `/zh-CN`, `/ja` -- `/zh-CN/stellarhosts`, `/ja/exoplanets` -- localized detail route switching -- table query state survives language switching -- `/rest`, `/mcp`, `/swagger-ui`, and export URLs remain unprefixed - -Browser checks: - -- desktop navbar does not overflow -- mobile language picker is usable -- CN/JP text does not overlap in cards, table controls, pagination, and detail - action buttons +## Localization Status + +The English homepage content pass and the first localization pass are complete. +The implementation uses `leptos_i18n` with semantic keys and compile-time JSON +resources for: + +- English (`en`) — default, unprefixed routes +- Simplified Chinese (`zh-CN`) — `/zh-CN/...` +- Japanese (`ja`) — `/ja/...` + +Traditional Chinese is out of scope unless explicitly requested. + +## Completed + +- Added locale-prefixed aliases for website routes without duplicating page + implementations. +- Added the desktop navbar and mobile-menu language switcher with current-locale + styling, accessible labels, and same-page switching. +- Preserved the current path, query string, and fragment when switching locale. +- Kept `/rest`, `/mcp`, `/swagger-ui`, sitemap routes, and JSON/CSV exports + unprefixed. +- Translated global navigation, mobile controls, footer text, homepage metadata, + hero, loading/error states, statistics headings, and the complete homepage + manual. +- Added Chinese and Japanese homepage Markdown under `docs/i18n/`. +- Set `` from the URL locale and added homepage canonical and + `hreflang` metadata. +- Added `/zh-CN` and `/ja` to the static sitemap; localized table/detail routes + are intentionally not advertised yet. +- Added tests for locale parsing, localized URL generation, URL-state + preservation, utility/export exclusions, and localized sitemap entries. +- Verified formatting, Clippy, workspace tests, and the combined SSR/WASM + `cargo leptos build`. + +## Stable Rules + +- The URL is the only locale source. Do not add cookie, browser-language, or + `Accept-Language` redirects. +- Unprefixed routes always render English; do not introduce `/en/...` routes. +- Never translate catalog values, entity names, route identifiers, NASA field + keys, SQL, commands, API paths, JSON/CSV keys, or scientific units. +- Keep stable homepage fragments identical across locales: + `#mcp-exoplanet-data`, `#catalog-examples-title`, and `#mcp-setup-title`. +- Use locale resources for short interface strings and separate Markdown files + for prose-heavy translated content. + +## Deferred Translation Work + +The following page-specific content still renders in English, even under locale +prefixes: + +- table headings, filters, column selector, pagination, and table states +- stellar-host and exoplanet detail-page chrome +- insight overview/detail chrome and explanatory text +- not-found and shared application error/empty states +- technical docs content and docs registry metadata +- non-homepage localized metadata, canonical URLs, and `hreflang` + +Do not add these partially translated routes to the sitemap. Add localized +sitemap entries only as each page surface is translated and receives correct +localized metadata. + +## Recommended Next Pass + +1. Translate shared table controls and states while leaving dataset values and + column keys unchanged. +2. Ensure table navigation, pagination, filters, and entity links retain the + active locale prefix and query state. +3. Translate detail and insight chrome. +4. Add localized docs summaries, then full translated docs only when reviewed. +5. Expand canonical, `hreflang`, and sitemap coverage alongside each completed + surface. diff --git a/specs/web-frontend.md b/specs/web-frontend.md index fedec30..0691f98 100644 --- a/specs/web-frontend.md +++ b/specs/web-frontend.md @@ -11,6 +11,19 @@ The frontend is a reactive single-page application (SPA) built with: - **Hydration** - Client-side interactivity after SSR - **WASM** - Client-side code compiled to WebAssembly +## Localization + +The website uses `leptos_i18n` with compile-time JSON resources for English, +Simplified Chinese, and Japanese. English is the default and remains +unprefixed; `/zh-CN` and `/ja` select the other locales. Locale-prefixed aliases +exist for website routes, while REST, MCP, Swagger, sitemap, and export URLs +remain unprefixed. + +The active locale is derived only from the URL. The language switcher preserves +the current path, query string, and fragment. In the first localization pass, +global navigation, the footer, and the homepage are translated; table, detail, +insight, and technical documentation content remains English. + ## Architecture ``` diff --git a/src/app.rs b/src/app.rs index 960a74d..5fd7698 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,7 @@ use crate::components::{ use crate::error_template::{AppError, ErrorTemplate}; use crate::metadata::{METADATA_SCRIPT_ID, provide_app_metadata_store}; use crate::metadata_helpers::SITE_NAME; +use crate::{i18n::*, locale::locale_from_path}; use leptos::error::Errors; use leptos::hydration::HydrationScripts; @@ -66,19 +67,21 @@ pub fn App() -> impl IntoView { - // content for this welcome page - <Router> - // Navigation bar (must be inside Router to use location) - <Navbar/> + <I18nContextProvider enable_cookie=false set_lang_attr_on_html=true> + // content for this welcome page + <Router> + <LocaleSync/> + // Navigation bar (must be inside Router to use location) + <Navbar/> - <Routes fallback=|| { - let mut outside_errors = Errors::default(); - outside_errors.insert_with_default_key(AppError::NotFound); - view! { - <ErrorTemplate outside_errors/> - } - .into_view() - }> + <Routes fallback=|| { + let mut outside_errors = Errors::default(); + outside_errors.insert_with_default_key(AppError::NotFound); + view! { + <ErrorTemplate outside_errors/> + } + .into_view() + }> <Route path=StaticSegment("") view={Lazy::<OverviewLazy>::new()} ssr=SsrMode::Async/> <Route path=StaticSegment("stellarhosts") @@ -97,9 +100,45 @@ pub fn App() -> impl IntoView { <Route path=StaticSegment("docs") view={Lazy::<DocsLazy>::new()} ssr=SsrMode::Async/> <Route path=path!("/docs/:slug") view={Lazy::<DocsLazy>::new()} ssr=SsrMode::Async/> <Route path=StaticSegment("about") view={Lazy::<AboutLazy>::new()} ssr=SsrMode::Async/> - </Routes> + <Route path=StaticSegment("zh-CN") view={Lazy::<OverviewLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/stellarhosts") view={Lazy::<StellarHostsTableLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/stellarhosts/:hostname") view={Lazy::<StellarHostDetailLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/exoplanets") view={Lazy::<ExoplanetsTableLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/exoplanets/:pl_name") view={Lazy::<ExoplanetDetailLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/insights") view={Lazy::<InsightsLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/insights/:slug") view={Lazy::<InsightDetailLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/docs") view={Lazy::<DocsLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/docs/:slug") view={Lazy::<DocsLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/zh-CN/about") view={Lazy::<AboutLazy>::new()} ssr=SsrMode::Async/> + <Route path=StaticSegment("ja") view={Lazy::<OverviewLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/stellarhosts") view={Lazy::<StellarHostsTableLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/stellarhosts/:hostname") view={Lazy::<StellarHostDetailLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/exoplanets") view={Lazy::<ExoplanetsTableLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/exoplanets/:pl_name") view={Lazy::<ExoplanetDetailLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/insights") view={Lazy::<InsightsLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/insights/:slug") view={Lazy::<InsightDetailLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/docs") view={Lazy::<DocsLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/docs/:slug") view={Lazy::<DocsLazy>::new()} ssr=SsrMode::Async/> + <Route path=path!("/ja/about") view={Lazy::<AboutLazy>::new()} ssr=SsrMode::Async/> + </Routes> - <VersionFooter/> - </Router> + <VersionFooter/> + </Router> + </I18nContextProvider> } } + +#[component] +fn LocaleSync() -> impl IntoView { + use leptos_router::hooks::use_location; + + let location = use_location(); + let i18n = use_i18n(); + i18n.set_locale_untracked(locale_from_path( + &location.pathname.get_untracked(), + )); + + Effect::new(move |_| { + i18n.set_locale(locale_from_path(&location.pathname.get())); + }); +} diff --git a/src/components/footer.rs b/src/components/footer.rs index eafda15..5222b7a 100644 --- a/src/components/footer.rs +++ b/src/components/footer.rs @@ -1,5 +1,7 @@ use leptos::prelude::*; +use crate::i18n::*; + const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TIMESTAMP: &str = match option_env!("BUILD_TIMESTAMP") { Some(value) => value, @@ -8,15 +10,16 @@ const BUILD_TIMESTAMP: &str = match option_env!("BUILD_TIMESTAMP") { #[component] pub fn VersionFooter() -> impl IntoView { + let i18n = use_i18n(); view! { <footer class="w-full border-t border-slate-800 bg-slate-950"> <div class="mx-auto flex max-w-7xl flex-col items-center justify-center gap-2 px-4 py-3 sm:flex-row sm:gap-4"> <span class="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-slate-900 px-3 py-1 text-sm font-medium text-slate-100"> - <span class="text-slate-400">"Version"</span> + <span class="text-slate-400">{t!(i18n, footer.version)}</span> <span class="font-semibold text-cyan-300">{APP_VERSION}</span> </span> <span class="inline-flex items-center gap-2 rounded-full border border-slate-700 bg-slate-900 px-3 py-1 text-sm font-medium text-slate-100"> - <span class="text-slate-400">"Updated"</span> + <span class="text-slate-400">{t!(i18n, footer.updated)}</span> <span class="font-semibold text-slate-200">{BUILD_TIMESTAMP}</span> </span> <a @@ -25,7 +28,7 @@ pub fn VersionFooter() -> impl IntoView { rel="noopener noreferrer" class="text-sm font-medium text-slate-200 hover:text-cyan-300" > - "Developed by imscraping.ninja" + {t!(i18n, footer.developed_by)} </a> </div> </footer> diff --git a/src/components/homepage_manual.rs b/src/components/homepage_manual.rs index 1ec06d5..41ffc79 100644 --- a/src/components/homepage_manual.rs +++ b/src/components/homepage_manual.rs @@ -1,20 +1,29 @@ use leptos::prelude::*; use crate::components::docs::render::render_markdown; +use crate::i18n::*; +use crate::locale::localized_path; -const MANUAL_MARKDOWN: &str = include_str!("../../docs/index.md"); -const HOST_NAME: &str = "TRAPPIST-1"; +const MANUAL_MARKDOWN_EN: &str = include_str!("../../docs/index.md"); +const MANUAL_MARKDOWN_ZH_CN: &str = + include_str!("../../docs/i18n/zh-CN/index.md"); +const MANUAL_MARKDOWN_JA: &str = include_str!("../../docs/i18n/ja/index.md"); const HOST_PATH: &str = "/stellarhosts/TRAPPIST-1"; -const PLANET_NAME: &str = "Kepler-22 b"; const PLANET_PATH: &str = "/exoplanets/Kepler-22%20b"; const MCP_ENDPOINT: &str = "https://exodata.space/mcp"; #[component] pub fn HomepageManual() -> impl IntoView { - let html = render_markdown(MANUAL_MARKDOWN); + let locale = use_i18n().get_locale_untracked(); + let markdown = match locale { + Locale::en => MANUAL_MARKDOWN_EN, + Locale::zh_CN => MANUAL_MARKDOWN_ZH_CN, + Locale::ja => MANUAL_MARKDOWN_JA, + }; + let html = render_markdown(markdown); view! { - <section id="homepage-manual" class="homepage-manual" aria-labelledby="homepage-manual-title"> + <section id="mcp-exoplanet-data" class="homepage-manual" aria-labelledby="homepage-manual-title"> <div class="homepage-manual__container"> <div class="homepage-manual__intro"> <article @@ -34,63 +43,67 @@ pub fn HomepageManual() -> impl IntoView { #[component] fn ExampleLinks() -> impl IntoView { + let i18n = use_i18n(); + let locale = i18n.get_locale_untracked(); view! { <div class="homepage-manual__examples" aria-labelledby="catalog-examples-title"> <div class="homepage-manual__section-heading"> - <h2 id="catalog-examples-title">"Open a real catalog record"</h2> - <p>"Stable routes make pages, exports, API docs, and tool setup easy to share."</p> + <h2 id="catalog-examples-title"> + <a href="#catalog-examples-title">{t!(i18n, manual.examples_title)}</a> + </h2> + <p>{t!(i18n, manual.examples_subtitle)}</p> </div> <div class="homepage-manual__link-grid"> <ExampleLink - href=HOST_PATH - title="Stellar host profile" - description=format!("Open the {HOST_NAME} host-system detail page.") + href=localized_path(HOST_PATH, locale) + title=t_string!(i18n, manual.stellar_profile) + description=t_string!(i18n, manual.stellar_profile_description) /> <ExampleLink - href=PLANET_PATH - title="Planet profile" - description=format!("Open the {PLANET_NAME} exoplanet detail page.") + href=localized_path(PLANET_PATH, locale) + title=t_string!(i18n, manual.planet_profile) + description=t_string!(i18n, manual.planet_profile_description) /> <ExampleLink - href="/stellarhosts/TRAPPIST-1.json" - title="Host JSON" - description="Download the full host detail payload.".to_string() + href="/stellarhosts/TRAPPIST-1.json".to_string() + title=t_string!(i18n, manual.host_json) + description=t_string!(i18n, manual.host_json_description) /> <ExampleLink - href="/stellarhosts/TRAPPIST-1.csv" - title="Host CSV" - description="Download matching host source rows.".to_string() + href="/stellarhosts/TRAPPIST-1.csv".to_string() + title=t_string!(i18n, manual.host_csv) + description=t_string!(i18n, manual.host_csv_description) /> <ExampleLink - href="/exoplanets/Kepler-22%20b.json" - title="Planet JSON" - description="Download the full planet detail payload.".to_string() + href="/exoplanets/Kepler-22%20b.json".to_string() + title=t_string!(i18n, manual.planet_json) + description=t_string!(i18n, manual.planet_json_description) /> <ExampleLink - href="/exoplanets/Kepler-22%20b.csv" - title="Planet CSV" - description="Download matching planet source rows.".to_string() + href="/exoplanets/Kepler-22%20b.csv".to_string() + title=t_string!(i18n, manual.planet_csv) + description=t_string!(i18n, manual.planet_csv_description) /> <ExampleLink - href="/docs/api" - title="REST API" - description="Query tables, schemas, details, insights, and SQL endpoints.".to_string() + href=localized_path("/docs/api", locale) + title=t_string!(i18n, manual.rest_api) + description=t_string!(i18n, manual.rest_api_description) /> <ExampleLink - href="/docs/mcp" - title="MCP server" - description="Connect an AI client to inspect schema and query the catalog.".to_string() + href=localized_path("/docs/mcp", locale) + title=t_string!(i18n, manual.mcp_server) + description=t_string!(i18n, manual.mcp_server_description) /> <ExampleLink - href="/docs/cli" - title="CLI" - description="Query live or local data from the terminal.".to_string() + href=localized_path("/docs/cli", locale) + title=t_string!(i18n, manual.cli) + description=t_string!(i18n, manual.cli_description) /> <ExampleLink - href="/swagger-ui" - title="Swagger UI" - description="Explore the OpenAPI schema interactively.".to_string() + href="/swagger-ui".to_string() + title=t_string!(i18n, manual.swagger) + description=t_string!(i18n, manual.swagger_description) /> </div> </div> @@ -99,9 +112,9 @@ fn ExampleLinks() -> impl IntoView { #[component] fn ExampleLink( - href: &'static str, + href: String, title: &'static str, - description: String, + description: &'static str, ) -> impl IntoView { view! { <a class="homepage-manual-link" href=href> @@ -113,23 +126,27 @@ fn ExampleLink( #[component] fn McpSetup() -> impl IntoView { + let i18n = use_i18n(); + let docs_href = localized_path("/docs/mcp", i18n.get_locale_untracked()); view! { <div class="homepage-manual__mcp" aria-labelledby="mcp-setup-title"> <div class="homepage-manual__section-heading"> - <h2 id="mcp-setup-title">"Connect an MCP client"</h2> + <h2 id="mcp-setup-title"> + <a href="#mcp-setup-title">{t!(i18n, manual.connect_title)}</a> + </h2> <p> - "The hosted endpoint is " + {t!(i18n, manual.hosted_endpoint)} " " <code>{MCP_ENDPOINT}</code> - ". Read the " + ". " {t!(i18n, manual.read)} " " <a href="https://modelcontextprotocol.io/docs/getting-started/intro" target="_blank" rel="noopener noreferrer" > - "MCP introduction" + {t!(i18n, manual.mcp_introduction)} </a> - " or the " - <a href="/docs/mcp">"Exodata MCP docs"</a> + " " {t!(i18n, manual.or)} " " + <a href=docs_href>{t!(i18n, manual.mcp_docs)}</a> "." </p> </div> @@ -142,7 +159,7 @@ fn McpSetup() -> impl IntoView { --url https://exodata.space/mcp" /> <CommandBox - label="Codex config fallback" + label=t_string!(i18n, manual.codex_config) language="toml" code="[mcp_servers.exodata] url = \"https://exodata.space/mcp\"" @@ -162,7 +179,7 @@ fn McpSetup() -> impl IntoView { --url https://exodata.space/mcp" /> <CommandBox - label="OpenCode config" + label=t_string!(i18n, manual.opencode_config) language="json" code="{ \"$schema\": \"https://opencode.ai/config.json\", @@ -179,7 +196,7 @@ fn McpSetup() -> impl IntoView { label="MCP Inspector" language="bash" code="npx @modelcontextprotocol/inspector" - note="Open the Inspector UI, then connect to the hosted endpoint with Streamable HTTP transport." + note=t_string!(i18n, manual.inspector_note) /> </div> </div> @@ -193,6 +210,7 @@ fn CommandBox( code: &'static str, #[prop(optional)] note: Option<&'static str>, ) -> impl IntoView { + let i18n = use_i18n(); let (copied, set_copied) = signal(false); view! { @@ -204,7 +222,11 @@ fn CommandBox( class="homepage-command-box__copy" on:click=move |_| copy_code_to_clipboard(code, set_copied) > - {move || if copied.get() { "Copied" } else { "Copy" }} + {move || if copied.get() { + t_string!(i18n, manual.copied) + } else { + t_string!(i18n, manual.copy) + }} </button> </div> <pre><code class=format!("language-{language}")>{code}</code></pre> @@ -215,35 +237,37 @@ fn CommandBox( #[component] fn AgentInteractionCard() -> impl IntoView { + let i18n = use_i18n(); + let locale = i18n.get_locale_untracked(); view! { - <aside class="homepage-agent-card" aria-label="Example catalog tool interaction"> + <aside class="homepage-agent-card" aria-label=t_string!(i18n, manual.interaction_label)> <div class="homepage-agent-card__header"> <span>"exodata"</span> <span>"CLI / MCP"</span> </div> <dl class="homepage-agent-card__flow"> <div> - <dt>"Ask"</dt> - <dd>"nearest known planet-hosting systems with distance and planet count"</dd> + <dt>{t!(i18n, manual.ask)}</dt> + <dd>{t!(i18n, manual.ask_example)}</dd> </div> <div> - <dt>"Tool"</dt> + <dt>{t!(i18n, manual.tool)}</dt> <dd>"describe_catalog -> query_catalog"</dd> </div> <div> - <dt>"SQL"</dt> + <dt>{t!(i18n, manual.sql)}</dt> <dd> <code>"SELECT hostname, sy_dist, sy_pnum FROM stellarhosts WHERE sy_dist IS NOT NULL ORDER BY sy_dist LIMIT 5"</code> </dd> </div> <div> - <dt>"Result"</dt> - <dd>"structured rows ready for a table, notebook, or follow-up query"</dd> + <dt>{t!(i18n, manual.result)}</dt> + <dd>{t!(i18n, manual.result_example)}</dd> </div> </dl> <div class="homepage-agent-card__links"> - <a href="/docs/cli">"CLI docs"</a> - <a href="/docs/mcp">"MCP docs"</a> + <a href=localized_path("/docs/cli", locale)>{t!(i18n, manual.cli_docs)}</a> + <a href=localized_path("/docs/mcp", locale)>{t!(i18n, manual.short_mcp_docs)}</a> </div> </aside> } diff --git a/src/components/navbar.rs b/src/components/navbar.rs index 889d5f7..fe08f2f 100644 --- a/src/components/navbar.rs +++ b/src/components/navbar.rs @@ -3,15 +3,24 @@ use leptos::web_sys::KeyboardEvent; use leptos_router::components::A; use leptos_router::hooks::use_location; +use crate::i18n::*; +use crate::locale::{ + locale_from_path, localized_path, localized_url, strip_locale_prefix, +}; + #[component] pub fn Navbar() -> impl IntoView { let location = use_location(); + let i18n = use_i18n(); let pathname = move || location.pathname.get(); - let is_stellarhosts = move || pathname().starts_with("/stellarhosts"); - let is_exoplanets = move || pathname().starts_with("/exoplanets"); - let is_insights = move || pathname().starts_with("/insights"); + let current_path = move || strip_locale_prefix(&pathname()).to_string(); + let current_locale = move || locale_from_path(&pathname()); + let is_stellarhosts = move || current_path().starts_with("/stellarhosts"); + let is_exoplanets = move || current_path().starts_with("/exoplanets"); + let is_insights = move || current_path().starts_with("/insights"); let is_docs = move || { - pathname().starts_with("/docs") || pathname().starts_with("/about") + current_path().starts_with("/docs") + || current_path().starts_with("/about") }; // Mobile menu state @@ -38,7 +47,7 @@ pub fn Navbar() -> impl IntoView { <nav class="bg-slate-900/80 backdrop-blur-sm border-b border-purple-500/20 sticky top-0 z-50"> <div class="container mx-auto px-4"> <div class="flex justify-between items-center h-16"> - <A href="/" attr:class="flex items-center"> + <A href=move || localized_path("/", current_locale()) attr:class="flex items-center"> <span class="text-xl md:text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400"> "🪐 Exoplanets" </span> @@ -47,7 +56,7 @@ pub fn Navbar() -> impl IntoView { // Desktop Navigation (hidden on mobile) <div class="hidden md:flex space-x-2 text-gray-300"> <A - href="/stellarhosts" + href=move || localized_path("/stellarhosts", current_locale()) attr:class=move || { if is_stellarhosts() { active_class @@ -56,11 +65,11 @@ pub fn Navbar() -> impl IntoView { } } > - "Stellar Hosts" + {t!(i18n, nav.stellar_hosts)} </A> <A - href="/exoplanets" + href=move || localized_path("/exoplanets", current_locale()) attr:class=move || { if is_exoplanets() { active_class @@ -69,11 +78,11 @@ pub fn Navbar() -> impl IntoView { } } > - "Exoplanets" + {t!(i18n, nav.exoplanets)} </A> <A - href="/insights" + href=move || localized_path("/insights", current_locale()) attr:class=move || { if is_insights() { active_class @@ -82,11 +91,11 @@ pub fn Navbar() -> impl IntoView { } } > - "Insights" + {t!(i18n, nav.insights)} </A> <A - href="/docs" + href=move || localized_path("/docs", current_locale()) attr:class=move || { if is_docs() { active_class @@ -95,7 +104,7 @@ pub fn Navbar() -> impl IntoView { } } > - "Docs" + {t!(i18n, nav.docs)} </A> <a @@ -103,7 +112,7 @@ pub fn Navbar() -> impl IntoView { class=link_class target="_blank" > - "API" + {t!(i18n, nav.api)} </a> <a @@ -111,17 +120,18 @@ pub fn Navbar() -> impl IntoView { class=icon_link_class target="_blank" rel="noopener noreferrer" - aria-label="GitHub repository" - title="GitHub repository" + aria-label=move || t_string!(i18n, nav.github_repository) + title=move || t_string!(i18n, nav.github_repository) > {github_icon()} </a> + <LanguageSwitcher/> </div> // Hamburger Button (visible on mobile only) <button class="md:hidden flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/10 transition-colors" - aria-label="Open navigation menu" + aria-label=move || t_string!(i18n, nav.open_menu) aria-expanded=move || mobile_menu_open.get() aria-controls="mobile-menu" on:click=move |_| set_mobile_menu_open.set(true) @@ -146,7 +156,7 @@ pub fn Navbar() -> impl IntoView { id="mobile-menu" class="relative z-10 flex flex-col h-full" role="navigation" - aria-label="Mobile navigation" + aria-label=move || t_string!(i18n, nav.mobile_navigation) on:keydown=move |e: KeyboardEvent| { if e.key() == "Escape" { set_mobile_menu_open.set(false); @@ -160,7 +170,7 @@ pub fn Navbar() -> impl IntoView { </span> <button class="flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/10 transition-colors" - aria-label="Close navigation menu" + aria-label=move || t_string!(i18n, nav.close_menu) on:click=move |_| set_mobile_menu_open.set(false) > {close_icon()} @@ -170,44 +180,44 @@ pub fn Navbar() -> impl IntoView { // Navigation Links <div class="flex-1 flex flex-col items-center justify-center space-y-4 px-6"> <A - href="/" + href=move || localized_path("/", current_locale()) attr:class=move || { - if pathname() == "/" { mobile_active_class } else { mobile_link_class } + if current_path() == "/" { mobile_active_class } else { mobile_link_class } } > - "Overview" + {t!(i18n, nav.overview)} </A> <A - href="/stellarhosts" + href=move || localized_path("/stellarhosts", current_locale()) attr:class=move || { if is_stellarhosts() { mobile_active_class } else { mobile_link_class } } > - "Stellar Hosts" + {t!(i18n, nav.stellar_hosts)} </A> <A - href="/exoplanets" + href=move || localized_path("/exoplanets", current_locale()) attr:class=move || { if is_exoplanets() { mobile_active_class } else { mobile_link_class } } > - "Exoplanets" + {t!(i18n, nav.exoplanets)} </A> <A - href="/insights" + href=move || localized_path("/insights", current_locale()) attr:class=move || { if is_insights() { mobile_active_class } else { mobile_link_class } } > - "Insights" + {t!(i18n, nav.insights)} </A> <A - href="/docs" + href=move || localized_path("/docs", current_locale()) attr:class=move || { if is_docs() { mobile_active_class } else { mobile_link_class } } > - "Docs" + {t!(i18n, nav.docs)} </A> <a @@ -215,8 +225,9 @@ pub fn Navbar() -> impl IntoView { class=mobile_link_class target="_blank" > - "API" + {t!(i18n, nav.api)} </a> + <LanguageSwitcher/> <a href="https://github.com/oiwn/exoplanets-catalog" @@ -236,6 +247,61 @@ pub fn Navbar() -> impl IntoView { } } +#[component] +fn LanguageSwitcher() -> impl IntoView { + let location = use_location(); + let i18n = use_i18n(); + let current_locale = move || locale_from_path(&location.pathname.get()); + let href_for = move |locale| { + localized_url( + &location.pathname.get(), + &location.search.get(), + &location.hash.get(), + locale, + ) + }; + let option_class = move |locale| { + if current_locale() == locale { + "rounded-md bg-purple-500/25 px-2.5 py-1.5 text-xs font-semibold text-purple-200" + } else { + "rounded-md px-2.5 py-1.5 text-xs font-semibold text-slate-300 transition-colors hover:bg-white/10 hover:text-white" + } + }; + + view! { + <div + class="flex items-center gap-0.5 rounded-lg border border-slate-700 bg-slate-950/60 p-1" + role="group" + aria-label=move || t_string!(i18n, nav.language) + > + <A + href=move || href_for(Locale::en) + attr:class=move || option_class(Locale::en) + attr:lang="en" + attr:title="English" + > + "EN" + </A> + <A + href=move || href_for(Locale::zh_CN) + attr:class=move || option_class(Locale::zh_CN) + attr:lang="zh-CN" + attr:title="简体中文" + > + "中文" + </A> + <A + href=move || href_for(Locale::ja) + attr:class=move || option_class(Locale::ja) + attr:lang="ja" + attr:title="日本語" + > + "日本語" + </A> + </div> + } +} + fn hamburger_icon() -> impl IntoView { view! { <svg diff --git a/src/components/overview.rs b/src/components/overview.rs index ff85a41..de68965 100644 --- a/src/components/overview.rs +++ b/src/components/overview.rs @@ -3,9 +3,8 @@ use leptos_meta::{Link, Meta, Title}; use leptos_router::LazyRoute; use leptos_router::lazy_route; -use crate::metadata_helpers::{ - canonical_url, overview_description, overview_title, -}; +use crate::metadata_helpers::{SITE_URL, canonical_url, title_with_site}; +use crate::{i18n::*, locale::localized_path}; // Import server function and types - #[server] macro handles client/server compilation use crate::components::homepage_manual::HomepageManual; use crate::server::functions::{DataStats, get_stats}; @@ -29,14 +28,21 @@ impl LazyRoute for OverviewLazy { #[component] pub fn OverviewPage() -> impl IntoView { + let i18n = use_i18n(); + let locale = i18n.get_locale_untracked(); + let canonical_path = localized_path("/", locale); // Create a resource that calls the server function let stats_resource = Resource::new(move || (), move |_| async move { get_stats().await }); view! { - <Title text=overview_title()/> - <Meta name="description" content=overview_description()/> - <Link rel="canonical" href=canonical_url("/")/> + <Title text=title_with_site(t_string!(i18n, home.title))/> + <Meta name="description" content=t_string!(i18n, home.description)/> + <Link rel="canonical" href=canonical_url(&canonical_path)/> + <Link rel="alternate" hreflang="en" href=format!("{SITE_URL}/")/> + <Link rel="alternate" hreflang="zh-CN" href=format!("{SITE_URL}/zh-CN")/> + <Link rel="alternate" hreflang="ja" href=format!("{SITE_URL}/ja")/> + <Link rel="alternate" hreflang="x-default" href=format!("{SITE_URL}/")/> <StructuredData value=website_schema()/> <div class="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900"> // Header with cosmic background @@ -46,13 +52,13 @@ pub fn OverviewPage() -> impl IntoView { <div class="container mx-auto px-4 py-16 relative"> <div class="text-center space-y-4"> <h1 class="text-5xl md:text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 animate-pulse"> - "🌌 Exoplanet Archive" + {t!(i18n, home.hero_title)} </h1> <a - href="#homepage-manual" + href="#mcp-exoplanet-data" class="block text-xl text-gray-300 max-w-2xl mx-auto transition-colors hover:text-white focus:outline-none focus:ring-2 focus:ring-purple-400 rounded-lg" > - "Exploring the cosmos: A comprehensive catalog of confirmed exoplanets and their host stars" + {t!(i18n, home.hero_subtitle)} </a> </div> </div> @@ -70,7 +76,9 @@ pub fn OverviewPage() -> impl IntoView { "🪐" </div> </div> - <span class="mt-6 text-lg text-gray-300 animate-pulse">"Loading cosmic data..."</span> + <span class="mt-6 text-lg text-gray-300 animate-pulse"> + {t!(i18n, home.loading)} + </span> </div> } } @@ -85,13 +93,13 @@ pub fn OverviewPage() -> impl IntoView { </div> }), Err(err) => { - let error_msg = format!("Error loading data: {}", err); + let error_msg = format!("{}: {err}", t_string!(i18n, home.error_loading)); leptos::either::Either::Right(view! { <div class="max-w-2xl mx-auto mt-10 bg-red-900/50 border-2 border-red-500 text-red-100 px-6 py-4 rounded-xl backdrop-blur-sm"> <div class="flex items-center gap-3"> <span class="text-2xl">"⚠️"</span> <div> - <h3 class="font-semibold text-lg">"Connection Error"</h3> + <h3 class="font-semibold text-lg">{t!(i18n, home.connection_error)}</h3> <p class="text-sm text-red-200">{error_msg}</p> </div> </div> @@ -108,34 +116,35 @@ pub fn OverviewPage() -> impl IntoView { #[component] fn StatsOverview(stats: DataStats) -> impl IntoView { + let i18n = use_i18n(); view! { <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <StatCard - title="Stellar Systems" + title=t_string!(i18n, home.stellar_systems) value=stats.stellarhosts_total.to_string() icon="⭐" - subtitle="Host stars catalogued" + subtitle=t_string!(i18n, home.host_stars_catalogued) gradient="from-blue-600 to-cyan-500" /> <StatCard - title="Exoplanets" + title=t_string!(i18n, home.exoplanets) value=stats.exoplanets_total.to_string() icon="🪐" - subtitle="Distinct planets in the catalog" + subtitle=t_string!(i18n, home.distinct_planets) gradient="from-purple-600 to-pink-500" /> <StatCard - title="Avg Stellar Temp" + title=t_string!(i18n, home.average_temperature) value=format!("{:.0} K", stats.avg_stellar_temp) icon="🌡️" - subtitle="Mean effective temperature" + subtitle=t_string!(i18n, home.mean_temperature) gradient="from-orange-600 to-red-500" /> <StatCard - title="Avg Distance" + title=t_string!(i18n, home.average_distance) value=format!("{:.1} pc", stats.avg_stellar_distance) icon="📏" - subtitle="Mean system distance" + subtitle=t_string!(i18n, home.mean_distance) gradient="from-green-600 to-emerald-500" /> </div> @@ -144,46 +153,47 @@ fn StatsOverview(stats: DataStats) -> impl IntoView { #[component] fn DetailedStats(stats: DataStats) -> impl IntoView { + let i18n = use_i18n(); view! { <div class="space-y-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start"> <StatSection - title="Planet Classifications" - subtitle="Distinct planets grouped by canonical radius" + title=t_string!(i18n, home.planet_classifications) + subtitle=t_string!(i18n, home.planet_classifications_subtitle) icon="🌍" items=stats.planet_size_categories /> <StatSection - title="Orbital Periods" - subtitle="Distinct planets grouped by canonical period" + title=t_string!(i18n, home.orbital_periods) + subtitle=t_string!(i18n, home.orbital_periods_subtitle) icon="🌀" items=stats.orbital_period_buckets /> </div> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start"> <StatSection - title="Discovery Methods" - subtitle="Distinct planets grouped by canonical method" + title=t_string!(i18n, home.discovery_methods) + subtitle=t_string!(i18n, home.discovery_methods_subtitle) icon="🔭" items=stats.discovery_methods /> <StatSection - title="Discovery Years" - subtitle="Distinct planets grouped by earliest discovery year" + title=t_string!(i18n, home.discovery_years) + subtitle=t_string!(i18n, home.discovery_years_subtitle) icon="📅" items=stats.discovery_years /> </div> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start"> <StatSection - title="Planet Temperature Bands" - subtitle="Distinct planets grouped by canonical equilibrium temperature" + title=t_string!(i18n, home.temperature_bands) + subtitle=t_string!(i18n, home.temperature_bands_subtitle) icon="🌡️" items=stats.planet_temperature_bands /> <StatSection - title="Detection Sources" - subtitle="Distinct planets grouped by discovery facility" + title=t_string!(i18n, home.detection_sources) + subtitle=t_string!(i18n, home.detection_sources_subtitle) icon="🛰️" items=stats.detection_sources /> @@ -200,6 +210,7 @@ fn StatCard( subtitle: &'static str, gradient: &'static str, ) -> impl IntoView { + let i18n = use_i18n(); view! { <div class="group relative overflow-hidden rounded-2xl bg-slate-800/50 backdrop-blur-sm border border-slate-700 p-6 transition-all duration-300 hover:scale-105 hover:border-slate-500 hover:shadow-2xl hover:shadow-purple-500/20"> // Gradient overlay @@ -209,7 +220,7 @@ fn StatCard( <div class="flex items-start justify-between mb-4"> <span class="text-5xl filter drop-shadow-lg">{icon}</span> <div class=format!("px-3 py-1 rounded-full bg-gradient-to-r {} text-white text-xs font-bold", gradient)> - "LIVE" + {t!(i18n, home.live)} </div> </div> diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..313d6a3 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,5 @@ +#![allow(deprecated)] + +leptos_i18n::load_locales!(); + +pub use i18n::*; diff --git a/src/lib.rs b/src/lib.rs index e1c0773..feaaae9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ pub mod app; pub mod components; pub mod error_template; +pub mod i18n; +pub mod locale; pub mod metadata; pub mod metadata_helpers; pub mod structured_data; diff --git a/src/locale.rs b/src/locale.rs new file mode 100644 index 0000000..5d2b0ba --- /dev/null +++ b/src/locale.rs @@ -0,0 +1,115 @@ +use crate::i18n::Locale; + +pub const ZH_CN_PREFIX: &str = "/zh-CN"; +pub const JA_PREFIX: &str = "/ja"; + +pub fn locale_from_path(path: &str) -> Locale { + if has_prefix(path, ZH_CN_PREFIX) { + Locale::zh_CN + } else if has_prefix(path, JA_PREFIX) { + Locale::ja + } else { + Locale::en + } +} + +pub fn strip_locale_prefix(path: &str) -> &str { + for prefix in [ZH_CN_PREFIX, JA_PREFIX] { + if path == prefix { + return "/"; + } + if let Some(rest) = path.strip_prefix(prefix) + && rest.starts_with('/') + { + return rest; + } + } + path +} + +pub fn localized_path(path: &str, locale: Locale) -> String { + let base = strip_locale_prefix(path); + if is_utility_path(base) { + return base.to_string(); + } + + match locale { + Locale::en => base.to_string(), + Locale::zh_CN => prefix_path(ZH_CN_PREFIX, base), + Locale::ja => prefix_path(JA_PREFIX, base), + } +} + +pub fn localized_url( + pathname: &str, + search: &str, + hash: &str, + locale: Locale, +) -> String { + format!("{}{}{}", localized_path(pathname, locale), search, hash) +} + +pub fn is_utility_path(path: &str) -> bool { + path == "/mcp" + || path == "/swagger-ui" + || path.starts_with("/rest/") + || path.starts_with("/swagger-ui/") + || path.starts_with("/sitemap-") + || path.ends_with(".json") + || path.ends_with(".csv") +} + +fn has_prefix(path: &str, prefix: &str) -> bool { + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn prefix_path(prefix: &str, path: &str) -> String { + if path == "/" || path.is_empty() { + prefix.to_string() + } else { + format!("{prefix}{path}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_only_supported_locale_prefixes() { + assert_eq!(locale_from_path("/"), Locale::en); + assert_eq!(locale_from_path("/zh-CN"), Locale::zh_CN); + assert_eq!(locale_from_path("/zh-CN/exoplanets"), Locale::zh_CN); + assert_eq!(locale_from_path("/ja/stellarhosts"), Locale::ja); + assert_eq!(locale_from_path("/zh-CN-not-a-locale"), Locale::en); + } + + #[test] + fn switches_locale_and_preserves_url_state() { + assert_eq!( + localized_url("/ja/exoplanets", "?page=2", "#results", Locale::zh_CN), + "/zh-CN/exoplanets?page=2#results" + ); + assert_eq!( + localized_url("/zh-CN", "", "#mcp-exoplanet-data", Locale::en), + "/#mcp-exoplanet-data" + ); + } + + #[test] + fn utility_and_export_paths_remain_unprefixed() { + for path in [ + "/mcp", + "/rest/query", + "/swagger-ui", + "/stellarhosts/TRAPPIST-1.json", + "/exoplanets/Kepler-22%20b.csv", + "/sitemap-index.xml", + ] { + assert_eq!(localized_path(path, Locale::ja), path); + } + } +} diff --git a/src/server/handlers.rs b/src/server/handlers.rs index 6bf2e18..f1257f8 100644 --- a/src/server/handlers.rs +++ b/src/server/handlers.rs @@ -760,6 +760,8 @@ pub fn build_sitemaps( fn build_static_urls(site_url: &str) -> Vec<String> { let mut urls = vec![ format!("{site_url}/"), + format!("{site_url}/zh-CN"), + format!("{site_url}/ja"), format!("{site_url}/docs"), format!("{site_url}/docs/cli"), format!("{site_url}/docs/api"), diff --git a/src/server/tests.rs b/src/server/tests.rs index 99ba2ed..2d23ca2 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -542,6 +542,8 @@ mod tests { assert!(xml.contains("<urlset")); assert!(xml.contains("<loc>https://example.com/</loc>")); + assert!(xml.contains("<loc>https://example.com/zh-CN</loc>")); + assert!(xml.contains("<loc>https://example.com/ja</loc>")); assert!(xml.contains("<loc>https://example.com/docs</loc>")); assert!(xml.contains("<loc>https://example.com/docs/cli</loc>")); assert!(xml.contains("<loc>https://example.com/docs/api</loc>")); @@ -572,6 +574,8 @@ mod tests { assert!(xml.contains("<lastmod>2026-01-15</lastmod>")); assert!(!xml.contains("/stellarhosts/")); assert!(!xml.contains("/exoplanets/")); + assert!(!xml.contains("/zh-CN/stellarhosts")); + assert!(!xml.contains("/ja/exoplanets")); } #[tokio::test] diff --git a/style/components/homepage-manual.css b/style/components/homepage-manual.css index 52f19dd..38beb62 100644 --- a/style/components/homepage-manual.css +++ b/style/components/homepage-manual.css @@ -18,6 +18,10 @@ @apply mb-4 text-3xl font-semibold text-white md:text-4xl; } +.homepage-manual__prose h1 a { + @apply inline-block cursor-pointer no-underline transition-colors hover:text-sky-200 focus:rounded-sm focus:outline-none focus:ring-2 focus:ring-sky-400; +} + .homepage-manual__prose p { @apply my-4; } @@ -30,6 +34,10 @@ @apply text-2xl font-semibold text-white; } +.homepage-manual__section-heading h2 a { + @apply inline-block cursor-pointer text-white no-underline transition-colors hover:text-sky-200 focus:rounded-sm focus:outline-none focus:ring-2 focus:ring-sky-400; +} + .homepage-manual__section-heading p { @apply text-sm leading-6 text-slate-400; } From 0860c3f52aa37bc5c58de8c05033a4a04d48b0b0 Mon Sep 17 00:00:00 2001 From: oiwn <alex@imscraping.ninja> Date: Mon, 13 Jul 2026 01:55:36 +0700 Subject: [PATCH 2/2] make clippy happy --- src/server/handlers.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server/handlers.rs b/src/server/handlers.rs index f1257f8..97a71f8 100644 --- a/src/server/handlers.rs +++ b/src/server/handlers.rs @@ -340,10 +340,9 @@ fn parse_detail_export_path( let (encoded_name, format) = if let Some(name) = name_with_suffix.strip_suffix(".json") { (name, exports::ExportFormat::Json) - } else if let Some(name) = name_with_suffix.strip_suffix(".csv") { - (name, exports::ExportFormat::Csv) } else { - return None; + let name = name_with_suffix.strip_suffix(".csv")?; + (name, exports::ExportFormat::Csv) }; let name = percent_encoding::percent_decode_str(encoded_name)