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
153
Cargo.lock
generated
153
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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