refactor: move author-facing mail copy into a Fluent catalogue
Ports carrier's src/i18n.rs and its locales/en-US/main.ftl layout, same fluent-bundle and unic-langid versions, so the two catalogues stay portable between them. Only mail the daemon sends an author moves; operator CLI output stays inline, since the operator runs the binary and reads English. The error-reply reasons from dispatch and ApplyError are deliberately left behind: dispatch is pure by contract, so those become reason identifiers resolved at the mail-building edge in a separate change rather than having the pure layer reach for a bundle. Adds a test carrier lacks. 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 it a bad catalogue surfaces inside poll() on a live instance. Sentences are verbatim; the bodies are now hard-wrapped where they were single long lines, which reads better in a plain-text client.
This commit is contained in:
parent
f2d2d75063
commit
3279fd3a2d
6 changed files with 321 additions and 17 deletions
|
|
@ -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"
|
||||
|
|
|
|||
37
daemon/locales/en-US/main.ftl
Normal file
37
daemon/locales/en-US/main.ftl
Normal file
|
|
@ -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).
|
||||
110
daemon/src/i18n.rs
Normal file
110
daemon/src/i18n.rs
Normal file
|
|
@ -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<FluentResource> {
|
||||
static BUNDLE: OnceLock<FluentBundle<FluentResource>> = 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<FluentValue<'static>>) -> 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<String>())
|
||||
.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)),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue