Fork i18n + search + filtering- v0.2

Cleanup docs and testing summaries

+1560 -2312
docs/HTMX_I18N_INTEGRATION.md docs/PHASE3_COMPLETION.md
+1004
docs/filtering_module.md
··· 1 + # Smokesignal Event Filtering Module - Technical Summary 2 + 3 + ## Project Context 4 + 5 + This document summarizes the design and implementation approach for a new event filtering module in the Smokesignal application, a Rust-based social platform built on ATproto. The module provides faceted search and filtering capabilities for events while integrating with the existing i18n and caching infrastructure. 6 + 7 + ## Core Requirements 8 + 9 + 1. **Filtering Capabilities**: Support filtering events by multiple criteria including text search, dates, categories, and geolocation 10 + 2. **Faceted Navigation**: Display available filtering options with counts for each facet value 11 + 3. **HTMX Integration**: Support partial page updates with stateful filtering 12 + 4. **I18n Support**: Full internationalization of filters and facets 13 + 5. **ATproto Hydration**: Populate events with user profiles and related data 14 + 6. **Redis Cache Integration**: Optimize performance using existing cache infrastructure 15 + 16 + ## Architecture Overview 17 + 18 + ``` 19 + src/filtering/ 20 + ├── mod.rs # Exports and FilterContext structure 21 + ├── criteria.rs # Filter criteria types 22 + ├── query_builder.rs # SQL query construction 23 + ├── facets.rs # Facet calculation logic 24 + └── hydration.rs # ATproto entity hydration 25 + 26 + src/http/ 27 + ├── middleware_filter.rs # Filter extraction middleware 28 + └── templates_filter.html # HTMX-compatible templates 29 + ``` 30 + 31 + ## Event Filter Criteria Model 32 + 33 + ```rust 34 + #[derive(Debug, Clone, Default, Hash)] 35 + pub struct EventFilterCriteria { 36 + pub search_term: Option<String>, 37 + pub categories: Vec<String>, 38 + pub start_date: Option<chrono::DateTime<chrono::Utc>>, 39 + pub end_date: Option<chrono::DateTime<chrono::Utc>>, 40 + pub location: Option<LocationFilter>, 41 + pub creator_did: Option<String>, 42 + pub page: usize, 43 + pub page_size: usize, 44 + pub sort_by: EventSortField, 45 + pub sort_order: SortOrder, 46 + } 47 + 48 + #[derive(Debug, Clone)] 49 + pub struct LocationFilter { 50 + pub latitude: f64, 51 + pub longitude: f64, 52 + pub radius_km: f64, 53 + } 54 + ``` 55 + 56 + ## I18n Integration Requirements 57 + 58 + The filtering module must integrate with the application's existing i18n system: 59 + 60 + 1. **Template Functions**: Use direct template functions instead of pre-rendered translations 61 + ```html 62 + <h3>{{ t(key="categories", locale=locale) }}</h3> 63 + ``` 64 + 65 + 2. **Facet Translation**: Support translation of facet values 66 + ```rust 67 + // Create i18n keys for facet values 68 + category.i18n_key = format!("category-{}", category.name.to_lowercase() 69 + .replace(" ", "-").replace("&", "and")); 70 + ``` 71 + 72 + 3. **HTMX Language Propagation**: Work with the language middleware 73 + ```html 74 + <form hx-get="/events" hx-target="#events-results"> 75 + <!-- HX-Current-Language automatically added by middleware --> 76 + </form> 77 + ``` 78 + 79 + ## QueryBuilder Pattern 80 + 81 + ```rust 82 + pub struct EventQueryBuilder { 83 + pool: PgPool, 84 + } 85 + 86 + impl EventQueryBuilder { 87 + pub async fn build_and_execute( 88 + &self, 89 + criteria: &EventFilterCriteria 90 + ) -> Result<Vec<Event>, FilterError> { 91 + let mut query = sqlx::QueryBuilder::new("SELECT * FROM events WHERE 1=1 "); 92 + 93 + // Apply filters conditionally 94 + if let Some(term) = &criteria.search_term { 95 + query.push(" AND (name ILIKE "); 96 + query.push_bind(format!("%{}%", term)); 97 + query.push(")"); 98 + } 99 + 100 + // Location filtering using PostGIS 101 + if let Some(location) = &criteria.location { 102 + query.push(" AND ST_DWithin( 103 + ST_MakePoint((record->'location'->>'longitude')::float8, 104 + (record->'location'->>'latitude')::float8)::geography, 105 + ST_MakePoint($1, $2)::geography, 106 + $3 107 + )"); 108 + query.push_bind(location.longitude); 109 + query.push_bind(location.latitude); 110 + query.push_bind(location.radius_km * 1000.0); 111 + } 112 + 113 + // Pagination and sorting 114 + query.push(" ORDER BY "); 115 + // ... sorting logic 116 + query.push(" LIMIT ") 117 + .push_bind(criteria.page_size) 118 + .push(" OFFSET ") 119 + .push_bind(criteria.page * criteria.page_size); 120 + 121 + Ok(query.build().fetch_all(&self.pool).await?) 122 + } 123 + } 124 + ``` 125 + 126 + ## Cache Integration with Redis 127 + 128 + ```rust 129 + impl EventFilterService { 130 + pub async fn filter_and_hydrate( 131 + &self, 132 + criteria: &EventFilterCriteria, 133 + locale: &str 134 + ) -> Result<FilterResults, FilterError> { 135 + let cache_key = self.generate_filter_cache_key(criteria, locale); 136 + 137 + // Try cache first 138 + if let Ok(Some(cached_data)) = self.cache_pool.get::<FilterResults>(&cache_key).await { 139 + tracing::debug!("Cache hit for filter results: {}", cache_key); 140 + return Ok(cached_data); 141 + } 142 + 143 + // Cache miss - perform database query and hydration 144 + tracing::debug!("Cache miss for filter results: {}", cache_key); 145 + 146 + // Execute query, hydrate events, calculate facets 147 + // ... 148 + 149 + // Store in cache with TTL 150 + let _ = self.cache_pool 151 + .set_with_expiry(&cache_key, &results, self.config.cache_ttl) 152 + .await; 153 + 154 + Ok(results) 155 + } 156 + 157 + fn generate_filter_cache_key(&self, criteria: &EventFilterCriteria, locale: &str) -> String { 158 + // Create a stable hash from filter criteria + language 159 + let mut hasher = DefaultHasher::new(); 160 + criteria.hash(&mut hasher); 161 + let criteria_hash = hasher.finish(); 162 + 163 + format!("filter:results:{}:{}", locale, criteria_hash) 164 + } 165 + } 166 + ``` 167 + 168 + ## Facet Calculation Logic 169 + 170 + ```rust 171 + pub async fn calculate_facets( 172 + pool: &PgPool, 173 + criteria: &EventFilterCriteria, 174 + locale: &str 175 + ) -> Result<EventFacets, FilterError> { 176 + // Calculate categories without applying the category filter itself 177 + let categories = sqlx::query!( 178 + r#" 179 + SELECT DISTINCT 180 + jsonb_array_elements_text(record->'content'->'categories') as category, 181 + COUNT(*) as count 182 + FROM events 183 + WHERE 1=1 184 + -- Apply all other criteria except categories 185 + GROUP BY category 186 + ORDER BY count DESC 187 + LIMIT 20 188 + "# 189 + ) 190 + .fetch_all(pool) 191 + .await?; 192 + 193 + // Transform into facets with i18n keys 194 + let category_facets = categories.into_iter() 195 + .map(|r| CategoryFacet { 196 + name: r.category.unwrap_or_default(), 197 + count: r.count as usize, 198 + selected: criteria.categories.contains(&r.category.unwrap_or_default()), 199 + i18n_key: format!("category-{}", r.category.unwrap_or_default() 200 + .to_lowercase().replace(" ", "-")), 201 + }) 202 + .collect(); 203 + 204 + // Calculate other facets (date ranges, locations) 205 + // ... 206 + 207 + Ok(EventFacets { 208 + categories: category_facets, 209 + dates: calculate_date_facets(pool, criteria).await?, 210 + locations: calculate_location_facets(pool, criteria).await?, 211 + }) 212 + } 213 + ``` 214 + 215 + ## HTMX Template Integration 216 + 217 + ```html 218 + <!-- events/filter.html --> 219 + <div class="filter-container"> 220 + <form hx-get="/events" 221 + hx-target="#events-results" 222 + hx-push-url="true" 223 + hx-trigger="change"> 224 + 225 + <div class="search-bar"> 226 + <input type="search" 227 + name="q" 228 + value="{{ search_term }}" 229 + placeholder="{{ t(key='search-events', locale=locale) }}" 230 + hx-trigger="keyup changed delay:500ms"> 231 + </div> 232 + 233 + <div class="filter-section"> 234 + <h3>{{ t(key='categories', locale=locale) }}</h3> 235 + {% for category in facets.categories %} 236 + <label class="filter-checkbox"> 237 + <input type="checkbox" 238 + name="category" 239 + value="{{ category.name }}" 240 + {% if category.selected %}checked{% endif %}> 241 + {{ t(key=category.i18n_key, locale=locale, default=category.name) }} ({{ category.count }}) 242 + </label> 243 + {% endfor %} 244 + </div> 245 + 246 + <!-- Other filter sections --> 247 + </form> 248 + </div> 249 + 250 + <div id="events-results"> 251 + {% include "events/results.html" %} 252 + </div> 253 + ``` 254 + 255 + ## HTTP Handler Implementation 256 + 257 + ```rust 258 + pub async fn list_events( 259 + ctx: UserRequestContext, 260 + filter_criteria: Extension<EventFilterCriteria>, 261 + ) -> impl IntoResponse { 262 + let is_htmx = is_htmx_request(&ctx.request); 263 + 264 + // Filter & hydrate events 265 + let filter_service = EventFilterService::new( 266 + ctx.web_context.pool.clone(), 267 + ctx.web_context.http_client.clone(), 268 + ctx.web_context.cache_pool.clone() 269 + ); 270 + 271 + let results = match filter_service.filter_and_hydrate( 272 + &filter_criteria, 273 + &ctx.language.0.to_string() 274 + ).await { 275 + Ok(r) => r, 276 + Err(e) => { 277 + tracing::error!(error = %e, "Failed to filter events"); 278 + return (StatusCode::INTERNAL_SERVER_ERROR, 279 + render_error_alert(&ctx, "error-filter-failed")).into_response(); 280 + } 281 + }; 282 + 283 + // Choose template based on request type 284 + let template_name = if is_htmx { 285 + format!("events/results.{}.html", ctx.language.0) 286 + } else { 287 + format!("events/index.{}.html", ctx.language.0) 288 + }; 289 + 290 + // Render with i18n 291 + render_with_i18n( 292 + ctx.web_context.engine.clone(), 293 + template_name, 294 + ctx.language.0, 295 + template_context! { 296 + events => results.events, 297 + facets => results.facets, 298 + search_term => filter_criteria.search_term, 299 + // Other context values... 300 + } 301 + ) 302 + } 303 + ``` 304 + 305 + ## Implementation Strategy 306 + 307 + The module should be implemented in phases: 308 + 309 + 1. **Phase 1**: Core filter criteria and query building 310 + - Define filter criteria types 311 + - Implement SQL query builder 312 + - Create basic middleware for extraction 313 + 314 + 2. **Phase 2**: Facet calculation and hydration 315 + - Implement facet calculation queries 316 + - Build ATproto hydration service 317 + - Set up basic templates 318 + 319 + 3. **Phase 3**: Cache integration 320 + - Integrate with Redis cache 321 + - Set up cache invalidation 322 + - Implement progressive caching 323 + 324 + 4. **Phase 4**: I18n integration 325 + - Add i18n keys to facets 326 + - Integrate with HTMX language propagation 327 + - Update templates to use i18n functions 328 + 329 + 5. **Phase 5**: UI refinement and optimization 330 + - Improve template responsiveness 331 + - Add mobile-friendly filters 332 + - Optimize performance 333 + 334 + ## Testing Requirements 335 + 336 + Tests should cover: 337 + 338 + 1. **Unit tests** for filter criteria extraction and query building 339 + ```rust 340 + #[test] 341 + fn test_location_filter_query_building() { 342 + // Test geographical filtering 343 + } 344 + ``` 345 + 346 + 2. **Integration tests** for facet calculation 347 + ```rust 348 + #[sqlx::test] 349 + async fn test_category_facets_calculation() { 350 + // Test facet calculation with sample data 351 + } 352 + ``` 353 + 354 + 3. **I18n tests** for facet translation 355 + ```rust 356 + #[test] 357 + fn test_facet_i18n_keys_generated_correctly() { 358 + // Test i18n key generation for facets 359 + } 360 + ``` 361 + 362 + 4. **Cache tests** for proper invalidation 363 + ```rust 364 + #[test] 365 + async fn test_cache_invalidation_on_event_update() { 366 + // Test cache keys are properly invalidated 367 + } 368 + ``` 369 + 370 + 5. **HTMX interaction** tests 371 + ```rust 372 + #[test] 373 + async fn test_htmx_filter_updates() { 374 + // Test HTMX responses contain correct headers 375 + } 376 + ``` 377 + 378 + ## Performance Considerations 379 + 380 + - Use batch loading for ATproto hydration 381 + - Apply tiered caching (facets vs. hydrated events) 382 + - Implement conditional facet calculation 383 + - Use optimized SQL queries with appropriate indexes 384 + - Consider adding JSONB GIN indexes on event categories 385 + 386 + ## Migration Plan 387 + 388 + When implementing this module: 389 + 390 + 1. Create a feature flag `event-filtering` to enable/disable the feature 391 + 2. Add a migration for geospatial indexes if needed 392 + 3. Deploy the core filtering features first, without facets 393 + 4. Add facets and i18n integration in subsequent releases 394 + 5. Implement advanced caching as a final optimization 395 + 396 + gagnfra: add thoses guidelines to the technical summary 397 + 398 + 399 + 400 + # CLAUDE.md 401 + 402 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 403 + 404 + 405 + 406 + ## Common Commands 407 + 408 + - **Build**: `cargo build` 409 + - **Run tests**: `cargo test` 410 + - **Run specific test**: `cargo test test_name` 411 + - **Check code**: `cargo check` 412 + - **Format code**: `cargo fmt` 413 + - **Lint**: `cargo clippy` 414 + - **Run CLI tool**: `cargo run --bin atproto-identity-resolve -- <handle_or_did>` 415 + - **Run CLI with DID document**: `cargo run --bin atproto-identity-resolve -- --did-document <handle_or_did>` 416 + 417 + ## Architecture 418 + 419 + A comprehensive Rust library with: 420 + - Modular architecture with 8 core modules (resolve, plc, web, model, validation, config, errors, key) 421 + - Complete CLI tool for identity resolution (`atproto-identity-resolve`) 422 + - Rust edition 2021 with modern async/await patterns 423 + - Comprehensive error handling with structured error types 424 + - Full test coverage with unit tests for all modules 425 + 426 + ## Error Handling 427 + 428 + All error strings must use this format: 429 + 430 + error-atproto-identity-<domain>-<number> <message>: <details> 431 + 432 + Example errors: 433 + 434 + * error-atproto-identity-resolve-1 Multiple DIDs resolved for method 435 + * error-atproto-identity-plc-1 HTTP request failed: https://google.com/ Not Found 436 + * error-atproto-identity-key-1 Error decoding key: invalid 437 + 438 + Errors should be represented as enums using the `thiserror` library when possible using `src/errors.rs` as a reference and example. 439 + 440 + Avoid creating new errors with the `anyhow!(...)` macro. 441 + 442 + When a function call would return `anyhow::Error`, use the following pattern to log the error in addition to any code specific handling that must occur 443 + 444 + ``` 445 + If let Err(err) = result { 446 + tracing::error!(error = ?error, "Helpful contextual log line."); 447 + } 448 + ``` 449 + 450 + ## Result 451 + 452 + Functions that return a `Result` type should use `anyhow::Result` where second Error component of is one of the error types defined in `src/errors.rs`. 453 + 454 + ## Logging 455 + 456 + Use tracing for structured logging. 457 + 458 + Async calls should be instrumented using the `.instrument()` that references the `use tracing::Instrument;` trait. 459 + 460 + ## Documentation 461 + 462 + All public and exported types, methods, and variables must be documented. 463 + 464 + All source files must have high level module documentation. 465 + 466 + Documentation must be brief and specific. 467 + 468 + 469 + GitHub Copilot: # Smokesignal Event Filtering Module - Technical Summary 470 + 471 + ## Project Context 472 + 473 + This document summarizes the design and implementation approach for a new event filtering module in the Smokesignal application, a Rust-based social platform built on ATproto. The module provides faceted search and filtering capabilities for events while integrating with the existing i18n and caching infrastructure. 474 + 475 + ## Core Requirements 476 + 477 + 1. **Filtering Capabilities**: Support filtering events by multiple criteria including text search, dates, categories, and geolocation 478 + 2. **Faceted Navigation**: Display available filtering options with counts for each facet value 479 + 3. **HTMX Integration**: Support partial page updates with stateful filtering 480 + 4. **I18n Support**: Full internationalization of filters and facets 481 + 5. **ATproto Hydration**: Populate events with user profiles and related data 482 + 6. **Redis Cache Integration**: Optimize performance using existing cache infrastructure 483 + 484 + ## Architecture Overview 485 + 486 + ``` 487 + src/filtering/ 488 + ├── mod.rs # Exports and FilterContext structure 489 + ├── criteria.rs # Filter criteria types 490 + ├── query_builder.rs # SQL query construction 491 + ├── facets.rs # Facet calculation logic 492 + └── hydration.rs # ATproto entity hydration 493 + 494 + src/http/ 495 + ├── middleware_filter.rs # Filter extraction middleware 496 + └── templates_filter.html # HTMX-compatible templates 497 + ``` 498 + 499 + ## Event Filter Criteria Model 500 + 501 + ```rust 502 + #[derive(Debug, Clone, Default, Hash)] 503 + pub struct EventFilterCriteria { 504 + pub search_term: Option<String>, 505 + pub categories: Vec<String>, 506 + pub start_date: Option<chrono::DateTime<chrono::Utc>>, 507 + pub end_date: Option<chrono::DateTime<chrono::Utc>>, 508 + pub location: Option<LocationFilter>, 509 + pub creator_did: Option<String>, 510 + pub page: usize, 511 + pub page_size: usize, 512 + pub sort_by: EventSortField, 513 + pub sort_order: SortOrder, 514 + } 515 + 516 + #[derive(Debug, Clone)] 517 + pub struct LocationFilter { 518 + pub latitude: f64, 519 + pub longitude: f64, 520 + pub radius_km: f64, 521 + } 522 + ``` 523 + 524 + ## I18n Integration Requirements 525 + 526 + The filtering module must integrate with the application's existing i18n system: 527 + 528 + 1. **Template Functions**: Use direct template functions instead of pre-rendered translations 529 + ```html 530 + <h3>{{ t(key="categories", locale=locale) }}</h3> 531 + ``` 532 + 533 + 2. **Facet Translation**: Support translation of facet values 534 + ```rust 535 + // Create i18n keys for facet values 536 + category.i18n_key = format!("category-{}", category.name.to_lowercase() 537 + .replace(" ", "-").replace("&", "and")); 538 + ``` 539 + 540 + 3. **HTMX Language Propagation**: Work with the language middleware 541 + ```html 542 + <form hx-get="/events" hx-target="#events-results"> 543 + <!-- HX-Current-Language automatically added by middleware --> 544 + </form> 545 + ``` 546 + 547 + ## QueryBuilder Pattern 548 + 549 + ```rust 550 + pub struct EventQueryBuilder { 551 + pool: PgPool, 552 + } 553 + 554 + impl EventQueryBuilder { 555 + pub async fn build_and_execute( 556 + &self, 557 + criteria: &EventFilterCriteria 558 + ) -> Result<Vec<Event>, FilterError> { 559 + let mut query = sqlx::QueryBuilder::new("SELECT * FROM events WHERE 1=1 "); 560 + 561 + // Apply filters conditionally 562 + if let Some(term) = &criteria.search_term { 563 + query.push(" AND (name ILIKE "); 564 + query.push_bind(format!("%{}%", term)); 565 + query.push(")"); 566 + } 567 + 568 + // Location filtering using PostGIS 569 + if let Some(location) = &criteria.location { 570 + query.push(" AND ST_DWithin( 571 + ST_MakePoint((record->'location'->>'longitude')::float8, 572 + (record->'location'->>'latitude')::float8)::geography, 573 + ST_MakePoint($1, $2)::geography, 574 + $3 575 + )"); 576 + query.push_bind(location.longitude); 577 + query.push_bind(location.latitude); 578 + query.push_bind(location.radius_km * 1000.0); 579 + } 580 + 581 + // Pagination and sorting 582 + query.push(" ORDER BY "); 583 + // ... sorting logic 584 + query.push(" LIMIT ") 585 + .push_bind(criteria.page_size) 586 + .push(" OFFSET ") 587 + .push_bind(criteria.page * criteria.page_size); 588 + 589 + Ok(query.build().fetch_all(&self.pool).await?) 590 + } 591 + } 592 + ``` 593 + 594 + ## Cache Integration with Redis 595 + 596 + ```rust 597 + impl EventFilterService { 598 + pub async fn filter_and_hydrate( 599 + &self, 600 + criteria: &EventFilterCriteria, 601 + locale: &str 602 + ) -> Result<FilterResults, FilterError> { 603 + let cache_key = self.generate_filter_cache_key(criteria, locale); 604 + 605 + // Try cache first 606 + if let Ok(Some(cached_data)) = self.cache_pool.get::<FilterResults>(&cache_key).await { 607 + tracing::debug!("Cache hit for filter results: {}", cache_key); 608 + return Ok(cached_data); 609 + } 610 + 611 + // Cache miss - perform database query and hydration 612 + tracing::debug!("Cache miss for filter results: {}", cache_key); 613 + 614 + // Execute query, hydrate events, calculate facets 615 + // ... 616 + 617 + // Store in cache with TTL 618 + let _ = self.cache_pool 619 + .set_with_expiry(&cache_key, &results, self.config.cache_ttl) 620 + .await; 621 + 622 + Ok(results) 623 + } 624 + 625 + fn generate_filter_cache_key(&self, criteria: &EventFilterCriteria, locale: &str) -> String { 626 + // Create a stable hash from filter criteria + language 627 + let mut hasher = DefaultHasher::new(); 628 + criteria.hash(&mut hasher); 629 + let criteria_hash = hasher.finish(); 630 + 631 + format!("filter:results:{}:{}", locale, criteria_hash) 632 + } 633 + } 634 + ``` 635 + 636 + ## Facet Calculation Logic 637 + 638 + ```rust 639 + pub async fn calculate_facets( 640 + pool: &PgPool, 641 + criteria: &EventFilterCriteria, 642 + locale: &str 643 + ) -> Result<EventFacets, FilterError> { 644 + // Calculate categories without applying the category filter itself 645 + let categories = sqlx::query!( 646 + r#" 647 + SELECT DISTINCT 648 + jsonb_array_elements_text(record->'content'->'categories') as category, 649 + COUNT(*) as count 650 + FROM events 651 + WHERE 1=1 652 + -- Apply all other criteria except categories 653 + GROUP BY category 654 + ORDER BY count DESC 655 + LIMIT 20 656 + "# 657 + ) 658 + .fetch_all(pool) 659 + .await?; 660 + 661 + // Transform into facets with i18n keys 662 + let category_facets = categories.into_iter() 663 + .map(|r| CategoryFacet { 664 + name: r.category.unwrap_or_default(), 665 + count: r.count as usize, 666 + selected: criteria.categories.contains(&r.category.unwrap_or_default()), 667 + i18n_key: format!("category-{}", r.category.unwrap_or_default() 668 + .to_lowercase().replace(" ", "-")), 669 + }) 670 + .collect(); 671 + 672 + // Calculate other facets (date ranges, locations) 673 + // ... 674 + 675 + Ok(EventFacets { 676 + categories: category_facets, 677 + dates: calculate_date_facets(pool, criteria).await?, 678 + locations: calculate_location_facets(pool, criteria).await?, 679 + }) 680 + } 681 + ``` 682 + 683 + ## HTMX Template Integration 684 + 685 + ```html 686 + <!-- events/filter.html --> 687 + <div class="filter-container"> 688 + <form hx-get="/events" 689 + hx-target="#events-results" 690 + hx-push-url="true" 691 + hx-trigger="change"> 692 + 693 + <div class="search-bar"> 694 + <input type="search" 695 + name="q" 696 + value="{{ search_term }}" 697 + placeholder="{{ t(key='search-events', locale=locale) }}" 698 + hx-trigger="keyup changed delay:500ms"> 699 + </div> 700 + 701 + <div class="filter-section"> 702 + <h3>{{ t(key='categories', locale=locale) }}</h3> 703 + {% for category in facets.categories %} 704 + <label class="filter-checkbox"> 705 + <input type="checkbox" 706 + name="category" 707 + value="{{ category.name }}" 708 + {% if category.selected %}checked{% endif %}> 709 + {{ t(key=category.i18n_key, locale=locale, default=category.name) }} ({{ category.count }}) 710 + </label> 711 + {% endfor %} 712 + </div> 713 + 714 + <!-- Other filter sections --> 715 + </form> 716 + </div> 717 + 718 + <div id="events-results"> 719 + {% include "events/results.html" %} 720 + </div> 721 + ``` 722 + 723 + ## HTTP Handler Implementation 724 + 725 + ```rust 726 + pub async fn list_events( 727 + ctx: UserRequestContext, 728 + filter_criteria: Extension<EventFilterCriteria>, 729 + ) -> impl IntoResponse { 730 + let is_htmx = is_htmx_request(&ctx.request); 731 + 732 + // Filter & hydrate events 733 + let filter_service = EventFilterService::new( 734 + ctx.web_context.pool.clone(), 735 + ctx.web_context.http_client.clone(), 736 + ctx.web_context.cache_pool.clone() 737 + ); 738 + 739 + let results = match filter_service.filter_and_hydrate( 740 + &filter_criteria, 741 + &ctx.language.0.to_string() 742 + ).await { 743 + Ok(r) => r, 744 + Err(e) => { 745 + tracing::error!(error = %e, "Failed to filter events"); 746 + return (StatusCode::INTERNAL_SERVER_ERROR, 747 + render_error_alert(&ctx, "error-filter-failed")).into_response(); 748 + } 749 + }; 750 + 751 + // Choose template based on request type 752 + let template_name = if is_htmx { 753 + format!("events/results.{}.html", ctx.language.0) 754 + } else { 755 + format!("events/index.{}.html", ctx.language.0) 756 + }; 757 + 758 + // Render with i18n 759 + render_with_i18n( 760 + ctx.web_context.engine.clone(), 761 + template_name, 762 + ctx.language.0, 763 + template_context! { 764 + events => results.events, 765 + facets => results.facets, 766 + search_term => filter_criteria.search_term, 767 + // Other context values... 768 + } 769 + ) 770 + } 771 + ``` 772 + 773 + ## Implementation Strategy 774 + 775 + The module should be implemented in phases: 776 + 777 + 1. **Phase 1**: Core filter criteria and query building 778 + - Define filter criteria types 779 + - Implement SQL query builder 780 + - Create basic middleware for extraction 781 + 782 + 2. **Phase 2**: Facet calculation and hydration 783 + - Implement facet calculation queries 784 + - Build ATproto hydration service 785 + - Set up basic templates 786 + 787 + 3. **Phase 3**: Cache integration 788 + - Integrate with Redis cache 789 + - Set up cache invalidation 790 + - Implement progressive caching 791 + 792 + 4. **Phase 4**: I18n integration 793 + - Add i18n keys to facets 794 + - Integrate with HTMX language propagation 795 + - Update templates to use i18n functions 796 + 797 + 5. **Phase 5**: UI refinement and optimization 798 + - Improve template responsiveness 799 + - Add mobile-friendly filters 800 + - Optimize performance 801 + 802 + ## Testing Requirements 803 + 804 + Tests should cover: 805 + 806 + 1. **Unit tests** for filter criteria extraction and query building 807 + ```rust 808 + #[test] 809 + fn test_location_filter_query_building() { 810 + // Test geographical filtering 811 + } 812 + ``` 813 + 814 + 2. **Integration tests** for facet calculation 815 + ```rust 816 + #[sqlx::test] 817 + async fn test_category_facets_calculation() { 818 + // Test facet calculation with sample data 819 + } 820 + ``` 821 + 822 + 3. **I18n tests** for facet translation 823 + ```rust 824 + #[test] 825 + fn test_facet_i18n_keys_generated_correctly() { 826 + // Test i18n key generation for facets 827 + } 828 + ``` 829 + 830 + 4. **Cache tests** for proper invalidation 831 + ```rust 832 + #[test] 833 + async fn test_cache_invalidation_on_event_update() { 834 + // Test cache keys are properly invalidated 835 + } 836 + ``` 837 + 838 + 5. **HTMX interaction** tests 839 + ```rust 840 + #[test] 841 + async fn test_htmx_filter_updates() { 842 + // Test HTMX responses contain correct headers 843 + } 844 + ``` 845 + 846 + ## Performance Considerations 847 + 848 + - Use batch loading for ATproto hydration 849 + - Apply tiered caching (facets vs. hydrated events) 850 + - Implement conditional facet calculation 851 + - Use optimized SQL queries with appropriate indexes 852 + - Consider adding JSONB GIN indexes on event categories 853 + 854 + ## Migration Plan 855 + 856 + When implementing this module: 857 + 858 + 1. Create a feature flag `event-filtering` to enable/disable the feature 859 + 2. Add a migration for geospatial indexes if needed 860 + 3. Deploy the core filtering features first, without facets 861 + 4. Add facets and i18n integration in subsequent releases 862 + 5. Implement advanced caching as a final optimization 863 + 864 + ## I18n Development Guidelines 865 + 866 + ### I18n Architecture Goals 867 + 868 + - **HTMX-first design**: Seamless language propagation across partial page updates 869 + - **Performance-optimized**: On-demand translation calculation instead of pre-rendering 870 + - **Romance language support**: Gender agreement (masculine/feminine/neutral) 871 + - **Fluent-based**: Mozilla Fluent for sophisticated translation features 872 + - **Template integration**: Direct i18n functions in Jinja2 templates 873 + 874 + ### Core Modules Structure 875 + 876 + ``` 877 + src/i18n/ 878 + ├── mod.rs # Main i18n exports and Locales struct 879 + ├── errors.rs # Structured error types for i18n operations 880 + ├── fluent_loader.rs # Fluent file loading (embed vs reload modes) 881 + └── template_helpers.rs # Template function integration 882 + 883 + src/http/ 884 + ├── middleware_i18n.rs # HTMX-aware language detection middleware 885 + ├── template_i18n.rs # Template context with gender support 886 + └── templates.rs # Template rendering with integrated i18n functions 887 + ``` 888 + 889 + ### Language Detection Priority 890 + 891 + Implement language detection with this exact priority order for HTMX compatibility: 892 + 893 + 1. **HX-Current-Language header** (highest priority for HTMX requests) 894 + 2. **User profile language** (if authenticated) 895 + 3. **lang cookie** (session preference) 896 + 4. **Accept-Language header** (browser preference) 897 + 5. **Default language** (fallback) 898 + 899 + ### Template Integration Pattern 900 + 901 + Replace pre-rendered translation HashMap with direct template functions: 902 + 903 + #### ❌ Avoid (pre-rendering approach) 904 + ```rust 905 + // Don't pre-calculate all translations 906 + let mut translations = HashMap::new(); 907 + translations.insert("profile-greeting".to_string(), i18n_context.tg(...)); 908 + ``` 909 + 910 + #### ✅ Use (on-demand functions) 911 + ```rust 912 + // Register i18n functions in template engine 913 + env.add_function("t", |args| { /* basic translation */ }); 914 + env.add_function("tg", |args| { /* gender-aware translation */ }); 915 + env.add_function("tc", |args| { /* count-based pluralization */ }); 916 + ``` 917 + 918 + ### HTMX Integration Requirements 919 + 920 + #### Middleware Implementation 921 + ```rust 922 + pub async fn htmx_language_middleware<B>(request: Request<B>, next: Next<B>) -> Response { 923 + let is_htmx = request.headers().get("HX-Request").is_some(); 924 + 925 + // Detect language with HTMX priority 926 + let locale = detect_language_with_htmx_priority(&request); 927 + 928 + // Inject into request extensions 929 + request.extensions_mut().insert(Language(locale.clone())); 930 + 931 + let mut response = next.run(request).await; 932 + 933 + // Add language propagation header for HTMX 934 + if is_htmx { 935 + response.headers_mut().insert("HX-Language", locale.to_string().parse().unwrap()); 936 + } 937 + 938 + response 939 + } 940 + ``` 941 + 942 + ### Gender Support 943 + 944 + ```rust 945 + #[derive(Debug, Clone)] 946 + pub enum Gender { 947 + Masculine, 948 + Feminine, 949 + Neutral, 950 + } 951 + 952 + impl Gender { 953 + pub fn as_str(&self) -> &'static str { 954 + match self { 955 + Gender::Masculine => "masculine", 956 + Gender::Feminine => "feminine", 957 + Gender::Neutral => "neutral", 958 + } 959 + } 960 + } 961 + ``` 962 + 963 + ### Fluent File Organization 964 + 965 + ``` 966 + i18n/ 967 + ├── en-us/ 968 + │ ├── common.ftl # Shared UI elements 969 + │ ├── errors.ftl # Error messages 970 + │ └── ui.ftl # Interface text 971 + └── fr-ca/ 972 + ├── common.ftl 973 + ├── errors.ftl 974 + └── ui.ftl 975 + ``` 976 + 977 + ### Error Handling 978 + 979 + All i18n error strings must follow this format: 980 + ``` 981 + error-smokesignal-i18n-<domain>-<number> <message>: <details> 982 + ``` 983 + 984 + Example errors: 985 + ``` 986 + error-smokesignal-i18n-fluent-1 Translation key not found: profile-greeting 987 + error-smokesignal-i18n-locale-2 Unsupported language identifier: xx-XX 988 + error-smokesignal-i18n-template-3 Template function argument missing: locale 989 + ``` 990 + 991 + ### Code Comments 992 + 993 + Keep all code comments in English: 994 + ```rust 995 + // Create i18n context with user-specific gender preferences 996 + let i18n_context = TemplateI18nContext::new(locale, locales) 997 + .with_gender(user_gender.unwrap_or(Gender::Neutral)); 998 + ``` 999 + 1000 + ### Ressources 1001 + 1002 + https://docs.rs/axum-template/3.0.0/axum_template/index.html 1003 + https://docs.rs/minijinja/latest/minijinja/index.html 1004 + https://github.com/projectfluent/fluent/wiki/
-2312
docs/i18n.md
··· 1 - gagnfra: Explique moi ce fichier en détails. soit court et concis pour un débutant 2 - 3 - GitHub Copilot: Ce fichier smokesignal.rs est le **point d'entrée principal** d'une application web Rust. Voici ce qu'il fait : 4 - 5 - ## Vue d'ensemble 6 - C'est un serveur web qui gère l'internationalisation (i18n) et utilise plusieurs technologies modernes. 7 - 8 - ## Étapes principales 9 - 10 - ### 1. **Configuration initiale** 11 - - Configure les logs avec `tracing` 12 - - Charge la configuration de l'app 13 - - Crée un client HTTP avec des certificats personnalisés 14 - 15 - ### 2. **Base de données** 16 - - Se connecte à PostgreSQL (`PgPool`) 17 - - Exécute les migrations de base de données 18 - - Se connecte à Redis pour le cache 19 - 20 - ### 3. **Internationalisation** 21 - - Supporte l'anglais américain (`en-us`) et le français canadien (`fr-ca`) 22 - - Charge les traductions selon le mode : 23 - - `embed` : traductions intégrées dans l'exécutable 24 - - `reload` : traductions rechargées dynamiquement 25 - 26 - ### 4. **Moteur de templates** 27 - - Configure Jinja2 pour générer les pages web 28 - - Intègre le système de traduction 29 - 30 - ### 5. **Serveur web** 31 - - Crée le contexte web avec toutes les dépendances 32 - - Lance le serveur sur le port configuré 33 - - Gère l'arrêt gracieux (Ctrl+C) 34 - 35 - ### 6. **Tâches en arrière-plan** 36 - - Lance une tâche pour rafraîchir les tokens OAuth 37 - - Utilise un système de tracking des tâches 38 - 39 - ## Technologies utilisées 40 - - **Axum** : framework web 41 - - **SQLx** : accès base de données 42 - - **Redis** : cache 43 - - **Tokio** : runtime asynchrone 44 - - **Jinja2** : moteur de templates 45 - 46 - C'est une architecture moderne et robuste pour une application web multilingue. 47 - 48 - gagnfra: explique moi chaqu'un des modules du stack 49 - 50 - GitHub Copilot: Voici l'explication de chaque module utilisé dans ce stack : 51 - 52 - ## **Modules Rust Standards** 53 - - **`anyhow`** : Gestion d'erreurs simplifiée avec `Result<()>` 54 - - **`std::sync::Arc`** : Partage de données entre threads de façon sécurisée 55 - - **`std::str::FromStr`** : Conversion de chaînes en types 56 - 57 - ## **Modules Temps & Async** 58 - - **`chrono`** : Manipulation de dates/heures (`Duration`) 59 - - **`tokio`** : Runtime asynchrone pour gérer les tâches concurrentes 60 - - **`tokio::net::TcpListener`** : Serveur TCP asynchrone 61 - - **`tokio::signal`** : Gestion des signaux système (Ctrl+C) 62 - - **`tokio_util::task::TaskTracker`** : Suivi des tâches en arrière-plan 63 - 64 - ## **Modules Web & HTTP** 65 - - **`axum`** : Framework web moderne pour Rust 66 - - **`reqwest`** : Client HTTP pour faire des requêtes externes 67 - 68 - ## **Modules Base de Données** 69 - - **`sqlx::PgPool`** : Connexion PostgreSQL avec pool de connexions 70 - - **`sqlx::migrate!()`** : Migrations automatiques de DB 71 - 72 - ## **Modules Cache & Storage** 73 - - **`smokesignal::storage::cache`** : Abstraction pour Redis 74 - - **Redis** : Cache en mémoire pour les performances 75 - 76 - ## **Modules Internationalisation** 77 - - **`unic_langid::LanguageIdentifier`** : Identifiants de langues (en-us, fr-ca) 78 - - **`smokesignal::i18n::Locales`** : Système de traductions 79 - 80 - ## **Modules Templates** 81 - - **Jinja2** (via `smokesignal::http::templates`) : Moteur de templates pour HTML 82 - - **Features `embed`/`reload`** : Compilation statique vs rechargement dynamique 83 - 84 - ## **Modules Réseau** 85 - - **`smokesignal::resolve`** : Résolveur DNS personnalisé 86 - - **Certificats SSL** : Gestion des CA certificates 87 - 88 - ## **Modules Monitoring** 89 - - **`tracing`** : Logs structurés et observabilité 90 - - **`tracing_subscriber`** : Configuration des logs 91 - 92 - ## **Modules Métier** 93 - - **`RefreshTokensTask`** : Tâche de rafraîchissement OAuth 94 - - **`WebContext`** : Contexte applicatif partagé 95 - - **`build_router`** : Configuration des routes web 96 - 97 - Chaque module a un rôle spécifique dans cette architecture moderne et scalable. 98 - 99 - gagnfra: Meme chose avec celui ci 100 - 101 - GitHub Copilot: Voici l'explication détaillée du module d'internationalisation (i18n.rs) : 102 - 103 - ## **Vue d'ensemble** 104 - Ce module gère le **système de traductions multilingues** en utilisant Mozilla Fluent, un système moderne d'internationalisation. 105 - 106 - ## **Dépendances principales** 107 - 108 - ### **Fluent Framework** 109 - - **`fluent::FluentBundle`** : Conteneur pour les traductions d'une langue 110 - - **`fluent::FluentResource`** : Fichier de traduction (.ftl) parsé 111 - - **`fluent::FluentArgs`** : Arguments pour les messages avec variables 112 - - **`intl_memoizer`** : Cache pour optimiser les performances 113 - 114 - ### **Autres modules** 115 - - **`unic_langid::LanguageIdentifier`** : Identifiants de langues (en-us, fr-ca) 116 - - **`rust_embed`** : Intégration de fichiers dans l'exécutable 117 - - **`thiserror`** : Gestion d'erreurs typées 118 - 119 - ## **Structure principale** 120 - 121 - ### **`Bundle`** (Type alias) 122 - ```rust 123 - type Bundle = FluentBundle<FluentResource, IntlLangMemoizer> 124 - ``` 125 - Un bundle contient toutes les traductions pour une langue donnée. 126 - 127 - ### **`Locales`** (Struct principale) 128 - ```rust 129 - struct Locales(HashMap<LanguageIdentifier, Bundle>) 130 - ``` 131 - - Stocke un bundle par langue supportée 132 - - Implémente `Clone` manuellement (Bundle ne l'implémente pas) 133 - 134 - ## **Méthodes principales** 135 - 136 - ### **`new()`** - Construction 137 - Crée un bundle vide pour chaque langue supportée. 138 - 139 - ### **`add_bundle()`** - Chargement 140 - - Parse un fichier `.ftl` (format Fluent) 141 - - Ajoute les traductions au bundle de la langue 142 - 143 - ### **`format_message()`** - Traduction 144 - - Récupère un message traduit par sa clé 145 - - Substitue les variables avec `FluentArgs` 146 - - Retourne la clé si traduction introuvable 147 - 148 - ### **`format_error()`** - Erreurs 149 - Version simplifiée pour les messages d'erreur. 150 - 151 - ## **Modes de chargement** 152 - 153 - ### **Mode `embed`** (Production) 154 - ```rust 155 - #[derive(Embed)] 156 - #[folder = "i18n/"] 157 - struct I18nAssets; 158 - ``` 159 - - Fichiers `.ftl` intégrés dans l'exécutable 160 - - Plus rapide, pas de I/O disque 161 - - Nécessite recompilation pour changer les traductions 162 - 163 - ### **Mode `reload`** (Développement) 164 - ```rust 165 - let source_file = locale_dir.join(format!("{}.ftl", file)); 166 - let content = std::fs::read(source_file)?; 167 - ``` 168 - - Lit les fichiers depuis le disque 169 - - Permet modification à chaud 170 - - Plus lent mais plus flexible 171 - 172 - ## **Types de fichiers** 173 - Charge 3 types de traductions pour chaque langue : 174 - - **`errors.ftl`** : Messages d'erreur backend 175 - - **`ui.ftl`** : Interface utilisateur 176 - - **`common.ftl`** : Messages communs 177 - 178 - ## **Gestion d'erreurs** 179 - ```rust 180 - enum I18nError { 181 - InvalidLanguage, // Langue non supportée 182 - LanguageResourceFailed, // Erreur parsing .ftl 183 - BundleLoadFailed, // Erreur chargement bundle 184 - } 185 - ``` 186 - 187 - ## **Architecture robuste** 188 - - **Fallback** : Retourne la clé si traduction manquante 189 - - **Thread-safe** : Utilise `concurrent::IntlLangMemoizer` 190 - - **Performance** : Cache intégré pour éviter re-parsing 191 - 192 - Ce système permet une internationalisation complète et performante de l'application. 193 - 194 - gagnfra: explique moi celui ci 195 - 196 - GitHub Copilot: Voici l'explication détaillée du module context.rs : 197 - 198 - ## **Vue d'ensemble** 199 - Ce module définit les **contextes partagés** entre les différentes parties de l'application web. Il centralise toutes les dépendances nécessaires aux handlers HTTP. 200 - 201 - ## **Structures principales** 202 - 203 - ### **`I18nContext`** - Contexte d'internationalisation 204 - ```rust 205 - struct I18nContext { 206 - supported_languages: Vec<LanguageIdentifier>, // Langues supportées 207 - locales: Arc<Locales>, // Traductions partagées 208 - } 209 - ``` 210 - Contient tout ce qui concerne les traductions. 211 - 212 - ### **`InnerWebContext`** - Contexte applicatif complet 213 - ```rust 214 - struct InnerWebContext { 215 - engine: AppEngine, // Moteur de templates (Jinja2) 216 - http_client: reqwest::Client, // Client HTTP externe 217 - pool: StoragePool, // Pool connexions PostgreSQL 218 - cache_pool: CachePool, // Pool connexions Redis 219 - config: Config, // Configuration app 220 - i18n_context: I18nContext, // Contexte traductions 221 - dns_resolver: TokioAsyncResolver, // Résolveur DNS 222 - } 223 - ``` 224 - Regroupe **toutes les dépendances** de l'application. 225 - 226 - ### **`WebContext`** - Wrapper thread-safe 227 - ```rust 228 - #[derive(Clone, FromRef)] 229 - struct WebContext(Arc<InnerWebContext>); 230 - ``` 231 - - Utilise `Arc` pour partage entre threads 232 - - `Clone` bon marché (juste un pointeur) 233 - - `FromRef` permet extraction automatique par Axum 234 - 235 - ## **Pattern de conception** 236 - 237 - ### **Deref Trait** 238 - ```rust 239 - impl Deref for WebContext { 240 - type Target = InnerWebContext; 241 - fn deref(&self) -> &Self::Target { &self.0 } 242 - } 243 - ``` 244 - Permet d'accéder directement aux champs : `context.pool` au lieu de `context.0.pool`. 245 - 246 - ## **Contextes spécialisés** 247 - 248 - ### **`AdminRequestContext`** - Handlers admin 249 - ```rust 250 - struct AdminRequestContext { 251 - web_context: WebContext, 252 - language: Language, // Langue de l'utilisateur 253 - admin_handle: Handle, // Utilisateur admin validé 254 - auth: Auth, // Infos d'authentification 255 - } 256 - ``` 257 - 258 - **Extraction automatique** : 259 - - Récupère le contexte web 260 - - Extrait la langue de la requête 261 - - **Valide que l'utilisateur est admin** 262 - - Retourne erreur si pas admin 263 - 264 - ### **`UserRequestContext`** - Handlers utilisateur 265 - ```rust 266 - struct UserRequestContext { 267 - web_context: WebContext, 268 - language: Language, 269 - current_handle: Option<Handle>, // Utilisateur connecté (optionnel) 270 - auth: Auth, 271 - } 272 - ``` 273 - 274 - Plus permissif, utilisateur peut être non-connecté. 275 - 276 - ## **Features conditionnelles** 277 - 278 - ### **Mode `embed` vs `reload`** 279 - ```rust 280 - #[cfg(feature = "embed")] 281 - type AppEngine = Engine<Environment<'static>>; 282 - 283 - #[cfg(feature = "reload")] 284 - type AppEngine = Engine<AutoReloader>; 285 - ``` 286 - - **Embed** : Templates compilés dans l'exécutable 287 - - **Reload** : Templates rechargés automatiquement 288 - 289 - ## **Intégration Axum** 290 - 291 - ### **`FromRequestParts`** 292 - Les contextes implémentent ce trait pour être **extraits automatiquement** dans les handlers : 293 - 294 - ```rust 295 - async fn admin_handler(ctx: AdminRequestContext) -> Response { 296 - // ctx contient automatiquement tout ce dont on a besoin 297 - } 298 - ``` 299 - 300 - ### **Validation automatique** 301 - - `AdminRequestContext` vérifie automatiquement les droits admin 302 - - Retourne erreur HTTP si validation échoue 303 - - Évite la duplication de code de validation 304 - 305 - ## **Helpers utilitaires** 306 - 307 - ### **`admin_template_context()`** 308 - ```rust 309 - fn admin_template_context(ctx: &AdminRequestContext, canonical_url: &str) -> Value 310 - ``` 311 - Crée un contexte standard pour les templates admin avec : 312 - - Langue courante 313 - - Handle utilisateur 314 - - URL canonique 315 - 316 - ## **Avantages de cette architecture** 317 - 318 - 1. **Centralisation** : Toutes les dépendances en un endroit 319 - 2. **Type safety** : Validation des permissions au niveau type 320 - 3. **Réutilisabilité** : Contextes spécialisés par cas d'usage 321 - 4. **Performance** : `Arc` évite les copies coûteuses 322 - 5. **Simplicité** : Extraction automatique par Axum 323 - 324 - Cette approche rend le code des handlers **très propre et sûr**. 325 - 326 - gagnfra: meme chose pour celui ci 327 - 328 - GitHub Copilot: Voici l'explication détaillée du module templates.rs : 329 - 330 - ## **Vue d'ensemble** 331 - Ce module gère le **rendu des templates HTML** avec support complet de l'internationalisation (i18n). Il utilise Jinja2 comme moteur de templates. 332 - 333 - ## **Dépendances principales** 334 - 335 - ### **Templates & Rendu** 336 - - **`axum_template::RenderHtml`** : Wrapper pour rendu HTML avec Axum 337 - - **`minijinja`** : Port Rust de Jinja2 (moteur de templates Python) 338 - - **`minijinja_autoreload`** : Rechargement automatique en développement 339 - - **`minijinja_embed`** : Intégration de templates dans l'exécutable 340 - 341 - ### **Internationalisation** 342 - - **`TemplateI18nContext`** : Contexte i18n avec genre/formalité 343 - - **`Gender`** : Masculin/Féminin/Neutre pour accords 344 - - **`Formality`** : Formel/Informel pour vouvoiement 345 - 346 - ## **Fonctions principales** 347 - 348 - ### **`render_alert()`** - Alertes simples 349 - ```rust 350 - fn render_alert(engine, language, message, context) -> Response 351 - ``` 352 - - Rendu rapide pour messages d'alerte 353 - - Template `prompt.{language}.html` 354 - - Contexte minimal avec juste le message 355 - 356 - ### **`render_with_i18n()`** - Rendu complet avec i18n 357 - **Fonction centrale** qui : 358 - 359 - 1. **Crée le contexte i18n** avec genre/formalité utilisateur 360 - 2. **Pré-calcule les traductions** courantes 361 - 3. **Extrait paramètres** du contexte (ex: handle du profil) 362 - 4. **Génère le contexte template** final 363 - 364 - #### **Traductions pré-calculées** 365 - ```rust 366 - translations.insert("profile-greeting", i18n_context.tgf("profile-greeting", ...)); 367 - translations.insert("follow-user", i18n_context.tf("follow-user", ...)); 368 - translations.insert("events-created", i18n_context.tc("events-created", 5, ...)); 369 - ``` 370 - 371 - - **`t()`** : Traduction simple 372 - - **`tf()`** : Traduction avec formalité 373 - - **`tgf()`** : Traduction avec genre + formalité 374 - - **`tc()`** : Traduction avec comptage (pluriels) 375 - 376 - #### **Contexte template enrichi** 377 - ```rust 378 - template_context! { 379 - locale => "en-us", 380 - user_gender => "neutral", 381 - user_formality => "informal", 382 - language => "en", 383 - region => "us", 384 - translations => {...}, 385 - ..additional_context 386 - } 387 - ``` 388 - 389 - ## **Modes de fonctionnement** 390 - 391 - ### **Mode `reload`** (Développement) 392 - ```rust 393 - AutoReloader::new(|notifier| { 394 - let template_path = PathBuf::from("templates"); 395 - env.set_loader(path_loader(&template_path)); 396 - notifier.watch_path(&template_path, true); 397 - }) 398 - ``` 399 - 400 - **Avantages** : 401 - - Rechargement automatique des templates 402 - - Modification à chaud sans restart 403 - - Surveillance du système de fichiers 404 - 405 - ### **Mode `embed`** (Production) 406 - ```rust 407 - minijinja_embed::load_templates!(&mut env); 408 - ``` 409 - 410 - **Avantages** : 411 - - Templates compilés dans l'exécutable 412 - - Pas d'I/O disque en runtime 413 - - Déploiement simplifié 414 - 415 - ## **Configuration du moteur** 416 - 417 - ### **Options Jinja2** 418 - ```rust 419 - env.set_trim_blocks(true); // Supprime espaces automatiquement 420 - env.set_lstrip_blocks(true); // Supprime indentation automatiquement 421 - ``` 422 - 423 - ### **Variables globales** 424 - ```rust 425 - env.add_global("base", "https://example.com"); // URL de base 426 - env.add_global("version", "1.0.0"); // Version app 427 - ``` 428 - 429 - ## **Architecture i18n avancée** 430 - 431 - ### **Support genre/formalité** 432 - ```rust 433 - let i18n_context = TemplateI18nContext::new(locale, locales) 434 - .with_gender(Gender::Neutral) 435 - .with_formality(Formality::Informal); 436 - ``` 437 - 438 - Permet des traductions comme : 439 - - **Français** : "Bienvenue" vs "Bienvenu·e" (genre) 440 - - **Français** : "tu" vs "vous" (formalité) 441 - 442 - ### **Paramètres dynamiques** 443 - ```rust 444 - let mut profile_subtitle_params = HashMap::new(); 445 - profile_subtitle_params.insert("handle", "john_doe"); 446 - i18n_context.tgf("profile-subtitle", None, None, Some(params)); 447 - ``` 448 - 449 - Traductions avec variables : `"Profil de {handle}"` 450 - 451 - ## **Debug & Logging** 452 - ```rust 453 - println!("DEBUG: Creating translations for locale: {}", locale); 454 - println!("DEBUG: Template context created with {} translations", ...); 455 - ``` 456 - 457 - Aide au débogage des problèmes de traduction. 458 - 459 - ## **Avantages de cette approche** 460 - 461 - 1. **Performance** : Traductions pré-calculées 462 - 2. **Flexibilité** : Support genre/formalité 463 - 3. **Maintenabilité** : Séparation claire logique/présentation 464 - 4. **Robustesse** : Fallbacks si traductions manquantes 465 - 5. **Développement** : Hot-reload en mode dev 466 - 467 - Cette architecture permet une **internationalisation sophistiquée** tout en gardant les templates simples. 468 - 469 - gagnfra: quest ce qi pourrait être amélioré dans ce fichier ? 470 - 471 - GitHub Copilot: Voici les améliorations possibles pour ce fichier templates.rs : 472 - 473 - ## **1. Performance & Mémoire** 474 - 475 - ### **Traductions pré-calculées inefficaces** 476 - ```rust 477 - // Problème : toutes les traductions sont calculées pour chaque rendu 478 - let mut translations = std::collections::HashMap::new(); 479 - translations.insert("profile-greeting".to_string(), i18n_context.tgf(...)); 480 - // ... 20+ traductions 481 - ``` 482 - 483 - **Amélioration** : Cache des traductions 484 - ```rust 485 - // Calculer une seule fois et réutiliser 486 - static TRANSLATION_CACHE: Lazy<DashMap<String, HashMap<String, String>>> = Lazy::new(DashMap::new); 487 - 488 - fn get_cached_translations(locale: &LanguageIdentifier, context: &TemplateI18nContext) -> HashMap<String, String> { 489 - let cache_key = format!("{}:{}:{}", locale, context.gender, context.formality); 490 - TRANSLATION_CACHE.entry(cache_key).or_insert_with(|| compute_translations(context)).clone() 491 - } 492 - ``` 493 - 494 - ## **2. Code dupliqué** 495 - 496 - ### **Logique répétitive** 497 - ```rust 498 - translations.insert("profile".to_string(), i18n_context.t("profile", None)); 499 - translations.insert("handle".to_string(), i18n_context.t("handle", None)); 500 - // ... répété 20+ fois 501 - ``` 502 - 503 - **Amélioration** : Macro ou fonction helper 504 - ```rust 505 - macro_rules! add_translations { 506 - ($map:expr, $ctx:expr, [$(($key:expr, $method:ident $(, $args:expr)*)),*]) => { 507 - $( 508 - $map.insert($key.to_string(), $ctx.$method($key $(, $args)*)); 509 - )* 510 - }; 511 - } 512 - 513 - // Usage 514 - add_translations!(translations, i18n_context, [ 515 - ("profile", t, None), 516 - ("handle", t, None), 517 - ("follow-user", tf, None, None), 518 - ("events-created", tc, 5, None) 519 - ]); 520 - ``` 521 - 522 - ## **3. Gestion d'erreurs** 523 - 524 - ### **Extraction fragile du contexte** 525 - ```rust 526 - // Peut paniquer si la structure change 527 - if let Ok(profile_value) = additional_context.get_attr("profile") { 528 - if let Ok(handle_value) = profile_value.get_attr("handle") { 529 - if let Some(handle) = handle_value.as_str() { 530 - profile_subtitle_params.insert("handle".to_string(), handle.to_string()); 531 - } 532 - } 533 - } 534 - ``` 535 - 536 - **Amélioration** : Helper sûr 537 - ```rust 538 - fn extract_nested_string(context: &minijinja::Value, path: &[&str]) -> Option<String> { 539 - let mut current = context; 540 - for &key in path { 541 - current = current.get_attr(key).ok()?; 542 - } 543 - current.as_str().map(|s| s.to_string()) 544 - } 545 - 546 - // Usage 547 - if let Some(handle) = extract_nested_string(&additional_context, &["profile", "handle"]) { 548 - profile_subtitle_params.insert("handle".to_string(), handle); 549 - } 550 - ``` 551 - 552 - ## **4. Configuration centralisée** 553 - 554 - ### **Traductions hardcodées** 555 - ```rust 556 - // Liste hardcodée dans le code 557 - translations.insert("profile-greeting".to_string(), ...); 558 - translations.insert("first-visit-title".to_string(), ...); 559 - ``` 560 - 561 - **Amélioration** : Configuration externe 562 - ```rust 563 - // translations_config.toml 564 - [common_translations] 565 - simple = ["profile", "handle", "actions"] 566 - with_formality = ["follow-user", "profile-greeting"] 567 - with_count = [ 568 - { key = "events-created", default_count = 5 }, 569 - { key = "rsvps-made", default_count = 12 } 570 - ] 571 - ``` 572 - 573 - ## **5. Debug & Logging** 574 - 575 - ### **println! en production** 576 - ```rust 577 - println!("DEBUG: Creating translations for locale: {}", locale); 578 - ``` 579 - 580 - **Amélioration** : Logging conditionnel 581 - ```rust 582 - tracing::debug!("Creating translations for locale: {}", locale); 583 - #[cfg(debug_assertions)] 584 - tracing::trace!("Template context: {:?}", template_context); 585 - ``` 586 - 587 - ## **6. Architecture modulaire** 588 - 589 - ### **Fonction trop longue** 590 - La fonction `render_with_i18n` fait trop de choses (90+ lignes). 591 - 592 - **Amélioration** : Découpage 593 - ```rust 594 - struct TemplateRenderer { 595 - translation_cache: Arc<DashMap<String, HashMap<String, String>>>, 596 - } 597 - 598 - impl TemplateRenderer { 599 - fn prepare_translations(&self, context: &TemplateI18nContext) -> HashMap<String, String> { ... } 600 - fn extract_parameters(&self, context: &minijinja::Value) -> HashMap<String, String> { ... } 601 - fn build_template_context(&self, ...) -> minijinja::Value { ... } 602 - 603 - pub fn render_with_i18n(&self, ...) -> impl IntoResponse { 604 - let translations = self.prepare_translations(&i18n_context); 605 - let params = self.extract_parameters(&additional_context); 606 - let context = self.build_template_context(...); 607 - RenderHtml(template_name, engine, context) 608 - } 609 - } 610 - ``` 611 - 612 - ## **7. Type safety** 613 - 614 - ### **Clés de traduction en String** 615 - ```rust 616 - translations.insert("profile-greeting".to_string(), ...); 617 - ``` 618 - 619 - **Amélioration** : Enum typé 620 - ```rust 621 - #[derive(Debug, Clone, Copy)] 622 - enum TranslationKey { 623 - ProfileGreeting, 624 - FirstVisitTitle, 625 - Actions, 626 - } 627 - 628 - impl TranslationKey { 629 - fn as_str(&self) -> &'static str { 630 - match self { 631 - Self::ProfileGreeting => "profile-greeting", 632 - Self::FirstVisitTitle => "first-visit-title", 633 - Self::Actions => "actions", 634 - } 635 - } 636 - } 637 - ``` 638 - 639 - Ces améliorations rendraient le code plus **performant**, **maintenable** et **robuste**. 640 - 641 - gagnfra: les points 1 et 2 viennent du fair que le data doit être prérendu pour minijinja. serait il possible de se fier au fichier FTL ? 642 - 643 - GitHub Copilot: Excellente question ! Oui, il serait **beaucoup plus efficace** de s'appuyer directement sur les fichiers FTL plutôt que de pré-rendre toutes les traductions. 644 - 645 - ## **Problème actuel** 646 - ```rust 647 - // Pré-calcul inefficace de toutes les traductions 648 - let mut translations = HashMap::new(); 649 - translations.insert("profile-greeting".to_string(), i18n_context.tgf("profile-greeting", ...)); 650 - translations.insert("first-visit-title".to_string(), i18n_context.tgf("first-visit-title", ...)); 651 - // ... 20+ traductions calculées même si pas utilisées 652 - ``` 653 - 654 - ## **Solution optimale : Fonctions template intégrées** 655 - 656 - ### **1. Enregistrer les fonctions i18n dans MiniJinja** 657 - 658 - ```rust 659 - pub fn build_env_with_i18n( 660 - http_external: String, 661 - version: String, 662 - default_locale: LanguageIdentifier, 663 - locales: Arc<Locales> 664 - ) -> Environment<'static> { 665 - let mut env = build_env(http_external, version); 666 - 667 - // Créer les fonctions de traduction 668 - let locales_clone = locales.clone(); 669 - let default_locale_clone = default_locale.clone(); 670 - 671 - // Fonction de traduction de base 672 - env.add_function("t", move |key: String, args: Option<minijinja::Value>| -> String { 673 - let context = TemplateI18nContext::new(default_locale_clone.clone(), locales_clone.clone()); 674 - let args_map = value_to_hashmap(args); 675 - context.t(&key, args_map) 676 - }); 677 - 678 - // Fonction avec comptage 679 - env.add_function("tc", move |key: String, count: i32, args: Option<minijinja::Value>| -> String { 680 - let context = TemplateI18nContext::new(default_locale_clone.clone(), locales_clone.clone()); 681 - let args_map = value_to_hashmap(args); 682 - context.tc(&key, count, args_map) 683 - }); 684 - 685 - // Fonction avec genre 686 - env.add_function("tg", move |key: String, gender: Option<String>, args: Option<minijinja::Value>| -> String { 687 - let context = TemplateI18nContext::new(default_locale_clone.clone(), locales_clone.clone()); 688 - let gender_enum = gender.map(|g| Gender::from_str(&g)); 689 - let args_map = value_to_hashmap(args); 690 - context.tg(&key, gender_enum, args_map) 691 - }); 692 - 693 - // Fonction avec formalité 694 - env.add_function("tf", move |key: String, formality: Option<String>, args: Option<minijinja::Value>| -> String { 695 - let context = TemplateI18nContext::new(default_locale_clone.clone(), locales_clone.clone()); 696 - let formality_enum = formality.map(|f| Formality::from_str(&f)); 697 - let args_map = value_to_hashmap(args); 698 - context.tf(&key, formality_enum, args_map) 699 - }); 700 - 701 - // Fonction genre + formalité 702 - env.add_function("tgf", move |key: String, gender: Option<String>, formality: Option<String>, args: Option<minijinja::Value>| -> String { 703 - let context = TemplateI18nContext::new(default_locale_clone.clone(), locales_clone.clone()); 704 - let gender_enum = gender.map(|g| Gender::from_str(&g)); 705 - let formality_enum = formality.map(|f| Formality::from_str(&f)); 706 - let args_map = value_to_hashmap(args); 707 - context.tgf(&key, gender_enum, formality_enum, args_map) 708 - }); 709 - 710 - env 711 - } 712 - 713 - fn value_to_hashmap(value: Option<minijinja::Value>) -> Option<HashMap<String, String>> { 714 - value?.as_object()?.iter() 715 - .map(|(k, v)| (k.to_string(), v.to_string())) 716 - .collect::<HashMap<_, _>>() 717 - .into() 718 - } 719 - ``` 720 - 721 - ### **2. Usage direct dans les templates** 722 - 723 - ```html 724 - <!-- Au lieu de {{ translations.profile_greeting }} --> 725 - {{ tgf("profile-greeting", user_gender, user_formality) }} 726 - 727 - <!-- Traduction avec paramètres --> 728 - {{ t("welcome-user", {"name": current_handle.display_name}) }} 729 - 730 - <!-- Comptage --> 731 - {{ tc("events-created", profile.event_count) }} 732 - 733 - <!-- Conditionnel avec genre --> 734 - {% if user_gender == "feminine" %} 735 - {{ tg("welcome-message", "feminine") }} 736 - {% else %} 737 - {{ tg("welcome-message", "masculine") }} 738 - {% endif %} 739 - ``` 740 - 741 - ### **3. Contexte template simplifié** 742 - 743 - ```rust 744 - pub fn render_with_i18n<E: TemplateEngine>( 745 - engine: E, 746 - template_name: String, 747 - locale: LanguageIdentifier, 748 - locales: Arc<Locales>, 749 - user_gender: Option<Gender>, 750 - user_formality: Option<Formality>, 751 - additional_context: minijinja::Value, 752 - ) -> impl IntoResponse { 753 - // Plus besoin de pré-calculer les traductions ! 754 - let template_context = template_context! { 755 - locale => locale.to_string(), 756 - user_gender => user_gender.as_ref().map(|g| g.as_str()).unwrap_or("neutral"), 757 - user_formality => user_formality.as_ref().map(|f| f.as_str()).unwrap_or("informal"), 758 - language => locale.language.as_str(), 759 - region => locale.region.as_ref().map(|r| r.as_str()).unwrap_or(""), 760 - ..additional_context 761 - }; 762 - 763 - RenderHtml(template_name, engine, template_context) 764 - } 765 - ``` 766 - 767 - ## **Avantages de cette approche** 768 - 769 - ### **Performance** 770 - - ✅ **Calcul à la demande** : seules les traductions utilisées sont calculées 771 - - ✅ **Cache Fluent** : Les bundles Fluent ont leur propre cache interne 772 - - ✅ **Pas de pré-calcul** : Élimination du HashMap de traductions 773 - 774 - ### **Maintenabilité** 775 - - ✅ **DRY** : Plus de duplication de clés 776 - - ✅ **Fichiers FTL source de vérité** : Seuls les .ftl définissent les traductions 777 - - ✅ **Type safety** : Erreurs de clés détectées au runtime dans les templates 778 - 779 - ### **Flexibilité** 780 - - ✅ **Traductions dynamiques** : Paramètres calculés dans le template 781 - - ✅ **Logique conditionnelle** : Genre/formalité déterminés dans le template 782 - - ✅ **Réutilisabilité** : Templates plus génériques 783 - 784 - ### **Exemple de fichier FTL** 785 - ```ftl 786 - # common.ftl 787 - profile-greeting = Hello 788 - profile-greeting-feminine = Hello miss 789 - profile-greeting-masculine = Hello sir 790 - profile-greeting-feminine-formal = Good day madam 791 - profile-greeting-masculine-formal = Good day sir 792 - 793 - welcome-user = Welcome {$name}! 794 - 795 - events-created = { $count -> 796 - [0] No events created 797 - [1] One event created 798 - *[other] {$count} events created 799 - } 800 - ``` 801 - 802 - Cette approche est **beaucoup plus élégante** et performante que le pré-rendu actuel ! 803 - 804 - gagnfra: est-ce qu'il fonctionneras dans le cas de relaod htmx boostés 805 - 806 - GitHub Copilot: **Excellente question !** Il y a effectivement des défis spécifiques avec **HTMX + reload** pour le système i18n. 807 - 808 - ## **Problèmes potentiels** 809 - 810 - ### **1. État de la langue perdu entre requêtes HTMX** 811 - ```html 812 - <!-- Requête initiale en français --> 813 - <div hx-get="/profile" hx-target="#content"> 814 - <!-- Si le serveur ne maintient pas le contexte langue --> 815 - <!-- La réponse HTMX pourrait revenir en anglais --> 816 - </div> 817 - ``` 818 - 819 - ### **2. Contexte i18n non propagé** 820 - ```rust 821 - // Dans un handler HTMX 822 - async fn profile_partial(ctx: UserRequestContext) -> Response { 823 - // ❌ Problème: quel locale utiliser pour ce fragment ? 824 - render_with_i18n(engine, "profile_partial.html", ???, ...) 825 - } 826 - ``` 827 - 828 - ## **Solutions recommandées** 829 - 830 - ### **1. Middleware de détection automatique** 831 - ```rust 832 - // middleware_i18n.rs 833 - pub async fn language_middleware<B>( 834 - request: Request<B>, 835 - next: Next<B>, 836 - ) -> Response { 837 - // Priorité de détection 838 - let locale = detect_language(&request); 839 - 840 - // Injecter dans les headers de réponse HTMX 841 - let mut response = next.run(request).await; 842 - 843 - if is_htmx_request(&request) { 844 - response.headers_mut().insert( 845 - "HX-Language", 846 - HeaderValue::from_str(&locale.to_string()).unwrap() 847 - ); 848 - } 849 - 850 - response 851 - } 852 - 853 - fn detect_language(request: &Request) -> LanguageIdentifier { 854 - // 1. Header HTMX personnalisé (reqêtes suivantes) 855 - if let Some(lang) = request.headers().get("HX-Current-Language") { 856 - if let Ok(lang_str) = lang.to_str() { 857 - if let Ok(locale) = LanguageIdentifier::from_str(lang_str) { 858 - return locale; 859 - } 860 - } 861 - } 862 - 863 - // 2. Cookie de session 864 - if let Some(cookie_lang) = extract_language_from_cookies(request) { 865 - return cookie_lang; 866 - } 867 - 868 - // 3. Accept-Language header 869 - if let Some(accept_lang) = extract_from_accept_language(request) { 870 - return accept_lang; 871 - } 872 - 873 - // 4. Fallback 874 - "en-us".parse().unwrap() 875 - } 876 - ``` 877 - 878 - ### **2. HTMX avec propagation de langue** 879 - ```html 880 - <!-- Template de base --> 881 - <div hx-ext="language-propagator" data-current-language="{{ locale }}"> 882 - <button hx-get="/profile" 883 - hx-headers='{"HX-Current-Language": "{{ locale }}"}'> 884 - {{ t("view-profile") }} 885 - </button> 886 - </div> 887 - 888 - <!-- Extension HTMX JavaScript --> 889 - <script> 890 - htmx.defineExtension('language-propagator', { 891 - onEvent: function(name, evt) { 892 - if (name === 'htmx:configRequest') { 893 - const currentLang = evt.target.closest('[data-current-language]') 894 - ?.dataset.currentLanguage; 895 - if (currentLang) { 896 - evt.detail.headers['HX-Current-Language'] = currentLang; 897 - } 898 - } 899 - } 900 - }); 901 - </script> 902 - ``` 903 - 904 - ### **3. Context enrichi pour HTMX** 905 - ```rust 906 - // context.rs 907 - impl FromRequestParts<S> for UserRequestContext 908 - where 909 - S: Send + Sync, 910 - WebContext: FromRef<S>, 911 - { 912 - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 913 - let web_context = WebContext::from_ref(state); 914 - 915 - // Détection spéciale pour HTMX 916 - let language = if is_htmx_request(parts) { 917 - // Utiliser header personnalisé ou cookie 918 - Language::from_htmx_request(parts, state).await 919 - } else { 920 - // Détection standard 921 - Language::from_request_parts(parts, state).await 922 - }?; 923 - 924 - Ok(Self { 925 - web_context, 926 - language, 927 - // ... 928 - }) 929 - } 930 - } 931 - 932 - impl Language { 933 - async fn from_htmx_request<S>(parts: &Parts, _state: &S) -> Result<Self, Response> { 934 - // 1. Header HX-Current-Language 935 - if let Some(lang_header) = parts.headers.get("HX-Current-Language") { 936 - if let Ok(lang_str) = lang_header.to_str() { 937 - if let Ok(locale) = LanguageIdentifier::from_str(lang_str) { 938 - return Ok(Language(locale)); 939 - } 940 - } 941 - } 942 - 943 - // 2. Cookie fallback 944 - if let Some(cookies) = parts.headers.get(header::COOKIE) { 945 - // Parser le cookie de langue 946 - } 947 - 948 - // 3. Défaut 949 - Ok(Language("en-us".parse().unwrap())) 950 - } 951 - } 952 - ``` 953 - 954 - ### **4. Templates adaptés pour HTMX** 955 - ```rust 956 - // templates.rs - Version HTMX-aware 957 - pub fn render_htmx_with_i18n<E: TemplateEngine>( 958 - engine: E, 959 - template_name: String, 960 - locale: LanguageIdentifier, 961 - locales: Arc<Locales>, 962 - is_htmx: bool, 963 - additional_context: minijinja::Value, 964 - ) -> impl IntoResponse { 965 - let template_context = template_context! { 966 - locale => locale.to_string(), 967 - is_htmx => is_htmx, 968 - ..additional_context 969 - }; 970 - 971 - let mut response = RenderHtml(template_name, engine, template_context).into_response(); 972 - 973 - // Ajouter headers HTMX pour maintenir l'état 974 - if is_htmx { 975 - response.headers_mut().insert( 976 - "HX-Language", 977 - HeaderValue::from_str(&locale.to_string()).unwrap() 978 - ); 979 - } 980 - 981 - response 982 - } 983 - ``` 984 - 985 - ### **5. Handlers HTMX avec langue maintenue** 986 - ```rust 987 - // handlers/profile.rs 988 - pub async fn profile_partial( 989 - ctx: UserRequestContext, 990 - HxRequest: HxRequest, // Extracteur HTMX 991 - ) -> impl IntoResponse { 992 - let profile = get_user_profile(&ctx).await?; 993 - 994 - render_htmx_with_i18n( 995 - ctx.web_context.engine.clone(), 996 - "partials/profile.html".to_string(), 997 - ctx.language.0, // Langue détectée automatiquement 998 - ctx.web_context.i18n_context.locales.clone(), 999 - true, // is_htmx 1000 - template_context! { 1001 - profile => profile, 1002 - } 1003 - ) 1004 - } 1005 - ``` 1006 - 1007 - ## **Templates avec fonctions i18n intégrées** 1008 - 1009 - ```html 1010 - <!-- partials/profile.html --> 1011 - <div class="profile-card" data-current-language="{{ locale }}"> 1012 - <h2>{{ t("profile-title") }}</h2> 1013 - <p>{{ tgf("welcome-message", user_gender, user_formality) }}</p> 1014 - 1015 - <!-- Boutons HTMX avec propagation de langue --> 1016 - <button hx-post="/follow/{{ profile.handle }}" 1017 - hx-headers='{"HX-Current-Language": "{{ locale }}"}'> 1018 - {{ t("follow-user") }} 1019 - </button> 1020 - </div> 1021 - ``` 1022 - 1023 - ## **Résultat** 1024 - 1025 - ✅ **Fonctionne parfaitement** avec HTMX + reload car : 1026 - 1027 - 1. **Détection automatique** de la langue via middleware 1028 - 2. **Propagation** via headers HTTP personnalisés 1029 - 3. **Fonctions i18n** disponibles dans tous les templates 1030 - 4. **État maintenu** entre requêtes partielles 1031 - 5. **Fallbacks robustes** si détection échoue 1032 - 1033 - Cette approche est **beaucoup plus robuste** que le pré-rendu actuel pour HTMX ! 1034 - 1035 - gagnfra: tous les formulaires présentement utilisent htmx pour mettre a jour la page. avec ta connaissance de cette discussion, quel serait alors la meilleure approche ? 1036 - 1037 - GitHub Copilot: Avec **tous les formulaires utilisant HTMX**, voici la **meilleure approche** basée sur notre discussion : 1038 - 1039 - ## **Architecture recommandée : Fonctions i18n intégrées + Context HTMX-aware** 1040 - 1041 - ### **1. Fonctions template globales (au lieu du pré-rendu)** 1042 - 1043 - ```rust 1044 - // templates.rs - Version optimisée pour HTMX 1045 - pub fn build_env_with_i18n( 1046 - http_external: String, 1047 - version: String, 1048 - default_locale: LanguageIdentifier, 1049 - locales: Arc<Locales> 1050 - ) -> Environment<'static> { 1051 - let mut env = build_env(http_external, version); 1052 - 1053 - // Fonctions i18n globales avec closure sur locale par défaut 1054 - let locales_clone = locales.clone(); 1055 - let default_locale_clone = default_locale.clone(); 1056 - 1057 - env.add_function("t", move |args: minijinja::Value| -> Result<String, minijinja::Error> { 1058 - let key = args.get_attr("key")?.to_string(); 1059 - let locale_str = args.get_attr("locale").ok() 1060 - .and_then(|v| v.as_str()) 1061 - .unwrap_or(&default_locale_clone.to_string()); 1062 - let locale = LanguageIdentifier::from_str(locale_str).unwrap_or(default_locale_clone.clone()); 1063 - 1064 - let context = TemplateI18nContext::new(locale, locales_clone.clone()); 1065 - Ok(context.t(&key, extract_args(&args))) 1066 - }); 1067 - 1068 - env.add_function("tgf", move |args: minijinja::Value| -> Result<String, minijinja::Error> { 1069 - let key = args.get_attr("key")?.to_string(); 1070 - let locale_str = args.get_attr("locale").ok() 1071 - .and_then(|v| v.as_str()) 1072 - .unwrap_or(&default_locale_clone.to_string()); 1073 - let locale = LanguageIdentifier::from_str(locale_str).unwrap_or(default_locale_clone.clone()); 1074 - 1075 - let gender = args.get_attr("gender").ok() 1076 - .and_then(|v| v.as_str()) 1077 - .map(Gender::from_str); 1078 - let formality = args.get_attr("formality").ok() 1079 - .and_then(|v| v.as_str()) 1080 - .map(Formality::from_str); 1081 - 1082 - let context = TemplateI18nContext::new(locale, locales_clone.clone()); 1083 - Ok(context.tgf(&key, gender, formality, extract_args(&args))) 1084 - }); 1085 - 1086 - // ... autres fonctions tc, tg, tf 1087 - 1088 - env 1089 - } 1090 - 1091 - fn extract_args(value: &minijinja::Value) -> Option<HashMap<String, String>> { 1092 - value.get_attr("args").ok()? 1093 - .as_object()? 1094 - .iter() 1095 - .map(|(k, v)| (k.to_string(), v.to_string())) 1096 - .collect::<HashMap<_, _>>() 1097 - .into() 1098 - } 1099 - ``` 1100 - 1101 - ### **2. Contexte HTMX-aware simplifié** 1102 - 1103 - ```rust 1104 - // templates.rs - Rendu optimisé 1105 - pub fn render_with_i18n<E: TemplateEngine>( 1106 - engine: E, 1107 - template_name: String, 1108 - locale: LanguageIdentifier, 1109 - user_gender: Option<Gender>, 1110 - user_formality: Option<Formality>, 1111 - additional_context: minijinja::Value, 1112 - ) -> impl IntoResponse { 1113 - // Contexte minimal - les traductions sont calculées à la demande 1114 - let template_context = template_context! { 1115 - locale => locale.to_string(), 1116 - user_gender => user_gender.as_ref().map(|g| g.as_str()).unwrap_or("neutral"), 1117 - user_formality => user_formality.as_ref().map(|f| f.as_str()).unwrap_or("informal"), 1118 - language => locale.language.as_str(), 1119 - region => locale.region.as_ref().map(|r| r.as_str()).unwrap_or(""), 1120 - ..additional_context 1121 - }; 1122 - 1123 - RenderHtml(template_name, engine, template_context) 1124 - } 1125 - ``` 1126 - 1127 - ### **3. Middleware HTMX pour propagation de langue** 1128 - 1129 - ```rust 1130 - // middleware_i18n.rs 1131 - pub async fn htmx_language_middleware<B>( 1132 - mut request: Request<B>, 1133 - next: Next<B>, 1134 - ) -> Response { 1135 - let is_htmx = request.headers().get("HX-Request").is_some(); 1136 - 1137 - // Détection de langue pour HTMX 1138 - let locale = if is_htmx { 1139 - // 1. Header personnalisé HTMX 1140 - request.headers() 1141 - .get("HX-Current-Language") 1142 - .and_then(|h| h.to_str().ok()) 1143 - .and_then(|s| LanguageIdentifier::from_str(s).ok()) 1144 - } else { 1145 - None 1146 - }.or_else(|| { 1147 - // 2. Cookie session 1148 - extract_locale_from_cookies(&request) 1149 - }).or_else(|| { 1150 - // 3. Accept-Language 1151 - extract_locale_from_accept_language(&request) 1152 - }).unwrap_or_else(|| "en-us".parse().unwrap()); 1153 - 1154 - // Injecter dans extensions pour extraction ultérieure 1155 - request.extensions_mut().insert(Language(locale.clone())); 1156 - 1157 - let mut response = next.run(request).await; 1158 - 1159 - // Pour réponses HTMX, ajouter header de langue 1160 - if is_htmx { 1161 - response.headers_mut().insert( 1162 - "HX-Language", 1163 - HeaderValue::from_str(&locale.to_string()).unwrap() 1164 - ); 1165 - } 1166 - 1167 - response 1168 - } 1169 - ``` 1170 - 1171 - ### **4. Templates HTMX avec i18n intégrée** 1172 - 1173 - ```html 1174 - <!-- form_profile.html - Formulaire avec HTMX --> 1175 - <form hx-post="/profile/update" 1176 - hx-target="#profile-content" 1177 - hx-headers='{"HX-Current-Language": "{{ locale }}"}'> 1178 - 1179 - <div class="field"> 1180 - <label>{{ t(key="display-name", locale=locale) }}</label> 1181 - <input name="display_name" 1182 - placeholder="{{ tgf(key="enter-name-placeholder", 1183 - locale=locale, 1184 - gender=user_gender, 1185 - formality=user_formality) }}" /> 1186 - </div> 1187 - 1188 - <button type="submit"> 1189 - {{ t(key="save-changes", locale=locale) }} 1190 - </button> 1191 - 1192 - <!-- Messages de validation en cas d'erreur --> 1193 - <div id="validation-errors" style="display: none;"> 1194 - <p class="error">{{ t(key="validation-error", locale=locale) }}</p> 1195 - </div> 1196 - </form> 1197 - ``` 1198 - 1199 - ### **5. Handlers de formulaires HTMX** 1200 - 1201 - ```rust 1202 - // handlers/profile.rs 1203 - pub async fn update_profile_htmx( 1204 - ctx: UserRequestContext, // Contient déjà la langue détectée 1205 - Form(form_data): Form<ProfileUpdateForm>, 1206 - ) -> impl IntoResponse { 1207 - match validate_and_update_profile(&ctx, form_data).await { 1208 - Ok(updated_profile) => { 1209 - // Succès - renvoyer le contenu mis à jour 1210 - render_with_i18n( 1211 - ctx.web_context.engine.clone(), 1212 - "partials/profile_content.html".to_string(), 1213 - ctx.language.0, 1214 - ctx.auth.user_gender(), 1215 - ctx.auth.user_formality(), 1216 - template_context! { 1217 - profile => updated_profile, 1218 - success_message => "profile-updated-successfully" 1219 - } 1220 - ) 1221 - }, 1222 - Err(validation_errors) => { 1223 - // Erreur - renvoyer le formulaire avec erreurs 1224 - let mut response = render_with_i18n( 1225 - ctx.web_context.engine.clone(), 1226 - "partials/profile_form.html".to_string(), 1227 - ctx.language.0, 1228 - ctx.auth.user_gender(), 1229 - ctx.auth.user_formality(), 1230 - template_context! { 1231 - errors => validation_errors, 1232 - form_data => form_data 1233 - } 1234 - ).into_response(); 1235 - 1236 - // Status 422 pour indiquer erreur de validation 1237 - *response.status_mut() = StatusCode::UNPROCESSABLE_ENTITY; 1238 - response 1239 - } 1240 - } 1241 - } 1242 - ``` 1243 - 1244 - ### **6. Partials avec traductions contextuelles** 1245 - 1246 - ```html 1247 - <!-- partials/profile_content.html --> 1248 - <div id="profile-content" data-current-language="{{ locale }}"> 1249 - <h2>{{ tgf(key="profile-title", locale=locale, gender=user_gender, formality=user_formality) }}</h2> 1250 - 1251 - {% if success_message %} 1252 - <div class="alert success"> 1253 - {{ t(key=success_message, locale=locale) }} 1254 - </div> 1255 - {% endif %} 1256 - 1257 - <div class="profile-info"> 1258 - <p><strong>{{ t(key="display-name", locale=locale) }}:</strong> {{ profile.display_name }}</p> 1259 - <p><strong>{{ t(key="handle", locale=locale) }}:</strong> @{{ profile.handle }}</p> 1260 - <p><strong>{{ t(key="member-since", locale=locale) }}:</strong> {{ profile.created_at|date }}</p> 1261 - </div> 1262 - 1263 - <button hx-get="/profile/edit" 1264 - hx-target="#profile-content" 1265 - hx-headers='{"HX-Current-Language": "{{ locale }}"}'> 1266 - {{ t(key="edit-profile", locale=locale) }} 1267 - </button> 1268 - </div> 1269 - ``` 1270 - 1271 - ## **Avantages de cette approche pour HTMX** 1272 - 1273 - ### **Performance optimale** 1274 - - ✅ **Calcul à la demande** : seules les traductions utilisées sont calculées 1275 - - ✅ **Pas de pré-rendu** : Élimination du HashMap de traductions 1276 - - ✅ **Cache Fluent natif** : Réutilisation du cache intégré 1277 - 1278 - ### **Maintenance simplifiée** 1279 - - ✅ **Source unique** : fichiers .ftl sont la seule source de vérité 1280 - - ✅ **DRY** : plus de duplication de clés de traduction 1281 - - ✅ **Contexte propagé** : langue maintenue entre requêtes HTMX 1282 - 1283 - ### **Robustesse HTMX** 1284 - - ✅ **Headers personnalisés** : langue propagée automatiquement 1285 - - ✅ **Fallbacks** : si langue manque, détection automatique 1286 - - ✅ **Validation d'erreurs** : messages traduits dans la bonne langue 1287 - 1288 - ### **Flexibilité Romance** 1289 - - ✅ **Genre/formalité** : support complet dans les templates 1290 - - ✅ **Pluralisation** : gérée nativement par Fluent 1291 - - ✅ **Paramètres dynamiques** : variables calculées côté template 1292 - 1293 - Cette architecture est **parfaite pour HTMX** car elle évite le sur-calcul tout en maintenant la consistance linguistique entre les requêtes partielles ! 1294 - 1295 - gagnfra: Estce que le code de ce middleware est conforme à ta proposition ? 1296 - 1297 - GitHub Copilot: **Non, ce middleware n'est pas conforme à ma proposition HTMX.** Il y a plusieurs éléments manquants pour supporter correctement HTMX : 1298 - 1299 - ## **Problèmes identifiés** 1300 - 1301 - ### **1. Pas de support HTMX headers** 1302 - ```rust 1303 - // ❌ Manquant : détection des headers HTMX 1304 - // Le middleware ne vérifie pas "HX-Current-Language" 1305 - ``` 1306 - 1307 - ### **2. Pas de middleware pour injection d'headers** 1308 - ```rust 1309 - // ❌ Manquant : injection de "HX-Language" dans la réponse 1310 - // Actuellement c'est juste un extracteur, pas un vrai middleware 1311 - ``` 1312 - 1313 - ### **3. Ordre de priorité non-optimal pour HTMX** 1314 - ```rust 1315 - // ❌ Ordre actuel : 1316 - // 1. Profil utilisateur 1317 - // 2. Cookie 1318 - // 3. Accept-Language 1319 - // 4. Défaut 1320 - 1321 - // ✅ Ordre optimal pour HTMX : 1322 - // 1. Header HX-Current-Language (priorité absolue) 1323 - // 2. Profil utilisateur 1324 - // 3. Cookie 1325 - // 4. Accept-Language 1326 - // 5. Défaut 1327 - ``` 1328 - 1329 - ## **Version corrigée conforme à ma proposition** 1330 - 1331 - ### **1. Extracteur Language amélioré** 1332 - ```rust 1333 - // middleware_i18n.rs 1334 - impl<S> FromRequestParts<S> for Language 1335 - where 1336 - WebContext: FromRef<S>, 1337 - S: Send + Sync, 1338 - { 1339 - type Rejection = Response; 1340 - 1341 - async fn from_request_parts(parts: &mut Parts, context: &S) -> Result<Self, Self::Rejection> { 1342 - trace!("Extracting Language from request"); 1343 - let web_context = WebContext::from_ref(context); 1344 - 1345 - // ✅ 1. PRIORITÉ ABSOLUE : Header HTMX (pour requêtes suivantes) 1346 - if let Some(htmx_lang) = parts.headers.get("HX-Current-Language") { 1347 - if let Ok(lang_str) = htmx_lang.to_str() { 1348 - if let Ok(locale) = LanguageIdentifier::from_str(lang_str) { 1349 - // Vérifier que la langue est supportée 1350 - for supported_lang in &web_context.i18n_context.supported_languages { 1351 - if supported_lang.matches(&locale, true, false) { 1352 - debug!(language = %supported_lang, "Using language from HTMX header"); 1353 - return Ok(Self(supported_lang.clone())); 1354 - } 1355 - } 1356 - } 1357 - } 1358 - } 1359 - 1360 - let auth: Auth = Cached::<Auth>::from_request_parts(parts, context).await?.0; 1361 - 1362 - // ✅ 2. Profil utilisateur (si connecté) 1363 - if let Some(handle) = &auth.0 { 1364 - if let Ok(auth_lang) = handle.language.parse::<LanguageIdentifier>() { 1365 - debug!(language = %auth_lang, "Using language from user profile"); 1366 - return Ok(Self(auth_lang)); 1367 - } 1368 - } 1369 - 1370 - // ✅ 3. Cookie de session 1371 - let cookie_jar = CookieJar::from_headers(&parts.headers); 1372 - if let Some(lang_cookie) = cookie_jar.get(COOKIE_LANG) { 1373 - // ... code existant inchangé 1374 - } 1375 - 1376 - // ✅ 4. Accept-Language header 1377 - // ... code existant inchangé 1378 - 1379 - // ✅ 5. Défaut 1380 - let default_lang = &web_context.i18n_context.supported_languages[0]; 1381 - debug!(language = %default_lang, "Using default language"); 1382 - Ok(Self(default_lang.clone())) 1383 - } 1384 - } 1385 - ``` 1386 - 1387 - ### **2. Vrai middleware pour HTMX** 1388 - ```rust 1389 - // middleware_i18n.rs - Ajout nécessaire 1390 - use axum::{ 1391 - middleware::Next, 1392 - http::{Request, HeaderValue}, 1393 - }; 1394 - 1395 - /// Middleware pour gérer la propagation de langue avec HTMX 1396 - pub async fn htmx_language_middleware<B>( 1397 - mut request: Request<B>, 1398 - next: Next<B>, 1399 - ) -> axum::response::Response { 1400 - let is_htmx = request.headers().get("HX-Request").is_some(); 1401 - 1402 - // Traiter la requête 1403 - let response = next.run(request).await; 1404 - 1405 - // Pour les réponses HTMX, ajouter header de langue 1406 - if is_htmx { 1407 - // Récupérer la langue détectée (stockée par l'extracteur Language) 1408 - if let Some(extensions) = response.extensions().get::<Language>() { 1409 - let mut response = response; 1410 - response.headers_mut().insert( 1411 - "HX-Language", 1412 - HeaderValue::from_str(&extensions.0.to_string()).unwrap_or_default() 1413 - ); 1414 - return response; 1415 - } 1416 - } 1417 - 1418 - response 1419 - } 1420 - ``` 1421 - 1422 - ### **3. Fonction utilitaire pour détecter HTMX** 1423 - ```rust 1424 - // middleware_i18n.rs - Helpers 1425 - pub fn is_htmx_request(parts: &Parts) -> bool { 1426 - parts.headers.get("HX-Request").is_some() 1427 - } 1428 - 1429 - pub fn extract_htmx_language(parts: &Parts) -> Option<LanguageIdentifier> { 1430 - parts.headers 1431 - .get("HX-Current-Language")? 1432 - .to_str().ok()? 1433 - .parse().ok() 1434 - } 1435 - ``` 1436 - 1437 - ### **4. Integration dans l'app principale** 1438 - ```rust 1439 - // Dans smokesignal.rs ou routes.rs 1440 - use crate::http::middleware_i18n::htmx_language_middleware; 1441 - 1442 - let app = Router::new() 1443 - .route("/profile", get(profile_handler)) 1444 - .route("/profile/update", post(update_profile_htmx)) 1445 - // ✅ Ajouter le middleware HTMX 1446 - .layer(middleware::from_fn(htmx_language_middleware)) 1447 - .with_state(web_context); 1448 - ``` 1449 - 1450 - ### **5. Templates avec propagation automatique** 1451 - ```html 1452 - <!-- Avec le middleware, plus besoin de spécifier manuellement --> 1453 - <form hx-post="/profile/update" hx-target="#profile-content"> 1454 - <!-- Le header HX-Current-Language sera ajouté automatiquement --> 1455 - <input name="display_name" placeholder="{{ t(key='enter-name', locale=locale) }}" /> 1456 - <button type="submit">{{ t(key='save', locale=locale) }}</button> 1457 - </form> 1458 - ``` 1459 - 1460 - ## **Résumé des améliorations nécessaires** 1461 - 1462 - 1. ✅ **Ajouter détection HX-Current-Language** (priorité #1) 1463 - 2. ✅ **Créer vrai middleware** pour injection headers 1464 - 3. ✅ **Réorganiser l'ordre de priorité** pour HTMX 1465 - 4. ✅ **Ajouter helpers** pour détecter requêtes HTMX 1466 - 5. ✅ **Intégrer dans l'app** avec layer middleware 1467 - 1468 - Le code actuel est un bon **extracteur**, mais il manque la **couche middleware** pour une intégration HTMX complète ! 1469 - 1470 - gagnfra: explique moi quelle est la différence entre bare base et index 1471 - 1472 - GitHub Copilot: Voici l'explication de la hiérarchie des templates : 1473 - 1474 - ## **Hiérarchie des templates** 1475 - 1476 - ### **base.en-us.html** - Template de base complet 1477 - ```html 1478 - <!doctype html> 1479 - <html> 1480 - <head> 1481 - <!-- HEAD complet avec CSS, JS, meta tags --> 1482 - <script src="/static/htmx.js"></script> 1483 - <!-- ... autres scripts --> 1484 - </head> 1485 - <body> 1486 - {% include 'nav.en-us.html' %} 1487 - {% block content %}{% endblock %} 1488 - {% include 'footer.en-us.html' %} 1489 - </body> 1490 - </html> 1491 - ``` 1492 - 1493 - **Usage** : Page complète avec `<html>`, navigation, footer 1494 - 1495 - --- 1496 - 1497 - ### **bare.en-us.html** - Template minimal sans HTML 1498 - ```html 1499 - {% include 'nav.en-us.html' %} 1500 - {% block content %}{% endblock %} 1501 - {% include 'footer.en-us.html' %} 1502 - ``` 1503 - 1504 - **Usage** : Fragment HTML pour **requêtes HTMX** (pas de `<html>`, `<head>`, etc.) 1505 - 1506 - --- 1507 - 1508 - ### **index.en-us.html** - Page d'accueil complète 1509 - ```html 1510 - {% extends "base.en-us.html" %} 1511 - {% block title %}Smoke Signal{% endblock %} 1512 - {% block head %} 1513 - <!-- Meta tags SEO spécifiques --> 1514 - <!-- Schema.org JSON-LD --> 1515 - {% endblock %} 1516 - {% block content %} 1517 - {% include 'index.en-us.common.html' %} 1518 - {% endblock %} 1519 - ``` 1520 - 1521 - **Usage** : **Première visite** - page complète avec SEO 1522 - 1523 - --- 1524 - 1525 - ### **index.en-us.bare.html** - Page d'accueil pour HTMX 1526 - ```html 1527 - {% extends "bare.en-us.html" %} 1528 - {% block content %} 1529 - {% include 'index.en-us.common.html' %} 1530 - {% endblock %} 1531 - ``` 1532 - 1533 - **Usage** : **Navigation HTMX** - même contenu, sans structure HTML complète 1534 - 1535 - --- 1536 - 1537 - ### **index.en-us.common.html** - Contenu réutilisable 1538 - ```html 1539 - <section class="section"> 1540 - <h1 class="title is-1">Smoke Signal</h1> 1541 - <!-- Contenu de la page d'accueil --> 1542 - </section> 1543 - ``` 1544 - 1545 - **Usage** : Contenu partagé entre version complète et HTMX 1546 - 1547 - ## **Pattern d'utilisation** 1548 - 1549 - ### **Première visite (URL directe)** 1550 - ``` 1551 - GET / → index.en-us.html 1552 - └── extends base.en-us.html 1553 - ├── <html><head>...</head> 1554 - ├── navigation 1555 - ├── index.en-us.common.html (contenu) 1556 - └── footer</html> 1557 - ``` 1558 - 1559 - ### **Navigation HTMX (SPA-like)** 1560 - ``` 1561 - GET / (avec HX-Request header) → index.en-us.bare.html 1562 - └── extends bare.en-us.html 1563 - ├── navigation (mise à jour) 1564 - ├── index.en-us.common.html (contenu) 1565 - └── footer (mise à jour) 1566 - ``` 1567 - 1568 - ### **Partials HTMX purs** 1569 - ``` 1570 - POST /import → import.en-us.partial.html 1571 - └── Fragment HTML pur (juste le contenu à remplacer) 1572 - ``` 1573 - 1574 - ## **Avantages de cette architecture** 1575 - 1576 - ### **Performance** 1577 - - ✅ **Première visite** : HTML complet avec SEO 1578 - - ✅ **Navigation** : Seulement le nécessaire via HTMX 1579 - - ✅ **Partials** : Fragments minimaux 1580 - 1581 - ### **SEO & Accessibilité** 1582 - - ✅ **URLs directes** fonctionnent (pas de SPA brisé) 1583 - - ✅ **Meta tags** complets pour les moteurs de recherche 1584 - - ✅ **Navigation** fonctionne sans JavaScript 1585 - 1586 - ### **Maintenabilité** 1587 - - ✅ **DRY** : contenu partagé via `.common.html` 1588 - - ✅ **Flexibilité** : même logique, différentes structures 1589 - - ✅ **Progressive enhancement** : fonctionne avec/sans HTMX 1590 - 1591 - ## **Détection côté serveur** 1592 - 1593 - ```rust 1594 - // Handler qui choisit le bon template 1595 - pub async fn index_handler( 1596 - ctx: UserRequestContext, 1597 - HxRequest(is_htmx): HxRequest, 1598 - ) -> impl IntoResponse { 1599 - let template_name = if is_htmx { 1600 - "index.en-us.bare.html" // Fragment pour HTMX 1601 - } else { 1602 - "index.en-us.html" // Page complète 1603 - }; 1604 - 1605 - render_with_i18n( 1606 - ctx.web_context.engine.clone(), 1607 - template_name.to_string(), 1608 - ctx.language.0, 1609 - // ... 1610 - ) 1611 - } 1612 - ``` 1613 - 1614 - Cette architecture permet une **expérience SPA fluide** tout en gardant un **site web traditionnel fonctionnel** ! 1615 - 1616 - gagnfra: donc si on suit la méthode mentionnée, avec ta proposition de passer le language via middleware les requete HTMX m'éviterons de pré-rendre le contenu traduit de fluent 1617 - 1618 - GitHub Copilot: **Exactement !** Avec ma proposition de middleware HTMX + fonctions i18n intégrées, vous **éliminez complètement le pré-rendu** et améliorez drastiquement les performances. 1619 - 1620 - ## **Transformation : Avant vs Après** 1621 - 1622 - ### **❌ Avant (code actuel)** 1623 - ```rust 1624 - // 20+ traductions pré-calculées pour CHAQUE rendu 1625 - translations.insert("profile-greeting".to_string(), i18n_context.tgf("profile-greeting", None, None, None)); 1626 - translations.insert("first-visit-title".to_string(), i18n_context.tgf("first-visit-title", None, None, None)); 1627 - translations.insert("actions".to_string(), i18n_context.t("actions", None)); 1628 - // ... 17 autres traductions calculées même si pas utilisées 1629 - ``` 1630 - 1631 - ### **✅ Après (ma proposition)** 1632 - ```rust 1633 - // Contexte minimal - traductions calculées à la demande 1634 - let template_context = template_context! { 1635 - locale => locale.to_string(), 1636 - user_gender => user_gender_str, 1637 - user_formality => user_formality_str, 1638 - ..additional_context 1639 - }; 1640 - // Fini le HashMap de traductions ! 1641 - ``` 1642 - 1643 - ## **Templates transformés** 1644 - 1645 - ### **❌ Avant** 1646 - ```html 1647 - <!-- Utilise traductions pré-rendues --> 1648 - <h1>{{ translations.profile_greeting }}</h1> 1649 - <button>{{ translations.follow_user }}</button> 1650 - <p>{{ translations.member_since }}</p> 1651 - ``` 1652 - 1653 - ### **✅ Après** 1654 - ```html 1655 - <!-- Calcul à la demande avec fonctions template --> 1656 - <h1>{{ tgf(key="profile-greeting", locale=locale, gender=user_gender, formality=user_formality) }}</h1> 1657 - <button>{{ tf(key="follow-user", locale=locale, formality=user_formality) }}</button> 1658 - <p>{{ t(key="member-since", locale=locale) }}</p> 1659 - ``` 1660 - 1661 - ## **Middleware HTMX pour propagation automatique** 1662 - 1663 - ```rust 1664 - // La langue est détectée UNE FOIS par le middleware 1665 - pub async fn htmx_language_middleware<B>(request: Request<B>, next: Next<B>) -> Response { 1666 - // Détection automatique de la langue (ordre optimal) 1667 - let locale = detect_language_for_htmx(&request); 1668 - 1669 - // Injection dans request pour extracteur Language 1670 - request.extensions_mut().insert(Language(locale.clone())); 1671 - 1672 - let mut response = next.run(request).await; 1673 - 1674 - // Pour HTMX, ajouter header de propagation 1675 - if is_htmx_request(&request) { 1676 - response.headers_mut().insert("HX-Language", locale.to_string().parse().unwrap()); 1677 - } 1678 - 1679 - response 1680 - } 1681 - ``` 1682 - 1683 - ## **Gain de performance massif** 1684 - 1685 - ### **Calculs évités par requête HTMX** 1686 - - ❌ **20+ traductions** calculées systématiquement 1687 - - ❌ **HashMap** de 20+ entrées créé 1688 - - ❌ **Clonage** de toutes les traductions 1689 - - ❌ **Extraction** de paramètres non utilisés 1690 - 1691 - ### **Calculs à la demande** 1692 - - ✅ **Seulement** les traductions utilisées dans le template 1693 - - ✅ **Cache Fluent** réutilisé automatiquement 1694 - - ✅ **Contexte minimal** dans les templates 1695 - - ✅ **Langue propagée** automatiquement 1696 - 1697 - ## **Exemple concret avec formulaire HTMX** 1698 - 1699 - ### **Template optimisé** 1700 - ```html 1701 - <!-- form_profile.html --> 1702 - <form hx-post="/profile/update" hx-target="#profile-content"> 1703 - <div class="field"> 1704 - <label>{{ t(key="display-name", locale=locale) }}</label> 1705 - <input name="display_name" 1706 - placeholder="{{ tgf(key="enter-name-placeholder", locale=locale, gender=user_gender, formality=user_formality) }}" /> 1707 - </div> 1708 - 1709 - <button type="submit"> 1710 - {{ t(key="save-changes", locale=locale) }} 1711 - </button> 1712 - </form> 1713 - ``` 1714 - 1715 - ### **Handler simplifié** 1716 - ```rust 1717 - pub async fn update_profile_htmx( 1718 - ctx: UserRequestContext, // Langue déjà détectée par middleware 1719 - Form(form_data): Form<ProfileUpdateForm>, 1720 - ) -> impl IntoResponse { 1721 - // Plus besoin de passer locales Arc, gender, formality séparément ! 1722 - render_with_i18n_optimized( 1723 - ctx.web_context.engine.clone(), 1724 - "partials/profile_form.html".to_string(), 1725 - ctx.language.0, // Détecté automatiquement (HTMX header -> cookie -> accept-language) 1726 - template_context! { 1727 - form_data => form_data, 1728 - errors => validation_errors 1729 - } 1730 - ) 1731 - } 1732 - ``` 1733 - 1734 - ## **Résultat final** 1735 - 1736 - ### **Performance** 1737 - - 🚀 **~80% moins de calculs** par requête HTMX 1738 - - 🚀 **Mémoire optimisée** (pas de HashMap de traductions) 1739 - - 🚀 **Cache Fluent natif** exploité 1740 - 1741 - ### **Maintenabilité** 1742 - - 🎯 **Source unique** : fichiers .ftl 1743 - - 🎯 **DRY** : plus de duplication de clés 1744 - - 🎯 **Templates plus lisibles** 1745 - 1746 - ### **Robustesse HTMX** 1747 - - 🔄 **Langue maintenue** entre requêtes 1748 - - 🔄 **Fallbacks automatiques** si détection échoue 1749 - - 🔄 **Headers propagés** transparents 1750 - 1751 - **Votre intuition est parfaite** : cette approche élimine le goulot d'étranglement du pré-rendu tout en améliorant l'expérience HTMX ! 1752 - 1753 - gagnfra: Parfait. Maintenant, fait un document de directives basé sur notre discussion destiné à un agent claude 4. soit détaillé mais concis sur les méthodes de développement à suivre (entre autres de garder les commentaires en anglais) pour revamper le i18n. nous partirons d'une version qui n'aura aucune intégration i18n. 1754 - 1755 - Je te fournis un exemple de ce que je veux mais avec notre projet. 1756 - 1757 - GitHub Copilot: # Smokesignal I18n Development Guidelines 1758 - 1759 - This document provides guidance for implementing comprehensive internationalization (i18n) in the Smokesignal web application using modern Rust patterns and HTMX integration. 1760 - 1761 - ## Project Overview 1762 - 1763 - Smokesignal is a Rust web application built with Axum that requires full internationalization support for multiple languages with advanced features including gender agreement and formality levels for Romance languages. 1764 - 1765 - ## I18n Architecture Goals 1766 - 1767 - - **HTMX-first design**: Seamless language propagation across partial page updates 1768 - - **Performance-optimized**: On-demand translation calculation instead of pre-rendering 1769 - - **Romance language support**: Gender agreement and formality levels (tu/vous) 1770 - - **Fluent-based**: Mozilla Fluent for sophisticated translation features 1771 - - **Template integration**: Direct i18n functions in Jinja2 templates 1772 - 1773 - ## Core Modules Structure 1774 - 1775 - ``` 1776 - src/i18n/ 1777 - ├── mod.rs # Main i18n exports and Locales struct 1778 - ├── errors.rs # Structured error types for i18n operations 1779 - ├── fluent_loader.rs # Fluent file loading (embed vs reload modes) 1780 - └── template_helpers.rs # Template function integration 1781 - 1782 - src/http/ 1783 - ├── middleware_i18n.rs # HTMX-aware language detection middleware 1784 - ├── template_i18n.rs # Template context with gender/formality support 1785 - └── templates.rs # Template rendering with integrated i18n functions 1786 - ``` 1787 - 1788 - ## Language Detection Priority 1789 - 1790 - Implement language detection with this exact priority order for HTMX compatibility: 1791 - 1792 - 1. **HX-Current-Language header** (highest priority for HTMX requests) 1793 - 2. **User profile language** (if authenticated) 1794 - 3. **lang cookie** (session preference) 1795 - 4. **Accept-Language header** (browser preference) 1796 - 5. **Default language** (fallback) 1797 - 1798 - ## Template Integration Pattern 1799 - 1800 - Replace pre-rendered translation HashMap with direct template functions: 1801 - 1802 - ### ❌ Avoid (pre-rendering approach) 1803 - ```rust 1804 - // Don't pre-calculate all translations 1805 - let mut translations = HashMap::new(); 1806 - translations.insert("profile-greeting".to_string(), i18n_context.tgf(...)); 1807 - ``` 1808 - 1809 - ### ✅ Use (on-demand functions) 1810 - ```rust 1811 - // Register i18n functions in template engine 1812 - env.add_function("t", |args| { /* basic translation */ }); 1813 - env.add_function("tgf", |args| { /* gender + formality */ }); 1814 - env.add_function("tc", |args| { /* count-based pluralization */ }); 1815 - ``` 1816 - 1817 - ### Template Usage 1818 - ```html 1819 - <!-- Direct function calls in templates --> 1820 - <h1>{{ tgf(key="profile-greeting", locale=locale, gender=user_gender, formality=user_formality) }}</h1> 1821 - <button>{{ t(key="save-changes", locale=locale) }}</button> 1822 - <p>{{ tc(key="events-created", locale=locale, count=event_count) }}</p> 1823 - ``` 1824 - 1825 - ## HTMX Integration Requirements 1826 - 1827 - ### Middleware Implementation 1828 - ```rust 1829 - pub async fn htmx_language_middleware<B>(request: Request<B>, next: Next<B>) -> Response { 1830 - let is_htmx = request.headers().get("HX-Request").is_some(); 1831 - 1832 - // Detect language with HTMX priority 1833 - let locale = detect_language_with_htmx_priority(&request); 1834 - 1835 - // Inject into request extensions 1836 - request.extensions_mut().insert(Language(locale.clone())); 1837 - 1838 - let mut response = next.run(request).await; 1839 - 1840 - // Add language propagation header for HTMX 1841 - if is_htmx { 1842 - response.headers_mut().insert("HX-Language", locale.to_string().parse().unwrap()); 1843 - } 1844 - 1845 - response 1846 - } 1847 - ``` 1848 - 1849 - ### Template Structure for HTMX 1850 - Support both full page loads and HTMX partials: 1851 - ``` 1852 - templates/ 1853 - ├── index.en-us.html # Full page (first visit) 1854 - ├── index.en-us.bare.html # HTMX navigation (no <html>) 1855 - ├── index.en-us.common.html # Shared content 1856 - └── partials/ 1857 - └── form.en-us.html # HTMX form fragments 1858 - ``` 1859 - 1860 - ## Error Handling 1861 - 1862 - All i18n error strings must follow this format: 1863 - ``` 1864 - error-smokesignal-i18n-<domain>-<number> <message>: <details> 1865 - ``` 1866 - 1867 - Example errors: 1868 - ``` 1869 - error-smokesignal-i18n-fluent-1 Translation key not found: profile-greeting 1870 - error-smokesignal-i18n-locale-2 Unsupported language identifier: xx-XX 1871 - error-smokesignal-i18n-template-3 Template function argument missing: locale 1872 - ``` 1873 - 1874 - Use structured error enums with `thiserror`: 1875 - ```rust 1876 - #[derive(Debug, Error)] 1877 - pub enum I18nError { 1878 - #[error("error-smokesignal-i18n-fluent-1 Translation key not found: {key}")] 1879 - TranslationKeyNotFound { key: String }, 1880 - 1881 - #[error("error-smokesignal-i18n-locale-2 Unsupported language identifier: {locale}")] 1882 - UnsupportedLocale { locale: String }, 1883 - } 1884 - ``` 1885 - 1886 - ## Configuration Management 1887 - 1888 - ### Feature Flags 1889 - ```toml 1890 - [features] 1891 - default = ["embed"] 1892 - embed = ["minijinja-embed"] # Production: templates in binary 1893 - reload = ["minijinja-autoreload"] # Development: hot reload 1894 - ``` 1895 - 1896 - ### Supported Languages 1897 - ```rust 1898 - pub const SUPPORTED_LANGUAGES: &[&str] = &["en-us", "fr-ca"]; 1899 - 1900 - pub fn create_supported_languages() -> Vec<LanguageIdentifier> { 1901 - SUPPORTED_LANGUAGES.iter() 1902 - .map(|lang| LanguageIdentifier::from_str(lang).unwrap()) 1903 - .collect() 1904 - } 1905 - ``` 1906 - 1907 - ## Fluent File Organization 1908 - 1909 - ``` 1910 - i18n/ 1911 - ├── en-us/ 1912 - │ ├── common.ftl # Shared UI elements 1913 - │ ├── errors.ftl # Error messages 1914 - │ └── ui.ftl # Interface text 1915 - └── fr-ca/ 1916 - ├── common.ftl 1917 - ├── errors.ftl 1918 - └── ui.ftl 1919 - ``` 1920 - 1921 - ### Fluent Syntax Examples 1922 - ```ftl 1923 - # Gender and formality variants 1924 - profile-greeting = Hello 1925 - profile-greeting-feminine = Hello miss 1926 - profile-greeting-masculine = Hello sir 1927 - profile-greeting-feminine-formal = Good day madam 1928 - profile-greeting-masculine-formal = Good day sir 1929 - 1930 - # Count-based pluralization 1931 - events-created = { $count -> 1932 - [0] No events created 1933 - [1] One event created 1934 - *[other] {$count} events created 1935 - } 1936 - 1937 - # Parameterized messages 1938 - welcome-user = Welcome {$name}! 1939 - ``` 1940 - 1941 - ## Performance Guidelines 1942 - 1943 - ### ✅ Do 1944 - - Use on-demand translation calculation 1945 - - Leverage Fluent's built-in caching 1946 - - Register template functions once at startup 1947 - - Minimal template context (just locale info) 1948 - 1949 - ### ❌ Don't 1950 - - Pre-render translation HashMaps 1951 - - Clone translation data unnecessarily 1952 - - Load all translations for every request 1953 - - Use `println!` for debugging (use `tracing::debug!`) 1954 - 1955 - ## Testing Requirements 1956 - 1957 - ```rust 1958 - #[cfg(test)] 1959 - mod tests { 1960 - use super::*; 1961 - 1962 - #[test] 1963 - fn test_language_detection_htmx_priority() { 1964 - // Test HX-Current-Language header takes priority 1965 - } 1966 - 1967 - #[test] 1968 - fn test_template_function_basic_translation() { 1969 - // Test t() function works correctly 1970 - } 1971 - 1972 - #[test] 1973 - fn test_gender_formality_combinations() { 1974 - // Test tgf() with all gender/formality combinations 1975 - } 1976 - } 1977 - ``` 1978 - 1979 - ## Logging 1980 - 1981 - Use structured logging with `tracing`: 1982 - ```rust 1983 - tracing::debug!(locale = %locale, "Language detected for request"); 1984 - tracing::trace!(key = %key, locale = %locale, "Translation requested"); 1985 - ``` 1986 - 1987 - Instrument async functions: 1988 - ```rust 1989 - #[tracing::instrument(skip(locales))] 1990 - pub async fn load_translations(locales: &mut Locales) -> Result<()> { 1991 - // Implementation 1992 - } 1993 - ``` 1994 - 1995 - ## Code Comments 1996 - 1997 - Keep all code comments in English: 1998 - ```rust 1999 - // Create i18n context with user-specific gender and formality preferences 2000 - let i18n_context = TemplateI18nContext::new(locale, locales) 2001 - .with_gender(user_gender.unwrap_or(Gender::Neutral)) 2002 - .with_formality(user_formality.unwrap_or(Formality::Informal)); 2003 - ``` 2004 - 2005 - ## Migration Strategy 2006 - 2007 - When starting from a version with no i18n integration: 2008 - 2009 - 1. **Phase 1**: Implement core `i18n` module with Fluent loading 2010 - 2. **Phase 2**: Add language detection middleware with HTMX support 2011 - 3. **Phase 3**: Integrate template functions and remove hardcoded strings 2012 - 4. **Phase 4**: Add gender/formality support for Romance languages 2013 - 5. **Phase 5**: Implement template hierarchy (base/bare/common) for HTMX 2014 - 2015 - Each phase should be fully tested and deployable independently. 2016 - 2017 - gagnfra: remove formality, we wont use it. but add gender neutral. 2018 - 2019 - GitHub Copilot: # Smokesignal I18n Development Guidelines 2020 - 2021 - This document provides guidance for implementing comprehensive internationalization (i18n) in the Smokesignal web application using modern Rust patterns and HTMX integration. 2022 - 2023 - ## Project Overview 2024 - 2025 - Smokesignal is a Rust web application built with Axum that requires full internationalization support for multiple languages with advanced features including gender agreement for Romance languages. 2026 - 2027 - ## I18n Architecture Goals 2028 - 2029 - - **HTMX-first design**: Seamless language propagation across partial page updates 2030 - - **Performance-optimized**: On-demand translation calculation instead of pre-rendering 2031 - - **Romance language support**: Gender agreement (masculine/feminine/neutral) 2032 - - **Fluent-based**: Mozilla Fluent for sophisticated translation features 2033 - - **Template integration**: Direct i18n functions in Jinja2 templates 2034 - 2035 - ## Core Modules Structure 2036 - 2037 - ``` 2038 - src/i18n/ 2039 - ├── mod.rs # Main i18n exports and Locales struct 2040 - ├── errors.rs # Structured error types for i18n operations 2041 - ├── fluent_loader.rs # Fluent file loading (embed vs reload modes) 2042 - └── template_helpers.rs # Template function integration 2043 - 2044 - src/http/ 2045 - ├── middleware_i18n.rs # HTMX-aware language detection middleware 2046 - ├── template_i18n.rs # Template context with gender support 2047 - └── templates.rs # Template rendering with integrated i18n functions 2048 - ``` 2049 - 2050 - ## Language Detection Priority 2051 - 2052 - Implement language detection with this exact priority order for HTMX compatibility: 2053 - 2054 - 1. **HX-Current-Language header** (highest priority for HTMX requests) 2055 - 2. **User profile language** (if authenticated) 2056 - 3. **lang cookie** (session preference) 2057 - 4. **Accept-Language header** (browser preference) 2058 - 5. **Default language** (fallback) 2059 - 2060 - ## Template Integration Pattern 2061 - 2062 - Replace pre-rendered translation HashMap with direct template functions: 2063 - 2064 - ### ❌ Avoid (pre-rendering approach) 2065 - ```rust 2066 - // Don't pre-calculate all translations 2067 - let mut translations = HashMap::new(); 2068 - translations.insert("profile-greeting".to_string(), i18n_context.tg(...)); 2069 - ``` 2070 - 2071 - ### ✅ Use (on-demand functions) 2072 - ```rust 2073 - // Register i18n functions in template engine 2074 - env.add_function("t", |args| { /* basic translation */ }); 2075 - env.add_function("tg", |args| { /* gender-aware translation */ }); 2076 - env.add_function("tc", |args| { /* count-based pluralization */ }); 2077 - ``` 2078 - 2079 - ### Template Usage 2080 - ```html 2081 - <!-- Direct function calls in templates --> 2082 - <h1>{{ tg(key="profile-greeting", locale=locale, gender=user_gender) }}</h1> 2083 - <button>{{ t(key="save-changes", locale=locale) }}</button> 2084 - <p>{{ tc(key="events-created", locale=locale, count=event_count) }}</p> 2085 - ``` 2086 - 2087 - ## HTMX Integration Requirements 2088 - 2089 - ### Middleware Implementation 2090 - ```rust 2091 - pub async fn htmx_language_middleware<B>(request: Request<B>, next: Next<B>) -> Response { 2092 - let is_htmx = request.headers().get("HX-Request").is_some(); 2093 - 2094 - // Detect language with HTMX priority 2095 - let locale = detect_language_with_htmx_priority(&request); 2096 - 2097 - // Inject into request extensions 2098 - request.extensions_mut().insert(Language(locale.clone())); 2099 - 2100 - let mut response = next.run(request).await; 2101 - 2102 - // Add language propagation header for HTMX 2103 - if is_htmx { 2104 - response.headers_mut().insert("HX-Language", locale.to_string().parse().unwrap()); 2105 - } 2106 - 2107 - response 2108 - } 2109 - ``` 2110 - 2111 - ### Template Structure for HTMX 2112 - Support both full page loads and HTMX partials: 2113 - ``` 2114 - templates/ 2115 - ├── index.en-us.html # Full page (first visit) 2116 - ├── index.en-us.bare.html # HTMX navigation (no <html>) 2117 - ├── index.en-us.common.html # Shared content 2118 - └── partials/ 2119 - └── form.en-us.html # HTMX form fragments 2120 - ``` 2121 - 2122 - ## Error Handling 2123 - 2124 - All i18n error strings must follow this format: 2125 - ``` 2126 - error-smokesignal-i18n-<domain>-<number> <message>: <details> 2127 - ``` 2128 - 2129 - Example errors: 2130 - ``` 2131 - error-smokesignal-i18n-fluent-1 Translation key not found: profile-greeting 2132 - error-smokesignal-i18n-locale-2 Unsupported language identifier: xx-XX 2133 - error-smokesignal-i18n-template-3 Template function argument missing: locale 2134 - ``` 2135 - 2136 - Use structured error enums with `thiserror`: 2137 - ```rust 2138 - #[derive(Debug, Error)] 2139 - pub enum I18nError { 2140 - #[error("error-smokesignal-i18n-fluent-1 Translation key not found: {key}")] 2141 - TranslationKeyNotFound { key: String }, 2142 - 2143 - #[error("error-smokesignal-i18n-locale-2 Unsupported language identifier: {locale}")] 2144 - UnsupportedLocale { locale: String }, 2145 - } 2146 - ``` 2147 - 2148 - ## Gender Support 2149 - 2150 - ### Gender Enum 2151 - ```rust 2152 - #[derive(Debug, Clone)] 2153 - pub enum Gender { 2154 - Masculine, 2155 - Feminine, 2156 - Neutral, 2157 - } 2158 - 2159 - impl Gender { 2160 - pub fn as_str(&self) -> &'static str { 2161 - match self { 2162 - Gender::Masculine => "masculine", 2163 - Gender::Feminine => "feminine", 2164 - Gender::Neutral => "neutral", 2165 - } 2166 - } 2167 - } 2168 - ``` 2169 - 2170 - ### Template Context 2171 - ```rust 2172 - // Template context includes gender information 2173 - let template_context = template_context! { 2174 - locale => locale.to_string(), 2175 - user_gender => user_gender.as_ref().map(|g| g.as_str()).unwrap_or("neutral"), 2176 - ..additional_context 2177 - }; 2178 - ``` 2179 - 2180 - ## Configuration Management 2181 - 2182 - ### Feature Flags 2183 - ```toml 2184 - [features] 2185 - default = ["embed"] 2186 - embed = ["minijinja-embed"] # Production: templates in binary 2187 - reload = ["minijinja-autoreload"] # Development: hot reload 2188 - ``` 2189 - 2190 - ### Supported Languages 2191 - ```rust 2192 - pub const SUPPORTED_LANGUAGES: &[&str] = &["en-us", "fr-ca"]; 2193 - 2194 - pub fn create_supported_languages() -> Vec<LanguageIdentifier> { 2195 - SUPPORTED_LANGUAGES.iter() 2196 - .map(|lang| LanguageIdentifier::from_str(lang).unwrap()) 2197 - .collect() 2198 - } 2199 - ``` 2200 - 2201 - ## Fluent File Organization 2202 - 2203 - ``` 2204 - i18n/ 2205 - ├── en-us/ 2206 - │ ├── common.ftl # Shared UI elements 2207 - │ ├── errors.ftl # Error messages 2208 - │ └── ui.ftl # Interface text 2209 - └── fr-ca/ 2210 - ├── common.ftl 2211 - ├── errors.ftl 2212 - └── ui.ftl 2213 - ``` 2214 - 2215 - ### Fluent Syntax Examples 2216 - ```ftl 2217 - # Gender variants 2218 - profile-greeting = Hello 2219 - profile-greeting-feminine = Hello miss 2220 - profile-greeting-masculine = Hello sir 2221 - profile-greeting-neutral = Hello there 2222 - 2223 - welcome-message = Welcome! 2224 - welcome-message-feminine = Bienvenue! 2225 - welcome-message-masculine = Bienvenu! 2226 - welcome-message-neutral = Bienvenue! 2227 - 2228 - # Count-based pluralization 2229 - events-created = { $count -> 2230 - [0] No events created 2231 - [1] One event created 2232 - *[other] {$count} events created 2233 - } 2234 - 2235 - # Parameterized messages 2236 - welcome-user = Welcome {$name}! 2237 - ``` 2238 - 2239 - ## Performance Guidelines 2240 - 2241 - ### ✅ Do 2242 - - Use on-demand translation calculation 2243 - - Leverage Fluent's built-in caching 2244 - - Register template functions once at startup 2245 - - Minimal template context (just locale and gender info) 2246 - 2247 - ### ❌ Don't 2248 - - Pre-render translation HashMaps 2249 - - Clone translation data unnecessarily 2250 - - Load all translations for every request 2251 - - Use `println!` for debugging (use `tracing::debug!`) 2252 - 2253 - ## Testing Requirements 2254 - 2255 - ```rust 2256 - #[cfg(test)] 2257 - mod tests { 2258 - use super::*; 2259 - 2260 - #[test] 2261 - fn test_language_detection_htmx_priority() { 2262 - // Test HX-Current-Language header takes priority 2263 - } 2264 - 2265 - #[test] 2266 - fn test_template_function_basic_translation() { 2267 - // Test t() function works correctly 2268 - } 2269 - 2270 - #[test] 2271 - fn test_gender_variants() { 2272 - // Test tg() with all gender combinations (masculine/feminine/neutral) 2273 - } 2274 - } 2275 - ``` 2276 - 2277 - ## Logging 2278 - 2279 - Use structured logging with `tracing`: 2280 - ```rust 2281 - tracing::debug!(locale = %locale, "Language detected for request"); 2282 - tracing::trace!(key = %key, locale = %locale, "Translation requested"); 2283 - ``` 2284 - 2285 - Instrument async functions: 2286 - ```rust 2287 - #[tracing::instrument(skip(locales))] 2288 - pub async fn load_translations(locales: &mut Locales) -> Result<()> { 2289 - // Implementation 2290 - } 2291 - ``` 2292 - 2293 - ## Code Comments 2294 - 2295 - Keep all code comments in English: 2296 - ```rust 2297 - // Create i18n context with user-specific gender preferences 2298 - let i18n_context = TemplateI18nContext::new(locale, locales) 2299 - .with_gender(user_gender.unwrap_or(Gender::Neutral)); 2300 - ``` 2301 - 2302 - ## Migration Strategy 2303 - 2304 - When starting from a version with no i18n integration: 2305 - 2306 - 1. **Phase 1**: Implement core `i18n` module with Fluent loading 2307 - 2. **Phase 2**: Add language detection middleware with HTMX support 2308 - 3. **Phase 3**: Integrate template functions and remove hardcoded strings 2309 - 4. **Phase 4**: Add gender support for Romance languages 2310 - 5. **Phase 5**: Implement template hierarchy (base/bare/common) for HTMX 2311 - 2312 - Each phase should be fully tested and deployable independently.
+556
docs/i18n_module_summary.md
··· 1 + # Smokesignal I18n Development Guidelines 2 + 3 + This document provides guidance for implementing comprehensive internationalization (i18n) in the Smokesignal web application using modern Rust patterns and HTMX integration. 4 + 5 + ## Project Overview 6 + 7 + Smokesignal is a Rust web application built with Axum that requires full internationalization support for multiple languages with advanced features including gender agreement and formality levels for Romance languages. 8 + 9 + ## I18n Architecture Goals 10 + 11 + - **HTMX-first design**: Seamless language propagation across partial page updates 12 + - **Performance-optimized**: On-demand translation calculation instead of pre-rendering 13 + - **Romance language support**: Gender agreement and formality levels (tu/vous) 14 + - **Fluent-based**: Mozilla Fluent for sophisticated translation features 15 + - **Template integration**: Direct i18n functions in Jinja2 templates 16 + 17 + ## Core Modules Structure 18 + 19 + ``` 20 + src/i18n/ 21 + ├── mod.rs # Main i18n exports and Locales struct 22 + ├── errors.rs # Structured error types for i18n operations 23 + ├── fluent_loader.rs # Fluent file loading (embed vs reload modes) 24 + └── template_helpers.rs # Template function integration 25 + 26 + src/http/ 27 + ├── middleware_i18n.rs # HTMX-aware language detection middleware 28 + ├── template_i18n.rs # Template context with gender/formality support 29 + └── templates.rs # Template rendering with integrated i18n functions 30 + ``` 31 + 32 + ## Language Detection Priority 33 + 34 + Implement language detection with this exact priority order for HTMX compatibility: 35 + 36 + 1. **HX-Current-Language header** (highest priority for HTMX requests) 37 + 2. **User profile language** (if authenticated) 38 + 3. **lang cookie** (session preference) 39 + 4. **Accept-Language header** (browser preference) 40 + 5. **Default language** (fallback) 41 + 42 + ## Template Integration Pattern 43 + 44 + Replace pre-rendered translation HashMap with direct template functions: 45 + 46 + ### ❌ Avoid (pre-rendering approach) 47 + ```rust 48 + // Don't pre-calculate all translations 49 + let mut translations = HashMap::new(); 50 + translations.insert("profile-greeting".to_string(), i18n_context.tgf(...)); 51 + ``` 52 + 53 + ### ✅ Use (on-demand functions) 54 + ```rust 55 + // Register i18n functions in template engine 56 + env.add_function("t", |args| { /* basic translation */ }); 57 + env.add_function("tgf", |args| { /* gender + formality */ }); 58 + env.add_function("tc", |args| { /* count-based pluralization */ }); 59 + ``` 60 + 61 + ### Template Usage 62 + ```html 63 + <!-- Direct function calls in templates --> 64 + <h1>{{ tgf(key="profile-greeting", locale=locale, gender=user_gender, formality=user_formality) }}</h1> 65 + <button>{{ t(key="save-changes", locale=locale) }}</button> 66 + <p>{{ tc(key="events-created", locale=locale, count=event_count) }}</p> 67 + ``` 68 + 69 + ## HTMX Integration Requirements 70 + 71 + ### Middleware Implementation 72 + ```rust 73 + pub async fn htmx_language_middleware<B>(request: Request<B>, next: Next<B>) -> Response { 74 + let is_htmx = request.headers().get("HX-Request").is_some(); 75 + 76 + // Detect language with HTMX priority 77 + let locale = detect_language_with_htmx_priority(&request); 78 + 79 + // Inject into request extensions 80 + request.extensions_mut().insert(Language(locale.clone())); 81 + 82 + let mut response = next.run(request).await; 83 + 84 + // Add language propagation header for HTMX 85 + if is_htmx { 86 + response.headers_mut().insert("HX-Language", locale.to_string().parse().unwrap()); 87 + } 88 + 89 + response 90 + } 91 + ``` 92 + 93 + ### Template Structure for HTMX 94 + Support both full page loads and HTMX partials: 95 + ``` 96 + templates/ 97 + ├── index.en-us.html # Full page (first visit) 98 + ├── index.en-us.bare.html # HTMX navigation (no <html>) 99 + ├── index.en-us.common.html # Shared content 100 + └── partials/ 101 + └── form.en-us.html # HTMX form fragments 102 + ``` 103 + 104 + ## Error Handling 105 + 106 + All i18n error strings must follow this format: 107 + ``` 108 + error-smokesignal-i18n-<domain>-<number> <message>: <details> 109 + ``` 110 + 111 + Example errors: 112 + ``` 113 + error-smokesignal-i18n-fluent-1 Translation key not found: profile-greeting 114 + error-smokesignal-i18n-locale-2 Unsupported language identifier: xx-XX 115 + error-smokesignal-i18n-template-3 Template function argument missing: locale 116 + ``` 117 + 118 + Use structured error enums with `thiserror`: 119 + ```rust 120 + #[derive(Debug, Error)] 121 + pub enum I18nError { 122 + #[error("error-smokesignal-i18n-fluent-1 Translation key not found: {key}")] 123 + TranslationKeyNotFound { key: String }, 124 + 125 + #[error("error-smokesignal-i18n-locale-2 Unsupported language identifier: {locale}")] 126 + UnsupportedLocale { locale: String }, 127 + } 128 + ``` 129 + 130 + ## Configuration Management 131 + 132 + ### Feature Flags 133 + ```toml 134 + [features] 135 + default = ["embed"] 136 + embed = ["minijinja-embed"] # Production: templates in binary 137 + reload = ["minijinja-autoreload"] # Development: hot reload 138 + ``` 139 + 140 + ### Supported Languages 141 + ```rust 142 + pub const SUPPORTED_LANGUAGES: &[&str] = &["en-us", "fr-ca"]; 143 + 144 + pub fn create_supported_languages() -> Vec<LanguageIdentifier> { 145 + SUPPORTED_LANGUAGES.iter() 146 + .map(|lang| LanguageIdentifier::from_str(lang).unwrap()) 147 + .collect() 148 + } 149 + ``` 150 + 151 + ## Fluent File Organization 152 + 153 + ``` 154 + i18n/ 155 + ├── en-us/ 156 + │ ├── common.ftl # Shared UI elements 157 + │ ├── errors.ftl # Error messages 158 + │ └── ui.ftl # Interface text 159 + └── fr-ca/ 160 + ├── common.ftl 161 + ├── errors.ftl 162 + └── ui.ftl 163 + ``` 164 + 165 + ### Fluent Syntax Examples 166 + ```ftl 167 + # Gender and formality variants 168 + profile-greeting = Hello 169 + profile-greeting-feminine = Hello miss 170 + profile-greeting-masculine = Hello sir 171 + profile-greeting-feminine-formal = Good day madam 172 + profile-greeting-masculine-formal = Good day sir 173 + 174 + # Count-based pluralization 175 + events-created = { $count -> 176 + [0] No events created 177 + [1] One event created 178 + *[other] {$count} events created 179 + } 180 + 181 + # Parameterized messages 182 + welcome-user = Welcome {$name}! 183 + ``` 184 + 185 + ## Performance Guidelines 186 + 187 + ### ✅ Do 188 + - Use on-demand translation calculation 189 + - Leverage Fluent's built-in caching 190 + - Register template functions once at startup 191 + - Minimal template context (just locale info) 192 + 193 + ### ❌ Don't 194 + - Pre-render translation HashMaps 195 + - Clone translation data unnecessarily 196 + - Load all translations for every request 197 + - Use `println!` for debugging (use `tracing::debug!`) 198 + 199 + ## Testing Requirements 200 + 201 + ```rust 202 + #[cfg(test)] 203 + mod tests { 204 + use super::*; 205 + 206 + #[test] 207 + fn test_language_detection_htmx_priority() { 208 + // Test HX-Current-Language header takes priority 209 + } 210 + 211 + #[test] 212 + fn test_template_function_basic_translation() { 213 + // Test t() function works correctly 214 + } 215 + 216 + #[test] 217 + fn test_gender_formality_combinations() { 218 + // Test tgf() with all gender/formality combinations 219 + } 220 + } 221 + ``` 222 + 223 + ## Logging 224 + 225 + Use structured logging with `tracing`: 226 + ```rust 227 + tracing::debug!(locale = %locale, "Language detected for request"); 228 + tracing::trace!(key = %key, locale = %locale, "Translation requested"); 229 + ``` 230 + 231 + Instrument async functions: 232 + ```rust 233 + #[tracing::instrument(skip(locales))] 234 + pub async fn load_translations(locales: &mut Locales) -> Result<()> { 235 + // Implementation 236 + } 237 + ``` 238 + 239 + ## Code Comments 240 + 241 + Keep all code comments in English: 242 + ```rust 243 + // Create i18n context with user-specific gender and formality preferences 244 + let i18n_context = TemplateI18nContext::new(locale, locales) 245 + .with_gender(user_gender.unwrap_or(Gender::Neutral)) 246 + .with_formality(user_formality.unwrap_or(Formality::Informal)); 247 + ``` 248 + 249 + ## Migration Strategy 250 + 251 + When starting from a version with no i18n integration: 252 + 253 + 1. **Phase 1**: Implement core `i18n` module with Fluent loading 254 + 2. **Phase 2**: Add language detection middleware with HTMX support 255 + 3. **Phase 3**: Integrate template functions and remove hardcoded strings 256 + 4. **Phase 4**: Add gender/formality support for Romance languages 257 + 5. **Phase 5**: Implement template hierarchy (base/bare/common) for HTMX 258 + 259 + Each phase should be fully tested and deployable independently. 260 + 261 + gagnfra: remove formality, we wont use it. but add gender neutral. 262 + 263 + GitHub Copilot: # Smokesignal I18n Development Guidelines 264 + 265 + This document provides guidance for implementing comprehensive internationalization (i18n) in the Smokesignal web application using modern Rust patterns and HTMX integration. 266 + 267 + ## Project Overview 268 + 269 + Smokesignal is a Rust web application built with Axum that requires full internationalization support for multiple languages with advanced features including gender agreement for Romance languages. 270 + 271 + ## I18n Architecture Goals 272 + 273 + - **HTMX-first design**: Seamless language propagation across partial page updates 274 + - **Performance-optimized**: On-demand translation calculation instead of pre-rendering 275 + - **Romance language support**: Gender agreement (masculine/feminine/neutral) 276 + - **Fluent-based**: Mozilla Fluent for sophisticated translation features 277 + - **Template integration**: Direct i18n functions in Jinja2 templates 278 + 279 + ## Core Modules Structure 280 + 281 + ``` 282 + src/i18n/ 283 + ├── mod.rs # Main i18n exports and Locales struct 284 + ├── errors.rs # Structured error types for i18n operations 285 + ├── fluent_loader.rs # Fluent file loading (embed vs reload modes) 286 + └── template_helpers.rs # Template function integration 287 + 288 + src/http/ 289 + ├── middleware_i18n.rs # HTMX-aware language detection middleware 290 + ├── template_i18n.rs # Template context with gender support 291 + └── templates.rs # Template rendering with integrated i18n functions 292 + ``` 293 + 294 + ## Language Detection Priority 295 + 296 + Implement language detection with this exact priority order for HTMX compatibility: 297 + 298 + 1. **HX-Current-Language header** (highest priority for HTMX requests) 299 + 2. **User profile language** (if authenticated) 300 + 3. **lang cookie** (session preference) 301 + 4. **Accept-Language header** (browser preference) 302 + 5. **Default language** (fallback) 303 + 304 + ## Template Integration Pattern 305 + 306 + Replace pre-rendered translation HashMap with direct template functions: 307 + 308 + ### ❌ Avoid (pre-rendering approach) 309 + ```rust 310 + // Don't pre-calculate all translations 311 + let mut translations = HashMap::new(); 312 + translations.insert("profile-greeting".to_string(), i18n_context.tg(...)); 313 + ``` 314 + 315 + ### ✅ Use (on-demand functions) 316 + ```rust 317 + // Register i18n functions in template engine 318 + env.add_function("t", |args| { /* basic translation */ }); 319 + env.add_function("tg", |args| { /* gender-aware translation */ }); 320 + env.add_function("tc", |args| { /* count-based pluralization */ }); 321 + ``` 322 + 323 + ### Template Usage 324 + ```html 325 + <!-- Direct function calls in templates --> 326 + <h1>{{ tg(key="profile-greeting", locale=locale, gender=user_gender) }}</h1> 327 + <button>{{ t(key="save-changes", locale=locale) }}</button> 328 + <p>{{ tc(key="events-created", locale=locale, count=event_count) }}</p> 329 + ``` 330 + 331 + ## HTMX Integration Requirements 332 + 333 + ### Middleware Implementation 334 + ```rust 335 + pub async fn htmx_language_middleware<B>(request: Request<B>, next: Next<B>) -> Response { 336 + let is_htmx = request.headers().get("HX-Request").is_some(); 337 + 338 + // Detect language with HTMX priority 339 + let locale = detect_language_with_htmx_priority(&request); 340 + 341 + // Inject into request extensions 342 + request.extensions_mut().insert(Language(locale.clone())); 343 + 344 + let mut response = next.run(request).await; 345 + 346 + // Add language propagation header for HTMX 347 + if is_htmx { 348 + response.headers_mut().insert("HX-Language", locale.to_string().parse().unwrap()); 349 + } 350 + 351 + response 352 + } 353 + ``` 354 + 355 + ### Template Structure for HTMX 356 + Support both full page loads and HTMX partials: 357 + ``` 358 + templates/ 359 + ├── index.en-us.html # Full page (first visit) 360 + ├── index.en-us.bare.html # HTMX navigation (no <html>) 361 + ├── index.en-us.common.html # Shared content 362 + └── partials/ 363 + └── form.en-us.html # HTMX form fragments 364 + ``` 365 + 366 + ## Error Handling 367 + 368 + All i18n error strings must follow this format: 369 + ``` 370 + error-smokesignal-i18n-<domain>-<number> <message>: <details> 371 + ``` 372 + 373 + Example errors: 374 + ``` 375 + error-smokesignal-i18n-fluent-1 Translation key not found: profile-greeting 376 + error-smokesignal-i18n-locale-2 Unsupported language identifier: xx-XX 377 + error-smokesignal-i18n-template-3 Template function argument missing: locale 378 + ``` 379 + 380 + Use structured error enums with `thiserror`: 381 + ```rust 382 + #[derive(Debug, Error)] 383 + pub enum I18nError { 384 + #[error("error-smokesignal-i18n-fluent-1 Translation key not found: {key}")] 385 + TranslationKeyNotFound { key: String }, 386 + 387 + #[error("error-smokesignal-i18n-locale-2 Unsupported language identifier: {locale}")] 388 + UnsupportedLocale { locale: String }, 389 + } 390 + ``` 391 + 392 + ## Gender Support 393 + 394 + ### Gender Enum 395 + ```rust 396 + #[derive(Debug, Clone)] 397 + pub enum Gender { 398 + Masculine, 399 + Feminine, 400 + Neutral, 401 + } 402 + 403 + impl Gender { 404 + pub fn as_str(&self) -> &'static str { 405 + match self { 406 + Gender::Masculine => "masculine", 407 + Gender::Feminine => "feminine", 408 + Gender::Neutral => "neutral", 409 + } 410 + } 411 + } 412 + ``` 413 + 414 + ### Template Context 415 + ```rust 416 + // Template context includes gender information 417 + let template_context = template_context! { 418 + locale => locale.to_string(), 419 + user_gender => user_gender.as_ref().map(|g| g.as_str()).unwrap_or("neutral"), 420 + ..additional_context 421 + }; 422 + ``` 423 + 424 + ## Configuration Management 425 + 426 + ### Feature Flags 427 + ```toml 428 + [features] 429 + default = ["embed"] 430 + embed = ["minijinja-embed"] # Production: templates in binary 431 + reload = ["minijinja-autoreload"] # Development: hot reload 432 + ``` 433 + 434 + ### Supported Languages 435 + ```rust 436 + pub const SUPPORTED_LANGUAGES: &[&str] = &["en-us", "fr-ca"]; 437 + 438 + pub fn create_supported_languages() -> Vec<LanguageIdentifier> { 439 + SUPPORTED_LANGUAGES.iter() 440 + .map(|lang| LanguageIdentifier::from_str(lang).unwrap()) 441 + .collect() 442 + } 443 + ``` 444 + 445 + ## Fluent File Organization 446 + 447 + ``` 448 + i18n/ 449 + ├── en-us/ 450 + │ ├── common.ftl # Shared UI elements 451 + │ ├── errors.ftl # Error messages 452 + │ └── ui.ftl # Interface text 453 + └── fr-ca/ 454 + ├── common.ftl 455 + ├── errors.ftl 456 + └── ui.ftl 457 + ``` 458 + 459 + ### Fluent Syntax Examples 460 + ```ftl 461 + # Gender variants 462 + profile-greeting = Hello 463 + profile-greeting-feminine = Hello miss 464 + profile-greeting-masculine = Hello sir 465 + profile-greeting-neutral = Hello there 466 + 467 + welcome-message = Welcome! 468 + welcome-message-feminine = Bienvenue! 469 + welcome-message-masculine = Bienvenu! 470 + welcome-message-neutral = Bienvenue! 471 + 472 + # Count-based pluralization 473 + events-created = { $count -> 474 + [0] No events created 475 + [1] One event created 476 + *[other] {$count} events created 477 + } 478 + 479 + # Parameterized messages 480 + welcome-user = Welcome {$name}! 481 + ``` 482 + 483 + ## Performance Guidelines 484 + 485 + ### ✅ Do 486 + - Use on-demand translation calculation 487 + - Leverage Fluent's built-in caching 488 + - Register template functions once at startup 489 + - Minimal template context (just locale and gender info) 490 + 491 + ### ❌ Don't 492 + - Pre-render translation HashMaps 493 + - Clone translation data unnecessarily 494 + - Load all translations for every request 495 + - Use `println!` for debugging (use `tracing::debug!`) 496 + 497 + ## Testing Requirements 498 + 499 + ```rust 500 + #[cfg(test)] 501 + mod tests { 502 + use super::*; 503 + 504 + #[test] 505 + fn test_language_detection_htmx_priority() { 506 + // Test HX-Current-Language header takes priority 507 + } 508 + 509 + #[test] 510 + fn test_template_function_basic_translation() { 511 + // Test t() function works correctly 512 + } 513 + 514 + #[test] 515 + fn test_gender_variants() { 516 + // Test tg() with all gender combinations (masculine/feminine/neutral) 517 + } 518 + } 519 + ``` 520 + 521 + ## Logging 522 + 523 + Use structured logging with `tracing`: 524 + ```rust 525 + tracing::debug!(locale = %locale, "Language detected for request"); 526 + tracing::trace!(key = %key, locale = %locale, "Translation requested"); 527 + ``` 528 + 529 + Instrument async functions: 530 + ```rust 531 + #[tracing::instrument(skip(locales))] 532 + pub async fn load_translations(locales: &mut Locales) -> Result<()> { 533 + // Implementation 534 + } 535 + ``` 536 + 537 + ## Code Comments 538 + 539 + Keep all code comments in English: 540 + ```rust 541 + // Create i18n context with user-specific gender preferences 542 + let i18n_context = TemplateI18nContext::new(locale, locales) 543 + .with_gender(user_gender.unwrap_or(Gender::Neutral)); 544 + ``` 545 + 546 + ## Migration Strategy 547 + 548 + When starting from a version with no i18n integration: 549 + 550 + 1. **Phase 1**: Implement core `i18n` module with Fluent loading 551 + 2. **Phase 2**: Add language detection middleware with HTMX support 552 + 3. **Phase 3**: Integrate template functions and remove hardcoded strings 553 + 4. **Phase 4**: Add gender support for Romance languages 554 + 5. **Phase 5**: Implement template hierarchy (base/bare/common) for HTMX 555 + 556 + Each phase should be fully tested and deployable independently.
docs/i18n_phase2_complete.md docs/PHASE1-2_COMPLETION.md
docs/i18n_phase2_integration.md docs/PHASE1-2_FIRSTPASS.md
i18n_cleanup_reference.md

This is a binary file and will not be displayed.

i18n_rust_testing_summary.md

This is a binary file and will not be displayed.