i18n: dependency-free message runtime, catalog checks, plugin strings, native strings

- runtime/i18n.ts: ICU-lite messages (placeholders as text, Intl.PluralRules plurals with exact
  forms, selectordinal, select, number/date/time), fallback chains (pt-BR -> pt -> en, ar-x-fm ->
  ar), X and legacy code aliases, RTL by locale, lazy catalogs, live onChange. Apostrophes are
  literal except right before { } # or another apostrophe.
- runtime/i18nCheck.ts: rules for contributed catalogs (shape, keys, syntax, placeholder parity,
  select options, markup/control characters, lengths). An argument English only uses to choose a
  plural form may be dropped; missing plural forms are warnings; meta "allowEmpty" fragments.
- runtime/pluginI18n.ts: api.i18n bound per plugin (register/t/locale/dir/onChange/number/date/
  relative/list); the UI kit's own words follow the app's language. pageLang.ts reports the
  site's <html lang>. shell/messages.ts: shell-model words.
- waybetter-host i18n.rs: NativeStrings (validated plain text, caps, one-pass {name} fill, page
  payload by prefix), system_locales via sys-locale; TabState.lang.
- JSON Schemas for catalogs and translator notes; npm exports waybetter/i18n, /i18n/check,
  /i18n/plugin.
This commit is contained in:
CRTkafa 2026-09-29 09:53:48 +07:00
parent aab3d4d64a
commit 36d6a81079
18 changed files with 1461 additions and 27 deletions

View file

@ -0,0 +1,230 @@
//! Native side of wayBetter i18n.
//!
//! The shell owns language: it loads the catalogs, picks the locale (follow the site, follow the
//! system, or the owner's pick) and hands native code the few strings native UI needs (dialog
//! titles, notifications, the site runtime's own words) as [`NativeStrings`]. These are data only:
//! validated here (bounded, plain text, no markup or control characters), kept in app data so the
//! next launch has them before the shell loads, and delivered to site tabs as a JSON value.
//!
//! [`system_locales`] reads the OS preference for the first run and for "Follow system".
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// At most this many strings reach native code (the site runtime's, the kit's, bundled plugins', native UI's).
pub const MAX_STRINGS: usize = 4000;
/// Longest message accepted.
pub const MAX_VALUE: usize = 2000;
/// Longest key accepted.
pub const MAX_KEY: usize = 96;
/// Total bytes of keys and values.
pub const MAX_TOTAL: usize = 512 * 1024;
/// A BCP 47 language tag as the catalogs use it: `tr`, `pt-BR`, `zh-Hant`, `ar-x-fm`.
pub fn valid_tag(tag: &str) -> bool {
if tag.is_empty() || tag.len() > 35 {
return false;
}
let mut parts = tag.split('-');
let Some(language) = parts.next() else { return false };
if !(2..=3).contains(&language.len()) || !language.bytes().all(|b| b.is_ascii_alphabetic()) {
return false;
}
parts.all(|part| (1..=8).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_alphanumeric()))
}
/// Writing direction of a tag (by primary language).
pub fn is_rtl(tag: &str) -> bool {
let language = tag.split('-').next().unwrap_or("").to_ascii_lowercase();
matches!(language.as_str(), "ar" | "arc" | "ckb" | "dv" | "fa" | "he" | "iw" | "ks" | "ps" | "sd" | "syr" | "ug" | "ur" | "yi")
}
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= MAX_KEY
&& key.as_bytes()[0].is_ascii_lowercase()
&& key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// Plain text only: no markup, no control or bidi-override characters (newlines and tabs are fine).
pub fn valid_text(value: &str) -> bool {
if value.len() > MAX_VALUE {
return false;
}
let lower = value.to_ascii_lowercase();
if lower.contains("javascript:") || lower.contains("&lt;") || lower.contains("&#60;") || lower.contains("&#x3c;") {
return false;
}
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '<' {
if let Some(next) = chars.clone().find(|c| !c.is_whitespace()) {
if next.is_ascii_alphabetic() || matches!(next, '/' | '!' | '?') {
return false;
}
}
}
let code = ch as u32;
let control = (ch.is_control() && !matches!(ch, '\n' | '\t' | '\r')) || (0x202A..=0x202E).contains(&code) || (0x2066..=0x2069).contains(&code);
if control {
return false;
}
}
true
}
/// The strings native code and site tabs need, in the owner's language.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", default)]
pub struct NativeStrings {
/// Active locale (a catalog tag).
pub locale: String,
/// The owner's choice: `site` (follow the site's own language), `system`, or a locale tag.
pub preference: String,
pub messages: BTreeMap<String, String>,
}
impl NativeStrings {
/// Checks everything the shell sent; any bad entry refuses the whole set.
pub fn validated(self) -> Result<Self, String> {
if !valid_tag(&self.locale) {
return Err("VALIDATION: invalid locale".into());
}
if !(self.preference == "site" || self.preference == "system" || valid_tag(&self.preference)) {
return Err("VALIDATION: language preference must be \"site\", \"system\" or a locale".into());
}
if self.messages.len() > MAX_STRINGS {
return Err("VALIDATION: too many strings".into());
}
let mut total = 0;
for (key, value) in &self.messages {
if !valid_key(key) {
return Err(format!("VALIDATION: invalid string key {:?}", key.chars().take(40).collect::<String>()));
}
if !valid_text(value) {
return Err(format!("VALIDATION: string {key} is not plain text"));
}
total += key.len() + value.len();
}
if total > MAX_TOTAL {
return Err("VALIDATION: strings too large".into());
}
Ok(self)
}
/// The message for `key`, or `fallback` (English, compiled in) when there is none.
pub fn get<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
self.messages.get(key).map(String::as_str).filter(|value| !value.is_empty()).unwrap_or(fallback)
}
/// `get` with `{name}` placeholders filled in, in one pass: values are inserted as text and are
/// never scanned for placeholders themselves. Unknown `{…}` stays as written.
pub fn format(&self, key: &str, fallback: &str, params: &[(&str, &str)]) -> String {
let template = self.get(key, fallback);
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let tail = &rest[open..];
let value = tail.find('}').and_then(|close| params.iter().find(|(name, _)| *name == &tail[1..close]).map(|(_, value)| (close, value)));
match value {
Some((close, value)) => {
out.push_str(value);
rest = &tail[close + 1..];
}
None => {
out.push('{');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
/// The strings whose keys start with one of `prefixes`, as the JSON value a site tab receives.
pub fn page_payload(&self, prefixes: &[&str]) -> serde_json::Value {
let messages: serde_json::Map<String, serde_json::Value> = self
.messages
.iter()
.filter(|(key, _)| prefixes.iter().any(|prefix| key.starts_with(prefix)))
.map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
.collect();
serde_json::json!({ "locale": self.locale, "dir": if is_rtl(&self.locale) { "rtl" } else { "ltr" }, "messages": messages })
}
}
/// The OS language preferences, most preferred first (`tr-TR`, `en-US`), as valid tags.
pub fn system_locales() -> Vec<String> {
sys_locale::get_locales()
.map(|tag| tag.replace('_', "-"))
// POSIX locales may carry an encoding or modifier (`tr_TR.UTF-8@euro`).
.map(|tag| tag.split(['.', '@']).next().unwrap_or("").to_owned())
.filter(|tag| valid_tag(tag))
.take(8)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(messages: &[(&str, &str)]) -> NativeStrings {
NativeStrings {
locale: "tr".into(),
preference: "site".into(),
messages: messages.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(),
}
}
#[test]
fn tags_are_validated() {
for good in ["tr", "pt-BR", "zh-Hant", "ar-x-fm", "fil", "en-GB"] {
assert!(valid_tag(good), "{good}");
}
for bad in ["", "t", "english", "tr_TR", "tr-", "tr-<x>", "a-b", "zh-Hant-TW-far-too-long-for-a-language-tag"] {
assert!(!valid_tag(bad), "{bad}");
}
assert!(is_rtl("ar-x-fm") && is_rtl("he") && !is_rtl("tr"));
}
#[test]
fn strings_are_plain_text_only() {
assert!(strings(&[("native.alert.title", "“{keyword}” geçti"), ("x.toast.saved", "Kaydedildi\n")]).validated().is_ok());
for bad in ["<img src=x onerror=alert(1)>", "a < b / <script>", "&lt;b&gt;", "javascript:alert(1)", "a\u{202e}b", "bell\u{7}"] {
assert!(strings(&[("x.a", bad)]).validated().is_err(), "{bad:?}");
}
assert!(strings(&[("x.a", "Boyut < 8 MB")]).validated().is_ok());
assert!(strings(&[("X.bad", "x")]).validated().is_err());
assert!(strings(&[("x.a", &"a".repeat(MAX_VALUE + 1))]).validated().is_err());
let mut many = strings(&[]);
for i in 0..=MAX_STRINGS {
many.messages.insert(format!("x.k{i}"), "v".into());
}
assert!(many.validated().is_err());
let mut bad_pref = strings(&[]);
bad_pref.preference = "everything".into();
assert!(bad_pref.validated().is_err());
}
#[test]
fn lookups_fall_back_to_english_and_fill_placeholders() {
let s = strings(&[("native.alert.body", "{handle}: {text}"), ("native.empty", "")]);
assert_eq!(s.get("native.missing", "Fallback"), "Fallback");
assert_eq!(s.get("native.empty", "Fallback"), "Fallback");
assert_eq!(s.format("native.alert.body", "", &[("handle", "@a"), ("text", "hi {x}")]), "@a: hi {x}");
// A value is never expanded again, even when it spells another placeholder.
assert_eq!(s.format("native.alert.body", "", &[("handle", "{text}"), ("text", "hi")]), "{text}: hi");
assert_eq!(s.format("native.missing", "{a} {b} {", &[("a", "1")]), "1 {b} {");
let payload = s.page_payload(&["native.alert"]);
assert_eq!(payload["messages"].as_object().unwrap().len(), 1);
assert_eq!(payload["dir"], "ltr");
}
#[test]
fn system_locales_are_tags() {
for tag in system_locales() {
assert!(valid_tag(&tag), "{tag}");
}
}
}

View file

@ -14,10 +14,12 @@
//! - [`update`]: silent-update scheduling and the crash handshake.
//! - [`tabs`]: tab layout/state payloads, link routing and tab-chrome validation.
//! - [`layout`]: native placement bookkeeping (unchanged bounds skipped, window-resize follow).
//! - [`i18n`]: the strings native UI and site tabs get in the owner's language (validated data), OS locale.
//!
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
pub mod hotfix;
pub mod i18n;
pub mod layout;
pub mod plugins;
pub mod policy;

View file

@ -71,6 +71,9 @@ pub struct TabState {
/// A deck column's unread count (capped at [`MAX_UNREAD`]); only ever a number about this tab.
#[serde(skip_serializing_if = "Option::is_none")]
pub unread: Option<u16>,
/// The language the site's own UI is in (its `<html lang>`), a validated tag; site tabs only.
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
}
impl TabState {
@ -89,6 +92,7 @@ impl TabState {
focused,
loading: None,
unread: None,
lang: None,
}
}
}