Skip to main content

linguini_config/
model.rs

1use crate::error::{ConfigError, ConfigResult};
2
3#[derive(Debug, Clone, Eq, PartialEq)]
4pub struct LinguiniConfig {
5    pub project: ProjectConfig,
6    pub paths: PathsConfig,
7    pub targets: TargetsConfig,
8    pub web: WebConfig,
9}
10
11#[derive(Debug, Clone, Eq, PartialEq)]
12pub struct ProjectConfig {
13    pub name: String,
14    pub default_locale: String,
15    pub locales: Vec<String>,
16}
17
18#[derive(Debug, Clone, Eq, PartialEq)]
19pub struct PathsConfig {
20    pub schema: String,
21    pub locale: String,
22}
23
24#[derive(Debug, Clone, Eq, PartialEq, Default)]
25pub struct TargetsConfig {
26    pub ts: Option<TypeScriptTargetConfig>,
27}
28
29#[derive(Debug, Clone, Eq, PartialEq)]
30pub struct TypeScriptTargetConfig {
31    pub out: String,
32    pub module: String,
33    pub declaration: bool,
34    pub gitignore: bool,
35    pub tree_shaking: bool,
36    pub messages: Vec<String>,
37    pub framework: Option<String>,
38}
39
40#[derive(Debug, Clone, Eq, PartialEq)]
41pub struct WebConfig {
42    pub configured: bool,
43    pub strategy: Vec<String>,
44    pub cookie_name: String,
45    pub cookie_path: String,
46    pub cookie_domain: Option<String>,
47    pub cookie_max_age: u64,
48    pub cookie_same_site: String,
49    pub cookie_secure: bool,
50    pub cookie_http_only: bool,
51    pub local_storage_key: String,
52    pub global_variable_name: Option<String>,
53    pub prefix_default_locale: bool,
54    pub base_path: String,
55    pub trailing_slash: String,
56    pub redirect: bool,
57    pub origin: Option<String>,
58    pub exclude: Vec<String>,
59    pub localize_links: bool,
60}
61
62impl Default for WebConfig {
63    fn default() -> Self {
64        Self {
65            configured: false,
66            strategy: vec![
67                "url".to_owned(),
68                "cookie".to_owned(),
69                "localStorage".to_owned(),
70                "preferredLanguage".to_owned(),
71                "baseLocale".to_owned(),
72            ],
73            cookie_name: "LINGUINI_LOCALE".to_owned(),
74            cookie_path: "/".to_owned(),
75            cookie_domain: None,
76            cookie_max_age: 60 * 60 * 24 * 365,
77            cookie_same_site: "lax".to_owned(),
78            cookie_secure: false,
79            cookie_http_only: false,
80            local_storage_key: "LINGUINI_LOCALE".to_owned(),
81            global_variable_name: None,
82            prefix_default_locale: false,
83            base_path: String::new(),
84            trailing_slash: "ignore".to_owned(),
85            redirect: true,
86            origin: None,
87            exclude: Vec::new(),
88            localize_links: true,
89        }
90    }
91}
92
93impl LinguiniConfig {
94    pub fn validate(&self) -> ConfigResult<()> {
95        validate_locale_tag(&self.project.default_locale)?;
96
97        if !self
98            .project
99            .locales
100            .iter()
101            .any(|locale| locale == &self.project.default_locale)
102        {
103            return Err(ConfigError::MissingField("project.locales default_locale"));
104        }
105
106        for locale in &self.project.locales {
107            validate_locale_tag(locale)?;
108        }
109
110        if let Some(ts) = &self.targets.ts {
111            if ts.out.trim().is_empty() {
112                return Err(ConfigError::MissingField("targets.ts.out"));
113            }
114            if ts.module != "esm" {
115                return Err(ConfigError::InvalidString(ts.module.clone()));
116            }
117            if !ts.tree_shaking && !ts.messages.is_empty() {
118                return Err(ConfigError::InvalidString(
119                    "targets.ts.messages requires tree_shaking = true".to_owned(),
120                ));
121            }
122            if let Some(framework) = &ts.framework {
123                match framework.as_str() {
124                    "svelte" | "sveltekit" => {}
125                    value => return Err(ConfigError::InvalidString(value.to_owned())),
126                }
127            }
128        }
129
130        validate_web_strategy(&self.web.strategy)?;
131        match self.web.trailing_slash.as_str() {
132            "ignore" | "always" | "never" | "directory" => {}
133            value => return Err(ConfigError::InvalidString(value.to_owned())),
134        }
135        match self.web.cookie_same_site.as_str() {
136            "lax" | "strict" | "none" => {}
137            value => return Err(ConfigError::InvalidString(value.to_owned())),
138        }
139
140        Ok(())
141    }
142}
143
144fn validate_web_strategy(strategy: &[String]) -> ConfigResult<()> {
145    if strategy.is_empty() {
146        return Err(ConfigError::InvalidArray("web.strategy".to_owned()));
147    }
148    for item in strategy {
149        let is_builtin = matches!(
150            item.as_str(),
151            "url"
152                | "cookie"
153                | "localStorage"
154                | "header"
155                | "navigator"
156                | "preferredLanguage"
157                | "globalVariable"
158                | "baseLocale"
159        );
160        if !is_builtin && !item.starts_with("custom-") {
161            return Err(ConfigError::InvalidString(item.clone()));
162        }
163    }
164    Ok(())
165}
166
167pub fn validate_locale_tag(tag: &str) -> ConfigResult<()> {
168    let mut parts = tag.split('-');
169    let Some(language) = parts.next() else {
170        return Err(ConfigError::InvalidLocaleTag(tag.to_owned()));
171    };
172
173    if language.len() < 2
174        || language.len() > 3
175        || !language
176            .chars()
177            .all(|character| character.is_ascii_lowercase())
178    {
179        return Err(ConfigError::InvalidLocaleTag(tag.to_owned()));
180    }
181
182    for part in parts {
183        let valid = (part.len() == 2
184            && part.chars().all(|character| character.is_ascii_uppercase()))
185            || (part.len() == 4
186                && part.chars().enumerate().all(|(index, character)| {
187                    if index == 0 {
188                        character.is_ascii_uppercase()
189                    } else {
190                        character.is_ascii_lowercase()
191                    }
192                }));
193
194        if !valid {
195            return Err(ConfigError::InvalidLocaleTag(tag.to_owned()));
196        }
197    }
198
199    Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::validate_locale_tag;
205
206    #[test]
207    fn accepts_spec_locale_tags() {
208        for tag in ["ru", "en", "en-US", "pt-BR", "zh-Hant"] {
209            assert!(validate_locale_tag(tag).is_ok(), "{tag}");
210        }
211    }
212
213    #[test]
214    fn rejects_non_bcp47_like_locale_tags() {
215        for tag in ["r", "EN", "en-us", "zh-hant", "en-US-extra"] {
216            assert!(validate_locale_tag(tag).is_err(), "{tag}");
217        }
218    }
219}