diff --git a/Cargo.lock b/Cargo.lock index ae1d061..9bdeb88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -268,6 +279,41 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror", +] + [[package]] name = "fumi-core" version = "0.1.0" @@ -362,6 +408,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "itoa" version = "1.0.18" @@ -483,6 +548,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -492,6 +563,12 @@ dependencies = [ "semver", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -577,10 +654,12 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" name = "smolboid" version = "0.1.0" dependencies = [ + "fluent-bundle", "fumi-core", "serde", "serde_json", "smolboid-core", + "unic-langid", ] [[package]] @@ -646,12 +725,70 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-ident" version = "1.0.26" @@ -718,6 +855,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" + [[package]] name = "zeroize" version = "1.9.0" @@ -738,6 +881,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "zerofrom", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml index 66b738e..034c026 100644 --- a/daemon/Cargo.toml +++ b/daemon/Cargo.toml @@ -10,3 +10,7 @@ smolboid-core = { path = "../core" } fumi-core = { path = "../../fumi/core" } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Author-facing mail copy lives in a Fluent catalogue; see src/i18n.rs. Same +# versions carrier pins, so the two catalogues stay portable between them. +fluent-bundle = "0.16.0" +unic-langid = "0.9.6" diff --git a/daemon/locales/en-US/main.ftl b/daemon/locales/en-US/main.ftl new file mode 100644 index 0000000..fb3b900 --- /dev/null +++ b/daemon/locales/en-US/main.ftl @@ -0,0 +1,37 @@ +## Loaded once by `src/i18n.rs`; every string the daemon mails to an author +## lives here, grouped by the mail it belongs to. Operator-facing CLI output is +## deliberately not here — see the module header. +## +## Keep the `-32` style placeables out: these bodies are read in plain-text +## mail clients and on phones, so they stay short lines and no markup. + +## Error replies — a mail the daemon could not apply + +reply-subject = twtxt: { $reason } +reply-body = + Your mail could not be applied. + + Reason: { $reason } + + Quoted mail: + { $quoted } + +## Retention — the one-time warning as a feed approaches its size cap + +cap-warning-subject = twtxt: feed at 90% of its size cap +cap-warning-body = + Your feed has reached 90% of the { $cap }-byte cap. + + In hard-limit mode (the default) a full feed rejects new records; delete + some with Twt-Delete, or choose FIFO pruning with Twt-Prune: fifo, which + drops the oldest records to make room. + +## Admission — the mail that hands over the accept token + +welcome-subject = twtxt: your feed is ready +welcome-body = + Your smolboid feed is live: { $url } + + Post by sending mail to this address with the twt as the body. Manage it + with Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields + (PLAN.md). diff --git a/daemon/src/i18n.rs b/daemon/src/i18n.rs new file mode 100644 index 0000000..8d74595 --- /dev/null +++ b/daemon/src/i18n.rs @@ -0,0 +1,110 @@ +//! A single Fluent bundle, loaded once from `locales/en-US/main.ftl` and baked +//! into the binary via `include_str!`. Every string the daemon mails to an +//! author is looked up through `t`/`t1`/`t_args` rather than written inline, so +//! copy review and translation both have one place to work from. There is only +//! one locale today; a future one would plug in here without touching a call +//! site. +//! +//! Operator-facing output — the CLI's `println!`s — stays inline: the operator +//! runs the binary and reads English, and routing it through a catalogue would +//! be cost with no reader. + +use std::sync::OnceLock; + +use fluent_bundle::concurrent::FluentBundle; +use fluent_bundle::{FluentArgs, FluentResource, FluentValue}; + +fn bundle() -> &'static FluentBundle { + static BUNDLE: OnceLock> = OnceLock::new(); + BUNDLE.get_or_init(|| { + let ftl = include_str!("../locales/en-US/main.ftl"); + let resource = + FluentResource::try_new(ftl.to_string()).expect("main.ftl must parse as Fluent"); + let locale = "en-US".parse().expect("\"en-US\" is a valid language tag"); + let mut bundle = FluentBundle::new_concurrent(vec![locale]); + // Bidi isolation marks are invisible but real characters; with a single + // left-to-right locale they only cost correctness — these strings go + // into mail bodies that get quoted, diffed and compared — for no + // visible benefit. + bundle.set_use_isolating(false); + bundle + .add_resource(resource) + .expect("main.ftl must not redefine a message id"); + bundle + }) +} + +/// Looks up a message with no variables. +pub fn t(id: &str) -> String { + t_args(id, None) +} + +/// Looks up a message with one variable — the common case, since most +/// interpolated strings here carry a single value. +pub fn t1(id: &str, key: &'static str, value: impl Into>) -> String { + let mut args = FluentArgs::new(); + args.set(key, value); + t_args(id, Some(&args)) +} + +/// Looks up a message against a caller-built argument set, for the rare string +/// that interpolates more than one value. +pub fn t_args(id: &str, args: Option<&FluentArgs>) -> String { + let bundle = bundle(); + // A missing id or a value-less term is a copy bug, not a runtime condition + // to hide: surface it in the string itself rather than silently falling + // back to English prose that isn't there to fall back to (there's only the + // one locale). + let Some(message) = bundle.get_message(id) else { + return format!("???{id}???"); + }; + let Some(pattern) = message.value() else { + return format!("???{id}???"); + }; + let mut errors = Vec::new(); + bundle + .format_pattern(pattern, args, &mut errors) + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `FluentResource::try_new` and `add_resource` both panic on a malformed + /// or duplicate-id catalogue, and nothing touches the bundle until the + /// daemon first mails someone — so without this the failure surfaces + /// inside `poll()` on a live instance rather than here. + #[test] + fn catalogue_parses_and_every_id_resolves() { + for id in IDS { + let rendered = t(id); + assert!( + !rendered.starts_with("???"), + "`{id}` is used in code but missing from main.ftl" + ); + } + } + + /// Every id the daemon looks up. A lookup added without a line here still + /// works, but loses its compile-adjacent guard — keep them in step. + const IDS: &[&str] = &[ + "reply-subject", + "reply-body", + "cap-warning-subject", + "cap-warning-body", + "welcome-subject", + "welcome-body", + ]; + + #[test] + fn a_missing_id_is_visible_rather_than_silent() { + assert_eq!(t("no-such-message-id"), "???no-such-message-id???"); + } + + #[test] + fn arguments_interpolate() { + let rendered = t1("cap-warning-body", "cap", 1024); + assert!(rendered.contains("1024"), "{rendered}"); + } +} diff --git a/daemon/src/mailloop.rs b/daemon/src/mailloop.rs index 693c9de..f72765d 100644 --- a/daemon/src/mailloop.rs +++ b/daemon/src/mailloop.rs @@ -357,13 +357,11 @@ impl Daemon { if state.feeds[index].warned && !was_warned { replies.push(( address.to_owned(), - "twtxt: feed at 90% of its size cap".into(), - format!( - "Your feed has reached 90% of the {}-byte cap.\n\n\ - In hard-limit mode (the default) a full feed rejects new records; \ - delete some with Twt-Delete, or choose FIFO pruning with \ - Twt-Prune: fifo, which drops the oldest records to make room.", - self.cfg.cap_bytes + crate::i18n::t("cap-warning-subject"), + crate::i18n::t1( + "cap-warning-body", + "cap", + self.cfg.cap_bytes.to_string(), ), )); } @@ -407,12 +405,13 @@ impl Daemon { let text = std::str::from_utf8(&incoming.body) .map(|t| t.chars().take(200).collect::()) .unwrap_or_default(); + let mut args = fluent_bundle::FluentArgs::new(); + args.set("reason", reason.to_owned()); + args.set("quoted", text); replies.push(( address.to_owned(), - format!("twtxt: {reason}"), - format!( - "Your mail could not be applied.\n\nReason: {reason}\n\nQuoted mail:\n{text}\n" - ), + crate::i18n::t1("reply-subject", "reason", reason.to_owned()), + crate::i18n::t_args("reply-body", Some(&args)), )); } diff --git a/daemon/src/main.rs b/daemon/src/main.rs index fb5089b..05bd9f2 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -8,6 +8,7 @@ mod config; mod dispatch; mod feedstore; mod http; +mod i18n; mod mailloop; mod state; @@ -191,18 +192,18 @@ fn admit(config: ConfigAndArg) -> Result<(), String> { // The welcome mail carries the Accept token (sec 5.8), which is what // moves the author's posts into the daemon's main tier; without it their // mail would stay in requests and never publish. + let welcome_subject = i18n::t("welcome-subject"); let sent = client::send( &store, &account, &client::SendDraft { address: &addr, - text: format!( - "Your smolboid feed is live: {}/twtxt/{}.txt\n\n\ - Post by sending mail to this address with the twt as the body. Manage it with\n\ - Twt-Nick, Twt-Delete, Twt-Repost and Twt-Prune frontmatter fields (PLAN.md).", - cfg.base_url, addr.user + text: i18n::t1( + "welcome-body", + "url", + format!("{}/twtxt/{}.txt", cfg.base_url, addr.user), ), - subject: Some("twtxt: your feed is ready"), + subject: Some(&welcome_subject), reply_to: None, headers: &[], anonymous: false,