Skip to main content

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}