projectfluent / fluent-rs

Rust implementation of Project Fluent
https://projectfluent.org
Apache License 2.0
1.08k stars 97 forks source link

Another high-level API sketch #137

Open cmyr opened 5 years ago

cmyr commented 5 years ago

not really related to #94, but thinking along related lines.

Thinking about the sort of API I would like to expose to application developers, I have a few guiding principles:

With those goals in mind, I've been sketching out some possible ways to use a derive macro to encode the number and type of arguments alongside the message key.

trait FluentMessage {
    const KEY: &'static str;
    fn localize<R: Borrow<FluentResource>>(&self, ctx: &FluentBundle<R>) -> String;
}

#[derive(FluentMessage)]
struct MacosMenuAboutApp {
    app_name: String,
}

This generates approximately the following code:


impl MacosMenuAboutApp {
    pub fn new(app_name: impl Into<String>) -> Self {
        MacosMenuAboutApp { app_name }
    }
}

impl FluentMessage for MacosMenuAboutApp {
    const KEY: &'static str = "macos-menu-about-app";

    fn localize<R: Borrow<FluentResource>>(&self, ctx: &FluentBundle<R>) -> String {
        let args = fluent_args![
            "app_name" => self.app_name.as_str()
        ];
        let msg = ctx.get_message(Self::KEY).expect("missing localization key");
        let value = msg.value.as_ref().expect("missing value");
        let mut errs = Vec::new();
        let result = ctx.format_pattern(value, Some(&args), &mut errs);
        for err in errs {
            eprintln!("localization error {:?}", err);
        }
        result.to_string()
    }
}

Which when used would look something like:

fn some_function() {
    let bundle: &FluentBundle<_> = get_fluent_bundle();
    let title = MacosMenuAboutApp::new("MyApp").localize(bundle);
    assert_eq!(&title, "About MyApp");
}

Again, this is just a sketch, and I'm not yet very familiar with all of fluent's features; it's very possible that this won't work for some cases, but I thought I would share the idea.

The thing I would really like, here, is some way to verify messages at compile time. This might involve something like,

// main.rs

 verify_localization_with_bundles!("../resources/l20n{locale}/{res_id}");

which would compile down to something like:

lazy_static! {
    static ref FLUENT_VERIFIER: Arc<Mutex<FluentVerifier>> = {
        let mgr = ::fluent::ResourceManager::new("../resources/l20n{locale}/{res_id}");
        ::fluent::FluentVerifier(mgr, ..)
    };
}

fn _fluent_static_verify(msg: &str, args: Option<FluentArgs>) -> Result<(), VerificationError> {
    FLUENT_VERIFIER.lock().unwrap().verify(msg, args)
}

And then each time the derive runs for our trait, before we generate our type we would check that the provided message and args work for some set of specified locales.

This is definitely a half-baked idea, right now, and there are lots of details that would need to be shared, but wanted to at least get my initial thoughts out.

zbraniecki commented 5 years ago

I don't have a strong opinion on your proposed trait. It seems to me like you're building a per-bundle API for high level localization.

The way we approached Fluent so far is that FluentBundle is low level and intended to construct higher level APIs, so it's quite in line with the thinking.

If you look at fluent-fallback, you can see a scaffolding of a higher level API that lazily iterates over locales to find fallbacks and recover from error scenarios.

You example in the current "fluent" imperative approach (although it's still in flux and we're still iterating over it!) that we use in Firefox (mostly in JS) is:

fn some_function() {
    let l10n = Localization::new(["de-AT", "de", "en-US"], ["./resources/{locale}/main.ftl", "./resources/{locale}/ohter.ftl"]).unwrap();
    let title = l10n.formatValue("macos-menu-about-app").unwrap();

    assert_eq!(&title, "About MyApp");
}

The current fluent-fallback crate is very rough POC, so it's better to look at how it's done in JS, DOM and React.

Now, since you're talking about UI, a good example is fluent-dom which is a JS binding for DOM. There, we do:

<menuitem data-l10n-id="macos-menu-about-app"/>

and we have a class called DOMLocalization which extends Localization and has methods such as translateFragment and also a MutationObserver that you can set to observe live changes in DOM and retranslate when needed.

I'm not sure what API you're planning for the UI layout, but I assume that there'll be some declarative model based on XML, DOM or sth similar. If so, I'd recommend you going for node annotations (not very different from your KEY field). The benefits of that model is that writing code is much simpler:

let elem = document.getElementById("macOsMenu");
domL10n.setAttributes(elem, "macos-menu-about-app");

and it asynchronously calls localization before the next frame translating the element not just with value, but also attributes (that's the power of compound messages in Fluent!)

There are many more nice things about this model, one of them being that you can now switch languages on fly, because all it takes is a new Localization and a call to translateFragment that finds all nodes with data-l10n-id and translates them into a new locale. We found this model to be very easy to use for developers and vastly reducing the burden and error rates in the code.

Hope that helps!

As for proc macros, I'd like to separate it out to a new issue. I think it's worth exploring but the tricky part of course is locale selection. It's hard to verify at build time that resources in the user locale resolve, unless you resolve all available locales, but let's talk about it!

cmyr commented 5 years ago

Okay yea, that's very helpful.

My intention was not to be per-bundle, that was just the only primitive I was aware of. I think that something like fluent-fallback is close to the level of abstraction I would like.

I'm curious about the relationship between a bundle, a locale, and a full 'fallback chain'. Take ["de-AT", "de", "en-US"]. In this example, I would expect "de-AT" to be expecting to intentionally fallback to "de" in some scenarios, in the same say I would expect fr-CA to intentionally fallback to fr-FR. In both cases, though, falling back to "en-US" would be a last resort, and indicate a failure. Is my understanding correct?

In either case: should I expect my application developer to have to specify the fallback chain they're using for a given locale, or should that be baked in?

In any case, this is the level of abstraction I'm interested in addressing: having loaded the various bundles I will be addressing for a given locale, what API do I want to surface for resolving the actual strings that will be presented in the application?

Pike commented 5 years ago

I think that the developer should at least have the option to modify the defaults.

One thing to keep in mind while developing bindings is to provide good value on the one side, but on the other side to also not restrict the actual localization and packaging processes that projects would like to use.