hypersig_i18n/lib.rs
1//! Typed translations for hypersig, where a missing translation is a compile error.
2//!
3//! An application lists its locales in an enum and implements [`Locale`] for
4//! it. The text of each locale sits in one value of [`Locale::Text`], a
5//! struct with a field per locale, so a text without its translation into
6//! one of the locales fails to compile, and a locale added to the enum fails
7//! to compile until every text has it. The locale itself is a
8//! [`Segment`], so it is the first segment of a page's
9//! [`Route`](hypersig::Route), and a link to the same page in another
10//! language is a link with another locale.
11//!
12//! ```
13//! use hypersig::html::{a, p};
14//! use hypersig::{Page, Route, Segment, Signals};
15//! use hypersig_i18n::Locale;
16//! use serde::{Deserialize, Serialize};
17//!
18//! #[derive(Segment, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
19//! #[serde(rename_all = "lowercase")]
20//! enum Lang {
21//! En,
22//! Ja,
23//! }
24//!
25//! /// One value per language.
26//! struct ByLang<T> {
27//! en: T,
28//! ja: T,
29//! }
30//!
31//! impl Locale for Lang {
32//! const ALL: &'static [Self] = &[Self::En, Self::Ja];
33//! type Text<T> = ByLang<T>;
34//!
35//! fn pick<T>(self, text: ByLang<T>) -> T {
36//! match self {
37//! Self::En => text.en,
38//! Self::Ja => text.ja,
39//! }
40//! }
41//! }
42//!
43//! #[derive(Signals, Serialize, Deserialize)]
44//! struct Home {}
45//!
46//! hypersig::routes! {
47//! fn pages(Router<()>);
48//! const HOME: Route<Lang> = "/{lang}" => home;
49//! }
50//!
51//! async fn home((): (), lang: Lang) -> Page<Home> {
52//! let other = if lang == Lang::En { Lang::Ja } else { Lang::En };
53//! Page::new(lang.pick(ByLang { en: "Welcome", ja: "ようこそ" }), Home {})
54//! .lang(lang.tag())
55//! .child(p().text(lang.pick(ByLang { en: "Hello.", ja: "こんにちは。" })))
56//! .child(a().href(HOME.link_with(&other)).text(other.pick(ByLang { en: "English", ja: "日本語" })))
57//! }
58//!
59//! assert_eq!(HOME.url(Lang::Ja).as_str(), "/ja");
60//! # let _ = pages;
61//! ```
62//!
63//! A message that takes values is a function per locale, as in
64//! `ByLang<fn(u32) -> String>`, so its arguments are typed in every
65//! translation alike.
66//!
67//! With the `axum` feature (on by default), [`LocaleRouterExt`] sends a
68//! visitor who opens `/` to the page in the language their browser asks for.
69#![doc(
70 html_logo_url = "https://imaimai17468.github.io/hypersig/icon.svg",
71 html_favicon_url = "https://imaimai17468.github.io/hypersig/icon.svg"
72)]
73
74#[cfg(feature = "axum")]
75mod axum;
76
77#[cfg(feature = "axum")]
78pub use axum::LocaleRouterExt;
79
80use hypersig::{Origin, Page, Segment, Signals, Url};
81use serde::Serialize;
82
83/// The locales of an application: an enum with `#[derive(Segment)]` whose segments are BCP 47 tags.
84///
85/// The compiler checks that each text has every locale, through
86/// [`Locale::Text`], and that [`Locale::pick`] handles every locale, through
87/// its `match`. It does not check that [`Locale::ALL`] lists every variant;
88/// a variant left out of it is never chosen by [`negotiate`].
89pub trait Locale: Segment + Serialize + Copy + Eq + 'static {
90 /// Every locale the application serves, the default first.
91 const ALL: &'static [Self];
92
93 /// One value per locale, usually a struct with a field per locale.
94 type Text<T>;
95
96 /// The value of `text` for this locale.
97 fn pick<T>(self, text: Self::Text<T>) -> T;
98
99 /// The BCP 47 tag of this locale, the string serde writes for it.
100 ///
101 /// # Panics
102 ///
103 /// Panics if serde does not write the locale as a string, which an enum
104 /// with `#[derive(Segment)]` always does.
105 #[must_use]
106 fn tag(self) -> String {
107 match serde_json::to_value(self) {
108 Ok(serde_json::Value::String(tag)) => tag,
109 _ => panic!("a `Segment` enum serializes to a string"),
110 }
111 }
112}
113
114/// The locale of `L` that best matches an `Accept-Language` header value, or the default.
115///
116/// The ranges in `accept_language` are tried from the highest `q` to the
117/// lowest, and in the order written where two are equal. A range matches a
118/// locale whose tag is the same, ignoring case, or the same after the last
119/// `-subtag` of the range is removed one at a time, so `ja-JP` matches `ja`.
120/// A locale whose tag starts with the range and a `-` matches too, so `en`
121/// matches `en-US`. `*`, a header with no match, and an empty header give
122/// the first locale of [`Locale::ALL`].
123///
124/// ```
125/// # use hypersig::Segment;
126/// # use hypersig_i18n::{Locale, negotiate};
127/// # use serde::{Deserialize, Serialize};
128/// # #[derive(Segment, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
129/// # #[serde(rename_all = "lowercase")]
130/// # enum Lang { En, Ja }
131/// # struct ByLang<T> { en: T, ja: T }
132/// # impl Locale for Lang {
133/// # const ALL: &'static [Self] = &[Self::En, Self::Ja];
134/// # type Text<T> = ByLang<T>;
135/// # fn pick<T>(self, t: ByLang<T>) -> T { match self { Self::En => t.en, Self::Ja => t.ja } }
136/// # }
137/// assert_eq!(negotiate::<Lang>("ja-JP,ja;q=0.9,en;q=0.8"), Lang::Ja);
138/// assert_eq!(negotiate::<Lang>("fr, en;q=0.5"), Lang::En);
139/// assert_eq!(negotiate::<Lang>("fr"), Lang::En);
140/// ```
141///
142/// # Panics
143///
144/// Panics if [`Locale::ALL`] is empty.
145#[must_use]
146pub fn negotiate<L: Locale>(accept_language: &str) -> L {
147 let default = *L::ALL
148 .first()
149 .expect("`Locale::ALL` lists at least one locale");
150 let mut ranges: Vec<(&str, f32)> = accept_language
151 .split(',')
152 .filter_map(|item| {
153 let mut parts = item.split(';');
154 let range = parts.next()?.trim();
155 let q = parts
156 .find_map(|param| param.trim().strip_prefix("q="))
157 .map_or(Some(1.0), |q| q.trim().parse::<f32>().ok())?;
158 (!range.is_empty() && q > 0.0).then_some((range, q))
159 })
160 .collect();
161 // A stable sort keeps the written order between ranges of the same weight.
162 ranges.sort_by(|a, b| b.1.total_cmp(&a.1));
163 let tags: Vec<(L, String)> = L::ALL
164 .iter()
165 .map(|&locale| (locale, locale.tag()))
166 .collect();
167 for (range, _) in ranges {
168 if range == "*" {
169 return default;
170 }
171 let mut prefix = range;
172 loop {
173 if let Some((locale, _)) = tags
174 .iter()
175 .find(|(_, tag)| tag.eq_ignore_ascii_case(prefix))
176 {
177 return *locale;
178 }
179 match prefix.rfind('-') {
180 Some(end) => prefix = &prefix[..end],
181 None => break,
182 }
183 }
184 if let Some((locale, _)) = tags.iter().find(|(_, tag)| {
185 tag.len() > range.len()
186 && tag.as_bytes()[range.len()] == b'-'
187 && tag[..range.len()].eq_ignore_ascii_case(range)
188 }) {
189 return *locale;
190 }
191 }
192 default
193}
194
195/// Adds the alternate of each locale to a [`Page`].
196pub trait LocalePage: Sized {
197 /// Names the page's address in each locale of `L`, where `url` gives the address in one.
198 ///
199 /// It adds one [`Page::alternate`] per locale of [`Locale::ALL`], tagged
200 /// with [`Locale::tag`], and a [`Page::alternate_default`] at the first
201 /// locale, the default that [`negotiate`] falls back to.
202 ///
203 /// ```
204 /// # use hypersig::{Origin, Page, Route, Segment, Signals};
205 /// # use hypersig_i18n::{Locale, LocalePage};
206 /// # use serde::{Deserialize, Serialize};
207 /// # #[derive(Segment, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
208 /// # #[serde(rename_all = "lowercase")]
209 /// # enum Lang { En, Ja }
210 /// # struct ByLang<T> { en: T, ja: T }
211 /// # impl Locale for Lang {
212 /// # const ALL: &'static [Self] = &[Self::En, Self::Ja];
213 /// # type Text<T> = ByLang<T>;
214 /// # fn pick<T>(self, t: ByLang<T>) -> T { match self { Self::En => t.en, Self::Ja => t.ja } }
215 /// # }
216 /// # #[derive(Signals, Serialize, Deserialize)]
217 /// # struct Post {}
218 /// # async fn post((): (), _: (Lang, u32)) -> Option<Page<Post>> { None }
219 /// hypersig::routes! {
220 /// fn pages(Router<()>);
221 /// const POST: Route<(Lang, u32)> = "/{lang}/posts/{id}" => post;
222 /// }
223 ///
224 /// const SITE: Origin = Origin::new("https://example.com");
225 ///
226 /// let html = Page::new("Post", Post {})
227 /// .alternates(SITE, |lang: Lang| POST.url((lang, 3)))
228 /// .render();
229 /// assert!(html.contains(r#"<link rel="alternate" hreflang="en" href="https://example.com/en/posts/3">"#));
230 /// assert!(html.contains(r#"<link rel="alternate" hreflang="ja" href="https://example.com/ja/posts/3">"#));
231 /// assert!(html.contains(r#"<link rel="alternate" hreflang="x-default" href="https://example.com/en/posts/3">"#));
232 /// # let _ = pages;
233 /// ```
234 #[must_use]
235 fn alternates<L: Locale>(self, origin: Origin, url: impl Fn(L) -> Url) -> Self;
236}
237
238impl<S: Signals> LocalePage for Page<S> {
239 fn alternates<L: Locale>(self, origin: Origin, url: impl Fn(L) -> Url) -> Self {
240 let mut page = self;
241 for &locale in L::ALL {
242 page = page.alternate(origin, &url(locale), locale.tag());
243 }
244 match L::ALL.first() {
245 Some(&default) => page.alternate_default(origin, &url(default)),
246 None => page,
247 }
248 }
249}