Skip to main content

eui_core/
strings.rs

1//! Round 1 (docs/round-1-contract.md §4): the BUILT-IN UI strings — the copy
2//! no author writes (search placeholder, month buttons, copy, close, …). The
3//! ids and English defaults are `catalog/strings.json` (embedded); a host
4//! overrides any id per surface; `$string.<id>` in a prop value resolves
5//! through the surface's table at bind time. Mirrors `src/strings.ts`.
6
7use std::sync::LazyLock;
8
9use indexmap::IndexMap;
10use serde_json::Value;
11
12use crate::generated::catalog as g;
13
14/// One string table: id → text (the defaults merged under the overrides).
15pub type StringTable = IndexMap<String, String>;
16
17#[derive(serde::Deserialize)]
18struct StringsFile {
19    strings: IndexMap<String, String>,
20}
21
22/// The English defaults, in source order.
23pub static DEFAULT_STRINGS: LazyLock<StringTable> =
24    LazyLock::new(|| serde_json::from_str::<StringsFile>(g::STRINGS_JSON).expect("strings.json").strings);
25
26/// `$string.choose` → `choose`; `None` for any other value
27/// (`^\$string\.([a-zA-Z][a-zA-Z0-9]*)$`).
28pub fn parse_string_ref(value: &str) -> Option<&str> {
29    let id = value.strip_prefix("$string.")?;
30    let mut chars = id.chars();
31    let first = chars.next()?;
32    (first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric())).then_some(id)
33}
34
35/// True for a well-formed reference to a KNOWN id.
36pub fn is_string_ref(value: &str) -> bool {
37    parse_string_ref(value).is_some_and(|id| DEFAULT_STRINGS.contains_key(id))
38}
39
40/// The host's overrides merged over the defaults.
41pub fn string_table(overrides: &IndexMap<String, String>) -> StringTable {
42    let mut out = DEFAULT_STRINGS.clone();
43    for (k, v) in overrides {
44        out.insert(k.clone(), v.clone());
45    }
46    out
47}
48
49/// `{name}` placeholders filled from `params`; unknown (or null) ones stay.
50pub fn format_string(template: &str, params: &serde_json::Map<String, Value>) -> String {
51    let mut out = String::with_capacity(template.len());
52    let mut rest = template;
53    while let Some(open) = rest.find('{') {
54        out.push_str(&rest[..open]);
55        let after = &rest[open + 1..];
56        let name_len = after.char_indices().find(|(_, c)| !c.is_ascii_alphanumeric()).map(|(i, _)| i).unwrap_or(after.len());
57        let name = &after[..name_len];
58        let well_formed = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && after[name_len..].starts_with('}');
59        if well_formed {
60            match params.get(name).filter(|v| !v.is_null()) {
61                Some(v) => out.push_str(&crate::json::to_js_string(v)),
62                None => out.push_str(&rest[open..open + 1 + name_len + 1]),
63            }
64            rest = &after[name_len + 1..];
65        } else {
66            out.push('{');
67            rest = after;
68        }
69    }
70    out.push_str(rest);
71    out
72}
73
74/// A value with a `$string.<id>` reference replaced by the table's text (an
75/// unknown id resolves to the id itself); any other value passes.
76pub fn resolve_string<'a>(value: &'a str, table: &'a StringTable) -> &'a str {
77    match parse_string_ref(value) {
78        Some(id) => table.get(id).map(String::as_str).unwrap_or(id),
79        None => value,
80    }
81}
82
83/// The default text of one id.
84pub fn default_string(id: &str) -> Option<&'static str> {
85    DEFAULT_STRINGS.get(id).map(String::as_str)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use serde_json::json;
92
93    #[test]
94    fn overrides_formatting_resolution() {
95        assert_eq!(DEFAULT_STRINGS.keys().map(String::as_str).collect::<Vec<_>>(), g::STRING_IDS);
96        assert_eq!(DEFAULT_STRINGS.values().map(String::as_str).collect::<Vec<_>>(), g::STRING_DEFAULTS);
97        let mut over = IndexMap::new();
98        over.insert("search".to_string(), "Suchen…".to_string());
99        assert_eq!(string_table(&over)["search"], "Suchen…");
100        let params = json!({"page": 2, "total": 5}).as_object().unwrap().clone();
101        assert_eq!(format_string(&DEFAULT_STRINGS["pageOf"], &params), "Page 2 of 5");
102        assert_eq!(format_string("{count} of {total}", json!({"count": 1}).as_object().unwrap()), "1 of {total}");
103        assert_eq!(format_string("{ x } {1a}", &params), "{ x } {1a}");
104        let mut de = IndexMap::new();
105        de.insert("cancel".to_string(), "Abbrechen".to_string());
106        assert_eq!(resolve_string("$string.cancel", &string_table(&de)), "Abbrechen");
107        assert_eq!(resolve_string("plain", &DEFAULT_STRINGS), "plain");
108        assert_eq!(resolve_string("$string.nope", &DEFAULT_STRINGS), "nope");
109        assert!(is_string_ref("$string.confirm") && !is_string_ref("$string.nope") && parse_string_ref("$string.1x").is_none());
110    }
111}