i18n+filtering fork - fluent-templates v2
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate i18n system from custom fluent to fluent-templates

- Replace custom Fluent implementation with fluent-templates crate
- Fix FluentValue type conversions and remove DateTime variant support
- Update translation methods to return String directly instead of Option<String>
- Maintain backward compatibility for existing form validation calls
- Resolve all compilation errors and ensure tests pass

BREAKING CHANGE: i18n system now uses fluent-templates instead of custom implementation

+2722 -33
+1
Cargo.toml
··· 83 83 once_cell = "1.19" 84 84 parking_lot = "0.12" 85 85 metrohash = "1.0.7" 86 + fluent-templates = { version = "0.13.0", features = ["handlebars"] } 86 87 87 88 [profile.release] 88 89 opt-level = 3
+158
docs/copilot-TODO
··· 1 + This file provides guidance to Claude Code (claude.ai/code) when working with the i18n refactoring migration in this repository. 2 + 3 + **Project Overview** 4 + This is a comprehensive i18n refactoring guide for migrating Smokesignal's i18n system from a complex manual `.ftl` file loading system to `fluent-templates` for simplified architecture and improved performance. 5 + 6 + **Common Commands** 7 + * **Build**: `cargo build` 8 + * **Check code**: `cargo check --lib` 9 + * **Run tests**: `cargo test` 10 + * **Run specific test**: `cargo test fluent_loader` 11 + * **Run template tests**: `cargo test template_helpers` 12 + * **Run middleware tests**: `cargo test middleware_i18n` 13 + * **Run integration tests**: `cargo test template` 14 + * **Run i18n tests**: `cargo test i18n` 15 + * **Format code**: `cargo fmt` 16 + * **Lint**: `cargo clippy` 17 + 18 + * All translation files are in i18n folder. 19 + 20 + **Migration Steps** 21 + 22 + **Step 1: Analysis of Existing System** 23 + Analyze current i18n implementation to understand dependencies and architecture. 24 + 25 + **Step 2: New fluent-templates Module** 26 + Reference documentation: 27 + * https://github.com/XAMPPRocky/fluent-templates 28 + * https://docs.rs/fluent/latest/fluent/all.html 29 + * https://docs.rs/minijinja/latest/minijinja/all.html 30 + 31 + Controls: 32 + * Module compiles: `cargo check --lib` 33 + * Tests pass: `cargo test fluent_loader` 34 + * No API regression 35 + 36 + **Step 3: Template Helpers Adaptation** 37 + Adapt existing template helpers to work with fluent-templates. 38 + 39 + Controls: 40 + * Helpers compile 41 + * Existing templates work 42 + * Test: `cargo test template_helpers` 43 + 44 + **Step 3.5: Context Adaptation** 45 + Update context.rs for simplified I18nContext. 46 + 47 + Controls: 48 + * Simplified I18nContext compiles 49 + * Translation helpers work 50 + * Template contexts include locale 51 + 52 + **Step 4: Architecture Simplification** 53 + Create new main module mod.rs for unified API. 54 + 55 + Controls: 56 + * Main module compiles 57 + * Compatible API maintained 58 + * Integration tests pass 59 + 60 + **Step 4.5: i18n Middleware Optimization** 61 + Update middleware_i18n.rs for performance improvements. 62 + 63 + Controls: 64 + * Middleware compiles with optimizations 65 + * Enriched HTMX headers work 66 + * Faster language detection 67 + * Tests pass: `cargo test middleware_i18n` 68 + 69 + **Step 5: Template Updates** 70 + Adapt template engine for fluent-templates integration. 71 + 72 + Controls: 73 + * Template engine compiles 74 + * i18n helpers work 75 + * Existing templates display correctly 76 + 77 + **Step 5.5: Template Handler Refactoring** 78 + Refactor template handler for complete functionality. 79 + 80 + Controls: 81 + * Template handler compiles without error 82 + * All template functions available 83 + * Extended tests pass: `cargo test template_handler` 84 + * Locale and gender validation works 85 + * HTMX/URL helpers available 86 + 87 + **Step 6: Testing and Validation** 88 + Complete system and integration testing. 89 + 90 + Controls: 91 + * Unit tests pass: `cargo test i18n` 92 + * Integration tests pass: `cargo test template` 93 + * Existing page rendering correct 94 + 95 + **Step 7: Cleanup and Optimization** 96 + Remove obsolete code and update dependencies. 97 + 98 + Controls: 99 + * Successful compilation after cleanup 100 + * Reduced dependency size 101 + * Improved performance (compilation time) 102 + 103 + **Step 8: Performance Testing and Final Validation** 104 + Benchmark performance improvements. 105 + 106 + Controls: 107 + * Benchmarks show performance improvement 108 + * Application starts without error 109 + * Pages load with correct translations 110 + * Language switching works 111 + 112 + **Step 9.5: Main Binary Update** 113 + Adapt smokesignal.rs for fluent-templates integration. 114 + 115 + Controls: 116 + * Application starts with fluent-templates 117 + * Translation validation at startup 118 + * Logs confirm proper functioning 119 + * Performance equal or superior to old system 120 + * Documentation updated to reflect changes 121 + * No functional regression 122 + 123 + **Final Migration Checklist** 124 + 125 + **Core Functionality** 126 + * fluent-templates integrated and functional 127 + * Gender support preserved (fr-ca) 128 + * Compatible API maintained 129 + * Existing templates work 130 + * Template helpers operational 131 + 132 + **Performance and Architecture** 133 + * Static loading at compile time 134 + * Reduced dependencies (5 crates removed) 135 + * Improved compilation time 136 + * Reduced memory usage 137 + * Simplified architecture 138 + 139 + **Compatibility** 140 + * No functional regression 141 + * All translations available (en-us, fr-ca) 142 + * 5 .ftl files per language loaded 143 + * Fallback to key if translation missing 144 + * Support for arguments in translations 145 + 146 + **Testing and Validation** 147 + * Unit tests pass 148 + * Integration tests pass 149 + * Application starts without error 150 + * Performance equal or superior 151 + * i18n_checker tool functional 152 + 153 + **Expected Results** 154 + * **Performance**: Static loading at compile time 155 + * **Architecture**: Code simplification (removal of ~500 lines) 156 + * **Dependencies**: Reduction of 5 external crates 157 + * **Maintenance**: Simpler and more robust API 158 +
+212
docs/fluent-template-doc
··· 1 + Fluent Templates: A High level Fluent API. 2 + Build & Test crates.io Help Wanted Lines Of Code Documentation 3 + 4 + fluent-templates lets you to easily integrate Fluent localisation into your Rust application or library. It does this by providing a high level "loader" API that loads fluent strings based on simple language negotiation, and the FluentLoader struct which is a Loader agnostic container type that comes with optional trait implementations for popular templating engines such as handlebars or tera that allow you to be able to use your localisations in your templates with no boilerplate. 5 + 6 + Loaders 7 + Currently this crate provides two different kinds of loaders that cover two main use cases. 8 + 9 + static_loader! — A procedural macro that loads your fluent resources at compile-time into your binary and creates a new StaticLoader static variable that allows you to access the localisations. static_loader! is most useful when you want to localise your application and want to ship your fluent resources with your binary. 10 + 11 + ArcLoader — A struct that loads your fluent resources at run-time using Arc as its backing storage. ArcLoader is most useful for when you want to be able to change and/or update localisations at run-time, or if you're writing a developer tool that wants to provide fluent localisation in your own application such as a static site generator. 12 + 13 + static_loader! 14 + The easiest way to use fluent-templates is to use the static_loader! procedural macro that will create a new StaticLoader static variable. 15 + 16 + Basic Example 17 + fluent_templates::static_loader! { 18 + // Declare our `StaticLoader` named `LOCALES`. 19 + static LOCALES = { 20 + // The directory of localisations and fluent resources. 21 + locales: "./tests/locales", 22 + // The language to falback on if something is not present. 23 + fallback_language: "en-US", 24 + // Optional: A fluent resource that is shared with every locale. 25 + core_locales: "./tests/locales/core.ftl", 26 + }; 27 + } 28 + Customise Example 29 + You can also modify each FluentBundle on initialisation to be able to change configuration or add resources from Rust. 30 + 31 + use std::sync::LazyLock; 32 + use fluent_bundle::FluentResource; 33 + use fluent_templates::static_loader; 34 + 35 + static_loader! { 36 + // Declare our `StaticLoader` named `LOCALES`. 37 + static LOCALES = { 38 + // The directory of localisations and fluent resources. 39 + locales: "./tests/locales", 40 + // The language to falback on if something is not present. 41 + fallback_language: "en-US", 42 + // Optional: A fluent resource that is shared with every locale. 43 + core_locales: "./tests/locales/core.ftl", 44 + // Optional: A function that is run over each fluent bundle. 45 + customise: |bundle| { 46 + // Since this will be called for each locale bundle and 47 + // `FluentResource`s need to be either `&'static` or behind an 48 + // `Arc` it's recommended you use lazily initialised 49 + // static variables. 50 + static CRATE_VERSION_FTL: LazyLock<FluentResource> = LazyLock::new(|| { 51 + let ftl_string = String::from( 52 + concat!("-crate-version = {}", env!("CARGO_PKG_VERSION")) 53 + ); 54 + 55 + FluentResource::try_new(ftl_string).unwrap() 56 + }); 57 + 58 + bundle.add_resource(&CRATE_VERSION_FTL); 59 + } 60 + }; 61 + } 62 + Locales Directory 63 + fluent-templates will collect all subdirectories that match a valid Unicode Language Identifier and bundle all fluent files found in those directories and map those resources to the respective identifier. fluent-templates will recurse through each language directory as needed and will respect any .gitignore or .ignore files present. 64 + 65 + Example Layout 66 + locales 67 + ├── core.ftl 68 + ├── en-US 69 + │ └── main.ftl 70 + ├── fr 71 + │ └── main.ftl 72 + ├── zh-CN 73 + │ └── main.ftl 74 + └── zh-TW 75 + └── main.ftl 76 + Looking up fluent resources 77 + You can use the Loader trait to lookup a given fluent resource, and provide any additional arguments as needed with lookup_with_args. 78 + 79 + Example 80 + # In `locales/en-US/main.ftl` 81 + hello-world = Hello World! 82 + greeting = Hello { $name }! 83 + 84 + # In `locales/fr/main.ftl` 85 + hello-world = Bonjour le monde! 86 + greeting = Bonjour { $name }! 87 + 88 + # In `locales/de/main.ftl` 89 + hello-world = Hallo Welt! 90 + greeting = Hallo { $name }! 91 + use std::collections::HashMap; 92 + 93 + use unic_langid::{LanguageIdentifier, langid}; 94 + use fluent_templates::{Loader, static_loader}; 95 + 96 + const US_ENGLISH: LanguageIdentifier = langid!("en-US"); 97 + const FRENCH: LanguageIdentifier = langid!("fr"); 98 + const GERMAN: LanguageIdentifier = langid!("de"); 99 + 100 + static_loader! { 101 + static LOCALES = { 102 + locales: "./tests/locales", 103 + fallback_language: "en-US", 104 + // Removes unicode isolating marks around arguments, you typically 105 + // should only set to false when testing. 106 + customise: |bundle| bundle.set_use_isolating(false), 107 + }; 108 + } 109 + 110 + fn main() { 111 + assert_eq!("Hello World!", LOCALES.lookup(&US_ENGLISH, "hello-world")); 112 + assert_eq!("Bonjour le monde!", LOCALES.lookup(&FRENCH, "hello-world")); 113 + assert_eq!("Hallo Welt!", LOCALES.lookup(&GERMAN, "hello-world")); 114 + 115 + let args = { 116 + let mut map = HashMap::new(); 117 + map.insert(String::from("name"), "Alice".into()); 118 + map 119 + }; 120 + 121 + assert_eq!("Hello Alice!", LOCALES.lookup_with_args(&US_ENGLISH, "greeting", &args)); 122 + assert_eq!("Bonjour Alice!", LOCALES.lookup_with_args(&FRENCH, "greeting", &args)); 123 + assert_eq!("Hallo Alice!", LOCALES.lookup_with_args(&GERMAN, "greeting", &args)); 124 + } 125 + Tera 126 + With the tera feature you can use FluentLoader as a Tera function. It accepts a key parameter pointing to a fluent resource and lang for what language to get that key for. Optionally you can pass extra arguments to the function as arguments to the resource. fluent-templates will automatically convert argument keys from Tera's snake_case to the fluent's preferred kebab-case arguments. 127 + 128 + fluent-templates = { version = "*", features = ["tera"] } 129 + use fluent_templates::{FluentLoader, static_loader}; 130 + 131 + static_loader! { 132 + static LOCALES = { 133 + locales: "./tests/locales", 134 + fallback_language: "en-US", 135 + // Removes unicode isolating marks around arguments, you typically 136 + // should only set to false when testing. 137 + customise: |bundle| bundle.set_use_isolating(false), 138 + }; 139 + } 140 + 141 + fn main() { 142 + let mut tera = tera::Tera::default(); 143 + let ctx = tera::Context::default(); 144 + tera.register_function("fluent", FluentLoader::new(&*LOCALES)); 145 + assert_eq!( 146 + "Hello World!", 147 + tera.render_str(r#"{{ fluent(key="hello-world", lang="en-US") }}"#, &ctx).unwrap() 148 + ); 149 + assert_eq!( 150 + "Hello Alice!", 151 + tera.render_str(r#"{{ fluent(key="greeting", lang="en-US", name="Alice") }}"#, &ctx).unwrap() 152 + ); 153 + } 154 + Handlebars 155 + In handlebars, fluent-templates will read the lang field in your handlebars::Context while rendering. 156 + 157 + fluent-templates = { version = "*", features = ["handlebars"] } 158 + use fluent_templates::{FluentLoader, static_loader}; 159 + 160 + static_loader! { 161 + static LOCALES = { 162 + locales: "./tests/locales", 163 + fallback_language: "en-US", 164 + // Removes unicode isolating marks around arguments, you typically 165 + // should only set to false when testing. 166 + customise: |bundle| bundle.set_use_isolating(false), 167 + }; 168 + } 169 + 170 + fn main() { 171 + let mut handlebars = handlebars::Handlebars::new(); 172 + handlebars.register_helper("fluent", Box::new(FluentLoader::new(&*LOCALES))); 173 + let data = serde_json::json!({"lang": "zh-CN"}); 174 + assert_eq!("Hello World!", handlebars.render_template(r#"{{fluent "hello-world"}}"#, &data).unwrap()); 175 + assert_eq!("Hello Alice!", handlebars.render_template(r#"{{fluent "greeting" name="Alice"}}"#, &data).unwrap()); 176 + } 177 + Handlebars helper syntax. 178 + The main helper provided is the {{fluent}} helper. If you have the following Fluent file: 179 + 180 + foo-bar = "foo bar" 181 + placeholder = this has a placeholder { $variable } 182 + placeholder2 = this has { $variable1 } { $variable2 } 183 + You can include the strings in your template with 184 + 185 + <!-- will render "foo bar" --> 186 + {{fluent "foo-bar"}} 187 + <!-- will render "this has a placeholder baz" --> 188 + {{fluent "placeholder" variable="baz"}} 189 + You may also use the {{fluentparam}} helper to specify variables, especially if you need them to be multiline. 190 + 191 + {{#fluent "placeholder2"}} 192 + {{#fluentparam "variable1"}} 193 + first line 194 + second line 195 + {{/fluentparam}} 196 + {{#fluentparam "variable2"}} 197 + first line 198 + second line 199 + {{/fluentparam}} 200 + {{/fluent}} 201 + FAQ 202 + Why is there extra characters around the values of arguments? 203 + These are called "Unicode Isolating Marks" that used to allow the text to be bidirectional. You can disable this with FluentBundle::set_isolating_marks being set to false. 204 + 205 + static_loader! { 206 + static LOCALES = { 207 + locales: "./tests/locales", 208 + fallback_language: "en-US", 209 + // Removes unicode isolating marks around arguments. 210 + customise: |bundle| bundle.set_use_isolating(false), 211 + }; 212 + }
+138
docs/minijinja-reference
··· 1 + Docs.rs 2 + minijinja-2.10.2 3 + Platform 4 + Feature flags 5 + docs.rs 6 + Rust 7 + 8 + Find crate 9 + logo 10 + minijinja 11 + 2.10.2 12 + Crate Items 13 + Macros 14 + Structs 15 + Enums 16 + Traits 17 + Functions 18 + Type ‘S’ or ‘/’ to search, ‘?’ for more options… 19 + List of all items 20 + Structs 21 + Environment 22 + Error 23 + Expression 24 + HtmlEscape 25 + Output 26 + State 27 + Template 28 + syntax::SyntaxConfig 29 + syntax::SyntaxConfigBuilder 30 + value::DynObject 31 + value::Kwargs 32 + value::Rest 33 + value::Value 34 + value::ValueIter 35 + value::ViaDeserialize 36 + Enums 37 + AutoEscape 38 + ErrorKind 39 + UndefinedBehavior 40 + value::Enumerator 41 + value::ObjectRepr 42 + value::ValueKind 43 + Traits 44 + functions::Function 45 + value::ArgType 46 + value::FunctionArgs 47 + value::FunctionResult 48 + value::Object 49 + value::ObjectExt 50 + Macros 51 + args 52 + context 53 + render 54 + Functions 55 + default_auto_escape_callback 56 + escape_formatter 57 + filters::abs 58 + filters::attr 59 + filters::batch 60 + filters::bool 61 + filters::capitalize 62 + filters::default 63 + filters::dictsort 64 + filters::escape 65 + filters::first 66 + filters::float 67 + filters::groupby 68 + filters::indent 69 + filters::int 70 + filters::items 71 + filters::join 72 + filters::last 73 + filters::length 74 + filters::lines 75 + filters::list 76 + filters::lower 77 + filters::map 78 + filters::max 79 + filters::min 80 + filters::pprint 81 + filters::reject 82 + filters::rejectattr 83 + filters::replace 84 + filters::reverse 85 + filters::round 86 + filters::safe 87 + filters::select 88 + filters::selectattr 89 + filters::slice 90 + filters::sort 91 + filters::split 92 + filters::string 93 + filters::sum 94 + filters::title 95 + filters::tojson 96 + filters::trim 97 + filters::unique 98 + filters::upper 99 + filters::urlencode 100 + functions::debug 101 + functions::dict 102 + functions::namespace 103 + functions::range 104 + path_loader 105 + tests::is_boolean 106 + tests::is_defined 107 + tests::is_divisibleby 108 + tests::is_endingwith 109 + tests::is_eq 110 + tests::is_even 111 + tests::is_false 112 + tests::is_filter 113 + tests::is_float 114 + tests::is_ge 115 + tests::is_gt 116 + tests::is_in 117 + tests::is_integer 118 + tests::is_iterable 119 + tests::is_le 120 + tests::is_lower 121 + tests::is_lt 122 + tests::is_mapping 123 + tests::is_ne 124 + tests::is_none 125 + tests::is_number 126 + tests::is_odd 127 + tests::is_safe 128 + tests::is_sameas 129 + tests::is_sequence 130 + tests::is_startingwith 131 + tests::is_string 132 + tests::is_test 133 + tests::is_true 134 + tests::is_undefined 135 + tests::is_upper 136 + value::from_args 137 + value::merge_maps 138 + value::serializing_for_value
+54
i18n/en-us/actions.ftl
··· 1 + # Action buttons and controls - English (US) 2 + 3 + # Basic actions 4 + save = Save 5 + add = Add 6 + update = Update 7 + remove = Remove 8 + submit = Submit 9 + view = View 10 + clear = Clear 11 + reset = Reset 12 + 13 + # Specific actions 14 + update-event = Update Event 15 + remove-entry = Remove 16 + follow = Follow 17 + unfollow = Unfollow 18 + login = Login 19 + create-rsvp = Create RSVP 20 + record-rsvp = Record RSVP 21 + import-event = Import Event 22 + 23 + # Admin actions 24 + manage-handles = Manage known handles 25 + manage-denylist = Manage blocked identities 26 + view-events = View all events ordered by recent updates 27 + view-rsvps = View all RSVPs ordered by recent updates 28 + import-rsvp = Import RSVP 29 + nuke-identity = Nuke Identity 30 + 31 + # Admin confirmations and warnings 32 + confirm-nuke-identity = Are you sure you want to nuke this identity? This will delete all records and add the handle, PDS, and DID to the denylist. 33 + 34 + # Event actions 35 + planned = Planned 36 + scheduled = Scheduled 37 + cancelled = Cancelled 38 + postponed = Postponed 39 + rescheduled = Rescheduled 40 + 41 + # Status options for events 42 + status-active = Active 43 + 44 + # Status options for RSVPs 45 + status-going = Going 46 + status-interested = Interested 47 + status-not-going = Not Going 48 + 49 + # Event modes 50 + 51 + # Location types 52 + location-type-venue = Venue 53 + location-type-coordinates = Coordinates 54 + location-type-virtual = Virtual
+63
i18n/en-us/common.ftl
··· 1 + # Common UI elements - English (US) 2 + 3 + # Basic greetings 4 + welcome = Welcome! 5 + hello = Hello 6 + 7 + # Gender-aware greetings 8 + profile-greeting = Hello there 9 + profile-greeting-feminine = Hello miss 10 + profile-greeting-masculine = Hello sir 11 + profile-greeting-neutral = Hello there 12 + 13 + welcome-user = Welcome {$name}! 14 + welcome-user-feminine = Welcome miss {$name}! 15 + welcome-user-masculine = Welcome sir {$name}! 16 + welcome-user-neutral = Welcome {$name}! 17 + 18 + # Actions 19 + save-changes = Save Changes 20 + cancel = Cancel 21 + delete = Delete 22 + edit = Edit 23 + create = Create 24 + back = Back 25 + next = Next 26 + previous = Previous 27 + close = Close 28 + loading = Loading... 29 + 30 + # Navigation 31 + home = Home 32 + events = Events 33 + profile = Profile 34 + settings = Settings 35 + admin = Admin 36 + logout = Logout 37 + 38 + # Profile related 39 + display-name = Display Name 40 + handle = Handle 41 + member-since = Member Since 42 + 43 + # Event related 44 + event-title = Event Title 45 + event-description = Event Description 46 + create-event = Create Event 47 + edit-event = Edit Event 48 + view-event = View Event 49 + events-created = { $count -> 50 + [0] No events created 51 + [1] One event created 52 + *[other] {$count} events created 53 + } 54 + 55 + # Forms 56 + enter-name-placeholder = Enter your name 57 + enter-email-placeholder = Enter your email 58 + required-field = This field is required 59 + 60 + # Messages 61 + success-saved = Successfully saved 62 + error-occurred = An error occurred 63 + validation-error = Please check your input and try again
+50 -1
i18n/en-us/errors.ftl
··· 1 - error-unknown-1 = Unknown error 1 + # Error messages and validation - English (US) 2 + 3 + # Form validation 4 + validation-required = This field is required 5 + validation-email = Please enter a valid email 6 + validation-minlength = Must be at least {$min} characters 7 + validation-maxlength = Must be no more than {$max} characters 8 + validation-name-length = Must be at least 10 characters and no more than 500 characters 9 + validation-description-length = Must be at least 10 characters and no more than 3000 characters 10 + 11 + # Error messages 12 + error-unknown = Unknown error 13 + form-submit-error = Unable to submit form 14 + profile-not-found = Profile not found 15 + event-creation-failed = Failed to create event 16 + event-update-failed = Failed to update event 17 + 18 + # Help text 19 + help-subject-uri = URI of the content to block (at URI, DIDs, URLs, domains) 20 + help-reason-blocking = Reason for blocking this content 21 + 22 + # Error pages 23 + error-404-title = Page Not Found 24 + error-404-message = The page you are looking for does not exist. 25 + error-500-title = Internal Server Error 26 + error-500-message = An unexpected error occurred. 27 + error-403-title = Access Denied 28 + error-403-message = You do not have permission to access this resource. 29 + 30 + # Form validation errors 31 + error-required-field = This field is required 32 + error-invalid-email = Invalid email address 33 + error-invalid-handle = Invalid handle 34 + error-handle-taken = This handle is already taken 35 + error-password-too-short = Password must be at least 8 characters 36 + error-passwords-dont-match = Passwords do not match 37 + 38 + # Database errors 39 + error-database-connection = Database connection error 40 + error-database-timeout = Database timeout exceeded 41 + 42 + # Authentication errors 43 + error-invalid-credentials = Invalid credentials 44 + error-account-locked = Account locked 45 + error-session-expired = Session expired 46 + 47 + # File upload errors 48 + error-file-too-large = File is too large 49 + error-invalid-file-type = Invalid file type 50 + error-upload-failed = Upload failed
+60
i18n/en-us/forms.ftl
··· 1 + # Form labels, placeholders, and help text - English (US) 2 + 3 + # Form field labels 4 + label-name = Name 5 + label-text = Text 6 + label-description = Description 7 + label-subject = Subject 8 + label-reason = Reason 9 + label-status = Status 10 + label-display-name = Display Name 11 + label-handle = Handle 12 + label-email = Email 13 + label-password = Password 14 + label-location-name = Location Name 15 + label-address = Address 16 + label-city = City 17 + label-state = State 18 + label-zip = ZIP Code 19 + label-link-name = Link Name 20 + label-link-url = Link URL 21 + label-timezone = Timezone 22 + label-start-day = Start Day 23 + label-start-time = Start Time 24 + label-end-day = End Day 25 + label-end-time = End Time 26 + label-starts-at = Starts At 27 + label-ends-at = Ends At 28 + label-country = Country 29 + label-street-address = Street Address 30 + label-locality = Locality 31 + label-region = Region 32 + label-postal-code = Postal Code 33 + label-location = Location 34 + label-event-at-uri = Event AT-URI 35 + label-at-uri = AT-URI 36 + 37 + # Form placeholders 38 + placeholder-tickets-url = https://smokesignal.tickets/ 39 + placeholder-at-uri-event = at://smokesignal.events/community.lexicon.calendar.event/neat 40 + placeholder-at-uri-rsvp = at://did:plc:abc123/app.bsky.feed.post/record123 41 + placeholder-at-uri-admin = at://did:plc:abcdef/community.lexicon.calendar.rsvp/3jizzrxoalv2h 42 + 43 + # Help text 44 + help-name-length = Must be at least 10 characters and no more than 500 characters 45 + help-description-length = Must be at least 10 characters and no more than 3000 characters 46 + help-rsvp-public = RSVPs are public and can be viewed by anyone that can view the information stored in your PDS. 47 + help-rsvp-learn-more = Learn more about rsvps on the 48 + help-rsvp-help-page = RSVP Help 49 + 50 + # Required field indicators 51 + optional-field = (optional) 52 + 53 + # Time and date 54 + not-set = Not Set 55 + add-end-time = Add End Time 56 + remove-end-time = Remove End Time 57 + 58 + # Authentication forms 59 + label-sign-in = Sign-In 60 + placeholder-handle-login = you.bsky.social
+366
i18n/en-us/ui.ftl
··· 1 + # User interface labels and text - English (US) 2 + 3 + # Page titles and headings 4 + page-title-admin = Smoke Signal Admin 5 + page-title-create-event = Smoke Signal - Create Event 6 + page-title-edit-event = Smoke Signal - Edit Event 7 + page-title-filter-events = Find Events - Smoke Signal 8 + page-title-import = Smoke Signal - Import 9 + page-title-view-rsvp = RSVP Viewer - Smoke Signal 10 + page-description-filter-events = Discover local events and activities in your community 11 + acknowledgement = Acknowledgement 12 + administration-tools = Administration Tools 13 + 14 + # Section headings 15 + what-are-smoke-signals = What are smoke signals? 16 + why-the-name = Why the name? 17 + land-acknowledgement = Land Acknowledgement 18 + learning-more = Learning More 19 + 20 + # Admin interface 21 + denylist = Denylist 22 + handle-records = Handle Records 23 + event-records = Event Records 24 + rsvp-records = RSVP Records 25 + event-record = Event Record 26 + add-update-entry = Add or Update Entry 27 + 28 + # Table headers 29 + subject = Subject 30 + reason = Reason 31 + updated = Updated 32 + actions = Actions 33 + 34 + # Form labels 35 + name-required = Name (required) 36 + text-required = Text (required) 37 + status = Status 38 + mode = Mode 39 + location = Location 40 + email = Email 41 + 42 + # Event status options 43 + status-planned = Planned 44 + status-scheduled = Scheduled 45 + status-cancelled = Cancelled 46 + status-postponed = Postponed 47 + status-rescheduled = Rescheduled 48 + 49 + # Event mode options 50 + mode-virtual = Virtual 51 + mode-hybrid = Hybrid 52 + mode-inperson = In Person 53 + 54 + # Location warnings 55 + location-cannot-edit = Location cannot be edited 56 + location-edit-restriction = Only events with a single location of type "Address" can be edited through this form. 57 + no-location-info = No location information available. 58 + 59 + # Location types 60 + location-type-link = Link 61 + location-type-address = Address 62 + location-type-other = Other location type 63 + 64 + # Placeholders 65 + placeholder-awesome-event = My Awesome Event 66 + placeholder-event-description = A helpful, brief description of the event 67 + placeholder-at-uri = at://did:plc:... 68 + placeholder-reason-blocking = Reason for blocking... 69 + placeholder-handle = you.bsky.social 70 + placeholder-tickets = Tickets 71 + placeholder-venue-name = The Gem City 72 + placeholder-address = 555 Somewhere 73 + placeholder-city = Dayton 74 + placeholder-state = Ohio 75 + placeholder-zip = 11111 76 + placeholder-rsvp-aturi = at://did:plc:example/community.lexicon.calendar.rsvp/abcdef123 77 + 78 + # Navigation 79 + nav-home = Home 80 + nav-events = Events 81 + nav-profile = Profile 82 + nav-settings = Settings 83 + nav-admin = Admin 84 + nav-logout = Logout 85 + 86 + # Content messages 87 + events-count = You have {$count -> 88 + [0] no events 89 + [1] 1 event 90 + *[other] {$count} events 91 + } 92 + back-to-profile = Back to Profile 93 + 94 + # Success messages 95 + event-created-success = The event has been created! 96 + event-updated-success = The event has been updated! 97 + 98 + # Info messages 99 + events-public-notice = Events are public and can be viewed by anyone that can view the information stored in your PDS. Do not publish personal or sensitive information in your events. 100 + event-help-link = Event Help 101 + help-rsvp-aturi = Enter the full AT-URI of the RSVP you want to view 102 + 103 + # Page titles and headings - English (US) 104 + 105 + # Admin and configuration pages 106 + page-title-admin-denylist = Admin - Denylist 107 + page-title-admin-events = Events - Smoke Signal Admin 108 + page-title-admin-rsvps = RSVPs - Smoke Signal Admin 109 + page-title-admin-rsvp = RSVP Record - Smoke Signal Admin 110 + page-title-admin-event = Event Record - Smoke Signal Admin 111 + page-title-admin-handles = Handles - Smoke Signal Admin 112 + page-title-create-rsvp = Create RSVP 113 + page-title-login = Smoke Signal - Login 114 + page-title-settings = Settings - Smoke Signal 115 + page-title-event-migration = Event Migration Complete - Smoke Signal 116 + page-title-view-event = Smoke Signal 117 + page-title-profile = Smoke Signal 118 + page-title-alert = Smoke Signal 119 + 120 + # Event and RSVP viewing 121 + message-legacy-event = You are viewing a older version of this event. 122 + message-view-latest = View Latest 123 + message-migrate-event = Migrate to Lexicon Community Event 124 + message-fallback-collection = This event was found in the "{$collection}" collection. 125 + message-edit-event = Edit Event 126 + message-create-rsvp = Create RSVP 127 + 128 + # Authentication and login 129 + login-instructions = Sign into Smoke Signal using your full ATProto handle. 130 + login-quick-start = The {$link} is a step-by-step guide to getting started. 131 + login-quick-start-link = Quick Start Guide 132 + login-trouble = Trouble signing in? 133 + 134 + # Page headings and content 135 + heading-admin = Admin 136 + heading-admin-denylist = Denylist 137 + heading-admin-events = Event Records 138 + heading-admin-rsvps = RSVP Records 139 + heading-admin-rsvp = RSVP Record 140 + heading-admin-event = Event Record 141 + heading-admin-handles = Handle Records 142 + heading-create-event = Create Event 143 + heading-create-rsvp = Create RSVP 144 + heading-import-event = Import Event by AT-URI 145 + heading-import-rsvp = Import RSVP 146 + heading-rsvp-details = RSVP Details 147 + heading-rsvp-json = RSVP JSON 148 + heading-rsvp-viewer = RSVP Viewer 149 + heading-settings = Settings 150 + heading-import = Import 151 + heading-edit-event = Edit Event 152 + 153 + # Status and notification messages 154 + message-rsvp-recorded = The RSVP has been recorded! 155 + message-rsvp-import-success = RSVP imported successfully! 156 + message-view-rsvp = View RSVP 157 + message-no-results = No results found. 158 + 159 + # Navigation and breadcrumbs 160 + nav-rsvps = RSVPs 161 + nav-denylist = Denylist 162 + nav-handles = Handles 163 + nav-rsvp-record = RSVP Record 164 + nav-event-record = Event Record 165 + nav-help = Help 166 + nav-blog = Blog 167 + nav-your-profile = Your Profile 168 + nav-add-event = Add Event 169 + nav-login = Log in 170 + 171 + # Footer navigation 172 + footer-support = Support 173 + footer-privacy-policy = Privacy Policy 174 + footer-cookie-policy = Cookie Policy 175 + footer-terms-of-service = Terms of Service 176 + footer-acknowledgement = Acknowledgement 177 + footer-made-by = made by 178 + footer-source-code = Source Code 179 + 180 + # Table headers 181 + header-name = Name 182 + header-updated = Updated 183 + header-actions = Actions 184 + header-rsvp = RSVP 185 + header-event = Event 186 + header-status = Status 187 + header-did = DID 188 + header-handle = Handle 189 + header-pds = PDS 190 + header-language = Language 191 + header-timezone = Timezone 192 + 193 + # Descriptions and subtitles 194 + subtitle-admin-events = View all events ordered by recent updates 195 + subtitle-admin-rsvps = View all RSVPs ordered by recent updates 196 + subtitle-admin-handles = View known handles 197 + help-import-aturi = Enter the full AT-URI of the event to import 198 + help-import-rsvp-aturi = Enter the AT-URI of an RSVP to import - supports both "community.lexicon.calendar.rsvp" and "events.smokesignal.calendar.rsvp" collections 199 + 200 + # Common UI elements 201 + greeting = Hello 202 + greeting-masculine = Hello 203 + greeting-feminine = Hello 204 + greeting-neutral = Hello 205 + timezone = timezone 206 + event-id = Event ID 207 + total-count = { $count -> 208 + [one] ({ $count }) 209 + *[other] ({ $count }) 210 + } 211 + 212 + # Technical labels and identifiers 213 + label-aturi = AT-URI 214 + label-cid = CID 215 + label-did = DID 216 + label-lexicon = Lexicon 217 + label-event-aturi = Event AT-URI 218 + label-event-cid = Event CID 219 + label-rsvp-details = RSVP Details 220 + label-rsvp-json = RSVP JSON 221 + label-rsvp-aturi = RSVP AT-URI 222 + 223 + # Home page 224 + page-title-home = Smoke Signal 225 + page-description-home = Smoke Signal is an event and RSVP management system. 226 + 227 + # Utility pages 228 + page-title-privacy-policy = Privacy Policy - Smoke Signal 229 + page-title-cookie-policy = Cookie Policy - Smoke Signal 230 + page-title-terms-of-service = Terms of Service - Smoke Signal 231 + page-title-acknowledgement = Acknowledgement - Smoke Signal 232 + 233 + # Event viewing - maps and links 234 + link-apple-maps = Apple Maps 235 + link-google-maps = Google Maps 236 + text-event-link = Event Link 237 + message-view-latest-rsvps = View latest version to see RSVPs 238 + 239 + # Event status tooltips 240 + tooltip-cancelled = The event is cancelled. 241 + tooltip-postponed = The event is postponed. 242 + tooltip-no-status = No event status set. 243 + tooltip-in-person = In person 244 + tooltip-virtual = A virtual (online) event 245 + tooltip-hybrid = A hybrid in-person and virtual (online) event 246 + 247 + # RSVP login message 248 + message-login-to-rsvp = Log in to RSVP to this 249 + 250 + # Event viewing - edit button 251 + button-edit = Edit 252 + 253 + # Event status labels 254 + label-no-status = No Status Set 255 + 256 + # Time labels 257 + label-no-start-time = No Start Time Set 258 + label-no-end-time = No End Time Set 259 + tooltip-starts-at = Starts at {$time} 260 + tooltip-ends-at = Ends at {$time} 261 + tooltip-no-start-time = No start time is set. 262 + tooltip-no-end-time = No end time is set. 263 + 264 + # RSVP buttons and status 265 + button-going = Going 266 + button-interested = Interested 267 + button-not-going = Not Going 268 + message-no-rsvp = You have not RSVP'd. 269 + message-rsvp-going = You have RSVP'd <strong>Going</strong>. 270 + message-rsvp-interested = You have RSVP'd <strong>Interested</strong>. 271 + message-rsvp-not-going = You have RSVP'd <strong>Not Going</strong>. 272 + 273 + # Tab labels for RSVP lists 274 + tab-going = Going ({$count}) 275 + tab-interested = Interested ({$count}) 276 + tab-not-going = Not Going ({$count}) 277 + 278 + # Legacy event messages 279 + message-rsvps-not-available = RSVPs are not available for legacy events. 280 + message-use-standard-version = Please use the <a href="{$url}">standard version</a> of this event to RSVP. 281 + button-migrate-rsvp = Migrate my RSVP to Lexicon Community Event 282 + message-rsvp-migrated = Your RSVP has been migrated 283 + message-rsvp-info-not-available = RSVP information is not available for legacy events. 284 + message-view-latest-to-see-rsvps = View latest version to see RSVPs 285 + 286 + # Settings sub-templates 287 + label-language = Language 288 + label-time-zone = Time Zone 289 + message-language-updated = Language updated successfully. 290 + message-timezone-updated = Time zone updated successfully. 291 + 292 + # Event list - role status labels 293 + role-going = Going 294 + role-interested = Interested 295 + role-not-going = Not Going 296 + role-organizer = Organizer 297 + role-unknown = Unknown 298 + label-legacy = Legacy 299 + 300 + # Event list - mode labels and tooltips 301 + mode-in-person = In Person 302 + 303 + # Event list - RSVP count tooltips 304 + tooltip-count-going = {$count} Going 305 + tooltip-count-interested = {$count} Interested 306 + tooltip-count-not-going = {$count} Not Going 307 + 308 + # Event list - status tooltips 309 + tooltip-planned = The event is planned. 310 + tooltip-scheduled = The event is scheduled. 311 + tooltip-rescheduled = The event is rescheduled. 312 + 313 + # Pagination 314 + pagination-previous = Previous 315 + pagination-next = Next 316 + 317 + # Home Page 318 + site-name = Smoke Signal 319 + site-tagline = Find events, make connections, and create community. 320 + home-quick-start = The <a href="https://docs.smokesignal.events/docs/getting-started/quick-start/">Quick Start Guide</a> is a step-by-step guide to getting started! 321 + home-recent-events = Recently Updated Events 322 + 323 + # Import Functionality 324 + import-complete = Import complete! 325 + import-start = Start Import 326 + import-continue = Continue Import 327 + import-complete-button = Import Complete 328 + 329 + # Navigation and Branding 330 + nav-logo-alt = Smoke Signal 331 + 332 + # Profile Page Meta 333 + profile-meta-description = {"@"}{$handle} {$did} on Smoke Signal 334 + 335 + # Site Branding (used in meta tags and structured data) 336 + site-branding = Smoke Signal 337 + 338 + # Event Filtering Interface 339 + filter-events-title = Filter Events 340 + filter-search-label = Search Events 341 + filter-search-placeholder = Search by title, description, or keywords... 342 + filter-date-label = Date Range 343 + filter-location-label = Location 344 + filter-latitude-placeholder = Latitude 345 + filter-longitude-placeholder = Longitude 346 + filter-radius-placeholder = Radius (km) 347 + filter-creator-label = Event Creator 348 + filter-creator-all = All Creators 349 + filter-sort-label = Sort By 350 + filter-sort-newest = Newest Events 351 + filter-sort-oldest = Upcoming Events 352 + filter-sort-recently-created = Recently Created 353 + filter-sort-distance = Nearest to You 354 + filter-apply-button = Apply Filters 355 + filter-active-filters = Active Filters 356 + filter-results-title = Events 357 + filter-results-per-page = per page 358 + filter-no-results-title = No events found 359 + filter-no-results-subtitle = Try adjusting your search criteria or clear filters 360 + filter-clear-all = Clear All Filters 361 + 362 + # Event Cards 363 + event-by = by 364 + event-rsvps = RSVPs 365 + event-view-details = View Details 366 + event-rsvp = RSVP
+54
i18n/fr-ca/actions.ftl
··· 1 + # Boutons d'action et contrôles - Français (Canada) 2 + 3 + # Actions de base 4 + save = Sauvegarder 5 + add = Ajouter 6 + update = Mettre à jour 7 + remove = Supprimer 8 + submit = Soumettre 9 + view = Voir 10 + clear = Effacer 11 + reset = Réinitialiser 12 + 13 + # Actions spécifiques 14 + update-event = Mettre à jour l'événement 15 + remove-entry = Supprimer 16 + follow = Suivre 17 + unfollow = Ne plus suivre 18 + login = Connexion 19 + create-rsvp = Créer une RSVP 20 + record-rsvp = Enregistrer une RSVP 21 + import-event = Importer un événement 22 + 23 + # Actions administratives 24 + manage-handles = Gérer les identifiants connus 25 + manage-denylist = Gérer les identités bloquées 26 + view-events = Voir tous les événements par mises à jour récentes 27 + view-rsvps = Voir toutes les RSVP par mises à jour récentes 28 + import-rsvp = Importer une RSVP 29 + nuke-identity = Supprimer l'identité 30 + 31 + # Confirmations et avertissements administratifs 32 + confirm-nuke-identity = Es-tu sûr de vouloir supprimer cette identité? Cette action va effacer tous les enregistrements et ajouter l'identifiant, le PDS et le DID à la liste de blocage. 33 + 34 + # Actions d'événement 35 + planned = Planifié 36 + scheduled = Programmé 37 + cancelled = Annulé 38 + postponed = Reporté 39 + rescheduled = Reprogrammé 40 + 41 + # Options de statut pour les événements 42 + status-active = Actif 43 + 44 + # Options de statut pour les RSVP 45 + status-going = J'y vais 46 + status-interested = Intéressé 47 + status-not-going = Je n'y vais pas 48 + 49 + # Modes d'événement 50 + 51 + # Types d'emplacement 52 + location-type-venue = Lieu 53 + location-type-coordinates = Coordonnées 54 + location-type-virtual = Virtuel
+63
i18n/fr-ca/common.ftl
··· 1 + # Éléments communs de l'interface utilisateur - Français (Canada) 2 + 3 + # Salutations de base 4 + welcome = Bienvenue! 5 + hello = Bonjour 6 + 7 + # Salutations selon le genre 8 + profile-greeting = Salut 9 + profile-greeting-feminine = Salut 10 + profile-greeting-masculine = Salut 11 + profile-greeting-neutral = Salut 12 + 13 + welcome-user = Bienvenue {$name}! 14 + welcome-user-feminine = Bienvenue {$name}! 15 + welcome-user-masculine = Bienvenue {$name}! 16 + welcome-user-neutral = Bienvenue {$name}! 17 + 18 + # Actions 19 + save-changes = Sauvegarder les changements 20 + cancel = Annuler 21 + delete = Supprimer 22 + edit = Modifier 23 + create = Créer 24 + back = Retour 25 + next = Suivant 26 + previous = Précédent 27 + close = Fermer 28 + loading = Chargement en cours... 29 + 30 + # Navigation 31 + home = Accueil 32 + events = Événements 33 + profile = Profil 34 + settings = Paramètres 35 + admin = Admin 36 + logout = Déconnexion 37 + 38 + # Lié au profil 39 + display-name = Nom d'affichage 40 + handle = Identifiant 41 + member-since = Membre depuis 42 + 43 + # Lié aux événements 44 + event-title = Titre de l'événement 45 + event-description = Description de l'événement 46 + create-event = Créer un événement 47 + edit-event = Modifier un événement 48 + view-event = Voir l'événement 49 + events-created = { $count -> 50 + [0] Aucun événement créé 51 + [1] Un événement créé 52 + *[other] {$count} événements créés 53 + } 54 + 55 + # Formulaires 56 + enter-name-placeholder = Entre ton nom 57 + enter-email-placeholder = Entre ton courriel 58 + required-field = Ce champ est requis 59 + 60 + # Messages 61 + success-saved = Sauvegardé avec succès 62 + error-occurred = Une erreur est survenue 63 + validation-error = Vérifie tes informations et essaie à nouveau
+50
i18n/fr-ca/errors.ftl
··· 1 + # Messages d'erreur et validation - Français (Canada) 2 + 3 + # Validation de formulaire 4 + validation-required = Ce champ est requis 5 + validation-email = Entre un courriel valide 6 + validation-minlength = Doit contenir au moins {$min} caractères 7 + validation-maxlength = Ne doit pas dépasser {$max} caractères 8 + validation-name-length = Doit contenir au moins 10 caractères et pas plus de 500 caractères 9 + validation-description-length = Doit contenir au moins 10 caractères et pas plus de 3000 caractères 10 + 11 + # Messages d'erreur 12 + error-unknown = Erreur inconnue 13 + form-submit-error = Impossible de soumettre le formulaire 14 + profile-not-found = Profil introuvable 15 + event-creation-failed = Échec de création de l'événement 16 + event-update-failed = Échec de mise à jour de l'événement 17 + 18 + # Textes d'aide 19 + help-subject-uri = URI du contenu à bloquer (URI AT, DIDs, URLs, domaines) 20 + help-reason-blocking = Raison du blocage de ce contenu 21 + 22 + # Pages d'erreur 23 + error-404-title = Page introuvable 24 + error-404-message = La page que tu cherches n'existe pas. 25 + error-500-title = Erreur interne du serveur 26 + error-500-message = Une erreur inattendue s'est produite. 27 + error-403-title = Accès refusé 28 + error-403-message = Tu n'as pas la permission d'accéder à cette ressource. 29 + 30 + # Erreurs de validation de formulaire 31 + error-required-field = Ce champ est requis 32 + error-invalid-email = Adresse courriel invalide 33 + error-invalid-handle = Identifiant invalide 34 + error-handle-taken = Cet identifiant est déjà pris 35 + error-password-too-short = Le mot de passe doit contenir au moins 8 caractères 36 + error-passwords-dont-match = Les mots de passe ne correspondent pas 37 + 38 + # Erreurs de base de données 39 + error-database-connection = Erreur de connexion à la base de données 40 + error-database-timeout = Délai de connexion à la base de données dépassé 41 + 42 + # Erreurs d'authentification 43 + error-invalid-credentials = Identifiants invalides 44 + error-account-locked = Compte verrouillé 45 + error-session-expired = Session expirée 46 + 47 + # Erreurs de téléchargement de fichier 48 + error-file-too-large = Fichier trop volumineux 49 + error-invalid-file-type = Type de fichier invalide 50 + error-upload-failed = Échec du téléchargement
+60
i18n/fr-ca/forms.ftl
··· 1 + # Étiquettes, textes indicatifs et aide pour les formulaires - Français (Canada) 2 + 3 + # Étiquettes de champs de formulaire 4 + label-name = Nom 5 + label-text = Texte 6 + label-description = Description 7 + label-subject = Sujet 8 + label-reason = Raison 9 + label-status = Statut 10 + label-display-name = Nom d'affichage 11 + label-handle = Identifiant 12 + label-email = Courriel 13 + label-password = Mot de passe 14 + label-location-name = Nom du lieu 15 + label-address = Adresse 16 + label-city = Ville 17 + label-state = Province 18 + label-zip = Code postal 19 + label-link-name = Nom du lien 20 + label-link-url = URL du lien 21 + label-timezone = Fuseau horaire 22 + label-start-day = Jour de début 23 + label-start-time = Heure de début 24 + label-end-day = Jour de fin 25 + label-end-time = Heure de fin 26 + label-starts-at = Commence à 27 + label-ends-at = Se termine à 28 + label-country = Pays 29 + label-street-address = Adresse civique 30 + label-locality = Localité 31 + label-region = Région 32 + label-postal-code = Code postal 33 + label-location = Lieu 34 + label-event-at-uri = AT-URI de l'événement 35 + label-at-uri = AT-URI 36 + 37 + # Textes indicatifs 38 + placeholder-tickets-url = https://smokesignal.tickets/ 39 + placeholder-at-uri-event = at://smokesignal.events/community.lexicon.calendar.event/neat 40 + placeholder-at-uri-rsvp = at://did:plc:abc123/app.bsky.feed.post/record123 41 + placeholder-at-uri-admin = at://did:plc:abcdef/community.lexicon.calendar.rsvp/3jizzrxoalv2h 42 + 43 + # Textes d'aide 44 + help-name-length = Doit contenir au moins 10 caractères et pas plus de 500 caractères 45 + help-description-length = Doit contenir au moins 10 caractères et pas plus de 3000 caractères 46 + help-rsvp-public = Les RSVP sont publiques et peuvent être vues par quiconque peut voir les informations stockées dans ton PDS. 47 + help-rsvp-learn-more = En savoir plus sur les RSVP sur la 48 + help-rsvp-help-page = Page d'aide RSVP 49 + 50 + # Indicateurs de champs obligatoires 51 + optional-field = (optionnel) 52 + 53 + # Heure et date 54 + not-set = Non défini 55 + add-end-time = Ajouter une heure de fin 56 + remove-end-time = Supprimer l'heure de fin 57 + 58 + # Formulaires d'authentification 59 + label-sign-in = Connexion 60 + placeholder-handle-login = toi.bsky.social
+366
i18n/fr-ca/ui.ftl
··· 1 + # Libellés et textes de l'interface utilisateur - Français (Canada) 2 + 3 + # Titres de page et en-têtes 4 + page-title-admin = Administration de Smoke Signal 5 + page-title-create-event = Smoke Signal - Créer un événement 6 + page-title-edit-event = Smoke Signal - Modifier un événement 7 + page-title-filter-events = Trouver des événements - Smoke Signal 8 + page-title-import = Smoke Signal - Importation 9 + page-title-view-rsvp = Visualiseur RSVP - Smoke Signal 10 + page-description-filter-events = Découvre des événements et activités locaux dans ta communauté 11 + acknowledgement = Reconnaissance 12 + administration-tools = Outils d'administration 13 + 14 + # En-têtes de section 15 + what-are-smoke-signals = Que sont les signaux de fumée? 16 + why-the-name = Pourquoi ce nom? 17 + land-acknowledgement = Reconnaissance territoriale 18 + learning-more = En savoir plus 19 + 20 + # Interface d'administration 21 + denylist = Liste de blocage 22 + handle-records = Registre des identifiants 23 + event-records = Registre des événements 24 + rsvp-records = Registre des RSVP 25 + event-record = Enregistrement d'événement 26 + add-update-entry = Ajouter ou mettre à jour une entrée 27 + 28 + # En-têtes de tableau 29 + subject = Sujet 30 + reason = Raison 31 + updated = Mis à jour 32 + actions = Actions 33 + 34 + # Étiquettes de formulaire 35 + name-required = Nom (requis) 36 + text-required = Texte (requis) 37 + status = Statut 38 + mode = Mode 39 + location = Lieu 40 + email = Courriel 41 + 42 + # Options de statut d'événement 43 + status-planned = Planifié 44 + status-scheduled = Programmé 45 + status-cancelled = Annulé 46 + status-postponed = Reporté 47 + status-rescheduled = Reprogrammé 48 + 49 + # Options de mode d'événement 50 + mode-virtual = Virtuel 51 + mode-hybrid = Hybride 52 + mode-inperson = En personne 53 + 54 + # Avertissements liés au lieu 55 + location-cannot-edit = Le lieu ne peut pas être modifié 56 + location-edit-restriction = Seuls les événements avec un seul lieu de type "Adresse" peuvent être modifiés via ce formulaire. 57 + no-location-info = Aucune information de lieu disponible. 58 + 59 + # Types de lieu 60 + location-type-link = Lien 61 + location-type-address = Adresse 62 + location-type-other = Autre type de lieu 63 + 64 + # Textes indicatifs 65 + placeholder-awesome-event = Mon événement génial 66 + placeholder-event-description = Une description brève et utile de l'événement 67 + placeholder-at-uri = at://did:plc:... 68 + placeholder-reason-blocking = Raison du blocage... 69 + placeholder-handle = toi.bsky.social 70 + placeholder-tickets = Billets 71 + placeholder-venue-name = La Ville des Gemmes 72 + placeholder-address = 555 Quelque Part 73 + placeholder-city = Montréal 74 + placeholder-state = Québec 75 + placeholder-zip = H1H 1H1 76 + placeholder-rsvp-aturi = at://did:plc:example/community.lexicon.calendar.rsvp/abcdef123 77 + 78 + # Navigation 79 + nav-home = Accueil 80 + nav-events = Événements 81 + nav-profile = Profil 82 + nav-settings = Paramètres 83 + nav-admin = Admin 84 + nav-logout = Déconnexion 85 + 86 + # Messages de contenu 87 + events-count = Tu as {$count -> 88 + [0] aucun événement 89 + [1] 1 événement 90 + *[other] {$count} événements 91 + } 92 + back-to-profile = Retour au profil 93 + 94 + # Messages de succès 95 + event-created-success = L'événement a été créé! 96 + event-updated-success = L'événement a été mis à jour! 97 + 98 + # Messages d'information 99 + events-public-notice = Les événements sont publics et peuvent être vus par quiconque peut voir les informations stockées dans ton PDS. Ne publie pas d'informations personnelles ou sensibles dans tes événements. 100 + event-help-link = Aide sur les événements 101 + help-rsvp-aturi = Entre l'AT-URI complet de la RSVP que tu veux voir 102 + 103 + # Titres de page et en-têtes - Français (Canada) 104 + 105 + # Pages d'administration et de configuration 106 + page-title-admin-denylist = Admin - Liste de blocage 107 + page-title-admin-events = Événements - Admin Smoke Signal 108 + page-title-admin-rsvps = RSVPs - Admin Smoke Signal 109 + page-title-admin-rsvp = Enregistrement RSVP - Admin Smoke Signal 110 + page-title-admin-event = Enregistrement d'événement - Admin Smoke Signal 111 + page-title-admin-handles = Identifiants - Admin Smoke Signal 112 + page-title-create-rsvp = Créer une RSVP 113 + page-title-login = Smoke Signal - Connexion 114 + page-title-settings = Paramètres - Smoke Signal 115 + page-title-event-migration = Migration d'événement terminée - Smoke Signal 116 + page-title-view-event = Smoke Signal 117 + page-title-profile = Smoke Signal 118 + page-title-alert = Smoke Signal 119 + 120 + # Visualisation d'événement et de RSVP 121 + message-legacy-event = Tu consultes une ancienne version de cet événement. 122 + message-view-latest = Voir la dernière version 123 + message-migrate-event = Migrer vers un événement communautaire Lexicon 124 + message-fallback-collection = Cet événement a été trouvé dans la collection "{$collection}". 125 + message-edit-event = Modifier l'événement 126 + message-create-rsvp = Créer une RSVP 127 + 128 + # Authentification et connexion 129 + login-instructions = Connecte-toi à Smoke Signal en utilisant ton identifiant ATProto complet. 130 + login-quick-start = Le {$link} est un guide étape par étape pour démarrer. 131 + login-quick-start-link = Guide de démarrage rapide 132 + login-trouble = Problème de connexion? 133 + 134 + # En-têtes et contenu de page 135 + heading-admin = Admin 136 + heading-admin-denylist = Liste de blocage 137 + heading-admin-events = Registre des événements 138 + heading-admin-rsvps = Registre des RSVP 139 + heading-admin-rsvp = Enregistrement RSVP 140 + heading-admin-event = Enregistrement d'événement 141 + heading-admin-handles = Registre des identifiants 142 + heading-create-event = Créer un événement 143 + heading-create-rsvp = Créer une RSVP 144 + heading-import-event = Importer un événement par AT-URI 145 + heading-import-rsvp = Importer une RSVP 146 + heading-rsvp-details = Détails de la RSVP 147 + heading-rsvp-json = RSVP JSON 148 + heading-rsvp-viewer = Visualiseur RSVP 149 + heading-settings = Paramètres 150 + heading-import = Importation 151 + heading-edit-event = Modifier un événement 152 + 153 + # Messages d'état et notifications 154 + message-rsvp-recorded = La RSVP a été enregistrée! 155 + message-rsvp-import-success = RSVP importée avec succès! 156 + message-view-rsvp = Voir la RSVP 157 + message-no-results = Aucun résultat trouvé. 158 + 159 + # Navigation et fil d'Ariane 160 + nav-rsvps = RSVPs 161 + nav-denylist = Liste de blocage 162 + nav-handles = Identifiants 163 + nav-rsvp-record = Enregistrement RSVP 164 + nav-event-record = Enregistrement d'événement 165 + nav-help = Aide 166 + nav-blog = Blogue 167 + nav-your-profile = Ton profil 168 + nav-add-event = Ajouter un événement 169 + nav-login = Se connecter 170 + 171 + # Navigation de pied de page 172 + footer-support = Support 173 + footer-privacy-policy = Politique de confidentialité 174 + footer-cookie-policy = Politique des cookies 175 + footer-terms-of-service = Conditions d'utilisation 176 + footer-acknowledgement = Reconnaissance 177 + footer-made-by = fait par 178 + footer-source-code = Code source 179 + 180 + # En-têtes de tableau 181 + header-name = Nom 182 + header-updated = Mis à jour 183 + header-actions = Actions 184 + header-rsvp = RSVP 185 + header-event = Événement 186 + header-status = Statut 187 + header-did = DID 188 + header-handle = Identifiant 189 + header-pds = PDS 190 + header-language = Langue 191 + header-timezone = Fuseau horaire 192 + 193 + # Descriptions et sous-titres 194 + subtitle-admin-events = Voir tous les événements classés par mises à jour récentes 195 + subtitle-admin-rsvps = Voir toutes les RSVP classées par mises à jour récentes 196 + subtitle-admin-handles = Voir les identifiants connus 197 + help-import-aturi = Entre l'AT-URI complet de l'événement à importer 198 + help-import-rsvp-aturi = Entre l'AT-URI d'une RSVP à importer - supporte les collections "community.lexicon.calendar.rsvp" et "events.smokesignal.calendar.rsvp" 199 + 200 + # Éléments d'interface communs 201 + greeting = Salut 202 + greeting-masculine = Salut 203 + greeting-feminine = Salut 204 + greeting-neutral = Salut 205 + timezone = fuseau horaire 206 + event-id = ID de l'événement 207 + total-count = { $count -> 208 + [one] ({ $count }) 209 + *[other] ({ $count }) 210 + } 211 + 212 + # Étiquettes et identifiants techniques 213 + label-aturi = AT-URI 214 + label-cid = CID 215 + label-did = DID 216 + label-lexicon = Lexicon 217 + label-event-aturi = AT-URI de l'événement 218 + label-event-cid = CID de l'événement 219 + label-rsvp-details = Détails de la RSVP 220 + label-rsvp-json = RSVP JSON 221 + label-rsvp-aturi = AT-URI de la RSVP 222 + 223 + # Page d'accueil 224 + page-title-home = Smoke Signal 225 + page-description-home = Smoke Signal est un système de gestion d'événements et de RSVP. 226 + 227 + # Pages utilitaires 228 + page-title-privacy-policy = Politique de confidentialité - Smoke Signal 229 + page-title-cookie-policy = Politique des cookies - Smoke Signal 230 + page-title-terms-of-service = Conditions d'utilisation - Smoke Signal 231 + page-title-acknowledgement = Reconnaissance - Smoke Signal 232 + 233 + # Visualisation d'événements - cartes et liens 234 + link-apple-maps = Apple Maps 235 + link-google-maps = Google Maps 236 + text-event-link = Lien de l'événement 237 + message-view-latest-rsvps = Voir la dernière version pour voir les RSVP 238 + 239 + # Infobulles d'état d'événement 240 + tooltip-cancelled = L'événement est annulé. 241 + tooltip-postponed = L'événement est reporté. 242 + tooltip-no-status = Aucun statut d'événement défini. 243 + tooltip-in-person = En personne 244 + tooltip-virtual = Un événement virtuel (en ligne) 245 + tooltip-hybrid = Un événement hybride en personne et virtuel (en ligne) 246 + 247 + # Message de connexion RSVP 248 + message-login-to-rsvp = Connecte-toi pour répondre à cet événement 249 + 250 + # Visualisation d'événement - bouton modifier 251 + button-edit = Modifier 252 + 253 + # Étiquettes de statut d'événement 254 + label-no-status = Aucun statut défini 255 + 256 + # Étiquettes de temps 257 + label-no-start-time = Aucune heure de début définie 258 + label-no-end-time = Aucune heure de fin définie 259 + tooltip-starts-at = Commence à {$time} 260 + tooltip-ends-at = Se termine à {$time} 261 + tooltip-no-start-time = Aucune heure de début n'est définie. 262 + tooltip-no-end-time = Aucune heure de fin n'est définie. 263 + 264 + # Boutons RSVP et statut 265 + button-going = J'y vais 266 + button-interested = Intéressé 267 + button-not-going = Je n'y vais pas 268 + message-no-rsvp = Tu n'as pas encore répondu. 269 + message-rsvp-going = Tu as répondu <strong>J'y vais</strong>. 270 + message-rsvp-interested = Tu as répondu <strong>Intéressé</strong>. 271 + message-rsvp-not-going = Tu as répondu <strong>Je n'y vais pas</strong>. 272 + 273 + # Étiquettes d'onglets pour les listes RSVP 274 + tab-going = J'y vais ({$count}) 275 + tab-interested = Intéressés ({$count}) 276 + tab-not-going = N'y vont pas ({$count}) 277 + 278 + # Messages pour événements hérités 279 + message-rsvps-not-available = Les RSVP ne sont pas disponibles pour les événements hérités. 280 + message-use-standard-version = Utilise la <a href="{$url}">version standard</a> de cet événement pour répondre. 281 + button-migrate-rsvp = Migrer ma réponse vers un événement communautaire Lexicon 282 + message-rsvp-migrated = Ta réponse a été migrée 283 + message-rsvp-info-not-available = Les informations de réponse ne sont pas disponibles pour les événements hérités. 284 + message-view-latest-to-see-rsvps = Voir la dernière version pour voir les RSVP 285 + 286 + # Sous-modèles de paramètres 287 + label-language = Langue 288 + label-time-zone = Fuseau horaire 289 + message-language-updated = Langue mise à jour avec succès. 290 + message-timezone-updated = Fuseau horaire mis à jour avec succès. 291 + 292 + # Liste d'événements - étiquettes de rôle 293 + role-going = J'y vais 294 + role-interested = Intéressé 295 + role-not-going = Je n'y vais pas 296 + role-organizer = Organisateur 297 + role-unknown = Inconnu 298 + label-legacy = Hérité 299 + 300 + # Liste d'événements - étiquettes de mode et infobulles 301 + mode-in-person = En personne 302 + 303 + # Liste d'événements - infobulles de compte RSVP 304 + tooltip-count-going = {$count} y vont 305 + tooltip-count-interested = {$count} intéressés 306 + tooltip-count-not-going = {$count} n'y vont pas 307 + 308 + # Liste d'événements - infobulles de statut 309 + tooltip-planned = L'événement est planifié. 310 + tooltip-scheduled = L'événement est programmé. 311 + tooltip-rescheduled = L'événement est reprogrammé. 312 + 313 + # Pagination 314 + pagination-previous = Précédent 315 + pagination-next = Suivant 316 + 317 + # Page d'accueil 318 + site-name = Smoke Signal 319 + site-tagline = Trouve des événements, crée des connexions et forme une communauté. 320 + home-quick-start = Le <a href="https://docs.smokesignal.events/docs/getting-started/quick-start/">Guide de démarrage rapide</a> est un guide étape par étape pour commencer! 321 + home-recent-events = Événements récemment mis à jour 322 + 323 + # Fonctionnalité d'importation 324 + import-complete = Importation terminée! 325 + import-start = Démarrer l'importation 326 + import-continue = Continuer l'importation 327 + import-complete-button = Importation terminée 328 + 329 + # Navigation et marque 330 + nav-logo-alt = Smoke Signal 331 + 332 + # Méta profil de page 333 + profile-meta-description = {"@"}{$handle} {$did} sur Smoke Signal 334 + 335 + # Marque du site (utilisée dans les balises meta et les données structurées) 336 + site-branding = Smoke Signal 337 + 338 + # Interface de filtrage d'événements 339 + filter-events-title = Filtrer les événements 340 + filter-search-label = Rechercher des événements 341 + filter-search-placeholder = Rechercher par titre, description ou mots-clés... 342 + filter-date-label = Plage de dates 343 + filter-location-label = Lieu 344 + filter-latitude-placeholder = Latitude 345 + filter-longitude-placeholder = Longitude 346 + filter-radius-placeholder = Rayon (km) 347 + filter-creator-label = Créateur d'événement 348 + filter-creator-all = Tous les créateurs 349 + filter-sort-label = Trier par 350 + filter-sort-newest = Événements les plus récents 351 + filter-sort-oldest = Événements à venir 352 + filter-sort-recently-created = Créés récemment 353 + filter-sort-distance = Plus près de toi 354 + filter-apply-button = Appliquer les filtres 355 + filter-active-filters = Filtres actifs 356 + filter-results-title = Événements 357 + filter-results-per-page = par page 358 + filter-no-results-title = Aucun événement trouvé 359 + filter-no-results-subtitle = Essaie d'ajuster tes critères de recherche ou de supprimer les filtres 360 + filter-clear-all = Effacer tous les filtres 361 + 362 + # Cartes d'événement 363 + event-by = par 364 + event-rsvps = RSVP 365 + event-view-details = Voir les détails 366 + event-rsvp = Répondre
+1 -1
src/bin/smokesignal.rs
··· 88 88 AppEngine::from(jinja), 89 89 &http_client, 90 90 config.clone(), 91 - I18nContext::new(supported_languages, locales), 91 + I18nContext::new(), 92 92 dns_resolver, 93 93 ); 94 94
+90 -10
src/http/context.rs
··· 25 25 config::Config, 26 26 http::middleware_auth::Auth, 27 27 http::middleware_i18n::Language, 28 - i18n::Locales, 28 + i18n::{get_supported_languages, get_translation, Locales}, 29 29 storage::handle::model::Handle, 30 30 storage::{CachePool, StoragePool}, 31 31 }; ··· 38 38 pub locales: Locales, 39 39 } 40 40 41 + impl I18nContext { 42 + pub fn new() -> Self { 43 + let supported_languages = get_supported_languages(); 44 + let locales = Locales::new(supported_languages.clone()); 45 + Self { 46 + supported_languages, 47 + locales, 48 + } 49 + } 50 + 51 + /// Get a translation for the given locale and key 52 + pub fn get_translation(&self, locale: &LanguageIdentifier, key: &str) -> String { 53 + get_translation(locale, key, None) 54 + } 55 + 56 + /// Get a translation with arguments 57 + pub fn get_translation_with_args( 58 + &self, 59 + locale: &LanguageIdentifier, 60 + key: &str, 61 + args: std::collections::HashMap<std::borrow::Cow<'static, str>, fluent::FluentValue<'static>>, 62 + ) -> String { 63 + // Convert the HashMap to the expected format 64 + let converted_args: std::collections::HashMap<String, fluent_templates::fluent_bundle::FluentValue> = args 65 + .into_iter() 66 + .map(|(k, v)| { 67 + let converted_value = match v { 68 + fluent::FluentValue::String(s) => fluent_templates::fluent_bundle::FluentValue::String(s), 69 + fluent::FluentValue::Number(n) => fluent_templates::fluent_bundle::FluentValue::Number(n), 70 + fluent::FluentValue::None => fluent_templates::fluent_bundle::FluentValue::String("".into()), 71 + fluent::FluentValue::Error => fluent_templates::fluent_bundle::FluentValue::String("".into()), 72 + _ => fluent_templates::fluent_bundle::FluentValue::String("".into()), // Handle any other variants 73 + }; 74 + (k.to_string(), converted_value) 75 + }) 76 + .collect(); 77 + 78 + get_translation(locale, key, Some(converted_args)) 79 + } 80 + 81 + /// Check if a language is supported 82 + pub fn supports_language(&self, locale: &LanguageIdentifier) -> bool { 83 + self.supported_languages.contains(locale) 84 + } 85 + } 86 + 41 87 pub struct InnerWebContext { 42 88 pub engine: AppEngine, 43 89 pub http_client: reqwest::Client, ··· 81 127 } 82 128 } 83 129 84 - impl I18nContext { 85 - pub fn new(supported_languages: Vec<LanguageIdentifier>, locales: Locales) -> Self { 86 - Self { 87 - supported_languages, 88 - locales, 89 - } 90 - } 91 - } 92 - 93 130 impl FromRef<WebContext> for Key { 94 131 fn from_ref(context: &WebContext) -> Self { 95 132 context.0.config.http_cookie_key.as_ref().clone() ··· 139 176 ctx: &AdminRequestContext, 140 177 canonical_url: &str, 141 178 ) -> minijinja::value::Value { 179 + let supported_languages: Vec<String> = ctx.web_context.i18n_context.supported_languages 180 + .iter() 181 + .map(|lang| lang.to_string()) 182 + .collect(); 183 + 142 184 template_context! { 143 185 language => ctx.language.to_string(), 186 + locale => ctx.language.to_string(), 144 187 current_handle => ctx.admin_handle.clone(), 145 188 canonical_url => canonical_url, 189 + supported_languages => supported_languages, 146 190 } 147 191 } 148 192 ··· 175 219 }) 176 220 } 177 221 } 222 + 223 + /// Helper function to create standard template context for user views 224 + pub fn user_template_context( 225 + ctx: &UserRequestContext, 226 + canonical_url: &str, 227 + ) -> minijinja::value::Value { 228 + let supported_languages: Vec<String> = ctx.web_context.i18n_context.supported_languages 229 + .iter() 230 + .map(|lang| lang.to_string()) 231 + .collect(); 232 + 233 + template_context! { 234 + language => ctx.language.to_string(), 235 + locale => ctx.language.to_string(), 236 + current_handle => ctx.current_handle.clone(), 237 + canonical_url => canonical_url, 238 + supported_languages => supported_languages, 239 + } 240 + } 241 + 242 + /// Helper function to create basic template context with just locale information 243 + pub fn basic_template_context( 244 + language: &Language, 245 + supported_languages: &[LanguageIdentifier], 246 + ) -> minijinja::value::Value { 247 + let supported_languages: Vec<String> = supported_languages 248 + .iter() 249 + .map(|lang| lang.to_string()) 250 + .collect(); 251 + 252 + template_context! { 253 + language => language.to_string(), 254 + locale => language.to_string(), 255 + supported_languages => supported_languages, 256 + } 257 + }
+1 -5
src/http/macros.rs
··· 39 39 { 40 40 let (err_bare, err_partial) = $crate::errors::expand_error($error.to_string()); 41 41 tracing::warn!(error = ?$error, "encountered error"); 42 - let error_message = 43 - $web_context 44 - .i18n_context 45 - .locales 46 - .format_error(&$language, &err_bare, &err_partial); 42 + let error_message = $crate::i18n::fluent_loader::format_error(&$language, &err_bare, &err_partial); 47 43 Ok( 48 44 ( 49 45 $status_code,
+4
src/http/templates.rs
··· 23 23 24 24 use minijinja::{path_loader, Environment}; 25 25 use minijinja_autoreload::AutoReloader; 26 + use crate::i18n::register_i18n_functions; 26 27 27 28 pub fn build_env(http_external: &str, version: &str) -> AutoReloader { 28 29 let http_external = http_external.to_string(); ··· 35 36 env.add_global("base", format!("https://{}", http_external)); 36 37 env.add_global("version", version.clone()); 37 38 env.set_loader(path_loader(&template_path)); 39 + register_i18n_functions(&mut env); 38 40 notifier.set_fast_reload(true); 39 41 notifier.watch_path(&template_path, true); 40 42 Ok(env) ··· 45 47 #[cfg(feature = "embed")] 46 48 pub mod embed_env { 47 49 use minijinja::Environment; 50 + use crate::i18n::register_i18n_functions; 48 51 49 52 pub fn build_env(http_external: String, version: String) -> Environment<'static> { 50 53 let mut env = Environment::new(); ··· 52 55 env.set_lstrip_blocks(true); 53 56 env.add_global("base", format!("https://{}", http_external)); 54 57 env.add_global("version", version.clone()); 58 + register_i18n_functions(&mut env); 55 59 minijinja_embed::load_templates!(&mut env); 56 60 env 57 61 }
+2 -16
src/i18n.rs src/i18n_old.rs
··· 3 3 use std::collections::HashMap; 4 4 use unic_langid::LanguageIdentifier; 5 5 6 + // Define error types here instead of importing them 7 + // Using errors from i18n module 6 8 use crate::i18n::errors::I18nError; 7 9 8 10 pub type Bundle = FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>; ··· 159 161 Ok(()) 160 162 } 161 163 } 162 - 163 - pub mod errors { 164 - use thiserror::Error; 165 - 166 - #[derive(Debug, Error)] 167 - pub enum I18nError { 168 - #[error("error-i18n-1 Invalid language")] 169 - InvalidLanguage, 170 - 171 - #[error("error-i18n-2 Language resource failed")] 172 - LanguageResourceFailed(Vec<fluent_syntax::parser::ParserError>), 173 - 174 - #[error("error-i18n-3 Bundle load failed")] 175 - BundleLoadFailed(Vec<fluent::FluentError>), 176 - } 177 - }
+37
src/i18n/errors.rs
··· 1 + use fluent::{FluentError}; 2 + use fluent_syntax::parser::ParserError; 3 + use std::error::Error; 4 + use std::fmt; 5 + use unic_langid::LanguageIdentifier; 6 + 7 + #[derive(Debug)] 8 + pub enum I18nError { 9 + InvalidLanguage, 10 + LanguageResourceFailed(Vec<ParserError>), 11 + BundleLoadFailed(Vec<FluentError>), 12 + TemplateError(String), 13 + MissingTranslation { 14 + locale: LanguageIdentifier, 15 + message_id: String, 16 + }, 17 + } 18 + 19 + impl fmt::Display for I18nError { 20 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 21 + match self { 22 + I18nError::InvalidLanguage => write!(f, "Invalid language identifier"), 23 + I18nError::LanguageResourceFailed(errors) => { 24 + write!(f, "Failed to parse language resource: {:?}", errors) 25 + } 26 + I18nError::BundleLoadFailed(errors) => { 27 + write!(f, "Failed to load bundle: {:?}", errors) 28 + } 29 + I18nError::TemplateError(msg) => write!(f, "Template error: {}", msg), 30 + I18nError::MissingTranslation { locale, message_id } => { 31 + write!(f, "Missing translation for '{}' in locale '{}'", message_id, locale) 32 + } 33 + } 34 + } 35 + } 36 + 37 + impl Error for I18nError {}
+76
src/i18n/fluent_loader.rs
··· 1 + use fluent_templates::{static_loader, Loader}; 2 + use unic_langid::LanguageIdentifier; 3 + use std::{collections::HashMap, borrow::Cow}; 4 + 5 + // Use the static_loader! macro as recommended in the documentation 6 + static_loader! { 7 + pub static LOCALES = { 8 + locales: "./i18n", 9 + fallback_language: "en-us", 10 + // Removes unicode isolating marks around arguments for cleaner output 11 + customise: |bundle| bundle.set_use_isolating(false), 12 + }; 13 + } 14 + 15 + /// Helper function to get a translation with specific arguments 16 + pub fn get_translation(language: &LanguageIdentifier, message_id: &str, args: Option<HashMap<String, fluent_templates::fluent_bundle::FluentValue>>) -> String { 17 + match args { 18 + Some(arg_map) => { 19 + // Convert HashMap<String, FluentValue> to HashMap<Cow<str>, FluentValue> 20 + let fluent_args: HashMap<Cow<str>, fluent_templates::fluent_bundle::FluentValue> = arg_map 21 + .into_iter() 22 + .map(|(k, v)| (Cow::Owned(k), v)) 23 + .collect(); 24 + 25 + LOCALES.lookup_with_args(language, message_id, &fluent_args) 26 + }, 27 + None => LOCALES.lookup(language, message_id) 28 + } 29 + } 30 + 31 + /// A simplified function to get all supported languages 32 + pub fn get_supported_languages() -> Vec<LanguageIdentifier> { 33 + LOCALES 34 + .locales() 35 + .cloned() 36 + .collect() 37 + } 38 + 39 + /// Format an error message using fluent templates 40 + /// This function takes error keys and returns a localized error message 41 + pub fn format_error(language: &LanguageIdentifier, err_bare: &str, err_partial: &str) -> String { 42 + // Try to get the specific error message first (err_partial) 43 + let error_key = if err_partial.is_empty() { 44 + err_bare 45 + } else { 46 + err_partial 47 + }; 48 + 49 + // Get the translation, it will fallback to the key if not found 50 + get_translation(language, error_key, None) 51 + } 52 + 53 + #[cfg(test)] 54 + mod tests { 55 + use super::*; 56 + use unic_langid::langid; 57 + 58 + #[test] 59 + fn test_static_loader() { 60 + // Test with English locale 61 + let en_us = langid!("en-us"); 62 + 63 + // Simple lookup - just test that the function works 64 + let result = get_translation(&en_us, "error-unknown", None); 65 + assert!(!result.is_empty()); // Just verify we get some result 66 + } 67 + 68 + #[test] 69 + fn test_supported_languages() { 70 + let langs = get_supported_languages(); 71 + assert!(!langs.is_empty()); // Should have at least one language 72 + 73 + // Test that we can parse at least one valid language 74 + assert!(langs.iter().any(|lang| lang.to_string().starts_with("en"))); 75 + } 76 + }
+239
src/i18n/fluent_templates.rs
··· 1 + // Fluent Templates integration module 2 + use fluent_templates::{static_loader, Loader, LanguageIdentifier}; 3 + use std::collections::HashMap; 4 + use std::path::PathBuf; 5 + use std::sync::Arc; 6 + use std::borrow::Cow; 7 + use fluent::FluentValue; 8 + use tracing::{debug, info, warn}; 9 + 10 + use crate::i18n::errors::I18nError; 11 + use crate::i18n::gender::Gender; 12 + use crate::i18n::SUPPORTED_LANGUAGES; 13 + 14 + // Initialize the static loader with localization files 15 + static_loader! { 16 + pub static LOCALES = { 17 + locales: "./i18n", 18 + fallback_language: "en-us", 19 + // Remove unicode isolating marks for consistent output 20 + customise: |bundle| { 21 + bundle.set_use_isolating(false); 22 + } 23 + }; 24 + } 25 + 26 + // Type alias for the Fluent bundle 27 + pub type Bundle = fluent::bundle::FluentBundle<fluent::FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>; 28 + 29 + // Compatibility wrapper for fluent-templates 30 + pub struct Locales(pub HashMap<LanguageIdentifier, Bundle>); 31 + 32 + impl Locales { 33 + pub fn new(locales: Vec<LanguageIdentifier>) -> Self { 34 + debug!("Creating new Locales for {} languages", locales.len()); 35 + let mut store = HashMap::new(); 36 + for locale in &locales { 37 + // Initialize empty bundles that will be populated later 38 + let bundle: Bundle = fluent::bundle::FluentBundle::new_concurrent(vec![locale.clone()]); 39 + store.insert(locale.clone(), bundle); 40 + } 41 + Self(store) 42 + } 43 + 44 + pub fn add_bundle( 45 + &mut self, 46 + locale: LanguageIdentifier, 47 + content: String, 48 + ) -> Result<(), I18nError> { 49 + let bundle = self.0.get_mut(&locale).ok_or(I18nError::InvalidLanguage)?; 50 + 51 + let resource = fluent::FluentResource::try_new(content) 52 + .map_err(|(_, errors)| I18nError::LanguageResourceFailed(errors))?; 53 + 54 + bundle 55 + .add_resource(resource) 56 + .map_err(I18nError::BundleLoadFailed)?; 57 + 58 + debug!("Successfully added bundle content for {}", locale); 59 + Ok(()) 60 + } 61 + 62 + pub fn format_message( 63 + &self, 64 + locale: &LanguageIdentifier, 65 + message: &str, 66 + args: fluent::FluentArgs, 67 + ) -> String { 68 + // Use fluent-templates for translation first 69 + let fluent_args = convert_fluent_args(args); 70 + let result = if fluent_args.is_empty() { 71 + LOCALES.lookup(&locale, message) 72 + } else { 73 + LOCALES.lookup_with_args(&locale, message, &fluent_args) 74 + }; 75 + 76 + // If result is the same as the key, it means the translation was not found 77 + // in that case, fallback to the original implementation 78 + if result == message { 79 + // Fallback to existing bundle if available 80 + if let Some(bundle) = self.0.get(locale) { 81 + if let Some(bundle_message) = bundle.get_message(message) { 82 + if let Some(value) = bundle_message.value() { 83 + let mut errors = Vec::new(); 84 + let formatted = bundle.format_pattern(value, None, &mut errors); 85 + return formatted.to_string(); 86 + } 87 + } 88 + } 89 + } 90 + 91 + result 92 + } 93 + 94 + pub fn format_error(&self, locale: &LanguageIdentifier, bare: &str, partial: &str) -> String { 95 + // Use fluent-templates first 96 + let result = LOCALES.lookup(locale, bare); 97 + if result != bare { 98 + return result; 99 + } 100 + 101 + // Fallback to original implementation 102 + if let Some(bundle) = self.0.get(locale) { 103 + if let Some(bundle_message) = bundle.get_message(bare) { 104 + if let Some(value) = bundle_message.value() { 105 + let mut errors = Vec::new(); 106 + let formatted = bundle.format_pattern(value, None, &mut errors); 107 + return formatted.to_string(); 108 + } 109 + } 110 + } 111 + 112 + partial.to_string() 113 + } 114 + 115 + pub fn format_message_with_gender( 116 + &self, 117 + locale: &LanguageIdentifier, 118 + key: &str, 119 + gender: &Gender, 120 + args: Option<&fluent::FluentArgs> 121 + ) -> String { 122 + // Try gender-specific key first 123 + let gender_key = format!("{}{}", key, gender.as_suffix()); 124 + let fluent_args = args.map(convert_fluent_args); 125 + 126 + // Try looking up gender-specific key with fluent-templates 127 + let result = if let Some(args_map) = &fluent_args { 128 + LOCALES.lookup_with_args(locale, &gender_key, args_map) 129 + } else { 130 + LOCALES.lookup(locale, &gender_key) 131 + }; 132 + 133 + // If gender-specific key returned the key itself (not found), try base key 134 + if result == gender_key { 135 + if let Some(args_map) = &fluent_args { 136 + LOCALES.lookup_with_args(locale, key, args_map) 137 + } else { 138 + LOCALES.lookup(locale, key) 139 + } 140 + } else { 141 + result 142 + } 143 + } 144 + } 145 + 146 + // Convert FluentArgs to fluent-templates compatible format 147 + fn convert_fluent_args(args: fluent::FluentArgs) -> HashMap<Cow<'static, str>, FluentValue<'static>> { 148 + args.iter() 149 + .map(|(k, v)| { 150 + let owned_key = k.to_string(); 151 + let owned_value = match v { 152 + FluentValue::String(s) => FluentValue::String(Cow::Owned(s.to_string())), 153 + FluentValue::Number(n) => FluentValue::Number(n.clone()), 154 + _ => FluentValue::String(Cow::Owned(format!("{:?}", v))), 155 + }; 156 + (Cow::Owned(owned_key), owned_value) 157 + }) 158 + .collect() 159 + } 160 + 161 + // Embedded mode implementation 162 + #[cfg(feature = "embed")] 163 + pub mod embed { 164 + use rust_embed::Embed; 165 + use unic_langid::LanguageIdentifier; 166 + 167 + use crate::i18n::{errors::I18nError, Locales}; 168 + 169 + #[derive(Embed)] 170 + #[folder = "i18n/"] 171 + struct I18nAssets; 172 + 173 + pub fn populate_locale( 174 + supported_locales: &Vec<LanguageIdentifier>, 175 + locales: &mut Locales, 176 + ) -> Result<(), I18nError> { 177 + let locale_files = vec!["actions", "common", "errors", "forms", "ui"]; 178 + 179 + for locale in supported_locales { 180 + for file in &locale_files { 181 + let source_file = format!("{}/{}.ftl", locale.to_string().to_lowercase(), file); 182 + let i18n_asset = I18nAssets::get(&source_file).expect("locale file not found"); 183 + let content = std::str::from_utf8(i18n_asset.data.as_ref()) 184 + .expect("invalid utf-8 in locale file"); 185 + locales.add_bundle(locale.clone(), content.to_string())?; 186 + } 187 + } 188 + 189 + // fluent-templates will load files automatically via static_loader! 190 + Ok(()) 191 + } 192 + } 193 + 194 + // Reload mode implementation 195 + #[cfg(feature = "reload")] 196 + pub mod reload { 197 + use std::path::PathBuf; 198 + use unic_langid::LanguageIdentifier; 199 + use tracing::{info, debug}; 200 + 201 + use crate::i18n::{errors::I18nError, Locales}; 202 + 203 + pub fn populate_locale( 204 + supported_locales: &Vec<LanguageIdentifier>, 205 + locales: &mut Locales, 206 + ) -> Result<(), I18nError> { 207 + let locale_files = vec!["actions", "common", "errors", "forms", "ui"]; 208 + 209 + for locale in supported_locales { 210 + let locale_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 211 + .join("i18n") 212 + .join(locale.to_string().to_lowercase()); 213 + 214 + debug!("Loading locale directory: {:?}", locale_dir); 215 + 216 + for file in &locale_files { 217 + let source_file = locale_dir.join(format!("{}.ftl", file)); 218 + info!("Loading locale file: {:?}", source_file); 219 + 220 + if !source_file.exists() { 221 + debug!("Skipping non-existent file: {:?}", source_file); 222 + continue; 223 + } 224 + 225 + let i18n_asset = std::fs::read(&source_file) 226 + .map_err(|e| I18nError::DirectoryError(format!("{}: {}", source_file.display(), e)))?; 227 + 228 + let content = 229 + std::str::from_utf8(&i18n_asset) 230 + .map_err(|e| I18nError::DirectoryError(format!("Invalid UTF-8: {}: {}", source_file.display(), e)))?; 231 + 232 + locales.add_bundle(locale.clone(), content.to_string())?; 233 + } 234 + } 235 + 236 + // fluent-templates will load files automatically via static_loader! 237 + Ok(()) 238 + } 239 + }
+102
src/i18n/gender.rs
··· 1 + // Gender handling module for fluent-templates integration 2 + // This will contain functions for handling gender variants in translations, 3 + // especially for languages like French Canadian (fr-ca). 4 + 5 + use std::{borrow::Cow, collections::HashMap, str::FromStr}; 6 + use unic_langid::LanguageIdentifier; 7 + 8 + use crate::i18n::fluent_loader::LOCALES; 9 + use fluent_templates::Loader; 10 + use fluent_templates::fluent_bundle::types::FluentValue; 11 + 12 + #[derive(Clone, Copy, Debug)] 13 + pub enum Gender { 14 + Male, 15 + Female, 16 + Neutral, 17 + } 18 + 19 + impl Default for Gender { 20 + fn default() -> Self { 21 + Gender::Neutral 22 + } 23 + } 24 + 25 + impl FromStr for Gender { 26 + type Err = String; 27 + 28 + fn from_str(s: &str) -> Result<Self, Self::Err> { 29 + match s.to_lowercase().as_str() { 30 + "male" | "m" | "masculine" => Ok(Gender::Male), 31 + "female" | "f" | "feminine" => Ok(Gender::Female), 32 + "neutral" | "n" => Ok(Gender::Neutral), 33 + _ => Err(format!("Invalid gender: {}", s)), 34 + } 35 + } 36 + } 37 + 38 + impl From<&str> for Gender { 39 + fn from(s: &str) -> Self { 40 + match s.to_lowercase().as_str() { 41 + "male" | "m" | "masculine" => Gender::Male, 42 + "female" | "f" | "feminine" => Gender::Female, 43 + _ => Gender::Neutral, 44 + } 45 + } 46 + } 47 + 48 + // Function to get a gender-specific translation 49 + pub fn get_gendered_translation( 50 + language: &LanguageIdentifier, 51 + message_id: &str, 52 + gender: Gender, 53 + args: Option<HashMap<String, String>> 54 + ) -> String { 55 + // Convert the generic arguments 56 + let mut fluent_args: HashMap<Cow<'static, str>, FluentValue> = match args { 57 + Some(arg_map) => arg_map 58 + .iter() 59 + .map(|(k, v)| (Cow::Owned(k.to_string()), FluentValue::String(v.to_string().into()))) 60 + .collect(), 61 + None => HashMap::new(), 62 + }; 63 + 64 + // Add the gender argument 65 + let gender_value = match gender { 66 + Gender::Male => "male", 67 + Gender::Female => "female", 68 + Gender::Neutral => "neutral", 69 + }; 70 + 71 + // Create FluentValue for gender 72 + let gender_value = FluentValue::String(gender_value.into()); 73 + fluent_args.insert(Cow::Borrowed("gender"), gender_value); 74 + 75 + // Use the fluent-templates loader with the gender argument 76 + LOCALES.lookup_with_args(language, message_id, &fluent_args) 77 + } 78 + 79 + #[cfg(test)] 80 + mod tests { 81 + use super::*; 82 + use unic_langid::langid; 83 + 84 + #[test] 85 + fn test_gender_handling() { 86 + // This test will be expanded when gender functionality is fully implemented 87 + let fr_ca = langid!("fr-ca"); 88 + 89 + // This will only work if fr-ca locale exists and has gendered translations 90 + let supported_langs = crate::i18n::fluent_loader::get_supported_languages(); 91 + if supported_langs.contains(&fr_ca) { 92 + let mut args = HashMap::new(); 93 + args.insert("name".to_string(), "Alice".to_string()); 94 + 95 + // Test with female gender 96 + let result = get_gendered_translation(&fr_ca, "greeting", Gender::Female, Some(args.clone())); 97 + 98 + // For now, we're just ensuring it returns something and doesn't panic 99 + assert!(!result.is_empty()); 100 + } 101 + } 102 + }
+155
src/i18n/mod.rs
··· 1 + // Keep previous i18n functionality for compatibility 2 + 3 + // Public exports for the new fluent-templates based i18n system 4 + pub mod fluent_loader; 5 + pub mod gender; 6 + pub mod errors; 7 + pub mod template_helpers; 8 + 9 + // Re-export the static loader for direct access 10 + pub use fluent_loader::LOCALES; 11 + 12 + // Re-export utility functions 13 + pub use fluent_loader::{get_translation, get_supported_languages, format_error}; 14 + pub use template_helpers::register_i18n_functions; 15 + 16 + // Re-export errors from the new errors module 17 + pub use errors::I18nError; 18 + 19 + // Compatibility type aliasing to support legacy code 20 + use fluent_bundle::bundle::FluentBundle; 21 + use fluent::FluentResource; 22 + pub type Bundle = FluentBundle<FluentResource, intl_memoizer::concurrent::IntlLangMemoizer>; 23 + 24 + // Core constants and utility functions for unified API 25 + use std::str::FromStr; 26 + use unic_langid::LanguageIdentifier; 27 + 28 + /// Supported languages - matches directory structure in ./i18n/ 29 + pub const SUPPORTED_LANGUAGES: &[&str] = &["en-us", "fr-ca"]; 30 + 31 + /// Creates the list of supported language identifiers 32 + pub fn create_supported_languages() -> Vec<LanguageIdentifier> { 33 + SUPPORTED_LANGUAGES 34 + .iter() 35 + .map(|lang| LanguageIdentifier::from_str(lang).unwrap()) 36 + .collect() 37 + } 38 + 39 + /// Normalize a language identifier to lowercase format for consistent storage and lookup 40 + /// This ensures consistency between browser language detection and our internal storage 41 + pub fn normalize_language_identifier(locale: &LanguageIdentifier) -> LanguageIdentifier { 42 + // Convert to lowercase string format and parse back to LanguageIdentifier 43 + // This normalizes cases like "en-US" to "en-us" to match our file structure 44 + LanguageIdentifier::from_str(&locale.to_string().to_lowercase()).unwrap() 45 + } 46 + 47 + /// Check if a language identifier is supported (after normalization) 48 + pub fn is_supported_language(locale: &LanguageIdentifier) -> bool { 49 + let normalized = normalize_language_identifier(locale); 50 + SUPPORTED_LANGUAGES 51 + .iter() 52 + .any(|&supported| LanguageIdentifier::from_str(supported).unwrap() == normalized) 53 + } 54 + 55 + // Temporary compatibility struct that will eventually be phased out 56 + // This provides a bridge between the old system and the new fluent-templates system 57 + use std::collections::HashMap; 58 + use fluent::FluentArgs; 59 + 60 + pub struct Locales(pub HashMap<LanguageIdentifier, Bundle>); 61 + 62 + impl Locales { 63 + pub fn new(locales: Vec<LanguageIdentifier>) -> Self { 64 + let mut store = HashMap::new(); 65 + for locale in &locales { 66 + let bundle = FluentBundle::new_concurrent(vec![locale.clone()]); 67 + store.insert(locale.clone(), bundle); 68 + } 69 + Self(store) 70 + } 71 + 72 + pub fn add_bundle( 73 + &mut self, 74 + locale: LanguageIdentifier, 75 + content: String, 76 + ) -> Result<(), I18nError> { 77 + // In the new system, bundles are loaded statically at compile time 78 + // This is just a compatibility wrapper 79 + let bundle = self.0.get_mut(&locale).ok_or(I18nError::InvalidLanguage)?; 80 + 81 + let resource = FluentResource::try_new(content) 82 + .map_err(|(_, errors)| I18nError::LanguageResourceFailed(errors))?; 83 + 84 + bundle 85 + .add_resource(resource) 86 + .map_err(I18nError::BundleLoadFailed)?; 87 + 88 + Ok(()) 89 + } 90 + 91 + pub fn format_error(&self, locale: &LanguageIdentifier, bare: &str, partial: &str) -> String { 92 + // Use the new fluent-templates system internally 93 + let result = fluent_loader::get_translation(locale, bare, None); 94 + if result.is_empty() { 95 + partial.to_string() 96 + } else { 97 + result 98 + } 99 + } 100 + 101 + pub fn format_message( 102 + &self, 103 + locale: &LanguageIdentifier, 104 + message: &str, 105 + args: FluentArgs, 106 + ) -> String { 107 + // Convert FluentArgs to HashMap<String, FluentValue> for the new system 108 + let mut args_map: HashMap<String, fluent_templates::fluent_bundle::FluentValue> = HashMap::new(); 109 + 110 + for (k, v) in args.iter() { 111 + // Convert FluentValue to the fluent_templates FluentValue 112 + let value = match v { 113 + fluent::FluentValue::String(s) => fluent_templates::fluent_bundle::FluentValue::String(s.clone()), 114 + fluent::FluentValue::Number(n) => fluent_templates::fluent_bundle::FluentValue::Number(n.clone()), 115 + fluent::FluentValue::Custom(_) => fluent_templates::fluent_bundle::FluentValue::String(format!("{:?}", v).into()), 116 + fluent::FluentValue::None => fluent_templates::fluent_bundle::FluentValue::String("".into()), 117 + fluent::FluentValue::Error => fluent_templates::fluent_bundle::FluentValue::String("".into()), 118 + }; 119 + args_map.insert(k.to_string(), value); 120 + } 121 + 122 + fluent_loader::get_translation(locale, message, Some(args_map)) 123 + } 124 + } 125 + 126 + // Compatibility modules for feature flags 127 + #[cfg(feature = "embed")] 128 + pub mod embed { 129 + use unic_langid::LanguageIdentifier; 130 + use crate::i18n::{errors::I18nError, Locales}; 131 + 132 + // This function is now a no-op since fluent-templates loads resources statically 133 + pub fn populate_locale( 134 + _supported_locales: &Vec<LanguageIdentifier>, 135 + _locales: &mut Locales, 136 + ) -> Result<(), I18nError> { 137 + // All locales are already loaded by fluent-templates static_loader 138 + Ok(()) 139 + } 140 + } 141 + 142 + #[cfg(feature = "reload")] 143 + pub mod reload { 144 + use unic_langid::LanguageIdentifier; 145 + use crate::i18n::{errors::I18nError, Locales}; 146 + 147 + // This function is now a compatibility layer 148 + pub fn populate_locale( 149 + _supported_locales: &Vec<LanguageIdentifier>, 150 + _locales: &mut Locales, 151 + ) -> Result<(), I18nError> { 152 + // All locales are already loaded by fluent-templates static_loader 153 + Ok(()) 154 + } 155 + }
+318
src/i18n/template_helpers.rs
··· 1 + //! Template helper functions for MiniJinja integration with fluent-templates 2 + //! 3 + //! This module provides template functions that can be used directly in templates 4 + //! for internationalization support, including gender-aware translations and 5 + //! locale-specific formatting. 6 + 7 + use minijinja::{Environment, Error, ErrorKind, Value}; 8 + use minijinja::value::Kwargs; 9 + use unic_langid::LanguageIdentifier; 10 + use std::collections::HashMap; 11 + use std::borrow::Cow; 12 + use fluent_templates::Loader; 13 + use fluent::FluentValue; 14 + 15 + use crate::i18n::{gender::Gender, fluent_loader::{LOCALES, get_supported_languages}}; 16 + 17 + /// Register i18n template functions in a MiniJinja environment 18 + /// 19 + /// This function adds the following template functions: 20 + /// - `t(key, **kwargs)` - Basic translation 21 + /// - `tg(key, gender, **kwargs)` - Gender-aware translation 22 + /// - `tl(locale, key, **kwargs)` - Translation with explicit locale 23 + /// - `tlg(locale, key, gender, **kwargs)` - Gender-aware translation with explicit locale 24 + /// - `current_locale()` - Get current locale string 25 + /// - `has_locale(locale)` - Check if locale is supported 26 + /// - `plural(count, key, **kwargs)` - Pluralization support 27 + /// - `format_number(number, style?)` - Number formatting 28 + /// - `format_date(date, format?)` - Date formatting (placeholder) 29 + /// 30 + /// # Arguments 31 + /// 32 + /// * `env` - MiniJinja environment to register functions in 33 + /// 34 + /// # Example 35 + /// 36 + /// ```rust 37 + /// use minijinja::Environment; 38 + /// use smokesignal::i18n::template_helpers::register_i18n_functions; 39 + /// 40 + /// let mut env = Environment::new(); 41 + /// register_i18n_functions(&mut env); 42 + /// 43 + /// // Now templates can use: {{ t('welcome') }}, {{ tg('greeting', 'masculine') }} 44 + /// ``` 45 + pub fn register_i18n_functions(env: &mut Environment) { 46 + // Basic translation: t(key, **kwargs) 47 + env.add_function("t", move |key: String, kwargs: Kwargs| -> Result<String, Error> { 48 + let default_locale = "en-us".parse::<LanguageIdentifier>().unwrap(); 49 + let locale = extract_locale_from_kwargs(&kwargs, &default_locale)?; 50 + let fluent_args = kwargs_to_fluent_hashmap(kwargs)?; 51 + 52 + let result = if fluent_args.is_empty() { 53 + LOCALES.lookup(&locale, &key) 54 + } else { 55 + LOCALES.lookup_with_args(&locale, &key, &fluent_args) 56 + }; 57 + 58 + Ok(result) 59 + }); 60 + 61 + // Translation with explicit locale: tl(locale, key, **kwargs) 62 + env.add_function("tl", move |locale: String, key: String, kwargs: Kwargs| -> Result<String, Error> { 63 + let lang_id = locale.parse::<LanguageIdentifier>() 64 + .map_err(|_| Error::new(ErrorKind::InvalidOperation, format!("Invalid locale: {}", locale)))?; 65 + let fluent_args = kwargs_to_fluent_hashmap(kwargs)?; 66 + 67 + let result = if fluent_args.is_empty() { 68 + LOCALES.lookup(&lang_id, &key) 69 + } else { 70 + LOCALES.lookup_with_args(&lang_id, &key, &fluent_args) 71 + }; 72 + 73 + Ok(result) 74 + }); 75 + 76 + // Gender-aware translation: tg(key, gender, **kwargs) 77 + env.add_function("tg", move |key: String, gender: String, kwargs: Kwargs| -> Result<String, Error> { 78 + let default_locale = "en-us".parse::<LanguageIdentifier>().unwrap(); 79 + let locale = extract_locale_from_kwargs(&kwargs, &default_locale)?; 80 + let string_args = kwargs_to_hashmap(kwargs)?; 81 + let gender_enum = gender.parse::<Gender>() 82 + .map_err(|_| Error::new(ErrorKind::InvalidOperation, format!("Invalid gender: {}", gender)))?; 83 + 84 + let args_ref = if string_args.is_empty() { None } else { Some(&string_args) }; 85 + let result = lookup_with_gender(&locale, &key, &gender_enum, args_ref); 86 + 87 + Ok(result) 88 + }); 89 + 90 + // Gender-aware translation with explicit locale: tlg(locale, key, gender, **kwargs) 91 + env.add_function("tlg", move |locale: String, key: String, gender: String, kwargs: Kwargs| -> Result<String, Error> { 92 + let lang_id = locale.parse::<LanguageIdentifier>() 93 + .map_err(|_| Error::new(ErrorKind::InvalidOperation, format!("Invalid locale: {}", locale)))?; 94 + let string_args = kwargs_to_hashmap(kwargs)?; 95 + let gender_enum = gender.parse::<Gender>() 96 + .map_err(|_| Error::new(ErrorKind::InvalidOperation, format!("Invalid gender: {}", gender)))?; 97 + 98 + let args_ref = if string_args.is_empty() { None } else { Some(&string_args) }; 99 + let result = lookup_with_gender(&lang_id, &key, &gender_enum, args_ref); 100 + 101 + Ok(result) 102 + }); 103 + 104 + // Get current locale: current_locale() 105 + env.add_function("current_locale", move || -> String { 106 + "en-us".to_string() 107 + }); 108 + 109 + // Check if locale is available: has_locale(locale) 110 + env.add_function("has_locale", move |locale: String| -> Result<bool, Error> { 111 + let supported = get_supported_languages(); 112 + match locale.parse::<LanguageIdentifier>() { 113 + Ok(lang_id) => Ok(supported.contains(&lang_id)), 114 + Err(_) => Ok(false), 115 + } 116 + }); 117 + 118 + // Handle pluralization: plural(count, key, **kwargs) 119 + env.add_function("plural", move |count: i64, key: String, kwargs: Kwargs| -> Result<String, Error> { 120 + let default_locale = "en-us".parse::<LanguageIdentifier>().unwrap(); 121 + let locale = extract_locale_from_kwargs(&kwargs, &default_locale)?; 122 + let mut fluent_args = kwargs_to_fluent_hashmap(kwargs)?; 123 + fluent_args.insert(Cow::Borrowed("count"), FluentValue::Number(count.into())); 124 + 125 + let result = LOCALES.lookup_with_args(&locale, &key, &fluent_args); 126 + Ok(result) 127 + }); 128 + 129 + // Format number with locale: format_number(number, style?) 130 + env.add_function("format_number", move |number: Value, _style: Option<String>| -> Result<String, Error> { 131 + if number.is_number() { 132 + if let Some(i) = number.as_i64() { 133 + Ok(i.to_string()) 134 + } else { 135 + // Try to convert to f64 via string parsing 136 + let s = number.to_string(); 137 + if let Ok(f) = s.parse::<f64>() { 138 + Ok(format!("{:.2}", f)) 139 + } else { 140 + Ok(number.to_string()) 141 + } 142 + } 143 + } else { 144 + Err(Error::new(ErrorKind::InvalidOperation, "Expected number for formatting")) 145 + } 146 + }); 147 + 148 + // Format date/time (placeholder implementation) 149 + env.add_function("format_date", move |date: Value, format: Option<String>| -> Result<String, Error> { 150 + if let Some(s) = date.as_str() { 151 + Ok(format!("{} ({})", s, format.unwrap_or_else(|| "default".to_string()))) 152 + } else { 153 + Err(Error::new(ErrorKind::InvalidOperation, "Expected string for date formatting")) 154 + } 155 + }); 156 + } 157 + 158 + /// Extract locale from function kwargs, falling back to default 159 + fn extract_locale_from_kwargs(kwargs: &Kwargs, default: &LanguageIdentifier) -> Result<LanguageIdentifier, Error> { 160 + if let Ok(Some(locale_str)) = kwargs.get::<Option<String>>("locale") { 161 + locale_str.parse::<LanguageIdentifier>() 162 + .map_err(|_| Error::new(ErrorKind::InvalidOperation, format!("Invalid locale: {}", locale_str))) 163 + } else { 164 + Ok(default.clone()) 165 + } 166 + } 167 + 168 + /// Convert MiniJinja kwargs to HashMap<String, String> 169 + fn kwargs_to_hashmap(kwargs: Kwargs) -> Result<HashMap<String, String>, Error> { 170 + let mut result = HashMap::new(); 171 + 172 + for key in kwargs.args() { 173 + if key == "locale" { 174 + continue; // Skip locale parameter 175 + } 176 + if let Ok(value) = kwargs.get::<Value>(key) { 177 + result.insert(key.to_string(), value.to_string()); 178 + } 179 + } 180 + 181 + Ok(result) 182 + } 183 + 184 + /// Convert MiniJinja kwargs to fluent-templates compatible HashMap 185 + fn kwargs_to_fluent_hashmap(kwargs: Kwargs) -> Result<HashMap<Cow<'static, str>, FluentValue<'static>>, Error> { 186 + let mut result = HashMap::new(); 187 + 188 + for key in kwargs.args() { 189 + if key == "locale" { 190 + continue; // Skip locale parameter 191 + } 192 + if let Ok(value) = kwargs.get::<Value>(key) { 193 + let fluent_value = if let Some(s) = value.as_str() { 194 + FluentValue::String(Cow::Owned(s.to_string())) 195 + } else if let Some(i) = value.as_i64() { 196 + FluentValue::Number(i.into()) 197 + } else { 198 + // For floating point numbers, parse from string representation 199 + let s = value.to_string(); 200 + if let Ok(f) = s.parse::<f64>() { 201 + FluentValue::Number(f.into()) 202 + } else { 203 + FluentValue::String(Cow::Owned(value.to_string())) 204 + } 205 + }; 206 + 207 + result.insert(Cow::Owned(key.to_string()), fluent_value); 208 + } 209 + } 210 + 211 + Ok(result) 212 + } 213 + 214 + /// Perform gender-aware lookup using our gender module 215 + fn lookup_with_gender( 216 + locale: &LanguageIdentifier, 217 + key: &str, 218 + gender: &Gender, 219 + args: Option<&HashMap<String, String>> 220 + ) -> String { 221 + // Use our gender-aware lookup from the gender module 222 + crate::i18n::gender::get_gendered_translation(locale, key, *gender, args.cloned()) 223 + } 224 + 225 + #[cfg(test)] 226 + mod tests { 227 + use super::*; 228 + use minijinja::{Environment, context}; 229 + 230 + #[test] 231 + fn test_template_function_registration() { 232 + let mut env = Environment::new(); 233 + 234 + register_i18n_functions(&mut env); 235 + 236 + // Test that functions are registered by attempting to compile expressions that use them 237 + assert!(env.compile_expression("current_locale()").is_ok()); 238 + assert!(env.compile_expression("has_locale('en-us')").is_ok()); 239 + assert!(env.compile_expression("t('test')").is_ok()); 240 + assert!(env.compile_expression("tl('en-us', 'test')").is_ok()); 241 + } 242 + 243 + #[test] 244 + fn test_current_locale_function() { 245 + let mut env = Environment::new(); 246 + 247 + register_i18n_functions(&mut env); 248 + 249 + let tmpl = env.compile_expression("current_locale()").unwrap(); 250 + let result = tmpl.eval(context!()).unwrap(); 251 + assert_eq!(result.as_str().unwrap(), "en-us"); 252 + } 253 + 254 + #[test] 255 + fn test_has_locale_function() { 256 + let mut env = Environment::new(); 257 + 258 + register_i18n_functions(&mut env); 259 + 260 + let tmpl = env.compile_expression("has_locale('en-us')").unwrap(); 261 + let result = tmpl.eval(context!()).unwrap(); 262 + assert!(result.is_true()); 263 + 264 + let tmpl = env.compile_expression("has_locale('invalid-locale')").unwrap(); 265 + let result = tmpl.eval(context!()).unwrap(); 266 + assert!(!result.is_true()); 267 + } 268 + 269 + #[test] 270 + fn test_gender_aware_translation_function() { 271 + let mut env = Environment::new(); 272 + 273 + register_i18n_functions(&mut env); 274 + 275 + // Test gender-aware translation function registration 276 + assert!(env.compile_expression("tg('test', 'masculine')").is_ok()); 277 + assert!(env.compile_expression("tg('test', 'feminine')").is_ok()); 278 + assert!(env.compile_expression("tg('test', 'neutral')").is_ok()); 279 + assert!(env.compile_expression("tlg('en-us', 'test', 'masculine')").is_ok()); 280 + } 281 + 282 + #[test] 283 + fn test_translation_with_args() { 284 + let mut env = Environment::new(); 285 + 286 + register_i18n_functions(&mut env); 287 + 288 + // Test that translation functions can accept kwargs 289 + assert!(env.compile_expression("t('test', name='Alice')").is_ok()); 290 + assert!(env.compile_expression("tg('test', 'masculine', name='Bob')").is_ok()); 291 + } 292 + 293 + #[test] 294 + fn test_number_formatting() { 295 + let mut env = Environment::new(); 296 + 297 + register_i18n_functions(&mut env); 298 + 299 + let tmpl = env.compile_expression("format_number(1234)").unwrap(); 300 + let result = tmpl.eval(context!()).unwrap(); 301 + assert_eq!(result.as_str().unwrap(), "1234"); 302 + 303 + let tmpl = env.compile_expression("format_number(12.34)").unwrap(); 304 + let result = tmpl.eval(context!()).unwrap(); 305 + assert_eq!(result.as_str().unwrap(), "12.34"); 306 + } 307 + 308 + #[test] 309 + fn test_pluralization() { 310 + let mut env = Environment::new(); 311 + 312 + register_i18n_functions(&mut env); 313 + 314 + // Test that pluralization function compiles 315 + assert!(env.compile_expression("plural(1, 'items')").is_ok()); 316 + assert!(env.compile_expression("plural(5, 'items', item='books')").is_ok()); 317 + } 318 + }
+2
src/lib.rs
··· 7 7 pub mod errors; 8 8 pub mod http; 9 9 pub mod i18n; 10 + // Keeping old i18n module for compatibility during migration 11 + pub mod i18n_old; 10 12 pub mod jose; 11 13 pub mod jose_errors; 12 14 pub mod oauth;