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}