bones_framework/
localization.rs1use std::{
4 borrow::Cow,
5 marker::PhantomData,
6 path::PathBuf,
7 str::FromStr,
8 sync::{Arc, OnceLock},
9};
10
11use crate::prelude::*;
12
13use fluent::{FluentArgs, FluentResource};
14use intl_memoizer::concurrent::IntlLangMemoizer;
15use unic_langid::LanguageIdentifier;
16
17pub use fluent;
18pub use fluent::fluent_args;
19pub use fluent_langneg;
20pub use intl_memoizer;
21pub use sys_locale;
22pub use unic_langid;
23
24pub type FluentBundle = fluent::bundle::FluentBundle<FluentResourceAsset, IntlLangMemoizer>;
26
27#[derive(HasSchema, Deref, DerefMut, Clone)]
29#[schema(opaque, no_default)]
30#[type_data(asset_loader(["ftl"], FluentResourceLoader))]
31pub struct FluentResourceAsset(pub Arc<FluentResource>);
32impl std::borrow::Borrow<FluentResource> for FluentResourceAsset {
33 fn borrow(&self) -> &FluentResource {
34 &self.0
35 }
36}
37
38#[derive(HasSchema, Deref, DerefMut, Clone)]
40#[schema(opaque, no_default)]
41#[type_data(asset_loader(["locale.yaml", "locale.yml"], FluentBundleLoader))]
42pub struct FluentBundleAsset(pub Arc<FluentBundle>);
43
44#[derive(HasSchema, Deref, DerefMut, Clone)]
46#[schema(opaque, no_default)]
47#[type_data(asset_loader(["localization.yaml", "localization.yml"], LocalizationLoader))]
48pub struct LocalizationAsset {
49 #[deref]
51 pub current_bundle: FluentBundleAsset,
52 pub bundles: Arc<[FluentBundleAsset]>,
54}
55
56impl LocalizationAsset {
57 pub fn get(&self, id: &str) -> Cow<'_, str> {
59 let b = &self.current_bundle.0;
60 let Some(message) = b.get_message(id) else {
61 return Cow::from("");
62 };
63 let Some(value) = message.value() else {
64 return Cow::from("");
65 };
66
67 b.format_pattern(value, None, &mut vec![])
71 }
72
73 pub fn get_with<'a>(&'a self, id: &'a str, args: &'a FluentArgs) -> Cow<'a, str> {
75 let b = &self.current_bundle.0;
76 let Some(message) = b.get_message(id) else {
77 return Cow::from("");
78 };
79 let Some(value) = message.value() else {
80 return Cow::from("");
81 };
82
83 b.format_pattern(value, Some(args), &mut vec![])
84 }
85}
86
87use dashmap::mapref::one::MappedRef;
88#[derive(Deref, DerefMut)]
93pub struct Localization<'a, T> {
94 #[deref]
95 asset: MappedRef<'a, Cid, LoadedAsset, LocalizationAsset>,
96 _phantom: PhantomData<T>,
97}
98
99#[derive(HasSchema, Default, Clone)]
102pub struct RootLocalizationFieldIdx(OnceLock<usize>);
103
104impl<T: HasSchema> SystemParam for Localization<'_, T> {
105 type State = (AssetServer, AtomicResource<RootLocalizationFieldIdx>);
106 type Param<'s> = Localization<'s, T>;
107
108 fn get_state(world: &World) -> Self::State {
109 (
110 (*world.resources.get::<AssetServer>().unwrap()).clone(),
111 world.resources.get_cell::<RootLocalizationFieldIdx>(),
112 )
113 }
114 fn borrow<'s>(
115 world: &'s World,
116 (asset_server, field_idx): &'s mut Self::State,
117 ) -> Self::Param<'s> {
118 const ERR: &str = "Could not find a `Handle<LocalizationAsset>` field on root asset, \
119 needed for `Localization` parameter to work";
120 let field_idx = field_idx.init_borrow(world);
121 let field_idx = field_idx.0.get_or_init(|| {
122 let mut idx = None;
123 for (i, field) in T::schema()
124 .kind
125 .as_struct()
126 .expect(ERR)
127 .fields
128 .iter()
129 .enumerate()
130 {
131 if let Some(handle_data) = field.schema.type_data.get::<SchemaAssetHandle>() {
132 if let Some(schema) = handle_data.inner_schema() {
133 if schema == LocalizationAsset::schema() {
134 idx = Some(i);
135 break;
136 }
137 }
138 }
139 }
140 idx.expect(ERR)
141 });
142
143 let root = asset_server.root::<T>();
144 let root = root.as_schema_ref();
145 let handle = root
146 .field(*field_idx)
147 .expect(ERR)
148 .cast::<Handle<LocalizationAsset>>();
149 let asset = asset_server.get(*handle);
150
151 Localization {
152 asset,
153 _phantom: PhantomData,
154 }
155 }
156}
157
158struct FluentResourceLoader;
159impl AssetLoader for FluentResourceLoader {
160 fn load(&self, _ctx: AssetLoadCtx, bytes: &[u8]) -> BoxedFuture<anyhow::Result<SchemaBox>> {
161 let bytes = bytes.to_vec();
162 Box::pin(async move {
163 let string = String::from_utf8(bytes).context("Error loading fluent resource file.")?;
164 let res = FluentResource::try_new(string).map_err(|(_, errors)| {
165 let errors = errors
166 .into_iter()
167 .map(|e| e.to_string())
168 .collect::<Vec<_>>()
169 .join("\n");
170
171 anyhow::format_err!("Error loading fluent resource file. \n{}", errors)
172 })?;
173
174 Ok(SchemaBox::new(FluentResourceAsset(Arc::new(res))))
175 })
176 }
177}
178
179struct FluentBundleLoader;
180impl AssetLoader for FluentBundleLoader {
181 fn load(&self, mut ctx: AssetLoadCtx, bytes: &[u8]) -> BoxedFuture<anyhow::Result<SchemaBox>> {
182 let bytes = bytes.to_vec();
183 Box::pin(async move {
184 let self_path = ctx.loc.path.clone();
185 #[derive(serde::Serialize, serde::Deserialize)]
186 struct BundleMeta {
187 pub locales: Vec<LanguageIdentifier>,
188 pub resources: Vec<PathBuf>,
189 }
190 let meta: BundleMeta =
191 serde_yaml::from_slice(&bytes).context("Could not parse locale YAML")?;
192
193 let mut bundle = FluentBundle::new_concurrent(meta.locales);
194
195 for resource_path in meta.resources {
196 let normalized = resource_path
197 .absolutize_from(self_path.parent().unwrap())
198 .unwrap();
199 let resource_handle = ctx.load_asset(&normalized)?.typed::<FluentResourceAsset>();
200 let resource = loop {
201 if let Some(resource) = ctx.asset_server.try_get(resource_handle) {
202 break resource.context("FluentBundle resource `{normalized:?}` handle could not be cast to `FluentResourceAsset`")?;
203 }
204 ctx.asset_server.load_progress.listen().await;
205 };
206 bundle.add_resource(resource.clone()).map_err(|e| {
207 anyhow::format_err!(
208 "Error(s) adding resource `{normalized:?}` to bundle `{self_path:?}`: {e:?}"
209 )
210 })?;
211 }
212
213 Ok(SchemaBox::new(FluentBundleAsset(Arc::new(bundle))))
214 })
215 }
216}
217
218struct LocalizationLoader;
219impl AssetLoader for LocalizationLoader {
220 fn load(&self, mut ctx: AssetLoadCtx, bytes: &[u8]) -> BoxedFuture<anyhow::Result<SchemaBox>> {
221 let bytes = bytes.to_vec();
222 Box::pin(async move {
223 let self_path = ctx.loc.path.clone();
224 #[derive(serde::Serialize, serde::Deserialize)]
225 struct LocalizationMeta {
226 pub locales: Vec<PathBuf>,
227 }
228 let meta: LocalizationMeta =
229 serde_yaml::from_slice(&bytes).context("Could not parse locale YAML")?;
230
231 let mut bundles: Vec<FluentBundleAsset> = Vec::new();
232
233 for bundle_path in meta.locales {
234 let normalized = bundle_path
235 .absolutize_from(self_path.parent().unwrap())
236 .unwrap();
237 let bundle_handle = ctx.load_asset(&normalized)?.typed::<FluentBundleAsset>();
238 let bundle = loop {
239 if let Some(bundle) = ctx.asset_server.try_get(bundle_handle) {
240 break bundle.context("Localization resource `{normalized:?}` loaded handle could not be cast to `FluentBundleAsset`")?;
241 }
242 ctx.asset_server.load_progress.listen().await;
243 };
244 bundles.push(bundle.clone());
245 }
246
247 let available_locales = bundles
248 .iter()
249 .flat_map(|x| x.locales.iter())
250 .cloned()
251 .collect::<Vec<_>>();
252
253 let en_us = LanguageIdentifier::from_str("en-US").unwrap();
254 let user_locale = sys_locale::get_locale()
255 .and_then(|x| x.parse::<LanguageIdentifier>().ok())
256 .unwrap_or(en_us.clone());
257
258 let selected_locale = fluent_langneg::negotiate_languages(
259 std::slice::from_ref(&user_locale),
260 &available_locales,
261 Some(&en_us),
262 fluent_langneg::NegotiationStrategy::Filtering,
263 )[0];
264
265 let selected_bundle = bundles
266 .iter()
267 .find(|bundle| bundle.locales.contains(selected_locale))
268 .ok_or_else(|| {
269 anyhow::format_err!("Could not find matching locale for {user_locale}")
270 })?;
271
272 Ok(SchemaBox::new(LocalizationAsset {
273 current_bundle: selected_bundle.clone(),
274 bundles: bundles.into_iter().collect(),
275 }))
276 })
277 }
278}